diff --git a/.claude/.headroom_wrap_marker.json b/.claude/.headroom_wrap_marker.json deleted file mode 100644 index 0811ea36..00000000 --- a/.claude/.headroom_wrap_marker.json +++ /dev/null @@ -1 +0,0 @@ -{"pid": 53774, "start_src": "psutil", "start_time": 1790633543.21, "port": 8787, "key": "ANTHROPIC_BASE_URL", "previous": null} \ No newline at end of file diff --git a/.claude/.headroom_wrap_owners.json b/.claude/.headroom_wrap_owners.json deleted file mode 100644 index bef679b3..00000000 --- a/.claude/.headroom_wrap_owners.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "ANTHROPIC_BASE_URL": { - "original": null, - "holders": [ - { - "pid": 53774, - "start_src": "psutil", - "start_time": 1790633543.21, - "port": 8787, - "inherited": false - } - ] - }, - "ENABLE_TOOL_SEARCH": { - "original": null, - "holders": [ - { - "pid": 53774, - "start_src": "psutil", - "start_time": 1790633543.21, - "port": null, - "inherited": false - } - ] - } -} diff --git a/.env.example b/.env.example index e82cadb2..e821b61b 100644 --- a/.env.example +++ b/.env.example @@ -67,6 +67,15 @@ MQTT_ACS_PASSWORD= # MQTT_CERT_SAN_EXTRA=IP:192.168.0.10 MQTT_CERT_SAN_EXTRA= +# mTLS do broker: o backend apresenta um certificado de cliente (CN = usuário +# MQTT, emitido pelo mosquitto-init com a CA de desenvolvimento). O +# docker-compose.yml já define as duas variáveis para o container do servidor +# (/app/certs/backend.crt e backend.key); só precisam vir aqui para rodar o +# servidor fora do Compose. Vêm sempre juntas; fora de development com +# MQTT_USE_TLS=true são obrigatórias. A senha MQTT continua valendo. +# MQTT_CLIENT_CERT_PATH=infra/docker/mosquitto/runtime/certs/backend.crt +# MQTT_CLIENT_KEY_PATH=infra/docker/mosquitto/runtime/certs/backend.key + # SANs adicionais no certificado do RPC, que o Traefik apresenta em :443 # (RNF04). Mesma necessidade e mesmo formato do MQTT_CERT_SAN_EXTRA acima, mas # para a outra CA: a do RPC é SEPARADA da do broker — ver docs/README.md. @@ -161,6 +170,11 @@ CPF_HASH_PEPPER= # --------------------------------------------------------------------------- DEV_ACS_PASSWORD= +# Senha do admin de desenvolvimento do backoffice (matrícula ADM-001), também +# gravada por `acs-credential-seed`. A MFA do staff é obrigatória: o primeiro +# acesso passa pela tela de ativação do TOTP. Gere com `openssl rand -hex 32`. +DEV_ADMIN_PASSWORD= + # --------------------------------------------------------------------------- # Comportamento · consumido por: serverpod # --------------------------------------------------------------------------- @@ -173,6 +187,12 @@ APP_ENV=development # autenticação institucional. ENABLE_DEV_LOGIN=true +# MFA por TOTP (RFC 6238) no login institucional do ACS (RF07). Com true, o ACS +# sem MFA ativa não entra: precisa ativá-la antes (auth.beginTotpEnrollment). +# Em produção o padrão é `true` (variável ausente = ligada fora de development); +# este default é só da stack local. +REQUIRE_ACS_MFA=false + # Gateway de SMS do login passwordless. `log` (padrão em development) escreve o # código no log do processo em vez de enviar SMS. Em produção é obrigatório # apontar para um gateway real — nenhum provedor está escolhido ainda, então @@ -216,3 +236,26 @@ SMS_GATEWAY= # credencial do broker não pode viajar no app. A correção é credencial por # dispositivo / mTLS, registrada como lacuna conhecida. # --------------------------------------------------------------------------- + +# --- Avisos push (RF14) ---------------------------------------------------- +# Gorush é o relé para FCM/APNs e continua exigindo as credenciais dos +# provedores (arquivos fora do repositório). Vazio desliga o envio de avisos. +# Para ligar: `docker compose --profile push up` e GORUSH_URL=http://gorush:8088. +GORUSH_URL= +GORUSH_CREDENTIALS_DIR= +GORUSH_IOS_KEY_ID= +GORUSH_IOS_TEAM_ID= + +# --- Retenção e expurgo (LGPD-RF07) --------------------------------------- +# Opcionais: os defaults são a tabela de spec/lgpd_design.md §5.6 — alertas 2 +# anos (730), visitas e triagens 5 anos (1825). Só inteiro positivo de dias +# (teto 36500): um valor errado aqui apaga dados de forma irreversível. +# RETENTION_ALERT_DAYS=730 +# RETENTION_VISIT_DAYS=1825 +# RETENTION_TRIAGE_DAYS=1825 + +# --- Escalonamento de alertas vermelhos (issue #44) ----------------------- +# Opcional: segundos até um alerta vermelho sem reconhecimento ficar elegível +# a escalonamento. Padrão 300 (5 min); só inteiro de 30 a 3600, fora disso o +# servidor não sobe. +# RED_ALERT_ESCALATION_SECONDS=300 diff --git a/.github/SECURITY.md b/.github/SECURITY.md index ad2dd829..7f6f5acc 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -22,9 +22,10 @@ explique como os mantenedores podem reproduzir o problema sem retê-la. Esta política abrange vulnerabilidades nos componentes versionados do SinalACS: -- Aplicativos em `apps/acs` e `apps/patient`. +- Aplicativos em `apps/acs`, `apps/patient` e `apps/admin` (backoffice). - Backend em `backend` e seus clientes gerados. - Infraestrutura, arquivos Docker Compose e configurações sob `infra`. +- Scripts de desenvolvimento, validação e CI sob `scripts/`. - Workflows de CI e dependências declaradas no repositório. Relatos são bem-vindos inclusive para limitações já documentadas quando diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3787b7ba..b319a054 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,16 +6,25 @@ on: # de develop rodava CI — foi assim que 74d89c0 ficou sem um run sequer # (FINDING-2/FINDING-4 da avaliação de 2026-09-28). branches: [main, master, develop] + # Sem filtro nenhum (branches, branches-ignore, types, paths): uma PR fora + # do filtro nunca reporta os checks obrigatórios e espera para sempre. + # check_gatilhos em scripts/qa/ci_invariants.sh falha se aparecer um (issue #19). pull_request: # Para gerar o dado sem precisar abrir PR: `gh workflow run CI --ref `. workflow_dispatch: # Um run vivo por PR: push novo na mesma PR cancela o anterior, que já não # descreve a ponta dela — o android-e2e sozinho chega a 60 min (FINDING-7). -# Push e dispatch são agrupados pelo SHA, então nenhum commit de main/develop -# perde o próprio resultado para o commit seguinte. +# Push e dispatch são agrupados pelo ref E pelo SHA: o SHA impede que um commit +# de main/develop perca o resultado para o seguinte; o ref impede que main e +# develop, no mesmo SHA (fast-forward de uma para a outra), se cancelem +# (issue #23). github.ref, e não ref_name, para uma tag não colidir com uma +# branch de mesmo nome num dispatch. +# Este texto é comparado por igualdade com GRUPO_CONCORRENCIA em +# scripts/qa/ci_invariants.sh (issue #24): mudou aqui, muda lá e em GRUPO_NOVO +# de scripts/qa/ci_invariants_test.sh no mesmo commit. concurrency: - group: ci-${{ github.event_name }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.sha }} + group: ci-${{ github.event_name }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || format('{0}-{1}', github.ref, github.sha) }} cancel-in-progress: true # Todos os jobs fixam ubuntu-24.04 em vez de ubuntu-latest: o label migra @@ -37,6 +46,16 @@ jobs: run: | python3 -c 'import yaml' 2>/dev/null || { sudo apt-get update && sudo apt-get install -y python3-yaml; } ./scripts/qa/ci_invariants.sh + ./scripts/qa/ci_invariants_test.sh + - name: Testes dos scripts de CI + run: | + ./scripts/ci/decode_secret_file_test.sh + ./scripts/qa/ci_push_e2e_test.sh + ./scripts/qa/ci_invariants_fcm_test.sh + ./scripts/qa/ci_concurrency_act_test.sh + ./scripts/dev/sync_dev_ca_test.sh + ./scripts/dev/bootstrap_env_test.sh + ./scripts/dev/run_acs_captura_test.sh serverpod-backend: runs-on: ubuntu-24.04 defaults: @@ -177,6 +196,10 @@ jobs: with: flutter-version: '3.44.8' - run: flutter pub get + # Mesmo motivo do job patient-app: assets/certs/ é gitignored e vem do + # scripts/dev/sync_dev_ca.sh, que precisa da stack de pé. + - name: Cria o diretório assets/certs/ que o pubspec declara + run: mkdir -p assets/certs - run: flutter analyze - run: flutter test @@ -214,11 +237,6 @@ jobs: GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }} steps: - uses: actions/checkout@v7 - # Sem isto o emulador roda por emulação de CPU em software: medido, o log - # dizia "ProbeKVM: This user doesn't have permissions to use KVM", o boot - # levava ~12 min e cada `adb install` de 45 a 95 s. O runner tem - # /dev/kvm, só não para o usuário do job — a regra do udev abre o - # dispositivo. É o passo documentado pelo android-emulator-runner. - name: Habilita KVM run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ @@ -254,12 +272,15 @@ jobs: path: | ~/.android/avd/* ~/.android/adb* - key: avd-36-x86_64-pixel_7-ubuntu-24.04 + # target no nome: o push e2e precisa de Play Services (a imagem padrão da action é + # AOSP, sem ele); trocar o target sem trocar a chave restauraria o AVD antigo. + key: avd-36-google_apis-x86_64-pixel_7-ubuntu-24.04 - name: Gera o snapshot do AVD para o cache if: steps.avd-cache.outputs.cache-hit != 'true' uses: reactivecircus/android-emulator-runner@v2 with: api-level: 36 + target: google_apis arch: x86_64 profile: pixel_7 force-avd-creation: false @@ -278,10 +299,39 @@ jobs: (cd apps/patient && flutter pub get) & p2=$! (cd apps/acs && flutter pub get) & p3=$! wait $p1 && wait $p2 && wait $p3 + # Credenciais do FCM para o push e2e (Gorush e FCM reais). Cada secret é um arquivo em + # base64 (`base64 -w0 arquivo`). A chave da conta de serviço vai para um arquivo + # temporário (0600) e GOOGLE_APPLICATION_CREDENTIALS aponta para ele; o + # google-services.json vai para onde o build do paciente o procura (sem ele o APK + # compila e degrada para "sem push"). Sem os secrets (PR de fork) os passos só avisam + # e o job roda como sempre. Cada secret entra por `env:` do PASSO: nunca em `run:` + # (injeção de script) e nunca no `env:` do job (toda ação de terceiros o veria) — + # ci_invariants.sh vigia as duas coisas. Ficam LOGO ANTES do E2E (e depois do + # setup de Java/Flutter/caches/AVD): as credenciais só existem em disco quando quem as usa roda, + # e nenhuma ação de terceiros que não precisa delas as lê — o guarda também exige isso. + - name: Decodifica a credencial do FCM + env: + FCM_CREDENTIALS_BASE64: ${{ secrets.FCM_CREDENTIALS_BASE64 }} + run: | + ./scripts/ci/decode_secret_file.sh --env FCM_CREDENTIALS_BASE64 \ + --kind service_account --temp-export GOOGLE_APPLICATION_CREDENTIALS + - name: Decodifica o google-services.json + env: + GOOGLE_SERVICES_JSON_BASE64: ${{ secrets.GOOGLE_SERVICES_JSON_BASE64 }} + run: | + ./scripts/ci/decode_secret_file.sh --env GOOGLE_SERVICES_JSON_BASE64 \ + --kind google_services --package br.com.prismrr.sinalacs.patient \ + --to apps/patient/android/app/google-services.json + # Sem isto o emulador roda por emulação de CPU em software: medido, o log + # dizia "ProbeKVM: This user doesn't have permissions to use KVM", o boot + # levava ~12 min e cada `adb install` de 45 a 95 s. O runner tem + # /dev/kvm, só não para o usuário do job — a regra do udev abre o + # dispositivo. É o passo documentado pelo android-emulator-runner. - name: E2E no emulador Android uses: reactivecircus/android-emulator-runner@v2 with: api-level: 36 + target: google_apis arch: x86_64 profile: pixel_7 force-avd-creation: false @@ -296,6 +346,15 @@ jobs: # container. O pós-passo do flutter-action faz hashFiles('**/pubspec.lock') # sobre o workspace inteiro, não consegue ler pg_data/ e derruba o job # depois de o E2E ter passado — medido no run 36494654391. + # Apaga as credenciais mesmo se o E2E falhar. Num runner hospedado o disco é descartado + # ao fim do job, mas o passo mantém a regra verdadeira em qualquer runner (inclusive um + # autohospedado no futuro) e tira o google-services.json e a chave do Gorush do workspace. + - name: Remove as credenciais do FCM + if: always() + run: | + ./scripts/ci/decode_secret_file.sh --cleanup-temp GOOGLE_APPLICATION_CREDENTIALS + ./scripts/ci/decode_secret_file.sh --cleanup-file apps/patient/android/app/google-services.json + ./scripts/ci/decode_secret_file.sh --cleanup-file infra/docker/gorush/credentials/fcm-service-account.json - name: Remove pg_data/ do workspace if: always() run: sudo rm -rf pg_data @@ -336,4 +395,7 @@ jobs: 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 + # Sem a CA o APK sobe fail-closed (só raízes do sistema); aqui só interessa compilar. + - name: Cria o diretório assets/certs/ que o pubspec declara + run: mkdir -p assets/certs - run: flutter build apk --debug diff --git a/.gitignore b/.gitignore index bc66ae39..fd036aa2 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .DS_Store *.log *.tmp +.serena/ # Dart / Flutter .dart_tool/ @@ -38,4 +39,19 @@ graphify-out/ # e uma CA de desenvolvimento gerada por máquina não pode ser versionada. apps/acs/assets/certs/ apps/patient/assets/certs/ +apps/admin/assets/certs/ .worktrees/ + +# Estado local do proxy headroom (PID e timestamps), reescrito a cada sessão +.claude/.headroom_wrap_*.json +infra/docker/gorush/credentials/ + +# Google services +google-services.json +fcm-service-account.json + +# Fixtures e segredos da stack de e2e (gerados por scripts/qa/e2e_stack.sh seed) +.e2e/ + +__pycache__/ +*.pyc diff --git a/.serena/.gitignore b/.serena/.gitignore deleted file mode 100644 index 2e510aff..00000000 --- a/.serena/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -/cache -/project.local.yml diff --git a/.serena/project.yml b/.serena/project.yml deleted file mode 100644 index 595ed722..00000000 --- a/.serena/project.yml +++ /dev/null @@ -1,169 +0,0 @@ -# the name by which the project can be referenced within Serena/when chatting with the LLM. -project_name: "SinalACS" - -# list of language servers to start when using the LSP backend; choose from: -# ada al angular ansible bash -# bsl clojure cpp cpp_ccls crystal -# csharp csharp_omnisharp cue dart deno -# elixir elm erlang fortran fsharp -# gdscript gleam go groovy haskell -# haxe hlsl html java json -# julia kotlin latex lean4 lua -# luau markdown matlab msl nextflow -# nix ocaml pascal perl php -# php_phpactor php_phpantom powershell python python_basedpyright -# python_jedi python_pyrefly python_ty qml r -# rego ruby ruby_solargraph rust scala -# scss solidity svelte swift systemverilog -# terraform toml typescript typescript_vts vue -# wolfram yaml zig -# (This list may be outdated; generated with scripts/print_language_list.py; -# For the current list, see values of the LanguageServerId enum here: -# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py) -# For some languages, there are several alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) -# Note: -# - For C, use cpp -# - For JavaScript, use typescript -# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root) -# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm) -# - For Deno projects, use deno (serves the same .ts/.js files as typescript; requires the deno CLI on PATH) -# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three) -# - For Free Pascal/Lazarus, use pascal -# Special requirements: -# Some language servers require additional setup/installations. -# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers -# When using multiple language servers, the first language server that supports a given file will be used for that file. -# The first language server is the default language and the respective language server will be used as a fallback. -# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. -language_servers: -- dart - -# the encoding used by text files in the project -# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings -encoding: "utf-8" - -# optional shell command to run before the language backend (LSP or JetBrains) is initialised. -# the command runs in the project root directory and is only executed if the project is trusted -# (see trusted_project_path_patterns in the global configuration). -# serena waits for the command to exit: a non-zero exit code is logged as an error but does not -# abort activation. a per-project timeout (activation_command_timeout, default 180s) is the safety -# backstop for non-terminating commands; on expiry the process is killed and activation continues. -# example: activation_command: "npx nx run-many -t build" -activation_command: - -# maximum time in seconds to wait for activation_command to complete before killing it (default 180s). -# must be a positive number. -activation_command_timeout: 180.0 - -# line ending convention to use when writing source files. -# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default) -# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings. -line_ending: - -# The language backend to use for this project. -# If not set, the global setting from serena_config.yml is used. -# Valid values: LSP, JetBrains -# Note: the backend is fixed at startup. If a project with a different backend -# is activated post-init, an error will be returned. -language_backend: - -# whether to use project's .gitignore files to ignore files -ignore_all_files_in_gitignore: true - -# advanced configuration option allowing to configure language server-specific options. -# Maps the language key to the options. -# The settings are considered only if the project is trusted (see global configuration to define trusted projects). -# See https://oraios.github.io/serena/02-usage/050_configuration.html#language-server-specific-settings -ls_specific_settings: {} - -# list of workspace folder paths (LSP backend only). -# These folders will be used to build up Serena's symbol index. -# Paths must be within the project root and should thus be relative to the project root. -# Furthermore, the paths should not be filtered by ignore settings. -# Default setting: The entire project root folder (".") is considered. -# In (large) monorepos, this can be used to index only subfolders of the project root, e.g. -# ls_workspace_folders: -# - "./subproject1" -# - "./subproject2" -ls_workspace_folders: -- "." - -# list of additional workspace folder paths for cross-package reference support. -# Paths can be absolute or relative to the project root. -# Each folder is registered as an LSP workspace folder, enabling language servers to discover -# symbols and references across package boundaries, but these folders are not indexed by Serena, -# i.e. the respective symbols will not be found using Serena's symbol search tools. -# Example: -# additional_workspace_folders: -# - ../sibling-package -# - ../shared-lib -ls_additional_workspace_folders: [] - -# list of additional paths to ignore in this project. -# Same syntax as gitignore, so you can use * and **. -# Important: quote patterns that start with `*`, otherwise YAML treats them as aliases. -# Example: -# ignored_paths: -# - "examples/**" -# - ".worktrees/**" -# - "**/bin/**" -# - "**/obj/**" -# Note: global ignored_paths from serena_config.yml are also applied additively. -ignored_paths: [] - -# whether the project is in read-only mode -# If set to true, all editing tools will be disabled and attempts to use them will result in an error -# Added on 2025-04-18 -read_only: false - -# list of tool names to exclude. -# This extends the existing exclusions (e.g. from the global configuration) -# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html -excluded_tools: [] - -# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default). -# This extends the existing inclusions (e.g. from the global configuration). -# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html -included_optional_tools: [] - -# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. -# This cannot be combined with non-empty excluded_tools or included_optional_tools. -# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html -fixed_tools: [] - -# list of mode names that are to be activated by default, overriding the setting in the global configuration. -# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. -# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply. -# Otherwise, this overrides the setting from the global configuration (serena_config.yml). -# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply -# for this project. -# This setting can, in turn, be overridden by CLI parameters (--mode). -# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes -default_modes: - -# list of mode names to be activated additionally for this project, e.g. ["query-projects"] -# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. -# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes -added_modes: - -# initial prompt for the project. It will always be given to the LLM upon activating the project -# (contrary to the memories, which are loaded on demand). -initial_prompt: "" - -# time budget (seconds) per tool call for the retrieval of additional symbol information -# such as docstrings or parameter information. -# This overrides the corresponding setting in the global configuration; see the documentation there. -# If null or missing, use the setting from the global configuration. -symbol_info_budget: - -# list of regex patterns which, when matched, mark a memory entry as read‑only. -# Extends the list from the global configuration, merging the two lists. -read_only_memory_patterns: [] - -# list of regex patterns for memories to completely ignore. -# Matching memories will not appear in list_memories or activate_project output -# and cannot be accessed via read_memory or write_memory. -# To access ignored memory files, use the read_file tool on the raw file path. -# Extends the list from the global configuration, merging the two lists. -# Example: ["_archive/.*", "_episodes/.*"] -ignored_memory_patterns: [] diff --git a/AGENTS.md b/AGENTS.md index 4b860ec3..e8ba6e6f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -145,10 +145,10 @@ Importante: - os testes de integração e validação de conexão vivem fora do `flutter test` e utilizam a stack Docker/VM; - a validação contra a stack real está na skill `validacao-e2e` (`scripts/qa/e2e.sh`, `tool/live_check.dart`, `integration_test` no emulador); - o CI ([.github/workflows/ci.yml](.github/workflows/ci.yml)) roda em toda PR, em push para `main`/`develop` e à mão (`gh workflow run CI --ref `), e tem nove jobs: `workflow-lint`, `serverpod-backend`, `backend-docker-build`, `patient-app`, `acs-app`, `admin-app`, `coverage-report`, `android-e2e` (único que sobe emulador Android contra a stack) e `admin-android-build` (compila o APK do admin); -- o `workflow-lint` roda actionlint e `scripts/qa/ci_invariants.sh`, que falha se a lista de jobs divergir de `JOBS_DOCUMENTADOS`, se um job sair do runner fixado (`ubuntu-24.04`, nunca `ubuntu-latest`), se uma ação cair abaixo da major em node24 (`checkout@v7`, `setup-java@v6`, `cache@v6`, `upload-artifact@v7`), se o workflow ganhar filtro de `paths` ou perder o grupo de `concurrency` por PR/SHA, se o `android-e2e` perder a limpeza de `pg_data/` ou se a chave do cache de AVD não terminar em `-`; +- o `workflow-lint` roda actionlint e `scripts/qa/ci_invariants.sh`, que falha se a lista de jobs divergir de `JOBS_DOCUMENTADOS`, se um job sair do runner fixado (`ubuntu-24.04`, nunca `ubuntu-latest`), se uma ação cair abaixo da major em node24 (`checkout@v7`, `setup-java@v6`, `cache@v6`, `upload-artifact@v7`), se o workflow ganhar filtro de `paths` ou perder o grupo de `concurrency` por PR/ref+SHA, se o `pull_request` ganhar qualquer filtro (`branches`, `branches-ignore`, `types` — issue #19), se o `android-e2e` perder a limpeza de `pg_data/`, se a chave do cache de AVD não terminar em `-`, ou se um check obrigatório tiver `if:`, `continue-on-error: true`, `strategy.matrix` ou `needs:` apontando para fora do conjunto obrigatório (issue #20 — qualquer um desses faz o job reportar `skipped`/verde sem ter rodado, e a proteção de branch conta isso como aprovado). Logo depois roda `scripts/qa/ci_invariants_test.sh`, com fixture positiva e negativa para cada checagem nova, pra essas checagens não ficarem sem teste nenhum; - `main` e `develop` são protegidas: os 8 checks de `./scripts/qa/ci_invariants.sh --checks-obrigatorios` (todo job exceto `android-e2e`) precisam passar para mesclar, e `main` exige PR; admins ainda podem dar push direto. O `android-e2e` está verde desde as correções de 2026-09-28, mas segue informativo até acumular histórico; - o script não lê a proteção configurada no GitHub: ao renomear ou criar um job, reaplique-a (ver `CONTRIBUTING.md` › CI e merge), senão as PRs ficam esperando um check que não existe mais; -- migrar para o Ubuntu 26.04 (`ubuntu-latest` migra em 2026-10-19; um ensaio passou 9/9) é uma PR que troca juntos `runs-on`, `RUNNER` e o sufixo da chave do AVD, e precisa de um actionlint que conheça o rótulo `ubuntu-26.04`. Histórico em [docs/ci-audit/2026-09-28-avaliacao-ci-develop.md](docs/ci-audit/2026-09-28-avaliacao-ci-develop.md); lacunas conhecidas da guarda nas issues #19 a #24. +- migrar para o Ubuntu 26.04 (`ubuntu-latest` migra em 2026-10-19; um ensaio passou 9/9) é uma PR que troca juntos `runs-on`, `RUNNER` e o sufixo da chave do AVD, e precisa de um actionlint que conheça o rótulo `ubuntu-26.04`. Histórico em [docs/ci-audit/2026-09-28-avaliacao-ci-develop.md](docs/ci-audit/2026-09-28-avaliacao-ci-develop.md); lacuna conhecida da guarda na issue #21 (#19 corrigida — o `check_gatilhos` exige `pull_request` sem filtro nenhum, para uma PR contra branch protegida nunca ficar esperando checks que não rodam; #23 corrigida — o grupo de concorrência de push/dispatch leva `github.ref` e o SHA; #24 corrigida — o `check_concorrencia` compara o grupo por igualdade exata com `GRUPO_CONCORRENCIA` em vez de buscar substrings, então mudar o grupo é mudar no mesmo commit o `ci.yml`, essa constante e o `GRUPO_NOVO` de `scripts/qa/ci_invariants_test.sh`; o `scripts/qa/ci_concurrency_act.sh` prova com o `act` o que esse texto avalia, e o `scripts/qa/ci_concurrency_act_test.sh` cobre a sonda (#63); #20 corrigida — ver `check_checks_obrigatorios` e `scripts/qa/ci_invariants_test.sh`; #22 corrigida — `on:` como string/lista é normalizado em vez de quebrar, e uma flag não reconhecida sai com 2 e mensagem de uso em vez de sair calada com 0; #68 corrigida — `push`/`pull_request` que não é dict nem nulo gera uma só mensagem "tem tipo inesperado" em vez de traceback ou de `paths` acusado à toa). ## Observações finais diff --git a/CLAUDE.md b/CLAUDE.md index f58b0f6c..e5366968 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,15 +4,15 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project overview -SinalACS is a platform that prioritizes Primary Health Care visits in Brazil by turning structured clinical signals into a risk-ranked work queue for the Community Health Agent (ACS). Project documentation, code comments, and UI copy are in Portuguese — match that language for anything user-facing or spec-related. Current state: functional prototype, validated locally via Docker Compose; not production-ready. Already delivered: ACS institutional login (RF07, matrícula + senha/Argon2id, credentials kept locally — no integration with a real identity provider), patient passwordless login (RF01, CPF + birth date + OTP), RBAC per micro-area (RNF06), TLS on the RPC via Traefik (RNF04) and on the broker (password + ACL). Still missing: MFA and refresh token (deliberately deferred — see `PROGRESS.md`), mTLS on the broker (no client certificates), a backend for the admin backoffice (`apps/admin` still runs on `MockAdminDataSource`), and a production deploy (`backend/DEPLOY.md` is only a free-tier demo path). +SinalACS is a platform that prioritizes Primary Health Care visits in Brazil by turning structured clinical signals into a risk-ranked work queue for the Community Health Agent (ACS). Project documentation, code comments, and UI copy are in Portuguese — match that language for anything user-facing or spec-related. Current state: functional prototype, validated locally via Docker Compose; not production-ready. Already delivered: ACS institutional login (RF07, matrícula + senha/Argon2id, credentials kept locally — no integration with a real identity provider), patient passwordless login (RF01, CPF + birth date + OTP), RBAC per micro-area (RNF06), TLS on the RPC via Traefik (RNF04) and on the broker (password + ACL + client certificate on the local stack, `scripts/qa/mtls_invariants.sh`), TOTP MFA for the ACS login, RF08 micro-area persisted in the encrypted local database (72 h, per owner), the ACS refresh token (rotating, idle window 2 h, absolute cap 8 h from the password+TOTP login, family revoked on reuse/other device/inactive account; the app keeps no password) bound to the Keystore: an AES-256-GCM + RSA-OAEP envelope whose RSA key requires user authentication on every use, so the token is decrypted only through `BiometricPrompt` + `CryptoObject` on cold start (API 30+ also accepts the device PIN; on API 30+ a newly enrolled fingerprint does **not** invalidate the key; proved on the API 36 emulator only, `scripts/qa/acs_keystore_bound_e2e.sh`), plus the 30 s background lock, which stays an interface gate over the token held in RAM and "Sair e encerrar o turno", `FLAG_SECURE` on the ACS window in every build (only a debug build made with `./scripts/dev/run_acs.sh --build --captura` opens it, by a compile-time constant that release refuses), release builds that refuse the debug key unless explicitly allowed (a release keystore is still to be provisioned), the UBS call button, the per-owner offline visit queue (schema v7, legacy rows quarantined and uploaded with unknown authorship, deferred upload token, wipe only when everything is sent) and the closing of the `login()` race (`BackendClient._exclusive`), plus the backoffice account management (#43: listing, ACS create/link/activate/deactivate and password/MFA reset, staff MFA reset with the activation code emitted by the screen, and the coordinator's audit scoped to his UBS — every operation audited, refusals included). Still missing: invalidation of the token key by a newly enrolled fingerprint on API 30+ and a proof of the Keystore binding on API 24–29 (the SQLCipher key and the deferred upload token stay unbound by design), a refresh token for the patient (OTP stays 1 h), iOS, a physical-device proof on Android ≤ 8.1 (API 24–27) (the emulator on API 24 and 27 showed no theme breakage; see `PROGRESS.md`, section Tema em API 24–27), background (OS-level) upload of visits left on the device (WorkManager is outside `spec/stack.md`), a supervisor-forced device wipe (no supervisor login exists), unblocking of a queue whose 7-day upload token expired or was refused (it needs the owner to log in again and blocks the wipe) and LGPD purge of an owner who never returns (see `PROGRESS.md`, section Fila de visitas por dono e corrida do login), per-device client-certificate provisioning in production (mTLS is delivered only on the local stack), creating coordinator accounts and assigning their `ubsId` through the UI (a coordinator still exists only in the seeds and by SQL; #43 covers the ACS lifecycle and the MFA resets), the ACS's own password change (a lost password is reset by the coordination), the audit trail's append-only enforcement in the database (the chain detects tampering, it does not prevent it; no trigger/`REVOKE`), a real file download of the CSV export (it goes through the clipboard), the owner's acceptance of the green "Cadeia íntegra" exception to `spec/ui_design.md` and the data-protection officer's review of the export (`PROGRESS.md`, section #45), and a production deploy (`backend/DEPLOY.md` is only a free-tier demo path). The data-subject request handling (#42) is delivered; the validation of its anonymization rules with the data-protection officer is still open (`PROGRESS.md`). Two core flows: - **Paciente (patient)**: passwordless auth (OTP), urgency alert, structured triage, request status tracking, "Meus Dados" (LGPD). - **ACS**: dynamic prioritization, territorialization (micro-area), offline visit registration, micro-area follow-up. -A third app, `apps/admin`, is a read-only backoffice (Indicadores, Microáreas, Alertas, Auditoria) on mock data. +A third app, `apps/admin`, is the backoffice (Indicadores, Microáreas, Alertas, Auditoria, Pedidos do titular), reading real data from the backend since #40/#41, managing accounts since #43 (ACS create/link/activate/deactivate, password and MFA reset, staff MFA reset, audit scoped to the coordinator's UBS) and handling LGPD data-subject requests since #42 (a coordinator/admin starts review, completes or rejects them). Since #44 the Alertas and Indicadores screens refresh by a 30 s polling pulse and a coordinator/admin can escalate a red alert unacknowledged past `RED_ALERT_ESCALATION_SECONDS` (default 300 s; the alert stays `pending`, the ACS gets a reinforcement; the emulator e2e passed end to end on 2026-10-12). Since #45 the Auditoria screen filters and pages the trail, shows a per-row seal and a global chain verification computed by the server (with a tail checkpoint), and the admin (not the coordinator) can export a nine-column CSV with the export itself recorded in the same transaction; the emulator e2e, including a deliberate tampering detected on screen and by `audit_chain_check`, passed on 2026-10-13/14. Since #46 the backoffice has an Avisos tab (schedule, cancel and history of community notices; a coordinator only for his UBS) and, for the admin only, a Termos sub-tab (draft, legal review, publication of terms versions; publication is blocked until the legal review is recorded and requires an effective date at least 15 days ahead). Proved: backend unit/integration tests, widget tests, a hermetic emulator test of the tab, and a push e2e in which a scheduled notice reaches the emulator through Gorush and FCM; the legal review of the real terms text and the manual switch of the in-force version remain outside the code (`PROGRESS.md`, section #46). -Main RPC endpoints (`backend/sinalacs_server/lib/src/endpoints/`): `auth` (`loginInstitutional`, `requestOtp`, `verifyOtp`, `developmentLogin` — the last only with `ENABLE_DEV_LOGIN`), `onboarding`, `triage.evaluate`, `alerts` (`createRedAlert`, `acknowledge`, `statusFor`), `patients` (`listMicroArea`, `myData`, chronic conditions), `visits` (`sync`, `pull`), `health.check`. +Main RPC endpoints (`backend/sinalacs_server/lib/src/endpoints/`): `auth` (`loginInstitutional`, `requestOtp`, `verifyOtp`, `developmentLogin` — the last only with `ENABLE_DEV_LOGIN`), `onboarding`, `triage.evaluate`, `alerts` (`createRedAlert`, `acknowledge`, `statusFor`), `patients` (`listMicroArea`, `myData`, chronic conditions, `updateConsent`, `requestDataDeletion`, `requestDataCorrection`), `visits` (`sync` e `pull` com a sessão do ACS; `syncLegacy`, que usa a sessão só como transporte das visitas legadas sem dono; `syncDeferred` e `revokeUploadToken`, autenticados pelo token de envio diferido do dono, não pelo JWT), `admin` (`indicators`, `microAreas`, `alerts` e `auditLogs` — leitura do backoffice escopada por papel/UBS desde #40; desde a #43 a gestão de contas: `acs`/`staff`, `createAcs`, `setAcsMicroArea`, `setAcsActive`, `resetAcsPassword`, `resetAcsMfa` e `resetStaffMfa`; desde a #44 `escalateAlert`, `alertsPulse` e `endMonitoringSession`; desde a #46 `scheduleNotice`, `cancelScheduledNotice`, `noticeHistory`, `termsVersions`, `draftTermsVersion`, `markTermsReviewed` e `publishTermsVersion` — detalhe em `backend/CLAUDE.md`), `health.check`. `PROGRESS.md` holds milestone status and the open items with owners. `spec/validation_report.md` is stale in both directions (items it lists as open are closed, and its test counts are far below the real ones) — verify against the code before trusting it. @@ -23,7 +23,7 @@ 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 (16 domain tables plus Serverpod's own; the count changes with every migration — re-measure it in the `definition.sql` of the latest `backend/sinalacs_server/migrations/*/`, and see L-17 of `spec/validation_report.md` for the drift this line has already accumulated). +- [spec/lgpd_data_audit.md](spec/lgpd_data_audit.md) — field-by-field LGPD sensitivity classification for every persisted table (25 domain tables plus Serverpod's own; the count changes with every migration — re-measure it in the `definition.sql` of the latest `backend/sinalacs_server/migrations/*/`, and see L-17 of `spec/validation_report.md` for the drift this line has already accumulated). - [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. @@ -51,10 +51,10 @@ 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`, `AUDIT_CHAIN_SECRET` and `HEALTH_DATA_ENCRYPTION_KEY`, writes +`JWT_SECRET`, `AUDIT_CHAIN_SECRET`, `HEALTH_DATA_ENCRYPTION_KEY` and `DEV_ADMIN_PASSWORD` (the dev password of the backoffice admin `ADM-001`), 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` +`--force` (an existing `.env` that lacks `DEV_ADMIN_PASSWORD` fails every `docker compose` command, `down` included, until `./scripts/dev/bootstrap_env.sh` is run without `--force`, which appends only that variable and touches no other value), and warns when `pg_data/` predates a rotation: `POSTGRES_PASSWORD` only takes effect on the volume's first init (old password keeps being required, backend won't connect), and a rotated `HEALTH_DATA_ENCRYPTION_KEY` doesn't block boot but makes already-encrypted clinical columns unreadable — @@ -105,9 +105,10 @@ Product/architecture source of truth (PRD, UX flows, LGPD design, stack decision - 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 section in [apps/CLAUDE.md](apps/CLAUDE.md), `spec/ux_accessibility_assessment.md` and each app's `test/contrast_tokens_test.dart`. -- CI lives in [.github/workflows/ci.yml](.github/workflows/ci.yml) and runs on every PR, on pushes to `main`/`develop`, and by hand (`gh workflow run CI --ref `). 9 jobs: `workflow-lint`, `serverpod-backend`, `backend-docker-build`, `patient-app`, `acs-app`, `admin-app`, `coverage-report`, `android-e2e` (the only one that boots a real emulator against the stack), `admin-android-build`. `workflow-lint` runs actionlint plus `scripts/qa/ci_invariants.sh`, which fails when: this job list drifts from `JOBS_DOCUMENTADOS`; a job leaves the pinned runner (`RUNNER = 'ubuntu-24.04'`, never `ubuntu-latest`); an action drops below its node24 major (`checkout@v7`, `setup-java@v6`, `cache@v6`, `upload-artifact@v7`); the workflow gains a `paths` filter or loses the per-PR/per-SHA `concurrency` group; `android-e2e` loses its `pg_data/` cleanup step; or the AVD cache key does not end in `-`. -- `main` and `develop` are protected: the 8 checks from `./scripts/qa/ci_invariants.sh --checks-obrigatorios` (every job except `android-e2e`, tied to GitHub Actions app 15368) must pass to merge, and `main` also requires a PR; admins can still push directly. `android-e2e` has been green since the fixes of 2026-09-28 but stays informational until it builds a longer history. The script does not read the live protection: after renaming or adding a job, re-apply it (see `CONTRIBUTING.md` › CI e merge) or PRs wait forever for a check that no longer exists. Moving to Ubuntu 26.04 (`ubuntu-latest` migrates on 2026-10-19; a rehearsal ran 9/9 green) is a PR that changes `runs-on`, `RUNNER` and the AVD key suffix together, and needs an actionlint that knows the `ubuntu-26.04` label. History in `docs/ci-audit/2026-09-28-avaliacao-ci-develop.md`; known gaps in the guard are issues #19–#24. +- CI lives in [.github/workflows/ci.yml](.github/workflows/ci.yml) and runs on every PR, on pushes to `main`/`develop`, and by hand (`gh workflow run CI --ref `). 9 jobs: `workflow-lint`, `serverpod-backend`, `backend-docker-build`, `patient-app`, `acs-app`, `admin-app`, `coverage-report`, `android-e2e` (the only one that boots a real emulator against the stack), `admin-android-build`. `workflow-lint` runs actionlint plus `scripts/qa/ci_invariants.sh`, which fails when: this job list drifts from `JOBS_DOCUMENTADOS`; a job leaves the pinned runner (`RUNNER = 'ubuntu-24.04'`, never `ubuntu-latest`); an action drops below its node24 major (`checkout@v7`, `setup-java@v6`, `cache@v6`, `upload-artifact@v7`); the workflow gains a `paths` filter or loses the per-PR/per-ref+SHA `concurrency` group; `pull_request` gains any filter at all (`branches`, `branches-ignore`, `types` — issue #19); `android-e2e` loses its `pg_data/` cleanup step; the AVD cache key does not end in `-`; or a required check has `if:`, `continue-on-error: true`, `strategy.matrix`, or a `needs:` pointing outside the required set (issue #20 — each of those makes a required job report `skipped`/green without actually running, which branch protection counts as approval). `scripts/qa/ci_invariants_test.sh` runs right after it, with a positive and negative fixture per new case, so those checks don't drift silently either. `workflow-lint` also runs `scripts/ci/decode_secret_file_test.sh`, `scripts/qa/ci_push_e2e_test.sh`, `scripts/qa/ci_invariants_fcm_test.sh` and `scripts/qa/ci_concurrency_act_test.sh` (hermetic test of the `act` probe), and `ci_invariants.sh` additionally fails when the secrets `FCM_CREDENTIALS_BASE64`/`GOOGLE_SERVICES_JSON_BASE64` appear inside a `run:` or in the job-level `env:`, when a decode step comes after the E2E step, when the `if: always()` cleanup step is missing, when the emulator loses `target: google_apis` (Play Services; the action's default image is AOSP and FCM returns no token) or when the AVD cache key does not contain it. `android-e2e` decodes both secrets (service-account key to a temp file + `GOOGLE_APPLICATION_CREDENTIALS`, `google-services.json` into `apps/patient/android/app/`) and, when they exist, ends with `scripts/qa/ci_push_e2e.sh` (real Gorush and FCM); without them (fork PRs) it skips. +- `main` and `develop` are protected: the 8 checks from `./scripts/qa/ci_invariants.sh --checks-obrigatorios` (every job except `android-e2e`, tied to GitHub Actions app 15368) must pass to merge, and `main` also requires a PR; admins can still push directly. `android-e2e` has been green since the fixes of 2026-09-28 but stays informational until it builds a longer history. The script does not read the live protection: after renaming or adding a job, re-apply it (see `CONTRIBUTING.md` › CI e merge) or PRs wait forever for a check that no longer exists. Moving to Ubuntu 26.04 (`ubuntu-latest` migrates on 2026-10-19; a rehearsal ran 9/9 green) is a PR that changes `runs-on`, `RUNNER` and the AVD key suffix together, and needs an actionlint that knows the `ubuntu-26.04` label. History in `docs/ci-audit/2026-09-28-avaliacao-ci-develop.md`; known gap in the guard is issue #21 (#19 fixed — `check_gatilhos` requires `pull_request` with no filter at all, so a PR against a protected branch can never be left waiting for checks that never run; #23 fixed — the push/dispatch concurrency group carries `github.ref` and the SHA; #24 fixed — `check_concorrencia` compares the group for exact equality with `GRUPO_CONCORRENCIA` instead of searching substrings, so changing the group means changing `ci.yml`, that constant and `GRUPO_NOVO` in `scripts/qa/ci_invariants_test.sh` in the same commit, and `scripts/qa/ci_concurrency_act.sh` proves with `act` what that text evaluates to (#63); #20 fixed — see `check_checks_obrigatorios` and `scripts/qa/ci_invariants_test.sh`; #22 fixed — `on:` as a string/list is normalized instead of crashing, and an unrecognized flag exits 2 with a usage message instead of silently exiting 0; #68 fixed — a `push`/`pull_request` that is neither a dict nor null yields one "tem tipo inesperado" message instead of a traceback or a false `paths` finding). - Never commit real patient data, credentials, or the dev Docker Compose secrets into anything beyond local development. +- Never include AI attributions, "Co-Authored-By", or "Generated with Claude Code" labels in git commits or PR descriptions. ## graphify diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 60579c3a..810485b2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,13 +44,20 @@ com uma convenção genérica, a documentação do projeto prevalece. ## Pré-requisitos -- Flutter SDK compatível com Dart `>=3.8.0 <4.0.0` (`apps/patient`, `apps/acs`) ou `>=3.3.0 <4.0.0` (`apps/admin`); a CI usa Flutter 3.44.8. -- Dart SDK `>=3.8.0` para o backend. +- Flutter SDK com Dart 3.12 ou mais recente para os três aplicativos (`apps/patient`, `apps/acs`, `apps/admin`): eles declaram `^3.8.0`, mas dependem de `backend/sinalacs_client`, que exige `^3.12.0`; a CI usa Flutter 3.44.8. +- Dart SDK `^3.12.0` para o backend. - Docker Engine com Docker Compose v2. - Android SDK API 36 e JDK 17 para executar ou gerar os aplicativos Android. ## Ambiente local +Num clone limpo, gere antes a configuração local (detalhes em +[README.md](README.md#configuração)): + +```bash +./scripts/dev/bootstrap_env.sh +``` + Para iniciar PostgreSQL, Mosquitto, backend e Traefik, execute na raiz: ```bash @@ -117,6 +124,7 @@ Para executar um aplicativo em dispositivo ou emulador: ```bash ./scripts/dev/run_acs.sh # ACS: preenche os dart-defines a partir do .env cd apps/patient && flutter run +cd apps/admin && flutter run # exige código de ativação no primeiro login; ver apps/admin/README.md ``` O ACS precisa do script: a senha do broker é constante de compilação, sem valor @@ -215,4 +223,13 @@ formato específico para nomes de branches ou mensagens de commit. Os mantenedores definem prioridade, revisão e merge. Contribuições não devem assumir autenticação institucional, infraestrutura de produção ou integrações -clínicas que ainda não estejam implementadas. \ No newline at end of file +clínicas que ainda não estejam implementadas. + +Ao alterar documentação, confira os links relativos: + +```bash +bash scripts/qa/check_documentation_links.sh +``` + +O script lista cada `link local ausente` e sai com 1 se houver algum. Evite +gravar contagens de testes nos documentos: elas envelhecem a cada PR. diff --git a/PROGRESS.md b/PROGRESS.md index 8c47ad3f..fbdd3111 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -558,6 +558,179 @@ Atenção: o código atual já valida a operação crítica de alerta vermelho e --- +## Finalização do app ACS (2026-10-01) + +Plano: `docs/superpowers/plans/2026-10-01-finalizacao-app-acs.md`. Medido no `emulator-5554`. + +- **RF12 — permissão de localização.** O manifesto do ACS não declarava `ACCESS_FINE_LOCATION`/`ACCESS_COARSE_LOCATION` (nem `INTERNET`, que só vinha de plugin): sem elas o GPS do check-in nunca chegava, e os testes passavam porque injetam a posição. Declaradas e guardadas por `test/android_manifest_test.dart`. `./scripts/qa/acs_gps_e2e.sh [--sem-permissao]` prova a permissão em runtime e falha com o manifesto antigo. **Não prova a chegada de um fix de GPS**: neste AVD (Android 16, Play Services) nem `adb emu geo fix`, nem provider de teste, nem `forceLocationManager` entregam posição ao app; GPS real em aparelho físico segue sem prova em dispositivo. +- **RF13 — SAMU.** O botão abre o discador com 192 (`core/services/emergency_dialer.dart`, `url_launcher`, `` para `tel:`), sem ligar sozinho e sem `CALL_PHONE`; sem discador mostra o número em texto; trava de toque duplo. "Ligar para a UBS" também abre o discador, com o telefone de `ubs.contactPhone` entregue por `ubs.myContact` (RPC do ACS; a UBS vem do token); sem telefone cadastrado, avisa e não liga. O cadastro do número cabe ao backoffice (ainda mock): hoje vale a seed. +- **E2E do ACS no banco de teste.** `./scripts/qa/acs_full_e2e.sh`: login institucional real (senha errada e certa), seletor restrito à microárea, visita sincronizada e conferida no servidor. A senha do ACS chega pelo relé `otp_relay.py` (`/acs`, opt-in por `E2E_FIXTURES_FILE`), fora do `--dart-define` e do APK. **Não cobre MQTT/ACK**: o `aclfile` do broker só libera o UUID de microárea do seed de dev, e as fixtures são aleatórias; isso segue provado por `smoke`/`red_alert_cycle` na stack de dev. +- **Defeito do app achado pelo e2e:** `AcsHomeShell.dispose()` parava o feed MQTT com `onConnectionChanged` ainda ligado, e o `setState` caía em elemento defunct. Corrigido (`test/shell_dispose_test.dart`). +- **Minors da revisão — fechados (2026-10-01).** Plano: `docs/superpowers/plans/2026-10-01-minors-da-revisao-do-app-acs.md`. (1) A contagem do `validation_report.md` foi corrigida e passou a ter conferidor (`scripts/qa/contagem_validation_report.py`). (2) `deniedForever`: `acs_gps_e2e.sh --sem-permissao` leva a permissão a negada-de-vez recusando o diálogo do sistema numa rodada só (`pm set-permission-flags` não existe no Android 16; `flutter drive` desinstala o app ao terminar). (3)(4) O script de GPS aborta se o app já está instalado (`--reinstalar` autoriza; reinstalar apaga a fila SQLCipher e o Keystore de dev), mostra o erro do build e remove o app de teste ao sair. (5) RNF06 também conferido por `patients.listMicroArea`, e o seletor deve abrir sem filtro. (6) O relé serve `/acs` uma única vez por execução. (7) A checagem da porta 8765 enxerga qualquer endereço (`scripts/qa/lib_rele.sh`). Os novos testes de shell/Python (`lib_rele_test.sh`, `acs_gps_e2e_test.sh`, `contagem_validation_report_test.py`) **não** estão ligados à CI (exigiria mexer no workflow vigiado por `ci_invariants.sh`). +- **Revisão do app ACS e fechamento (2026-10-02).** Plano: `docs/superpowers/plans/2026-10-02-revisao-e-fechamento-do-app-acs.md`. (1) **Fonte ampliada (RNF05/WCAG 1.4.4):** o ACS estourava o layout a 130% e 200% de fonte; corrigidos o chip de conexão e o título do cabeçalho (altura agora cresce com a escala, `acsHeaderHeight`), o `_InfoRow`, o botão "Atualizar dados da microárea", o dropdown "Status do atendimento" da aba Visita (`isExpanded`) e a folha "Mais" (agora rola); guardado por `test/support/layout_harness.dart` e `test/text_scale_test.dart`, que percorrem as quatro abas e os cinco itens de "Mais". (2) **Manifesto:** `android:allowBackup="false"` (o padrão era `true`: o backup automático podia levar o banco local e as preferências para a nuvem) e nome legível "SinalACS ACS"; o APK de release foi medido (`aapt2`: INTERNET, localização em primeiro plano e ACCESS_NETWORK_STATE do `connectivity_plus`; sem `debuggable`, sem localização em segundo plano) e abriu no emulador sem `FATAL`. (3) **M2.4/RNF02:** 100 visitas offline provadas na fila (`test/offline_burst_test.dart`) e contra o servidor real no emulador: **118 ms** (meta < 5000), conferidas por `pull`. (4) **Tema:** `ThemeController` coberto (`test/theme_controller_test.dart`) e `spec/ui_design.md` corrigido (dizia tema fixo escuro). **Continua aberto:** assinatura de release com chave própria (ainda a de debug), `FLAG_SECURE` (decisão de produto/LGPD: a lista da microárea mostra nome e condições crônicas), RF08 persistido, refresh token, iOS, botão da UBS. **(fechado depois — ver a seção Pendências do ACS (2026-10-02): RF08, `FLAG_SECURE`, assinatura de release e botão da UBS estão fechados; refresh token e iOS seguem abertos.)** +- **Fora:** iOS (`apps/acs/ios/` não existe). (Refresh token estava aqui; entregue em 2026-10-03.) +- **RF08 — cache persistido e cifrado (72 h, por dono) (2026-10-02).** `MicroAreaDirectory` + `MicroAreaCacheStore` guardam a última lista da microárea na base SQLCipher do ACS (schema v6, tabelas `micro_area_cache`/`micro_area_cache_meta`), chaveada por `userId|microAreaId`, validade de 72 h, só no lugar de falha recuperável de rede; a tela avisa de quando é a lista. Decisão de LGPD em `spec/lgpd_design.md` §5.11, escrita antes do código. Provado no emulador (`full_journey_e2e.dart`): serve sem rede e o arquivo do banco não contém o nome do paciente. +- **MFA do ACS (2026-10-02).** MFA/TOTP do ACS entregue (RF07, LGPD-RT06): TOTP no `loginInstitutional`, ativação por matrícula+senha+código, replay barrado, exigida fora de `development` (`REQUIRE_ACS_MFA`); refresh token e redefinição por coordenador seguem abertos. **Com MFA ligada, o ACS precisa se autenticar de novo (matrícula + senha + código) mais ou menos a cada 15 minutos**, porque ainda não existe refresh token e o código TOTP é de uso único: `renewSession` recebe `MfaRequiredException`, esquece a credencial em memória e falha de forma não recuperável, e `AcsBackend.onSessionExpired` faz o painel EMPILHAR a tela de reautenticação por cima (uma só por vez). O painel não é desmontado: fila de alertas já recebidos por MQTT (o broker não reentrega o que foi confirmado com PUBACK), feed MQTT (autentica por senha+certificado, não pelo JWT), fila de visitas e formulários em andamento sobrevivem; login do MESMO usuário só dá `pop`, de OUTRO usuário descarta o painel (RNF06). Confirmar um alerta com a sessão vencida mostra a mensagem e o alerta segue sem confirmação. No app: o login ganha o campo `totp_field` (só dígitos, 6) quando o servidor responde `MfaRequiredException`; `MfaEnrollmentRequiredException` abre `MfaEnrollmentScreen` (QR + chave + código; o segredo não é gravado no aparelho). Provas: `test/mfa_login_test.dart` (5), `test/totp_support_test.dart`, casos de fonte ampliada em `test/text_scale_test.dart` e o último teste de `integration_test/full_journey_e2e.dart` (liga a MFA pelo RPC e entra pela tela com o código do passo atual, depois de esperar o passo avançar); `test/session_reauth_test.dart` (5: alerta e formulário preservados, ACK com mensagem, outro usuário, sem rota dupla). Ruling: a tela de ativação é provada por teste de widget, não no emulador (o e2e mantém `REQUIRE_ACS_MFA=false` para os outros testes). +- **mTLS do broker (2026-10-02).** Entregue **na stack local**: `require_certificate true`, certificado de cliente para o backend e para o ACS (asset de desenvolvimento `assets/certs/acs_client.*`, copiado por `sync_dev_ca.sh`, gitignorado), senha e ACL mantidos; `scripts/qa/mtls_invariants.sh` prova os quatro casos. **Aberto:** provisionamento por aparelho em produção (CSR no cadastro, chave no Keystore); o release com a chave de dev é barrado pelo Gradle (`-Psinalacs.allowDevClientKey=true` só para teste local). +- **Aberto fora do ACS:** `apps/patient` — manifesto `main` sem `INTERNET` explícito (hoje herdado de dependência); medir com `aapt2 dump permissions` no APK de release antes de mexer. + +## Pendências do ACS (2026-10-02) + +Plano: `docs/superpowers/plans/2026-10-02-pendencias-do-acs-flag-secure-ubs-rf08-mfa-assinatura-mtls.md`. Fecha as pendências deixadas pela revisão de 2026-10-02 (linha "Continua aberto" acima). Um commit de fechamento por tarefa; provas medidas no `emulator-5554`. + +- **T1 — `FLAG_SECURE` (`0930522`).** Janela inteira do ACS protegida. Prova: `scripts/qa/acs_secure_window.sh` (`dumpsys window`) e teste de fonte. Aberto: capturas de tela do ACS (`docs/telas-acs.md`) agora saem pretas e não há procedimento para regenerá-las. +- **T2 — assinatura de release (`e37bf5d`).** `assembleRelease` falha sem chave própria (`key.properties` ou `SINALACS_KEYSTORE_*`); debug só com `-Psinalacs.allowDebugSigning=true`. Prova: `scripts/qa/acs_release_signing.sh`. Aberto: **o keystore de release real e o segredo dele na CI não existem**. +- **T3 — botão da UBS (`b28f9be`, `7160e4c`).** `ubs.contactPhone` (nullable) entregue por `ubs.myContact`; sem telefone o botão avisa e não liga; a consulta da UBS não trava o botão do SAMU. Prova: testes do app e do servidor, `full_journey_e2e`. Aberto: o cadastro do telefone pelo backoffice (mock); vale a seed. +- **T4 — RF08 persistido (`43c58fa`).** Cache da microárea na base SQLCipher (schema v6), por dono, 72 h, só em falha recuperável de rede; decisão de LGPD em `spec/lgpd_design.md` §5.11. Prova: testes e `full_journey_e2e` (serve sem rede; o arquivo do banco não contém o nome). Aberto: ver Minors adiados. +- **T5 — MFA TOTP, backend (`d6718dd`, `dc517f7`).** RFC 6238, segredo cifrado AES-256-GCM, replay e corrida de ativação barrados (5 logins simultâneos, 1 sessão), `REQUIRE_ACS_MFA` exigida fora de `development`. Prova: suíte do backend. Aberto: sem lockout progressivo além do limite de 5 tentativas/15 min. +- **T6 — MFA no app (`abb8438`, `69c19d4`).** Campo do código no login, tela de ativação com QR, expiração de sessão sem renovação silenciosa. Prova: `mfa_login_test`, `text_scale_test` e o último teste de `full_journey_e2e` no emulador. Aberto: ver a consequência da sessão de 15 min abaixo. +- **T7 — mTLS, broker e backend (`38beea2`, `0e8484b`).** `require_certificate true`, senha e ACL mantidas, backend apresenta certificado. Prova: `scripts/qa/mtls_invariants.sh` (recusa sem certificado e de outra CA, aceite com ambos; distingue recusa de falha de infraestrutura). Aberto: só na stack local. +- **T8 — mTLS, app (`05fcd1c`).** O ACS apresenta o certificado de cliente (asset de desenvolvimento `assets/certs/acs_client.*`); o release com essa chave dentro falha a build. Prova do app com mTLS: `./scripts/qa/e2e.sh --emulator` (smoke + `red_alert_cycle`) e `mtls_invariants.sh`; o `full_journey_e2e` NÃO cobre MQTT. Aberto: **provisionamento de certificado por aparelho em produção** (CSR no cadastro, chave no Keystore). +- **T9 — registro e barra final.** Este texto, o `CLAUDE.md` da raiz e a barra completa (resultados no relatório da tarefa). + +### Decisões do plano (D1–D8) + +- **D1:** `FLAG_SECURE` na janela inteira do ACS, não só na lista (a lista aparece em Área, Visita e no seletor); `screencap` e gravação saem pretos. +- **D2:** chave de release de `apps/acs/android/key.properties` ou `SINALACS_KEYSTORE_*`; sem chave a build falha; debug só com licença explícita; o plano não cria keystore nem segredo de CI. +- **D3:** telefone da UBS em `ubs.contactPhone` nullable, via `ubs.myContact`; sem número o botão avisa; o cadastro é do backoffice. +- **D4:** o cache de RF08 guarda só o que a tela de visita já mostra, na base SQLCipher, 72 h, chave `userId|microAreaId`, apagado ao mudar o dono ou vencer, só no lugar de falha recuperável de rede. +- **D5:** MFA = TOTP RFC 6238 (SHA-1, 6 dígitos, 30 s, janela ±1), segredo cifrado com a chave dos dados clínicos, ativação sem token (matrícula + senha + código), `REQUIRE_ACS_MFA` falsa em `development` e verdadeira fora. +- **D6:** ~~refresh token e redefinição de MFA por coordenador ficam fora.~~ **Superado em 2026-10-03** quanto ao refresh token (ver "Refresh token e desbloqueio biométrico do ACS" abaixo); a redefinição de MFA por coordenador segue fora. +- **D7:** mTLS mantém a senha MQTT (`require_certificate true` sem `use_identity_as_username`): certificado e senha. +- **D8:** o certificado de cliente do ACS vem de asset de desenvolvimento; a chave privada no APK serve só ao dev e o release com ela falha; o mTLS fica entregue na stack local e aberto para produção. + +### Consequência de produto — resolvida em 2026-10-03 + +*Histórico (superado pelo refresh token, seção abaixo).* Com MFA ligada e sem refresh token, a sessão do ACS (15 min) não pode ser renovada em silêncio (o código TOTP é de uso único): **o ACS reautentica com matrícula + senha + código cerca de a cada 15 minutos**; o painel, os alertas e os formulários são preservados (a tela de login é empilhada por cima). A decisão foi implementar o refresh token. + +### Continua aberto + +(Refresh token: fechado em 2026-10-03.) Redefinição de MFA por coordenador *(tratado na #43: `admin.resetAcsMfa`/`admin.resetStaffMfa` pela tela do backoffice — ver a seção da #43, mais abaixo)*; provisionamento de certificado por aparelho em produção; keystore de release como segredo na CI; cadastro do telefone da UBS pelo backoffice; iOS. + +- **Adivinhação de TOTP — bloqueio progressivo entregue em 2026-10-05.** Antes: bloqueio fixo de 5 tentativas por 15 min, 480 tentativas por dia, cerca de 0,14% por dia para quem já tem a senha (480 × 3 códigos válidos na janela ±1 ÷ 10⁶). Agora cada rodada de bloqueio dura o dobro da anterior (15 min × 2^rodada, teto de 24 h; `InstitutionalAuthService.lockDurationFor`, coluna `user_credentials.lockStreak`, migração aditiva `20261005204222939`); só um login válido zera a escalada, e senha e TOTP seguem no mesmo contador. Orçamento na mesma fórmula: 35 tentativas no primeiro dia (rodadas em 0, 15 min, 45 min, 1 h 45, 3 h 45, 7 h 45 e 15 h 45), cerca de 0,0105%, e 5 por dia no regime com o teto (cerca de 0,0015% por dia). **Custo:** o mesmo mecanismo tranca o ACS legítimo por até 24 h se alguém errar o código de propósito; a redefinição é manual (zerar `failedAttempts`, `lockedUntil` e `lockStreak` da linha). Continua sem limite por origem (IP), que é infraestrutura. Prova: teste unitário (`lockDurationFor` e a segunda rodada de 30 min) e teste contra Postgres (`institutional_lock_streak_test.dart`, incluindo a rajada concorrente). +- **Cadastro da MFA é trust-on-first-use:** quem souber a senha primeiro pode ativá-la; **(atualizado pela #43)** a redefinição pela coordenação existe desde 2026-10-08 (`admin.resetAcsMfa` pela tela; ver a seção da #43), e a redefinição **pelo próprio ACS** continua não existindo. +- **`FLAG_SECURE` escurece captura e gravação:** `screencap`/`screenrecord` do ACS saem pretos, inclusive o pipeline de vídeo (`video/capture/lib.sh`, linhas 142 e 161). Consequência conhecida; **Desfecho (2026-10-05): opt-out só em debug, por constante de compilação.** `BuildConfig.ALLOW_SCREEN_CAPTURE` é `false` em todo build; só o buildType `debug` o liga, com `-Psinalacs.allowScreenCapture=true` (`./scripts/dev/run_acs.sh --build --captura`); o Gradle **recusa** a propriedade em release (cenário 6 de `acs_release_signing.sh`). Foi `BuildConfig` nativo e não `kReleaseMode` porque a flag é aplicada em `Activity.onCreate`, antes do Dart: com `kReleaseMode` a janela e a miniatura dos recentes ficariam sem proteção até o primeiro quadro. Provado no `emulator-5554`: debug padrão COM `FLAG_SECURE`, debug `--captura` SEM (`screencap` devolve imagem de 94 KB, antes vazia) e release instalado COM `SECURE` na janela. + +### Minors adiados + +- T1: *(fechado em 2026-10-05)* `acs_secure_window.sh` espera a janela por até 40 s e lê o `dumpsys` de uma variável, sem `awk exit` sobre pipe; `secure_window_test` agora exige a chamada dentro de `onCreate` (a mutação foi vista falhar); como regenerar as capturas já estava em `docs/telas-acs.md`. +- T2: *(fechado em 2026-10-05)* o script já limpava `SINALACS_KEYSTORE_*` (medido: o item estava obsoleto); `licenca()` no Gradle só aceita `=true` (cenário 5 do script, visto falhar antes); a mensagem lista as propriedades que faltam; `apksigner` pela maior versão (`sort -V`). +- T3: o store do ORM faz duas consultas; "UBS não encontrada" vira `AlertPermissionException`; *(`capture-acs.sh` corrigido em 2026-10-05: cita "Ligar para a UBS")*. +- T4: relógio do aparelho voltado serve cache de mais de 72 h; o cache do dono anterior só some no próximo load; o guard do `main.dart` é por string. +- T5: *(lockout progressivo fechado em 2026-10-05)* ruído de reformatação em `institutional_auth_service`; confirmar que o log do Serverpod não registra o retorno de endpoint (`secretBase32`). +- T6: preexistente: `renewSession` com falha não recuperável que não é MFA re-tenta o login a cada sync e pode bloquear a conta. +- T7: *(fechado em parte em 2026-10-05, ver abaixo)* chaves privadas de cliente em modo 644 em dev; o teste "production sem TLS" só verifica a ausência do erro de certificado; cert/chave ignorados em silêncio se o TLS está desligado. +- T8: `sync_dev_ca.sh` não confere que `acs-area-12.key` é o par do certificado e deixa `.tmp` se interrompido; doc de `alert_feed.dart` passa de 100 colunas; `buildMqttSecurityContext` ignora certificado sem chave em silêncio. + +## Login real do backoffice (issue #39, 2026-10-06) + +**Entregue (código e testes hermáticos):** `auth.loginStaff`, `auth.beginStaffTotpEnrollment` e `auth.confirmStaffTotpEnrollment` (MFA sempre obrigatória para o staff, JWT com papel `coordinator`/`admin` e sem microárea, `Authorization.staffRoles`), tabela `staff_accounts` (migração aditiva), `ADM-001` no seed de desenvolvimento (senha `DEV_ADMIN_PASSWORD`), `apps/admin` com login real (`LoginScreen`, `MfaEnrollmentScreen`, `BackendAdminAuth`, host só HTTPS, CA de desenvolvimento como asset) e sem `devLoginEnabled` nem atalho. Detalhes em `backend/CLAUDE.md` e `apps/CLAUDE.md`; telas em `docs/telas-admin.md`. + +**Suítes rodadas em 2026-10-06:** `dart test` do backend (646 testes, inclui os de integração contra o `postgres-test`) e `flutter test` do admin (95) verdes; `flutter analyze` do admin sem avisos; `scripts/qa/ci_invariants.sh` ok (9 grupos); `scripts/qa/otp_relay_test.py` ok. + +**Prova em aparelho: EXECUTADA em 2026-10-07 (Motorola edge 40 neo, Android 15, serial `0087014315`, não no emulador).** `DEVICE=0087014315 ./scripts/qa/admin_login_e2e.sh` passou 4/4 (senha errada com mensagem genérica; ativação da MFA pela tela; código errado recusado; login com o código do passo seguinte até "Painel de Indicadores") e a conferência do banco de teste deu `TOTP ativado e passo registrado: true|true` e `tentativas falhas zeradas pelo login: 0`. A primeira rodada falhou no primeiro teste (3/4, após 1 min 05 s) e a causa não foi identificada, porque o log não foi guardado; a segunda rodada, com log em `.superpowers/admin_e2e_run1.log` (git-ignorado), passou. Trate a falha inicial como intermitência não explicada. RPC real + TLS com a CA do asset + telas estão provados em aparelho Android 15; **não** estão provados no emulador nem em Android ≤ 14. + +**Continua aberto:** refresh token do staff (hoje 15 min e novo login com código); tela de redefinição de MFA e gestão de contas *(#43, fechado em 2026-10-08 — ver a seção da #43; ficou de fora só a emissão do código para uma conta que ainda não nasceu, ou seja, criar coordenação pela tela)*; endpoints de dados do staff com RBAC *(#40, fechado em 2026-10-07, ver seção abaixo)*; trocar o `MockAdminDataSource` (#41, parcial: ver a seção da #40); a trilha de auditoria ainda não distingue a audiência *(fechado em 2026-10-06, ver abaixo)*; a primeira ativação do TOTP do staff exigir só matrícula + senha *(fechado em 2026-10-07 pela #48, ver seção abaixo)*; screenshot de `docs/telas-admin.md` do login não foi recapturado *(fechado em 2026-10-06, ver abaixo)*. + +## Endpoints do backoffice e painel com dados reais (issue #40, 2026-10-07) + +**Entregue.** `AdminEndpoint` (`admin.indicators/microAreas/alerts/auditLogs`), somente leitura, só `coordinator`/`admin`; `AdminReadService` e `OrmAdminReadStore`; `staff_accounts.ubsId` (migração aditiva); rótulos minimizados (`#A18F`); no app, `BackendAdminDataSource` por sessão e `buildAdminWiring`. Regras e decisões em `backend/CLAUDE.md` ("Leitura do backoffice") e `apps/CLAUDE.md`. + +**Provado nesta execução.** Backend `dart test` inteiro: 716 verdes (eram 682; entre os novos, 3 de rótulo, 9 do serviço, 11 do store contra Postgres, 10 do endpoint com tokens reais, 1 das fixtures). `apps/admin`: `flutter test` 117 verdes (eram 105) e `flutter analyze` limpo. `scripts/qa/admin_login_e2e.sh` no emulador `emulator-5554` (API 36): 5 testes passaram, incluindo o painel com dados reais (Vermelho 2, TMRAV 60s, 3 alertas só com rótulo, leitura do admin na auditoria), e o banco confirmou 4 recursos `admin_*` lidos e auditados como `read/success`; log em `.superpowers/admin40_emulador.log` (git-ignorado). + +**Decisões que valem até alguém trocá-las.** (1) Coordenador vê só a UBS de `staff_accounts.ubsId`; sem UBS é recusado em todos os métodos. (2) Só o administrador lê a auditoria. (3) A auditoria da leitura é fail-closed. (4) TMRAV: janela de 30 dias, só vermelhos reconhecidos, `null` sem amostra (o PRD §1.3 não fixa a janela). (5) Alertas por `offset`, auditoria por `beforeSequence`. + +**Continua aberto.** (1) Não há tela nem endpoint para atribuir `ubsId` a um coordenador: hoje só por SQL, e o seed de desenvolvimento não tem coordenador. *(Parcialmente tratado na #43: a operação do coordenador passou a existir e é escopada à UBS de `staff_accounts.ubsId`, com e2e real, mas criar a conta de coordenação e escolher a UBS pela tela segue aberto — ver a seção da #43.)* (2) Auditoria por UBS para o coordenador *(tratado na #43: `admin.auditLogs` passou a ser escopado à UBS do coordenador — o deferimento era este; ver a seção da #43)*. (3) *(Tratado na #41, seção abaixo.)* (4) *(Tratado na #41: as telas mostram o texto de `AdminDataFailure` e o 401 leva ao login.)* (5) A paginação do app lê só a primeira página (50 itens): não há "carregar mais". (6) Não foi rodado no celular físico nesta execução. (7) As capturas 02 a 05 de `docs/telas-admin.md` são do emulador com dados do backend; as 06 a 08 (celular e tablet simulado) são de antes e ainda mostram o painel sobre o mock. + +## Admin consome o `sinalacs_client` (issue #41, 2026-10-07) + +**Estado ao começar.** A #40 já tinha entregue a dependência local do `sinalacs_client`, o `BackendAdminDataSource`, o `buildAdminWiring` e o mock mantido só para testes; `main.dart` nunca usa o mock. Verificado de novo: `flutter analyze` limpo e `flutter test` verde com `assets/certs/` vazio (o que o job `admin-app` faz), e `admin_login_e2e.sh` 5/5 no emulador antes de qualquer mudança. + +**Entregue.** (1) `AdminSessionExpired`: sessão vencida no meio de uma leitura leva ao login com "Sessão encerrada. Entre novamente." em vez de "Tentar novamente". O servidor **não** responde 401: recusa o token com `AlertPermissionException` (a mesma da recusa por papel/escopo), então o app só distingue pelo relógio (`expiresAt` da sessão); o 401 (`ServerpodClientUnauthorized`) também é mapeado, por garantia. Relógio do aparelho atrasado ou conta desativada antes do vencimento ainda mostram "Acesso restrito ao backoffice." com retry. (2) As telas mostram o texto de `AdminDataFailure` ("Não foi possível conectar ao servidor.", "Acesso restrito ao backoffice.") no lugar do erro genérico. (3) `scripts/qa/e2e.sh --full` roda do admin só o smoke hermético (`admin_mobile_smoke_test.dart`; antes rodava a pasta inteira, que inclui o `admin_login_e2e.dart`, que precisa de relé) e aponta para `admin_login_e2e.sh` para o fluxo com backend real; o cabeçalho desse script não diz mais que o painel é o mock. Guardas em `scripts/qa/admin_login_e2e_test.sh`. + +**Provado nesta execução.** `apps/admin`: `flutter test` 122 verdes (eram 117) e `flutter analyze` limpo. `admin_login_e2e.sh` no emulador `emulator-5554`: 5/5, com os 4 recursos `admin_*` lidos e auditados como `read/success` (logs em `.superpowers/admin41_base.log` e `admin41_final.log`, git-ignorados). `admin_mobile_smoke_test.dart` no emulador: 4/4. `ci_invariants.sh` ok (9 grupos). + +**Revisão final.** Dois achados Important corrigidos com teste (RED→GREEN, 126/126): a falha de rede chega do cliente como `ServerpodClientException(-1)` (não `SocketException`), e o token recusado não vira 401. Reprovado no emulador: `admin_login_e2e.sh` 5/5. + +**Decisões.** Falha de rede e sessão vencida são provadas por teste de widget e de data source, não por um caso de e2e que derrube o backend: parar o backend dentro do script arrisca deixar a stack quebrada. Não foi criada a flag `--admin-real` no `e2e.sh`. + +**Continua aberto.** (0) Exceção própria no servidor para token inválido/expirado (hoje reutiliza `AlertPermissionException`), para o app não depender do relógio. (0b) `AuditLogScreen` e `_AlertsList` buscam dentro do `build`: cada rebuild do shell lê o backend e grava uma linha em `audit_logs`; guardar o `Future` no `State`. (1) O `admin-app` da CI roda `flutter test` com o cliente real compilado, mas não há e2e contra a stack na CI (o `android-e2e` não roda o admin). (2) As capturas 06 a 08 de `docs/telas-admin.md` seguem as de antes, sobre o mock (layout igual, números diferentes). (3) Paginação "carregar mais" e refresh token do staff, já listados acima. (4) A prova no celular físico não foi repetida. + +## Ativação do TOTP do staff com código de uso único (issue #48, 2026-10-07) + +**Entregue.** `beginStaffTotpEnrollment`/`confirmStaffTotpEnrollment` exigem `activationCode` além de matrícula e senha; quem só tem a senha não registra autenticador. Quatro colunas aditivas em `staff_accounts` (hash SHA-256 do código, validade de 24 h, quem emitiu, quando), `StaffActivationCode`, `StaffActivationStore`, a CLI `bin/issue_staff_activation_code.dart` (lógica em `lib/src/ops/staff_activation_issuer.dart`), a etapa "Código de ativação" antes do QR em `apps/admin` e o código no manifesto de e2e (bloco `staff`, entregue pelo relé em `/admin`). Detalhes em `backend/CLAUDE.md`. + +**Provado nesta execução.** Backend `dart test` inteiro: 682 testes verdes (entre os novos: 4 de `StaffActivationCode`, 12 do serviço, 2 de endpoint, 1 do store, 5 da CLI contra o `postgres-test` e 1 das fixtures). `apps/admin`: `flutter test` 105 verdes (eram 95) e `flutter analyze` limpo; `otp_relay_test.py` 15/15. E2E de `scripts/qa/admin_login_e2e.sh` com 5 testes (inclui "código de ativação errado: recusado, e o QR não aparece") e conferência do banco (`TOTP ativado`, `código de ativação apagado após a ativação`): **passou no Motorola edge 40 neo (Android 15, `0087014315`) e no emulador `emulator-5554`**. Logs em `.superpowers/admin48_celular.log` e `admin48_emulador.log` (git-ignorados). + +**Continua aberto.** (1) A emissão do código por um coordenador/administrador já autenticado, pela interface, e a redefinição da MFA do staff dependem da #43; hoje só a CLI de operador emite. *(Tratado na #43: `admin.resetStaffMfa` zera a MFA e emite o código pela tela do backoffice — e é o único emissor que atende ao critério de auditoria, abaixo. A CLI segue existindo para o operador e para uma conta que ainda não nasceu — ver a seção da #43.)* (2) Quem emitiu o código fica em `activationCodeIssuedBy/At`, **não** em `audit_logs`: a CLI não carrega a cadeia de auditoria do servidor, então o critério "auditoria registra quem emitiu" da issue está cumprido na coluna, não na trilha encadeada. *(Tratado na #43 para o caminho da tela: a redefinição grava `admin_staff`/`mfa_reset` em `audit_logs` com o id do alvo, e o emissor fica em `activationCodeIssuedBy`; a emissão pela CLI continua fora da trilha.)* (3) O ACS segue trust-on-first-use (não fazia parte da issue). *(A redefinição pela coordenação chegou na #43 — `admin.resetAcsMfa`, pela tela.)* (4) O `ADM-001` do seed de desenvolvimento não ganha código sozinho: emita um com a CLI antes do primeiro login. (5) A captura da tela do código (`docs/screenshots/admin/01b-codigo-ativacao.png`) foi feita em 2026-10-07 no mesmo aparelho; as telas do QR e do login com a MFA já ativa não foram refeitas. + +## Minors adiados da #39 (2026-10-06) + +**Fechados:** +- (a) Testes de endpoint de `beginStaffTotpEnrollment`/`confirmStaffTotpEnrollment` contra o Postgres (5 testes, `e4df0de`). +- (b) A auditoria do login distingue staff (`staff_session`) de ACS (`session`) (`4f4c9bd`); sem backfill das linhas antigas. +- (c) O cabeçalho do admin mostra o papel da sessão em vez de `admin.dev` fixo (`5da3008`). O rótulo do papel pode ser truncado com reticências a 320 dp + fonte a 200%, mas o leitor de tela o anuncia por inteiro. +- (d) O contador de bloqueio é por conta e a política é a mesma para ACS e staff (`57c186a`): os testes fixam o limiar de 5 tentativas e o contador por conta; a progressão de 15 min com dobra é compartilhada porque as duas audiências usam o mesmo código `lockDurationFor`, sem teste específico do staff para a duração. O item original estava mal descrito: o contador nunca foi compartilhado. +- (e) A captura da tela de login foi refeita (`a378318`). +- (f) A prova E2E do login real passou em aparelho físico em 2026-10-07 (ver a seção acima). `admin_login_e2e.sh` aceita o aparelho por `DEVICE` (`scripts/qa/admin_login_e2e_test.sh`, não ligado à CI). +- (g) As capturas 02–08 de `docs/telas-admin.md` foram refeitas em 2026-10-07 com sessão real no mesmo aparelho. A 08 é simulada com `wm size 1600x2560` (não é um tablet físico); 03–05 passaram de web largo para celular em retrato; 06 repete a tela de 02. A Auditoria ainda mostra `admin.dev` como autor, porque os dados são o `MockAdminDataSource` (#41). +- (h) `dart format` aplicado em `staff_login_test.dart`; é o único minor cosmético nomeado nesta seção, e não há outra lista. + +**Continua aberto:** +- Refresh token do staff. Reconfirmado em 2026-10-07: `loginStaff` devolve só o JWT de 15 min; `refreshSession` é do ACS. Desde a #41 uma chamada em voo com token vencido (401) leva ao login com aviso; o painel segue sem renovar a sessão. + +**Suítes (2026-10-06):** backend `dart test` 657 verdes; admin `flutter test` 102 verdes, `flutter analyze` sem avisos; `ci_invariants.sh` ok (9 grupos); `check_documentation_links.sh` exit 0. + +## Gestão de ACS e reset de MFA (issue #43, 2026-10-08) + +Plano: `docs/superpowers/plans/2026-10-08-issue-43-gestao-de-acs-e-reset-de-mfa.md`, branch `feat/issue-43-gestao-de-acs-e-mfa` (`a363f9d`..`7cb46a5`, 14 tarefas). Fecha — ou reduz ao que sobrou, listado em "Continua aberto" — as pendências que apontavam para cá: a "tela de redefinição de MFA e gestão de contas" da seção #39, os itens (1) e (2) da #40 (o (1) só em parte), os itens (1) e (2) da #48, o "Continua aberto" das Pendências do ACS (2026-10-02) e o item (b) do "Aberto" do refresh token. + +**Entregue.** + +- **Backend.** `AdminScopeResolver` (`application/admin/admin_scope.dart`) passa a ser a regra **única** de papel e escopo do backoffice — extraída do `AdminReadService`, que deixou de ter a sua cópia. `AdminAccountService` + `OrmAdminAccountStore` (`application/admin/`, sobre `AcsCredentialStore`/`TotpStore`/`StaffActivationStore`) e **oito métodos novos** no `AdminEndpoint`: `acs`, `staff`, `createAcs`, `setAcsMicroArea`, `setAcsActive`, `resetAcsPassword`, `resetAcsMfa` e `resetStaffMfa`. **Auditoria escopada à UBS para o coordenador** (o deferimento da #40): `admin.auditLogs` deixa de ser só do administrador e passa a filtrar pelo **autor** da linha. Toda escrita bem-sucedida grava `actionType: write`, `resourceType: admin_acs|admin_staff`, `result` específico (`created`, `micro_area_changed`, `activated`, `deactivated`, `password_reset`, `mfa_reset`) e o `resourceId` do alvo; toda recusa grava `denied` **antes** de lançar. Sem migração: só modelos de API (`AdminAcs`, `AdminStaff`, `AdminAcsCreationResult`, `AdminPasswordResetResult`, `AdminStaffMfaResetResult`). Regras, mensagens e decisões em `backend/CLAUDE.md`, seção "Gestão de contas do backoffice (#43)". +- **App.** A tela Microáreas deixou de ser somente leitura: `AcsManagementSection` ("Novo ACS", vincular, redefinir senha, redefinir MFA, desativar/ativar) e `StaffManagementSection` ("Equipe do backoffice", só o administrador — nem a seção é montada para o coordenador, nem o servidor devolve a listagem). Diálogo de confirmação em toda ação destrutiva e credencial mostrada uma única vez (senha inicial, senha redefinida, código de ativação). O `AdminDataSource` ganhou `fetchAcs`, `fetchStaff`, `createAcs`, `setAcsMicroArea`, `setAcsActive`, `resetAcsPassword`, `resetAcsMfa` e `resetStaffMfa`; `AdminValidationFailure` é a **única** recusa que vai à tela com o texto do servidor (o resto continua com texto fixo). Detalhes em `apps/CLAUDE.md` e em `docs/telas-admin.md`. +- **Acessibilidade (critério de aceite).** Alvos de 48×52 dp nos botões dos cartões e **dentro dos diálogos** (o `AlertDialog` do Material 3 nasce abaixo disso), `AdminColors.redOnSurface` no rótulo que confirma, `Semantics(liveRegion: true)` no erro do formulário e diálogos `scrollable` para a fonte a 200% (WCAG 1.4.4/2.5.5/4.1.3). + +**Provado nesta execução** (emulador `emulator-5554`; stack `docker-compose.e2e.yml` sobre o `postgres-test`, banco `sinalacs_e2e`, fixtures sintéticas por execução; a stack de desenvolvimento foi restaurada ao fim). + +`./scripts/qa/admin_acs_gestao_e2e.sh` — o e2e novo da gestão: + +``` +00:30 +1: coordenador entra, não vê a equipe do backoffice e cadastra outro ACS na própria UBS +00:53 +2: admin redefine a MFA do coordenador e o código emitido pela tela ativa a MFA nova +01:01 +3: (tearDownAll) +01:01 +3: All tests passed! +== gestão de contas no banco de teste (ACS E2E-ACS-1791550651736) + ACS desativado ao fim: false + nenhum refresh token vivo do ACS criado: 0 + MFA do ACS criado redefinida (colunas totp* nulas): 1 + código de ativação emitido e escopo com UBS: true + escritas da gestão auditadas: 7 +OK — gestão de contas do backoffice contra o banco de teste +``` + +Leitura: os 3 testes instrumentados passaram (jornada do admin + jornada do coordenador + reset de MFA do staff); a matrícula do ACS criado pela própria tela apareceu no marcador (`E2E-ACS-1791550651736`); o ACS terminou desativado; nenhum refresh token vivo; colunas `totp*` nulas após a redefinição; código de ativação emitido com escopo de UBS; 7 linhas de escrita da gestão na trilha (o assert exige ≥5). + +`./scripts/qa/admin_login_e2e.sh` — regressão do login real (#39/#40/#48): + +``` +00:44 +5: All tests passed! +== conta de staff no banco de teste + TOTP ativado e passo registrado: true|true + código de ativação apagado após a ativação: true|true + leituras do painel auditadas (4 recursos, read/success): 4|true + tentativas falhas zeradas pelo login: 0 +OK — login real do backoffice contra o banco de teste +``` + +Leitura: os 5 testes de login/MFA do staff continuam verdes — a #43 não regrediu o caminho existente. A duração ficou ~1 min por script; os avisos de JVM (`restricted method… native access`) são do wrapper do Gradle no build do APK de teste, não do app. + +**Suítes finais (2026-10-09).** Backend `dart test` inteiro: **818 verdes**; `dart analyze`: 62 avisos, **todos `info`**, nenhum `error`/`warning` (aviso pré-existente). `apps/admin`: `flutter analyze` limpo ("No issues found!") e `flutter test` **162 verdes**. + +**Decisões que valem até alguém trocá-las.** (1) **O coordenador gerencia os ACS da própria UBS.** O PRD §4.2.2 dá ao administrador "Configurações de sistema" e ao coordenador "Logs de auditoria (UBS)"; a issue (mais nova) manda restringir "ao papel coordenador/admin", então ela vence, com escopo de UBS. Se o produto quiser admin-only, é trocar `resolve` por `requireAdmin` nas operações de conta (mudança localizada). (2) **A senha inicial (e a de redefinição) é sorteada pelo servidor** — 16 caracteres de um alfabeto de 31 símbolos sem os pares ambíguos (`ABC…Z` e `2…9`, sem `0`/`O`, `1`/`I`/`L`), em quatro grupos de quatro, ~79 bits — e mostrada uma única vez; o servidor guarda só o hash Argon2id. Nunca é escolhida pelo operador (mesmo precedente do código de ativação da #48). (3) **Redefinir a senha zera o bloqueio e NÃO revoga as sessões em curso** — a credencial antiga deixa de existir, e quem precisa cortar acesso na hora é a desativação. (4) **A redefinição da MFA do staff é só do administrador e nunca da própria conta**: uma conta que redefine a própria MFA não tem segundo fator nenhum no intervalo; quem perdeu o autenticador pede a outro administrador. O coordenador não monta a seção Equipe (e o servidor recusa `admin.staff` para ele). (5) **A auditoria do coordenador inclui as linhas pseudonimizadas de paciente da UBS** (ex.: `triage_session`, login OTP), porque o predicado do escopo é "o autor da linha tem microárea da UBS dele" — leitura literal de "Logs de auditoria (UBS)" do PRD; excluir paciente seria um `AND u.role <> 'patient'` a mais. (6) **Sem migração**: só modelos de API + endpoints; guardar "senha inicial emitida em" ou "quem desativou" exigiria migração aditiva, e não é necessária para os critérios. + +**Continua aberto.** (1) **Criar coordenação e atribuir `ubsId` pela tela não existe** — segue por SQL; o e2e usa um coordenador semeado (`E2E-COORD-*`). (2) **A troca de senha pelo próprio ACS não existe no produto** (a tela de credencial diz isso ao operador): perder a senha significa pedir a redefinição à coordenação. (3) **TOCTOU residual nos resets**: `resetAcsPassword`/`resetAcsMfa` validam o alvo por **leitura** (`acsById` com escopo) e escrevem **por PK** (`saveCredential`/`clearTotp`); um alvo que saia do escopo entre as duas é alcançado. Fechá-la pede a escrita com o predicado do escopo, como já é feito em `setAcsMicroArea`/`setAcsActive`. (4) **`enrollmentIdTaken` é global**, não por UBS: a recusa "Já existe um ACS com esta matrícula." é um oráculo de existência de matrícula entre UBS. (5) O botão "Tentar novamente" de `_AsyncError` pinta 40 dp — pré-existente, o único do app sem `minimumSize`. (6) **Capturas de tela novas de `docs/telas-admin.md` pendentes**: a tela de gestão ganhou seção em texto, com o que falta capturar nomeado (a regra do repositório é o doc afirmar só o que foi executado). (7) A CLI `bin/issue_staff_activation_code.dart` segue existindo: é o caminho do operador para uma conta que ainda não nasceu (e o que emite para o `ADM-001` do seed de desenvolvimento), e a emissão por ela continua fora da trilha encadeada. + ## Migração para Serverpod Trabalho posterior às Fases 1 e 2, fora da numeração M1.x/M2.x/M3.x do PRD. O @@ -585,7 +758,7 @@ fecha esse buraco. | Schema | 13 tabelas como modelos `.spy.yaml` (as 11 originais mais `alert_idempotency_keys` e `alert_outbox` — ver "cadeia de hash" e "outbox pattern" abaixo); migrações geradas e aplicadas pelo servidor no boot | | Endpoints | RPC: `alerts.createRedAlert`, `alerts.acknowledge`, `auth.developmentLogin`, `health.check`, `triage.evaluate`, `patients.listMicroArea`, `visits.sync` | | Testes | 16 arquivos (12 unitários herméticos, 4 de integração) sobre o harness `withServerpod`; contagem estática de `test(` no código-fonte, não uma execução nesta revisão — rodar `cd backend/sinalacs_server && dart test` contra a stack local para o número de casos passando | -| Cliente gerado | Publicado em `backend/sinalacs_client`; **paciente e ACS já o consomem** por dependência local (ver M2.4 acima); `apps/admin` ainda não | +| Cliente gerado | Publicado em `backend/sinalacs_client`; **paciente e ACS já o consomem** por dependência local (ver M2.4 acima); `apps/admin` também, desde a #40/#41 | ### Decisões de schema que valem registro @@ -672,6 +845,44 @@ O backend `dart:io` foi removido da árvore; o histórico do git o preserva. --- +## Refresh token e desbloqueio biométrico do ACS (2026-10-03) + +Plano: `docs/superpowers/plans/2026-10-03-refresh-token-e-biometria-do-acs.md`. Fecha o "reautentica a cada 15 minutos" que a MFA tinha criado e supera a D6 do plano de 2026-10-02. + +- **Backend.** Tabela `acs_refresh_tokens` (migração `20261003151950052`; hash SHA-256, família, aparelho — o `deviceId` é o identificador da instalação, UUID aleatório, não uma atestação de hardware: protege contra token vazado sem ele, não contra comprometimento do aparelho). Janela ociosa de 2 h, teto absoluto de 8 h desde o login por senha + TOTP, tolerância de reuso de 30 s. Reuso fora da tolerância, aparelho diferente e conta inativa ou sem microárea revogam a família; a mensagem é uma só, `Sessão expirada. Entre novamente.`. A microárea é relida do banco a cada refresh. INSERT condicional atômico e revogação em duas passagens contra a corrida. Auditoria `session_refresh` (`refresh_granted`, `refresh_granted_grace`, `denied_*`). `auth.refreshSession` e `auth.logout` são públicos por desenho e estão na allowlist de `endpoint_auth_posture_test.dart`. `loginInstitutional` só emite o token com `deviceId` não-branco. O JWT do ACS segue em 15 min; o paciente segue sem refresh (OTP, 1 h). +- **App.** Não retém mais a senha. Refresh token e `deviceId` (UUID aleatório) no Keystore (`secure_session_token_store.dart`); renovação single-flight. `AppLockGate` (`local_auth` 3.0.2) acima do Navigator bloqueia após 30 s em segundo plano; painel, MQTT, filas e formulários continuam montados. A partida a frio exige biometria ou bloqueio de tela antes de retomar a sessão (aparelho sem bloqueio de tela: login completo). "Entrar com senha" empilha a reautenticação opaca de duração zero. "Sair e encerrar o turno" revoga a família e **não** apaga a fila de visitas. `MainActivity` virou `FlutterFragmentActivity` e mantém `FLAG_SECURE`; `USE_BIOMETRIC` no manifesto. +- **Limite de desenho.** *(Reescrito em 2026-10-05.)* O refresh token agora só é decifrado depois do `BiometricPrompt` com `CryptoObject` sobre uma chave RSA do Keystore criada com `setUserAuthenticationRequired(true)` e autenticação a cada uso (envelope AES-256-GCM + RSA-OAEP, `KeystoreVault.kt`; ver "Biometria amarrada ao Keystore" abaixo), uma vez por partida a frio. O `AppLockGate` continua sendo portão de **interface** para os 30 s em segundo plano: depois do desbloqueio o token fica em RAM durante o processo. A chave do SQLCipher e o token de envio diferido seguem sem esse vínculo, por desenho. O ganho contra o roubo vem do cofre somado à janela de 2 h / teto de 8 h e à revogação (reuso, "Sair"). +- **Prova.** Suítes: backend 536, ACS 336. Emulador `emulator-5554` (API 36, `google_apis_playstore`): `acs_full_e2e.sh` 4/4, e `--esperar-jwt` com renovação silenciosa após a expiração real do JWT (871 s), sem tela de reautenticação, `refresh_granted` em `audit_logs` e zero `denied*`; o bloqueio com BiometricPrompt real após mais de 30 s em segundo plano, desbloqueado por PIN e por digital (digital errada ou cancelamento não desbloqueiam); `FLAG_SECURE` intacto (`acs_secure_window.sh`). O e2e automatizado usa um `BiometricGate` de teste na retomada da partida a frio. Na rodada achou-se e corrigiu-se `e2e.sh` quebrado no branch (`live_check.dart` importava `dart:ui` pelo Keystore). +- **Não provado:** Android ≤ 8.1 (API 24–27) em aparelho físico (o tema herda `@android:style/Theme.*.NoTitleBar` e o README do `local_auth_android` pede AppCompat para Android 8.1 ou anterior; no emulador API 24 e 27 a quebra não se reproduziu, ver "Tema em API 24–27" abaixo; `minSdk` é 24), aparelho físico, `lockedOut` por tentativas, iOS, o prompt real na partida a frio com sessão guardada (só o do bloqueio por inatividade foi exercitado à mão por adb; `KEYCODE_HOME` no lugar de `SLEEP`/`WAKEUP`). *(O prompt na partida a frio foi provado em 2026-10-05 por `acs_keystore_bound_e2e.sh`.)* +- **Aberto:** (a) ~~biometria ligada ao Keystore (`setUserAuthenticationRequired`) em vez de portão de UI~~ — fechada em 2026-10-05 para o refresh token, com prova no emulador API 36 (ver "Biometria amarrada ao Keystore" abaixo; o que segue aberto está lá); (b) ~~revogação de todas as famílias de um ACS e redefinição de MFA pela coordenação (hoje manual)~~ — fechada em 2026-10-08 pela #43: a desativação revoga, na mesma transação, todas as famílias de refresh token e todos os tokens de envio diferido da conta, e a coordenação redefine a MFA pela tela (`admin.setAcsActive`, `admin.resetAcsMfa`, `admin.resetStaffMfa` — ver a seção da #43), com prova no emulador; (c) limpeza periódica de `acs_refresh_tokens` (hoje o login só poda os tokens vencidos do próprio usuário); (d) iOS não existe; (e) refresh token do **paciente** (OTP segue em 1 h); (f) tema Android/AppCompat em Android ≤ 8.1 (API 24–27) — **não reproduzida** no emulador em 2026-10-05 (ver "Tema em API 24–27" abaixo); o tema não foi alterado e segue sem prova em aparelho físico; (g) ~~a fila de visitas do app não é escopada por dono~~ — fechada em 2026-10-04 (ver "Fila de visitas por dono e corrida do login" abaixo); (h) pops programáticos enquanto o app está bloqueado não são interceptados (limitação documentada no `AppLockGate`); (i) ~~resta uma janela estreita de logout concorrente com renovação~~ — fechada pela revisão final (I-1, abaixo); (j) **M-2:** o login por senha não revoga a família que substitui: a família anterior do mesmo aparelho segue válida até o teto de 8 h (ou a janela ociosa de 2 h) e só cai pelo reuso ou pelo "Sair"; (k) **rate limit** em `auth.refreshSession`/`auth.logout` (públicos, sem token): é infraestrutura — middleware `ratelimit` do Traefik na rota do RPC —, não código do endpoint; (l) **M-7:** em dois refreshes concorrentes com o MESMO token, o perdedor que recebe o filho pela tolerância é auditado como `refresh_granted` em vez de `refresh_granted_grace` (a trilha subconta o uso da tolerância). +- **Revisão final do branch (corrigido aqui).** **I-1 (INV-01/RNF06):** uma renovação em voo podia sobrescrever um login mais novo — o ACS B entrava por "Entrar com senha" enquanto a renovação lenta de A estava em voo, e o resultado de A gravava o token e a sessão de A por cima: o painel de B passava a usar o JWT de A. Agora `BackendClient` tem uma geração (epoch): login e logout a incrementam (o logout antes **e** depois da chamada de rede), e a renovação que começou numa geração anterior termina recuperável, sem gravar o filho, sem expor a sessão e sem `onSessionExpired` (a família filha fica abandonada). Fecha também o logout concorrente com renovação, inclusive a renovação iniciada durante a chamada de logout. **I-2 (alerta vermelho nunca descartado em silêncio):** "Sair e encerrar o turno" diz quantos alertas seguem sem confirmação ("Eles saem deste aparelho, mas continuam pendentes no servidor") e o botão vira "Sair mesmo assim"; a saída não é bloqueada. Também: a reautenticação de "Entrar com senha" que não abre devolve a flag e mantém a cobertura (M-3), e `_tryResume` sempre solta `_resuming` (M-4). + +## Biometria amarrada ao Keystore — refresh token do ACS (2026-10-05) + +Plano: `docs/superpowers/plans/2026-10-05-biometria-amarrada-ao-keystore.md`. Fecha o item (a) da seção anterior. + +- **App.** `AuthBoundSessionTokenStore` (`lib/core/security/auth_bound_session_token_store.dart`) com `MethodChannelKeystoreVault` e o cofre nativo `KeystoreVault.kt`: uma chave AES-256-GCM descartável cifra o token; a chave AES vai cifrada com a pública RSA-2048/OAEP (SHA-256, MGF1-SHA-1) de um par do Android Keystore com `setUserAuthenticationRequired(true)`, validade 0 (a cada uso), `BIOMETRIC_STRONG | DEVICE_CREDENTIAL` na API 30+ e só biometria forte antes. `write` sela sem prompt (a rotação de 15 min segue silenciosa); `read` só devolve a RAM; `unlock` é o único que decifra, com `BiometricPrompt` + `CryptoObject`, uma vez por partida a frio (`_tryResume`). `cancelled`/`lockedOut` mantêm o blob; `invalidated` (chave morta ou blob corrompido) apaga o blob e cai no login com "Sua sessão expirou. Entre novamente."; teto de 2 min no `unseal` (o lado nativo pode descartar o pedido sem callback). O token do `flutter_secure_storage` das instalações antigas é migrado (selado e apagado) no primeiro `contains()`/`unlock`. `MemorySessionTokenStore`/`SecureStorageSessionTokenStore` devolvem `notRequired`, e aí vale o `BiometricGate` de UI de antes (é o caminho do `integration_test`). +- **Prova no emulador** (`emulator-5554`, API 36, `google_apis_playstore`, PIN + digital 1): `./scripts/qa/acs_keystore_bound_e2e.sh --pin [--preparar] [--nova-digital] [--remover-bloqueio]`, com o app de verdade (`run_acs.sh --build`) e o ACS do seed na stack de desenvolvimento. Verde em 2026-10-05: (1) com token guardado, a partida a frio mostra o diálogo do `BiometricPrompt` (systemui) e o painel não está na tela; (2) a digital 1 abre o painel; (3) a digital 2 não abre, o prompt continua, cancelar volta ao login **sem** o aviso, o blob continua no `SharedPreferences` e a partida seguinte entra com a digital 1; (4) o cenário do revisor: o app vai ao segundo plano com o prompt aberto (e antes de ele abrir); na volta nada fica preso ("Retomando a sessão…" some, o botão de login está habilitado), o blob continua e a partida seguinte entra; (5) remover o bloqueio de tela e pôr um PIN novo: a partida seguinte não abre prompt, mostra "Sua sessão expirou. Entre novamente." e apaga o blob (sem laço). O `dumpsys fingerprint` registrou `acceptCrypto`/`rejectCrypto`, ou seja, autenticações com `CryptoObject`. Também visto: com o emulador sem PIN nem digital, o login deixa o token só em RAM (`SharedPreferences` vazio) e a partida a frio cai no login completo, sem prompt. Não-regressão verde: `./scripts/qa/e2e.sh --emulator` (smoke), `acs_full_e2e.sh` (5/5, `SecureStorageSessionTokenStore` → `notRequired` → `BiometricGate` de teste) e `acs_secure_window.sh`. Suíte do ACS: 519 (`flutter test`). +- **Achado da prova: digital nova NÃO invalida a chave na API 30+.** Depois de cadastrar uma segunda digital (`--nova-digital`, ou à mão em Configurações › Segurança), a partida a frio abre o prompt e a digital **antiga** entra. Esperava-se "Sua sessão expirou" sem prompt. Causa: com `DEVICE_CREDENTIAL` permitido, a chave inclui o SID do bloqueio de tela, e o token de autenticação da digital também o carrega, então `setInvalidatedByBiometricEnrollment(true)` não tem efeito. Não há laço de prompt e o PIN continua exigido para cadastrar digital; quem sabe o PIN já abre o cofre de qualquer forma, então a perda de segurança é pequena. Ficar com a invalidação exigiria uma chave só de biometria (sem o PIN no prompt: aparelho só com PIN cairia sempre no login completo) ou uma chave-sentinela só de biometria. Decisão em aberto; os comentários do código foram corrigidos para não prometer a invalidação. +- **Fechado em 2026-10-05:** (a6) "sem Tentar de novo" — depois de cancelar, `lockedOut` ou `unavailable` com o token ainda guardado, o login mostra "Desbloqueio não concluído. Tente de novo ou entre com senha." e o botão "Tentar de novo" (token apagado continua com "Sua sessão expirou", sem o botão). +- **Segue aberto:** (a1) invalidação por digital nova na API 30+ (achado acima); (a2) a chave do SQLCipher e o token de envio diferido seguem sem vínculo à autenticação, **por desenho** (a fila offline e o envio de outro dono funcionam sem o dono presente); (a3) API 24–29 sem prova (só biometria forte no prompt; o item (f) da seção anterior continua aberto), e aparelho físico também sem prova; (a4) aparelho sem biometria nem bloqueio de tela, ou API 24–29 sem biometria forte: o token fica só em RAM e **cada partida a frio exige login completo**; (a5) o teto de 2 min do `unseal` vira `cancelled` no Dart, mas o prompt nativo pode continuar aberto, órfão (UX); (a7) `lockedOut` por tentativas não foi provado no emulador; (a8) a RSA-2048 é gerada na thread principal no primeiro `seal` (engasgo possível em aparelho lento). +- **Tema em API 24–27 (2026-10-05, emulador).** Hipótese testada: o tema `@android:style/Theme.*.NoTitleBar` quebraria o prompt biométrico abaixo da API 28. Imagens: API 24 `google_apis` e API 27 `default` (não existe `google_apis` 27), x86_64, PIN + digital 1 cadastrados, app debug com o tema atual (`scripts/qa/acs_api27_avd.sh`). **Resultado: não se reproduz.** (1) Prompt do cofre do refresh token: não abre, porque `canAuthenticate(BIOMETRIC_STRONG)` devolve 12 (`ERROR_NO_HARDWARE`, medido na API 27 com uma sonda temporária, removida) e o `SharedPreferences` do cofre fica vazio nas duas APIs; o token fica só em RAM e a partida a frio exige o login completo (é o item (a4), não um defeito novo). (2) Prompt do `local_auth` (bloqueio após 30 s em segundo plano): abre a tela de credencial do sistema (`com.android.settings.ConfirmLockPassword`, na task do app), o PIN devolve o painel, e o logcat não tem `AppCompat`, `IllegalState` nem `FATAL`. **Não provado:** o `FingerprintDialogFragment` do `androidx.biometric` (o que de fato exigiria AppCompat) — o app não o alcança nessas imagens —, aparelho físico, outros fabricantes, e a API 25–26 e 28–29. Observação à parte: na API 24 o bloqueio empilhou três `ConfirmLockPassword` (cada PIN fechou uma), sem relação com o tema; não investigado. + +## Fila de visitas por dono e corrida do login (2026-10-04) + +Plano: `docs/superpowers/plans/2026-10-03-fila-de-visitas-por-dono-e-corrida-do-login.md` (decisões D1–D9). Fecha o item (g) acima e a corrida residual do `login()`. + +- **Fechado: corrida do `login()`.** `BackendClient._exclusive` é uma seção crítica serial e não reentrante: login, `developmentLogin`, a leitura do token e a confirmação da renovação, e o logout trocam época, token do Keystore e `_session` dentro dela; a rede (refresh, logout no servidor) fica fora. Uma renovação iniciada na janela da gravação do login lê o token novo e não toca na sessão do usuário seguinte. +- **Fechado: fila de visitas por dono (RNF06).** Schema local v7 (`offline_visits.owner`, aditivo): `VisitStorage.forOwner(userId)` só lê, conta, grava e apaga `WHERE owner = ?`; o `save()` de B nunca toca nas linhas de A. Linhas do schema v6 ficam em **quarentena** (`owner IS NULL`): nenhum dono as carrega, conta, envia ou descarta; só `LegacyVisitStore` as vê. `localId` já existente em outro dono vira `VisitLocalIdConflict` (a unicidade é global). Cursor do pull por `userId|microAreaId` (o cursor global antigo foi abandonado; cada dono recomeça do `epoch`, idempotente por `localId`). Fila e pull são resolvidos por sessão e recriados por dono; o sincronizador recusa sessão ou token de outro dono (checagem no push e `expectedUserId` conferido **depois** de resolvido o token, para a renovação de A que espera a seção do login de B nunca enviar sob o token de B). Isso fecha também o caminho de perda de dado clínico por `discardRejected()` de outro ACS. +- **Backend.** `visits.syncLegacy` (sessão de ACS territorializado **só como transporte**): grava com `authorship = legacyUnclaimed`, `acsId` nulo e `originDeviceId`; teto de 200 entradas (`VisitSyncService.maxLegacyBatch`); valida paciente e território do transportador (visita de outra microárea ou paciente divergente: recusa genérica); reenvio idêntico (mesmo `localId`, mesma versão) devolve `synced`; auditoria `visit_legacy_sync` do lote e, por visita, `visit_legacy_sync_item` (`resourceId` = `visits.id`) gravadas só depois do laço; ACS inativo recusado (`isActiveAcs`). `visits.syncDeferred` e `visits.revokeUploadToken` autenticam pelo **token de envio**, não pelo JWT (na allowlist de `endpoint_auth_posture_test.dart`): tabela `acs_upload_tokens` (migrações `20261004013108186` e `20261004014924364`; só hash SHA-256, 7 dias, um por usuário+aparelho, escopo único, advisory lock na troca), emitido no `loginInstitutional` junto do refresh token (só com `deviceId` real); a visita é gravada com a autoria do dono do token e a microárea é relida do banco. `OpaqueToken` é o gerador/hasher compartilhado com o refresh token. Tabelas de domínio na migração mais recente: **20** (`definition.sql`). +- **App.** `UploadTokenStore` (`secure_upload_token_store.dart`): um token por dono no Keystore (`acs_upload_token|`) mais um índice de donos; o envio também tenta o dono de toda linha no disco, então o índice é otimização. `DeferredFlushService.flushAll()` (single-flight, não lança): o legado sobe pela sessão atual como transporte; cada dono com token sobe por `syncDeferred`, em lotes de 100; uma visita só sai do aparelho em `synced`; o token é revogado e apagado só com a lista do dono vazia; o dono da sessão atual é pulado (a fila é do painel). Gatilhos: painel aberto, retomada do app e conexão que volta (`connectivity_plus`). "Sair e encerrar o turno" sincroniza a fila do painel, sobe o resto (teto de 20 s por etapa, `sessionEndSendTimeout`) e diz o que fica no aparelho e por quê. "Limpar este aparelho": congela a área, espera o refresh em voo e `VisitDatabase.wipeAllData` apaga `offline_visits`, `sync_cursor` e o cache da microárea numa transação **só se não houver NENHUMA linha** (inclusive recusada, legado ou de outro dono) nem fila só em RAM; o arquivo e a chave ficam. Textos distinguem "ainda não enviadas", "recusadas ou em revisão" e "de outra conta sem chave de envio". +- **Prova.** Suítes: backend 609, ACS 476. Emulador `emulator-5554` (API 36): `encrypted_storage_test.dart` (dois donos + quarentena no mesmo arquivo SQLCipher real, migração v6 preservando linhas, arquivo sem o texto das notas) e `full_journey_e2e.dart` via `acs_full_e2e.sh` com dois ACS (`acsB`) nas fixtures: A (com MFA) registra visita offline e sai; B entra e não a vê; a conexão volta e `flushAll` sobe a de A com `acsId` de A; uma visita legada plantada no banco v6 sobe com `authorship = legacyUnclaimed`, `acsId` nulo e `originDeviceId`; `psql` confere `visits`, `audit_logs` e que não resta token de envio ativo de A nem de B. **O offline é SIMULADO**: um decorator de `AcsBackend` no teste recusa as chamadas de envio de visita (não é a rede do aparelho caindo) e o gatilho de conectividade é alimentado por um `StreamController`. +- **Não provado:** token de envio vencido ou recusado no aparelho; rejeição por "versão diferente" e por território no aparelho; retomada a frio com fila de outro dono; `e2e.sh --full`; execução em segundo plano; Android ≤ 8.1 (API 24–27) em aparelho físico; iOS. +- **Aberto, com motivo.** (1) **Execução em segundo plano do SO (WorkManager):** o envio é só em primeiro plano; `workmanager` é pacote fora de `spec/stack.md` (exige decisão), roda em isolate sem UI e não teria prova no emulador (D8). (2) **Wipe forçado por supervisor:** não existe login de supervisor/coordenador (D9); o wipe é do ACS e só abre com tudo enviado. (3) **Token de envio vencido (7 dias) ou recusado deixa a fila do dono presa** até o dono entrar de novo, e **bloqueia o wipe** do aparelho. (4) **Retenção/expurgo LGPD de dono que nunca volta** continua dependendo do envio: sem token válido e sem login dele, as visitas ficam cifradas no aparelho sem prazo. (5) **ACS inativo ainda é aceito em `visits.sync`/`pull`** até o JWT vencer; `isActiveAcs` só é checado no `syncLegacy` (e no `syncDeferred` pelo token). (6) Não há CHECK no banco para `acsId` nulo ⇔ `authorship = legacyUnclaimed`. (7) Oráculo de existência de `localId` residual (um `localId` existente de outro paciente é recusado, um inexistente é inserido), igual ao `visits.sync`; o mesmo `localId` duas vezes no lote gera dois itens de auditoria, e uma falha do commit depois do laço pode deixá-los órfãos. (8) **Sem teto de lote no `visits.sync` existente** (o teto de 200 vale para `syncLegacy`/`syncDeferred`; mudar o existente seria regressão da fila do app). (9) **Microárea trocada:** visitas antigas do dono terminam `rejected` de forma terminal. (10) **`rejected` por "versão diferente"** (a visita já existe no servidor com autor, em outra versão) fica em quarentena e exige revisão: uma edição offline de linha já enviada fica presa por escolha segura. (11) `deleteExpiredFor` fora da transação da troca de token (deadlock teórico); a classe gerada `AcsUploadToken` é exposta ao cliente. (12) `uploadToken`/`refreshToken` trafegam como argumento de RPC (nota do VAZ-03). +- **Janelas conhecidas (documentadas).** Dono só em RAM (a fila não conseguiu gravar) cujo token de envio é revogado pelo envio diferido enquanto há visitas pendentes só em memória (elas só sobem pela sessão dele, que ganha outro token ao entrar); `VisitRegistrationScreen._loadPatients` grava o cache da microárea fora do congelamento do wipe; `close()` durante um `open()` em voo deixa a conexão aberta; `_upgrade` a partir do schema v1 não cria `sync_cursor`/`micro_area_cache` (pré-existente); o single-flight de `BackendClient.renewSession` tem folga (uma renovação entre o enfileiramento do login e o corpo da seção abre um voo extra, absorvido pela tolerância de 30 s); logout concorrente com renovação respondida antes dela dispara `onSessionExpired` no meio do "Sair" (pré-existente); um Keystore travado bloqueia todas as seções críticas. A janela do teto de 20 s do "Sair" seguida de um ciclo B/A (fila antiga regravando às cegas o disco de uma fila nova do mesmo dono) foi **fechada** pela revisão final: a fila com envio em voo (`OfflineVisitQueue.isSyncing`) não sai da memória, então o próximo login do dono reencontra a MESMA fila. O compare-and-clear do token de envio também foi fechado: `UploadTokenStore.compareAndClear` roda dentro da cadeia serial do store. +- **Revisão final (2026-10-04), corrigido.** **C-1 (pré-existente, perda de dado):** `OfflineVisitQueue._applyOutcomes` refazia a fila só com o lote capturado antes do envio, então uma visita registrada com o lote no servidor sumia da memória e, no `save` seguinte, do disco; agora quem chegou durante o envio continua pendente, e `sync()` é single-flight (uma segunda chamada recebe o resultado do envio em voo; o lote nunca sai duas vezes). **I-1:** a recuperação "chave não abre o arquivo → apaga arquivo e chave e recomeça" só roda para o erro de chave errada do SQLCipher (`SQLITE_NOTADB`/"file is not a database", ou o `open_failed` em que o `sqflite_sqlcipher` 3.2.0 o converte no Android); erro de migração, de E/S, `PlatformException` ou qualquer outro sobe, nada é apagado e a fila segue em RAM (`persistenceFailed`). Como no Android a chave errada e uma falha passageira chegam como o mesmo `open_failed`, a recuperação só roda se uma segunda tentativa de abrir também falhar, e nunca apaga o arquivo: ele vai para uma cópia única `.recuperado`, ainda cifrada e com a chave apagada — **ilegível ao app e ao suporte**, só um artefato forense (os bytes ficam caso a chave antiga um dia seja restaurada de um backup do aparelho/Keystore); as visitas pendentes desse arquivo não são recuperáveis; se mover falhar, o erro sobe e nada é tocado; "Limpar este aparelho" apaga também a cópia. **I-2:** fila com envio em voo não é descartada (ver acima). **M-5:** `compareAndClear` em série. Textos: o "Sair" explica a espera ("Enviando as visitas pendentes antes de sair…") e diz que o que ficou sobe "quando o aplicativo estiver aberto com conexão (em até 7 dias)". +- **Aberto da revisão final.** **Recuperação de chave no Android:** `open_failed` é indistinguível entre chave errada e falha real de abertura; uma falha real que persista nas duas tentativas move o arquivo e troca a chave, e sem restaurar a chave antiga as visitas do arquivo movido ficam ilegíveis (perda de acesso às pendentes). Opção futura: guardar a chave antiga sob `.recuperado` até o "Limpar este aparelho". **M-1:** recusas de território do legado ficam lembradas só em memória (`DeferredFlushService._legacyRejectedBy`), então cada partida do app reenvia a mesma visita legada e o servidor grava de novo `denied_territory` sob o ACS que só a transportou (inocente). Correção: persistir o conjunto de recusas por transportador ou auditar com outro `result`. **M-3:** "Sair" sem rede deixa o token de envio do dono válido (no Keystore e no servidor) por até 7 dias, mesmo sem visita pendente, até um envio diferido rodar com rede e revogá-lo; desativar a conta o corta (`syncDeferred` confere a conta). **Downgrade:** `onDowngrade: onDatabaseDowngradeDelete` (`encrypted_database.dart`) **apaga** o banco quando o APK volta para uma build anterior ao schema v7 — visitas não enviadas, cursor e cache da microárea se perdem. + ## Login institucional do ACS (RF07) — o que ficou de fora (2026-09-18) O login do ACS deixou de ser o token HMAC de desenvolvimento: o app envia @@ -688,7 +899,8 @@ plano **não** fez: enrollment de TOTP em nenhum dos apps. - **Refresh token rotativo, e o TTL de 1h/8h.** Exigidos por LGPD-RT06. Na prática a sessão dura 15 minutos e a renovação depende da credencial mantida - em memória. + em memória. *(Superado em 2026-10-03: refresh token do ACS entregue; o app não + guarda mais a senha.)* - **Limite de tentativas por origem (IP).** O bloqueio é por conta, não por origem: um atacante com muitas matrículas válidas distribui as tentativas. - **Amplificação anônima no caminho da matrícula inexistente.** Toda tentativa @@ -728,7 +940,7 @@ defeito: 2. **A credencial vive até o processo morrer, sem caminho de limpeza.** Não há logout: o record fica acessível pela instância de `BackendClient` enquanto o app viver. É consequência direta de adiar o refresh token; some quando ele - existir. + existir. *(Fechado em 2026-10-03: a senha não é mais retida e há "Sair".)* 3. **`autofillHints` sem `AutofillGroup`** provavelmente não faz nada no aparelho: o gerenciador de senhas do Android precisa do grupo (e de `finishAutofillContext()`) para oferecer preenchimento. @@ -759,12 +971,12 @@ nascimento e o código de 6 dígitos recebido, e o servidor só emite a sessão - **A sessão do paciente passou a 1 hora** (`AuthEndpoint.patientSessionLifetime`), e não aos 15 minutos do padrão, aplicando LGPD-RT06. O motivo: o código OTP não pode ser reapresentado como a senha do ACS pode, então **não há renovação silenciosa** para o paciente — com 15 minutos - ele receberia um SMS novo a cada 15 minutos. O ACS continua com 15 minutos e renovação pela - credencial mantida em memória (RF07). A assimetria é deliberada e está escrita em + ele receberia um SMS novo a cada 15 minutos. O ACS continua com 15 minutos e renovação pelo + refresh token (desde 2026-10-03; antes, credencial mantida em memória, RF07). A assimetria é deliberada e está escrita em `spec/lgpd_design.md`. -- **Refresh token rotativo continua ausente** (LGPD-RT06) — é o que permitiria voltar o TTL do +- **Refresh token rotativo continua ausente para o paciente** (LGPD-RT06; o do ACS existe desde 2026-10-03) — é o que permitiria voltar o TTL do paciente aos 15 minutos sem quebrar a experiência. -- **MFA/TOTP ausente** (achado F5 de `spec/security_assessment.md`). +- **MFA/TOTP do ACS: entregue em 2026-10-02** (achado F5 de `spec/security_assessment.md`; ver a entrada "MFA do ACS" do app). - **Sem limite de tentativas por origem (IP).** O teto de 5 verificações é por desafio e o intervalo de 60 s é por CPF; nada olha de onde vem a chamada — o mesmo vale para o pedido de código em si, que não tem limite nenhum. @@ -988,6 +1200,8 @@ ambiente do delta que se quer medir. ### Um defeito do RF02 que esta entrega mediu — com dono (2026-09-19) +> **Resolvido:** `completeEnrollment` já emite `patientSessionLifetime`; `onboarding_endpoint_test.dart` prende o valor. Esta seção descreve o defeito como foi medido. + `onboarding_endpoint.dart:62` faz `issueToken(user)` — o default de **15 minutos** — para `role: UserRole.patient`, e o app consome esse token como sessão (`apps/patient/lib/core/network/backend_client.dart:259-273`). Ou seja: **há dois caminhos de @@ -1058,3 +1272,378 @@ ainda: revogar consentimento a partir deste painel (já existe em `RemindersScre SLA de 15 dias para pedidos que exigem intervenção humana — isso é processo, não código. Endurecimento de segurança do RF01 (rate limit por IP, canal de tempo, retenção de `otp_challenges`, refresh token) continua como já registrado acima, sem dono novo. + +## Direitos do titular no app paciente — LGPD-RF05 e LGPD-RF08 (2026-09-28) + +"Meus dados" deixou de ser só leitura. O paciente agora: + +- **concede ou revoga** por conta própria as duas finalidades opcionais + (`localReminders`, `segmentedPush`) — `patients.updateConsent` grava uma + linha nova assinada em `consent_logs` (append-only; a assinatura sai de + `signedConsentLog`, a mesma função do onboarding). Revogar pede confirmação + explícita. Revogar lembretes cancela na hora os lembretes agendados no + aparelho, e o espelho local (`ConsentPreferences`) passa a ser alinhado ao + servidor sempre que "Meus dados" carrega — o que também resolve o aparelho que + entrou pelo login OTP sem passar pelo onboarding; +- **pede exclusão** (`patients.requestDataDeletion`, idempotente enquanto houver + uma aberta) ou **correção** (`patients.requestDataCorrection`, texto livre de + até 500 caracteres, cifrado com AES-256-GCM em `data_subject_requests`), e vê + a situação e o prazo (15 dias) de cada pedido. + +O que **não** foi feito, de propósito: + +- **Ninguém atende os pedidos.** *(Resolvido em 2026-10-09, ver "Atendimento dos + pedidos do titular (#42)".)* Na época `status` só era escrito como `open`. O + prazo de 15 dias passa a ser exibido com destaque de vencido, mas nenhum + alarme ou expurgo o cobra sozinho. +- **`healthDataProcessing` não tem interruptor.** É a base legal do app inteiro, + inclusive do alerta de emergência; `updateConsent` recusa essa finalidade com + `DataRightsException` e aponta para o pedido de exclusão. Parar o tratamento + depois da exclusão atendida (LGPD-RF07, "em até 15 dias") depende do mesmo + atendimento acima. +- **Corrida de dois pedidos de exclusão simultâneos — resolvida (2026-09-29).** + A criação passou a ser atômica (`createDeletionRequestIfNoneOpen`, com + `pg_advisory_xact_lock` por titular, já que o Serverpod não declara `WHERE` em + índice e um índice único parcial não é possível); ver "Fechamento das + pendências do paciente". +- **`segmentedPush` tem leitor no código e o aviso chega a um emulador Android** (RF14, ver + "RF14: Gorush e FCM provados no emulador"): falta hospedar o Gorush fora do Compose local, + iOS/APNs e um teste em aparelho físico. + +## QR Code do onboarding e documentos legais (2026-09-29) + +Plano: `docs/superpowers/plans/2026-09-29-qr-onboarding-e-documentos-legais.md`, branch `fix/patient`. + +**RF02 de ponta a ponta.** O ACS ganhou "Mais › Convidar paciente": escolhe um paciente da própria microárea, gera o convite (`onboarding.generateEnrollmentToken`, que existia sem nenhum chamador) e mostra o QR Code (`qr_flutter`), com validade de 15 minutos e o código em texto para digitação. O paciente lê com "Ler QR Code com a câmera" (`mobile_scanner`, permissão `CAMERA`, câmera opcional na instalação), que preenche o mesmo campo do código; QR que não tem o formato do convite (43 caracteres base64url) é recusado sem sobrescrever o campo. O PRD citava `qr_code_scanner`, pacote descontinuado — trocado por `mobile_scanner`. + +**LGPD-RF18/RF19/RF10.** Termo de Uso e Política de Privacidade versão 2026.1 no app paciente (`lib/core/legal/legal_documents.dart`): resumo visual em passos (fluxo do dado), texto completo em seções, histórico de versões. Abrem antes do cadastro (link no login e no onboarding) e depois em "Mais › Privacidade e termos". O aceite é explícito, desmarcado por padrão e obrigatório; vira `ConsentPurpose.termsOfUse` em `consent_logs`, na mesma transação dos outros consentimentos, com `version = consentPolicyVersion`. `updateConsent` recusa alterá-lo. Um teste do app falha se a versão exibida divergir da carimbada pelo backend. + +**Ficou de fora, de propósito:** +- O texto 2026.1 precisa de revisão jurídica e dos dados reais do controlador e do encarregado (hoje genéricos: "Secretaria Municipal de Saúde do seu município"). +- Aviso de mudança com 15 dias de antecedência e novo aceite quando a versão mudar: só existe uma versão; não há mecanismo de reaceite no login. — **reaceite resolvido**, ver "Aceite do termo no login OTP" (o aviso de 15 dias segue pendente). +- Pacientes que entram por CPF + OTP (RF01) sem ter passado pelo onboarding nunca aceitaram o termo — o seed inclusive. Falta um aceite no primeiro login. — **resolvido**, ver "Aceite do termo no login OTP". +- Canal de dúvidas é "fale com o ACS ou a UBS", sem canal digital próprio. +- A página da câmera (`_CameraScanPage`) só roda no aparelho; os testes cobrem o fluxo com um leitor duplo. Validar no emulador com um QR gerado pelo app do ACS. +- Contagens de teste depois desta entrega: backend 329, paciente 167, ACS 168. + +## Aceite do termo no login OTP (2026-09-29) + +Plano: `docs/superpowers/plans/2026-09-29-aceite-do-termo-no-login-otp.md`, branch `fix/patient`. + +**O que existe.** `patients.acceptTermsOfUse` (só paciente) grava `termsOfUse` `granted` com `consentPolicyVersion` em `consent_logs`, pela mesma trilha assinada de `updateConsent`, que continua recusando esse propósito. No app, depois de `verifyOtp` o login (hoje consulta `hasAcceptedCurrentTerms`; ver "Fechamento das pendências do paciente") lê o status; se a linha mais recente de `termsOfUse` não for `granted` na versão `legalDocumentsVersion` (`needsTermsAcceptance`), abre `TermsAcceptanceScreen`. Isso cobre pacientes do seed e de OTP sem onboarding, e o reaceite quando a versão mudar. A sessão do onboarding de 1 hora, que estava no escopo pedido, já estava no código; só os comentários e docs foram corrigidos. + +**Ficou de fora, de propósito:** +- O aceite **não é portão duro**: "Agora não" e uma falha de `hasAcceptedCurrentTerms` entram direto, porque o alerta de urgência nunca pode ficar atrás de uma tela de aceite. Consequência: quem pula pode seguir sem aceite registrado, e o aviso volta no próximo login. +- Aviso de mudança com 15 dias de antecedência: feito depois (ver "Menores do push e aviso de 15 dias"); revisão jurídica do texto segue pendente. +- ~~`acceptTermsOfUse` grava uma linha nova a cada chamada~~ — resolvido na rodada "Menores adiados e push do paciente": o aceite é idempotente e atômico. +- Contagens de teste depois desta entrega: backend 333, paciente 182, ACS 168. + +## Fechamento das pendências do paciente (2026-09-29) + +Plano: `docs/superpowers/plans/2026-09-29-fechamento-de-pendencias-do-paciente.md`, branch `fix/patient`. + +**O que foi fechado:** +- O login por OTP consulta `patients.hasAcceptedCurrentTerms` (um `bool`) em vez de ler o painel "Meus dados" inteiro: menos dados no aparelho e nenhuma linha de auditoria de leitura por login. A consulta tem teto de 3 s. +- `acceptTermsOfUse` ficou idempotente: com o aceite vigente já gravado, devolve a linha existente e não cresce o histórico. +- Pedidos de exclusão simultâneos deixam uma só linha aberta (lock por titular). A repetição agora também é auditada, com `result: repeated`. +- A linha de auditoria de consentimento passou a levar o `resourceId` da linha gravada. +- Câmera do onboarding: o toque duplo em "Ler QR Code" abre uma leitura só, e o aviso de QR inválido some quando a pessoa volta a digitar. +- O texto de uma correção que falhou volta ao reabrir o diálogo; é descartado ao enviar ou cancelar. +- ACS: o convite expirado some da tela e pede um novo. + +**Ficou de fora, de propósito:** +- `ipHash` e `userAgent` `nao-aplicavel-painel-titular` em `consent_logs` seguem como estão: é um marcador deliberado de ausência (o request HTTP já é auditado em `audit_logs`), documentado em `signed_consent_log.dart`. Guardar o IP do titular ali é uma decisão de privacidade, não um conserto. +- A prova da corrida cobre o store ORM, não o endpoint: o endpoint grava `audit_logs`, que tem FK para `users` e cadeia de hash, e a limpeza manual do grupo sem rollback quebraria os dois. +- Backoffice que atende os pedidos e o push (RF14) seguem pendentes; o aviso de 15 dias foi feito depois, ver "Menores do push e aviso de 15 dias". +- Contagens de teste depois desta entrega: backend 346, paciente 182, ACS 172. + +## Menores adiados e push do paciente (2026-09-29) + +Plano: `docs/superpowers/plans/2026-09-29-menores-adiados-e-push-do-paciente.md`, branch `fix/patient`. + +**O que foi fechado:** +- `patients.acceptTermsOfUse` é atômico: `recordConsentUnlessCurrent` grava sob advisory lock por titular, então duas chamadas simultâneas deixam uma linha só (teste de corrida contra Postgres real, três chamadas). +- Os advisory locks por titular usam a forma de duas chaves (`lockPerSubject`, namespaces em `subject_lock.dart`), que não divide espaço com a chave única da cadeia de auditoria. +- O diálogo de correção não fecha mais ao tocar fora (`barrierDismissible: false`); só "Cancelar" descarta o rascunho. Dois testes de caracterização entraram: cancelar limpa o rascunho e o botão de ler QR volta a funcionar depois que o leitor lança. +- **RF14, lado do paciente:** tabela `push_tokens`, `devices.registerPushToken` (só paciente, só com o consentimento `segmentedPush` vigente, uma linha por token, o token de outro titular troca de dono) e revogação de `segmentedPush` apaga os tokens do titular. No app, `PushTokenSource` (padrão `NoPushTokenSource`, sem Firebase) registra o aparelho depois do login, do onboarding e ao conceder "Avisos da equipe", em silêncio: recusa ou falha nunca atrasa a home nem o alerta. + +**Achados do revisor final, corrigidos:** o consentimento era lido fora da transação do registro, então registrar × revogar em paralelo deixava um token ligado a um titular que já tinha revogado; hoje `registerIfConsented` lê e grava sob um lock por titular, e a revogação (`deleteAllFor`) espera o mesmo lock (teste de corrida de 40 iterações contra Postgres real). E um aparelho apresentado por quem não consentiu perde o vínculo do titular anterior, porque o token prova que o aparelho está na mão de outra pessoa. + +**Ficou de fora, de propósito:** +- ~~Revogar grava o `denied` e apaga os tokens em duas operações~~ — resolvido na rodada "Menores do push e aviso de 15 dias": as duas coisas são uma transação só. +- `devices.registerPushToken` só grava auditoria na **troca de dono** do token (`push_token`); registrar e repetir não, porque a revogação já deixa `consent_log`. +- ~~Sem teto de tokens por titular; sem teste da fonte que nunca completa; `PushTokenScope.of` sem `maybeOf`~~ — resolvidos na rodada "Menores do push e aviso de 15 dias". +- Envio segmentado, tela de avisos do ACS e captura do token nativo: **o código do envio e a tela foram feitos depois**, ver "RF14: envio de avisos segmentados com Gorush". Depois disso o lado nativo Android do token e a entrega real foram provados no emulador (ver "RF14: Gorush e FCM provados no emulador"); continuam pendentes o Gorush hospedado fora do Compose local e iOS/APNs (§3.2, revisado em 2026-09-29). +- Apagar tokens ao atender o pedido de exclusão: pertence ao backoffice que atende os pedidos, ainda inexistente. +- ~~Aviso de 15 dias de mudança dos termos~~ — feito na rodada "Menores do push e aviso de 15 dias" (a agenda está vazia); revisão jurídica do texto 2026.1 segue pendente. +- O erro de um pedido em "Meus dados" aparece no topo da lista, fora da tela para quem rolou até o botão (anterior a esta rodada). +- Baseline do `dart analyze` do backend: 44 infos (eram 41), os três novos são o mesmo `prefer_initializing_formals` que o resto dos serviços já tem. +- Contagens de teste: backend 359, paciente 190, ACS 172. + +## Revisão do RF14: Gorush no lugar do Firebase (2026-09-29) + +Só especificação; nenhum código mudou. `spec/stack.md`, `spec/PRD_system.md`, `spec/lgpd_design.md`, `spec/validation_report.md` e o §3.2 de `docs/superpowers/specs/2026-09-16-decisoes-produto-pos-validacao.md` passaram a descrever o envio por **Gorush** (auto-hospedado), com segmentação em SQL restrita a quem consentiu. + +- A tabela continua `push_tokens` (já existe); o nome `user_push_tokens` não foi adotado. +- **Riverpod só no push do paciente** (decisão de 2026-09-29): `flutter_riverpod` na captura e no registro do token; o resto segue por `InheritedWidget`. +- **O Gorush não elimina as credenciais:** é relé para FCM/APNs, então Android ainda precisa de uma credencial FCM e iOS de uma chave APNs. A pendência muda de "projeto Firebase" para "hospedar o Gorush e provisionar credenciais". +- Em aberto: o pacote que captura o token nativo em cada plataforma (`firebase_messaging` ou canal nativo no Android; pacote leve de APNs no iOS). +- O plano `2026-09-29-menores-do-push-e-aviso-de-mudanca-dos-termos.md` não é afetado: não toca envio nem provedor. + +## RF14: envio de avisos segmentados com Gorush (2026-09-29) + +Plano: `docs/superpowers/plans/2026-09-29-rf14-gorush-avisos-segmentados.md`, branch `fix/patient`. + +**O que existe:** +- **Infra:** serviço `gorush` no `docker-compose.yml` sob o perfil `push` (sem porta publicada), `GORUSH_URL` no `AppConfig` (vazio desliga o envio), `infra/docker/gorush/` com `config.yml` e README. +- **Backend:** `GorushClient` (`dart:io`, 5 s, poda de tokens inválidos), `NoticeService` e `notices.sendSegmented` (só ACS; microárea do token; consentimento `segmentedPush` mais recente por titular; filtro opcional de crônicos; 0 destinatários não chama o Gorush; auditoria `community_notice` sem o texto). +- **ACS:** `NoticesScreen` real (título 60, mensagem 240, público, resultado "X de Y pacientes"). +- **Paciente:** `pushTokenSourceProvider` (único uso de `flutter_riverpod`, ^3.4.3) e `NativePushTokenSource` sobre o canal `sinalacs/push_token`. +- Contagens de teste: backend 390, paciente 197, ACS 178. + +**O que NÃO estava provado nessa rodada (superado pela seção "RF14: Gorush e FCM provados no emulador", abaixo):** +- ~~Nenhum teste fala com um Gorush ou com o FCM/APNs de verdade~~ — provado em 2026-09-30 no emulador Android. +- ~~O lado nativo do canal (Kotlin/Swift) não existe~~ — o lado Android existe; o iOS (Swift) continua inexistente. +- As credenciais FCM (conta de serviço) e APNs (chave `.p8`) são da organização e não estão no repositório; o `config.yml` foi escrito sem conferir os nomes das chaves contra uma tag fixa do `appleboy/gorush`, e o Compose usa `:latest` — _superado em 2026-09-30: imagem fixada em `1.22.0` e configuração conferida contra o FCM real_. +- O `async` do app do paciente subiu de 2.11.0 para 2.13.1 por causa do Riverpod 3. + +**Fora de escopo:** migrar o resto do paciente para Riverpod, salvar o token no SQLite local, histórico ou agendamento de avisos. + +**Achados do revisor final do RF14, corrigidos:** timeout do envio agora é "resultado desconhecido" (mensagem manda conferir antes de reenviar, auditoria `unknown`), porque o Gorush com `sync: true` pode entregar depois do limite de 5 s e o "tente de novo" duplicaria o aviso; "aceitos" passou a ser alvos menos falhas, sem confiar em `counts`; `MismatchSenderId` deixou de apagar tokens (é erro de configuração do servidor e esvaziaria a microárea) e a string de erro do FCM v1 entrou; `hide_messages: true` no `config.yml`. **Deferido:** `HttpClient` sem `close()`, resposta JSON de forma inesperada fora do erro tipado, desempate por timestamp igual, `INNER JOIN` com `patients`, Gorush em loop sem credenciais, auditoria `granted` com 0 aceitos, nome do teste de auditoria que promete mais do que verifica. + +## Menores do push e aviso de 15 dias (2026-09-29) + +Plano: `docs/superpowers/plans/2026-09-29-menores-do-push-e-aviso-de-mudanca-dos-termos.md`, branch `fix/patient`. + +**O que foi fechado:** +- A revogação de `segmentedPush` grava o `denied` e apaga os tokens do titular numa transação só, sob o lock por titular (`recordConsentRevokingPush`); um teste com falha injetada depois de apagar os tokens prova que nada fica pela metade. +- A troca de dono de um token de push é auditada (`push_token`, sem o token); cada titular fica com no máximo 10 tokens (o mais antigo sai); a regra do aceite vigente existe num lugar só (`isCurrentAcceptance`). +- **Aviso de 15 dias (LGPD-RF18):** `TermsChangeSchedule` (recusa vigência a menos de 15 dias da publicação), `patients.termsChangeNotice` e um cartão dispensável na home do paciente. A agenda real (`upcomingTermsChange`) está **vazia**: nenhuma mudança de termos está anunciada, então o cartão nunca aparece em produção até alguém agendar uma. O canal do aviso é só dentro do app (push ainda não chega a aparelhos; SMS é só de OTP). +- `PushTokenScope.maybeOf` e o teste da fonte de token que nunca completa. +- Contagens de teste: backend 411, paciente 207, ACS 178. Analyze do backend em 51 infos. + +**Ficou de fora, de propósito:** +- O contraste do texto do cartão não foi medido contra a superfície em que ele renderiza. +- O caso "agenda ativa" só é provado no serviço e no app com um backend falso; nenhum teste de integração exercita uma agenda ativa porque a constante do repositório é `null`. +- O cartão só aparece quando a home abre; quem já está com o app aberto não o vê até reabrir. + +**Achados do revisor final, corrigidos:** o cartão de aviso descia o botão de pânico (e, chegando de forma assíncrona, o moveria sob o dedo), então **não aparece mais na aba de urgência** (teste em tela 360x640); a regra dos 15 dias usava `assert`, que não roda em release, e passou a `ArgumentError`; `spec/lgpd_design.md` ainda listava o aviso como pendente. **Deferido:** poda do teto por `id` sem `userId` (corrida rara com troca de dono), empate de `updatedAt` na poda, injeção de "sem agenda" no serviço, contraste do texto/botão/ícone de fechar do cartão, auditoria da troca de dono sem o dono anterior, `Semantics(header)` do cartão. **Limite conhecido:** a regra dos 15 dias compara datas declaradas; um `publishedAt` retroativo passa. + +## Menores do RF14 e texto novo dos termos (2026-09-30) + +Plano: `docs/superpowers/plans/2026-09-30-menores-do-rf14-e-texto-novo-dos-termos.md`, branch `fix/patient`. + +**O que foi fechado:** +- **Registro de token:** a poda do teto apaga por id **e** titular e poupa o token recém-gravado (um relógio que voltou o faria parecer o mais antigo); o consentimento mais recente desempata por `id` (no registro, na consulta do login e na gravação do aceite); a troca de dono audita também o **dono anterior** (`push_token`, `result = lost`); `DataSubjectRightsService` aceita um leitor de agenda injetável (`termsChangeReader`). +- **Envio de avisos:** `GorushClient` encerrável e um só por processo; corpo 2xx ilegível é "resultado desconhecido" (nunca "tente de novo"); `logs` fora de forma é tolerado; falha ao apagar tokens inválidos depois do envio não vira erro; auditoria `not_delivered` quando nenhum aparelho aceitou; `LEFT JOIN` com `patients` (quem não tem linha clínica recebe "para todos" e só fica fora do filtro de crônicos). +- **Texto novo dos termos durante os 15 dias (LGPD-RF18):** `UpcomingLegalDocuments`/`upcomingLegalDocuments` (hoje `null`), `UpcomingDocumentsScope` e "Ler o texto novo" no cartão, oferecido só quando o app carrega exatamente a versão que o servidor anunciou. Um teste (`upcoming_legal_documents_test.dart`) lê a agenda do backend e falha se as duas pontas divergirem. +- Cartão de aviso: `Semantics(header)` no título e os três pares de contraste que o tema gera (texto, botão e ícone de fechar) medidos contra o `Card`. +- Contagens de teste: backend 423, paciente 219, ACS 178. Analyze do backend em 51 infos. + +**Ficou de fora, de propósito:** +- Nenhuma mudança real foi agendada: `upcomingTermsChange` e `upcomingLegalDocuments` seguem `null`, então o cartão e o texto novo só são provados com agendas de teste. +- Um app antigo, sem o texto embarcado, vê só "Ler os termos atuais" durante os 15 dias. Não há marcação do que mudou (diff) nem aviso a quem já está com o app aberto. +- A amarração backend×app só é provada por mutação temporária: sem agenda real, o teste passa com os dois `null`. +- O teste "a poda só apaga tokens do próprio titular" é de caracterização: em execução sequencial ele não reproduz a corrida. +- ~~Lado nativo do token de push, credenciais FCM/APNs e Gorush real seguem pendentes.~~ — superado em 2026-09-30 para Android (ver "RF14: Gorush e FCM provados no emulador"); iOS/APNs, aparelho físico e Gorush hospedado seguem pendentes. +- O atendimento do pedido de exclusão (backoffice, ainda inexistente) precisa apagar `push_tokens` e gravar `denied` em `segmentedPush`: `requestDataDeletion` hoje não faz nenhum dos dois, e o `LEFT JOIN` com `patients` deixa de servir de rede de segurança quando a exclusão apagar a linha clínica e mantiver `users`. + +**Achados do revisor final, corrigidos:** o detalhe do texto novo (`LegalDocumentScreen`) dizia "vigente desde" e o histórico chamava a versão futura de vigente, 15 dias antes da data — agora diz "passa a valer em ", com teste; o teste de amarração backend×app passava em silêncio quando não entendia a agenda (aspas duplas, constante, `;` no resumo, comentário enganoso) — agora o leitor tem três estados e **lança** em vez de tratar "não entendi" como "sem agenda". **Deferido:** a linha `lost` grava o dono anterior como `userId` de um `write` que ele não fez (documentado em `spec/lgpd_data_audit.md`); o registro recusado apaga o vínculo do dono anterior sem rastro; o desempate por id é estável mas arbitrário (e o painel "Meus dados" ordena só por timestamp); corrida da poda contra outra troca de dono pode gerar um 500 no registro; `catch (_)` sem log na poda de tokens do envio; `on StateError` no `GorushClient` cobre mais do que o `postUrl`; `failed` inflado por `failed-push` repetido do mesmo token; o `FilledButton.tonal` "Ler o texto novo" sem contraste medido; a poda sem desempate final por `id`. + + +## RF14: Gorush e FCM provados no emulador (2026-09-30) + +Plano: `docs/superpowers/plans/2026-09-30-gorush-fcm-e2e-emulador.md`, branch `fix/patient`. **Provado no `emulator-5554` (Android 16, Google Play, projeto Firebase `sinal-acs`, Gorush 1.22.0 no Compose local), pelo `scripts/qa/push_e2e.sh --negativos`, que saiu com 0:** + +- Um aviso enviado pelo ACS (`notices.sendSegmented`) atravessa backend → Gorush → FCM → Play Services e **aparece na bandeja do aparelho** com o título e o texto enviados (`recipients=1 accepted=1`, auditoria `granted`). +- O app obtém um **token FCM real** pelo canal `sinalacs/push_token` (`MainActivity.kt`, `firebase-messaging`, plugin do Google Services aplicado **só** quando `google-services.json` existe: sem o arquivo o APK compila e o app degrada para "sem push"). +- **Casos negativos observados contra o FCM real:** token falso podado e token real mantido; linha `ios` mantida (não é erro de token) e sem derrubar o Android; Gorush parado falha em ~3 s com "Tente de novo" e nenhum token apagado; revogar apaga os tokens e o envio seguinte tem `recipients=0`; app desinstalado devolve `NotRegistered` e o token é podado. + +**O que a execução real corrigiu (nenhum teste com servidor falso pegaria):** +- `ios.enabled: true` sem a chave APNs derrubava o boot do Gorush (código 1), e `access_log/error_log: "-"` calava todo log: o erro do boot ficava invisível. +- O Gorush devolve o token **mascarado** por padrão (`log.hide_token`), então a poda nunca casava; o `config.yml` usa `hide_token: false` e o cliente só poda tokens que ele enviou. A string de erro do FCM v1 (`The registration token is not a valid FCM registration token`) não estava na lista. +- O `GorushClient` tratava todo estouro de tempo como "resultado desconhecido": com o Gorush parado isso dizia "alguns pacientes podem já ter recebido" e nada tinha sido enviado. A conexão agora tem tempo próprio (2 s): estourar ali é "inacessível, tente de novo". +- O ambiente do shell trazia `GORUSH_CREDENTIALS_DIR` apontando para um **arquivo**; o Compose o prioriza sobre o `.env` e montou o arquivo como `/credentials` (o Gorush caía). `push_e2e.sh` ignora o valor do ambiente, com aviso. + +**Limites (o que continua sem prova):** +- **iOS/APNs:** sem app iOS, sem chave APNs, `ios.enabled: false`. **`accepted` superconta tokens `ios` enquanto o iOS está desligado** (o Gorush os descarta sem log e eles entram como aceitos): sem app iOS não há token `ios`, então só aparece em teste. +- **Aparelho físico:** só emulador. A entrega com o celular bloqueado, em modo economia de bateria ou sem Play Services não foi exercida. +- O registro foi feito por `developmentLogin` dentro de um teste de integração, **não** pelo fluxo de tela (login OTP → `registerPushDevice`), que só tem testes de widget com backend falso. O app também não trata renovação de token (`onNewToken`) nem abre tela ao tocar na notificação. +- O teste de registro **segura o app instalado** por `PUSH_HOLD_SECONDS`: o `flutter test` desinstala o app ao terminar e o token morre. Sem a permissão `POST_NOTIFICATIONS` (Android 13+) o token registra mas o aviso não aparece; o teste concede a permissão por `adb`, e no app real quem a pede é o `main.dart`. +- Com FCM em primeiro plano a mensagem de notificação não vai para a bandeja: o teste manda o app para segundo plano. +- `hide_token: false` faz o `docker logs` do Gorush imprimir tokens de aparelho; o teste deixou um token **já morto** aparecer na saída. Em produção, restringir o acesso ao log do Gorush. +- Gorush hospedado fora do Compose local, rotação da chave da conta de serviço e a chave em modo `644` em `/opt/apps_android/` (cópia fora do repositório, legível por outros usuários da máquina). + +**Achados do revisor final do e2e, corrigidos:** +- **Auto-init do FCM:** com o `google-services.json` no build, o `firebase-messaging` gerava o token e falava com o Google em **toda abertura do app**, antes do login e sem consentimento `segmentedPush`. O manifesto agora traz `firebase_messaging_auto_init_enabled=false` (teste `android_push_manifest_test.dart` lê o manifesto; o APK foi conferido com `aapt2`; o `getToken()` explícito **continua trazendo token** com o auto-init desligado, provado no emulador, e a entrega ponta a ponta repetida passou). +- **`e2e.sh --full` vermelho para quem não tem as credenciais:** `push_native_token_test` e `push_register_test` só rodam com `--dart-define=PUSH_E2E=1` (o `push_e2e.sh` passa); sem o define são pulados. Um bug meu apareceu ao provar isso no emulador: `bool.fromEnvironment` só aceita o texto `true`, então `=1` teria **pulado o próprio teste do e2e**; a comparação passou a ser `String.fromEnvironment('PUSH_E2E') == '1'`. +- Docs que contradiziam o código: `spec/lgpd_design.md` (parágrafo truncado que dizia "nada envia") foi reescrito; dois resquícios deste arquivo foram marcados como superados. + +**Lacuna que continua aberta (LGPD):** o aparelho ainda pede o token ao FCM depois de **todo login**, mesmo sem consentimento; o servidor recusa e não o guarda, mas o Google já foi contatado. Fechar isso exige o app conhecer o consentimento antes de pedir o token (espelho local, como `localReminders`). **Deferido:** `hide_token: false` tem alternativa que não imprime token no log (casar a máscara por comprimento + sufixo só quando única) e o comentário "não liga a nenhuma pessoa sem o banco" subestima o risco (o token é identificador pseudônimo e o Google o liga ao aparelho); usar `HttpClient.connectionTimeout` em vez do `timeout` sobre `postUrl`; `accepted` superconta `ios` e um cliente adulterado pode registrar `ios`; `push_e2e.sh` apaga `push_tokens` do dev e termina com o consentimento `granted`, e o `kill` pode deixar o `flutter test` órfão; `appleboy/gorush:1.22.0` por tag e não por digest; o build da CI com `firebase-messaging` no classpath ainda não foi observado (a branch não tem execução de CI). + + +## Token de push só com consentimento (2026-09-30) + +Plano: `docs/superpowers/plans/2026-09-30-finalizacao-app-paciente.md`, branch `fix/patient`. + +- `registerPushDevice` pergunta ao servidor (`patients.hasGrantedConsent`, `bool`, sem auditoria de leitura, sem ler o painel) antes de falar com o FCM e fecha na dúvida (sem consentimento vigente, falha ou mais de 3 s; resposta tardia descartada). Onboarding (só com a caixa marcada) e o interruptor de "Meus dados" registram sem perguntar. Fecha a lacuna LGPD de "pede o token depois de todo login". +- **Revisão independente (subagente, 2026-09-30):** a primeira versão lia `myData` a cada login (dossiê decifrado + linha de auditoria falsa, inclusive onde nunca viria token); trocada pelo endpoint novo. Também: teste de que conceder não consulta de novo, teste do teto de 3 s (a resposta tardia é descartada), onboarding sem leitura à toa, comentário da constante no lugar certo. **Deferido:** empate exato de `timestamp` em `consent_logs` (o painel ordena só por tempo; o endpoint novo usa o desempate do servidor); revogação no intervalo entre resposta e token (ver `spec/lgpd_design.md`). +- Testes: paciente 233, backend 433. +- **Provado em 2026-09-30 no `emulator-5554` (AVD `Medium_Phone`):** `tool/live_check.dart` OK; `e2e.sh --keep --emulator` (smoke paciente e ACS) OK; `backend_connection_test` 7/7; `push_e2e.sh --negativos` saiu com 0 (entrega, token falso podado, Gorush parado, revogação). `ci_invariants.sh` OK e `flutter build apk --debug` sem `google-services.json` OK. A conferência do login OTP de paciente sem consentimento é coberta por teste de widget, não por execução no aparelho. +- **Segue aberto:** iOS/APNs, aparelho físico, Gorush hospedado, revisão jurídica dos termos 2026.1, backoffice que atende exclusão/correção, refresh token, menores deferidos do RF14, endpoint leve de consentimento (evita a auditoria de leitura por login). + + +## Teste completo do paciente contra o banco de teste (2026-09-30) + +Plano: `docs/superpowers/plans/2026-09-30-teste-completo-paciente-banco-de-teste.md`, branch `fix/patient`. + +- **Stack de e2e** (`docker-compose.e2e.yml` + `scripts/qa/e2e_stack.sh up|seed|down|psql`): o mesmo backend e o Gorush apontados para `sinalacs_e2e` dentro do `postgres-test` (efêmero), com `ENABLE_DEV_LOGIN=false`. Os seeds de desenvolvimento não sobem. O container do `serverpod` tem nome fixo: a stack de e2e e a de desenvolvimento não coexistem. +- **Dados fixos substituídos:** UUIDs `0000…000N` e CPFs do seed, `developmentLogin` (paciente e ACS), `seedMicroAreaId`. Em vez deles, fixtures geradas por execução (`bin/seed_e2e_fixtures.dart`, 7 testes do gerador; backend 440, paciente 238), login do paciente por OTP real (relé `scripts/qa/otp_relay.py`) e do ACS por matrícula e senha. Sem manifesto os helpers caem no login de desenvolvimento (a CI segue igual). **Não mudam:** os widget tests com `FakePatientBackend` (herméticos), `development.sql` e `developmentLogin` (a stack de desenvolvimento e o `android-e2e` dependem deles). +- **Provado em `emulator-5554`:** `full_journey_test` 3/3 (código errado não entra e o certo entra; termos; triagem vermelha; alerta; status; "Meus dados" só do próprio paciente; o ACS da microárea lista os dela e não o da outra), `backend_connection_test`, e `push_e2e.sh --e2e-db --negativos` (aviso na bandeja pelo Gorush e FCM reais, token falso podado, Gorush parado, revogação), com o banco de desenvolvimento idêntico antes e depois. O banco de e2e mostrou `denied_code`, `otp_requested`, um alerta do paciente crônico e **uma** leitura de `patient_data_overview` (só "Meus dados"; o login não lê mais o painel). +- **O que a execução real achou:** o teste de jornada errou por dois detalhes do teste (faltava `terms_gate_checkbox`; o texto é `Risco: Vermelho`); um manifesto esquecido fazia o `live_check` tentar OTP contra a stack de desenvolvimento (agora só com `E2E_FIXTURES_FILE`, e `down` apaga o manifesto); o OTP impõe 60 s entre pedidos do mesmo paciente, o que pediu um paciente por consumidor e uma espera no `push_e2e.sh`. +- **Limites:** só emulador; o GPS é um leitor fixo (o diálogo de permissão do sistema não é alcançável pelo `flutter test`); a CI não roda isto (precisa do relé e, para o push, das credenciais do FCM); migrar o `android-e2e` para a stack de e2e é outro plano. +- **Flaky achado e corrigido:** na 1ª execução completa, `push_e2e.sh` falhou com "título não está na bandeja" (um `sleep 10` fixo contra a entrega do FCM); passou a esperar até 45 s pelo texto na bandeja. Depois disso `patient_full_e2e.sh` saiu com 0 e `--sem-push` também; o banco de desenvolvimento ficou idêntico. +- **Revisão independente do e2e (subagente):** 0 Critical, 4 Important, corrigidos — (I1) a senha do ACS ia no `--dart-define` (argv e APK): o manifesto do aparelho não traz mais o bloco `acs` e o território virou `tool/territory_check.dart` no host; (I2) o `down` apaga o manifesto antes do `drop` e o cleanup avisa se falhar; (I3) `up`/`down` avisam que o `sinalacs-serverpod` da stack de desenvolvimento é substituído e como voltá-lo; (I4) o corte do código do OTP usa o relógio do host (`/now`). A reexecução achou ainda um relé órfão ocupando a porta 8765 e devolvendo código velho: os runners agora recusam subir nesse caso. **Adiado (Minor):** `.pyc` versionado em `scripts/qa/__pycache__`; relé sem checagem de `Host`; o seeder confia em host/porta do ambiente; manifesto criado com umask antes do `chmod 600`; `PGPASSWORD` no argv do `docker exec`; CPFs sintéticos com DV válido podem coincidir com CPFs reais; o teste não prova que o erro de código não gasta outro pedido; `na_bandeja` sob `pipefail`; `up` depende de certificados/mosquitto já inicializados e esconde a causa. +- **Minors do e2e fechados (2026-09-30):** `.pyc` fora do git; relé recusa `Host` alheio (403) e se encerra em 45 min; seeder exige host local e a porta 9090 (`e2eSeedRefusal`); manifesto criado 0600/0700 antes de receber o conteúdo; `PGPASSWORD` herdado do ambiente; `up` mostra a causa da falha e tem timeout; `na_bandeja` sem SIGPIPE; o relé conta os códigos (`/count`) e a jornada exige exatamente um pedido; risco do CPF sintético registrado em `spec/lgpd_design.md`. Testes: backend 440→445, paciente 239→240. + + +## Credenciais do FCM e Gorush no CI (2026-09-30) + +Plano: `docs/superpowers/plans/2026-09-30-ci-credencial-fcm.md`, branch `fix/patient`. + +- `scripts/ci/decode_secret_file.sh` decodifica `FCM_CREDENTIALS_BASE64` (arquivo `0600` em `$RUNNER_TEMP` + `GOOGLE_APPLICATION_CREDENTIALS`) e `GOOGLE_SERVICES_JSON_BASE64` (`apps/patient/android/app/google-services.json`, conferindo o pacote), sempre por `env:` do passo, sem imprimir conteúdo, sem sobrescrever nem apagar arquivo de dev fora do CI, e sem falhar em PR de fork (secret ausente = `::notice`). 53 asserções; 4 mutações mortas. +- `scripts/qa/ci_push_e2e.sh` instala a chave no diretório que o Gorush monta e roda o `push_e2e.sh` (caminho feliz) por último no `run_android_e2e.sh`, propagando o resultado. O emulador do CI passou para `target: google_apis` (a imagem padrão da action é AOSP, sem Play Services). +- `ci_invariants.sh` ganhou `check_credenciais_fcm` (9 grupos); 12 mutações do `ci.yml` são reprovadas; os três testes de shell rodam no `workflow-lint`. +- **Não provado:** o primeiro run no runner. A imagem `google_apis` não foi exercitada aqui (o emulador local é a Google Play); se o FCM não entregar token, trocar para `google_apis_playstore` (ver o plano, Task 5). O push adiciona ~6 min ao `android-e2e` (limite 60 min). +- **Política em aberto:** qualquer PR de dentro do repositório recebe os secrets; um autor com escrita poderia imprimi-los editando o `ci.yml`. Saída: Environment do GitHub com revisores obrigatórios. +- **Revisão independente (CI do FCM):** 1 Critical e 1 Important, corrigidos. (C1) o Gorush (uid 1000) não lia a chave `0600` do usuário `runner` (uid 1001): o serviço roda agora com `GORUSH_UID/GORUSH_GID` (provado no Docker real: uid do dono = `healthy`; outro uid = `permission denied`). (I1) as credenciais existiam em disco durante ações de terceiros: a decodificação foi para logo antes do E2E e o guarda exige que não haja `uses:` entre elas e o E2E (13 mutações). **Adiado (Minor):** `check_credenciais_fcm` não cobre `env:` de workflow, `with:` de ação nem `toJSON(secrets)`; a mutação de ordem só testa a ausência do passo; `set -e` com falha no `trap ... down` troca o código de saída (já era assim); `SHELLOPTS=xtrace` herdado imprimiria o secret (falta `set +x`); `${!nome}` executa código se o nome da variável for hostil (hoje são constantes); `--cleanup-temp` compara só o prefixo; o `tmp` pode sobrar se `chmod`/`mv` falharem; o cabeçalho de `push_e2e.sh` ainda diz "NÃO roda na CI"; `project_id` no log público; o push soma ~6 min ao limite de 60. +- **Provado no runner (2026-09-30, run `36790685752`, commit `32e6e88`):** 9/9 jobs verdes. No `android-e2e`: as duas decodificações passaram; o emulador `google_apis` deu token FCM; o Gorush subiu com o uid do runner; `push_e2e.sh` saiu com `chave ok para o projeto sinal-acs`, `tokens no banco: 1`, `recipients=1 accepted=1` e `OK — o aviso chegou ao emulador`. O log não contém chave, `private_key` nem `api_key` (0 ocorrências). Isto fecha o risco da imagem `google_apis` e o do uid do container. +- **A 1ª execução do mesmo run falhou** em "FCM_CREDENTIALS_BASE64 não é um base64 válido": o valor cadastrado estava errado (o guarda recusou e nada vazou). Refeito o secret, a re-execução (`gh run rerun --failed`) passou. O erro de base64 agora traz diagnóstico sem valores (tamanho, alfabeto, JSON em claro, base64url). +- **Minors da revisão do CI do FCM fechados (2026-09-30):** (M1/M2) o `check_credenciais_fcm` varre o texto bruto (cada secret só 1x, no `env:` do passo que o decodifica: cobre `env:` de workflow, `with:`, `toJSON(secrets)`, `secrets[...]`), exige o passo do E2E e a limpeza DEPOIS dele, e a mutação de ordem agora move o bloco de verdade (20 mutações); (M3) `trap ... down || true` preserva o exit do script; (M4–M7) `decode_secret_file.sh`: `set +x` primeiro, nomes de variável validados (`[A-Za-z_][A-Za-z0-9_]*`), `--cleanup-temp` normaliza o caminho (`..`) e um `trap` apaga o temporário se algo falhar (67 asserções); (M8) cabeçalho do `push_e2e.sh` corrigido; (M9) o `project_id` não é impresso no CI; (M10) `set -m` + `parar_arvore` matam o `flutter test` (neto do subshell), provado no emulador sem órfãos, e o tempo medido no run `36790685752` foi 13 min 19 s de 60 (passo do E2E 8 min 24 s). Sem Minors abertos deste trabalho. + +- **Permissão das chaves privadas de dev (2026-10-05).** Medido: as `*.key` de `runtime/` pertencem ao root (uid 0, criadas pelo `init.sh` num contêiner root) e são lidas por **outro uid**: o backend roda como uid 1000 e lê `backend.key`; o `sync_dev_ca.sh` roda como o usuário do host (uid 1000) e lê `acs-area-12.key`. Por isso `server.key`, `backend.key` e `acs-area-12.key` **ficam em 644** (600 quebraria a cópia dos assets e o cliente MQTT do backend); a máquina é de um usuário e `runtime/` é gitignored. Apertado: `ca.key` das duas CAs → **600**, porque só os `init.sh` (root) a leem. Prova: `scripts/qa/dev_key_permissions_test.sh` (RED em 644, GREEN depois de reaplicar os dois `init.sh` com `docker compose up -d --force-recreate mosquitto-init traefik-init`, sem apagar o `runtime/`), `sync_dev_ca.sh` copiou os assets, `mtls_invariants.sh` e `tls_invariants.sh` verdes. Em produção o par vem de outro caminho (CA própria, nunca estes arquivos). + +- **Alertas sob a reautenticação e `sync_dev_ca.sh` (2026-10-05).** (1) Confirmado aberto por teste (o aviso não existia): com a tela de reautenticação por cima, a fila e o feed seguiam vivos, mas nada dizia que havia alerta vermelho aguardando. `PendingAlertsBanner` (`apps/acs/lib/app/pending_alerts_banner.dart`) passa a ficar no topo da rota de reautenticação, com a contagem de alertas não confirmados (`AlertQueue.unacknowledgedCount`), `liveRegion` e `redOnSurface` sobre `cardTheme.color`; o formulário continua rolável e tocável. Provado em teste de widget e no shell (`session_reauth_test.dart`, caso (e)); **não** provado no emulador, porque a reautenticação com MFA expirada exige a stack em modo MFA. (2) `sync_dev_ca.sh` agora compara a chave pública da chave do cliente com a do certificado antes de copiar qualquer asset ("nada foi copiado" segue verdadeiro) e apaga os seis `*.tmp` por `trap EXIT` (falha no meio da publicação e Ctrl-C); `scripts/dev/sync_dev_ca_test.sh` (3 casos, openssl descartável, shim de `openssl` para a falha no meio) roda no job `workflow-lint`. Ficou o limite de que uma falha depois do primeiro `mv` deixa os assets já promovidos como estão (rename atômico por arquivo, não por conjunto). + +- **Autoria das visitas sob outro token — veredito (2026-10-05).** A crítica (a fila deve ser *tenant-aware* e nunca carregar visita de A sob o token de B) **já estava atendida**, e foi reconferida por teste, não por leitura. (1) *Fila amarrada ao dono:* `offline_visits.owner` (schema v7), `VisitStorage.forOwner(userId)`, `BackendVisitSynchronizer` que recusa sessão de outro dono (`visit_owner_flow_test.dart`, 71/71 com os testes do envio diferido). (2) *Nunca pega carona:* a fila de A sobe por `visits.syncDeferred` com o **token de envio diferido de A** (o "sou este aparelho enviando pendências do usuário A", revogável no servidor), não pelo JWT de B. (3) *O servidor não credita B:* `syncLegacy` grava `acsId` nulo e `authorship = legacyUnclaimed`; `sync` recusa o `localId` de outro agente e não reivindica visita sem autor. **Prova de que os testes valem:** a mutação que faz o legado creditar o transportador derruba 12 testes, e a que remove a recusa de outro agente derruba `recusa sincronizar visita registrada por outro agente` (fonte restaurada nas duas). **Decisão aceita:** as linhas *legadas* (sem dono, de antes do schema v7) sobem pela sessão de quem logar, **sem autoria** e auditadas; só existem em aparelho de desenvolvimento anterior a 2026-10-04 (0 tags de release, nenhuma publicação). **Reabrir se uma release tiver existido:** nesse caso a quarentena até o dono voltar (sem `syncLegacy`) passa a ser a opção. + + +- **Fonte a 200% no ACS: 320 dp, paisagem, `save_visit` e chip de conexão (2026-10-06).** Plano: `docs/superpowers/plans/2026-10-06-verificacao-fonte-200-acs.md`, resultado da verificação em `...-resultado.md`. As três lacunas que o teste de fonte deixava estavam abertas e foram fechadas: (1) `test/text_scale_test.dart` agora percorre o painel a 130% e 200% em 360x800, 320x640 e 800x360 (paisagem); a varredura achou um estouro real, a legenda do Mapa (`_LegendChip`, 24 px a 320 dp e 200%), corrigido com `Flexible`. O harness também tinha dois defeitos que esvaziavam a varredura: `percorrerTelaInteira` arrastava o `Scrollable` interno do `TextField` (fora da tela) nas abas com campo de texto, e `abrirPainel` tocava no botão Entrar antes de a rolagem assentar em paisagem. (2) `save_visit` é provado alcançável a 200% nas três janelas: habilitado, tocável e com 48 dp ou mais (rota: alerta amarelo → Fila → "Iniciar rota de visita" → chegada confirmada). (3) O rótulo semântico do chip `broker_status` é completo nos três estados (`Alertas em tempo real`, `Sem central`, `Offline ready`) mesmo com o texto cortado por elipse; não precisou de mudança no código. **Prova no emulador (API 36, fonte real):** `integration_test/layout_fonte_200_test.dart` percorre o painel inteiro com a janela e a escala que o sistema configurou (`adb shell settings put system font_scale 2.0` e `wm size`/`wm density 160`): passou a 320x640 dp e a 800x360 dp, ambos a 200%, sem estouro. No aparelho o `enterText` abre o teclado de verdade e ele come ~300 dp da janela (o corpo do login fica com 177 dp a 320x640, e o botão Entrar sai da viewport até o teclado recolher); o harness larga o foco e espera o teclado sumir antes de medir, então esse número **não** cobre o login com o teclado aberto. **Limites:** os testes de widget usam a fonte Ahem; a prova do aparelho é só no emulador API 36, e aparelho físico segue sem prova. + + +## Atendimento dos pedidos do titular (#42, 2026-10-09) + +Plano: `docs/superpowers/plans/2026-10-08-issue-42-pedidos-do-titular-admin.md`; log do e2e: `docs/superpowers/plans/2026-10-08-issue-42-e2e.log`. + +**O que existe** +- Backend: estado `inReview` e colunas `decidedAt`, `decidedBy`, `resolutionEncrypted`, `resolutionKeyVersion` em `data_subject_requests`; `DataSubjectCaseService` (regras puras) sobre `OrmDataSubjectCaseStore` (transação com lock por pedido, nota cifrada, auditoria na mesma transação); cinco métodos em `AdminEndpoint` (`dataSubjectRequests`, `dataSubjectRequest`, `startDataSubjectReview`, `completeDataSubjectRequest`, `rejectDataSubjectRequest`), só `coordinator` (UBS própria) e `admin`. +- Exclusão atendida = **anonimização** na mesma transação: nome, `cpfHash` (`removed:` não derivável do CPF), contato de emergência, data de nascimento (sentinela 1900-01-01), condições crônicas, respostas de triagem; apaga `push_tokens`, `otp_challenges`, `user_credentials`; fecha outros pedidos de exclusão abertos; acrescenta uma recusa de `segmentedPush`. Sessão ainda viva de conta anonimizada não regrava condições crônicas nem respostas de triagem; o alerta vermelho segue aceito. +- Aviso push ao titular (correção atendida ou recusa), melhor esforço, sem a nota no payload. +- Admin: aba "Pedidos do titular" (fila com vencido primeiro e destacado, filtro de status, "Carregar mais", detalhe, iniciar análise, atender, recusar com motivo, confirmação na exclusão). +- App do paciente: "Em análise", "Resposta: " e botão de exclusão indisponível também com o pedido em análise. + +**Provas (2026-10-09, números medidos agora)**: backend `dart test` 767/767; admin 162, patient 259, acs 536, todos verdes e `flutter analyze` limpo; `dart analyze` do backend sem warnings (59 `info`). Emulador: `scripts/qa/admin_titular_e2e.sh` verde (login com MFA, vencido primeiro, correção em duas etapas, exclusão com confirmação; no banco: dois `completed` pelo admin, nota cifrada, titular anonimizado, zero push/OTP, alertas e consentimentos preservados, uma linha de auditoria por escrita, cadeia íntegra com 17 linhas, texto do titular ausente dos logs); regressões `admin_login_e2e.sh` e `patient_full_e2e.sh --sem-push` verdes. + +**Decisões assumidas** (a validar com o encarregado/DPO): (1) quem decide: coordenador da própria UBS e admin; (2) exclusão = anonimização, não `DELETE` (`alerts`, `visits`, `audit_logs`, `consent_logs` ficam, pseudonimizados); (3) correção não reescreve campo, exige nota; (4) transições `open→inReview→completed|rejected` e `open→completed|rejected`, recusa exige motivo de 3 a 500 caracteres; (5) aviso push só para correção/recusa, com consentimento e token. + +**Ficou de fora, de propósito**: a correção não reescreve o campo; sem aviso por e-mail; sem prazo de expurgo de `consent_logs`; ficam também pseudônimos como hash de localização, hashes de aparelho, `visits.notesEncrypted` e `enrollment_tokens` (M-2/Task 4); iOS e aparelho físico; o app ACS não consome o cliente novo; `screen: my_data` do push não tem consumidor no app do paciente; o `e2e.sh --full` só indica o script (troca a stack) e o CI não o roda; `docs/telas-admin.md` ainda diz "quatro destinos" (recaptura de telas pendente). + +## Escalonar alerta e tempo quase real no backoffice (#44, 2026-10-12) + +Plano: `docs/superpowers/plans/2026-10-11-issue-44-acoes-operacionais-alertas.md`; decisão de transporte: `docs/superpowers/specs/2026-10-11-adr-tempo-real-alertas-admin.md`. + +**O que existe (entregue)** +- Backend: colunas `alerts.escalatedAt/escalatedBy/escalationReason`; prazo `RED_ALERT_ESCALATION_SECONDS` (padrão 300, faixa 30–3600); `admin.escalateAlert` (coordenador da UBS ou admin, idempotente, auditado na mesma transação, reforço no outbox com `escalated: true`; o alerta **segue `pending`**); `admin.alertsPulse` e `admin.endMonitoringSession` (sessão de monitoramento auditada em vez de uma linha `read` por pulso); ACK auditado na mesma transação (fail-closed); expurgo LGPD não apaga vermelho `pending` (`skippedPendingRed`); inventário `red_alert_discard_paths_test`. Regra de negócio proposta no PRD como RF19 (PRD) (a confirmar; distinto do RF13 e do LGPD-RF19). +- Admin: `MonitoringController` (pulso de 30 s, selo de dado desatualizado após 60 s), botão **Escalonar** com motivo fechado, atualização automática da lista e do painel. ACS: o cartão do alerta reforçado sobe ao topo com "Reforço da coordenação". + +**Provas (2026-10-12, medidas agora)**: backend `dart test` 983/983, `dart analyze` 71 `info` e nenhum warning; admin 252, acs 544, patient 259, todos verdes e `flutter analyze` limpo; `ci_invariants.sh`, `ci_invariants_test.sh` e os quatro `*_e2e_test.sh` estáticos do admin verdes. Depois da rodada final da revisão (vencidos primeiro, `acknowledged_mqtt`, contador "N vermelhos vencidos", filtro sem "Escalonado"): backend 988/988 (`dart analyze` 71 `info`), admin 260, acs 544, patient 259, `ci_invariants.sh`/`ci_invariants_test.sh` e os quatro estáticos verdes. **O e2e do emulador (`scripts/qa/admin_alertas_escalonar_e2e.sh --com-regressao-offline`) passou de ponta a ponta em 2026-10-12 (2ª execução; log em `docs/superpowers/plans/2026-10-12-issue-44-e2e.log`).** + +**Execuções reais de 2026-10-12 (emulador, banco `sinalacs_e2e`).** *1ª execução:* passou tudo até o selo de desatualizado e **falhou na volta** (o selo não sumiu) porque o script pausava o **backend** por 120 s: congelado, ele não mandou keepalive MQTT, o broker fechou a conexão e, no `docker unpause`, a escrita no socket morto derrubou o processo (`SocketException: Broken pipe … address = mosquitto` não tratada, saída 255). Era um **defeito do despachante MQTT do backend**, fora da #44, tratado na issue #56 (corrigido; ver o item fechado em "Abertos"). O script passou a pausar o **Traefik** (`sinalacs-traefik`), a única rota do aparelho até o backend, e a conferir que o backend seguiu no ar sem reinício. *2ª execução (com `--com-regressao-offline`): passou inteira.* Painel de 1 para 2 abertos só pelo pulso, sem troca de aba, em 29,9 s; botão "Escalonar" aparecendo sozinho 8,1 s depois do prazo; escalonamento pela tela; reescalonamento por RPC com `alreadyEscalated` e o mesmo `escalatedAt`; selo de dado desatualizado com Traefik pausado por 120 s e **sumindo sozinho** na volta, com o backend vivo e sem reinício. No banco de teste: uma linha `escalated:no_ack_timeout` e uma `already_escalated:no_ack_timeout`, ambas do coordenador; alerta ainda `pending`, sem confirmação; um único reforço no outbox; o vencido semeado intocado; os dois vermelhos seguem abertos; 2 `monitoring_session_start` (a queda de 120 s sem pulso abriu a 2ª sessão, como projetado); zero linhas `read` de pulso; leituras do coordenador 12 (esperado 12; tetos por recurso respeitados); cadeia de auditoria íntegra (21 linhas). A regressão offline do ACS (`acs_full_e2e.sh`) passou: 100 visitas offline em 98 ms, microárea no aparelho, MFA e refresh, fila por dono com envio diferido e visita legada. **Ainda não provado no emulador:** a linha de alerta renderizar sobre `surfaceRaised` (contraste medido só por teste de widget) e o reforço do escalonamento chegando ao app do ACS por MQTT (coberto só por teste de fila e de widget da Task 7b; o e2e não tem o lado do ACS). + +**Abertos, nomeados** +- Encerramento forçado administrativo de alerta vermelho órfão (D7): sem ele, vermelho `pending` nunca é expurgado. +- `maxChangedAt` é cego a alerta que sai do escopo (expurgo, troca de microárea); mitigado pelo app, que também relê a lista quando as contagens do pulso mudam. +- **Autenticar o ACK por MQTT:** `acsId`/`microAreaId` vêm do payload sem autenticação (ninguém publica hoje; a ACL do broker permite). Desde a rodada final da #44 a trilha marca esse ACK como `acknowledged_mqtt` (o do RPC segue `acknowledged`), para não atribuí-lo como um ACK autenticado; a autenticação em si continua aberta. +- **Origem do ACK decidida por `deviceId`, que o cliente controla:** o rótulo `acknowledged_mqtt` sai de `user.deviceId == RedAlertService.mqttAckDeviceId` (`'mqtt'`), e no RPC o `deviceId` vem do próprio cliente — um ACS pode entrar por `auth.loginInstitutional` com `deviceId: 'mqtt'` e ter os ACKs do RPC rotulados `acknowledged_mqtt`. O `userId` da linha continua sendo o do ACS autenticado: é só um problema de negabilidade ("não fui eu, veio do broker"), não de autoria. Correção proposta: recusar o `deviceId` reservado no login (e no refresh) ou marcar a origem por outro campo que o cliente não alcance (por exemplo, um parâmetro interno do caminho de `lib/server.dart`). +- **`TimeZone` do Postgres diferente de UTC** deslocaria o corte de "vencido": o fragmento único (`alert_overdue_sql.dart`) compara `@now::timestamp` com `triggeredAt` (`timestamp without time zone`), e o cast de um instante com fuso para `timestamp` usa o `TimeZone` da sessão. Hoje o banco roda em UTC; nada o garante nem o testa. +- **Dois aparelhos admin do mesmo usuário:** a sessão de monitoramento é por `userId`, então o END de um aparelho fecha a sessão do outro, que só reabre com um novo START no pulso seguinte (a trilha fica com sessões partidas). +- **`ubsId` do coordenador mudando no meio da sessão:** o `monitoring_session_start` não registra a UBS observada; se a lotação mudar por SQL durante a sessão, a trilha não diz qual escopo ele monitorou antes da troca. +- **Reforço publicado depois de o ACS já ter confirmado por RPC offline:** se o ACK do ACS está na fila local e ainda não chegou ao servidor, o escalonamento passa (o alerta segue `pending` no servidor) e o reforço chega a um aparelho que já o confirmou; o app nunca desfaz o ACK, mas a fila local e o servidor divergem até o envio. +- **Paginação da lista de alertas do admin:** a tela pede só a primeira página (50). Mitigado desde a rodada final da #44: `admin.alerts` ordena os **vencidos primeiro** (mesmo fragmento SQL) e Painel/Alertas mostram "N vermelhos vencidos" a partir do `overdueRed` do pulso, então um vencido não fica escondido além da página; os não vencidos antigos continuam inalcançáveis pela tela. +- Dois primeiros pulsos concorrentes do mesmo usuário podem gravar dois `monitoring_session_start`; o END é best-effort (app morto não o envia; a sessão fecha por inatividade em 90 s). +- O app do ACS não tem som, vibração nem notificação do SO: o reforço de um alerta já listado só é percebido com a lista aberta. +- **Backend cai quando o socket MQTT morre (#56) — corrigido em 2026-10-13; riscos residuais abaixo.** *Causa provada:* o erro assíncrono do `Socket.done` do socket TLS (`SocketException`, `EPIPE` errno 32 ou `ECONNRESET` errno 104) é entregue à zona onde `client.connect` criou o socket; sem tratador, vira exceção não tratada e o processo sai com 255. Caiu em A (`docker pause` 120 s) 5/5 e em C (partição de rede 100 s) 3/3; não caiu em B (reinício do Mosquitto) 0/3 nem na sonda de broker falso em TCP plano. *Correções:* zona protegida por cliente derivada de `Zone.root`, com política transitório × fatal e reconexão/backoff endurecidos; `runZonedGuarded` global em `bin/main.dart` (erro desconhecido registra só o tipo e `exit(70)`); outbox só marca `publishedAt` após o PUBACK (timeout 10 s) e `disconnectOnNoResponsePeriod` de 10 s no PINGRESP, o que fecha a hipótese H5 (alerta vermelho criado na partição C se perdia em silêncio, 4/4 antes); `restart: unless-stopped` no `serverpod` do Compose como defesa em profundidade. *Depois:* A 0/5 e C 0/3 quedas; base, B e C entregam 12/12 alertas, cada um exatamente uma vez (`scripts/qa/mqtt_resiliencia_e2e.sh`, `--com-pausa-do-backend` para o A; `dart test -t resiliencia` na suíte). Evidência completa em `docs/superpowers/plans/2026-10-12-issue-56-evidencias.md`. *Riscos residuais e abertos:* (1) duplicatas sob carga (`claimDue` sem lease, pior caso N²), aceitas pelo INV-03; o tratamento de PUBLISH duplicado do mesmo `alertId` no app do ACS não foi verificado; (2) `publishNow` espera em série os PUBACKs do lote reivindicado: a resposta ao paciente pode levar até ~10 s numa conexão morta ainda não percebida; (3) entrega até ~96 s depois da rede voltar (backoff do outbox); (4) o cartão no app do ACS não foi verificado nos e2e de B e C (ACL do broker e microárea); (5) o fluxo novo no emulador só passou pela regressão do e2e da #44; (6) a detecção da partição TLS foi provada só no Docker, não na CI; (7) `AlertOutbox.markFailed` grava `error.toString()` numa coluna do banco (pode carregar texto de exceção; fora do escopo de logs do VAZ-01; seguimento); (8) ACK MQTT sem autenticação segue aberto, inalterado e fora do escopo; (9) `exit(70)` não faz o desligamento ordenado do Serverpod; (10) a política de reinício vale só para o Compose local (a hospedagem define a sua); (11) menores: dispensador de message-id singleton, identidade do timer, testes de SIGSTOP/RST só em Linux. +- MQTT para o backoffice (ADR) só depois do provisionamento de certificado de cliente por aparelho. +- Alerta sem microárea (só o admin o vê) escalona **sem reforço** e o resultado não avisa isso. +- `AlertStatus.escalated` segue sem escritor (escalonar não muda o status), e o app do paciente o rotula "Encaminhado para o SAMU": risco se alguém passar a escrevê-lo. Desde a rodada final da #44 o filtro de status da tela Alertas não oferece mais "Escalonado" (só devolveria vazio); o rótulo continua para linhas legadas. + +## Trilha de auditoria completa do backoffice (#45, 2026-10-14) + +Plano: `docs/superpowers/plans/2026-10-13-issue-45-trilha-de-auditoria-completa.md`. Contratos e decisões D1–D10 como implementadas: `backend/CLAUDE.md` (seção "Trilha de auditoria do backoffice"); tela: `apps/CLAUDE.md` e `docs/telas-admin.md`. + +**O que existe (entregue)** +- `admin.auditLogs` com filtro tipado (matrícula, papel, ação, tipo de recurso, resultado, período `from`/`to`, busca restrita) e keyset por `sequence`; selo por linha (`verified`/`invalid`/`unverifiable`) calculado no servidor. +- `admin.verifyAuditChain`: verificação global em lotes (single-flight, orçamento 10 s, cache 60 s), estados `verified`/`broken`/`unavailable`/`inconclusive` com motivo fechado, projeção por papel (o coordenador não aprende `sequence` nem volume fora da UBS) e checkpoint assinado de cauda (`audit_chain_checkpoints`) contra truncamento. +- `admin.exportAuditLogs`: só admin, período obrigatório, teto 5000, CSV de nove colunas sem hash/UUID/IP, evento `export` + `audit_exports` na mesma transação, `chainState` de verificação fresca, falha sempre genérica. +- Admin: filtros, "Carregar mais", selos, painel de cadeia (só `verified` é verde), seção "Exportar" só para admin, entrega pela área de transferência, app inteiro em pt-BR. + +**Provas** +- Regressão completa de 2026-10-14 (saídas em `docs/superpowers/plans/2026-10-13-issue-45-regressao.log`): backend `dart test -j 1` **+1191** verdes (unidade e integração, Postgres real) e `dart analyze` 75 `info`, nenhum erro nem warning; admin **+345**, acs **+544**, patient **+259**, os três `flutter analyze` sem problemas; `ci_invariants.sh` (9 grupos) e `ci_invariants_test.sh` verdes; `admin_auditoria_e2e_test.sh` ok; `check_documentation_links.sh` exit 0; `grep auditChainSecret` em `lib/src/models` e `lib/src/endpoints` vazio; `grep entryHash|ipHash` em `audit_export_service.dart` vazio. Baseline antes da #45: backend 988, admin 260. +- E2E no emulador (`scripts/qa/admin_auditoria_e2e.sh`, log `docs/superpowers/plans/2026-10-13-issue-45-e2e.log`, 4ª execução verde; as 3 primeiras falharam por defeitos do teste/script, no log): `E2E_CADEIA_INTEGRA`; filtros por ação, período e busca e "Carregar mais" sem repetição (`E2E_FILTROS_OK paginas=2 linhas=76`); `E2E_ADULTERAR sequence=50` seguido de "Falha de integridade" na tela com só a 50 marcada (`E2E_ADULTERACAO_DETECTADA`); exportação de 45 linhas (nº 16 a 60) lida de volta de `Clipboard.getData`, nove colunas, sem UUID/hash/IP (`E2E_EXPORT_FEITO`); coordenador vê só a UBS dele (20 linhas), sem "Exportar", e `exportAuditLogs` por RPC recusado (`E2E_COORDENADOR_OK`). No banco: 1 `export/success` ligada a 1 linha de `audit_exports` (`45|16|60|broken|csv`), 1 `export/denied` do coordenador, `filtersJson` só com `from`/`to`; o CLI `audit_chain_check` deu `exit=1` com a linha adulterada (`hashMismatch` na sequence 50) e `exit=0` depois de restaurada (99 linhas). +- Cliente antigo: ACS e paciente compilam, analisam e testam com o cliente regenerado (só adições; P4). + +**O que NÃO está provado / limites assumidos** +- O e2e **não roda na CI** (nenhum `admin_*_e2e.sh` roda; o `_e2e_test.sh` estático também fica fora, como os dos outros fluxos). O seed do e2e **não usa `OrmAuditTrail`** (usa `AuditChain` direto, para fixar timestamps e o empate). O teste espera ~60 s (cache da verificação) antes de reverificar a adulteração. +- **Download real de arquivo (P5)** fora: o CSV sai pela área de transferência (compartilhada com outros apps); pacote de arquivo exigiria aprovação em `spec/stack.md`. Resposta perdida depois do commit deixa evento sem entrega. +- **Filtro por `resourceId` exato** fora (D10); validar com o encarregado de dados, junto com D5 e com a base/retenção de `audit_exports` (sem política de retenção; fora da cadeia, então edição direta não é detectada). +- **Exceção própria do teto** não existe: o servidor reutiliza `AdminInvalidRequestException` para teto e filtro inválido; falha de geração é `AuditExportFailedException` genérica (o cliente não distingue rede de geração). +- **Sem trigger/`REVOKE` append-only:** a cadeia detecta, não impede; quem escreve no Postgres **e** tem `AUDIT_CHAIN_SECRET` reescreve tudo. +- **Backup restaurado inteiro** (cadeia + checkpoints) passa como íntegro. **Truncamento de cauda** só é pego com checkpoint; quem apaga cauda e checkpoints não é detectado. **A CLI `audit_chain_check` não usa checkpoints.** Sem **re-baseline** auditado: restauração parcial de `audit_logs` dá `broken` permanente. Só os 100 checkpoints mais recentes são conferidos; a tabela não tem poda. +- **Rotação de `AUDIT_CHAIN_SECRET` invalida toda a história** (parece adulteração em massa; a tela diz "falha", não "ataque"); exigiria `keyVersion` por linha (migração futura). +- Verificação global é O(n): ~22 µs/linha medidos, 10 s cobrem ~440 mil linhas; acima disso, verificação incremental por checkpoint. O selo por linha olha só para trás (a linha seguinte à adulterada segue "Íntegro"). `chainState` da exportação atesta o instante da verificação, não do commit; N exportações simultâneas = N verificações (só admin). +- **Abrir a Auditoria grava 2 linhas** (`admin_audit_logs` e `admin_audit_chain`) e paginar/verificar acrescenta `read` (a trilha de e2e foi de 60 para 99 linhas). +- Falha ao gravar checkpoint só vai ao log do servidor; o coordenador não vê mais `timeBudget` (recebe `inconclusive` sem `reasonCode`; só `unavailable` e `checkpointInvalid` trazem motivo); `auditRowInUbs` e `audit.record` sem timeout próprio; `sequenceGap` avalia o pertencimento pela linha seguinte ao buraco. +- App: seletor de hora em modo teclado estoura com fonte ≥ 150% (defeito do Material); corrida mínima em `_copiarCsv` se o filtro muda durante `Clipboard.setData`; Enter nos campos aplica filtro durante a exportação (seguro); a exportação segue o filtro **aplicado**. +- Menores de teste/estrutura diferidos: índice `audit_exports_requested_at_idx` sem medição; teste de schema e o de "nove campos" dependem do cwd; integração paralela tem contenção intermitente (rodar com `-j 1`); `_escaparLike` mora na infraestrutura e o unit a importa de lá; testes de `actorRole=admin` sem controle positivo e de nome/`resourceId` só como guarda de regressão; sem teste dedicado para `-`/`-x` isolados no CSV; `AuditRowSealer` não checa segredo vazio (o `app_config` já recusa); `entryAt` só é usado pelo checkpoint. +- **Exceção do verde:** "Cadeia íntegra" e o selo "Íntegro" usam verde, reservado ao risco clínico em `spec/ui_design.md`; mantida (sempre ícone + texto) e **pendente de aceite do dono**. +- A captura `docs/screenshots/admin/05-auditoria.png` é anterior à #45. + +## Avisos agendados e versões dos termos (#46, 2026-10-14) + +Plano: `docs/superpowers/plans/2026-10-14-issue-46-avisos-e-termos.md`; achados da investigação: `docs/superpowers/plans/2026-10-14-issue-46-fase0-achados.md`; contratos: `backend/CLAUDE.md` (seção "Avisos agendados e versões dos termos"). **Backend (Fases 1–2), app admin (Fase 3) e e2e (Fase 4) entregues; ver "Continua em aberto" abaixo.** + +**O que existe (entregue)** +- Tabelas `scheduled_notices` e `terms_versions`; `NoticeSchedulingService` e `NoticeDispatchLoop` (disparo a cada 30 s, reserva atômica `pending → sending`); `TermsPublicationService` (revisão jurídica e 15 dias de antecedência); sete métodos novos no `AdminEndpoint` (`scheduleNotice`, `cancelScheduledNotice`, `noticeHistory`, `termsVersions`, `draftTermsVersion`, `markTermsReviewed`, `publishTermsVersion`). O cartão do paciente lê a agenda publicada. `NoticesEndpoint.sendSegmented` continua ACS-imediato. +- Regressão do backend: `dart test` +1289 verdes (unidade e integração, Postgres real), `dart analyze` sem erro nem warning (87 `info`). + +**O que NÃO está provado / limites assumidos** +- A varredura de aviso preso em `sending` roda **só na partida** e olha `scheduledFor` (sem coluna `claimedAt`); vale para instância única do servidor. +- Limitação da varredura: um aviso que fica `sending` com `scheduledFor` a menos de 10 min do reinício permanece `sending` (invisível como preso) até um reinício posterior; não há reenvio. Cada aviso que a varredura move a `unknown` gera uma linha `community_notice` / `unknown_after_restart` (autor = quem agendou, recurso = id do aviso, `recordSafely` para não impedir o boot). +- A constante `upcomingTermsChange` foi mantida (`null`) e dois testes a leem (`terms_change_schedule_test.dart` no backend, `upcoming_legal_documents_test.dart` no app do paciente). +- A revisão jurídica do texto real dos termos continua pendente (fora do código). +- iOS fora do projeto. + +**Entregue no app admin e no e2e** +- App admin Android com a aba **Avisos** (agendar, cancelar, histórico) e a sub-aba **Termos** (rascunho, revisão jurídica, publicação; só admin): `notices_screen.dart`, `notice_form_dialog.dart`, `terms_panel.dart`, `terms_form_dialog.dart`. +- Testes de widget e de acessibilidade, e teste de integração hermético no emulador `emulator-5554` (`admin_notices_mobile_test.dart`). +- `./scripts/qa/push_e2e.sh --e2e-db --negativos` verde em 2026-10-14, com o caso do aviso agendado contra Gorush e FCM reais no emulador; log em `docs/superpowers/plans/2026-10-14-issue-46-e2e.log`. +- Regressão final (2026-10-14): backend `dart analyze` sem erro (87 `info`) e `dart test` +1289 verdes; paciente `flutter test` +259; ACS `flutter test` +544; admin `flutter analyze` sem problemas e `flutter test` +453; `scripts/qa/ci_invariants.sh` ok. +- Regressão após a revisão final (2026-10-14): backend `dart analyze` sem erro nem warning (87 `info`) e `dart test -j 1` +1307 verdes; paciente `flutter test` +259; ACS `flutter test` +544; admin `flutter analyze` sem problemas e `flutter test` +456; `integration_test/admin_notices_mobile_test.dart` 2/2 e smoke 4/4 no `emulator-5554`; `scripts/qa/ci_invariants.sh` ok; `push_e2e.sh --e2e-db --negativos` reexecutado e verde ("OK — o aviso chegou ao emulador"), log em `docs/superpowers/plans/2026-10-14-issue-46-e2e.log`. + +**Fechamento (2026-10-14)** +- O disparo reconfere a autoridade atual de quem agendou (`ScheduledNoticeStore.authorityOf`, `noticeAuthorRevokedReason`; auditoria `community_notice`/`denied_author_revoked`): coordenador desativado, sem conta de staff ou fora da UBS da microárea não envia mais o aviso (vai a `failed`). Commit `a373a6f`. +- `./scripts/qa/push_e2e.sh --e2e-db --negativos` verde em 2026-10-14 agendando pelo endpoint real `admin.scheduleNotice` (`apps/admin/tool/schedule_notice.dart`: login de staff com MFA/TOTP, aviso a +70 s; o seed direto `seed_e2e_scheduled_notice.dart` saiu). Log: `docs/superpowers/plans/2026-10-14-issue-46-fechamento-e2e.log`. +- Roteiros de emulador do admin reexecutados contra o backend real no `emulator-5554`, todos com saída 0: `admin_login_e2e.sh`, `admin_auditoria_e2e.sh`, `admin_acs_gestao_e2e.sh`. Log: `docs/superpowers/plans/2026-10-14-issue-46-fechamento-emulador.log`. +- Regressão final (2026-10-14): backend `dart analyze` sem erro nem warning (87 `info`) e `dart test -j 1` +1318 verdes; paciente `flutter test` +259; admin `flutter analyze` sem problemas e `flutter test` +459; `scripts/qa/ci_invariants.sh` ok. Linha de base: `docs/superpowers/plans/2026-10-14-issue-46-fechamento-baseline.log`. + +**Continua em aberto** +- Revisão jurídica do texto real dos termos. (issue a abrir) +- Retenção e expurgo de `scheduled_notices.message`: o texto digitado pelo staff fica guardado sem prazo nem dono (definir com o encarregado de dados). (issue a abrir) +- Troca manual de `consentPolicyVersion` e `legalDocumentsVersion` na data de vigência: a publicação só **anuncia**, nada passa a valer sozinho. (issue a abrir) +- Revisão e publicação por um único admin, sem segregação de funções; e rascunho sem editar nem descartar (a versão fica "queimada" se a revisão estourar os 15 dias). (issue a abrir) +- Histórico de avisos sem paginação na UI (50 fixos). (issue a abrir, junto com a varredura de `sending` e o fuso) +- Fuso: a comparação `timestamp without time zone` com parâmetro `DateTime` só foi provada com o Postgres em UTC. (issue a abrir) +- Validação manual do cartão de termos (`terms_change_notice_card`) no emulador: **não** foi automatizada e continua pendente (a cargo do responsável pelo produto); roteiro em `docs/superpowers/plans/2026-10-14-issue-46-validacao-manual.md` (cobertura automatizada: `admin_endpoint_terms_test.dart` no backend e `terms_change_notice_test.dart` no paciente). +- Os rótulos curtos da barra inferior do admin mudaram ('Áreas', 'Trilha'): decisão de produto a confirmar. +- `upcomingTermsChange` morta mantida; varredura de `sending` só na partida e para instância única. (issue a abrir) +- Guarda "texto novo embarcado no app" perdida (issue a abrir): `upcoming_legal_documents_test.dart` compara com `upcomingTermsChange`, constante morta desde que a agenda passou a `terms_versions`; o admin pode publicar uma versão cujo texto o app não embarca (o cartão aparece sem "Ler o texto novo"). diff --git a/README.md b/README.md index f116a0b4..c2f0a42f 100644 --- a/README.md +++ b/README.md @@ -5,26 +5,51 @@ SinalACS transforma sinais clínicos estruturados em uma fila de trabalho para o Agente Comunitário de Saúde (ACS), priorizada por risco e preparada para operação em conectividade instável. -> **Estado atual:** protótipo funcional da Fase 2 validado localmente em stack -> Docker. O backend já executa autenticação, criação de alerta vermelho, -> idempotência por microárea, publicação no broker e confirmação de recebimento -> pelo ACS. Integrações de produção com MQTT autenticado, identidade e deploy -> operacional ainda não estão concluídas. +> **Estado atual:** protótipo funcional, validado localmente em stack Docker +> (PostgreSQL, Mosquitto, backend Serverpod e Traefik) e em emulador Android. +> **Não está pronto para produção:** não há deploy de produção, iOS, nem +> integração com um provedor de identidade real. A lista completa do que falta, +> com responsáveis, está em [PROGRESS.md](PROGRESS.md). ## Funcionalidades atuais -- App do paciente com acesso inicial, triagem estruturada e status da solicitação. -- App ACS com login institucional demonstrativo, painel de priorização, - territorialização e registro local de visitas. -- Motor de triagem determinístico, com classificação verde, amarela ou vermelha. -- Fila de visitas offline com sincronização simulada, retry e detecção de conflitos. -- Backend com fluxo real de alerta vermelho, incluindo autenticação, idempotência, - publicação em tópico de microárea e ACK do ACS. -- Contrato de alerta MQTT com configuração TLS/WSS e validação local do ciclo de entrega. -- Persistência local preparada para SQLCipher. +**App do paciente** (`apps/patient`) +- Login sem senha: CPF + data de nascimento + código de uso único (OTP), e + cadastro por convite em QR Code. +- Alerta de urgência real (`alerts.createRedAlert`) com acompanhamento de + entrega, triagem estruturada determinística e status da solicitação. +- "Meus Dados" (LGPD): exportação, histórico de consentimentos, revogação e + pedidos de correção e exclusão; aceite dos Termos de Uso. +- Lembretes locais e recebimento de avisos à comunidade por push (Gorush + FCM). + +**App do ACS** (`apps/acs`) +- Login institucional com matrícula, senha (Argon2id) e TOTP; refresh token + rotativo guardado no Keystore e liberado por biometria (provado só no + emulador API 36; no API 30+ o PIN do aparelho também libera e uma digital + nova não invalida a chave); bloqueio após 30 s em + segundo plano e `FLAG_SECURE` na janela. +- Fila de visitas priorizada por risco, restrita à microárea do ACS (RBAC), + com mapa, territorialização e geofencing. +- Registro de visitas offline em banco local cifrado (SQLCipher), com fila por + dono, reenvio e detecção de conflito; recebimento de alertas vermelhos por + MQTT com TLS e confirmação (ACK). + +**Backoffice** (`apps/admin`, Flutter Web e Android) +- Login de coordenador/administrador com senha e TOTP obrigatório. +- Indicadores, microáreas, alertas (com escalonamento do alerta vermelho não + confirmado), auditoria com selo por linha, verificação da cadeia e + exportação CSV, gestão de contas de ACS e de MFA, pedidos do titular (LGPD), + avisos agendados e versões dos Termos. + +**Backend** (`backend/`, Serverpod) +- Motor de triagem determinístico (verde, amarelo, vermelho), alerta vermelho + idempotente por microárea publicado no broker, sincronização de visitas, + colunas clínicas cifradas em repouso (AES-256-GCM) e trilha de auditoria + encadeada. Consulte [PROGRESS.md](PROGRESS.md) para o status detalhado dos milestones e [spec/PRD_system.md](spec/PRD_system.md) para requisitos e decisões técnicas. +As telas estão documentadas em [docs/README.md](docs/README.md). ## Estrutura @@ -33,22 +58,63 @@ apps/ acs/ Aplicativo Flutter do Agente Comunitário de Saúde patient/ Aplicativo Flutter do paciente 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 +backend/ Workspace Dart: servidor Serverpod (sinalacs_server) e cliente gerado (sinalacs_client) +infra/ Configuração local de infraestrutura (Mosquitto, Traefik, Gorush) +scripts/ dev/ (configuração e execução local), qa/ (validação e e2e), ci/ spec/ PRD, UX, privacidade e fluxos do produto -tests/ Testes compartilhados +docs/ Telas, guias operacionais e planos de implementação +video/ Roteiro e material do vídeo de apresentação ``` ## 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 os apps ACS, paciente e admin no Android. +- Flutter com Dart 3.12 ou mais recente para os três apps (o piso efetivo vem + de `backend/sinalacs_client`, dependência dos apps); a CI usa Flutter 3.44.8. +- Dart `^3.12.0` para o backend (`backend/pubspec.yaml`). +- Android SDK com API 36 e JDK 17 para gerar ou executar os apps 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. +- Emulador Android ou aparelho físico, opcional para execução mobile. -As versões usadas pela CI estão definidas em +As versões usadas pela CI estão em [.github/workflows/ci.yml](.github/workflows/ci.yml). +## Configuração + +**Antes do primeiro `docker compose up`, gere a configuração local:** + +```bash +./scripts/dev/bootstrap_env.sh +``` + +O script cria o `.env` com segredos aleatórios desta máquina — +`POSTGRES_PASSWORD`, `TEST_DATABASE_PASSWORD`, `MQTT_BACKEND_PASSWORD`, +`MQTT_ACS_PASSWORD`, `JWT_SECRET`, `AUDIT_CHAIN_SECRET`, +`HEALTH_DATA_ENCRYPTION_KEY`, `CPF_HASH_PEPPER`, `DEV_ACS_PASSWORD` e +`DEV_ADMIN_PASSWORD` — e gera `backend/sinalacs_server/config/passwords.yaml` +(gitignored; sem ele a suíte do Serverpod morre sem imprimir nada). Os dois +arquivos ficam com `chmod 600` e nenhum entra no git. O script nunca +sobrescreve sem `--force`; rodado sem `--force` sobre um `.env` antigo, só +acrescenta `DEV_ADMIN_PASSWORD`, se ela faltar. + +[.env.example](.env.example) é a referência completa das variáveis. O +`docker-compose.yml` declara cada segredo como `${VAR:?...}`: se faltar, o +Compose falha dizendo qual, em vez de subir com uma senha embutida no arquivo +versionado. + +**Trocar `POSTGRES_PASSWORD` ou `HEALTH_DATA_ENCRYPTION_KEY` com um `pg_data/` +já existente quebra o ambiente:** a senha só vale na primeira inicialização do +volume e as colunas clínicas já cifradas ficam ilegíveis. Para adotar valores +novos: `docker compose down && rm -rf pg_data/`. + +Configuração de servidor e banco vem de `sinalacs_server/config/*.yaml` e pode +ser sobrescrita por variáveis de ambiente (`SERVERPOD_DATABASE_HOST` e +companhia, `SERVERPOD_APPLY_MIGRATIONS`, `SERVERPOD_REDIS_ENABLED`, +`SERVERPOD_INSIGHTS_SERVER_PORT`). O MQTT tem variáveis próprias, lidas por +`sinalacs_server/lib/src/config/app_config.dart`, assim como `JWT_SECRET`, +`APP_ENV` e `ENABLE_DEV_LOGIN` (desligado por padrão). Fora de `development`, o +servidor **recusa subir** se `JWT_SECRET` estiver ausente, vazio ou igual ao +valor de desenvolvimento. Detalhes em [backend/CLAUDE.md](backend/CLAUDE.md). + ## Execução local ### Stack de serviços @@ -115,6 +181,10 @@ flutter devices ./scripts/dev/run_acs.sh --build ``` +O ACS de desenvolvimento entra com a matrícula `ACS-001` e a senha de +`DEV_ACS_PASSWORD` (`.env`), gravada pelo seed +`backend/sinalacs_server/bin/seed_acs_credentials.dart`. + ### Aplicativo do paciente O paciente não usa MQTT: o default de `SINALACS_HOST` já serve no emulador. @@ -150,6 +220,36 @@ Sem essa cópia **nada fica vermelho na hora de compilar**: `flutter build` e se denuncia depois, no handshake do TLS. O único comando que reclama é o `flutter analyze`, pelo diretório que o `pubspec.yaml` declara e não existe. +### Backoffice (admin) + +Com a stack de pé e as CAs copiadas (`./scripts/dev/sync_dev_ca.sh`): + +```bash +cd apps/admin +flutter pub get +flutter run -d emulator-5554 # Android; SINALACS_HOST padrão: https://10.0.2.2/ +``` + +No navegador (`flutter run -d chrome --dart-define=SINALACS_HOST=https://localhost/`) +o TLS é validado pelo próprio navegador, que não confia na CA de +desenvolvimento do RPC; esse caminho não foi validado ponta a ponta. + +O seed cria o administrador `ADM-001`, com a senha de `DEV_ADMIN_PASSWORD` +(`.env`), **sem TOTP e sem código de ativação**. Antes do primeiro login, emita +um código (válido por 24 h, mostrado uma única vez): + +```bash +cd backend/sinalacs_server +SERVERPOD_DATABASE_PASSWORD="$(grep -E '^POSTGRES_PASSWORD=' ../../.env | cut -d= -f2-)" \ + dart run bin/issue_staff_activation_code.dart ADM-001 --issued-by seu-nome +``` + +A ferramenta lê as variáveis `SERVERPOD_DATABASE_*` (padrões `localhost:5432`, +`sinalacs_db`, `sinalacs_user`); a senha não tem padrão e é a +`POSTGRES_PASSWORD` do `.env`. Troque `seu-nome` por quem emite. Veja +[backend/CLAUDE.md](backend/CLAUDE.md). Na tela de login, a conta usa esse +código para ativar a verificação em duas etapas. + ## Build ### APK Android de depuração @@ -172,10 +272,12 @@ O mesmo procedimento pode ser aplicado ao app do paciente, substituindo ### Build de release -A configuração Android atual assina builds de release com a chave de debug, -adequada apenas para testes internos. Antes de qualquer distribuição, defina -um `applicationId` próprio, configure assinatura de release e forneça os -segredos por variáveis de ambiente ou um cofre de segredos. +O app **ACS** recusa gerar release assinada com a chave de debug: sem chave de +release configurada, `flutter build apk --release` falha. Para um teste local +consciente, `-Psinalacs.allowDebugSigning=true` libera a chave de debug — nunca +para distribuição. Os apps **paciente** e **admin** ainda assinam release com a +chave de debug. Nenhum dos três tem chave de release provisionada; veja +[docs/publicacao-google-play.md](docs/publicacao-google-play.md). ## Testes e análise @@ -183,61 +285,25 @@ Execute cada conjunto a partir do respectivo diretório: ```bash cd backend && dart pub get && dart analyze -cd backend/sinalacs_server && dart test +cd backend/sinalacs_server && dart test # unitários + integração +cd backend/sinalacs_server && dart test test/unit # só os herméticos, sem banco cd apps/acs && flutter pub get && flutter analyze && flutter test cd apps/patient && flutter pub get && flutter analyze && flutter test +cd apps/admin && flutter pub get && flutter analyze && flutter test ``` -O backend é um workspace Dart com dois pacotes: `sinalacs_server` (servidor -Serverpod) e `sinalacs_client` (cliente tipado gerado). A suíte tem 25 testes — -16 unitários herméticos, que não precisam de banco, e 9 de integração sobre o -harness do Serverpod, que exigem um Postgres em `localhost:9090` conforme -`sinalacs_server/config/test.yaml`. Para rodar só os herméticos: -`dart test test/unit`. - -A última validação local cobriu o ciclo crítico ponta a ponta em stack Docker — -autenticação, idempotência, publicação no broker e ACK do ACS. A CI -(`.github/workflows/ci.yml`) roda quatro jobs em pushes para main e pull -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. +Os testes de integração do backend usam o harness do Serverpod e exigem o +Postgres de teste em `localhost:9090` (`sinalacs_server/config/test.yaml`). A +validação ponta a ponta contra a stack real (scripts em `scripts/qa/`, +`integration_test/` no emulador) está descrita em +[CONTRIBUTING.md](CONTRIBUTING.md) e no README de cada app. -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`/`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. +A CI ([.github/workflows/ci.yml](.github/workflows/ci.yml)) roda em toda PR, +em pushes para `main`/`develop` e sob demanda, com nove jobs: +`workflow-lint`, `serverpod-backend`, `backend-docker-build`, `patient-app`, +`acs-app`, `admin-app`, `coverage-report`, `android-e2e` (o único que sobe um +emulador contra a stack) e `admin-android-build`. Todos menos `android-e2e` +são obrigatórios para o merge em `main` e `develop`. ## Deploy @@ -266,9 +332,16 @@ desenho de privacidade em [spec/lgpd_design.md](spec/lgpd_design.md). O projeto lida com dados de saúde. Não inclua dados reais de pacientes em testes, logs, capturas de tela ou configurações de desenvolvimento. A -classificação de risco é determinística e alertas vermelhos não devem ser -descartados silenciosamente. As garantias de autenticação, autorização por -microárea e entrega MQTT com ACK permanecem pendentes de integração real. +classificação de risco é determinística e não pode ser alterada manualmente, +alertas vermelhos não são descartados em silêncio e o acesso do ACS fica +restrito à própria microárea. + +Autenticação, RBAC por microárea, TLS no RPC e no broker (com senha, ACL e +certificado de cliente) estão implementados **na stack local**. Ainda faltam, +entre outros: provisionamento de certificado de cliente por aparelho em +produção, refresh token para o paciente, imposição de append-only da trilha de +auditoria no banco e o deploy de produção — ver [PROGRESS.md](PROGRESS.md) e a +[política de segurança](.github/SECURITY.md). ## Licença diff --git a/apps/CLAUDE.md b/apps/CLAUDE.md index 4ca7340a..a2e1d00a 100644 --- a/apps/CLAUDE.md +++ b/apps/CLAUDE.md @@ -20,7 +20,7 @@ For the ACS app, run `./scripts/dev/run_acs.sh` (or `--build` for the APK) inste ### 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` (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. +- `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 setting the unreadable file aside and starting fresh instead of leaving the app permanently stuck — **only** for SQLCipher's wrong-key error (`isWrongDatabaseKeyError`: `SQLITE_NOTADB`, or the `open_failed` that `sqflite_sqlcipher` 3.2.0 turns it into on Android); any other open error (migration, I/O, platform channel) is rethrown, nothing is deleted, and the queue keeps working in RAM (`persistenceFailed`). The wrong-key error must repeat on a second open attempt (after `retryDelay`) before recovery runs, and recovery never deletes the file: it moves it (with its `-wal`/`-shm`/`-journal` sidecars) to a single `.recuperado`, still SQLCipher-encrypted with a key that is then deleted — so it is **not readable by the app or by support**; it is kept only as an unreadable forensic artifact (the bytes survive in case the lost key is ever restored from a device/Keystore backup), the pending visits in it are not recoverable, and on Android a real, persistent open failure ends the same way because `open_failed` is indistinguishable from a wrong key — if the move fails the error is rethrown with the file and key untouched; "Limpar este aparelho" (`wipeAllData`) also deletes `.recuperado`. - **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. CI only runs the same probe inside `integration_test/smoke_test.dart`; the wrong-key, v1-migration and lost-key cases of `encrypted_storage_test.dart` run only with `./scripts/qa/e2e.sh --emulator --full`. `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`). @@ -30,9 +30,19 @@ Both apps consume the generated `sinalacs_client` by path dependency and talk to The RPC trusts a development CA that is **separate** from the broker's: `scripts/dev/sync_dev_ca.sh` copies two of them into `apps/*/assets/certs/` — `dev_ca.crt`, the Mosquitto CA that signs the broker's certificate for MQTT on 8883, and `dev_rpc_ca.crt`, which signs the certificate Traefik presents on :443. They are deliberately not unified (see [../docs/README.md](../docs/README.md)). Both apps keep hostname verification on — the TLS stack's, which is never disabled: no path sets `badCertificateCallback => true`, so an untrusted RPC certificate must fail, never silently degrade to accepting any certificate. On top of it, `requireSecureHost` validates the **scheme**, not the hostname (it parses the URI and requires `https`): a host that is not `https://` is rejected rather than used in cleartext. -The ACS login is institutional now (RF07): `_enter` in `apps/acs/lib/app/app.dart` sends the matrícula and the senha the person typed to `auth.loginInstitutional` and only opens the panel once the server issues a token. The credential lives **in memory only** — `BackendClient._credentials`, never on disk — which is what lets the silent renewal reauthenticate when the 15-minute token expires. `developmentLogin` survives as a tool-only method, for `tool/live_check.dart` and `integration_test/` against a stack with `ENABLE_DEV_LOGIN=true`; it stores no credential, so a session started that way has nothing to renew from. The durable fix, the rotating refresh token that LGPD-RT06 asks for, is deferred along with MFA/TOTP — the gaps are registered in [PROGRESS.md](../PROGRESS.md). +**mTLS to the broker (dev stack).** The broker requires a client certificate (password and ACL stay). `sync_dev_ca.sh` also copies `runtime/certs/acs-area-12.{crt,key}` (after `openssl verify` against the broker CA) to the gitignored assets `apps/acs/assets/certs/acs_client.{crt,key}`; `AlertFeed` loads them (`BackendConfig.mqttClientCertAsset`/`mqttClientKeyAsset`; absent -> `AlertFeedFailureKind.missingClientCertificate`, non-transient) and `buildMqttSecurityContext` presents them while still trusting only the project CA. `tool/live_check.dart` reads the same pair from `runtime/certs`. The private key inside an asset is dev-only: the Gradle release guard fails the build while `assets/certs/acs_client.key` exists, unless `-Psinalacs.allowDevClientKey=true` (local test release; `scripts/qa/acs_release_signing.sh`, scenario 4). Per-device provisioning in production is still open. -The patient login is passwordless now (RF01), and no longer uses `developmentLogin` either: the app sends the CPF and the birth date to `auth.requestOtp`, then the 6-digit code to `auth.verifyOtp`, and only that second call issues the session. **There is no silent renewal on this side, on purpose** — an OTP code cannot be replayed the way the ACS's password can, so a 15-minute token would mean a new SMS every 15 minutes. The session therefore lives **1 hour** (`AuthEndpoint.patientSessionLifetime`, applying LGPD-RT06), the single deliberate asymmetry with the ACS: when it expires the app does not renew, it shows "Sua sessão expirou. Entre novamente com o código de acesso." with a button back to the login (`reenter_button`), because that is the only way back in. The asymmetry is written down in `spec/lgpd_design.md` so the next reader does not read it as an oversight. One known gap: the onboarding path (`onboarding.completeEnrollment`, RF02) still issues the 15-minute default and has nothing to renew from — registered with an owner in [PROGRESS.md](../PROGRESS.md). One stale comment comes with it: the doc of `isExpired` in `apps/patient/lib/core/network/auth_session.dart:73` states the patient token lives 1 hour as a fact about the patient's session, which is true only of the OTP path — the onboarding session is the exception, so read that comment as describing the login path, not the role. +The ACS login is institutional now (RF07): `_enter` in `apps/acs/lib/app/app.dart` sends the matrícula and the senha the person typed to `auth.loginInstitutional` and only opens the panel once the server issues a token. The app **no longer keeps the senha** after the login call returns (the old `BackendClient._credentials` is gone). The 15-minute JWT is renewed by the rotating **refresh token** that LGPD-RT06 asks for: `loginInstitutional` is sent a `deviceId` (random UUID, generated once and kept with the token — o identificador da instalação, não uma atestação de hardware: protege contra token vazado sem ele, não contra comprometimento do aparelho) and returns a `refreshToken`. The `deviceId` lives in `flutter_secure_storage` (`SecureStorageDeviceIdStore`); the refresh token, since 2026-10-05, lives in `AuthBoundSessionTokenStore` (`lib/core/security/auth_bound_session_token_store.dart`) over `MethodChannelKeystoreVault` and the native `KeystoreVault.kt` (channel `br.com.prismrr.sinalacs.acs/keystore_vault`): a throwaway AES-256-GCM key encrypts the token and is itself encrypted with the public half of an RSA-2048/OAEP Keystore pair (SHA-256 + MGF1-SHA-1) created with `setUserAuthenticationRequired(true)` and per-use authentication (`BIOMETRIC_STRONG | DEVICE_CREDENTIAL` on API 30+, strong biometric only below). `write` seals without a prompt (the 15-minute rotation stays silent) and keeps the value in RAM; `read` only returns RAM (it runs inside `_exclusive`, so it must never prompt); `unlock` is the only decrypt — `BiometricPrompt` + `CryptoObject`, once per cold start, 2-minute ceiling — and maps `cancelled`/`lockedOut` (blob kept) and `invalidated` (blob deleted, login shows "Sua sessão expirou. Entre novamente."). Without a screen lock (or on API 24–29 without strong biometrics) the token stays in RAM only, so every cold start needs the full login. A token left by an older install in `flutter_secure_storage` (`SecureStorageSessionTokenStore`) is sealed and erased on first use. On API 30+ a newly enrolled fingerprint does **not** invalidate the key (the key also carries the screen-lock SID; proved on the emulator); removing the screen lock does. Proof: `scripts/qa/acs_keystore_bound_e2e.sh` (API 36 emulator only). The abstractions (`SessionTokenStore`, `DeviceIdStore`, in-memory versions) are pure Dart in `session_token_store.dart` on purpose: `tool/live_check.dart` runs on the Dart VM and imports `backend_client.dart`, so nothing under that import path may reach `dart:ui`. `BackendClient` therefore defaults to the in-memory stores and `main.dart` passes the Keystore ones explicitly — `test/session_store_wiring_test.dart` guards both the wiring and the import path. Renewal is single-flight (concurrent calls share one `auth.refreshSession`) and epoch-guarded: `login()`/`logout()` bump a generation, and a renewal that started in an older one finishes as a recoverable failure without writing its child token, exposing its session or calling `onSessionExpired` (a slow renewal of ACS A can no longer overwrite B's login, nor resurrect a logged-out session); the new refresh token is written *before* the session is exposed; a network failure is recoverable and keeps the token, while `SessionExpiredException` (the server's single answer to any refusal) erases it and makes the panel push the reauthentication screen. `logout()` revokes the family on the server and erases the token. `developmentLogin` survives as a tool-only method, for `tool/live_check.dart` and `integration_test/` against a stack with `ENABLE_DEV_LOGIN=true`; it issues no refresh token, so a session started that way ends at the JWT's expiry. + +**Biometric unlock (`local_auth` 3.0.2).** `AppLockGate` (`lib/app/app_lock_gate.dart`) is mounted in `MaterialApp.builder`, *above* the Navigator, with the app's `navigatorKey` (required: it is how the gate swallows the back button while covered). After 30 s in the background it covers everything with an opaque "Aplicativo bloqueado" screen until `BiometricGate` (`lib/core/security/biometric_gate.dart`) succeeds; the panel, MQTT feed, queues and half-filled forms stay mounted underneath (an incoming red alert is still delivered and queued while locked), a failed or unavailable prompt keeps it locked, and a clock moved backwards counts as elapsed. Known limitation (documented in the gate): a programmatic `pop` while locked is not intercepted. On a **cold start** `LoginScreen` asks for the biometric/screen lock *before* resuming the stored session (`resumeSession()`); a device without any screen lock, or a refused prompt, falls back to the full login form (offline with a stored token shows a retry button instead of discarding it). "Entrar com senha" pushes an opaque, zero-duration reauthentication route and then recreates the gate, so the panel is never visible without the password; another user logging in discards the previous panel and feed (RNF06). The Preferências tab has "Sair e encerrar o turno", which revokes the refresh-token family and returns to the login — its confirmation counts the unacknowledged alerts (`AlertQueue.unacknowledgedCount`; they leave the device but stay pending on the server) and then labels the button "Sair mesmo assim" — and **does not clear the offline visit queue**: the queue belongs to its owner (see the visit-queue paragraph below), and "Sair" first syncs the panel queue, then uploads what it can of the others (20 s ceiling per step, `sessionEndSendTimeout`) and tells the ACS what stays on the device and why. **The cold-start unlock is bound to the Keystore for the refresh token only** (see above): the `AppLockGate` after 30 s in the background is still an interface gate over the token held in RAM, and the SQLCipher key and the deferred upload token stay in `flutter_secure_storage` without any authentication binding, by design (the offline queue and another owner's deferred upload must work without the owner present) — a rooted device can read those regardless. On a cold start the store's `unlock` (`AcsBackend.unlockStoredSession`) replaces the `BiometricGate` prompt; stores that answer `notRequired` (in-memory, legacy secure storage, the `integration_test` ones) fall back to the `BiometricGate`. Platform: `MainActivity` extends `FlutterFragmentActivity` (required by `local_auth`) and keeps `FLAG_SECURE`; the manifest declares `USE_BIOMETRIC`. The app theme still inherits `@android:style/Theme.*.NoTitleBar` while `local_auth_android` asks for an AppCompat parent on Android ≤ 8.1 (API 24–27) (`minSdk` is 24); on the API 24 and API 27 emulators (2026-10-05) the breakage did not reproduce — the vault never prompts there (`canAuthenticate(BIOMETRIC_STRONG)` = `NO_HARDWARE`) and `local_auth` opens the system credential screen — so the theme was left as is; a physical device is still unproved (`PROGRESS.md`, "Tema em API 24–27"). + +**ACS: fila de visitas por dono, envio diferido e "Limpar este aparelho" (2026-10-04).** Plano `docs/superpowers/plans/2026-10-03-fila-de-visitas-por-dono-e-corrida-do-login.md` (D1–D9). `offline_visits` tem a coluna `owner` (schema v7, migração aditiva): `VisitStorage.forOwner(userId)` só lê, conta, grava e apaga `WHERE owner = ?` (o `save()` de B nunca toca nas linhas de A; dono vazio é `ArgumentError`; `localId` já existente em outro dono é `VisitLocalIdConflict`, unicidade global). Linhas do schema v6 ficam em **quarentena** (`owner IS NULL`): nenhum dono as vê; só `VisitStorage.legacy` (`LegacyVisitStore`) as lê e remove por `localId`. O cursor do pull é por `userId|microAreaId`; fila e pull são resolvidos por sessão (`visit_queue_factory.dart`, `visit_pull_service_factory.dart`) e recriados por dono, e `BackendVisitSynchronizer` recusa sessão ou token de outro dono (checagem no push e `expectedUserId` conferido depois de resolvido o token). Os testes que injetam `visitQueue`/`visitPullService` seguem como *seam*. `BackendClient._exclusive` (seção crítica serial, **não reentrante**: dentro dela só E/S local do Keystore e atribuições, nunca rede, `renewSession()` ou outra `_exclusive`) cobre login, `developmentLogin`, a leitura do token e a confirmação da renovação e o logout, e fecha a corrida em que uma renovação do usuário anterior sobrescrevia o login novo. `DeferredFlushService` (`core/services/deferred_flush_service.dart`) sobe o que não é do dono da sessão atual: o legado pela sessão atual como transporte (`syncLegacy`, "autoria desconhecida"), cada dono com token por `syncDeferred`; lotes de 100 (`deferredBatchSize`); a visita só sai do aparelho em `synced`; o token de envio é revogado no servidor e apagado do Keystore só com a lista do dono vazia; `flushAll()` é single-flight e não lança; o dono da sessão atual é pulado (a fila é do painel, por isso o "Sair" sincroniza a fila do painel **antes** de `flushAll`). `UploadTokenStore` (`core/security/secure_upload_token_store.dart`) guarda um token por dono no Keystore (`acs_upload_token|`) e um índice de donos; o envio também tenta o dono de toda linha no disco, então o índice é só otimização. Gatilhos em `app.dart`: painel aberto, retomada do app e conexão que volta (`connectivityChanges`, `connectivity_plus`). Rejeição "versão diferente" (`legacyVersionMismatchMessage`) não é erro de território: a visita fica em quarentena e pede revisão. "Limpar este aparelho" congela a área, espera o refresh em voo e chama `VisitDatabase.wipeAllData`, que numa transação recusa (`WipeBlocked`) se houver **qualquer** linha em `offline_visits` — recusada, legado ou de outro dono — ou fila só em RAM; sem linhas, apaga `offline_visits`, `sync_cursor` e o cache da microárea (arquivo e chave do Keystore ficam). Não há wipe forçado por supervisor (não existe login de supervisor) nem envio em segundo plano do SO (WorkManager fora de `spec/stack.md`); um token de envio vencido (7 dias) ou recusado deixa a fila do dono presa e bloqueia o wipe. Provas e limites no emulador: `PROGRESS.md`, seção "Fila de visitas por dono e corrida do login" (o offline do `acs_full_e2e.sh` é **simulado** por um decorator de `AcsBackend`, e o gatilho de conectividade vem de um `StreamController`). + +The patient login is passwordless now (RF01), and no longer uses `developmentLogin` either: the app sends the CPF and the birth date to `auth.requestOtp`, then the 6-digit code to `auth.verifyOtp`, and only that second call issues the session. **There is no silent renewal on this side, on purpose** — an OTP code cannot be replayed the way the ACS's password can, so a 15-minute token would mean a new SMS every 15 minutes. The session therefore lives **1 hour** (`AuthEndpoint.patientSessionLifetime`, applying LGPD-RT06), the single deliberate asymmetry with the ACS: when it expires the app does not renew, it shows "Sua sessão expirou. Entre novamente com o código de acesso." with a button back to the login (`reenter_button`), because that is the only way back in. The asymmetry is written down in `spec/lgpd_design.md` so the next reader does not read it as an oversight. Both patient session paths — the OTP login and `onboarding.completeEnrollment` — issue the same 1-hour token (`AuthEndpoint.patientSessionLifetime`). After the OTP login the app asks `patients.hasAcceptedCurrentTerms` (a `bool` — not the whole "Meus dados" panel, which would write a read-audit row on every login) with a 3 s timeout and, when the answer is `false`, shows `TermsAcceptanceScreen` (`legal_screens.dart`); it is a prompt, not a gate — "Agora não" and a failed or slow `hasAcceptedCurrentTerms` both go straight to the home, so the emergency alert never waits for an acceptance. `patients.acceptTermsOfUse` is the only writer of `termsOfUse` outside onboarding; `updateConsent` still refuses it. After login and onboarding, and when "Avisos da equipe" is granted, the app silently calls `devices.registerPushToken` with the token from `PushTokenSource` (`core/push/push_token_source.dart`); the token comes from `pushTokenSourceProvider` (the only `flutter_riverpod` use in the patient app; `SinalAcsApp(pushTokens:)` overrides it in tests, and without a `ProviderScope` the app falls back to `NoPushTokenSource`), whose real implementation `NativePushTokenSource` asks the `sinalacs/push_token` method channel — the Android side exists (`MainActivity.kt` with `firebase-messaging`; the Google Services plugin is applied **only** when the git-ignored `android/app/google-services.json` exists, so CI and other devs still build and the app degrades to no push), while **the iOS/Swift side does not exist**. The manifest turns FCM auto-init **off** (`firebase_messaging_auto_init_enabled=false`, guarded by `test/android_push_manifest_test.dart`): otherwise the device would contact Google on every app open, before login and without consent; the explicit `getToken()` still works. `registerPushDevice` asks the provider for the token only after `patients.hasGrantedConsent(segmentedPush)` answers `true` (a `bool`: no dossier, no read-audit row; 3 s ceiling, fail closed, late answer discarded; skipped when the source is `NoPushTokenSource`). Onboarding (only when the box is ticked) and the "Meus dados" switch pass `consentKnownGranted: true` because they just wrote the decision. `scripts/qa/push_e2e.sh` proves the real delivery on `emulator-5554`; it needs the FCM service-account key and is not run in CI, and the two push integration tests only run with `--dart-define=PUSH_E2E=1`, so `e2e.sh --full` stays green without credentials. A refusal or failure never blocks the home or the emergency alert. The home also fetches `patients.termsChangeNotice` once (3 s ceiling, failure or a shell without `BackendScope` = no card) and shows a dismissible `TermsChangeNoticeCard` — never a gate, and never on the urgency tab. When the app embeds the text of exactly the version the server announced (`upcomingLegalDocuments`, injected via `UpcomingDocumentsScope`), the card also offers "Ler o texto novo" (`LegalDocumentsScreen(upcoming:)`); otherwise only "Ler os termos atuais". `PushTokenScope.maybeOf` falls back to no push instead of throwing. In the ACS app, "Avisos à comunidade" (`NoticesScreen`) calls `AcsBackend.sendNotice` → `notices.sendSegmented`. + +The onboarding QR (RF02) now exists on both sides: the ACS's "Mais › Convidar paciente" (`apps/acs/lib/app/invite_screen.dart`) calls `onboarding.generateEnrollmentToken` through `AcsBackend.generateInvite` and draws the token with `qr_flutter` — the plaintext token lives only in that screen's `State`, never on disk, and switching patient hides the previous QR. The patient's onboarding reads it with `mobile_scanner` through `QrScannerScope` (`apps/patient/lib/app/qr_scanner.dart`), injectable like `BackendScope` so widget tests never touch the camera; `parseEnrollmentQr` only accepts the server's 43-char base64url format. Terms of Use and Privacy Policy are constant Dart content in `apps/patient/lib/core/legal/legal_documents.dart`; `legalDocumentsVersion` must equal the backend's `consentPolicyVersion`, and `test/legal_documents_test.dart` reads the server file to enforce it. Acceptance is `ConsentPurpose.termsOfUse`, mandatory in `completeEnrollment` like `healthDataProcessing`. + +**Full patient test against the test database (`scripts/qa/patient_full_e2e.sh`).** One command on `emulator-5554`: it brings up `docker-compose.e2e.yml` through `scripts/qa/e2e_stack.sh` (database `sinalacs_e2e` inside the ephemeral `postgres-test`, `ENABLE_DEV_LOGIN=false`, `SMS_GATEWAY=log`, Gorush), seeds **synthetic fixtures generated per run** (`bin/seed_e2e_fixtures.dart`: random UUIDs, CPFs with valid check digits, one ACS with a random password; manifest `.e2e/fixtures.json`, mode 600, git-ignored and deleted at the end) and runs `integration_test/full_journey_test.dart` (real OTP through the screen, terms, red triage, alert, status, "Meus dados", and ACS territory) plus `backend_connection_test.dart`; without `--sem-push` it ends with `push_e2e.sh --e2e-db --negativos` (real Gorush and FCM). The OTP code reaches the emulator through `scripts/qa/otp_relay.py` (`adb reverse tcp:8765`; the cut-off of "codes after my request" uses the relay's `/now`, the HOST clock, never the device's; the runners refuse to start if an old relay still holds the port), because the `log` SMS gateway only writes it to the server log. The integration helpers (`integration_test/support/e2e_login.dart`) use the manifest when `--dart-define=E2E_FIXTURES=...` is given and **fall back to `developmentLogin` otherwise**, which is what keeps the CI's `android-e2e` working; the host tools (`tool/live_check.dart`, `tool/push_consent.dart`) only use a manifest when `E2E_FIXTURES_FILE` is set. The server enforces 60 s between two OTP requests of the same patient, hence one fixture per consumer (`main`, `chronic`, `api`, `push`, plus `outsider` in another micro-area) and a wait in `push_e2e.sh`. The widget tests keep `FakePatientBackend` and their fixed names on purpose: they are hermetic. 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/`. @@ -42,6 +52,24 @@ Color is a clinical signal only in these apps: red/yellow/green map strictly to **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. +**ACS: permissões, SAMU e e2e (2026-10-01).** O manifesto `main` do ACS declara `INTERNET` e localização em primeiro plano (`ACCESS_FINE/COARSE_LOCATION`) — nunca `ACCESS_BACKGROUND_LOCATION` (decisão §4) —, guardado por `test/android_manifest_test.dart`; `ACCESS_FINE_LOCATION` ausente faz o `Geolocator` falhar em silêncio. O botão do SAMU usa `EmergencyDialer` (`core/services/emergency_dialer.dart`): abre o discador com `tel:192`, nunca liga sozinho. O botão "Ligar para a UBS" (RF13) usa o mesmo discador com o telefone que `AcsBackend.ubsContact()` busca em `ubs.myContact`; sem telefone cadastrado, avisa e não liga. `scripts/qa/acs_gps_e2e.sh [--sem-permissao] [--reinstalar]` prova a permissão em runtime — concedida, ou negada de vez (o teste pede a permissão e um tocador recusa o diálogo do sistema; a checagem de que nenhum diálogo cobre o app vale no cenário concedido) — e aborta se o app do ACS já estiver instalado (reinstalar apaga a fila SQLCipher e o Keystore; `--reinstalar` autoriza); não prova a chegada de um fix: o AVD não entrega posição e `scripts/qa/acs_full_e2e.sh` roda a jornada do ACS no banco de teste (sem MQTT: o ACL do broker só libera o UUID do seed). O relé de OTP serve `/acs` (senha sintética do ACS, opt-in por `E2E_FIXTURES_FILE`) **uma única vez** por execução. Os arquivos `integration_test/*_e2e.dart` têm esse sufixo de propósito, para `flutter test integration_test` (e o `e2e.sh --full`) não os descobrirem. O manifesto do ACS tem `android:allowBackup="false"` (guardado por `android_manifest_test.dart`). `test/text_scale_test.dart` percorre as quatro abas e os cinco itens de "Mais" a 130% e 200% de fonte (`test/support/layout_harness.dart`; `pumpAndSettle` nunca assenta na aba Área, use `assentar`). `test/offline_burst_test.dart` e o segundo teste de `integration_test/full_journey_e2e.dart` guardam a meta de 100 visitas offline em menos de 5 s; o relé entrega `/acs` uma vez só, por isso `acsCredentialFromRelay()` memoriza a credencial. A janela do ACS é `FLAG_SECURE` em todo build (`MainActivity.kt`), salvo a constante de compilação `BuildConfig.ALLOW_SCREEN_CAPTURE` (`false` por padrão; só o buildType `debug` a liga, por `-Psinalacs.allowScreenCapture=true` ou `./scripts/dev/run_acs.sh --build --captura`, e o release a **recusa** no Gradle: nenhum menu, intent ou preferência a altera; `kReleaseMode` não serviria, porque a flag é aplicada em `onCreate`, antes do Dart); `scripts/qa/acs_secure_window.sh [--captura]` confere a flag no WindowManager e `test/secure_window_test.dart` guarda o código. A build de **release** do ACS exige chave própria (`android/key.properties` ou `SINALACS_KEYSTORE_*`) e **falha** sem ela; assinar com a de debug exige `-Psinalacs.allowDebugSigning=true`. `scripts/qa/acs_release_signing.sh` prova os três cenários com um keystore descartável. O CI não faz build de release e não guarda segredo de assinatura (aberto). + +**ACS: MFA (2026-10-02).** O login institucional aceita o código TOTP: quando o servidor devolve `MfaRequiredException` (`MfaCodeRequired` no app), `LoginScreen` mostra `totp_field` (6 dígitos, só números) e reenvia matrícula+senha com `totpCode`; mudar matrícula ou senha descarta o campo. `MfaEnrollmentRequiredException` abre `MfaEnrollmentScreen` (`mfa_secret`, `mfa_code_field`, `mfa_confirm_button`; QR sempre branco com módulos escuros, por leitura), sem token e sem gravar o segredo. `test/support/totp.dart` e `integration_test/support/totp.dart` (cópias idênticas) geram o código RFC 6238 para teste; o e2e liga a MFA pelo RPC usando `acsCredentialFromRelay()` e entra com o código do passo seguinte (o da ativação já foi usado e o replay é barrado), por isso é o último teste de `full_journey_e2e.dart`. + +**ACS: cache da microárea (RF08).** `MicroAreaDirectory` (`core/services/micro_area_directory.dart`) é a única regra de quando o cache serve: só no lugar de uma falha **recuperável** (`BackendFailure.isRecoverable`), só para o mesmo `userId|microAreaId` e em até 72 h (`microAreaCacheMaxAge`); recusa do servidor sobe como veio, e dono diferente ou lista vencida **apaga** o cache. `MicroAreaCacheStore` guarda nome, `isChronic` e condições na mesma base SQLCipher das visitas (schema v6, migração aditiva); gravar o cache nunca derruba a lista fresca. `SinalAcsApp(microAreaDirectory:)` é opcional e `null` significa sem cache (o Keystore não existe no `flutter test`): quem liga o de produção é o `main.dart` (`buildMicroAreaDirectory`), guardado por `test/micro_area_cache_ui_test.dart`. As telas Área e Visita mostram `micro_area_cache_notice(_area)` quando a lista é do cache. Política em `spec/lgpd_design.md` §5.11. + ### 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`. +Package `sinalacs_admin`, backoffice — read-only on the Indicadores, Alertas and Auditoria screens, and since #43 the Microáreas screen manages accounts. Same skeleton as ACS/patient: `lib/main.dart` → `lib/app/app.dart` (+ `admin_theme.dart`) → `lib/core/data/`. `AdminDataSource` (`lib/core/data/admin_data_source.dart`) is an abstract interface whose models (`RiskLevel`, `AlertStatus`, `DashboardIndicators`, `MicroAreaSummary`, `AlertSummary`, `AuditLogEntry`) mirror the backend's models, the same DI pattern as `PatientBackend`/`AcsBackend`. Since issue #40 the production implementation is `BackendAdminDataSource` (`lib/core/data/backend_admin_data_source.dart`) over the generated `EndpointAdmin` (`admin.indicators/microAreas/alerts/auditLogs`), created **per session** with the token the login returned (`AdminDataSourceFactory dataSourceFor`); `MockAdminDataSource` stays for widget tests (`SinalAdminApp(dataSource:)`) and is the fallback of `SinalAdminApp` when neither is passed — so `main.dart` and the e2e always go through `buildAdminWiring` (`admin_auth_bootstrap.dart`), which builds auth and data from the **same** `Client` (same CA, https-only; `test/admin_auth_bootstrap_test.dart` guards that the production wiring is never the mock). The client enums share names with the app's (`RiskLevel`, `AlertStatus`), so the data source imports the client with a prefix and translates by **name**, never by index. The patient is shown as `Paciente #A18F` (the server sends `#A18F`; no name ever reaches the app); `tmravSeconds` is `int?` and a `null` renders as `—`, never `0s` (`test/dashboard_tmrav_test.dart`); the alert filter sends the micro-area **id**, not its name; failures become `AdminDataFailure` with fixed texts (the server's refusal text never reaches the screen), and since #41 the screens **show** that text (`_AsyncError` in `app.dart`; any other error keeps the screen's generic text) while an expired session mid-call becomes `AdminSessionExpired` (the server does not answer 401: it refuses the token with `AlertPermissionException`, so `BackendAdminDataSource` takes the session's `expiresAt` and treats a refusal after it as expiry; a real 401 is mapped too), which sends the user back to login with "Sessão encerrada. Entre novamente." instead of offering a retry that would fail again (`_SessaoVencida` hands the shell's `_encerrar` to the screens; `test/error_handling_test.dart`; the `FailingAdminDataSource.nextError` double fails after 150 ms because `pump` advances the clock before building the frame). `recordAccess` is a no-op on the backend implementation on purpose: the server already audits every read, so calling it again would write the same read twice. `AdminHomeShell` exposes five sections (the fifth, **Pedidos do titular**, `lib/app/data_requests_screen.dart`, handles LGPD data-subject requests since #42: queue with the overdue first, detail, start review, complete and reject; on a phone the bottom bar uses the short label "Pedidos"; proved by `integration_test/admin_titular_e2e.dart` via `scripts/qa/admin_titular_e2e.sh`): **Indicadores** (risk counters + TMRAV, the PRD's North Star metric), **Microáreas** (ACS↔microarea binding plus, since #43, the account management), **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 — since #43 the coordinator reads it scoped to the actors of his UBS instead of being refused). 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. + +**Gestão de contas (issue #43).** Microáreas is no longer read-only: `AcsManagementSection` and `StaffManagementSection` (`lib/app/admin_accounts.dart`) list the ACS of the session's scope (the admin sees the system, the coordinator only his UBS) with **Novo ACS**, **Vincular**, **Redefinir senha**, **Redefinir MFA** and **Desativar/Ativar**, plus the "Equipe do backoffice" section (admin only — the section is not even mounted for the coordinator, and the server refuses the listing). Every destructive action goes through `confirmarDestrutiva` (`_ConfirmacaoDestrutiva`, keys `confirmar_acao`/`cancelar_acao`) and **nothing reaches the server before the operator confirms** (the widget test proves that cancelling never calls the data source); linking and reactivating are not destructive and take one step, the link dialog choosing the micro-area first. The credentials the server shows exactly once — the initial/renewed ACS password and the staff activation code — come out in `_CredencialMostradaUmaVez` (`SelectableText` keyed `senha_inicial`/`codigo_ativacao`, a copy button and the explicit "não será mostrada de novo" warning). `AdminDataSource` gained `fetchAcs`, `fetchStaff`, `createAcs`, `setAcsMicroArea`, `setAcsActive`, `resetAcsPassword`, `resetAcsMfa` and `resetStaffMfa`, and `AdminValidationFailure` is the single failure whose message comes from the server and reaches the screen (`mensagemDaFalhaEscrita`) — every other refusal keeps the fixed `AdminDataFailure` text, so the reason of a permission refusal never becomes screen copy; a registration refusal is shown **inside the form** (`cadastro_erro`) instead of closing the dialog and losing what was typed. The list is reloaded from the server after every write (`onRecarregar`), never patched locally. Touch targets are `48×52` on the card buttons **and inside the dialogs** (`touch_targets_test.dart` measures the painted height too, since a `ButtonStyle` alone can paint less than it declares), the label that confirms uses `AdminColors.redOnSurface`, the form error is a `Semantics(liveRegion: true)`, and every dialog is `scrollable` so the long consequence sentence survives a 200% system font (WCAG 1.4.4/2.5.5/4.1.3). + +Login is real: `LoginScreen` (`lib/app/login_screen.dart`) calls `AdminAuthBackend.login` (`BackendAdminAuth` over `auth.loginStaff`, `lib/core/auth/admin_auth_backend.dart`) with matrícula + senha + TOTP (`totp_field` appears on `AdminMfaCodeRequired`; changing matrícula/senha discards it; `AdminMfaEnrollmentRequired` opens `MfaEnrollmentScreen`, QR + `mfa_secret`, nothing stored on the device). `SinalAdminApp` requires `auth:`; there is no bypass and `devLoginEnabled` no longer exists — `AdminHomeShell` needs the `AdminSession` the backend returned, and an expired session (checked at tab change and by a one-shot timer at `expiresAt`) sends the user back to login with "Sessão encerrada. Entre novamente.". `main.dart` builds the auth with `buildAdminAuth` (`admin_auth_bootstrap.dart`): https-only host, and the dev RPC CA from `assets/certs/dev_rpc_ca.crt` (copied by `scripts/dev/sync_dev_ca.sh`, gitignored, CI creates the empty folder) is the only extra trust; without it the client stays fail-closed. Tests use `test/support/fake_admin_auth.dart` and `entrarComCredenciais` in the layout harness. 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 at 800×600 (unedited until #44, whose "N vermelhos vencidos" banner pushed `alert_alert-3` of `alerts_screen_test.dart` a few pixels below the fold, so both lookups of that card — the positive one and the `findsNothing` after the filter — now pass `skipOffstage: false`, the negative one so it cannot pass just because the card sits below the fold; nothing else in them changed); 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/*_test.dart` is hermetic**, unlike the ACS and patient ones described above: it runs on `MockAdminDataSource` and a fake auth, so `flutter test integration_test/admin_mobile_smoke_test.dart -d emulator-5554` (which is also what `scripts/qa/e2e.sh --emulator --full` runs for the admin) needs no `docker compose`, no seed and no `--dart-define`. The one exception is `integration_test/admin_login_e2e.dart` (suffix `_e2e` on purpose, so discovery skips it): the real login and, since #40, the real panel (three seeded alerts → Vermelho 2, Verde 1, TMRAV 60s, labels only, the admin's own read in the audit list) against the e2e database, run by `scripts/qa/admin_login_e2e.sh` (same pattern as `acs_full_e2e.sh`: `e2e_stack.sh`, a synthetic staff admin without MFA from `seed_e2e_fixtures.dart`, credential delivered once by the relay at `/admin`, `support/totp.dart` an identical copy of the ACS one, host `https://localhost:8443/` over `adb reverse`, dev CA from the `assets/certs/dev_rpc_ca.crt` asset). The panel data is real since issue #40. Since #43 there is a second real-backend journey, `integration_test/admin_acs_gestao_e2e.dart` (also suffixed `_e2e`; `@Timeout(minutes: 6)` because of the real 30 s TOTP windows), run by `scripts/qa/admin_acs_gestao_e2e.sh` (same anatomy, plus the relay's `/coordenador` route for the seeded coordinator): the admin reads the initial password off the screen and logs in with it over RPC, links the ACS to another micro-area, resets the ACS MFA and the coordinator's MFA (the code emitted by the screen activates the new MFA), deactivates the account (login and refresh refused) and, in the coordinator's journey, proves he does not see "Equipe do backoffice" after scrolling the whole screen but does see and manage the ACS of his own UBS; the script then asserts the database state over `psql` (deactivated ACS, no live refresh token, the four `totp*` columns null, the activation code issued, the write rows in `audit_logs`). `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 screens with real screenshots (the #43 management flows are described in text, with the missing screenshots named as pending — never invented), mirroring `docs/telas-acs.md`. CI covers it in the `admin-app` job, identical in shape to `acs-app`/`patient-app`. + + +**Escalonar e tempo quase real (#44).** `MonitoringController` (`lib/app/monitoring_controller.dart`), dono do `AdminHomeShell`, chama **só** `admin.alertsPulse` a cada 30 s (timeout do pulso 20 s) enquanto Indicadores ou Alertas estão montadas (`MonitoredScreenState`); pausa em `paused`/`hidden` (não em `inactive`, para o painel numa segunda tela seguir atualizando) e manda `endMonitoringSession` sem `await`. O painel se atualiza pelo pulso (`admin.indicators` não é consultado); a lista de alertas é relida (só alertas, não microáreas) quando `maxChangedAt` ou as contagens mudam. Sem pulso bem-sucedido há mais de 60 s (`staleAfter`) aparece o `StaleBadge` (ícone + texto "Dados de HH:MM:SS — sem atualização há N min", `liveRegion`, cor `overdueOnSurface`). `EscalateAlertButton` (`escalate_alert_dialog.dart`) só aparece quando o servidor diz `overdue`, pede o motivo (conjunto fechado) e, depois do resultado, chama `monitoring.refreshNow()` (pulso imediato fora do ciclo). Painel e Alertas mostram, ao lado do título (`TitleWithOverdueRed`, `lib/app/overdue_red_banner.dart`, num `Wrap` para não empurrar a lista no desktop), a faixa "N vermelhos vencidos" (`Key('overdue_red_banner')`; singular "1 vermelho vencido"; some com 0 e antes do 1º pulso) a partir de `lastPulse.overdueRed`: ícone + texto em `redOnSurface` (par em `contrast_tokens_test.dart`), `Semantics(liveRegion: true)`, não é botão; o servidor lista os vencidos primeiro. O filtro de status da tela Alertas não oferece `AlertStatus.escalated` (sem escritor; escalonar mantém `pending`), mas `statusLabel` segue rotulando "Escalonado" nas linhas legadas (`test/overdue_red_banner_test.dart`). ACS: o backend republica o alerta com `escalated: true`; `PrioritizedAlert.escalated` sobe o cartão para o topo com o rótulo "Reforço da coordenação", sem nunca desfazer o ACK. **Limite conhecido:** o app do ACS não tem som, vibração nem notificação do SO; o reforço de um alerta já listado só é percebido com a lista aberta (ordem e rótulo; banner e contador não mudam). + +**E2E do escalonamento e do tempo real (#44).** `integration_test/admin_alertas_escalonar_e2e.dart` (sufixo `_e2e`, `@Timeout(minutes: 12)`), rodado só por `scripts/qa/admin_alertas_escalonar_e2e.sh [--com-regressao-offline]` (mesma anatomia dos outros: `e2e_stack.sh`, relé com `/coordenador`, `adb reverse`; estrutura guardada por `scripts/qa/admin_alertas_escalonar_e2e_test.sh`, sem Docker nem aparelho). A stack de e2e fixa `RED_ALERT_ESCALATION_SECONDS=30` (`docker-compose.e2e.yml`; a de desenvolvimento segue com 300) e o seed, com `E2E_SEED_ALERT_ESCALATION=1`, cria um vermelho pendente de 10 min na UBS do coordenador (bloco `escalationAlerts` do manifesto; ids passados ao teste por `--dart-define=E2E_ALERTA_VENCIDO/E2E_ALERTA_NOVO`). O teste fala com o script por **linhas marcadoras** lidas em tempo real da saída do `flutter test`: `E2E_DISPARAR_ALERTA`, impresso com o Painel aberto e parado, faz o script inserir por SQL o segundo alerta com `triggeredAt = NOW() + 20 s` (um alerta semeado ou criado "agora" já estaria vencido quando a tela abrisse; os +20 s deixam a janela "no prazo" maior que um pulso de 30 s), e `E2E_CORTAR_BACKEND` faz `docker pause sinalacs-traefik` por 120 s — a única rota do aparelho até o backend (`adb reverse tcp:8443` → :443 do host → Traefik → `serverpod:8080`; o 8080 não é publicado), enquanto o backend e a conexão MQTT dele (direto a `mosquitto:8883`) ficam no ar. Cortar o Wi-Fi do emulador não derruba o `adb reverse`, e pausar o próprio backend o derrubou na 1ª execução real (sem keepalive MQTT o broker fechou a conexão; no `unpause`, `SocketException: Broken pipe` e saída 255 — defeito latente do despachante, a acompanhar em issue própria). Depois do `unpause` o script confere que o backend segue no ar com o mesmo `StartedAt` e, se não, mostra o fim do log; o `cleanup` sempre despausa o Traefik (e o backend, por defesa). O pulso devolve `overdueRed` e `maxChangedAt` mais os indicadores embutidos, e o app compara os três para decidir recarregar a lista; o Painel desenha `lastPulse.indicators`. Sem tocar em filtro, puxar a lista nem trocar de aba nos trechos de prova, o teste mostra: o Painel passando de 1 a 2 abertos sozinho (`E2E_PAINEL_ATUALIZOU`, ≤ 40 s), botão ausente no prazo e presente ≤ 40 s depois dele (35 nominais + folga de relógio), escalonamento pela tela com motivo ("Pendente" + "Escalonado às"), painel ainda com 2 vermelhos abertos, reescalonamento por RPC com `alreadyEscalated` e o mesmo `escalatedAt` (o token vem da `AdminSession` capturada no `dataSourceFor`, sem segundo login) e o selo `stale_badge` que aparece na queda com os dados mantidos e some sozinho na volta. O script confere no banco uma linha `escalated:no_ack_timeout` e uma `already_escalated:no_ack_timeout`, o alerta `pending` com `escalatedBy` do coordenador, um único reforço no `alert_outbox`, `monitoring_session_start`, zero `read` do `admin_pulse`, os tetos de leitura do coordenador por recurso (indicadores 4, alertas 9, microáreas 3, auditoria 0; total 16) e a cadeia de auditoria. `--com-regressao-offline` roda `acs_full_e2e.sh` (a jornada offline do ACS) ao fim. Passo a passo manual no cabeçalho do script. + +**Auditoria completa (#45).** `AuditLogScreen` (`lib/app/audit_log_screen.dart`, extraída de `app.dart`, que a reexporta) registra o acesso antes de qualquer dado e mostra só o que o servidor diz: o app nunca calcula integridade. Filtros recolhíveis (busca, matrícula, papel, ação, tipo de recurso, resultado e período `from`/`to` por seletor de data), validação local com as regras do servidor (busca 2–64, matrícula ≤ 32, `^[a-z0-9_:]{1,64}$`), "Aplicar"/"Limpar", Enter aplica; "Carregar mais" por `nextBeforeSequence` sem repetir `sequence`; filtro novo zera o cursor e descarta resposta velha. Cada linha leva o selo do servidor com ícone + rótulo + `Semantics` (Íntegro / Adulterado? / Não verificável); o selo olha só para trás, então só o painel global acusa a quebra a jusante. O painel "Integridade da trilha" (`AuditChainReason` com os 8 motivos, `brokenInScope` `bool?`) só mostra "Cadeia íntegra" para `verified`, em verde; `broken` ("Falha de integridade na cadeia", `redOnSurface`, "Primeiro registro com falha: nº N", com a ressalva de que pode ser troca de chave, sem dizer "ataque"), `unavailable` ("Verificação indisponível") e `inconclusive` ("Verificação inconclusiva") trazem "isso não significa que a cadeia está íntegra"; erro de consulta não vira estado. O verde é exceção a `spec/ui_design.md` (D3, **pendente de aceite do dono**) e vem sempre com ícone + texto. A verificação roda ao abrir a tela e no botão "Verificar" (cache de 60 s no servidor). **Exportação** (seção montada só para `role == 'admin'`; o coordenador nem a vê): exige período aplicado, confirma ("será registrado na trilha…"), mostra contagem, intervalo de `sequence`, SHA-256, estado da cadeia (só `verified` diz "íntegra"; os outros dizem "Atenção: …") e entrega o CSV por **área de transferência** (`Clipboard.setData`, "Copiar CSV"; sem arquivo, sem pacote novo) com aviso de que a área de transferência é compartilhada com outros apps; o CSV vive só no estado da tela (descartado em "Fechar", ao mudar o filtro e no `dispose`, nunca desenhado ou logado) e **segue o filtro aplicado**, não o rascunho. Falhas com texto fixo do app (teto, permissão, genérica + "Tentar novamente"); `AuditExportFailedException` chega como erro genérico. O app inteiro é pt-BR por `flutter_localizations` (seletores de data/hora); o seletor de hora em modo teclado estoura com fonte ≥ 150% (defeito do Material). Abrir a Auditoria grava duas linhas (`admin_audit_logs` + `admin_audit_chain`). Menores abertos: corrida mínima em `_copiarCsv` se o filtro muda durante `Clipboard.setData`; Enter nos campos aplica filtro durante a exportação (seguro). Testes: `audit_log_screen_test.dart`, `audit_export_test.dart`, pares de contraste em `contrast_tokens_test.dart`. **E2E:** `scripts/qa/admin_auditoria_e2e.sh` (`DEVICE=`; sobe a stack de e2e com `E2E_SEED_AUDIT_TRAIL=1`, roda `integration_test/admin_auditoria_e2e.dart` no `emulator-5554`, adultera/restaura uma linha só em `sinalacs_e2e` por `e2e_stack.sh psql`, confere o banco e o CLI `audit_chain_check`); a estrutura é guardada por `bash scripts/qa/admin_auditoria_e2e_test.sh` (sem Docker nem aparelho, fora da CI como os outros `admin_*_e2e_test.sh`). O teste espera ~60 s (cache) antes de reverificar a adulteração. Evidência: `docs/superpowers/plans/2026-10-13-issue-45-e2e.log`. +**Avisos e Termos (#46).** A barra inferior do admin passa a ter 6 destinos, incluindo **Avisos** (`lib/app/notices_screen.dart`: agendar, cancelar e histórico, via `NoticeFormDialog` em `notice_form_dialog.dart`) com a sub-aba **Termos** visível só para o admin (`lib/app/terms_panel.dart` e `terms_form_dialog.dart`: rascunho, revisão jurídica, publicação; Publicar fica desabilitado até a revisão). Os rótulos da barra inferior foram encurtados ('Áreas', 'Trilha') para caber 6 destinos; decisão de produto a confirmar. Teste hermético no emulador: `integration_test/admin_notices_mobile_test.dart`. diff --git a/apps/acs/.flutter-plugins b/apps/acs/.flutter-plugins deleted file mode 100644 index 39146335..00000000 --- a/apps/acs/.flutter-plugins +++ /dev/null @@ -1,16 +0,0 @@ -# This is a generated file; do not edit or check into version control. -connectivity_plus=/home/codespace/.pub-cache/hosted/pub.dev/connectivity_plus-7.3.1/ -flutter_plugin_android_lifecycle=/home/codespace/.pub-cache/hosted/pub.dev/flutter_plugin_android_lifecycle-2.0.26/ -geolocator=/home/codespace/.pub-cache/hosted/pub.dev/geolocator-12.0.0/ -geolocator_android=/home/codespace/.pub-cache/hosted/pub.dev/geolocator_android-4.6.2/ -geolocator_apple=/home/codespace/.pub-cache/hosted/pub.dev/geolocator_apple-2.3.14/ -geolocator_web=/home/codespace/.pub-cache/hosted/pub.dev/geolocator_web-4.1.4/ -geolocator_windows=/home/codespace/.pub-cache/hosted/pub.dev/geolocator_windows-0.2.5/ -google_maps_flutter=/home/codespace/.pub-cache/hosted/pub.dev/google_maps_flutter-2.10.1/ -google_maps_flutter_android=/home/codespace/.pub-cache/hosted/pub.dev/google_maps_flutter_android-2.14.13/ -google_maps_flutter_ios=/home/codespace/.pub-cache/hosted/pub.dev/google_maps_flutter_ios-2.15.2/ -google_maps_flutter_web=/home/codespace/.pub-cache/hosted/pub.dev/google_maps_flutter_web-0.5.12/ -sqflite=/home/codespace/.pub-cache/hosted/pub.dev/sqflite-2.4.1/ -sqflite_android=/home/codespace/.pub-cache/hosted/pub.dev/sqflite_android-2.4.0/ -sqflite_darwin=/home/codespace/.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/ -sqflite_sqlcipher=/home/codespace/.pub-cache/hosted/pub.dev/sqflite_sqlcipher-3.2.0/ diff --git a/apps/acs/.flutter-plugins-dependencies b/apps/acs/.flutter-plugins-dependencies deleted file mode 100644 index 45ffef89..00000000 --- a/apps/acs/.flutter-plugins-dependencies +++ /dev/null @@ -1 +0,0 @@ -{"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/.gitignore b/apps/acs/.gitignore index 3820a95c..ba6d2380 100644 --- a/apps/acs/.gitignore +++ b/apps/acs/.gitignore @@ -27,6 +27,7 @@ migrate_working_dir/ **/doc/api/ **/ios/Flutter/.last_build_id .dart_tool/ +.flutter-plugins .flutter-plugins-dependencies .pub-cache/ .pub/ diff --git a/apps/acs/README.md b/apps/acs/README.md index 67e5b1a1..c37310f0 100644 --- a/apps/acs/README.md +++ b/apps/acs/README.md @@ -1,5 +1,7 @@ # SinalACS — App ACS -Aplicativo Flutter do Agente Comunitário de Saúde: login institucional (demonstrativo), dashboard de priorização dinâmica, territorialização por microárea e registro de visitas offline-first. +Aplicativo Flutter do Agente Comunitário de Saúde: login institucional com matrícula, senha e TOTP (refresh token no Keystore, liberado por biometria — provado só no emulador API 36; no API 30+ o PIN do aparelho também libera; ver `PROGRESS.md`), fila de visitas priorizada por risco e restrita à microárea, mapa e territorialização, registro de visitas offline em banco cifrado com sincronização, e alertas vermelhos por MQTT com confirmação. + +Execute com `./scripts/dev/run_acs.sh` a partir da raiz (não com `flutter run` direto): o script injeta a senha do broker, que não tem valor padrão. Ver o [README.md](../../README.md) na raiz do repositório para pré-requisitos, comandos de execução/teste e o estado atual do projeto, e [PROGRESS.md](../../PROGRESS.md) para o status detalhado dos milestones. Documentação visual das telas em [docs/telas-acs.md](../../docs/telas-acs.md). diff --git a/apps/acs/android/app/build.gradle.kts b/apps/acs/android/app/build.gradle.kts index 72506f74..582daa87 100644 --- a/apps/acs/android/app/build.gradle.kts +++ b/apps/acs/android/app/build.gradle.kts @@ -1,4 +1,6 @@ +import java.io.FileInputStream import java.util.Base64 +import java.util.Properties fun dartDefineValue(name: String): String? { val encoded = project.findProperty("dart-defines")?.toString() ?: return null @@ -9,6 +11,33 @@ fun dartDefineValue(name: String): String? { } } +// Chave de release: de `apps/acs/android/key.properties` (gitignorado) ou, para +// CI e shell, das variáveis SINALACS_KEYSTORE_*. Nenhum valor mora no repositório. +// storeFile=/caminho/para/release.jks +// storePassword=... +// keyAlias=... +// keyPassword=... +val keyProperties = Properties().apply { + val arquivo = rootProject.file("key.properties") + if (arquivo.exists()) FileInputStream(arquivo).use { load(it) } +} + +fun assinatura(propriedade: String, ambiente: String): String? = + keyProperties.getProperty(propriedade)?.takeIf { it.isNotBlank() } + ?: System.getenv(ambiente)?.takeIf { it.isNotBlank() } + +// `-Pfoo=false` não pode liberar nada: só o texto "true" vale (`hasProperty` +// aceitava qualquer valor, inclusive `false`). +fun licenca(nome: String): Boolean = project.findProperty(nome)?.toString() == "true" + +val releaseStoreFile = assinatura("storeFile", "SINALACS_KEYSTORE_PATH") +val releaseStorePassword = assinatura("storePassword", "SINALACS_KEYSTORE_PASSWORD") +val releaseKeyAlias = assinatura("keyAlias", "SINALACS_KEY_ALIAS") +val releaseKeyPassword = assinatura("keyPassword", "SINALACS_KEY_PASSWORD") +val temChaveDeRelease = listOf( + releaseStoreFile, releaseStorePassword, releaseKeyAlias, releaseKeyPassword, +).all { it != null } + plugins { id("com.android.application") // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. @@ -25,6 +54,12 @@ android { targetCompatibility = JavaVersion.VERSION_17 } + buildFeatures { + // AGP 9 desliga a geração de BuildConfig por padrão; a captura de tela do + // debug depende dela. + buildConfig = true + } + defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId = "br.com.prismrr.sinalacs.acs" @@ -35,13 +70,32 @@ android { versionCode = flutter.versionCode versionName = flutter.versionName manifestPlaceholders["GOOGLE_MAPS_API_KEY"] = dartDefineValue("GOOGLE_MAPS_API_KEY") ?: "" + // Captura de tela: FECHADA em todo build. Só o buildType debug pode abri-la. + buildConfigField("boolean", "ALLOW_SCREEN_CAPTURE", "false") + } + + signingConfigs { + if (temChaveDeRelease) { + create("release") { + storeFile = file(releaseStoreFile!!) + storePassword = releaseStorePassword + keyAlias = releaseKeyAlias + keyPassword = releaseKeyPassword + } + } } buildTypes { + debug { + // Opt-out para QA, vídeo de demonstração e Firebase Test Lab: só aqui e só + // com `-Psinalacs.allowScreenCapture=true` (o texto exato `true`). Release + // herda o `false` do defaultConfig e não lê a propriedade. + buildConfigField("boolean", "ALLOW_SCREEN_CAPTURE", licenca("sinalacs.allowScreenCapture").toString()) + } release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("debug") + // Sem chave, a configuração continua carregável (sync da IDE, debug): + // quem barra a build de RELEASE é o guard `taskGraph.whenReady` abaixo. + signingConfig = signingConfigs.getByName(if (temChaveDeRelease) "release" else "debug") } } } @@ -56,6 +110,12 @@ flutter { source = "../.." } +dependencies { + // BiometricPrompt com CryptoObject para o cofre do refresh token + // (KeystoreVault.kt). Fixada na 1.1.0 estável: a 1.2.0 é alpha. + implementation("androidx.biometric:biometric:1.1.0") +} + // 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 @@ -88,7 +148,7 @@ fun hasMqttPassword(): Boolean { tasks.configureEach { if (!name.startsWith("compileFlutterBuild")) return@configureEach doFirst { - val allowMissing = project.hasProperty("sinalacs.allowMissingMqttPassword") + val allowMissing = licenca("sinalacs.allowMissingMqttPassword") if (hasMqttPassword()) return@doFirst if (allowMissing) { logger.warn("aviso: compilando sem SINALACS_MQTT_PASSWORD — este APK não vai receber alerta nenhum.") @@ -115,3 +175,60 @@ tasks.configureEach { ) } } + +// Guard de release: só quando uma tarefa de release está no grafo (o sync da IDE +// e `flutter run` em debug não passam por aqui). Sem chave de release, assinar +// com a de debug exige pedir (-Psinalacs.allowDebugSigning=true). +gradle.taskGraph.whenReady { + val buildaRelease = allTasks.any { + it.path.endsWith(":assembleRelease") || + it.path.endsWith(":bundleRelease") || + it.path.endsWith(":packageRelease") + } + if (buildaRelease && !temChaveDeRelease && !licenca("sinalacs.allowDebugSigning")) { + val faltam = listOf( + "storeFile (SINALACS_KEYSTORE_PATH)" to releaseStoreFile, + "storePassword (SINALACS_KEYSTORE_PASSWORD)" to releaseStorePassword, + "keyAlias (SINALACS_KEY_ALIAS)" to releaseKeyAlias, + "keyPassword (SINALACS_KEY_PASSWORD)" to releaseKeyPassword, + ).filter { it.second == null }.joinToString(", ") { it.first } + throw GradleException( + """ + |Sem chave de assinatura de release: esta build sairia assinada com a chave de debug. + |Faltando: $faltam + | + |Informe a chave por apps/acs/android/key.properties (storeFile, storePassword, + |keyAlias, keyPassword) ou pelas variáveis SINALACS_KEYSTORE_PATH, + |SINALACS_KEYSTORE_PASSWORD, SINALACS_KEY_ALIAS e SINALACS_KEY_PASSWORD. + | + |Para assinar com a chave de debug DE PROPÓSITO (teste local, nunca distribuição): + | flutter build apk --release -Psinalacs.allowDebugSigning=true + """.trimMargin() + ) + } + + // A chave privada de CLIENTE do broker de desenvolvimento (assets/certs/acs_client.key) + // não pode ir dentro de um APK de release: qualquer pessoa com o APK a extrairia. + if (buildaRelease && licenca("sinalacs.allowScreenCapture")) { + throw GradleException( + """ + |-Psinalacs.allowScreenCapture=true não vale em build de release: a janela do ACS + |(que mostra nome e condições crônicas de pacientes) fica sem captura de tela + |liberada SOMENTE em debug. Para gravar a demonstração, gere a build de debug: + | ./scripts/dev/run_acs.sh --build --captura + """.trimMargin() + ) + } + val chaveDeDev = rootProject.file("../assets/certs/acs_client.key") + if (buildaRelease && chaveDeDev.exists() && !licenca("sinalacs.allowDevClientKey")) { + throw GradleException( + """ + |O release levaria a chave privada de desenvolvimento do broker (assets/certs/acs_client.key). + | + |Remova o arquivo antes de gerar o release (em produção o certificado de cliente é + |provisionado por aparelho — ainda não implementado). Para um release de TESTE LOCAL, + |acrescente -Psinalacs.allowDevClientKey=true. + """.trimMargin() + ) + } +} diff --git a/apps/acs/android/app/src/main/AndroidManifest.xml b/apps/acs/android/app/src/main/AndroidManifest.xml index 84c2c3ab..b2cde239 100644 --- a/apps/acs/android/app/src/main/AndroidManifest.xml +++ b/apps/acs/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,15 @@ + + + + + + + + + diff --git a/apps/acs/android/app/src/main/kotlin/br/com/prismrr/sinalacs/acs/KeystoreVault.kt b/apps/acs/android/app/src/main/kotlin/br/com/prismrr/sinalacs/acs/KeystoreVault.kt new file mode 100644 index 00000000..a2109858 --- /dev/null +++ b/apps/acs/android/app/src/main/kotlin/br/com/prismrr/sinalacs/acs/KeystoreVault.kt @@ -0,0 +1,335 @@ +package br.com.prismrr.sinalacs.acs + +import android.content.Context +import android.os.Build +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyPermanentlyInvalidatedException +import android.security.keystore.KeyProperties +import android.util.Base64 +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import java.security.KeyFactory +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.PrivateKey +import java.security.PublicKey +import java.security.spec.MGF1ParameterSpec +import java.security.spec.X509EncodedKeySpec +import java.util.concurrent.atomic.AtomicBoolean +import javax.crypto.AEADBadTagException +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.OAEPParameterSpec +import javax.crypto.spec.PSource +import javax.crypto.spec.SecretKeySpec + +/** + * Cofre do refresh token (envelope): RSA-2048/OAEP no Android Keystore com + * autenticação do usuário exigida a CADA decifra (CryptoObject) protege uma + * chave AES-256-GCM descartável que cifra o token. Cifrar usa a chave pública e + * não pede prompt. Só o texto cifrado fica em SharedPreferences privado: sem a + * chave do TEE ele não vale nada. Nunca registre token, chave ou exceção. + * + * Falha fechada: blob corrompido, truncado ou com tag GCM adulterada nunca vai + * decifrar — é apagado (com a chave) e responde `invalidated`, como a chave + * invalidada; qualquer outro erro inesperado vira `unavailable`, sem mensagem. + */ +class KeystoreVault(private val activity: FragmentActivity) : MethodChannel.MethodCallHandler { + companion object { + const val CHANNEL = "br.com.prismrr.sinalacs.acs/keystore_vault" + private const val PROVIDER = "AndroidKeyStore" + private const val PREFS = "sinalacs_keystore_vault" + private const val TRANSFORMATION = "RSA/ECB/OAEPWithSHA-256AndMGF1Padding" + private const val AES = "AES/GCM/NoPadding" + private const val GCM_TAG_BITS = 128 + private const val GCM_IV_BYTES = 12 + private const val AES_KEY_BYTES = 32 + // O provedor do Keystore usa MGF1 com SHA-1 mesmo pedindo SHA-256; os + // dois lados (cifra fora do Keystore e decifra dentro) usam esta spec. + private val OAEP = OAEPParameterSpec( + "SHA-256", "MGF1", MGF1ParameterSpec.SHA1, PSource.PSpecified.DEFAULT, + ) + } + + /** Responde ao canal uma única vez; respostas tardias são descartadas. */ + private class OnceResult(private val inner: MethodChannel.Result) : MethodChannel.Result { + private val done = AtomicBoolean(false) + override fun success(value: Any?) { + if (done.compareAndSet(false, true)) inner.success(value) + } + override fun error(code: String, message: String?, details: Any?) { + if (done.compareAndSet(false, true)) inner.error(code, message, details) + } + override fun notImplemented() { + if (done.compareAndSet(false, true)) inner.notImplemented() + } + } + + /** Blob já validado no formato: IV(12) . chaveAES cifrada com RSA . token cifrado. */ + private class Envelope(val iv: ByteArray, val wrappedKey: ByteArray, val body: ByteArray) + + private val authenticators: Int = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + BiometricManager.Authenticators.BIOMETRIC_STRONG or + BiometricManager.Authenticators.DEVICE_CREDENTIAL + } else { + // Antes da API 30 o CryptoObject só aceita biometria forte. + BiometricManager.Authenticators.BIOMETRIC_STRONG + } + + private val prefs get() = activity.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + private fun keyAlias(alias: String) = "sinalacs.vault.$alias" + private fun keyStore() = KeyStore.getInstance(PROVIDER).apply { load(null) } + + // Chamado na thread da plataforma (principal). seal/delete/contains rodam + // aqui mesmo; unseal abre o BiometricPrompt nesta thread e responde depois, + // pelo callback, também na principal (executor do ContextCompat). + override fun onMethodCall(call: MethodCall, raw: MethodChannel.Result) { + val result = OnceResult(raw) + try { + val alias = call.argument("alias") + when (call.method) { + "isSupported" -> result.success( + BiometricManager.from(activity).canAuthenticate(authenticators) == + BiometricManager.BIOMETRIC_SUCCESS, + ) + "contains" -> result.success(alias != null && prefs.contains(alias)) + "seal" -> { + seal(alias!!, call.argument("plaintext")!!) + result.success(null) + } + "unseal" -> unseal(alias!!, call.argument("reason") ?: "", result) + "delete" -> { + delete(alias!!) + result.success(null) + } + else -> result.notImplemented() + } + } catch (e: Exception) { + // Sem getMessage: o texto do Keystore não vai para o canal nem para log. + result.error("unavailable", null, null) + } + } + + private fun generateKey(alias: String) { + val builder = KeyGenParameterSpec.Builder( + keyAlias(alias), + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) + .setKeySize(2048) + .setDigests(KeyProperties.DIGEST_SHA256) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP) + .setUserAuthenticationRequired(true) + // Só tem efeito com chave SÓ de biometria (API < 30). Na API 30+, + // com DEVICE_CREDENTIAL, a chave inclui o SID do bloqueio de tela, + // que o token de autenticação da digital também carrega: uma + // digital nova NÃO invalida (provado no emulador API 36, + // 2026-10-05). Remover o bloqueio de tela apaga a chave. + .setInvalidatedByBiometricEnrollment(true) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + builder.setUserAuthenticationParameters( + 0, // a cada uso + KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL, + ) + } + // Antes da API 30 o padrão (validade -1) já é "a cada uso, só biometria". + KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_RSA, PROVIDER).run { + initialize(builder.build()) + generateKeyPair() + } + } + + /** + * Chave privada utilizável, ou `null` se ausente. Lança + * [KeyPermanentlyInvalidatedException] quando a chave foi invalidada + * (bloqueio de tela removido; na API < 30 também uma biometria nova; o + * Android pode apagá-la de vez, e aí volta `null`). `init` não pede + * autenticação: ela é cobrada no `doFinal`, via CryptoObject. + */ + private fun initDecrypt(alias: String): Cipher? { + val key = keyStore().getKey(keyAlias(alias), null) as? PrivateKey ?: return null + return Cipher.getInstance(TRANSFORMATION).apply { init(Cipher.DECRYPT_MODE, key, OAEP) } + } + + private fun publicKey(alias: String): PublicKey { + // Nunca selar um token novo sob uma chave já invalidada: ele nasceria + // ilegível. Se a privada morreu (ou sumiu), recria o par. + val usable = try { + initDecrypt(alias) != null + } catch (e: KeyPermanentlyInvalidatedException) { + false + } + val ks = keyStore() + if (!usable) { + if (ks.containsAlias(keyAlias(alias))) ks.deleteEntry(keyAlias(alias)) + generateKey(alias) + } + val pub = keyStore().getCertificate(keyAlias(alias)).publicKey + // Recria a chave fora do Keystore: a original herda as restrições de + // autenticação e falharia ao cifrar sem prompt em alguns aparelhos. + return KeyFactory.getInstance(pub.algorithm).generatePublic(X509EncodedKeySpec(pub.encoded)) + } + + private fun b64(b: ByteArray) = Base64.encodeToString(b, Base64.NO_WRAP) + private fun unb64(s: String) = Base64.decode(s, Base64.NO_WRAP) + + /** + * Envelope: AES-256-GCM descartável cifra o token; a chave AES (32 bytes) + * é cifrada com a pública RSA. Blob: IV.chaveAES_RSA.token_AES (Base64). + */ + private fun seal(alias: String, plaintext: String) { + val aesKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + val aes = Cipher.getInstance(AES).apply { init(Cipher.ENCRYPT_MODE, aesKey) } + val body = aes.doFinal(plaintext.toByteArray(Charsets.UTF_8)) + val rsa = Cipher.getInstance(TRANSFORMATION).apply { + init(Cipher.ENCRYPT_MODE, publicKey(alias), OAEP) + } + val wrapped = rsa.doFinal(aesKey.encoded) + // `commit` (síncrono): o blob novo tem de estar em disco antes do legado + // ser apagado. Se não gravou, falha (o Dart recebe `unavailable`). + val ok = prefs.edit().putString(alias, b64(aes.iv) + "." + b64(wrapped) + "." + b64(body)).commit() + check(ok) + } + + private fun delete(alias: String) { + prefs.edit().remove(alias).commit() + val ks = keyStore() + if (ks.containsAlias(keyAlias(alias))) ks.deleteEntry(keyAlias(alias)) + } + + /** Valida o formato ANTES do prompt: blob truncado não abre biometria à toa. */ + private fun parse(blob: String): Envelope? = try { + val parts = blob.split(".") + if (parts.size != 3) { + null + } else { + val env = Envelope(unb64(parts[0]), unb64(parts[1]), unb64(parts[2])) + // Corpo menor que a tag GCM não pode ser um token cifrado. + if (env.iv.size != GCM_IV_BYTES || env.wrappedKey.isEmpty() || + env.body.size < GCM_TAG_BITS / 8 + ) null else env + } + } catch (e: IllegalArgumentException) { + null + } + + private fun unseal(alias: String, reason: String, result: MethodChannel.Result) { + val blob = prefs.getString(alias, null) + if (blob == null) { + result.success(null) + return + } + val envelope = parse(blob) + if (envelope == null) { + // Corrompido ou truncado: nunca decifrará. Apaga para não prender o + // ACS num "indisponível" eterno; o Dart cai no login completo. + delete(alias) + result.error("invalidated", null, null) + return + } + val cipher: Cipher + try { + val c = initDecrypt(alias) + if (c == null) { + // Blob sem chave (ex.: Keystore limpo): nunca decifrará. + delete(alias) + result.error("invalidated", null, null) + return + } + cipher = c + } catch (e: KeyPermanentlyInvalidatedException) { + delete(alias) + result.error("invalidated", null, null) + return + } catch (e: Exception) { + result.error("unavailable", null, null) + return + } + + val info = BiometricPrompt.PromptInfo.Builder() + .setTitle("SinalACS") + .setSubtitle(reason) + .setAllowedAuthenticators(authenticators) + .apply { + // Com DEVICE_CREDENTIAL o botão negativo é proibido. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) setNegativeButtonText("Cancelar") + } + .build() + + val callback = object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(r: BiometricPrompt.AuthenticationResult) { + var aesBytes: ByteArray? = null + try { + // O CryptoObject é o RSA privado: libera a chave AES, que decifra o token. + val rsa = r.cryptoObject?.cipher ?: throw IllegalStateException() + aesBytes = rsa.doFinal(envelope.wrappedKey) + if (aesBytes.size != AES_KEY_BYTES) throw IllegalStateException() + val aes = Cipher.getInstance(AES).apply { + init( + Cipher.DECRYPT_MODE, + SecretKeySpec(aesBytes, "AES"), + GCMParameterSpec(GCM_TAG_BITS, envelope.iv), + ) + } + // GCM só devolve bytes depois de validar a tag: adulterado + // lança AEADBadTagException e nunca sai token parcial. + result.success(String(aes.doFinal(envelope.body), Charsets.UTF_8)) + } catch (e: AEADBadTagException) { + // Tag GCM não confere: blob adulterado ou corrompido. + discard(alias, result) + } catch (e: Exception) { + result.error("unavailable", null, null) + } finally { + aesBytes?.fill(0) + } + } + + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + val mapped = when (errorCode) { + BiometricPrompt.ERROR_USER_CANCELED, + BiometricPrompt.ERROR_NEGATIVE_BUTTON, + BiometricPrompt.ERROR_CANCELED -> "cancelled" + BiometricPrompt.ERROR_LOCKOUT, + BiometricPrompt.ERROR_LOCKOUT_PERMANENT -> "lockedOut" + else -> "unavailable" + } + result.error(mapped, null, null) + } + // onAuthenticationFailed (digital errada) não encerra: o prompt segue. + } + + // BiometricPrompt exige a thread principal; onMethodCall já roda nela, + // e runOnUiThread executa na hora nesse caso. Uma falha ao abrir o + // prompt responde uma vez só. + activity.runOnUiThread { + // Com o estado do FragmentManager já salvo (activity indo para o + // fundo), a biometric 1.1.0 NÃO lança: registra e retorna em + // silêncio, sem callback nenhum — o Dart esperaria para sempre. + if (activity.supportFragmentManager.isStateSaved) { + result.error("cancelled", null, null) + return@runOnUiThread + } + try { + BiometricPrompt(activity, ContextCompat.getMainExecutor(activity), callback) + .authenticate(info, BiometricPrompt.CryptoObject(cipher)) + } catch (e: Exception) { + result.error("unavailable", null, null) + } + } + } + + /** Apaga blob e chave de um envelope que nunca decifrará e responde `invalidated`. */ + private fun discard(alias: String, result: MethodChannel.Result) { + try { + delete(alias) + result.error("invalidated", null, null) + } catch (e: Exception) { + result.error("unavailable", null, null) + } + } +} diff --git a/apps/acs/android/app/src/main/kotlin/br/com/prismrr/sinalacs/acs/MainActivity.kt b/apps/acs/android/app/src/main/kotlin/br/com/prismrr/sinalacs/acs/MainActivity.kt index 0f2f7772..c6aea85f 100644 --- a/apps/acs/android/app/src/main/kotlin/br/com/prismrr/sinalacs/acs/MainActivity.kt +++ b/apps/acs/android/app/src/main/kotlin/br/com/prismrr/sinalacs/acs/MainActivity.kt @@ -1,5 +1,34 @@ package br.com.prismrr.sinalacs.acs -import io.flutter.embedding.android.FlutterActivity +import android.os.Bundle +import android.view.WindowManager +import io.flutter.embedding.android.FlutterFragmentActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel -class MainActivity : FlutterActivity() +class MainActivity : FlutterFragmentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // A lista da microárea mostra nome e condições crônicas. FLAG_SECURE + // bloqueia captura de tela, gravação e a miniatura nos apps recentes, + // na janela INTEIRA: Área, Visita e o seletor de paciente a exibem, e + // proteger tela por tela deixaria uma de fora. + // + // A ÚNICA exceção é a constante de compilação abaixo, que o Gradle só liga no + // buildType debug por `-Psinalacs.allowScreenCapture=true` e recusa em release: + // nenhum menu, intent ou preferência a altera. + if (!BuildConfig.ALLOW_SCREEN_CAPTURE) { + window.setFlags( + WindowManager.LayoutParams.FLAG_SECURE, + WindowManager.LayoutParams.FLAG_SECURE, + ) + } + } + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + // Cofre do refresh token: decifra só com BiometricPrompt/CryptoObject. + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, KeystoreVault.CHANNEL) + .setMethodCallHandler(KeystoreVault(this)) + } +} diff --git a/apps/acs/integration_test/encrypted_storage_test.dart b/apps/acs/integration_test/encrypted_storage_test.dart index 5330b601..8c16336d 100644 --- a/apps/acs/integration_test/encrypted_storage_test.dart +++ b/apps/acs/integration_test/encrypted_storage_test.dart @@ -24,6 +24,8 @@ import 'package:sinalacs_acs/core/security/database_key_store.dart'; import 'package:sinalacs_acs/core/services/offline_visit_queue.dart'; import 'package:sqflite_sqlcipher/sqflite.dart' as sqlcipher; +import 'support/legacy_v6.dart'; + /// Marcador sintético que precisa NÃO aparecer no arquivo. const marcador = 'MARCADOR-SINTETICO-NAO-DEVE-VAZAR'; @@ -42,6 +44,7 @@ void main() { test('o arquivo do banco não contém o conteúdo da visita em texto plano', () async { final store = SqlCipherVisitStore( + owner: 'acs-teste', keyStore: InMemoryDatabaseKeyStore(), databaseName: databaseName, ); @@ -96,6 +99,7 @@ void main() { test('o banco não abre com a chave errada', () async { // Se abrisse, a criptografia não estaria protegendo nada. final store = SqlCipherVisitStore( + owner: 'acs-teste', keyStore: InMemoryDatabaseKeyStore(initialKey: generateDatabaseKey()), databaseName: databaseName, ); @@ -136,6 +140,7 @@ void main() { await legado.close(); final store = SqlCipherVisitStore( + owner: 'acs-teste', keyStore: InMemoryDatabaseKeyStore(initialKey: key), databaseName: databaseName, ); @@ -157,6 +162,7 @@ void main() { // correspondente. Sem recuperação, o app ficaria travado para sempre. final keyStore = InMemoryDatabaseKeyStore(); final first = SqlCipherVisitStore( + owner: 'acs-teste', keyStore: keyStore, databaseName: databaseName, ); @@ -169,14 +175,220 @@ void main() { await keyStore.delete(); final second = SqlCipherVisitStore( + owner: 'acs-teste', keyStore: keyStore, databaseName: databaseName, ); - // Não lança: descarta o arquivo ilegível e recomeça vazio. + // Não lança: põe o arquivo ilegível de lado (`.recuperado`, ainda + // cifrado) e recomeça vazio. expect(await second.load(), isEmpty); await second.close(); + final copia = File('${await EncryptedLocalDatabase.pathFor(databaseName)}${EncryptedLocalDatabase.recoverySuffix}'); + expect(copia.existsSync(), isTrue); + expect(String.fromCharCodes((await copia.readAsBytes()).take(15)), isNot('SQLite format 3'), + reason: 'a cópia continua cifrada'); + await EncryptedLocalDatabase.deleteRecoveryCopy(databaseName); + + await EncryptedLocalDatabase.deleteDatabaseFile(databaseName); + }); + + // ---- Fila por dono (schema v7) no SQLCipher real ------------------------- + // + // Mesma lógica de test/visit_storage_owner_test.dart, mas no caminho do + // sqflite_sqlcipher (ALTER/upgrade e transações no arquivo cifrado), que o + // `flutter test` na VM não exercita. + + test('dois donos e a quarentena no mesmo arquivo: B grava e limpa sem tocar em A nem no legado', () async { + final key = generateDatabaseKey(); + await createV6Database( + databaseName: databaseName, + passphrase: key, + rows: [legacyV6Row(localId: 'legado-1', patientId: seedPatientId, notes: '$marcador-LEGADO')], + ); + final storage = SqlCipherVisitStorage( + keyStore: InMemoryDatabaseKeyStore(initialKey: key), + databaseName: databaseName, + ); + final a = storage.forOwner('acs-a'); + final b = storage.forOwner('acs-b'); + + final visitaA1 = OfflineVisitRecord(patientId: seedPatientId, risk: 'red', status: 'PENDENTE', notes: '$marcador-A1'); + final visitaA2 = OfflineVisitRecord( + patientId: seedPatientId, + risk: 'yellow', + status: 'PENDENTE', + rejectionReason: 'recusada (sintético)', + ); + await a.save([visitaA1, visitaA2]); + final visitaB = OfflineVisitRecord(patientId: seedPatientId, risk: 'green', status: 'PENDENTE', notes: '$marcador-B'); + await b.save([visitaB]); + + // Cada dono vê só as suas; a quarentena só pelo legado. + expect((await a.load()).map((v) => v.localId).toSet(), {visitaA1.localId, visitaA2.localId}); + expect((await b.load()).map((v) => v.localId), [visitaB.localId]); + expect((await storage.legacy.load()).map((v) => v.localId), ['legado-1']); + expect(await storage.countsByOwner(), {'acs-a': 2, 'acs-b': 1}, reason: 'a quarentena não é de dono nenhum'); + expect((await a.load()).firstWhere((v) => v.localId == visitaA2.localId).rejectionReason, 'recusada (sintético)'); + + // B tenta gravar um localId de A: tudo ou nada, a linha de A fica. + await expectLater(b.save([visitaB, visitaA1]), throwsA(isA())); + // ...e um localId da quarentena: idem. + await expectLater( + b.save([OfflineVisitRecord(localId: 'legado-1', patientId: seedPatientId, risk: 'red', status: 'PENDENTE')]), + throwsA(isA()), + ); + expect((await b.load()).map((v) => v.localId), [visitaB.localId], reason: 'a gravação recusada foi desfeita'); + expect(await a.load(), hasLength(2)); + + // B limpa a própria fila (o que a fila faz depois de sincronizar tudo). + await b.save([]); + expect(await b.load(), isEmpty); + expect(await a.load(), hasLength(2), reason: 'o save de B nunca apaga linhas de A'); + expect(await storage.legacy.load(), hasLength(1), reason: 'nem da quarentena'); + + // A remoção legada por localId não alcança linhas com dono. + await storage.legacy.remove([visitaA1.localId]); + expect(await a.load(), hasLength(2)); + + await storage.close(); + + // Nenhuma nota (de A, de B, do legado) nem o UUID do paciente em claro. + final bytes = await File(await EncryptedLocalDatabase.pathFor(databaseName)).readAsBytes(); + final conteudo = String.fromCharCodes(bytes); + expect(String.fromCharCodes(bytes.take(15)), isNot('SQLite format 3')); + expect(conteudo.contains(marcador), isFalse, reason: 'texto de nota legível no arquivo'); + expect(conteudo.contains(seedPatientId), isFalse); + expect(conteudo.contains('acs-a'), isFalse, reason: 'nem o dono'); + + await EncryptedLocalDatabase.deleteDatabaseFile(databaseName); + }); + + test('banco v6 criado no aparelho migra para v7: as linhas vão para a QUARENTENA, cache e cursor ficam', () async { + final key = generateDatabaseKey(); + await createV6Database( + databaseName: databaseName, + passphrase: key, + rows: [ + legacyV6Row(localId: 'v6-pendente', patientId: seedPatientId, notes: '$marcador-V6'), + legacyV6Row( + localId: 'v6-recusada', + patientId: seedPatientId, + rejectionReason: 'fora da microárea (sintético)', + createdAt: DateTime.utc(2026, 9, 2), + ), + ], + cacheRows: [ + {'patient_id': seedPatientId, 'name': 'Paciente Sintético', 'is_chronic': 0, 'chronic_conditions': '[]'}, + ], + cursor: {'pull|acs-antigo': '2026-09-01T00:00:00.000Z'}, + ); + + // Antes: v6, sem `owner`. + final antes = await sqlcipher.openDatabase(await EncryptedLocalDatabase.pathFor(databaseName), password: key); + expect(await antes.getVersion(), 6); + final colunasAntes = await antes.rawQuery("PRAGMA table_info('offline_visits')"); + expect(colunasAntes.any((c) => c['name'] == 'owner'), isFalse); + await antes.close(); + + // Abre pelas classes do app: migra. + final storage = SqlCipherVisitStorage( + keyStore: InMemoryDatabaseKeyStore(initialKey: key), + databaseName: databaseName, + ); + final legado = await storage.legacy.load(); + expect(legado.map((v) => v.localId), ['v6-pendente', 'v6-recusada'], reason: 'nenhuma linha perdida na migração'); + expect(legado.first.notes, '$marcador-V6', reason: 'conteúdo preservado'); + expect(legado.last.rejectionReason, 'fora da microárea (sintético)'); + + // Invisível a QUALQUER dono, inclusive um que grave em seguida. + for (final dono in ['acs-a', 'acs-b', 'acs-antigo']) { + expect(await storage.forOwner(dono).load(), isEmpty, reason: '$dono não pode adotar o legado'); + } + await storage.forOwner('acs-a').save([OfflineVisitRecord(patientId: seedPatientId, risk: 'red', status: 'PENDENTE')]); + await storage.forOwner('acs-a').save([]); + expect(await storage.legacy.load(), hasLength(2), reason: 'o save de um dono não apaga a quarentena'); + expect(await storage.countsByOwner(), isEmpty); + + // O schema ficou na v7, com `owner` nulo nas linhas antigas; o resto da v6 intacto. + final db = await storage.database.open(); + expect(await db.getVersion(), EncryptedLocalDatabase.schemaVersion); + expect(EncryptedLocalDatabase.schemaVersion, 7); + final semDono = await db.rawQuery('SELECT COUNT(*) AS n FROM offline_visits WHERE owner IS NULL'); + expect(semDono.single['n'], 2); + expect((await db.query('micro_area_cache')).single['patient_id'], seedPatientId); + expect((await db.query('sync_cursor')).single['key'], 'pull|acs-antigo'); + + await storage.close(); + final bytes = await File(await EncryptedLocalDatabase.pathFor(databaseName)).readAsBytes(); + expect(String.fromCharCodes(bytes).contains(marcador), isFalse); + await EncryptedLocalDatabase.deleteDatabaseFile(databaseName); + }); + + test('wipeAllData: recusa com linha recusada, legada ou de outro dono; com tudo enviado apaga as quatro tabelas', () async { + final key = generateDatabaseKey(); + await createV6Database( + databaseName: databaseName, + passphrase: key, + rows: [legacyV6Row(localId: 'legado-wipe', patientId: seedPatientId)], + cacheRows: [ + {'patient_id': seedPatientId, 'name': 'Paciente Sintético', 'is_chronic': 0, 'chronic_conditions': '[]'}, + ], + cursor: {'pull|acs-a': '2026-09-01T00:00:00.000Z'}, + ); + final storage = SqlCipherVisitStorage( + keyStore: InMemoryDatabaseKeyStore(initialKey: key), + databaseName: databaseName, + ); + final a = storage.forOwner('acs-a'); + final b = storage.forOwner('acs-b'); + final db = await storage.database.open(); + await db.insert('micro_area_cache_meta', {'key': 'owner', 'value': 'acs-a|area'}); + + Future> contagens() async => { + for (final t in ['offline_visits', 'sync_cursor', 'micro_area_cache', 'micro_area_cache_meta']) + t: ((await db.rawQuery('SELECT COUNT(*) AS n FROM $t')).single['n']! as int), + }; + Future recusa(int esperado, String motivo) async { + final antes = await contagens(); + await expectLater( + storage.wipeAllData(), + throwsA(isA().having((e) => e.unsent, 'unsent', esperado)), + reason: motivo, + ); + expect(await contagens(), antes, reason: 'recusado = nada apagado ($motivo)'); + } + + // 1) Só o legado (quarentena). + await recusa(1, 'linha legada'); + await storage.legacy.remove(['legado-wipe']); + + // 2) Só uma visita RECUSADA de A (fora da fila de reenvio, mas não enviada). + final recusada = OfflineVisitRecord( + patientId: seedPatientId, + risk: 'red', + status: 'PENDENTE', + rejectionReason: 'recusada (sintético)', + ); + await a.save([recusada]); + await recusa(1, 'linha recusada'); + await a.save([]); + + // 3) Só uma pendente de OUTRO dono (B), com A como "sessão atual". + await b.save([OfflineVisitRecord(patientId: seedPatientId, risk: 'green', status: 'PENDENTE')]); + await recusa(1, 'linha de outro dono'); + await b.save([]); + + // 4) Tudo enviado: apaga visitas, cursor e cache da microárea. + expect(await contagens(), {'offline_visits': 0, 'sync_cursor': 1, 'micro_area_cache': 1, 'micro_area_cache_meta': 1}); + await storage.wipeAllData(); + expect(await contagens(), {'offline_visits': 0, 'sync_cursor': 0, 'micro_area_cache': 0, 'micro_area_cache_meta': 0}); + + // O arquivo e a chave continuam: o banco segue utilizável depois do wipe. + await a.save([OfflineVisitRecord(patientId: seedPatientId, risk: 'red', status: 'PENDENTE')]); + expect(await a.load(), hasLength(1)); + await storage.close(); await EncryptedLocalDatabase.deleteDatabaseFile(databaseName); }); } diff --git a/apps/acs/integration_test/full_journey_e2e.dart b/apps/acs/integration_test/full_journey_e2e.dart new file mode 100644 index 00000000..bbeaafb4 --- /dev/null +++ b/apps/acs/integration_test/full_journey_e2e.dart @@ -0,0 +1,664 @@ +/// Jornada completa do ACS contra o BANCO DE TESTE, pela tela, com login +/// institucional real (RF07): senha errada, seletor de pacientes restrito à +/// microárea (RNF06) e visita sincronizada, conferida no servidor. Rodada por +/// `scripts/qa/acs_full_e2e.sh`. +/// +/// NÃO cobre MQTT/ACK: o `aclfile` do broker só libera o UUID de microárea do +/// seed de desenvolvimento e as fixtures daqui são aleatórias, então o broker +/// nega o ACS. O alerta pelo broker é provado por `smoke_test.dart` e +/// `red_alert_cycle_test.dart` na stack de desenvolvimento. +/// +/// Sufixo `_e2e.dart` (não `_test.dart`): `flutter test integration_test` +/// descobre `*_test.dart` por pasta e o `e2e.sh --full` rodaria isto contra a +/// stack de desenvolvimento, onde não há fixtures nem relé. +/// +/// O último teste (MFA) também prova o refresh token no aparelho: o login com +/// TOTP deixa o token no Keystore, a renovação rotaciona sem tela de +/// reautenticação e a partida a frio retoma a sessão sem senha. Por padrão a +/// renovação é forçada por `renewSession()`; com +/// `--dart-define=E2E_ESPERAR_JWT=true` (`acs_full_e2e.sh --esperar-jwt`) o +/// teste espera o JWT de 15 min vencer de verdade e a renovação sai de uma +/// chamada autenticada comum. O `audit_logs` (`refresh_granted`) e as linhas de +/// `acs_refresh_tokens` são conferidos pelo script, no banco. +/// +/// O último teste prova a fila de visitas POR DONO no aparelho (plano +/// 2026-10-03): um banco v6 com uma visita LEGADA (sem dono) é plantado no +/// arquivo real do app; A (com MFA) registra uma visita com o envio de visitas +/// fora do ar e sai; B (segundo ACS da fixture, mesma microárea) entra e não a +/// vê; o "Limpar este aparelho" é recusado; a rede volta e o envio diferido sobe +/// a visita de A pelo token de envio de A e a legada pela sessão de B; o wipe +/// então passa e as tabelas ficam vazias. A autoria no servidor (`acsId`, +/// `authorship`, `originDeviceId`) e o `audit_logs` são conferidos pelo script. +/// +/// PRIVACIDADE: só fixtures sintéticas geradas na execução; nada de CPF em log. +@Timeout(Duration(minutes: 4)) +library; + +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data' show Uint8List; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show rootBundle; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:sinalacs_acs/app/app.dart'; +import 'package:sinalacs_acs/core/network/backend_client.dart'; +import 'package:sinalacs_acs/core/database/encrypted_database.dart'; +import 'package:sinalacs_acs/core/database/micro_area_cache_store.dart'; +import 'package:sinalacs_acs/core/database/sqlcipher_visit_store.dart'; +import 'package:sinalacs_acs/core/network/auth_session.dart'; +import 'package:sinalacs_acs/core/network/backend_config.dart'; +import 'package:sinalacs_acs/core/security/biometric_gate.dart'; +import 'package:sinalacs_acs/core/security/database_key_store.dart'; +import 'package:sinalacs_acs/core/security/secure_session_token_store.dart'; +import 'package:sinalacs_acs/core/security/secure_upload_token_store.dart'; +import 'package:sinalacs_acs/core/services/backend_visit_synchronizer.dart'; +import 'package:sinalacs_acs/core/services/micro_area_directory.dart'; +import 'package:sinalacs_acs/core/services/micro_area_directory_factory.dart'; +import 'package:sinalacs_acs/core/services/offline_visit_queue.dart'; +import 'package:sinalacs_client/sinalacs_client.dart' as api; + +import 'support/e2e_acs.dart'; +import 'support/legacy_v6.dart'; +import 'support/totp.dart'; + +Future _pumpUntil(WidgetTester tester, bool Function() done, + {Duration timeout = const Duration(seconds: 30), Duration step = const Duration(milliseconds: 200)}) async { + final fim = DateTime.now().add(timeout); + while (!done() && DateTime.now().isBefore(fim)) { + await tester.pump(step); + } + if (done()) return; + // O que a tela diz quando trava: sem isto a falha é só "condição não atingida". + final telas = [ + for (final k in const ['login_error', 'login_aviso', 'resume_offline', 'resume_progress']) + if (find.byKey(Key(k)).evaluate().isNotEmpty) + '$k=${find.descendant(of: find.byKey(Key(k)), matching: find.byType(Text), matchRoot: true).evaluate().map((e) => (e.widget as Text).data).join(' ')}', + ]; + fail('condição não atingida em ${timeout.inSeconds}s; tela: ${telas.isEmpty ? '(nada)' : telas.join('; ')}'); +} + +/// Toca "Entrar". O teclado virtual aberto pelo `enterText` encolhe a área +/// visível e o botão, no fim do `ListView`, fica fora dela: o toque cairia no +/// fundo do formulário (o `flutter test` só avisa "would not hit test"). +Future _tocarEntrar(WidgetTester tester) async { + final botao = find.byKey(const Key('login_button')); + await tester.ensureVisible(botao); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(botao); +} + +/// Cliente com o refresh token e o id do aparelho no Keystore, como o `main.dart`. +BackendClient _clienteComKeystore(List ca) => BackendClient( + trustedCaBytes: ca, + tokenStore: SecureStorageSessionTokenStore(), + deviceIds: SecureStorageDeviceIdStore(), + ); + +/// Segredo TOTP de A e o último passo usado, deixados pelo teste da MFA: o +/// teste da fila por dono entra com A de novo pela tela (a MFA já está ligada). +Uint8List? _mfaSegredo; +int? _mfaUltimoPasso; + +int _passo(DateTime t) => t.toUtc().millisecondsSinceEpoch ~/ 1000 ~/ 30; + +/// Backend do app com o ENVIO de visitas (sync, legado, diferido, revogação do +/// token de envio) "fora do ar" enquanto [offline]: falha recuperável, como a +/// queda de rede. Login, lista da microárea e logout seguem o servidor real — +/// é o que permite a A registrar e sair com a visita pendente, e a B entrar. +/// Não é a rede do aparelho caindo: só os caminhos de envio de visita. +class _EnvioDeVisitasForaDoAr implements AcsBackend { + _EnvioDeVisitasForaDoAr(this._inner); + + final AcsBackend _inner; + bool offline = true; + int recusas = 0; + + Future _rede(Future Function() call) { + if (offline) { + recusas++; + return Future.error(const BackendFailure('Sem conexão (simulada no e2e).')); + } + return call(); + } + + @override + AuthSession? get session => _inner.session; + @override + bool get isAuthenticated => _inner.isAuthenticated; + @override + void Function()? get onSessionExpired => _inner.onSessionExpired; + @override + set onSessionExpired(void Function()? value) => _inner.onSessionExpired = value; + @override + Future get hasStoredSession => _inner.hasStoredSession; + @override + Future unlockStoredSession({required String reason}) => _inner.unlockStoredSession(reason: reason); + @override + Future resumeSession() => _inner.resumeSession(); + @override + Future logout() => _inner.logout(); + @override + Future login({required String matricula, required String senha, String? totpCode}) => + _inner.login(matricula: matricula, senha: senha, totpCode: totpCode); + @override + Future beginTotpEnrollment({required String matricula, required String senha}) => + _inner.beginTotpEnrollment(matricula: matricula, senha: senha); + @override + Future confirmTotpEnrollment({required String matricula, required String senha, required String code}) => + _inner.confirmTotpEnrollment(matricula: matricula, senha: senha, code: code); + @override + Future developmentLogin({required String role}) => _inner.developmentLogin(role: role); + @override + Future acknowledge({required String alertId}) => _inner.acknowledge(alertId: alertId); + @override + Future> syncVisits(List visits, {String? expectedUserId}) => + _rede(() => _inner.syncVisits(visits, expectedUserId: expectedUserId)); + @override + Future> syncLegacyVisits(List visits, {required String deviceId}) => + _rede(() => _inner.syncLegacyVisits(visits, deviceId: deviceId)); + @override + Future> syncDeferredVisits({ + required String uploadToken, + required String deviceId, + required List visits, + }) => + _rede(() => _inner.syncDeferredVisits(uploadToken: uploadToken, deviceId: deviceId, visits: visits)); + @override + Future revokeUploadToken(String uploadToken) => _rede(() => _inner.revokeUploadToken(uploadToken)); + @override + Future> pullVisits({required DateTime since}) => _inner.pullVisits(since: since); + @override + Future> listPatients() => _inner.listPatients(); + @override + Future ubsContact() => _inner.ubsContact(); + @override + Future generateInvite({required String patientId}) => + _inner.generateInvite(patientId: patientId); + @override + Future sendNotice({required String title, required String message, required bool chronicOnly}) => + _inner.sendNotice(title: title, message: message, chronicOnly: chronicOnly); + @override + void close() => _inner.close(); +} + +/// Espera o JWT vencer de verdade (15 min) em vez de forçar a renovação. +const _esperarJwt = bool.fromEnvironment('E2E_ESPERAR_JWT'); + +/// Desbloqueio local de teste. O padrão é "indisponível": sem ele, um aparelho +/// com PIN/digital cadastrado abriria o `BiometricPrompt` real na partida (há +/// refresh token de um teste anterior no Keystore) e a tela de login ficaria +/// travada em "Retomando a sessão…". O desbloqueio real é provado à parte, por +/// adb (ver o relatório da Task 8). +class _DesbloqueioDeTeste implements BiometricGate { + _DesbloqueioDeTeste({this.available = false}); + final bool available; + int calls = 0; + @override + Future get isAvailable async => available; + @override + Future authenticate({required String reason}) async { + calls++; + return available ? UnlockResult.unlocked : UnlockResult.unavailable; + } +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('login real, seletor por microárea, visita e sincronização', (tester) async { + final ca = (await rootBundle.load(BackendConfig.rpcCaAsset)).buffer.asUint8List(); + final backend = _clienteComKeystore(ca); + addTearDown(backend.close); + const host = String.fromEnvironment('SINALACS_HOST', defaultValue: 'https://10.0.2.2/'); + api.Client novoCliente() => api.Client(host, securityContext: SecurityContext()..setTrustedCertificatesBytes(ca)) + ..connectivityMonitor = null; + final cred = await acsCredentialFromRelay(); + final main = e2ePatient('main'); + final outsider = e2ePatient('outsider'); + + await tester.pumpWidget(SinalAcsApp(backend: backend, biometricGate: _DesbloqueioDeTeste())); + + // Review Focus 4 — senha errada: erro visível, painel fechado. + await tester.enterText(find.byKey(const Key('matricula_field')), cred.matricula); + await tester.enterText(find.byKey(const Key('senha_field')), '${cred.senha}-errada'); + await _tocarEntrar(tester); + await _pumpUntil(tester, () => find.byKey(const Key('login_error')).evaluate().isNotEmpty); + expect(find.byKey(const Key('login_button')), findsOneWidget, reason: 'o painel não pode abrir'); + + // A sessão certa logo depois funciona, sem reiniciar. + await tester.enterText(find.byKey(const Key('senha_field')), cred.senha); + await _tocarEntrar(tester); + await _pumpUntil(tester, () => find.byKey(const Key('login_button')).evaluate().isEmpty); + + // Visita de rotina pelo seletor: o paciente da microárea aparece; o de fora, não (RNF06). + await tester.tap(find.text('Visita')); + await _pumpUntil(tester, () => find.byKey(const Key('patient_picker')).evaluate().isNotEmpty); + expect(find.byKey(Key('patient_${main.id}')), findsOneWidget); + // O seletor não pode estar filtrado: sem isto, "o de fora não aparece" + // poderia ser só um filtro por nome escondendo tudo o que não casa. + final busca = tester.widget(find.byKey(const Key('patient_search'))); + expect(busca.controller!.text, isEmpty, reason: 'o seletor deve abrir sem filtro de busca'); + expect(find.byKey(Key('patient_${outsider.id}')), findsNothing, reason: 'outra microárea'); + + await tester.tap(find.byKey(Key('patient_${main.id}'))); + await tester.pump(); + await tester.tap(find.byKey(const Key('arrival_confirmation'))); + await tester.pump(); + await tester.tap(find.byKey(const Key('save_visit'))); + await _pumpUntil(tester, () => find.text('Pendentes de sincronização: 1').evaluate().isNotEmpty); + await tester.tap(find.byKey(const Key('sync_visits'))); + await _pumpUntil(tester, () => find.text('Pendentes de sincronização: 0').evaluate().isNotEmpty); + + // A visita está NO SERVIDOR (conferida por um cliente separado, com login real). + final acsClient = novoCliente(); + addTearDown(acsClient.close); + final acs = await acsClient.auth.loginInstitutional(matricula: cred.matricula, password: cred.senha); + + // RNF06 no servidor: a lista da microárea nunca traz o paciente de fora. + // Quem garante é o backend, não o filtro do seletor. + final daMicroarea = await acsClient.patients.listMicroArea(accessToken: acs.accessToken); + expect(daMicroarea.any((p) => p.patientId == main.id), isTrue); + expect(daMicroarea.any((p) => p.patientId == outsider.id), isFalse, reason: 'outra microárea'); + // RF13: o contato da UBS chega pelo servidor real, escopado pelo token do ACS. + final contato = await acsClient.ubs.myContact(accessToken: acs.accessToken); + expect(contato.name, 'UBS E2E'); + expect(contato.phone, '+55 11 5550-0199'); + + final remotas = await acsClient.visits.pull( + accessToken: acs.accessToken, + since: DateTime.fromMillisecondsSinceEpoch(0), + ); + expect(remotas.any((v) => v.patientId == main.id), isTrue); + + // Desmonta o app aqui: o feed e os timers do painel ainda estão ativos e o + // desmonte automático do fim do teste os pegava em meio ao descarte. + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(seconds: 1)); + }); + + testWidgets('100 visitas offline sobem em menos de 5 s (M2.4)', (tester) async { + final ca = (await rootBundle.load(BackendConfig.rpcCaAsset)).buffer.asUint8List(); + final backend = _clienteComKeystore(ca); + addTearDown(backend.close); + final cred = await acsCredentialFromRelay(); + final main = e2ePatient('main'); + await backend.login(matricula: cred.matricula, senha: cred.senha); + + final fila = OfflineVisitQueue(synchronizer: BackendVisitSynchronizer(backend: backend, ownerId: backend.session!.userId)); + final base = DateTime.now().toUtc().subtract(const Duration(days: 1)); + for (var n = 0; n < 100; n++) { + // `localId` fica no padrão (um UUID novo): o protocolo o tipa como + // `UuidValue` e o servidor recusa o que não for UUID. + await fila.add(OfflineVisitRecord( + patientId: main.id, + risk: 'green', + status: 'PENDENTE', + createdAt: base.add(Duration(minutes: n)), + )); + } + final enviados = {for (final v in fila.pendingVisits) v.localId.toLowerCase()}; + expect(enviados, hasLength(100)); + + final relogio = Stopwatch()..start(); + final resultado = await fila.sync(); + relogio.stop(); + + expect(resultado.kind, SyncOutcomeKind.synced, reason: resultado.message); + expect(fila.syncedCount, 100); + expect(fila.pendingCount, 0); + // ignore: avoid_print + print('rajada: 100 visitas em ${relogio.elapsedMilliseconds} ms'); + expect(relogio.elapsed, lessThan(const Duration(seconds: 5)), + reason: 'PRD M2.4: 100 registros offline sincronizam em < 5 s'); + + final remotas = await backend.pullVisits(since: DateTime.fromMillisecondsSinceEpoch(0)); + final noServidor = {for (final v in remotas) v.localId.toString().toLowerCase()}; + expect(noServidor.containsAll(enviados), isTrue, + reason: 'as 100 visitas estão no servidor, conferidas por pull'); + }); + + testWidgets('RF08: a microárea fica no aparelho, sobrevive à queda de rede e está cifrada', (tester) async { + final ca = (await rootBundle.load(BackendConfig.rpcCaAsset)).buffer.asUint8List(); + final backend = _clienteComKeystore(ca); + addTearDown(backend.close); + final cred = await acsCredentialFromRelay(); + final main = e2ePatient('main'); + await backend.login(matricula: cred.matricula, senha: cred.senha); + + // O `keyStore` de PRODUÇÃO (Keystore do aparelho), numa base própria deste teste. + await EncryptedLocalDatabase.deleteDatabaseFile('rf08_e2e.db'); + final store = MicroAreaCacheStore(keyStore: SecureStorageDatabaseKeyStore(), databaseName: 'rf08_e2e.db'); + final online = MicroAreaDirectory(fetch: backend.listPatients, session: () => backend.session, store: store); + final primeira = await online.load(); + expect(primeira.fromCache, isFalse); + expect(primeira.patients.any((p) => p.patientId == main.id), isTrue); + + // Rede caída: a central "não responde", o aparelho serve o que guardou. + final offline = MicroAreaDirectory( + fetch: () async => throw const BackendFailure('Sem conexão.'), + session: () => backend.session, + store: store, + ); + final segunda = await offline.load(); + expect(segunda.fromCache, isTrue); + expect(segunda.patients.any((p) => p.patientId == main.id), isTrue); + + // O arquivo no disco NÃO contém o nome do paciente em texto claro (SQLCipher no aparelho). + final bytes = await File(await EncryptedLocalDatabase.pathFor('rf08_e2e.db')).readAsBytes(); + expect(String.fromCharCodes(bytes).contains(main.name), isFalse, + reason: 'o nome do paciente não pode estar legível no arquivo do banco'); + expect(String.fromCharCodes(bytes.take(16)), isNot(startsWith('SQLite format 3'))); + await store.clear(); + }); + + testWidgets('MFA: com a verificação ativa, o login pela tela pede e aceita o código; ' + 'o refresh token renova sem reautenticação e retoma a sessão na partida', (tester) async { + final ca = (await rootBundle.load(BackendConfig.rpcCaAsset)).buffer.asUint8List(); + final backend = _clienteComKeystore(ca); + addTearDown(backend.close); + const host = String.fromEnvironment('SINALACS_HOST', defaultValue: 'https://10.0.2.2/'); + final cliente = api.Client(host, securityContext: SecurityContext()..setTrustedCertificatesBytes(ca)) + ..connectivityMonitor = null; + addTearDown(cliente.close); + final cred = await acsCredentialFromRelay(); + + // 1) Liga a MFA pelo RPC: matrícula + senha + o código do segredo recém-sorteado. + final inicio = await cliente.auth.beginTotpEnrollment(matricula: cred.matricula, password: cred.senha); + final segredo = base32Decode(inicio.secretBase32); + int passo(DateTime t) => t.toUtc().millisecondsSinceEpoch ~/ 1000 ~/ 30; + final passoDaAtivacao = passo(DateTime.now()); + await cliente.auth.confirmTotpEnrollment( + matricula: cred.matricula, + password: cred.senha, + code: totpCode(segredo, DateTime.now()), + ); + + // 2) Login pela tela: matrícula + senha -> o app pede o código e o painel NÃO abre. + // `syncInterval` longo: nada renova a sessão por conta própria durante a + // espera do passo 4; quem renova é a chamada do passo 5. + await tester.pumpWidget(SinalAcsApp( + backend: backend, + biometricGate: _DesbloqueioDeTeste(), + syncInterval: const Duration(hours: 2), + )); + await tester.enterText(find.byKey(const Key('matricula_field')), cred.matricula); + await tester.enterText(find.byKey(const Key('senha_field')), cred.senha); + await _tocarEntrar(tester); + await _pumpUntil(tester, () => find.byKey(const Key('totp_field')).evaluate().isNotEmpty); + expect(find.byKey(const Key('login_button')), findsOneWidget, reason: 'o painel não pode abrir sem o código'); + + // 3) O código da ativação já foi usado (replay barrado). Espera o relógio do + // aparelho passar para um passo MAIOR que o da ativação e entra com o código + // do passo ATUAL: fica dentro da janela de +-1 do servidor para defasagem + // de relógio nos dois sentidos, sem apostar no passo seguinte. + await _pumpUntil(tester, () => passo(DateTime.now()) > passoDaAtivacao, timeout: const Duration(seconds: 40)); + final agora = DateTime.now(); + final codigo = totpCode(segredo, agora); + await tester.enterText(find.byKey(const Key('totp_field')), codigo); + await _tocarEntrar(tester); + await _pumpUntil(tester, () => + find.byKey(const Key('login_button')).evaluate().isEmpty || find.byKey(const Key('login_error')).evaluate().isNotEmpty); + final erro = find.byKey(const Key('login_error')); + expect(erro, findsNothing, + reason: '${erro.evaluate().isEmpty ? '' : (tester.widget(erro).data ?? '')} ' + '(relógio do aparelho ${agora.toUtc().toIso8601String()}, passo ${passo(agora)}, ativação no passo $passoDaAtivacao)'); + _mfaSegredo = segredo; + _mfaUltimoPasso = passo(agora); + + // 4) Refresh token: o login com MFA deixou um no Keystore (o servidor só o + // emite a quem manda o deviceId; a linha em acs_refresh_tokens é + // conferida pelo script). Nenhuma senha ficou no app. + final keystore = SecureStorageSessionTokenStore(); + final tokenDoLogin = await keystore.read(); + expect(tokenDoLogin, isNotNull, reason: 'o login com MFA grava o refresh token no Keystore'); + final primeira = backend.session!; + if (_esperarJwt) { + final inicio = DateTime.now(); + // ignore: avoid_print + print('jwt: esperando vencer (exp ${primeira.expiresAt.toIso8601String()}, início ${inicio.toUtc().toIso8601String()})'); + await _pumpUntil(tester, () => primeira.isExpired(), + timeout: const Duration(minutes: 16), step: const Duration(seconds: 2)); + // ignore: avoid_print + print('jwt: vencido após ${DateTime.now().difference(inicio).inSeconds} s de espera real'); + } else { + // Sem esperar 15 min: a mesma renovação que `_requireToken` dispara. + await backend.renewSession(); + } + + // 5) Chamada autenticada comum (lista da microárea pela aba Visita): com o + // JWT vencido ela renova sozinha e a tela de reautenticação NÃO aparece. + if (_esperarJwt) expect(backend.session!.isExpired(), isTrue, reason: 'o JWT deveria estar vencido aqui'); + final main = e2ePatient('main'); + await tester.tap(find.text('Visita')); + await _pumpUntil(tester, () => + find.byKey(Key('patient_${main.id}')).evaluate().isNotEmpty || + find.byKey(const Key('login_button')).evaluate().isNotEmpty || + find.byKey(const Key('patient_directory_error')).evaluate().isNotEmpty); + expect(find.byKey(const Key('login_button')), findsNothing, reason: 'a renovação não pode pedir a senha de novo'); + expect(find.textContaining('Sua sessão expirou'), findsNothing); + expect(find.byKey(const Key('patient_directory_error')), findsNothing); + expect(find.byKey(Key('patient_${main.id}')), findsOneWidget); + final renovada = backend.session!; + expect(renovada.isExpired(), isFalse); + // `exp` tem resolução de segundos: sem a espera real, login e renovação + // podem cair no mesmo segundo. A prova da rotação é o refresh token abaixo. + expect( + _esperarJwt ? renovada.expiresAt.isAfter(primeira.expiresAt) : !renovada.expiresAt.isBefore(primeira.expiresAt), + isTrue, + reason: 'JWT novo'); + final tokenRotacionado = await keystore.read(); + expect(tokenRotacionado, isNotNull); + expect(tokenRotacionado, isNot(tokenDoLogin), reason: 'o refresh token é rotativo: o filho substitui o pai no Keystore'); + // ignore: avoid_print + print('jwt: renovado sem reautenticação (exp anterior ${primeira.expiresAt.toIso8601String()}, ' + 'novo ${renovada.expiresAt.toIso8601String()})'); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(seconds: 1)); + + // 6) Partida a frio: cliente novo (nada em memória), o mesmo Keystore e um + // desbloqueio local que aceita. O painel abre sem matrícula/senha/código. + final frio = _clienteComKeystore(ca); + addTearDown(frio.close); + final desbloqueio = _DesbloqueioDeTeste(available: true); + await tester.pumpWidget(SinalAcsApp( + backend: frio, + biometricGate: desbloqueio, + syncInterval: const Duration(hours: 2), + )); + await _pumpUntil(tester, () => find.byKey(const Key('login_button')).evaluate().isEmpty); + expect(desbloqueio.calls, 1, reason: 'a retomada passa pelo desbloqueio local'); + expect(frio.session, isNotNull); + expect(await keystore.read(), isNot(tokenRotacionado), reason: 'a retomada também rotaciona'); + + // 7) "Sair" revoga no servidor e apaga do Keystore: o próximo app não retoma. + await frio.logout(); + expect(await keystore.read(), isNull); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(seconds: 1)); + }, timeout: _esperarJwt ? const Timeout(Duration(minutes: 22)) : null); + + testWidgets('fila por dono: A sai com visita pendente, B não a vê, o envio diferido sobe a de A com a autoria de A ' + 'e a legada sem autor; o wipe recusa com pendência e apaga com tudo enviado', (tester) async { + final segredo = _mfaSegredo; + expect(segredo, isNotNull, reason: 'roda depois do teste da MFA (acs_full_e2e.sh roda o arquivo inteiro)'); + final ca = (await rootBundle.load(BackendConfig.rpcCaAsset)).buffer.asUint8List(); + final credA = await acsCredentialFromRelay(); + final credB = await secondAcsCredentialFromRelay(); + final main = e2ePatient('main'); + + // 0) Montagem de produção (main.dart): Keystore para refresh token, id do + // aparelho e tokens de envio; SQLCipher real no arquivo do app; cache da + // microárea no mesmo arquivo. + final deviceIds = SecureStorageDeviceIdStore(); + final uploadTokens = SecureStorageUploadTokenStore(); + await uploadTokens.clearAll(); // restos de execuções anteriores neste emulador + final real = BackendClient( + trustedCaBytes: ca, + tokenStore: SecureStorageSessionTokenStore(), + deviceIds: deviceIds, + uploadTokens: uploadTokens, + ); + addTearDown(real.close); + final backend = _EnvioDeVisitasForaDoAr(real); + + // 1) Banco v6 de uma versão anterior do app, com uma visita SEM dono, no + // arquivo e com a chave que o app usa (sinalacs_acs.db + Keystore). + const arquivo = 'sinalacs_acs.db'; + final chaves = SecureStorageDatabaseKeyStore(); + await EncryptedLocalDatabase.deleteDatabaseFile(arquivo); + final legadoId = OfflineVisitRecord(patientId: main.id, risk: 'yellow', status: 'PENDENTE').localId; + await createV6Database( + databaseName: arquivo, + passphrase: await chaves.readOrCreate(), + rows: [ + legacyV6Row( + localId: legadoId, + patientId: main.id, + createdAt: DateTime.now().toUtc().subtract(const Duration(days: 2)), + ), + ], + ); + final storage = SqlCipherVisitStorage(keyStore: chaves); + expect((await storage.legacy.load()).map((v) => v.localId), [legadoId], reason: 'v6 → v7: a linha foi para a quarentena'); + + final rede = StreamController.broadcast(); + addTearDown(rede.close); + await tester.pumpWidget(SinalAcsApp( + backend: backend, + biometricGate: _DesbloqueioDeTeste(), + syncInterval: const Duration(hours: 2), + visitStorage: storage, + microAreaDirectory: buildMicroAreaDirectory(backend: backend), + uploadTokens: uploadTokens, + deviceIds: deviceIds, + connectivityChanges: rede.stream, + )); + rede.add(false); + + Future entrar(({String matricula, String senha}) cred, {bool mfa = false}) async { + await _pumpUntil(tester, () => find.byKey(const Key('matricula_field')).evaluate().isNotEmpty); + await tester.enterText(find.byKey(const Key('matricula_field')), cred.matricula); + await tester.enterText(find.byKey(const Key('senha_field')), cred.senha); + await _tocarEntrar(tester); + if (mfa) { + await _pumpUntil(tester, () => find.byKey(const Key('totp_field')).evaluate().isNotEmpty); + // O código do passo já usado é barrado (replay): espera o próximo. + await _pumpUntil(tester, () => _passo(DateTime.now()) > _mfaUltimoPasso!, timeout: const Duration(seconds: 40)); + final agora = DateTime.now(); + _mfaUltimoPasso = _passo(agora); + await tester.enterText(find.byKey(const Key('totp_field')), totpCode(segredo!, agora)); + await _tocarEntrar(tester); + } + await _pumpUntil(tester, () => + find.byKey(const Key('login_button')).evaluate().isEmpty || find.byKey(const Key('login_error')).evaluate().isNotEmpty); + expect(find.byKey(const Key('login_error')), findsNothing); + } + + Future irPara(String aba) async { + if (aba == 'Preferências') { + await tester.tap(find.text('Mais')); + await tester.pump(const Duration(milliseconds: 600)); + } + await tester.tap(find.text(aba).last); + await tester.pump(const Duration(milliseconds: 600)); + } + + Future esperarDisco(Future Function() pronto, String oQue) async { + final fim = DateTime.now().add(const Duration(seconds: 40)); + while (DateTime.now().isBefore(fim)) { + if (await pronto()) return; + await tester.pump(const Duration(milliseconds: 300)); + } + fail('não aconteceu em 40 s: $oQue'); + } + + // 2) A entra (MFA) e registra uma visita com o envio fora do ar. + await entrar(credA, mfa: true); + final idA = real.session!.userId; + expect(await uploadTokens.read(idA), isNotNull, reason: 'o login com deviceId deixa o token de envio de A'); + await irPara('Visita'); + await _pumpUntil(tester, () => find.byKey(Key('patient_${main.id}')).evaluate().isNotEmpty); + await tester.tap(find.byKey(Key('patient_${main.id}'))); + await tester.pump(); + await tester.tap(find.byKey(const Key('arrival_confirmation'))); + await tester.pump(); + await tester.tap(find.byKey(const Key('save_visit'))); + await _pumpUntil(tester, () => find.text('Pendentes de sincronização: 1').evaluate().isNotEmpty); + final visitasA = await storage.forOwner(idA).load(); + expect(visitasA, hasLength(1), reason: 'a visita de A está no disco, sob o dono A'); + final visitaA = visitasA.single.localId; + expect(await storage.legacy.load(), hasLength(1), reason: 'o legado não foi adotado por A'); + + // 3) "Sair": o envio antes de sair falha (fora do ar) e a visita FICA. + await irPara('Preferências'); + await tester.ensureVisible(find.byKey(const Key('logout_button'))); + await tester.tap(find.byKey(const Key('logout_button'))); + await _pumpUntil(tester, () => find.byKey(const Key('logout_confirm')).evaluate().isNotEmpty); + expect(find.byKey(const Key('logout_pending_visits')), findsOneWidget); + expect(find.text('Desta conta: 1. De outras contas ou sem autor: 1.'), findsOneWidget); + await tester.tap(find.byKey(const Key('logout_confirm'))); + await _pumpUntil(tester, () => find.byKey(const Key('login_button')).evaluate().isNotEmpty); + expect(real.session, isNull); + expect((await storage.forOwner(idA).load()).map((v) => v.localId), [visitaA], reason: 'Sair não apaga a fila'); + expect(await uploadTokens.read(idA), isNotNull, reason: 'o token de envio de A sobrevive ao Sair com pendência'); + + // 4) B entra: a fila dele está vazia e a visita de A não aparece. + await entrar(credB); + final idB = real.session!.userId; + expect(idB, isNot(idA)); + await irPara('Visita'); + await _pumpUntil(tester, () => find.text('Pendentes de sincronização: 0').evaluate().isNotEmpty); + expect(find.text('Pendentes de sincronização: 1'), findsNothing, reason: 'B não vê a visita de A'); + expect(await storage.forOwner(idB).load(), isEmpty); + expect(await storage.countsByOwner(), {idA: 1}); + await _pumpUntil(tester, () => find.byKey(Key('patient_${main.id}')).evaluate().isNotEmpty); + final db = await storage.database.open(); + Future linhas(String tabela) async => + (await db.rawQuery('SELECT COUNT(*) AS n FROM $tabela')).single['n']! as int; + await esperarDisco(() async => await linhas('micro_area_cache') > 0, 'cache da microárea gravado'); + + // 5) "Limpar este aparelho" com A e o legado pendentes: recusado, nada apagado. + await irPara('Preferências'); + await tester.ensureVisible(find.byKey(const Key('wipe_button'))); + await tester.tap(find.byKey(const Key('wipe_button'))); + await _pumpUntil(tester, () => find.byKey(const Key('wipe_blocked')).evaluate().isNotEmpty); + expect(find.text('Desta conta: 0 visitas. De outras contas ou sem autor: 2 visitas.'), findsOneWidget); + expect(find.text('Nada foi apagado.'), findsOneWidget); + await tester.tap(find.text('Entendi')); + await tester.pump(const Duration(milliseconds: 600)); + expect(await linhas('offline_visits'), 2); + expect(await linhas('micro_area_cache'), greaterThan(0)); + expect(backend.recusas, greaterThan(0), reason: 'o envio foi de fato tentado e recusado'); + + // 6) A rede volta: o gatilho de conectividade dispara o envio diferido. + backend.offline = false; + rede.add(true); + await esperarDisco(() async => (await storage.countsByOwner()).isEmpty && (await storage.legacy.load()).isEmpty, + 'visita de A e legada enviadas e fora do disco'); + await esperarDisco(() async => await uploadTokens.read(idA) == null, + 'token de envio de A revogado e apagado depois que a fila de A zerou'); + // ignore: avoid_print + print('E2E_FILA_POR_DONO visita_a=$visitaA legado=$legadoId acs_a=$idA acs_b=$idB'); + + // 7) Tudo enviado: o wipe pede confirmação e apaga as quatro tabelas. + await tester.ensureVisible(find.byKey(const Key('wipe_button'))); + await tester.tap(find.byKey(const Key('wipe_button'))); + await _pumpUntil(tester, () => find.byKey(const Key('wipe_confirm')).evaluate().isNotEmpty); + await tester.tap(find.byKey(const Key('wipe_confirm'))); + await _pumpUntil(tester, () => find.byKey(const Key('login_button')).evaluate().isNotEmpty); + expect(find.text('Os dados deste aparelho foram apagados.'), findsOneWidget); + for (final tabela in ['offline_visits', 'sync_cursor', 'micro_area_cache', 'micro_area_cache_meta']) { + expect(await linhas(tabela), 0, reason: '$tabela depois do wipe'); + } + expect(await uploadTokens.owners(), isEmpty); + expect(await uploadTokens.read(idB), isNull, reason: 'o wipe apaga o token de envio de B'); + expect(real.session, isNull, reason: 'o wipe encerra a sessão'); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(seconds: 1)); + }, timeout: const Timeout(Duration(minutes: 6))); +} diff --git a/apps/acs/integration_test/geofence_gps_e2e.dart b/apps/acs/integration_test/geofence_gps_e2e.dart new file mode 100644 index 00000000..e0440946 --- /dev/null +++ b/apps/acs/integration_test/geofence_gps_e2e.dart @@ -0,0 +1,92 @@ +/// RF12 no aparelho: a PERMISSÃO de localização em runtime e o que a tela faz +/// com ela. Cenário e pré-requisitos em `scripts/qa/acs_gps_e2e.sh` (concede ou +/// revoga a permissão por `pm` antes de rodar). `--dart-define=EXPECT_PERMISSION=granted|denied_forever`. +/// +/// O que isto prova: `pm grant` só funciona se o manifesto DECLARA a permissão +/// (o defeito que o `android_manifest_test.dart` guarda), e o app, com ela, +/// enxerga `whileInUse`; sem ela, a tela diz "indisponível" e não trava. +/// O que NÃO prova: a chegada de um fix de GPS. No AVD usado (Android 16, Play +/// Services) nem `adb emu geo fix`, nem provider de teste, nem +/// `forceLocationManager` entregam posição ao app; "Local alcançado" com posição +/// segue coberto por `map_flow_test.dart` e pelos widget tests (posição injetada). +/// +/// Sufixo `_e2e.dart` (não `_test.dart`) de propósito: `flutter test +/// integration_test` descobre `*_test.dart` por pasta, e este arquivo só faz +/// sentido pelo script. +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:sinalacs_acs/app/app.dart'; + +import '../test/support/fakes.dart'; + +const _expect = String.fromEnvironment('EXPECT_PERMISSION', defaultValue: 'granted'); + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('a permissão de localização no aparelho ($_expect)', (tester) async { + final alert = testAlert(alertId: 'gps-1', locationCell: testLocationCell); + + final permission = await Geolocator.checkPermission(); + if (_expect == 'granted') { + expect(permission, anyOf(LocationPermission.whileInUse, LocationPermission.always), + reason: 'o manifesto precisa declarar ACCESS_FINE_LOCATION para a permissão existir'); + } else { + // Leva a permissão a negada de vez (o Geolocator reporta `deniedForever`) NA MESMA SESSÃO (o `flutter drive` desinstala o app ao + // terminar, então o estado não sobrevive entre rodadas). Enquanto não está fixada, cada + // `requestPermission()` abre o diálogo do sistema, que o script do emulador recusa; a + // segunda recusa a torna permanente e o pedido passa a voltar na hora, sem diálogo. + expect(permission, isNot(anyOf(LocationPermission.whileInUse, LocationPermission.always))); + // No máximo 2 pedidos: o script do emulador recusa o 1º diálogo e o 2º, e a 2ª recusa + // faz o Android parar de perguntar. Um 3º pedido seria só um pedido que já volta na hora. + var resposta = await Geolocator.requestPermission(); + if (resposta != LocationPermission.deniedForever) { + resposta = await Geolocator.requestPermission(); + } + expect(resposta, LocationPermission.deniedForever); + } + + await tester.pumpWidget(SinalAcsApp( + backend: FakeAcsBackend(), + feedBuilder: (queue) { + queue.upsert(alert); // `initialAlert` só seleciona; a tela lê a fila + return FakeAlertFeed(queue); + }, + initialAlert: alert, + // `currentPosition` AUSENTE de propósito: a posição tem de vir do Geolocator. + )); + await tester.enterText(find.byKey(const Key('matricula_field')), '123456'); + await tester.enterText(find.byKey(const Key('senha_field')), 'qualquer'); + await tester.tap(find.byKey(const Key('login_button'))); + await _pumpFor(tester, const Duration(seconds: 3)); + + await tester.tap(find.text('Mais')); + await _pumpFor(tester, const Duration(milliseconds: 500)); + await tester.tap(find.text('Geofencing')); + await _pumpFor(tester, const Duration(seconds: 3)); + + if (_expect == 'denied_forever') { + // `checkPermission()` NÃO serve de prova aqui: no Android ele devolve `denied` mesmo com a + // negação fixada (só o resultado de `requestPermission()` carrega `deniedForever`). Com ela + // fixada, um novo pedido depois de abrir o painel volta `deniedForever` sem diálogo; se o + // diálogo reaparecesse, o tocador do script o recusaria e estouraria o teto de toques. + expect(await Geolocator.requestPermission(), LocationPermission.deniedForever, + reason: 'a permissão já fixada não pode voltar a abrir o diálogo'); + // Review Focus 1: permissão negada nunca trava o ACS. + expect(find.textContaining('Localização atual indisponível'), findsOneWidget); + expect(find.byKey(const Key('geofence_visit')), findsOneWidget); + } + expect(tester.takeException(), isNull); + }); +} + +Future _pumpFor(WidgetTester tester, Duration d) async { + final fim = DateTime.now().add(d); + while (DateTime.now().isBefore(fim)) { + await tester.pump(const Duration(milliseconds: 100)); + } +} diff --git a/apps/acs/integration_test/layout_fonte_200_test.dart b/apps/acs/integration_test/layout_fonte_200_test.dart new file mode 100644 index 00000000..040554cd --- /dev/null +++ b/apps/acs/integration_test/layout_fonte_200_test.dart @@ -0,0 +1,41 @@ +/// Layout do painel do ACS no aparelho, com a janela e a escala de fonte que o +/// SISTEMA configurou — o contrário de `test/text_scale_test.dart`, que força +/// os dois por `TestPlatformDispatcher`. Aqui a fonte é a real (Roboto), não a +/// Ahem dos testes de widget. +/// +/// Hermético: usa `FakeAcsBackend` e `FakeAlertFeed`, sem backend, broker nem +/// seed. Os parâmetros vêm do emulador: +/// +/// adb shell settings put system font_scale 2.0 +/// adb shell wm size 320x640 && adb shell wm density 160 # 1 dp = 1 px +/// flutter test integration_test/layout_fonte_200_test.dart -d emulator-5554 \ +/// --dart-define=SINALACS_MQTT_PASSWORD="$MQTT_ACS_PASSWORD" +/// adb shell wm size reset && adb shell wm density reset +/// adb shell settings put system font_scale 1.0 +/// +/// Sem ajuste nenhum ele passa na escala 1.0 da janela padrão: é um teste de +/// fumaça de layout, não uma prova de 200% por si só (o log imprime o que foi +/// medido). +library; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../test/support/fakes.dart'; +import '../test/support/layout_harness.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('painel inteiro cabe na janela e na fonte do aparelho', (tester) async { + final escala = tester.platformDispatcher.textScaleFactor; + final tamanho = tester.view.physicalSize / tester.view.devicePixelRatio; + // ignore: avoid_print + print('LAYOUT-DEVICE janela=${tamanho.width.toStringAsFixed(0)}x${tamanho.height.toStringAsFixed(0)}dp escalaDeFonte=$escala'); + + // Sem biometria real: no aparelho a retomada de sessão a consultaria e o login + // ficaria esperando o sistema; o fake indisponível leva ao formulário de senha. + await abrirPainel(tester, tamanho: tamanho, escalaDeFonte: escala, biometricGate: FakeBiometricGate(available: false)); + await percorrerPainelInteiro(tester, 'aparelho ${tamanho.width.toStringAsFixed(0)}x${tamanho.height.toStringAsFixed(0)} fonte $escala'); + }); +} diff --git a/apps/acs/integration_test/map_flow_test.dart b/apps/acs/integration_test/map_flow_test.dart index f3736dd8..7a35937d 100644 --- a/apps/acs/integration_test/map_flow_test.dart +++ b/apps/acs/integration_test/map_flow_test.dart @@ -1,3 +1,4 @@ +import 'package:url_launcher/url_launcher.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter/material.dart'; import 'package:google_maps_flutter/google_maps_flutter.dart'; @@ -118,4 +119,12 @@ void main() { await tester.tap(find.byKey(const Key('escalation_visit'))); expect(selected?.alertId, alert.alertId); }); + + testWidgets('o discador do aparelho resolve tel:192 (RF13)', (tester) async { + // Prova o que o widget test não alcança: declarada e um + // handler de `tel:` presente no aparelho. Não abre a chamada. + final available = await canLaunchUrl(Uri(scheme: 'tel', path: '192')); + expect(available, isTrue, + reason: 'sem para tel: (Android 11+) ou sem discador no emulador'); + }); } diff --git a/apps/acs/integration_test/red_alert_cycle_test.dart b/apps/acs/integration_test/red_alert_cycle_test.dart index 538f3b30..fffe6df5 100644 --- a/apps/acs/integration_test/red_alert_cycle_test.dart +++ b/apps/acs/integration_test/red_alert_cycle_test.dart @@ -228,7 +228,7 @@ void main() { await backend.developmentLogin(role: 'acs'); final queue = OfflineVisitQueue( - synchronizer: BackendVisitSynchronizer(backend: backend), + synchronizer: BackendVisitSynchronizer(backend: backend, ownerId: backend.session!.userId), ); await queue.add(OfflineVisitRecord( @@ -257,12 +257,13 @@ void main() { await backend.developmentLogin(role: 'acs'); final store = SqlCipherVisitStore( + owner: 'acs-teste', keyStore: InMemoryDatabaseKeyStore(), databaseName: nome, ); final queue = OfflineVisitQueue( store: store, - synchronizer: BackendVisitSynchronizer(backend: backend), + synchronizer: BackendVisitSynchronizer(backend: backend, ownerId: backend.session!.userId), ); await queue.add(OfflineVisitRecord( @@ -297,7 +298,7 @@ void main() { outcome: 'realizada', ); - final synchronizer = BackendVisitSynchronizer(backend: backend); + final synchronizer = BackendVisitSynchronizer(backend: backend, ownerId: backend.session!.userId); final first = await synchronizer.push([visit]); // Mesmo localId, mesma versão: é o retry de quem não viu a resposta. diff --git a/apps/acs/integration_test/smoke_test.dart b/apps/acs/integration_test/smoke_test.dart index 0300aa27..b12015e4 100644 --- a/apps/acs/integration_test/smoke_test.dart +++ b/apps/acs/integration_test/smoke_test.dart @@ -135,12 +135,13 @@ void main() { await EncryptedLocalDatabase.deleteDatabaseFile(databaseName); addTearDown(() => EncryptedLocalDatabase.deleteDatabaseFile(databaseName)); final store = SqlCipherVisitStore( + owner: 'acs-teste', keyStore: InMemoryDatabaseKeyStore(), databaseName: databaseName, ); final visitQueue = OfflineVisitQueue( store: store, - synchronizer: BackendVisitSynchronizer(backend: backend), + synchronizer: BackendVisitSynchronizer(backend: backend, ownerId: backend.session!.userId), ); await visitQueue.add(OfflineVisitRecord( patientId: seedPatientId, diff --git a/apps/acs/integration_test/support/e2e_acs.dart b/apps/acs/integration_test/support/e2e_acs.dart new file mode 100644 index 00000000..1f5d1f19 --- /dev/null +++ b/apps/acs/integration_test/support/e2e_acs.dart @@ -0,0 +1,88 @@ +import 'dart:convert'; +import 'dart:io'; + +/// Manifesto de fixtures SEM o bloco `acs` (a senha vem do relé, nunca do +/// `--dart-define`): `--dart-define=E2E_FIXTURES='{"patients":[…]}'`. +const _fixtures = String.fromEnvironment('E2E_FIXTURES'); +const relayBase = String.fromEnvironment('OTP_RELAY_BASE', defaultValue: 'http://localhost:8765'); + +class E2ePatient { + const E2ePatient({ + required this.id, + required this.role, + required this.name, + required this.cpf, + required this.birthDate, + required this.microAreaId, + }); + final String id; + final String role; + final String name; + final String cpf; // nunca em log + final String birthDate; // AAAA-MM-DD + final String microAreaId; + @override + String toString() => 'E2ePatient($role)'; +} + +List e2ePatients() { + if (_fixtures.isEmpty) { + throw StateError('sem --dart-define=E2E_FIXTURES (use scripts/qa/acs_full_e2e.sh)'); + } + final map = (jsonDecode(_fixtures) as Map).cast(); + return [ + for (final raw in (map['patients']! as List).cast()) + E2ePatient( + id: raw['id'] as String, + role: raw['role'] as String, + name: raw['name'] as String, + cpf: raw['cpf'] as String, + birthDate: raw['birthDate'] as String, + microAreaId: raw['microAreaId'] as String, + ), + ]; +} + +E2ePatient e2ePatient(String role) => e2ePatients().firstWhere((p) => p.role == role); + +Future _get(String path, {String query = ''}) async { + final client = HttpClient(); + try { + final request = await client.getUrl(Uri.parse(relayBase).replace(path: path, query: query)); + final response = await request.close(); + final body = await utf8.decodeStream(response); + if (response.statusCode != 200) throw StateError('o relé respondeu ${response.statusCode} a $path'); + return body.trim(); + } on SocketException { + throw StateError('o relé não está no ar (scripts/qa/otp_relay.py + adb reverse tcp:8765 tcp:8765)'); + } finally { + client.close(force: true); + } +} + +/// Matrícula e senha do ACS da fixture, servidas pelo relé (opt-in). +/// +/// O relé entrega `/acs` **uma única vez** por execução (a senha sintética não +/// fica servida), mas vários testes do mesmo arquivo precisam dela: a primeira +/// chamada busca e as seguintes reaproveitam. Todos rodam no mesmo processo. +Future<({String matricula, String senha})> acsCredentialFromRelay() => + _acsCredential ??= _buscarCredencialDoAcs(); + +Future<({String matricula, String senha})>? _acsCredential; + +Future<({String matricula, String senha})> _buscarCredencialDoAcs() async { + final json = jsonDecode(await _get('/acs')) as Map; + return (matricula: json['matricula'] as String, senha: json['senha'] as String); +} + +/// Matrícula e senha do SEGUNDO ACS da fixture (bloco `acsB`, mesma +/// microárea), servidas pelo relé em `/acs-b`, também uma única vez. +Future<({String matricula, String senha})> secondAcsCredentialFromRelay() => + _secondAcsCredential ??= _buscarCredencialDoSegundoAcs(); + +Future<({String matricula, String senha})>? _secondAcsCredential; + +Future<({String matricula, String senha})> _buscarCredencialDoSegundoAcs() async { + final json = jsonDecode(await _get('/acs-b')) as Map; + return (matricula: json['matricula'] as String, senha: json['senha'] as String); +} diff --git a/apps/acs/integration_test/support/legacy_v6.dart b/apps/acs/integration_test/support/legacy_v6.dart new file mode 100644 index 00000000..b543f2f8 --- /dev/null +++ b/apps/acs/integration_test/support/legacy_v6.dart @@ -0,0 +1,72 @@ +import 'package:sinalacs_acs/core/database/encrypted_database.dart'; +import 'package:sqflite_sqlcipher/sqflite.dart' as sqlcipher; + +/// `offline_visits` exatamente como a v6 a criava: sem a coluna `owner`. +const createOfflineVisitsV6 = ''' +CREATE TABLE IF NOT EXISTS offline_visits ( + local_id TEXT PRIMARY KEY, + patient_id TEXT NOT NULL, + risk TEXT NOT NULL, + status TEXT NOT NULL, + outcome TEXT NOT NULL, + notes TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + version INTEGER NOT NULL, + rejection_reason TEXT +)'''; + +/// Uma linha de visita no formato da v6 (sem dono). Dado SINTÉTICO. +Map legacyV6Row({ + required String localId, + required String patientId, + String notes = '', + String? rejectionReason, + DateTime? createdAt, +}) => + { + 'local_id': localId, + 'patient_id': patientId, + 'risk': 'yellow', + 'status': 'PENDENTE', + 'outcome': '', + 'notes': notes, + 'created_at': (createdAt ?? DateTime.utc(2026, 9, 1, 12)).toIso8601String(), + 'version': 1, + 'rejection_reason': rejectionReason, + }; + +/// Cria NO APARELHO, com o SQLCipher real e a [passphrase] dada, um banco com +/// o schema completo da v6 (`offline_visits` sem `owner`, `sync_cursor`, +/// `micro_area_cache`, `micro_area_cache_meta`, `user_version` = 6) e grava +/// [rows] em `offline_visits` e [cacheRows] em `micro_area_cache`. É o arquivo +/// que um aparelho com o app anterior tem; o app novo o abre e migra para a v7. +Future createV6Database({ + required String databaseName, + required String passphrase, + required List> rows, + List> cacheRows = const [], + Map cursor = const {}, +}) async { + final path = await EncryptedLocalDatabase.pathFor(databaseName); + final db = await sqlcipher.openDatabase( + path, + password: passphrase, + version: 6, + onCreate: (db, version) async { + await db.execute(createOfflineVisitsV6); + await db.execute(EncryptedLocalDatabase.createSyncCursor); + await db.execute(EncryptedLocalDatabase.createMicroAreaCache); + await db.execute(EncryptedLocalDatabase.createMicroAreaCacheMeta); + }, + ); + for (final row in rows) { + await db.insert('offline_visits', row); + } + for (final row in cacheRows) { + await db.insert('micro_area_cache', row); + } + for (final MapEntry(:key, :value) in cursor.entries) { + await db.insert('sync_cursor', {'key': key, 'value': value}); + } + await db.close(); +} diff --git a/apps/acs/integration_test/support/totp.dart b/apps/acs/integration_test/support/totp.dart new file mode 100644 index 00000000..f020bd45 --- /dev/null +++ b/apps/acs/integration_test/support/totp.dart @@ -0,0 +1,34 @@ +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; + +/// Gerador TOTP (RFC 6238, SHA-1, 6 dígitos, 30 s) SÓ para testes: a prova de +/// MFA no emulador precisa produzir o código que o autenticador produziria. +String totpCode(Uint8List secret, DateTime at) { + final passo = at.toUtc().millisecondsSinceEpoch ~/ 1000 ~/ 30; + final mensagem = ByteData(8)..setUint64(0, passo); + final h = Hmac(sha1, secret).convert(mensagem.buffer.asUint8List()).bytes; + final o = h[h.length - 1] & 0x0f; + final bin = ((h[o] & 0x7f) << 24) | (h[o + 1] << 16) | (h[o + 2] << 8) | h[o + 3]; + return (bin % 1000000).toString().padLeft(6, '0'); +} + +/// Decodifica base32 (RFC 4648, sem preenchimento) — o que o servidor entrega em `secretBase32`. +Uint8List base32Decode(String texto) { + const alfabeto = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + var bits = 0; + var valor = 0; + final saida = []; + for (final c in texto.toUpperCase().split('')) { + final i = alfabeto.indexOf(c); + if (i < 0) continue; + valor = (valor << 5) | i; + bits += 5; + if (bits >= 8) { + saida.add((valor >> (bits - 8)) & 0xff); + bits -= 8; + valor &= (1 << bits) - 1; + } + } + return Uint8List.fromList(saida); +} diff --git a/apps/acs/lib/app/acs_theme.dart b/apps/acs/lib/app/acs_theme.dart index b9f5e01b..82694792 100644 --- a/apps/acs/lib/app/acs_theme.dart +++ b/apps/acs/lib/app/acs_theme.dart @@ -1,55 +1,160 @@ import 'package:flutter/material.dart'; +/// Cores clínicas e de marca, compartilhadas pelos dois temas. +/// +/// `red`/`yellow`/`green` só podem significar `RiskLevel` (ver CLAUDE.md); o +/// mesmo tom vale em claro e escuro — é a superfície ao redor que muda. Para +/// usar qualquer uma destas como TEXTO/ÍCONE em vez de preenchimento, use +/// [AcsRiskColors] (via `context.acsRisk`), nunca o valor daqui direto. abstract final class AcsColors { - static const background = Color(0xFF030712); - static const surface = Color(0xFF111827); - static const surfaceRaised = Color(0xFF1F2937); - static const border = Color(0xFF374151); static const accent = Color(0xFF2563EB); static const red = Color(0xFFDC2626); static const yellow = Color(0xFFF59E0B); static const green = Color(0xFF10B981); +} + +abstract final class AcsDarkColors { + static const background = Color(0xFF030712); + static const surface = Color(0xFF111827); + static const surfaceRaised = Color(0xFF1F2937); + static const border = Color(0xFF374151); +} + +abstract final class AcsLightColors { + static const background = Color(0xFFF9FAFB); + static const surface = Color(0xFFFFFFFF); + static const surfaceRaised = Color(0xFFFFFFFF); + static const border = Color(0xFFD1D5DB); +} + +/// Variantes de `AcsColors` para uso como TEXTO/ÍCONE sobre superfície +/// (`surfaceRaised`/`background`), nunca como preenchimento. +/// +/// Ao contrário de [AcsColors], a cor certa depende do tema ativo — um +/// [ThemeExtension] em vez de `static const`. No escuro, `red`/`accent` +/// como texto direto caem para 3.04:1 e 2.84:1 (abaixo de 4.5:1, WCAG +/// 1.4.3); `yellow`/`green` já passam e por isso reaparecem inalterados. No +/// claro é o oposto: `yellow`/`green` (desenhados para fundo escuro) caem +/// para ~2:1, e `red` cai abaixo de 4.5:1 sobre o fundo da tela — só +/// `accent` sobrevive sem alteração. Ver `test/contrast_tokens_test.dart` e +/// `test/contrast_tokens_light_test.dart`. +@immutable +class AcsRiskColors extends ThemeExtension { + const AcsRiskColors({ + required this.redOnSurface, + required this.accentOnSurface, + required this.yellowOnSurface, + required this.greenOnSurface, + }); + + final Color redOnSurface; + final Color accentOnSurface; + final Color yellowOnSurface; + final Color greenOnSurface; - /// Variantes de `red`/`accent` para uso como TEXTO/ÍCONE sobre superfície - /// escura (`surface`/`surfaceRaised`), não como preenchimento. - /// - /// `red` e `accent` só atingem ≥4.5:1 (WCAG 1.4.3) sobre o fundo do - /// Scaffold — sobre `surfaceRaised` caem para 3.04:1 e 2.84:1. Como - /// preenchimento de botão eles continuam corretos (branco sobre `red` dá - /// 4.83: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`. - static const redOnSurface = Color(0xFFF87171); - static const accentOnSurface = Color(0xFF60A5FA); + static const dark = AcsRiskColors( + redOnSurface: Color(0xFFF87171), + accentOnSurface: Color(0xFF60A5FA), + yellowOnSurface: AcsColors.yellow, + greenOnSurface: AcsColors.green, + ); + + static const light = AcsRiskColors( + redOnSurface: Color(0xFFB91C1C), + accentOnSurface: AcsColors.accent, + yellowOnSurface: Color(0xFFB45309), + greenOnSurface: Color(0xFF047857), + ); + + @override + AcsRiskColors copyWith({ + Color? redOnSurface, + Color? accentOnSurface, + Color? yellowOnSurface, + Color? greenOnSurface, + }) => + AcsRiskColors( + redOnSurface: redOnSurface ?? this.redOnSurface, + accentOnSurface: accentOnSurface ?? this.accentOnSurface, + yellowOnSurface: yellowOnSurface ?? this.yellowOnSurface, + greenOnSurface: greenOnSurface ?? this.greenOnSurface, + ); + + @override + AcsRiskColors lerp(ThemeExtension? other, double t) { + if (other is! AcsRiskColors) return this; + return AcsRiskColors( + redOnSurface: Color.lerp(redOnSurface, other.redOnSurface, t)!, + accentOnSurface: Color.lerp(accentOnSurface, other.accentOnSurface, t)!, + yellowOnSurface: Color.lerp(yellowOnSurface, other.yellowOnSurface, t)!, + greenOnSurface: Color.lerp(greenOnSurface, other.greenOnSurface, t)!, + ); + } } -/// Cor de texto/ícone equivalente a uma cor clínica de preenchimento. +/// Atalho para ler [AcsRiskColors] do tema ativo. +/// +/// Cai para [AcsRiskColors.dark] quando a extensão não está registrada (um +/// `MaterialApp` de teste com `ThemeData()` padrão, por exemplo) — nunca +/// derruba a árvore de widgets por causa de uma variante de cor de texto. +extension AcsThemeContext on BuildContext { + AcsRiskColors get acsRisk => Theme.of(this).extension() ?? AcsRiskColors.dark; +} + +/// Cor de texto/ícone equivalente a uma cor clínica de preenchimento, no +/// tema ativo em [context]. /// /// Ponto único de conversão: qualquer lugar que hoje usa uma cor de -/// `switch (riskLevel) { ... AcsColors.red/accent ... }` como cor de -/// preenchimento (botão, badge, legenda) passa por aqui antes de aplicá-la a -/// um `TextStyle`/`Icon`. `yellow`/`green` retornam inalterados. -Color acsOnSurface(Color fill) => switch (fill) { - AcsColors.red => AcsColors.redOnSurface, - AcsColors.accent => AcsColors.accentOnSurface, - _ => fill, - }; - -ThemeData buildAcsTheme() => ThemeData( +/// `switch (riskLevel) { ... AcsColors.red/accent/yellow/green ... }` como +/// cor de preenchimento (botão, badge, legenda) passa por aqui antes de +/// aplicá-la a um `TextStyle`/`Icon`. +Color acsOnSurface(BuildContext context, Color fill) { + final risk = context.acsRisk; + return switch (fill) { + AcsColors.red => risk.redOnSurface, + AcsColors.accent => risk.accentOnSurface, + AcsColors.yellow => risk.yellowOnSurface, + AcsColors.green => risk.greenOnSurface, + _ => fill, + }; +} + +ThemeData buildAcsDarkTheme() => ThemeData( useMaterial3: true, brightness: Brightness.dark, - scaffoldBackgroundColor: AcsColors.background, - colorScheme: ColorScheme.fromSeed(seedColor: AcsColors.accent, brightness: Brightness.dark, surface: AcsColors.surface), - appBarTheme: const AppBarTheme(backgroundColor: AcsColors.surface, foregroundColor: Colors.white, elevation: 0), + scaffoldBackgroundColor: AcsDarkColors.background, + colorScheme: ColorScheme.fromSeed(seedColor: AcsColors.accent, brightness: Brightness.dark, surface: AcsDarkColors.surface), + appBarTheme: const AppBarTheme(backgroundColor: AcsDarkColors.surface, foregroundColor: Colors.white, elevation: 0), + cardTheme: CardThemeData( + color: AcsDarkColors.surfaceRaised, + elevation: 0, + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8), side: const BorderSide(color: AcsDarkColors.border)), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: AcsDarkColors.surface, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: const BorderSide(color: AcsDarkColors.border)), + ), + extensions: const [AcsRiskColors.dark], +); + +ThemeData buildAcsLightTheme() => ThemeData( + useMaterial3: true, + brightness: Brightness.light, + scaffoldBackgroundColor: AcsLightColors.background, + colorScheme: ColorScheme.fromSeed(seedColor: AcsColors.accent, brightness: Brightness.light, surface: AcsLightColors.surface), + appBarTheme: const AppBarTheme(backgroundColor: AcsLightColors.surface, foregroundColor: Color(0xFF111827), elevation: 0), cardTheme: CardThemeData( - color: AcsColors.surfaceRaised, + color: AcsLightColors.surfaceRaised, elevation: 0, margin: EdgeInsets.zero, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8), side: const BorderSide(color: AcsColors.border)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8), side: const BorderSide(color: AcsLightColors.border)), ), inputDecorationTheme: InputDecorationTheme( filled: true, - fillColor: AcsColors.surface, - border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: const BorderSide(color: AcsColors.border)), + fillColor: AcsLightColors.background, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: const BorderSide(color: AcsLightColors.border)), ), -); \ No newline at end of file + extensions: const [AcsRiskColors.light], +); diff --git a/apps/acs/lib/app/app.dart b/apps/acs/lib/app/app.dart index b6b22233..082d2015 100644 --- a/apps/acs/lib/app/app.dart +++ b/apps/acs/lib/app/app.dart @@ -2,22 +2,34 @@ import 'dart:async'; import 'dart:developer' as developer; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show FilteringTextInputFormatter; import 'package:geolocator/geolocator.dart'; import 'package:google_maps_flutter/google_maps_flutter.dart'; import 'package:sinalacs_acs/app/acs_theme.dart'; +import 'package:sinalacs_acs/app/app_lock_gate.dart'; +import 'package:sinalacs_acs/app/pending_alerts_banner.dart'; +import 'package:sinalacs_acs/app/invite_screen.dart'; +import 'package:sinalacs_acs/app/mfa_enrollment_screen.dart'; import 'package:sinalacs_acs/core/database/sqlcipher_visit_store.dart'; import 'package:sinalacs_acs/core/geo/location_cell.dart'; +import 'package:sinalacs_acs/core/network/auth_session.dart'; import 'package:sinalacs_acs/core/network/backend_client.dart'; import 'package:sinalacs_acs/core/network/backend_scope.dart'; +import 'package:sinalacs_acs/core/security/biometric_gate.dart'; import 'package:sinalacs_acs/core/security/database_key_store.dart'; +import 'package:sinalacs_acs/core/security/session_token_store.dart'; +import 'package:sinalacs_acs/core/security/upload_token_store.dart'; import 'package:sinalacs_acs/core/services/alert_feed.dart'; import 'package:sinalacs_acs/core/services/alert_queue.dart'; +import 'package:sinalacs_acs/core/services/deferred_flush_service.dart'; +import 'package:sinalacs_acs/core/services/emergency_dialer.dart'; +import 'package:sinalacs_acs/core/services/micro_area_directory.dart'; import 'package:sinalacs_acs/core/services/offline_visit_queue.dart'; import 'package:sinalacs_client/sinalacs_client.dart' show MicroAreaPatient; import 'package:sinalacs_acs/core/services/reconnect_schedule.dart'; import 'package:sinalacs_acs/core/services/route_service.dart'; +import 'package:sinalacs_acs/core/services/theme_controller.dart'; import 'package:sinalacs_acs/core/services/visit_pull_service.dart'; -import 'package:sinalacs_acs/core/services/visit_pull_service_factory.dart'; import 'package:sinalacs_acs/core/services/visit_queue_factory.dart'; class SinalAcsApp extends StatefulWidget { @@ -27,11 +39,52 @@ class SinalAcsApp extends StatefulWidget { this.feedBuilder, this.visitQueue, this.visitPullService, + this.visitStorage, + this.visitScopeResolver, + this.microAreaDirectory, this.initialAlert, this.currentPosition, + this.themeController, this.syncInterval, + this.biometricGate, + this.navigatorKey, + this.lockAfter, + this.deferredFlush, + this.uploadTokens, + this.deviceIds, + this.connectivityChanges, }); + /// Envio diferido (D4/D7) já montado — gancho de teste. `null` monta um + /// sobre o armazenamento de visitas do app quando [uploadTokens] e + /// [deviceIds] são dados; sem eles (o padrão dos testes de widget), não há + /// envio diferido nenhum e nada sai para a rede por este caminho. + final DeferredFlushService? deferredFlush; + + /// Tokens de envio por dono. Em produção, o `main.dart` passa o + /// `SecureStorageUploadTokenStore` — a MESMA instância do `BackendClient`. + final UploadTokenStore? uploadTokens; + + /// Id da instalação, a MESMA instância do `BackendClient`. + final DeviceIdStore? deviceIds; + + /// `true` quando há rede, `false` sem rede (o `main.dart` liga ao + /// `connectivity_plus`). Passar a ter rede dispara o envio diferido. `null`: + /// sem esse gatilho (o canal de plataforma não existe no `flutter test`). + final Stream? connectivityChanges; + + /// Desbloqueio local (digital/rosto ou bloqueio de tela). `null` usa + /// [LocalAuthBiometricGate]; testes injetam um duplo. + final BiometricGate? biometricGate; + + /// Navigator do app, o mesmo entregue ao [AppLockGate] (que precisa dele + /// para engolir o voltar enquanto bloqueado). `null` cria uma chave própria. + final GlobalKey? navigatorKey; + + /// Tempo em segundo plano até bloquear. `null` usa o padrão do + /// [AppLockGate] (30 s). + final Duration? lockAfter; + /// Backend do app. **Obrigatório, e construído em `main.dart`.** /// /// Havia aqui um `widget.backend ?? BackendClient()`: um cliente **sem a CA do @@ -43,10 +96,30 @@ class SinalAcsApp extends StatefulWidget { /// entra no lugar do real. final AcsBackend backend; final AlertFeed Function(AlertQueue queue)? feedBuilder; + + /// Gancho de teste (D5): fila usada para QUALQUER dono. `null` (produção) + /// monta uma fila por `userId` sobre [visitStorage]. final OfflineVisitQueue? visitQueue; + + /// Gancho de teste (D5): pull usado para QUALQUER dono. `null` (produção) + /// monta um por `userId|microAreaId`. final VisitPullService? visitPullService; + + /// Armazenamento das visitas, com uma visão por dono. `null` usa o banco + /// SQLCipher do aparelho; testes passam `InMemoryVisitStorage`. + final VisitStorage? visitStorage; + + /// Substitui inteira a resolução de fila + pull por sessão. + final VisitScopeResolver? visitScopeResolver; + + /// Lista da microárea com cache no aparelho (RF08). `null` lê direto do + /// backend, sem cache: quem monta o de produção é o `main.dart` + /// (`buildMicroAreaDirectory`). O padrão não monta um sozinho porque o + /// Keystore não existe no `flutter test` e a carga ficaria pendurada. + final MicroAreaDirectory? microAreaDirectory; final PrioritizedAlert? initialAlert; final LatLng? currentPosition; + final ThemeController? themeController; /// Intervalo da sincronização periódica em segundo plano (visitas + /// pacientes da microárea). `null` usa `AcsHomeShell.defaultSyncInterval` @@ -57,78 +130,519 @@ class SinalAcsApp extends StatefulWidget { State createState() => _SinalAcsAppState(); } -class _SinalAcsAppState extends State { - /// Compartilhado entre a fila offline e o serviço de pull (RF15). - /// - /// `VisitPullService` lê deste MESMO store para nunca reintroduzir - /// localmente uma visita que já está na fila offline (dedupe por - /// `localId`, ver `visit_pull_service.dart`). Duas instâncias separadas de - /// `SqlCipherVisitStore` apontando para o mesmo arquivo até funcionariam, - /// mas por acaso — uma só instância é o que garante que o pull enxerga - /// exatamente o que a fila gravou por último. - late final VisitStore _visitStore = SqlCipherVisitStore(keyStore: SecureStorageDatabaseKeyStore()); - - /// Uma única fila por execução do app. +/// Teto de cada etapa de envio do "Sair" e do "Limpar este aparelho" (a fila +/// do painel e o envio diferido): sem rede que responda, a saída segue — o que +/// não subiu fica no aparelho, protegido. +const sessionEndSendTimeout = Duration(seconds: 20); + +/// Teto de cada revogação de token de envio no "Limpar este aparelho" +/// (best-effort: o token apagado do aparelho já não serve a ninguém aqui). +const wipeRevokeTimeout = Duration(seconds: 5); + +/// O que o painel pede ao app ao encerrar o turno ou limpar o aparelho: o app +/// é dono do armazenamento, das filas memoizadas e do envio diferido. +abstract interface class _DeviceCare { + /// Envio diferido (legado + filas de outros donos), com teto. `null`: sem + /// envio diferido montado, ou o teto venceu. + Future flushOthers(); + + /// Visitas de OUTROS donos e da quarentena no disco, mais as que só existem + /// na memória de filas de outros donos. Só contagem; 0 se não deu para ler. + Future pendingElsewhere(String currentOwner); + + /// Tudo o que ainda não subiu, lido do DISCO (todos os donos + quarentena) + /// mais o que só está na memória de alguma fila, separado por motivo (só + /// contagens). `null` se não deu para conferir (quem chama trata como + /// bloqueio). + Future unsentOnDevice( + String currentOwner, + OfflineVisitQueue current, { + Set ownersWithoutToken, + int legacyNeedsReview, + }); + + /// Depois do "Sair": tira da memória a fila e o escopo de [owner] para o + /// próximo login dele reler o disco. [settled] `false` (o envio da fila + /// ainda pode estar em voo) mantém o MESMO objeto, para nunca haver duas + /// filas sobre o mesmo dono. + void release(String owner, {required bool settled}); + + /// Dispara o envio diferido sem esperar (depois do logout, o dono que saiu + /// passa a ser "outro dono": fila vazia → token revogado e apagado). + void afterLogout(); + + /// "Limpar este aparelho". Lança [WipeBlocked] sem apagar nada se alguma + /// visita não foi enviada (disco ou memória). + Future wipe(String currentOwner, OfflineVisitQueue current); +} + +class _DeviceCareScope extends InheritedWidget { + const _DeviceCareScope({required this.care, required super.child}); + + final _DeviceCare care; + + static _DeviceCare? maybeOf(BuildContext context) => + context.getInheritedWidgetOfExactType<_DeviceCareScope>()?.care; + + @override + bool updateShouldNotify(_DeviceCareScope oldWidget) => care != oldWidget.care; +} + +/// O que impede o "Limpar este aparelho", só em contagens. [own] + [elsewhere] +/// é o total (desta conta / de outras contas e sem autor); [unsent] + +/// [review] + [noToken] é o mesmo total, por motivo: ainda não enviadas +/// (dependem de rede), recusadas ou em revisão (dependem do ACS dono), e de +/// outro dono sem chave de envio neste aparelho (dependem de ele entrar). +typedef UnsentOnDevice = ({int own, int elsewhere, int unsent, int review, int noToken}); + +/// Fila que não conseguiu gravar e ainda tem visita: essas visitas existem só +/// em RAM, e descartá-la as perderia. +bool _onlyInMemory(OfflineVisitQueue queue) => + queue.persistenceFailed && (queue.pendingCount > 0 || queue.rejectedCount > 0); + +/// Fila que não pode sair da memória: só em RAM, ou com um envio em voo +/// (`isSyncing`). A segunda, descartada, faria o próximo login do dono montar +/// OUTRA fila sobre o mesmo disco — e o fim do envio antigo regravaria esse +/// disco às cegas, apagando o que a fila nova registrou. +bool _mustKeep(OfflineVisitQueue queue) => _onlyInMemory(queue) || queue.isSyncing; + +class _SinalAcsAppState extends State with WidgetsBindingObserver implements _DeviceCare { + /// Armazenamento de visitas do aparelho: UM banco, uma visão por dono. /// - /// A tela de visita antes fazia `OfflineVisitQueue()` a cada gravação — uma - /// instância nova por visita, descartada no retorno do callback. A visita - /// simplesmente sumia. - late final OfflineVisitQueue _visitQueue = widget.visitQueue ?? _persistentQueue(); + /// A fila e o cursor do pull de cada dono são montados sobre este mesmo + /// `VisitDatabase` (`buildOwnerVisitScope`): o pull deduplica contra a fila + /// do MESMO dono, e o cursor vive no mesmo arquivo, numa só conexão. + late final VisitStorage _storage = + widget.visitStorage ?? SqlCipherVisitStorage(keyStore: SecureStorageDatabaseKeyStore()); - /// Fila respaldada pelo banco criptografado, com o sincronizador ligado. + /// Uma fila por `userId`, enquanto o app vive. + /// + /// Nunca a fila de A sobre o armazenamento de B: os registros em memória de + /// A seriam regravados na visão de B e todo `save` dali em diante falharia + /// (`VisitLocalIdConflict`). Também nunca DUAS filas sobre o mesmo dono — a + /// mais antiga regravaria o disco com um retrato velho. Por isso a fila é + /// memoizada por usuário, e não por usuário+microárea. + final Map _queues = {}; + + /// Escopo (fila + pull) por `userId|microAreaId`: o mesmo ACS que sai e + /// volta, ou que se reautentica, reencontra o seu; outro ACS ganha o dele. + final Map _scopes = {}; + + /// Fila e pull do dono de [session]. /// - /// A chave vive no Keystore/Keychain, nunca no código. Deixou de ser `static` - /// para enxergar o backend: sem sincronizador, `sync()` caía no ramo sem - /// remetente e devolvia erro — as visitas nunca subiam ao servidor e o que já - /// estava confirmado nunca era apagado do disco. - OfflineVisitQueue _persistentQueue() => - buildVisitQueue(backend: widget.backend, store: _visitStore); - - /// Serviço de pull central→dispositivo (RF15, decisão §5). + /// Ordem: o resolvedor injetado; senão a fila/o pull injetados (gancho de + /// teste, D5 — valem para qualquer dono); senão a montagem por dono sobre + /// [_storage]. + OwnerVisitScope _scopeFor(AuthSession session) { + final resolver = widget.visitScopeResolver; + if (resolver != null) return resolver(session); + final injectedQueue = widget.visitQueue; + final injectedPull = widget.visitPullService; + if (injectedQueue != null && injectedPull != null) { + return OwnerVisitScope(queue: injectedQueue, pullService: injectedPull); + } + // Só o dono da sessão fica memoizado. O envio diferido (D7) sobe e apaga + // do disco as visitas de quem saiu; uma fila velha de A guardada aqui + // voltaria, no próximo login de A, com um retrato anterior a esse envio + // (contagem errada e reenvio do que já subiu). Sem memo, a fila de A é + // relida do disco. O painel de quem saiu já foi descartado. + // + // EXCETO a fila que não conseguiu gravar (`persistenceFailed`) e ainda tem + // visita: essas visitas existem só em RAM, e descartá-la as perderia — o + // próximo login do dono precisa reencontrá-las. + _queues.removeWhere((owner, queue) => owner != session.userId && !_mustKeep(queue)); + _scopes.removeWhere((key, scope) => !key.startsWith('${session.userId}|') && !_mustKeep(scope.queue)); + return _scopes.putIfAbsent(visitCursorOwner(session), () { + final built = buildOwnerVisitScope( + backend: widget.backend, + storage: _storage, + session: session, + queue: injectedQueue ?? + _queues.putIfAbsent( + session.userId, + () => buildVisitQueue( + backend: widget.backend, + store: _storage.forOwner(session.userId), + ownerId: session.userId, + ), + ), + ); + return OwnerVisitScope( + queue: built.queue, + pullService: injectedPull ?? built.pullService, + ); + }); + } + + @override + Future flushOthers() async { + final flush = _flush; + if (flush == null) return null; + try { + return await flush.flushAll().timeout(sessionEndSendTimeout); + } catch (_) { + return null; // o envio segue em segundo plano; nada se perde + } + } + + @override + Future pendingElsewhere(String currentOwner) async { + var total = 0; + try { + final flush = _flush; + if (flush != null) { + total = await flush.pendingElsewhere(currentOwner); + } else { + total = (await _storage.legacy.load()).length; + (await _storage.countsByOwner()).forEach((owner, count) { + if (owner != currentOwner) total += count; + }); + } + for (final MapEntry(key: owner, value: queue) in _queues.entries) { + if (owner != currentOwner) total += await _onlyInRam(owner, queue); + } + } catch (_) { + // Sem leitura do disco: o aviso fica só com a contagem da fila do painel. + } + return total; + } + + /// Visitas de [queue] que não estão no disco de [owner]. + Future _onlyInRam(String owner, OfflineVisitQueue queue) async { + final inQueue = [...queue.pendingVisits, ...queue.rejectedVisits]; + if (inQueue.isEmpty) return 0; + final onDisk = {for (final row in await _storage.forOwner(owner).load()) row.localId}; + return inQueue.where((visit) => !onDisk.contains(visit.localId)).length; + } + + @override + Future unsentOnDevice( + String currentOwner, + OfflineVisitQueue current, { + Set ownersWithoutToken = const {}, + int legacyNeedsReview = 0, + }) async { + try { + var own = 0, elsewhere = 0, unsent = 0, review = 0, noToken = 0; + void conta(String? owner, OfflineVisitRecord visit) { + if (owner == currentOwner) { + own++; + } else { + elsewhere++; + } + if (visit.rejectionReason != null) { + review++; + } else if (owner != null && owner != currentOwner && ownersWithoutToken.contains(owner)) { + noToken++; + } else { + unsent++; + } + } + + // Disco: cada dono com linhas e a quarentena. O conteúdo é lido só para + // separar recusada de pendente; nada dele sai daqui. + final disco = >{}; + for (final owner in (await _storage.countsByOwner()).keys) { + final rows = await _storage.forOwner(owner).load(); + disco[owner] = {for (final row in rows) row.localId}; + for (final row in rows) { + conta(owner, row); + } + } + var revisaoLegada = legacyNeedsReview; + for (final row in await _storage.legacy.load()) { + if (row.rejectionReason == null && revisaoLegada > 0) { + revisaoLegada--; + elsewhere++; + review++; // recusada pela central nesta execução ('versão diferente') + } else { + conta(null, row); + } + } + + // Memória: visitas de filas que não estão no disco do dono delas. + final filas = {..._queues, currentOwner: current}; + for (final MapEntry(key: owner, value: queue) in filas.entries) { + final naFila = [...queue.pendingVisits, ...queue.rejectedVisits]; + if (naFila.isEmpty) continue; + final noDisco = disco[owner] ?? {for (final row in await _storage.forOwner(owner).load()) row.localId}; + for (final visit in naFila) { + if (!noDisco.contains(visit.localId)) conta(owner, visit); + } + } + return (own: own, elsewhere: elsewhere, unsent: unsent, review: review, noToken: noToken); + } catch (_) { + return null; + } + } + + @override + void release(String owner, {required bool settled}) { + if (!settled) return; + final queue = _queues[owner]; + if (queue != null && _mustKeep(queue)) return; + _queues.remove(owner); + _scopes.removeWhere((key, scope) => key.startsWith('$owner|') && !_mustKeep(scope.queue)); + } + + @override + void afterLogout() => _triggerFlush(); + + @override + Future wipe(String currentOwner, OfflineVisitQueue current) async { + // A memória primeiro: o disco não enxerga a fila que não conseguiu gravar. + final inMemory = {..._queues.values, current} + .fold(0, (total, queue) => total + queue.pendingCount + queue.rejectedCount); + if (inMemory > 0) throw WipeBlocked(inMemory); + + // Reconta e apaga numa transação; recusa sem apagar nada se sobrou visita. + await _storage.wipeAllData(); + + final tokens = widget.uploadTokens; + if (tokens != null) { + Set owners; + try { + owners = {...await tokens.owners(), currentOwner}; + } catch (_) { + owners = {currentOwner}; + } + for (final owner in owners) { + try { + final token = await tokens.read(owner); + // Best-effort e com teto: sem rede o token só é apagado daqui. + if (token != null) await widget.backend.revokeUploadToken(token).timeout(wipeRevokeTimeout); + } catch (_) {} + } + try { + await tokens.clearAll(alsoOwners: owners); + } catch (_) { + // Keystore falhou: os dados já foram apagados. Um token que sobrar + // não acha visita nenhuma e é revogado pelo próximo envio diferido. + } + } + + _queues.clear(); + _scopes.clear(); + } + + late final ThemeController _themeController = widget.themeController ?? ThemeController(); + + late final BiometricGate _gate = widget.biometricGate ?? LocalAuthBiometricGate(); + late final GlobalKey _navigatorKey = widget.navigatorKey ?? GlobalKey(); + final _AppLockHooks _lockHooks = _AppLockHooks(); + + /// Muda a cada "Entrar com senha": o [AppLockGate] não tem API de + /// desbloqueio pela senha (contrato (d)), então ele é recriado com outra + /// `key`. O Navigator sobrevive porque tem `GlobalKey` própria; o `dispose` + /// do gate antigo tira a rota que engolia o voltar. + int _gateGeneration = 0; + + /// Envio do que está preso no aparelho e não é da sessão atual (legado e + /// filas de quem saiu). Ver [SinalAcsApp.deferredFlush]. + late final DeferredFlushService? _flush = widget.deferredFlush ?? + (widget.uploadTokens != null && widget.deviceIds != null + ? DeferredFlushService( + backend: widget.backend, + storage: _storage, + tokens: widget.uploadTokens!, + deviceIds: widget.deviceIds!, + ) + : null); + + StreamSubscription? _connectivity; + bool? _online; + + /// Dispara o envio diferido sem esperar: nunca bloqueia a UI e nunca mostra + /// nada (nem conteúdo clínico, nem erro) — `flushAll` não lança, é + /// single-flight, e o que não subir fica para o próximo gatilho. + void _triggerFlush() { + final flush = _flush; + if (flush != null) unawaited(flush.flushAll()); + } + + /// Resolve o escopo do painel que vai abrir e dispara o envio diferido + /// (gatilho "depois de abrir o painel"). Só `_openShell` chama o resolvedor. + OwnerVisitScope _scopeForShell(AuthSession session) { + final scope = _scopeFor(session); + _triggerFlush(); + return scope; + } + + @override + void initState() { + super.initState(); + _themeController.restore(); + WidgetsBinding.instance.addObserver(this); + _connectivity = widget.connectivityChanges?.listen((online) { + final was = _online; + _online = online; + if (online && was != true) _triggerFlush(); + }); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) _triggerFlush(); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _connectivity?.cancel(); + if (widget.themeController == null) _themeController.dispose(); + super.dispose(); + } + + /// "Entrar com senha" na cobertura do bloqueio. /// - /// Usa o MESMO `_visitStore` da fila — ver o comentário acima. - late final VisitPullService _visitPullService = widget.visitPullService ?? - buildVisitPullService(backend: widget.backend, localVisits: _visitStore); + /// 1. Toda tela de login montada descarta senha e código digitados antes do + /// bloqueio, e a ativação da MFA (com o segredo) sai da pilha. + /// 2. Com painel aberto, ele empilha a reautenticação por senha (a mesma da + /// sessão vencida) com transição INSTANTÂNEA e opaca, e só avisa + /// ([_AppLockHooks.openPasswordReauth] chama `onCovered`) quando a rota já + /// está opaca e pintada. Só então o gate é recriado (nova `key`; ele não + /// tem API de desbloqueio pela senha). Em quadro algum o painel fica à + /// vista, tocável ou na semântica: antes, a cobertura; depois, o + /// formulário opaco e não descartável. O painel (feed MQTT, filas, + /// formulários) segue montado por baixo até a senha do MESMO usuário. + /// Esperar o `pop` da reautenticação para recriar o gate não funciona: a + /// tela empurrada durante o bloqueio fica sob a cobertura (contrato (e)). + /// 3. Sem painel (bloqueou numa tela de login), a tela já é o formulário. + void _usePassword() { + if (_unlockingByPassword) return; + _unlockingByPassword = true; + _lockHooks.passwordRequests.value++; + void unlock() { + _unlockingByPassword = false; + if (mounted) setState(() => _gateGeneration++); + } + + // A reautenticação não abriu (o `push` lançou): a cobertura FICA — tirá-la + // sem o formulário por cima mostraria o painel sem senha —, mas a flag + // volta, e "Entrar com senha" e "Desbloquear" seguem tocáveis. Nunca uma + // cobertura presa sem saída. + void failed() => _unlockingByPassword = false; + + final open = _lockHooks.openPasswordReauth; + if (open == null) { + unlock(); + return; + } + try { + open(unlock, failed); + } catch (_) { + failed(); + } + } - // Sem `dispose`: este widget não cria mais cliente nenhum (o `main` é quem - // constrói e injeta), então não há o que fechar — fechar um backend injetado - // seria fechar o de quem injetou. + bool _unlockingByPassword = false; @override Widget build(BuildContext context) => BackendScope( backend: widget.backend, - child: MaterialApp( - title: 'SinalACS ACS', - debugShowCheckedModeBanner: false, - theme: buildAcsTheme(), - home: LoginScreen( - feedBuilder: widget.feedBuilder, - visitQueue: _visitQueue, - visitPullService: _visitPullService, - initialAlert: widget.initialAlert, - initialPosition: widget.currentPosition, - syncInterval: widget.syncInterval, + child: _DeviceCareScope( + care: this, + child: _AppLockScope( + hooks: _lockHooks, + child: ValueListenableBuilder( + valueListenable: _themeController, + builder: (context, mode, _) => MaterialApp( + title: 'SinalACS ACS', + debugShowCheckedModeBanner: false, + navigatorKey: _navigatorKey, + theme: buildAcsLightTheme(), + darkTheme: buildAcsDarkTheme(), + themeMode: mode, + // No `builder`, ACIMA do Navigator: cobre também as rotas + // empilhadas (formulário de visita, reautenticação). + builder: (context, child) => AppLockGate( + key: ValueKey(_gateGeneration), + gate: _gate, + navigatorKey: _navigatorKey, + lockAfter: widget.lockAfter ?? const Duration(seconds: 30), + onUsePassword: _usePassword, + child: child!, + ), + home: LoginScreen( + feedBuilder: widget.feedBuilder, + visitScopeFor: _scopeForShell, + directory: widget.microAreaDirectory, + initialAlert: widget.initialAlert, + initialPosition: widget.currentPosition, + themeController: _themeController, + syncInterval: widget.syncInterval, + biometricGate: _gate, + ), + ), ), + ), ), ); } +/// Ponte entre o app (dono do [AppLockGate]) e o painel aberto (dono das +/// rotas e do estado que a reautenticação precisa preservar). +class _AppLockHooks { + /// Registrado pelo [AcsHomeShell] montado; `null` sem painel. Chama + /// `onCovered` quando o formulário de senha já cobre o painel, ou `onFailed` + /// se ele não pôde ser aberto (a cobertura do bloqueio continua). + void Function(VoidCallback onCovered, VoidCallback onFailed)? openPasswordReauth; + + /// Incrementado a cada "Entrar com senha": as telas de login limpam o que + /// foi digitado antes do bloqueio. + final ValueNotifier passwordRequests = ValueNotifier(0); +} + +class _AppLockScope extends InheritedWidget { + const _AppLockScope({required this.hooks, required super.child}); + + final _AppLockHooks hooks; + + static _AppLockHooks? maybeOf(BuildContext context) => + context.getInheritedWidgetOfExactType<_AppLockScope>()?.hooks; + + @override + bool updateShouldNotify(_AppLockScope oldWidget) => hooks != oldWidget.hooks; +} + class LoginScreen extends StatefulWidget { const LoginScreen({ - required this.visitQueue, - required this.visitPullService, + required this.visitScopeFor, + required this.themeController, super.key, + this.directory, this.feedBuilder, this.initialAlert, this.initialPosition, this.syncInterval, + this.aviso, + this.reauthUserId, + this.biometricGate, }); + /// Presente só na partida a frio: com refresh token guardado, pede a digital + /// (ou o bloqueio de tela) e retoma a sessão sem senha. `null` (reautenticação, + /// tela depois de "Sair") nunca retoma sozinho. + final BiometricGate? biometricGate; + + /// Preenchido quando a tela é empilhada SOBRE o painel porque a sessão venceu + /// (MFA, sem refresh token): é o `userId` da sessão que o painel guarda. Mesmo + /// usuário: a tela só dá `pop` e o painel (fila de alertas, feed MQTT, fila de + /// visitas, formulários) segue vivo. Outro usuário: o painel antigo é + /// descartado (RNF06 — nada do território anterior aparece). + final String? reauthUserId; + + /// Aviso fixo no topo do formulário (ex.: sessão expirada). + final String? aviso; final AlertFeed Function(AlertQueue queue)? feedBuilder; - final OfflineVisitQueue visitQueue; - final VisitPullService visitPullService; + + /// Fila e pull de visitas do dono da sessão que esta tela abrir. O painel + /// recebe os DO DONO — nunca os do ACS anterior (RNF06). + final VisitScopeResolver visitScopeFor; + final MicroAreaDirectory? directory; final PrioritizedAlert? initialAlert; final LatLng? initialPosition; + final ThemeController themeController; final Duration? syncInterval; @override @@ -140,11 +654,158 @@ class _LoginScreenState extends State { // '123456' embutidos, e o botão navegava sem olhar para nenhum dos dois. final _matricula = TextEditingController(); final _senha = TextEditingController(); + final _totp = TextEditingController(); + bool _pedeCodigo = false; bool _busy = false; String? _error; + String? _aviso; + + /// `true` enquanto a retomada da sessão salva está em andamento (digital e + /// renovação): o botão de login fica desligado para não abrir dois painéis. + bool _resuming = false; + + /// A retomada falhou por falta de rede e o token segue guardado. + bool _resumeOffline = false; + + /// O desbloqueio local não concluiu (cancelado, bloqueado ou indisponível) e + /// o token segue guardado: sem rede o login por senha falharia, então oferece + /// "Tentar de novo" em vez de deixar o ACS sem saída. + bool _resumeIncomplete = false; + bool _resumeRequested = false; + + /// `true` depois que achou token e vai pedir o desbloqueio: só então o + /// "Retomando a sessão…" aparece. + bool _resumePrompting = false; + + /// Ativação da MFA empilhada por esta tela (mostra o segredo TOTP). + Route? _enrollmentRoute; + ValueNotifier? _passwordRequests; + + @override + void initState() { + super.initState(); + _aviso = widget.aviso; + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final requests = _AppLockScope.maybeOf(context)?.passwordRequests; + if (requests != _passwordRequests) { + _passwordRequests?.removeListener(_discardTyped); + _passwordRequests = requests?..addListener(_discardTyped); + } + // BackendScope.of só é seguro a partir daqui, não em initState. + if (!_resumeRequested && widget.reauthUserId == null && widget.biometricGate != null) { + _resumeRequested = true; + _tryResume(); + } + } + + /// "Entrar com senha" no bloqueio: o que foi digitado antes dele não fica + /// para quem desbloquear, e a ativação da MFA (segredo à vista) sai da + /// pilha. Roda sob a cobertura; `removeRoute` não é um `pop` pelo voltar. + void _discardTyped() { + if (!mounted) return; + _senha.clear(); + _totp.clear(); + final enrollment = _enrollmentRoute; + _enrollmentRoute = null; + if (enrollment != null && enrollment.navigator != null) { + enrollment.navigator!.removeRoute(enrollment); + } + setState(() { _pedeCodigo = false; _busy = false; _error = null; }); + } + + void _retryResume() { + _tryResume(); + setState(() {}); + } + + /// Partida a frio com refresh token salvo: só retoma depois do desbloqueio + /// local. Qualquer outro desfecho deixa o formulário completo, sem dado + /// algum da sessão guardada na tela. + Future _tryResume() async { + // Síncrono, antes de qualquer `await`: dois toques em "Tentar de novo" + // não abrem duas retomadas (dois prompts, duas renovações, dois painéis). + if (_resuming) return; + _resuming = true; + try { + // Dentro do `try`: se qualquer um lançar, o `finally` ainda solta + // `_resuming` e o botão de login não fica desligado para sempre. + final backend = BackendScope.of(context); + final gate = widget.biometricGate!; + // Sem token não há prompt biométrico (nem chamada à plataforma). + if (!await backend.hasStoredSession) return; + // P5: aparelho sem biometria nem bloqueio de tela não retoma sessão — + // quem pega o aparelho entraria direto no painel. + if (!await gate.isAvailable || !mounted) return; + setState(() { _resumePrompting = true; _resumeOffline = false; _resumeIncomplete = false; _error = null; }); + var unlocked = false; + final stored = await backend.unlockStoredSession(reason: 'Entrar no SinalACS'); + switch (stored) { + case SessionUnlock.unlocked: + unlocked = true; + case SessionUnlock.notRequired: + UnlockResult ui; + try { + ui = await gate.authenticate(reason: 'Entrar no SinalACS'); + } catch (_) { + ui = UnlockResult.unavailable; // falha fechada + } + unlocked = ui == UnlockResult.unlocked; + case SessionUnlock.cancelled || SessionUnlock.lockedOut || SessionUnlock.unavailable: + unlocked = false; + } + if (!mounted) return; + if (!unlocked) { + // Chave invalidada (ex.: bloqueio de tela removido): o cofre apagou o token. Só então + // vale o aviso; cancelar ou bloqueio mantêm o token e ficam quietos. + if (stored == SessionUnlock.unavailable && !await backend.hasStoredSession && mounted) { + setState(() => _aviso = 'Sua sessão expirou. Entre novamente.'); + } else if (stored != SessionUnlock.notRequired && await backend.hasStoredSession && mounted) { + setState(() => _resumeIncomplete = true); + } + return; + } + final session = await backend.resumeSession(); + if (!mounted) return; + if (session != null) { + if (session.microAreaId == null) { + setState(() => _error = 'Este acesso não está vinculado a uma microárea.'); + return; + } + _openShell(session); + return; + } + // `null` serve para recusa E para falta de rede: só a recusa apaga o token. + final kept = await backend.hasStoredSession; + if (!mounted) return; + setState(() { + if (kept) { + _resumeOffline = true; + } else { + _aviso = 'Sua sessão expirou. Entre novamente.'; + } + }); + } finally { + _resuming = false; + if (mounted) setState(() => _resumePrompting = false); + } + } @override - void dispose() { _matricula.dispose(); _senha.dispose(); super.dispose(); } + void dispose() { + _passwordRequests?.removeListener(_discardTyped); + _matricula.dispose(); _senha.dispose(); _totp.dispose(); super.dispose(); + } + + /// O código pertence à sessão que o pediu: mudar matrícula ou senha o descarta. + void _credencialMudou(String _) { + if (!_pedeCodigo) return; + setState(() => _pedeCodigo = false); + _totp.clear(); + } /// Autentica contra `auth.loginInstitutional` (RF07) e só então abre o /// painel. @@ -161,12 +822,13 @@ class _LoginScreenState extends State { return; } - setState(() { _busy = true; _error = null; }); + setState(() { _busy = true; _error = null; _aviso = null; }); try { final session = await BackendScope.of(context).login( matricula: matricula, senha: senha, + totpCode: _pedeCodigo ? _totp.text.trim() : null, ); final microAreaId = session.microAreaId; if (microAreaId == null) { @@ -180,27 +842,68 @@ class _LoginScreenState extends State { } if (!mounted) return; - Navigator.of(context).pushReplacement(MaterialPageRoute( - builder: (_) => AcsHomeShell( - microAreaId: microAreaId, - acsId: session.userId, - feedBuilder: widget.feedBuilder, - visitQueue: widget.visitQueue, - visitPullService: widget.visitPullService, - initialAlert: widget.initialAlert, - initialPosition: widget.initialPosition, - syncInterval: widget.syncInterval, + if (widget.reauthUserId != null && session.userId == widget.reauthUserId) { + Navigator.of(context).pop(true); + return; + } + _openShell(session); + } on MfaCodeRequired { + if (!mounted) return; + setState(() { _pedeCodigo = true; _error = null; _busy = false; }); + } on MfaEnrollmentRequired { + if (!mounted) return; + setState(() => _busy = false); + final route = MaterialPageRoute( + builder: (_) => MfaEnrollmentScreen( + backend: BackendScope.of(context), + matricula: matricula, + senha: senha, ), - )); + ); + _enrollmentRoute = route; + final ativou = await Navigator.of(context).push(route); + if (_enrollmentRoute == route) _enrollmentRoute = null; + if (ativou == true && mounted) { + setState(() { _pedeCodigo = true; _aviso = 'Aguarde o próximo código do autenticador.'; }); + } } on BackendFailure catch (failure) { if (!mounted) return; setState(() { _busy = false; _error = failure.message; }); } } + /// Abre o painel com a sessão emitida (login por senha ou retomada). + void _openShell(AuthSession session) { + final scope = widget.visitScopeFor(session); + final shellRoute = MaterialPageRoute( + builder: (_) => AcsHomeShell( + microAreaId: session.microAreaId!, + acsId: session.userId, + feedBuilder: widget.feedBuilder, + visitQueue: scope.queue, + visitPullService: scope.pullService, + visitScopeFor: widget.visitScopeFor, + directory: widget.directory, + initialAlert: widget.initialAlert, + initialPosition: widget.initialPosition, + themeController: widget.themeController, + syncInterval: widget.syncInterval, + ), + ); + if (widget.reauthUserId != null) { + // Outro usuário: o painel de baixo é do território anterior. + Navigator.of(context).pushAndRemoveUntil(shellRoute, (_) => false); + } else { + Navigator.of(context).pushReplacement(shellRoute); + } + } + @override - Widget build(BuildContext context) => Scaffold( - appBar: const _Header('Segurança e rastreabilidade', 'Acesso institucional'), + Widget build(BuildContext context) => PopScope( + // Reautenticação não é descartável: sem sessão o painel não renova nada. + canPop: widget.reauthUserId == null, + child: Scaffold( + appBar: _Header('Segurança e rastreabilidade', 'Acesso institucional', height: acsHeaderHeight(context)), body: Center(child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 600), child: ListView(padding: const EdgeInsets.all(24), children: [ @@ -214,6 +917,7 @@ class _LoginScreenState extends State { TextField( key: const Key('matricula_field'), controller: _matricula, + onChanged: _credencialMudou, autofillHints: const [AutofillHints.username], decoration: const InputDecoration(labelText: 'Matrícula / CNS'), ), @@ -221,10 +925,28 @@ class _LoginScreenState extends State { TextField( key: const Key('senha_field'), controller: _senha, + onChanged: _credencialMudou, obscureText: true, autofillHints: const [AutofillHints.password], decoration: const InputDecoration(labelText: 'Senha de acesso'), ), + if (_aviso != null) Padding( + padding: const EdgeInsets.only(top: 16), + child: Semantics(liveRegion: true, child: Text(_aviso!, key: const Key('login_aviso'), textAlign: TextAlign.center)), + ), + if (_pedeCodigo) ...[ + const SizedBox(height: 16), + TextField( + key: const Key('totp_field'), + controller: _totp, + keyboardType: TextInputType.number, + maxLength: 6, + autofillHints: const [AutofillHints.oneTimeCode], + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: const InputDecoration(labelText: 'Código do autenticador (6 dígitos)'), + onSubmitted: (_) => _enter(), + ), + ], const SizedBox(height: 20), // Sem `Semantics` em volta, pelo mesmo motivo do login do paciente: // o `Text` do botão já é o nome acessível ("Entrar com credenciais") @@ -234,25 +956,52 @@ class _LoginScreenState extends State { SizedBox(width: double.infinity, child: FilledButton( key: const Key('login_button'), style: FilledButton.styleFrom(minimumSize: const Size(48, 52)), - onPressed: _busy ? null : _enter, + onPressed: _busy || _resuming ? null : _enter, child: _busy ? const SizedBox(height: 22, width: 22, child: CircularProgressIndicator(strokeWidth: 2)) : const Text('Entrar com credenciais'), )), + if (_resumePrompting) Padding( + padding: const EdgeInsets.only(top: 16), + child: Semantics(liveRegion: true, child: const Text('Retomando a sessão…', key: Key('resume_progress'), textAlign: TextAlign.center)), + ), + if ((_resumeOffline || _resumeIncomplete) && !_resuming) ...[ + Padding( + padding: const EdgeInsets.only(top: 16), + child: Semantics(liveRegion: true, child: _resumeOffline + ? const Text( + 'Sem conexão para retomar a sessão. Tente de novo ou entre com senha.', + key: Key('resume_offline'), + textAlign: TextAlign.center, + ) + : const Text( + 'Desbloqueio não concluído. Tente de novo ou entre com senha.', + key: Key('resume_incomplete'), + textAlign: TextAlign.center, + )), + ), + const SizedBox(height: 8), + SizedBox(width: double.infinity, child: OutlinedButton( + key: const Key('resume_retry'), + style: OutlinedButton.styleFrom(minimumSize: const Size(48, 52)), + onPressed: _resuming ? null : _retryResume, + child: const Text('Tentar de novo'), + )), + ], if (_error != null) Padding( padding: const EdgeInsets.only(top: 16), child: Semantics( liveRegion: true, - child: Text(key: const Key('login_error'), _error!, textAlign: TextAlign.center, style: const TextStyle(color: AcsColors.redOnSurface, fontWeight: FontWeight.bold)), + child: Text(key: const Key('login_error'), _error!, textAlign: TextAlign.center, style: TextStyle(color: context.acsRisk.redOnSurface, fontWeight: FontWeight.bold)), ), ), ]))), ]), )), - ); + )); } -enum AcsDestination { area, queue, map, visit, escalation, geofencing, notices } +enum AcsDestination { area, queue, map, visit, escalation, geofencing, notices, invite, settings } /// Aviso de infraestrutura: o que quebrou e a consequência prática. /// @@ -266,8 +1015,11 @@ class AcsHomeShell extends StatefulWidget { required this.microAreaId, required this.acsId, required this.visitQueue, + required this.themeController, required this.visitPullService, + required this.visitScopeFor, super.key, + this.directory, this.feedBuilder, this.initialAlert, this.initialPosition, @@ -279,6 +1031,7 @@ class AcsHomeShell extends StatefulWidget { final AlertFeed Function(AlertQueue queue)? feedBuilder; final PrioritizedAlert? initialAlert; final LatLng? initialPosition; + final ThemeController themeController; /// Intervalo entre sincronizações automáticas com a central (visitas + /// pacientes da microárea), além do disparo ao abrir o painel e do botão @@ -300,6 +1053,13 @@ class AcsHomeShell extends StatefulWidget { /// já foi o defeito de outra fila neste mesmo arquivo. final VisitPullService visitPullService; + /// Repassado às telas de login que este painel empilha (reautenticação, + /// "Sair"): quem entrar por elas recebe a fila e o pull DELE. + final VisitScopeResolver visitScopeFor; + + /// Lista da microárea com cache no aparelho (RF08); `null` lê direto do backend. + final MicroAreaDirectory? directory; + @override State createState() => _AcsHomeShellState(); } @@ -363,6 +1123,9 @@ class _AcsHomeShellState extends State with WidgetsBindingObserver /// Quando a última carga bem-sucedida terminou. DateTime? _microAreaPatientsLoadedAt; + /// `true` quando a lista veio do cache do aparelho (RF08), não da central. + bool _microAreaFromCache = false; + /// Presente quando a última tentativa falhou. InfraNotice? _microAreaPatientsError; @@ -393,6 +1156,7 @@ class _AcsHomeShellState extends State with WidgetsBindingObserver /// docs/superpowers/plans/2026-09-18-rf15-consumo-acs-pull-visitas.md). void _startPeriodicSync() { _periodicSyncTimer?.cancel(); + if (_areaFrozen) return; _periodicSyncTimer = Timer.periodic(_syncInterval, (_) => _refreshAreaData()); } @@ -403,20 +1167,142 @@ class _AcsHomeShellState extends State with WidgetsBindingObserver // mesmo motivo de VisitRegistrationScreen._patientsRequested. if (!_patientsRequested) { _patientsRequested = true; + _backend = BackendScope.of(context); + _backend!.onSessionExpired = _sessionExpired; + _lockHooks = _AppLockScope.maybeOf(context)?..openPasswordReauth = _reauthForLock; _loadMicroAreaPatients(); } } - Future _loadMicroAreaPatients() async { + _AppLockHooks? _lockHooks; + + AcsBackend? _backend; + + /// Só uma tela de reautenticação por vez (duas expirações seguidas não empilham). + bool _reauthAberta = false; + + /// A sessão venceu e não renova sozinha (MFA, sem refresh token). NÃO derruba o + /// painel: a reautenticação é empilhada por cima, e a fila de alertas, o feed + /// MQTT (autentica pelo broker, não pelo JWT), a fila de visitas e qualquer + /// formulário em andamento seguem vivos por baixo. Um alerta já recebido por + /// MQTT não é reentregue pelo broker; descartá-lo seria perdê-lo em silêncio. + Future _sessionExpired() async { + if (!mounted || _reauthAberta) return; + await _openReauth('Sua sessão expirou. Entre novamente com o código do autenticador.'); + } + + /// "Entrar com senha" na cobertura do bloqueio: o mesmo login empilhado da + /// sessão vencida. Se ele já está aberto, basta o app tirar a cobertura — + /// o que fica à vista é o formulário, não o painel. + /// + /// A rota entra SEM transição e opaca, e [onCovered] só é chamado no quadro + /// seguinte ao que a pintou: a cobertura do bloqueio só sai quando o + /// formulário já esconde o painel (nenhum quadro de transição com o painel + /// à vista, tocável ou na semântica). + void _reauthForLock(VoidCallback onCovered, VoidCallback onFailed) { + if (!mounted || _reauthAberta) { + onCovered(); + return; + } + _openReauth('Entre com matrícula e senha para desbloquear o aplicativo.', onCovered: onCovered, onFailed: onFailed); + } + + Future _openReauth(String aviso, {VoidCallback? onCovered, VoidCallback? onFailed}) async { + _reauthAberta = true; + // O aviso de alertas aguardando fica FORA do `LoginScreen`: a fila segue + // viva por baixo da rota opaca e um alerta vermelho não pode esperar o novo + // login para ser notado. + Widget page(BuildContext _) => Column( + children: [ + SafeArea( + bottom: false, + child: PendingAlertsBanner(listenable: _queue, count: () => _queue.unacknowledgedCount), + ), + Expanded( + child: LoginScreen( + visitScopeFor: widget.visitScopeFor, + themeController: widget.themeController, + directory: widget.directory, + feedBuilder: widget.feedBuilder, + initialAlert: widget.initialAlert, + initialPosition: widget.initialPosition, + syncInterval: widget.syncInterval, + aviso: aviso, + reauthUserId: widget.acsId, + ), + ), + ], + ); + final Route route = onCovered == null + ? MaterialPageRoute(builder: page) + : PageRouteBuilder( + opaque: true, + transitionDuration: Duration.zero, + reverseTransitionDuration: Duration.zero, + pageBuilder: (context, _, __) => page(context), + ); + try { + final Future done; + try { + done = Navigator.of(context).push(route); + } catch (_) { + // Sem formulário por cima: quem pediu (o bloqueio) não pode tirar a + // cobertura, mas também não pode ficar esperando para sempre. + onFailed?.call(); + if (onFailed == null) rethrow; + return; + } + if (onCovered != null) _whenOpaque(route as ModalRoute, onCovered); + await done; + } finally { + _reauthAberta = false; + } + } + + /// Chama [then] no quadro depois de a [route] terminar de entrar (opaca e + /// pintada). + void _whenOpaque(ModalRoute route, VoidCallback then) { + var called = false; + void fire() { + if (called) return; + called = true; + WidgetsBinding.instance.addPostFrameCallback((_) => then()); + WidgetsBinding.instance.scheduleFrame(); + } + + final animation = route.animation; + if (animation == null || animation.status == AnimationStatus.completed) { + fire(); + return; + } + void listener(AnimationStatus status) { + if (status == AnimationStatus.completed) { + animation.removeStatusListener(listener); + fire(); + } else if (status == AnimationStatus.dismissed) { + animation.removeStatusListener(listener); + then(); // rota saiu antes de entrar: não deixa o app preso + } + } + animation.addStatusListener(listener); + } + + Future _loadMicroAreaPatients() => _trackArea(_loadMicroAreaPatientsNow); + + Future _loadMicroAreaPatientsNow() async { final backend = BackendScope.of(context); - setState(() { _loadingMicroAreaPatients = true; _microAreaPatientsError = null; }); + setState(() { _loadingMicroAreaPatients = true; _microAreaPatientsError = null; _microAreaFromCache = false; }); try { - final patients = await backend.listPatients(); + final snapshot = widget.directory == null + ? null + : await widget.directory!.load(); + final patients = snapshot?.patients ?? await backend.listPatients(); if (!mounted) return; setState(() { _microAreaPatients = patients; - _microAreaPatientsLoadedAt = DateTime.now(); + _microAreaPatientsLoadedAt = snapshot?.fetchedAt ?? DateTime.now(); + _microAreaFromCache = snapshot?.fromCache ?? false; }); } on BackendFailure catch (failure) { if (!mounted) return; @@ -454,6 +1340,44 @@ class _AcsHomeShellState extends State with WidgetsBindingObserver _loadMicroAreaPatients(); } + /// `true` a partir da confirmação do "Limpar este aparelho": nenhuma + /// atualização da área (pull de visitas, lista da microárea) começa mais + /// neste painel, para nada do território ser regravado no aparelho depois + /// de "dados apagados". Volta a `false` só se a limpeza não acontecer. + bool _areaFrozen = false; + + /// Atualizações da área em andamento (gravam o cursor do pull e o cache + /// da microárea). + final Set> _areaInFlight = >{}; + + Future _trackArea(Future Function() op) { + if (_areaFrozen) return Future.value(); + final running = op(); + _areaInFlight.add(running); + unawaited(running.catchError((Object _) {}).whenComplete(() => _areaInFlight.remove(running))); + return running; + } + + /// Para o ciclo periódico, impede novas atualizações e espera as que estão + /// em voo (com teto). `false`: alguma não terminou a tempo — limpar agora + /// deixaria ela regravar dados depois. + Future _freezeArea() async { + _areaFrozen = true; + _periodicSyncTimer?.cancel(); + _periodicSyncTimer = null; + try { + await Future.wait(_areaInFlight.toList()).timeout(sessionEndSendTimeout); + return true; + } catch (_) { + return false; + } + } + + void _unfreezeArea() { + _areaFrozen = false; + _startPeriodicSync(); + } + /// Recarrega as visitas gravadas em execuções anteriores. /// /// Sem esta chamada, persistir não serviria para nada — `restore()` existia e @@ -484,7 +1408,9 @@ class _AcsHomeShellState extends State with WidgetsBindingObserver /// Disparado automaticamente ao abrir o painel, mais um botão manual na /// própria tela — não um timer de sincronização em segundo plano, decisão /// de produto separada e fora do escopo desta task. - Future _pullVisits() async { + Future _pullVisits() => _trackArea(_pullVisitsNow); + + Future _pullVisitsNow() async { setState(() { _pullingVisits = true; _pullError = null; }); try { @@ -682,24 +1608,335 @@ class _AcsHomeShellState extends State with WidgetsBindingObserver } Future _acknowledge(PrioritizedAlert alert) async { + // O messenger vem do MaterialApp: sobrevive à troca de rota e ao painel. + final messenger = ScaffoldMessenger.maybeOf(context); + void aviso(String texto) => messenger + ?..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(texto))); try { final result = await BackendScope.of(context).acknowledge(alertId: alert.alertId); - if (!mounted) return; - if (result.acknowledged) _queue.markAcknowledged(alert.alertId); - _message(context, result.acknowledged + if (result.acknowledged && mounted) _queue.markAcknowledged(alert.alertId); + aviso(result.acknowledged ? 'Recebimento confirmado à central.' : 'A central não reconheceu este alerta.'); } on BackendFailure catch (failure) { + // Nunca engolir: o alerta segue na fila, sem confirmação, e a pessoa vê por quê. + aviso(failure.message); + } + } + + bool _saindo = false; + bool _limpando = false; + + /// "Sair" está na etapa de envio (até 2 × [sessionEndSendTimeout]): a tela + /// explica a espera debaixo do indicador. + bool _enviandoAntesDeSair = false; + + _DeviceCare? get _care => _DeviceCareScope.maybeOf(context); + + /// Antes de sair ou limpar: (1) a fila do painel (o envio diferido pula o + /// dono da sessão atual de propósito, para não haver dois `sync` sobre a + /// mesma fila); (2) o envio diferido do legado e dos outros donos. Cada + /// etapa tem teto ([sessionEndSendTimeout]) e nenhuma falha bloqueia quem + /// chamou. `settled` é `false` quando a fila do painel ainda pode estar + /// enviando (o teto venceu). + Future<({bool settled, FlushReport? report})> _sendBeforeLeaving() async { + var settled = true; + try { + await widget.visitQueue.sync().timeout(sessionEndSendTimeout); + } on TimeoutException { + settled = false; + } catch (_) { + // `sync` não lança; defesa. + } + final report = await _care?.flushOthers(); + return (settled: settled, report: report); + } + + static String _visitas(int n) => n == 1 ? '1 visita' : '$n visitas'; + + /// "Sair e encerrar o turno": envia antes o que puder ([_sendBeforeLeaving]) + /// e então revoga o refresh token (best-effort, sem rede também sai) e volta + /// ao login. O que não subiu NÃO é apagado (offline-first): continua no + /// aparelho e sobe sozinho quando o app estiver aberto com conexão, em até + /// 7 dias (validade do token de envio do + /// dono, D7). O feed MQTT e os timers param no `dispose` deste painel. + /// + /// Alerta não confirmado NÃO sobrevive à saída: a fila de alertas é deste + /// painel e o broker não reentrega o que já entregou. Por isso a confirmação + /// diz quantos são e o botão explicita a perda ("Sair mesmo assim") — a + /// saída não é bloqueada, mas também não descarta alerta em silêncio. O + /// mesmo vale para visitas que ficaram no aparelho. + Future _logout() async { + if (_saindo || _limpando) return; + setState(() { _saindo = true; _enviandoAntesDeSair = true; }); + final envio = await _sendBeforeLeaving(); + if (!mounted) return; + setState(() => _enviandoAntesDeSair = false); + final fila = widget.visitQueue; + final recusadas = fila.rejectedCount; + final proprias = fila.pendingCount + recusadas; + final outras = await _care?.pendingElsewhere(widget.acsId) ?? 0; + if (!mounted) return; + setState(() => _saindo = false); + final ficam = proprias + outras; + final pendentes = _queue.unacknowledgedCount; + final confirmou = await showDialog( + context: context, + builder: (dialog) => AlertDialog( + title: const Text('Encerrar o turno?'), + content: SingleChildScrollView(child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (pendentes > 0) ...[ + Text( + pendentes == 1 + ? 'Há 1 alerta ainda não confirmado. Ele sai deste aparelho, mas continua pendente no servidor.' + : 'Há $pendentes alertas ainda não confirmados. Eles saem deste aparelho, mas continuam pendentes no servidor.', + key: const Key('logout_pending_alerts'), + ), + const SizedBox(height: 12), + ], + if (ficam == 0) + const Text('Tudo foi enviado. Nenhuma visita fica pendente neste aparelho.', key: Key('logout_all_sent')) + else ...[ + Text( + ficam == 1 + ? '1 visita ainda não enviada continua neste aparelho. Ela segue protegida e sobe sozinha quando o aplicativo estiver aberto com conexão (em até 7 dias).' + : '$ficam visitas ainda não enviadas continuam neste aparelho. Elas seguem protegidas e sobem sozinhas quando o aplicativo estiver aberto com conexão (em até 7 dias).', + key: const Key('logout_pending_visits'), + ), + if (outras > 0 && proprias > 0) ...[ + const SizedBox(height: 8), + Text( + 'Desta conta: $proprias. De outras contas ou sem autor: $outras.', + key: const Key('logout_pending_breakdown'), + ), + ], + if (recusadas > 0) ...[ + const SizedBox(height: 8), + Text( + recusadas == 1 + ? '1 delas foi recusada pela central e aguarda revisão nesta conta.' + : '$recusadas delas foram recusadas pela central e aguardam revisão nesta conta.', + key: const Key('logout_rejected_visits'), + ), + ], + ], + ], + )), + actions: [ + TextButton(onPressed: () => Navigator.pop(dialog, false), child: const Text('Cancelar')), + FilledButton( + key: const Key('logout_confirm'), + onPressed: () => Navigator.pop(dialog, true), + child: Text(pendentes > 0 || ficam > 0 ? 'Sair mesmo assim' : 'Sair'), + ), + ], + ), + ); + if (confirmou != true || !mounted) return; + setState(() => _saindo = true); + final care = _care; + try { + await BackendScope.of(context).logout(); + } on BackendFailure { + // Falha do armazenamento seguro: sai do painel do mesmo jeito. A tela de + // login abaixo não retoma sessão sozinha (sem `biometricGate`). + } + // O próximo login deste ACS relê a fila do disco (o envio diferido pode + // tê-la mudado), exceto a fila que só existe em RAM. + care?.release(widget.acsId, settled: envio.settled); + care?.afterLogout(); + if (!mounted) return; + _voltarAoLogin('Turno encerrado neste aparelho.'); + } + + void _voltarAoLogin(String aviso) { + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute(builder: (_) => LoginScreen( + visitScopeFor: widget.visitScopeFor, + themeController: widget.themeController, + directory: widget.directory, + feedBuilder: widget.feedBuilder, + initialAlert: widget.initialAlert, + initialPosition: widget.initialPosition, + syncInterval: widget.syncInterval, + aviso: aviso, + )), + (_) => false, + ); + } + + /// "Limpar este aparelho" (D9): envia antes o que puder e só apaga quando + /// NADA ficou sem enviar — de nenhum dono, da quarentena nem da memória. + /// Se sobrou algo, bloqueia e diz quantas (só contagem). A recontagem final + /// é do próprio banco, na transação que apaga ([VisitStorage.wipeAllData]). + /// Não existe limpeza forçada por supervisor (não há login de supervisor). + Future _limparAparelho() async { + if (_saindo || _limpando) return; + setState(() => _limpando = true); + try { + final envio = await _sendBeforeLeaving(); + if (!mounted) return; + final care = _care; + final revisao = envio.report?.needsReview ?? 0; + final semToken = envio.report?.blockedOwners ?? const []; + final restam = care == null + ? null + : await care.unsentOnDevice( + widget.acsId, + widget.visitQueue, + ownersWithoutToken: semToken.toSet(), + legacyNeedsReview: revisao, + ); + if (!mounted) return; + if (care == null || restam == null || restam.own + restam.elsewhere > 0 || revisao > 0 || semToken.isNotEmpty) { + setState(() => _limpando = false); + await _wipeBloqueado(restam); + return; + } + setState(() => _limpando = false); + final alertas = _queue.unacknowledgedCount; + final confirmou = await showDialog( + context: context, + builder: (dialog) => AlertDialog( + title: const Text('Limpar este aparelho?'), + content: SingleChildScrollView(child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Todas as visitas deste aparelho já foram enviadas.'), + const SizedBox(height: 12), + const Text( + 'Isto apaga neste aparelho as visitas já enviadas, o cache da microárea, ' + 'o cursor de sincronização e as chaves de envio. Não pode ser desfeito.', + ), + if (alertas > 0) ...[ + const SizedBox(height: 12), + Text( + alertas == 1 + ? 'Há 1 alerta ainda não confirmado. Ele sai deste aparelho, mas continua pendente no servidor.' + : 'Há $alertas alertas ainda não confirmados. Eles saem deste aparelho, mas continuam pendentes no servidor.', + ), + ], + ], + )), + actions: [ + TextButton(onPressed: () => Navigator.pop(dialog, false), child: const Text('Cancelar')), + FilledButton( + key: const Key('wipe_confirm'), + onPressed: () => Navigator.pop(dialog, true), + child: const Text('Limpar'), + ), + ], + ), + ); + if (confirmou != true || !mounted) return; + setState(() => _limpando = true); + void naoLimpou() => ScaffoldMessenger.maybeOf(context) + ?..hideCurrentSnackBar() + ..showSnackBar(const SnackBar(content: Text('Não foi possível limpar o aparelho. Tente de novo.'))); + // Antes da transação: nenhuma atualização da área pode regravar cache + // ou cursor depois da limpeza (LGPD: "apagado" tem de ser verdade). + if (!await _freezeArea()) { + if (!mounted) return; + _unfreezeArea(); + naoLimpou(); + return; + } + if (!mounted) return; + try { + await care.wipe(widget.acsId, widget.visitQueue); + } on WipeBlocked catch (blocked) { + // Algo chegou entre a conferência e a transação: nada foi apagado. + if (!mounted) return; + _unfreezeArea(); + setState(() => _limpando = false); + await _wipeBloqueado( + (own: 0, elsewhere: blocked.unsent, unsent: blocked.unsent, review: 0, noToken: 0), + ); + return; + } catch (error, stackTrace) { + developer.log('falha ao limpar o aparelho', name: 'sinalacs.acs.wipe', error: error, stackTrace: stackTrace); + if (!mounted) return; + _unfreezeArea(); + naoLimpou(); + return; + } + if (!mounted) return; + try { + await BackendScope.of(context).logout(); + } on BackendFailure { + // Sem rede: a sessão local some do mesmo jeito. + } if (!mounted) return; - _message(context, failure.message); + _voltarAoLogin('Os dados deste aparelho foram apagados.'); + } finally { + if (mounted) setState(() => _limpando = false); } } + /// Bloqueio do wipe: só contagens, nenhum conteúdo clínico, e o motivo de + /// cada grupo — o que a pessoa pode fazer muda com ele. [restam] `null`: + /// não deu para conferir. + Future _wipeBloqueado(UnsentOnDevice? restam) { + final linhas = []; + if (restam == null) { + linhas.add('Não foi possível conferir as visitas guardadas neste aparelho. Tente de novo.'); + } else { + final total = restam.own + restam.elsewhere; + if (total > 0) { + linhas.add('Desta conta: ${_visitas(restam.own)}. De outras contas ou sem autor: ${_visitas(restam.elsewhere)}.'); + } + if (restam.unsent > 0) { + linhas.add('${restam.unsent == 1 ? '1 visita ainda não foi enviada' : '${restam.unsent} visitas ainda não foram enviadas'}: ' + 'conecte e tente de novo; se não puderem ser enviadas, procure a coordenação.'); + } + if (restam.review > 0) { + linhas.add('${restam.review == 1 ? '1 visita precisa' : '${restam.review} visitas precisam'} ' + 'de revisão pelo ACS dono (reenvie ou descarte-as ao entrar com a conta dele).'); + } + if (restam.noToken > 0) { + linhas.add('${restam.noToken == 1 ? '1 visita é' : '${restam.noToken} visitas são'} de outra conta sem chave de ' + 'envio neste aparelho: entre com a conta do agente para enviar.'); + } + if (restam.unsent + restam.review + restam.noToken == 0) { + linhas.add('Ainda há dados que não puderam ser conferidos. Tente de novo.'); + } + } + return showDialog( + context: context, + builder: (dialog) => AlertDialog( + key: const Key('wipe_blocked'), + title: const Text('Ainda não dá para limpar'), + content: SingleChildScrollView(child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final linha in linhas) ...[Text(linha), const SizedBox(height: 8)], + const Text('Nada foi apagado.'), + ], + )), + actions: [ + FilledButton(onPressed: () => Navigator.pop(dialog), child: const Text('Entendi')), + ], + ), + ); + } + @override void dispose() { + if (_backend?.onSessionExpired == _sessionExpired) _backend!.onSessionExpired = null; + if (_lockHooks?.openPasswordReauth == _reauthForLock) _lockHooks!.openPasswordReauth = null; _reconnectTimer?.cancel(); _periodicSyncTimer?.cancel(); WidgetsBinding.instance.removeObserver(this); + // Solta o callback ANTES de parar: `stop()` desconecta o MQTT, que avisa + // `onConnectionChanged(false)`, e `mounted` ainda é `true` durante o + // `dispose` — o `setState` caía num elemento já defunct (assertion). + _feed.onConnectionChanged = null; _feed.stop(); _queue.dispose(); super.dispose(); @@ -707,7 +1944,7 @@ class _AcsHomeShellState extends State with WidgetsBindingObserver @override Widget build(BuildContext context) => Scaffold( - appBar: _Header('ACS • ${_brokerConnected ? 'em linha' : 'sem conexão'}', 'Painel operacional', connected: _brokerConnected), + appBar: _Header('ACS • ${_brokerConnected ? 'em linha' : 'sem conexão'}', 'Painel operacional', height: acsHeaderHeight(context), connected: _brokerConnected), body: SafeArea(child: switch (destination) { AcsDestination.area => TerritorializationScreen( pulling: _pullingVisits, @@ -718,6 +1955,7 @@ class _AcsHomeShellState extends State with WidgetsBindingObserver loadingPatients: _loadingMicroAreaPatients, patientCount: _microAreaPatients?.length, patientsLoadedAt: _microAreaPatientsLoadedAt, + patientsFromCache: _microAreaFromCache, patientsError: _microAreaPatientsError, ), AcsDestination.queue => DashboardScreen( @@ -739,6 +1977,7 @@ class _AcsHomeShellState extends State with WidgetsBindingObserver }), ), AcsDestination.visit => VisitRegistrationScreen( + directory: widget.directory, alert: _selected, queue: widget.visitQueue, currentPosition: _currentPosition, @@ -759,6 +1998,15 @@ class _AcsHomeShellState extends State with WidgetsBindingObserver }), ), AcsDestination.notices => const NoticesScreen(), + AcsDestination.invite => const InviteScreen(), + AcsDestination.settings => ThemeSettingsScreen( + controller: widget.themeController, + onLogout: _saindo || _limpando ? null : _logout, + loggingOut: _saindo, + logoutStatus: _enviandoAntesDeSair ? 'Enviando as visitas pendentes antes de sair…' : null, + onWipe: _saindo || _limpando ? null : _limparAparelho, + wiping: _limpando, + ), }), bottomNavigationBar: NavigationBar( selectedIndex: destination.index <= 3 ? destination.index : 4, @@ -766,11 +2014,13 @@ class _AcsHomeShellState extends State with WidgetsBindingObserver destinations: const [NavigationDestination(icon: Icon(Icons.storage_outlined), label: 'Área'), NavigationDestination(icon: Icon(Icons.grid_view_outlined), label: 'Fila'), NavigationDestination(icon: Icon(Icons.map_outlined), label: 'Mapa'), NavigationDestination(icon: Icon(Icons.assignment_outlined), label: 'Visita'), NavigationDestination(icon: Icon(Icons.more_horiz), label: 'Mais')], ), ); - void _more(BuildContext context) => showModalBottomSheet(context: context, builder: (sheet) => SafeArea(child: Column(mainAxisSize: MainAxisSize.min, children: [ + void _more(BuildContext context) => showModalBottomSheet(context: context, builder: (sheet) => SafeArea(child: SingleChildScrollView(child: Column(mainAxisSize: MainAxisSize.min, children: [ _moreItem(sheet, Icons.call_outlined, 'Acionamento', AcsDestination.escalation), _moreItem(sheet, Icons.location_searching, 'Geofencing', AcsDestination.geofencing), _moreItem(sheet, Icons.campaign_outlined, 'Avisos à comunidade', AcsDestination.notices), - ]))); + _moreItem(sheet, Icons.qr_code_2, 'Convidar paciente', AcsDestination.invite), + _moreItem(sheet, Icons.tune_outlined, 'Preferências', AcsDestination.settings), + ])))); Widget _moreItem(BuildContext sheet, IconData icon, String label, AcsDestination value) => ListTile(leading: Icon(icon), title: Text(label), onTap: () { Navigator.pop(sheet); setState(() => destination = value); }); } @@ -789,6 +2039,7 @@ class TerritorializationScreen extends StatelessWidget { this.loadingPatients = false, this.patientCount, this.patientsLoadedAt, + this.patientsFromCache = false, this.patientsError, }); @@ -818,6 +2069,9 @@ class TerritorializationScreen extends StatelessWidget { /// Quando a última carga bem-sucedida terminou. final DateTime? patientsLoadedAt; + /// `true` quando a contagem veio do cache do aparelho (RF08). + final bool patientsFromCache; + /// Presente quando a última tentativa falhou. final InfraNotice? patientsError; @@ -827,6 +2081,8 @@ class TerritorializationScreen extends StatelessWidget { const SizedBox(height: 12), _InfoRow('Pacientes sincronizados', _patientCountText()), _InfoRow('Cache local', _cacheFreshnessText()), + if (patientsFromCache && patientsLoadedAt != null) + _cacheNotice(context, const Key('micro_area_cache_notice_area'), patientsLoadedAt!), // A linha acima diz que falhou; este banner diz por quê. Sem ele o // `detail` de `patientsError` (a mensagem do servidor) nunca chegava à // tela — o `InfraNotice` inteiro era reduzido a "Não foi possível @@ -865,7 +2121,7 @@ class TerritorializationScreen extends StatelessWidget { const SizedBox(height: 22, width: 22, child: CircularProgressIndicator(strokeWidth: 2)), const SizedBox(width: 8), ], - const Text('Atualizar dados da microárea'), + const Flexible(child: Text('Atualizar dados da microárea', textAlign: TextAlign.center)), ]), ), ), @@ -1011,7 +2267,7 @@ class _InfraBanner extends StatelessWidget { child: Card( color: AcsColors.accent.withValues(alpha: 0.15), child: ListTile( - leading: Icon(icon, color: AcsColors.accentOnSurface), + leading: Icon(icon, color: context.acsRisk.accentOnSurface), title: Text(notice.title, style: const TextStyle(fontWeight: FontWeight.bold)), subtitle: Text(notice.detail), trailing: onRetry == null ? null : TextButton( @@ -1038,18 +2294,21 @@ class _AlertCard extends StatelessWidget { /// /// `color` é o preenchimento (borda esquerda, fundo do botão de /// confirmação); `textColor` é a variante ajustada para ≥4.5:1 como texto - /// sobre o card (`AcsColors.surfaceRaised`) — `color` sozinho falha nisso - /// para vermelho (3.04:1) e para o fallback azul (2.84:1). - (Color, Color, String) get _risk => switch (alert.riskLevel.toLowerCase()) { - 'red' || 'vermelho' => (AcsColors.red, AcsColors.redOnSurface, 'Risco: Vermelho'), - 'yellow' || 'amarelo' => (AcsColors.yellow, AcsColors.yellow, 'Risco: Amarelo'), - 'green' || 'verde' => (AcsColors.green, AcsColors.green, 'Risco: Verde'), - _ => (AcsColors.accent, AcsColors.accentOnSurface, 'Risco: não classificado'), - }; + /// sobre o card — a variante certa depende do tema ativo, por isso vem de + /// [AcsRiskColors] em vez de `AcsColors` direto (ver `acs_theme.dart`). + (Color, Color, String) _risk(BuildContext context) { + final risk = context.acsRisk; + return switch (alert.riskLevel.toLowerCase()) { + 'red' || 'vermelho' => (AcsColors.red, risk.redOnSurface, 'Risco: Vermelho'), + 'yellow' || 'amarelo' => (AcsColors.yellow, risk.yellowOnSurface, 'Risco: Amarelo'), + 'green' || 'verde' => (AcsColors.green, risk.greenOnSurface, 'Risco: Verde'), + _ => (AcsColors.accent, risk.accentOnSurface, 'Risco: não classificado'), + }; + } @override Widget build(BuildContext context) { - final (color, textColor, label) = _risk; + final (color, textColor, label) = _risk(context); final isRed = alert.riskLevel.toLowerCase() == 'red' || alert.riskLevel.toLowerCase() == 'vermelho'; return Card( @@ -1066,6 +2325,7 @@ class _AlertCard extends StatelessWidget { Semantics( container: true, label: 'Paciente ${alert.patientId.substring(0, 8)}, $label' + '${alert.escalated ? ', reforço da coordenação' : ''}' '${alert.acknowledged ? ', recebimento confirmado' : ''}, ' 'recebido às ${_time(alert.triggeredAt)}', excludeSemantics: true, @@ -1075,14 +2335,26 @@ class _AlertCard extends StatelessWidget { Text('Paciente ${alert.patientId.substring(0, 8)}', style: const TextStyle(fontWeight: FontWeight.bold)), const SizedBox(height: 6), Text(label, style: TextStyle(color: textColor, fontWeight: FontWeight.bold)), + // Rótulo + ícone, não só cor (WCAG 1.4.1); cor de texto pelo token + // *OnSurface, medido sobre o Card. + if (alert.escalated) Padding( + padding: const EdgeInsets.only(top: 6), + child: Row(children: [ + Icon(Icons.campaign, size: 16, color: textColor), + const SizedBox(width: 6), + Text('Reforço da coordenação', + key: Key('escalated_${alert.alertId}'), + style: TextStyle(color: textColor, fontWeight: FontWeight.bold)), + ]), + ), const SizedBox(height: 6), Text('Recebido às ${_time(alert.triggeredAt)} • local ${alert.locationHash}'), - if (alert.acknowledged) const Padding( - padding: EdgeInsets.only(top: 8), + if (alert.acknowledged) Padding( + padding: const EdgeInsets.only(top: 8), child: Row(children: [ - Icon(Icons.check_circle, size: 16, color: AcsColors.green), - SizedBox(width: 6), - Text('Recebimento confirmado', style: TextStyle(color: AcsColors.green)), + Icon(Icons.check_circle, size: 16, color: context.acsRisk.greenOnSurface), + const SizedBox(width: 6), + Text('Recebimento confirmado', style: TextStyle(color: context.acsRisk.greenOnSurface)), ]), ), ]), @@ -1238,9 +2510,9 @@ class _MapScreenState extends State { ) : Container( height: 260, - decoration: const BoxDecoration( - color: AcsColors.surface, - borderRadius: BorderRadius.all(Radius.circular(12)), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: const BorderRadius.all(Radius.circular(12)), ), child: const Center( child: Padding( @@ -1272,13 +2544,13 @@ class _MapScreenState extends State { RouteStatus.complete => 'Rota concluída • ${_routePlan!.distanceKm.toStringAsFixed(1)} km • ETA ${_routePlan!.etaMinutes} min', _ => 'Rota ativa • ${_routePlan!.distanceKm.toStringAsFixed(1)} km • ETA ${_routePlan!.etaMinutes} min', }, - style: const TextStyle(color: AcsColors.accentOnSurface, fontWeight: FontWeight.bold), + style: TextStyle(color: context.acsRisk.accentOnSurface, fontWeight: FontWeight.bold), ), if (_routePlan!.status == RouteStatus.complete && _selectedAlert != null) ...[ const SizedBox(height: 8), - const Text( + Text( 'Local alcançado. Você pode abrir o registro de visita agora.', - style: TextStyle(color: AcsColors.green, fontWeight: FontWeight.bold), + style: TextStyle(color: context.acsRisk.greenOnSurface, fontWeight: FontWeight.bold), ), ], ], @@ -1325,7 +2597,7 @@ class _MapScreenState extends State { children: [ Text('Paciente ${alert.patientId.substring(0, 8)}', style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18)), const SizedBox(height: 8), - Text('$riskLabel • ${_time(alert.triggeredAt)}', style: TextStyle(color: _riskColor(alert.riskLevel))), + Text('$riskLabel • ${_time(alert.triggeredAt)}', style: TextStyle(color: _riskColor(sheetContext, alert.riskLevel))), const SizedBox(height: 8), Text('Microárea: ${alert.microAreaId.substring(0, 8)}'), const SizedBox(height: 16), @@ -1384,12 +2656,15 @@ class _MapScreenState extends State { } } - Color _riskColor(String riskLevel) => switch (riskLevel.toLowerCase()) { - 'red' || 'vermelho' => AcsColors.red, - 'yellow' || 'amarelo' => AcsColors.yellow, - 'green' || 'verde' => AcsColors.green, - _ => AcsColors.accent, - }; + Color _riskColor(BuildContext context, String riskLevel) { + final risk = context.acsRisk; + return switch (riskLevel.toLowerCase()) { + 'red' || 'vermelho' => risk.redOnSurface, + 'yellow' || 'amarelo' => risk.yellowOnSurface, + 'green' || 'verde' => risk.greenOnSurface, + _ => risk.accentOnSurface, + }; + } /// Preenchimento do círculo de incerteza — deriva de [riskLevel], não do /// `BitmapDescriptor` do marcador (aquele é opaco, não dá para extrair cor @@ -1443,7 +2718,7 @@ class _AlertMapSummary extends StatelessWidget { ), child: Row( children: [ - Icon(Icons.location_on, color: acsOnSurface(color)), + Icon(Icons.location_on, color: acsOnSurface(context, color)), const SizedBox(width: 8), Expanded( child: Text( @@ -1475,14 +2750,35 @@ class _LegendChip extends StatelessWidget { children: [ Container(width: 10, height: 10, decoration: BoxDecoration(color: color, shape: BoxShape.circle)), const SizedBox(width: 6), - Text(label), + // `Flexible`: a 200% de fonte em 320 dp o rótulo não cabe numa linha. + Flexible(child: Text(label)), ], ), ); } +String _formatarDataHora(DateTime utc) { + final d = utc.toLocal(); + String dois(int n) => n.toString().padLeft(2, '0'); + return '${dois(d.day)}/${dois(d.month)} às ${dois(d.hour)}:${dois(d.minute)}'; +} + +/// Aviso de que a lista da microárea é a guardada no aparelho (RF08, §5.11 de +/// `spec/lgpd_design.md`): o ACS precisa saber que não é a lista da central. +Widget _cacheNotice(BuildContext context, Key key, DateTime fetchedAt) => Padding( + padding: const EdgeInsets.only(top: 8), + child: Semantics( + liveRegion: true, + child: Text( + key: key, + 'Sem conexão com a central: lista salva em ${_formatarDataHora(fetchedAt)}.', + style: TextStyle(color: context.acsRisk.accentOnSurface, fontWeight: FontWeight.bold), + ), + ), + ); + class VisitRegistrationScreen extends StatefulWidget { - const VisitRegistrationScreen({required this.queue, super.key, this.alert, this.currentPosition}); + const VisitRegistrationScreen({required this.queue, super.key, this.alert, this.currentPosition, this.directory}); /// A fila é recebida pronta, não construída aqui: uma instância por gravação /// descartava a visita assim que o callback retornava. @@ -1490,6 +2786,9 @@ class VisitRegistrationScreen extends StatefulWidget { final PrioritizedAlert? alert; final LatLng? currentPosition; + /// Lista da microárea com cache no aparelho (RF08); `null` lê direto do backend. + final MicroAreaDirectory? directory; + @override State createState() => _VisitRegistrationScreenState(); } @@ -1514,6 +2813,8 @@ class _VisitRegistrationScreenState extends State { List? _patients; BackendFailure? _patientsError; bool _loadingPatients = false; + bool _patientsFromCache = false; + DateTime? _patientsFetchedAt; bool _patientsRequested = false; @override @@ -1560,11 +2861,18 @@ class _VisitRegistrationScreenState extends State { /// funcionando mesmo sem rede (o app é offline-first), então o erro vira /// aviso com um jeito de tentar de novo, nunca uma tela presa. Future _loadPatients() async { - setState(() { _loadingPatients = true; _patientsError = null; }); + final backend = BackendScope.of(context); + setState(() { _loadingPatients = true; _patientsError = null; _patientsFromCache = false; }); try { - final result = await BackendScope.of(context).listPatients(); + final snapshot = widget.directory == null ? null : await widget.directory!.load(); + final result = snapshot?.patients ?? await backend.listPatients(); if (!mounted) return; - setState(() { _patients = result; _loadingPatients = false; }); + setState(() { + _patients = result; + _patientsFromCache = snapshot?.fromCache ?? false; + _patientsFetchedAt = snapshot?.fetchedAt; + _loadingPatients = false; + }); } on BackendFailure catch (failure) { if (!mounted) return; setState(() { _patientsError = failure; _loadingPatients = false; }); @@ -1672,7 +2980,7 @@ class _VisitRegistrationScreenState extends State { /// aba simplesmente dizia "selecione um alerta" e não havia outro caminho — /// e como o único produtor de alertas publica só risco vermelho (emergência, /// SAMU), a visita de rotina do PRD (≥ 8/dia) nunca tinha de onde partir. - List _buildPatientPicker() { + List _buildPatientPicker(BuildContext context) { if (_loadingPatients) { return const [ Padding( @@ -1692,7 +3000,7 @@ class _VisitRegistrationScreenState extends State { child: Text( key: const Key('patient_directory_error'), error.message, - style: const TextStyle(color: AcsColors.accentOnSurface, fontWeight: FontWeight.bold), + style: TextStyle(color: context.acsRisk.accentOnSurface, fontWeight: FontWeight.bold), ), ), ), @@ -1703,14 +3011,14 @@ class _VisitRegistrationScreenState extends State { final all = _patients ?? const []; if (all.isEmpty) { - return const [ + return [ Padding( - padding: EdgeInsets.only(top: 8), + padding: const EdgeInsets.only(top: 8), child: Text( - key: Key('visit_needs_alert'), + key: const Key('visit_needs_alert'), 'Nenhum paciente cadastrado na sua microárea ainda. Selecione um ' 'alerta na fila para registrar a visita.', - style: TextStyle(color: AcsColors.accentOnSurface), + style: TextStyle(color: context.acsRisk.accentOnSurface), ), ), ]; @@ -1722,6 +3030,8 @@ class _VisitRegistrationScreenState extends State { : all.where((p) => p.name.toLowerCase().contains(query)).toList(); return [ + if (_patientsFromCache && _patientsFetchedAt != null) + _cacheNotice(context, const Key('micro_area_cache_notice'), _patientsFetchedAt!), const SizedBox(height: 8), TextField( key: const Key('patient_search'), @@ -1765,7 +3075,7 @@ class _VisitRegistrationScreenState extends State { return _page([ Text(_patientLabel, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), - if (semAlerta && selected == null) ..._buildPatientPicker(), + if (semAlerta && selected == null) ..._buildPatientPicker(context), if (semAlerta && selected != null) Padding( padding: const EdgeInsets.only(top: 8), child: TextButton.icon( @@ -1781,11 +3091,11 @@ class _VisitRegistrationScreenState extends State { padding: const EdgeInsets.only(top: 8), child: Semantics( liveRegion: true, - child: const Text( - key: Key('visit_storage_error'), + child: Text( + key: const Key('visit_storage_error'), 'As visitas não estão sendo salvas neste aparelho — elas só existem ' 'na memória até sincronizar.', - style: TextStyle(color: AcsColors.accentOnSurface, fontWeight: FontWeight.bold), + style: TextStyle(color: context.acsRisk.accentOnSurface, fontWeight: FontWeight.bold), ), ), ), @@ -1798,7 +3108,7 @@ class _VisitRegistrationScreenState extends State { contentPadding: EdgeInsets.zero, ), const SizedBox(height: 8), - DropdownButtonFormField(initialValue: outcome, decoration: const InputDecoration(labelText: 'Status do atendimento'), items: const ['Realizada com sucesso', 'Paciente ausente', 'Recusou atendimento'].map((v) => DropdownMenuItem(value: v, child: Text(v))).toList(), onChanged: !hasPatient || !_arrivalConfirmed ? null : (v) => setState(() => outcome = v!)), + DropdownButtonFormField(isExpanded: true, initialValue: outcome, decoration: const InputDecoration(labelText: 'Status do atendimento'), items: const ['Realizada com sucesso', 'Paciente ausente', 'Recusou atendimento'].map((v) => DropdownMenuItem(value: v, child: Text(v))).toList(), onChanged: !hasPatient || !_arrivalConfirmed ? null : (v) => setState(() => outcome = v!)), const SizedBox(height: 16), TextField( controller: notes, @@ -1819,9 +3129,9 @@ class _VisitRegistrationScreenState extends State { ), if (hasPatient && _arrivalConfirmed) ...[ const SizedBox(height: 12), - const Text( + Text( 'Local alcançado. Você pode registrar a visita agora.', - style: TextStyle(color: AcsColors.green, fontWeight: FontWeight.bold), + style: TextStyle(color: context.acsRisk.greenOnSurface, fontWeight: FontWeight.bold), ), ], const Divider(height: 32), @@ -1836,7 +3146,7 @@ class _VisitRegistrationScreenState extends State { key: const Key('conflict_visits_count'), 'Em conflito: ${queue.conflictCount}', // Conflito de sincronização é operacional, não gravidade clínica. - style: const TextStyle(color: AcsColors.accentOnSurface, fontWeight: FontWeight.bold), + style: TextStyle(color: context.acsRisk.accentOnSurface, fontWeight: FontWeight.bold), ), ), if (queue.rejectedCount > 0) ...[ @@ -1850,7 +3160,7 @@ class _VisitRegistrationScreenState extends State { '${queue.rejectedVisits.map((v) => v.rejectionReason).whereType().toSet().join('; ')}', // Recusa de sync é operacional, não gravidade clínica — mesma cor // do contador de conflito. - style: const TextStyle(color: AcsColors.accentOnSurface, fontWeight: FontWeight.bold), + style: TextStyle(color: context.acsRisk.accentOnSurface, fontWeight: FontWeight.bold), ), ), ), @@ -1877,15 +3187,76 @@ class _VisitRegistrationScreenState extends State { } } -class EscalationScreen extends StatelessWidget { - const EscalationScreen({super.key, this.alert, this.onVisit}); +class EscalationScreen extends StatefulWidget { + const EscalationScreen({ + super.key, + this.alert, + this.onVisit, + this.dialer = const UrlLauncherEmergencyDialer(), + }); final PrioritizedAlert? alert; final void Function(PrioritizedAlert alert)? onVisit; + final EmergencyDialer dialer; + + @override + State createState() => _EscalationScreenState(); +} + +class _EscalationScreenState extends State { + /// Trava o toque duplo: dois `launchUrl` seguidos empilham dois discadores. + bool _dialing = false; + + /// Trava própria da consulta da UBS: nunca pode desabilitar o botão do SAMU. + bool _fetchingUbs = false; + + Future _callSamu() async { + if (_dialing) return; + setState(() => _dialing = true); + var opened = false; + try { + opened = await widget.dialer.dial(samuNumber); + } catch (_) { + opened = false; + } + if (!mounted) return; + setState(() => _dialing = false); + if (!opened) { + _message(context, 'Não foi possível abrir o discador. Ligue manualmente para $samuNumber.'); + } + } + + /// Liga para a UBS do ACS (RF13). O telefone vem do servidor; sem ele, avisa. + Future _callUbs() async { + if (_fetchingUbs) return; + setState(() => _fetchingUbs = true); + String? aviso; + try { + final contato = await BackendScope.of(context).ubsContact(); + final telefone = contato.phone?.trim() ?? ''; + // O discador recebe só dígitos e `+`: espaço e hífen viram %20 no `tel:`. + final numero = telefone.replaceAll(RegExp(r'[^0-9+]'), ''); + if (numero.isEmpty) { + aviso = '${contato.name} ainda não cadastrou um telefone. Acione a coordenação.'; + } else { + if (!await widget.dialer.dial(numero)) { + aviso = 'Não foi possível abrir o discador. Ligue manualmente para $telefone.'; + } + } + } on BackendFailure catch (falha) { + aviso = falha.message; + } catch (_) { + aviso = 'Não foi possível obter o contato da UBS.'; + } + if (!mounted) return; + setState(() => _fetchingUbs = false); + if (aviso != null) _message(context, aviso); + } @override Widget build(BuildContext context) { - final current = alert; + final current = widget.alert; + final onVisit = widget.onVisit; return _page([ const Text('Escalonamento rápido', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), const SizedBox(height: 16), @@ -1895,7 +3266,7 @@ class EscalationScreen extends StatelessWidget { _InfoRow('Local (hash)', current?.locationHash ?? '—'), const SizedBox(height: 20), FilledButton.icon( - onPressed: () => _message(context, 'Discagem não está integrada neste protótipo.'), + onPressed: _dialing ? null : _callSamu, // Alvo de toque de 60x60 (padrão de emergência do PRD): o default do // Material 3 para `FilledButton.icon` fica em 40dp de altura visual, // abaixo do exigido para uma ação de acionar o SAMU. @@ -1904,24 +3275,26 @@ class EscalationScreen extends StatelessWidget { label: const Text('Ligar para o SAMU (192)'), ), const SizedBox(height: 12), - OutlinedButton( - onPressed: () => _message(context, 'Encaminhamento será integrado à UBS.'), + OutlinedButton.icon( + key: const Key('escalation_ubs'), + onPressed: _fetchingUbs ? null : _callUbs, style: OutlinedButton.styleFrom(minimumSize: const Size(48, 52)), - child: const Text('Encaminhar para UBS Central'), + icon: const Icon(Icons.local_hospital_outlined), + label: const Text('Ligar para a UBS'), ), if (current != null) ...[ const SizedBox(height: 12), OutlinedButton.icon( key: const Key('escalation_visit'), - onPressed: onVisit == null ? null : () => onVisit!(current), + onPressed: onVisit == null ? null : () => onVisit(current), style: OutlinedButton.styleFrom(minimumSize: const Size(48, 52)), icon: const Icon(Icons.alt_route_outlined), label: const Text('Iniciar rota de visita'), ), const SizedBox(height: 8), - const Text( + Text( 'A visita é acompanhamento do caso e não substitui o acionamento do SAMU.', - style: TextStyle(color: AcsColors.accentOnSurface), + style: TextStyle(color: context.acsRisk.accentOnSurface), ), ], ]); @@ -1984,7 +3357,7 @@ class GeofencingScreen extends StatelessWidget { }, key: const Key('geofence_status'), style: TextStyle( - color: status == ArrivalStatus.arrived ? AcsColors.green : AcsColors.accentOnSurface, + color: status == ArrivalStatus.arrived ? context.acsRisk.greenOnSurface : context.acsRisk.accentOnSurface, fontWeight: FontWeight.bold, ), ), @@ -1999,8 +3372,190 @@ class GeofencingScreen extends StatelessWidget { }, ); } -class NoticesScreen extends StatefulWidget { const NoticesScreen({super.key}); @override State createState() => _NoticesScreenState(); } -class _NoticesScreenState extends State { final notice = TextEditingController(); @override void dispose() { notice.dispose(); super.dispose(); } @override Widget build(BuildContext context) => _page([const Text('Aviso comunitário', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), const SizedBox(height: 16), const TextField(decoration: InputDecoration(labelText: 'Público-alvo', hintText: 'Pacientes com condições crônicas')), const SizedBox(height: 16), TextField(controller: notice, maxLines: 4, decoration: const InputDecoration(labelText: 'Mensagem')), const SizedBox(height: 20), FilledButton(onPressed: () => _message(context, 'Envio depende da integração de notificações push.'), child: const Text('Preparar aviso'))]); } +/// Aviso comunitário do ACS aos pacientes da própria microárea (RF14). +/// +/// Só chega a quem aceitou receber avisos: o servidor consulta o consentimento +/// mais recente de cada titular antes de montar a lista. O texto nunca deve +/// citar paciente nem condição de saúde — o aviso vai para o aparelho de várias +/// pessoas e aparece na tela bloqueada. +class NoticesScreen extends StatefulWidget { + const NoticesScreen({super.key}); + + @override + State createState() => _NoticesScreenState(); +} + +class _NoticesScreenState extends State { + final _title = TextEditingController(); + final _messageController = TextEditingController(); + bool _chronicOnly = false; + bool _busy = false; + String? _error; + String? _result; + + @override + void dispose() { + _title.dispose(); + _messageController.dispose(); + super.dispose(); + } + + bool get _canSend => + !_busy && _title.text.trim().isNotEmpty && _messageController.text.trim().isNotEmpty; + + Future _send() async { + if (!_canSend) return; + setState(() { + _busy = true; + _error = null; + _result = null; + }); + try { + final r = await BackendScope.of(context).sendNotice( + title: _title.text.trim(), + message: _messageController.text.trim(), + chronicOnly: _chronicOnly, + ); + if (!mounted) return; + setState(() => _result = r.recipients == 0 + ? 'Nenhum paciente da sua microárea aceitou receber avisos ainda.' + : 'Aviso enviado a ${r.accepted} de ${r.recipients} pacientes.'); + } on BackendFailure catch (failure) { + if (!mounted) return; + setState(() => _error = failure.message); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) => _page([ + const Text('Aviso comunitário', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + const Text( + 'Só chega a quem aceitou receber avisos. Não escreva nome nem condição de saúde na mensagem.', + ), + const SizedBox(height: 16), + TextField( + key: const Key('notice_title_field'), + controller: _title, + maxLength: 60, + onChanged: (_) => setState(() {}), + decoration: const InputDecoration(labelText: 'Título'), + ), + const SizedBox(height: 8), + TextField( + key: const Key('notice_message_field'), + controller: _messageController, + maxLength: 240, + maxLines: 4, + onChanged: (_) => setState(() {}), + decoration: const InputDecoration(labelText: 'Mensagem'), + ), + SwitchListTile( + key: const Key('notice_chronic_switch'), + contentPadding: EdgeInsets.zero, + title: const Text('Só pacientes com condição crônica'), + value: _chronicOnly, + onChanged: _busy ? null : (value) => setState(() => _chronicOnly = value), + ), + const SizedBox(height: 12), + FilledButton( + key: const Key('notice_send_button'), + onPressed: _canSend ? _send : null, + child: Text(_busy ? 'Enviando…' : 'Enviar aviso'), + ), + if (_error != null) ...[ + const SizedBox(height: 12), + Text(_error!, key: const Key('notice_error'), style: TextStyle(color: context.acsRisk.redOnSurface)), + ], + if (_result != null) ...[ + const SizedBox(height: 12), + Text(_result!, key: const Key('notice_result')), + ], + ]); +} +class ThemeSettingsScreen extends StatelessWidget { + const ThemeSettingsScreen({ + required this.controller, + super.key, + this.onLogout, + this.loggingOut = false, + this.logoutStatus, + this.onWipe, + this.wiping = false, + }); + + /// Saída em andamento: o botão mostra progresso. + final bool loggingOut; + + /// Linha curta debaixo do botão "Sair" enquanto ele trabalha (ex.: o envio + /// antes de sair). `null` não mostra nada. + final String? logoutStatus; + + /// "Limpar este aparelho". `null` desliga o botão. + final VoidCallback? onWipe; + + /// Limpeza (ou o envio antes dela) em andamento. + final bool wiping; + + final ThemeController controller; + + /// "Sair e encerrar o turno". `null` desliga o botão (saída em andamento). + final VoidCallback? onLogout; + + @override + Widget build(BuildContext context) => ValueListenableBuilder( + valueListenable: controller, + builder: (context, mode, _) => _page([ + const Text('Aparência', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + const SizedBox(height: 4), + const Text('Escolha como o app deve se apresentar neste aparelho.'), + const SizedBox(height: 16), + RadioGroup( + groupValue: mode, + onChanged: (value) { if (value != null) controller.setThemeMode(value); }, + child: const Column(children: [ + Card(child: RadioListTile(key: Key('theme_light'), value: ThemeMode.light, title: Text('Claro'))), + Card(child: RadioListTile(key: Key('theme_dark'), value: ThemeMode.dark, title: Text('Escuro'))), + Card(child: RadioListTile(key: Key('theme_system'), value: ThemeMode.system, title: Text('Automático (segue o sistema)'))), + ]), + ), + const SizedBox(height: 24), + const Text('Turno', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + const SizedBox(height: 4), + const Text('Sair envia antes o que estiver pendente e encerra a sessão neste aparelho. ' + 'O que não puder ser enviado continua salvo e protegido, e sobe sozinho quando o aplicativo estiver aberto com conexão (em até 7 dias).'), + const SizedBox(height: 16), + SizedBox(width: double.infinity, child: FilledButton( + key: const Key('logout_button'), + style: FilledButton.styleFrom(minimumSize: const Size(48, 52)), + onPressed: onLogout, + child: loggingOut + ? const SizedBox(key: Key('logout_progress'), height: 22, width: 22, child: CircularProgressIndicator(strokeWidth: 2)) + : const Text('Sair e encerrar o turno'), + )), + if (logoutStatus != null) Padding( + padding: const EdgeInsets.only(top: 8), + child: Semantics(liveRegion: true, child: Text(logoutStatus!, key: const Key('logout_status'))), + ), + const SizedBox(height: 24), + const Text('Aparelho', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + const SizedBox(height: 4), + const Text('Limpar este aparelho apaga as visitas já enviadas, o cache da microárea e as chaves de envio. ' + 'Só é possível quando todas as visitas, de todas as contas, já foram enviadas.'), + const SizedBox(height: 16), + SizedBox(width: double.infinity, child: OutlinedButton( + key: const Key('wipe_button'), + style: OutlinedButton.styleFrom(minimumSize: const Size(48, 52)), + onPressed: onWipe, + child: wiping + ? const SizedBox(key: Key('wipe_progress'), height: 22, width: 22, child: CircularProgressIndicator(strokeWidth: 2)) + : const Text('Limpar este aparelho'), + )), + ]), + ); +} 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)))]); @@ -2013,13 +3568,24 @@ String _riskLabelPt(String riskLevel) => switch (riskLevel.toLowerCase()) { 'green' || 'verde' => 'Verde', _ => 'Não classificado', }; -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: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [Text(label), Flexible(child: Text(value, textAlign: TextAlign.end, style: const TextStyle(fontWeight: FontWeight.bold)))])); } +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: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.start, children: [Flexible(child: Text(label)), const SizedBox(width: 12), Flexible(child: Text(value, textAlign: TextAlign.end, style: const TextStyle(fontWeight: FontWeight.bold)))])); } +/// Altura do cabeçalho, crescendo com a escala de fonte. +/// +/// `preferredSize` não tem acesso ao `BuildContext`, então quem monta o +/// `Scaffold` calcula e entrega. 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. +double acsHeaderHeight(BuildContext context) => + MediaQuery.textScalerOf(context).scale(72).clamp(72.0, 132.0); + class _Header extends StatelessWidget implements PreferredSizeWidget { - const _Header(this.eyebrow, this.title, {this.connected}); + const _Header(this.eyebrow, this.title, {required this.height, this.connected}); final String eyebrow; final String title; + /// Calculada por quem monta o Scaffold, via [acsHeaderHeight]. + final double height; + /// Estado da conexão com o broker. `null` fora do painel. /// /// Fica no cabeçalho de propósito: um ACS precisa saber, sem procurar, que @@ -2027,29 +3593,37 @@ class _Header extends StatelessWidget implements PreferredSizeWidget { final bool? connected; @override - Size get preferredSize => const Size.fromHeight(72); + Size get preferredSize => Size.fromHeight(height); @override Widget build(BuildContext context) => AppBar( + toolbarHeight: height, title: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(eyebrow.toUpperCase(), style: const TextStyle(fontSize: 10, color: AcsColors.accentOnSurface, fontWeight: FontWeight.bold)), - Text(title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + Text(eyebrow.toUpperCase(), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 10, color: context.acsRisk.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: Chip( - key: const Key('broker_status'), - avatar: connected == null ? null : Icon( - connected! ? Icons.cloud_done_outlined : Icons.cloud_off_outlined, - size: 18, - color: connected! ? AcsColors.green : AcsColors.red, + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width * 0.5), + child: Chip( + key: const Key('broker_status'), + avatar: connected == null ? null : Icon( + connected! ? Icons.cloud_done_outlined : Icons.cloud_off_outlined, + size: 18, + color: connected! ? context.acsRisk.greenOnSurface : context.acsRisk.redOnSurface, + ), + label: Text( + switch (connected) { + null => 'Offline ready', + true => 'Alertas em tempo real', + false => 'Sem central', + }, + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), ), - label: Text(switch (connected) { - null => 'Offline ready', - true => 'Alertas em tempo real', - false => 'Sem central', - }), ), ), ], diff --git a/apps/acs/lib/app/app_lock_gate.dart b/apps/acs/lib/app/app_lock_gate.dart new file mode 100644 index 00000000..cf68a67f --- /dev/null +++ b/apps/acs/lib/app/app_lock_gate.dart @@ -0,0 +1,252 @@ +import 'package:flutter/material.dart'; + +import '../core/security/biometric_gate.dart'; +import 'acs_theme.dart'; + +/// Bloqueio por inatividade. O [child] nunca é desmontado: o estado do painel +/// (formulários, rotas) sobrevive ao bloqueio. +/// +/// Contrato para o chamador (Task 7): +/// (a) colocar em `MaterialApp.builder`, ACIMA do Navigator, para cobrir também +/// as rotas empilhadas; [navigatorKey] é OBRIGATÓRIA e deve ser a mesma do +/// MaterialApp (sem ela o botão voltar do Android não seria engolido); +/// (b) o estado de bloqueio vive só na memória: numa partida a frio o gate +/// nasce desbloqueado, então o chamador deve exigir `authenticate()` (ou a +/// senha) antes de mostrar dados quando houver sessão restaurada; +/// (c) `authenticate()` que falha, lança, ou volta `lockedOut`/`unavailable` +/// NUNCA desbloqueia: falha fechada; +/// (d) [onUsePassword] deve SEMPRE ser ligado: com `null` o botão "Entrar com +/// senha" não faz nada e lockedOut/unavailable ficam sem saída. O gate não +/// tem API de desbloqueio pela senha: ao concluir o login por senha, o +/// chamador deve substituir/recriar o gate (nova `key`) ou encerrar a +/// sessão, o que descarta o bloqueio; +/// (e) enquanto bloqueado, o código do app NÃO deve fazer pop programático (a +/// cobertura bloqueia toda entrada do usuário, mas `pop()` por código não +/// passa pelo `PopScope`). Uma tela empurrada durante o bloqueio (ex.: +/// reautenticação por `onSessionExpired`) fica ACIMA da rota-bloqueio, mas +/// continua sob a cobertura opaca. +class AppLockGate extends StatefulWidget { + const AppLockGate({ + super.key, + required this.gate, + required this.child, + this.lockAfter = const Duration(seconds: 30), + this.clock, + this.onUsePassword, + required this.navigatorKey, + }); + + final BiometricGate gate; + final Widget child; + final Duration lockAfter; + final DateTime Function()? clock; + final VoidCallback? onUsePassword; + + /// Navigator do app. Como o gate fica ACIMA do Navigator, um `PopScope` aqui + /// não intercepta o botão voltar do Android; com a chave, o gate empurra uma + /// rota invisível com `canPop: false` enquanto bloqueado, que engole o voltar. + final GlobalKey navigatorKey; + + @override + State createState() => _AppLockGateState(); +} + +class _AppLockGateState extends State with WidgetsBindingObserver { + static const _reason = 'Desbloqueie o SinalACS para continuar'; + + DateTime? _leftAt; + bool _locked = false; + bool _prompting = false; + UnlockResult? _last; + Route? _backBlocker; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _unblockBack(); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.paused || + state == AppLifecycleState.hidden) { + _leftAt ??= (widget.clock ?? DateTime.now)(); + } else if (state == AppLifecycleState.resumed) { + final left = _leftAt; + _leftAt = null; + if (left == null || _locked) return; + final away = (widget.clock ?? DateTime.now)().difference(left); + // Relógio que andou para trás também bloqueia: não dá para provar o prazo. + if (away.isNegative || away >= widget.lockAfter) { + setState(() { + _locked = true; + _last = null; + }); + FocusManager.instance.primaryFocus?.unfocus(); + _blockBack(); + _prompt(); + } + } + } + + void _blockBack() { + if (!_locked || _backBlocker != null) return; + final nav = widget.navigatorKey.currentState; + if (nav == null) { + // Navigator ainda não montado: tenta de novo no próximo quadro. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _blockBack(); + }); + return; + } + final route = PageRouteBuilder( + opaque: false, + barrierDismissible: false, + transitionDuration: Duration.zero, + reverseTransitionDuration: Duration.zero, + pageBuilder: (_, __, ___) => + const PopScope(canPop: false, child: SizedBox.shrink()), + ); + _backBlocker = route; + nav.push(route); + } + + void _unblockBack() { + final route = _backBlocker; + _backBlocker = null; + final nav = route?.navigator; + if (route != null && nav != null) { + nav.removeRoute(route); + } + } + + Future _prompt() async { + if (_prompting) return; + _prompting = true; + UnlockResult result; + try { + result = await widget.gate.authenticate(reason: _reason); + } catch (_) { + result = UnlockResult.unavailable; // falha fechada + } finally { + _prompting = false; + } + if (!mounted) return; + setState(() { + _last = result; + if (result == UnlockResult.unlocked) { + _locked = false; + _unblockBack(); + } + }); + } + + @override + Widget build(BuildContext context) { + final blockedOut = + _last == UnlockResult.lockedOut || _last == UnlockResult.unavailable; + return Stack( + children: [ + ExcludeSemantics( + excluding: _locked, + child: ExcludeFocus( + excluding: _locked, + child: IgnorePointer(ignoring: _locked, child: widget.child), + ), + ), + if (_locked) + Positioned.fill( + child: _LockCover( + message: switch (_last) { + UnlockResult.lockedOut => + 'Biometria bloqueada por tentativas. Entre com a senha.', + UnlockResult.unavailable => + 'Desbloqueio do aparelho indisponível. Entre com a senha.', + _ => + 'Alertas continuam chegando; eles estarão no painel ao desbloquear.', + }, + canRetry: !blockedOut, + onRetry: _prompt, + onUsePassword: widget.onUsePassword, + ), + ), + ], + ); + } +} + +class _LockCover extends StatelessWidget { + const _LockCover({ + required this.message, + required this.canRetry, + required this.onRetry, + required this.onUsePassword, + }); + + final String message; + final bool canRetry; + final VoidCallback onRetry; + final VoidCallback? onUsePassword; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + const minSize = Size(double.infinity, 48); + return Material( + color: theme.colorScheme.surface, + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ExcludeSemantics( + child: Icon( + Icons.fingerprint, + size: 72, + color: context.acsRisk.accentOnSurface, + ), + ), + const SizedBox(height: 16), + Text( + 'Aplicativo bloqueado', + style: theme.textTheme.titleLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + message, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + if (canRetry) + FilledButton( + style: FilledButton.styleFrom(minimumSize: minSize), + onPressed: onRetry, + child: const Text('Desbloquear'), + ), + const SizedBox(height: 12), + OutlinedButton( + style: OutlinedButton.styleFrom(minimumSize: minSize), + onPressed: onUsePassword, + child: const Text('Entrar com senha'), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/apps/acs/lib/app/invite_screen.dart b/apps/acs/lib/app/invite_screen.dart new file mode 100644 index 00000000..3326faea --- /dev/null +++ b/apps/acs/lib/app/invite_screen.dart @@ -0,0 +1,276 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:sinalacs_acs/app/acs_theme.dart'; +import 'package:sinalacs_acs/core/network/backend_client.dart'; +import 'package:sinalacs_acs/core/network/backend_scope.dart'; +import 'package:sinalacs_client/sinalacs_client.dart' + show EnrollmentTokenResult, MicroAreaPatient; + +/// Convite de onboarding (RF02): o ACS escolhe um paciente da própria +/// microárea e mostra o QR Code que o app do paciente lê. +/// +/// O token em claro existe só na memória desta tela — nunca vai para disco, +/// log ou fila offline; o servidor guarda só o hash (`enrollment_tokens`). +/// Sai da tela, some o token. A lista mostra nome e condições crônicas, a +/// mesma minimização do seletor da visita de rotina (spec/lgpd_design.md:364). +class InviteScreen extends StatefulWidget { + const InviteScreen({super.key}); + + @override + State createState() => _InviteScreenState(); +} + +/// Validade de um convite, igual à do servidor. +const _inviteLifetime = Duration(minutes: 15); + +class _InviteScreenState extends State { + List? _patients; + MicroAreaPatient? _selected; + EnrollmentTokenResult? _invite; + String? _error; + bool _loading = true; + bool _generating = false; + Timer? _expiryTimer; + bool _expired = false; + + @override + void initState() { + super.initState(); + // `BackendScope.of` depende de herança: não pode rodar dentro do + // `initState` em si. + WidgetsBinding.instance.addPostFrameCallback((_) => _load()); + } + + @override + void dispose() { + _expiryTimer?.cancel(); + super.dispose(); + } + + void _clearExpiry() { + _expiryTimer?.cancel(); + _expiryTimer = null; + _expired = false; + } + + /// Agenda o aviso de que o convite deixou de valer. + /// + /// `expiresAt` vem do relógio do servidor; comparar com o do aparelho falha + /// nos dois sentidos. Aparelho adiantado: o convite chegaria "já vencido" e a + /// tela nunca mostraria um QR que o servidor aceitaria. Aparelho atrasado: o + /// QR ficaria de pé depois de morto. Por isso a espera é limitada a + /// [_inviteLifetime] (o mesmo TTL do servidor, `_tokenLifetime` em + /// `onboarding_service.dart`), contado do recebimento, e um `expiresAt` que já + /// passou para o aparelho usa o prazo inteiro em vez de valer como vencido. + /// O servidor segue sendo quem recusa um convite expirado. + void _watchExpiry(DateTime expiresAt) { + _clearExpiry(); + var remaining = expiresAt.difference(DateTime.now()); + if (remaining <= Duration.zero || remaining > _inviteLifetime) { + remaining = _inviteLifetime; + } + _expiryTimer = Timer(remaining, () { + if (mounted) setState(() => _expired = true); + }); + } + + Future _load() async { + setState(() { + _loading = true; + _error = null; + }); + try { + final patients = await BackendScope.of(context).listPatients(); + if (!mounted) return; + setState(() { + _patients = patients; + _loading = false; + }); + } on BackendFailure catch (failure) { + if (!mounted) return; + setState(() { + _error = failure.message; + _loading = false; + }); + } + } + + void _select(MicroAreaPatient patient) { + setState(() { + // O convite exibido pertence ao paciente anterior: nunca deixá-lo na + // tela sob o nome de outra pessoa. + if (_selected?.patientId != patient.patientId) { + _invite = null; + _clearExpiry(); + } + _selected = patient; + _error = null; + }); + } + + Future _generate() async { + final patient = _selected; + if (patient == null) return; + setState(() { + _generating = true; + _error = null; + }); + try { + final invite = await BackendScope.of( + context, + ).generateInvite(patientId: patient.patientId); + if (!mounted) return; + setState(() { + _generating = false; + // A resposta pode chegar depois de o ACS trocar de paciente (rede + // lenta): o token é de quem foi pedido, nunca do selecionado agora. + if (_selected?.patientId == patient.patientId) { + _invite = invite; + _watchExpiry(invite.expiresAt); + } + }); + } on BackendFailure catch (failure) { + if (!mounted) return; + setState(() { + _generating = false; + if (_selected?.patientId != patient.patientId) return; + _invite = null; + _clearExpiry(); + _error = failure.message; + }); + } + } + + String _hhmm(DateTime time) => + '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}'; + + @override + Widget build(BuildContext context) { + final patients = _patients; + final invite = _invite; + return ListView( + padding: const EdgeInsets.all(16), + children: [ + const Text( + 'Convidar paciente', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + const Text( + 'Escolha o paciente e mostre o QR Code para ele ler no app SinalACS ' + 'Paciente. O convite vale por 15 minutos e só pode ser usado uma vez.', + ), + const SizedBox(height: 16), + if (_loading) + const Center(child: CircularProgressIndicator()) + else if (patients == null) + OutlinedButton.icon( + key: const Key('invite_retry'), + onPressed: _load, + style: OutlinedButton.styleFrom(minimumSize: const Size(48, 52)), + icon: const Icon(Icons.refresh), + label: const Text('Tentar de novo'), + ) + else if (patients.isEmpty) + const Text( + 'Nenhum paciente cadastrado na sua microárea.', + key: Key('invite_no_patients'), + ) + else ...[ + for (final patient in patients) + ListTile( + key: Key('invite_patient_${patient.patientId}'), + selected: _selected?.patientId == patient.patientId, + title: Text(patient.name), + subtitle: patient.chronicConditions.isEmpty + ? null + : Text(patient.chronicConditions.join(', ')), + trailing: _selected?.patientId == patient.patientId + ? Icon( + Icons.check_circle, + color: context.acsRisk.accentOnSurface, + ) + : null, + onTap: () => _select(patient), + ), + const SizedBox(height: 12), + FilledButton.icon( + key: const Key('generate_invite_button'), + onPressed: _selected == null || _generating ? null : _generate, + style: FilledButton.styleFrom(minimumSize: const Size(48, 52)), + icon: const Icon(Icons.qr_code_2), + label: Text( + invite == null ? 'Gerar convite' : 'Gerar novo convite', + ), + ), + ], + if (_error != null) + Padding( + padding: const EdgeInsets.only(top: 16), + child: Semantics( + liveRegion: true, + child: Text( + _error!, + key: const Key('invite_error'), + style: TextStyle( + color: context.acsRisk.redOnSurface, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + if (invite != null && _expired) + Padding( + padding: const EdgeInsets.only(top: 24), + child: Semantics( + liveRegion: true, + child: const Text( + 'O convite expirou. Gere um novo.', + key: Key('invite_expired'), + textAlign: TextAlign.center, + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + ), + if (invite != null && _selected != null && !_expired) ...[ + const SizedBox(height: 24), + Center( + child: Container( + key: const Key('invite_qr'), + // Fundo branco com margem: leitores de QR precisam da "zona + // silenciosa" clara em volta, e o tema do app é escuro. + color: Colors.white, + padding: const EdgeInsets.all(16), + child: QrImageView( + data: invite.token, + size: 240, + backgroundColor: Colors.white, + semanticsLabel: 'QR Code do convite de ${_selected!.name}', + ), + ), + ), + const SizedBox(height: 12), + Text( + 'Convite de ${_selected!.name} · Válido até ${_hhmm(invite.expiresAt.toLocal())} · uso único', + key: const Key('invite_expires_at'), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Se a câmera do paciente não funcionar, ele pode digitar este código:', + textAlign: TextAlign.center, + style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + SelectableText( + invite.token, + key: const Key('invite_token_text'), + textAlign: TextAlign.center, + style: const TextStyle(fontFamily: 'monospace'), + ), + ], + ], + ); + } +} diff --git a/apps/acs/lib/app/mfa_enrollment_screen.dart b/apps/acs/lib/app/mfa_enrollment_screen.dart new file mode 100644 index 00000000..f560ef84 --- /dev/null +++ b/apps/acs/lib/app/mfa_enrollment_screen.dart @@ -0,0 +1,119 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:sinalacs_acs/core/network/backend_client.dart'; +import 'package:sinalacs_client/sinalacs_client.dart' show TotpEnrollmentStart; + +/// Ativação da verificação em duas etapas (RF07 / LGPD-RT06). +/// +/// Sem token: o ACS acabou de provar matrícula e senha. O segredo aparece só +/// aqui, em QR e em texto, e **não** é gravado no aparelho. +class MfaEnrollmentScreen extends StatefulWidget { + const MfaEnrollmentScreen({ + super.key, + required this.backend, + required this.matricula, + required this.senha, + }); + + final AcsBackend backend; + final String matricula; + final String senha; + + @override + State createState() => _MfaEnrollmentScreenState(); +} + +class _MfaEnrollmentScreenState extends State { + final _codigo = TextEditingController(); + TotpEnrollmentStart? _inicio; + String? _erro; + bool _ocupado = false; + + @override + void initState() { + super.initState(); + _iniciar(); + } + + @override + void dispose() { + _codigo.dispose(); + super.dispose(); + } + + Future _iniciar() async { + setState(() => _erro = null); + try { + final inicio = await widget.backend.beginTotpEnrollment(matricula: widget.matricula, senha: widget.senha); + if (mounted) setState(() => _inicio = inicio); + } on BackendFailure catch (falha) { + if (mounted) setState(() => _erro = falha.message); + } + } + + Future _confirmar() async { + if (_ocupado) return; + setState(() { _ocupado = true; _erro = null; }); + try { + await widget.backend.confirmTotpEnrollment( + matricula: widget.matricula, + senha: widget.senha, + code: _codigo.text.trim(), + ); + if (mounted) Navigator.of(context).pop(true); + } on BackendFailure catch (falha) { + if (mounted) setState(() { _erro = falha.message; _ocupado = false; }); + } + } + + @override + Widget build(BuildContext context) { + final inicio = _inicio; + return Scaffold( + appBar: AppBar(title: const Text('Verificação em duas etapas')), + body: ListView( + padding: const EdgeInsets.all(24), + children: [ + const Text('Leia o QR no aplicativo autenticador (ou digite a chave) e informe o código de 6 dígitos que ele mostrar.'), + const SizedBox(height: 16), + if (inicio == null && _erro == null) const Center(child: CircularProgressIndicator()), + if (inicio != null) ...[ + // Fundo branco com módulos escuros por exigência de leitura: não usa token de tema. + Center(child: QrImageView(data: inicio.otpauthUri, size: 200, backgroundColor: Colors.white)), + const SizedBox(height: 12), + SelectableText(inicio.secretBase32, key: const Key('mfa_secret')), + ], + if (_erro != null) + Semantics(liveRegion: true, child: Text(_erro!, key: const Key('mfa_error'), + style: TextStyle(color: Theme.of(context).colorScheme.error))), + if (inicio == null && _erro != null) + Align( + alignment: Alignment.centerLeft, + child: TextButton( + key: const Key('mfa_retry_button'), + onPressed: _iniciar, + child: const Text('Tentar de novo'), + ), + ), + const SizedBox(height: 16), + TextField( + key: const Key('mfa_code_field'), + controller: _codigo, + keyboardType: TextInputType.number, + maxLength: 6, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: const InputDecoration(labelText: 'Código de 6 dígitos'), + ), + const SizedBox(height: 12), + FilledButton( + key: const Key('mfa_confirm_button'), + onPressed: inicio == null || _ocupado ? null : _confirmar, + style: FilledButton.styleFrom(minimumSize: const Size(48, 52)), + child: const Text('Ativar'), + ), + ], + ), + ); + } +} diff --git a/apps/acs/lib/app/pending_alerts_banner.dart b/apps/acs/lib/app/pending_alerts_banner.dart new file mode 100644 index 00000000..943cec99 --- /dev/null +++ b/apps/acs/lib/app/pending_alerts_banner.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +import 'acs_theme.dart'; + +/// Aviso fixo no topo da reautenticação (sessão vencida ou bloqueio): o painel +/// com a fila de alertas fica por baixo da rota opaca, e um alerta vermelho que +/// chega ali não pode esperar o novo login para ser notado. +class PendingAlertsBanner extends StatelessWidget { + const PendingAlertsBanner({super.key, required this.listenable, required this.count}); + + final Listenable listenable; + final int Function() count; + + @override + Widget build(BuildContext context) => ListenableBuilder( + listenable: listenable, + builder: (context, _) { + final n = count(); + if (n <= 0) return const SizedBox.shrink(); + final texto = n == 1 + ? '1 alerta vermelho aguardando. Entre para ver.' + : '$n alertas vermelhos aguardando. Entre para ver.'; + return Material( + key: const Key('reauth_pending_alerts'), + // `cardTheme.color` (surfaceRaised), não `cardColor`: é a superfície onde + // `contrast_tokens_test.dart` mede `redOnSurface` sobre card. + color: Theme.of(context).cardTheme.color, + child: Semantics( + liveRegion: true, + label: texto, + excludeSemantics: true, + child: Padding( + padding: const EdgeInsets.all(12), + child: Text( + texto, + textAlign: TextAlign.center, + style: TextStyle(color: context.acsRisk.redOnSurface, fontWeight: FontWeight.w700), + ), + ), + ), + ); + }, + ); +} diff --git a/apps/acs/lib/core/database/encrypted_database.dart b/apps/acs/lib/core/database/encrypted_database.dart index 302277c9..1a6502f9 100644 --- a/apps/acs/lib/core/database/encrypted_database.dart +++ b/apps/acs/lib/core/database/encrypted_database.dart @@ -22,8 +22,12 @@ class EncryptedLocalDatabase { /// ficar visível no aparelho em vez de retentar para sempre. v5 acrescenta /// `sync_cursor`, para o cursor de `visits.pull` (`SyncCursorStore`) — dado /// operacional do dispositivo, não uma visita, mas vive no mesmo banco - /// criptografado por estar sob a mesma política de proteção. - static const schemaVersion = 5; + /// criptografado por estar sob a mesma política de proteção. v6 acrescenta + /// `micro_area_cache` e `micro_area_cache_meta`, o cache da microárea (RF08, + /// `spec/lgpd_design.md` §5.11). v7 acrescenta `offline_visits.owner`, o dono + /// da visita (id do ACS); nulo = visita legada em QUARENTENA, que nenhum dono + /// enxerga e que só sai pelo envio legado (`visits.syncLegacy`). + static const schemaVersion = 7; /// Visitas registradas offline, aguardando sincronização. /// @@ -42,9 +46,14 @@ CREATE TABLE IF NOT EXISTS offline_visits ( notes TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, version INTEGER NOT NULL, - rejection_reason TEXT + rejection_reason TEXT, + owner TEXT )'''; + /// Índice por dono: toda leitura/escrita de visitas filtra por `owner`. + static const createOfflineVisitsOwnerIndex = + 'CREATE INDEX IF NOT EXISTS offline_visits_owner_idx ON offline_visits(owner)'; + /// Cursor de sincronização central→dispositivo (`visits.pull`), por /// instalação do app — ver `SyncCursorStore`. static const createSyncCursor = ''' @@ -53,7 +62,24 @@ CREATE TABLE IF NOT EXISTS sync_cursor ( value TEXT NOT NULL )'''; - /// Migração v1 → v2, v2 → v3, v3 → v4 e v4 → v5. + /// Cache da microárea (RF08, §5.11 de spec/lgpd_design.md): a última lista que + /// o servidor devolveu, de um dono (`userId|microAreaId`), com a data do + /// download. Sob a mesma criptografia das visitas. + static const createMicroAreaCache = ''' +CREATE TABLE IF NOT EXISTS micro_area_cache ( + patient_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + is_chronic INTEGER NOT NULL, + chronic_conditions TEXT NOT NULL +)'''; + + static const createMicroAreaCacheMeta = ''' +CREATE TABLE IF NOT EXISTS micro_area_cache_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +)'''; + + /// Migração v1 → v2, v2 → v3, v3 → v4, v4 → v5, v5 → v6 e v6 → v7. /// /// Nenhuma das duas formas de v1 guarda o UUID do paciente: `patient_name` /// era `'Paciente ' + 8 dos 32 dígitos hex`, irreversível. Sem UUID, @@ -71,6 +97,7 @@ CREATE TABLE IF NOT EXISTS sync_cursor ( await db.execute('DROP TABLE IF EXISTS local_queue'); await db.execute('DROP TABLE IF EXISTS offline_visits'); await db.execute(createOfflineVisits); + await db.execute(createOfflineVisitsOwnerIndex); return; } @@ -104,6 +131,25 @@ CREATE TABLE IF NOT EXISTS sync_cursor ( // então nenhuma visita pendente ou recusada existente é perdida. await db.execute(createSyncCursor); } + + if (from < 6) { + // Aditiva: `CREATE TABLE IF NOT EXISTS` não toca em nada que já existe. + await db.execute(createMicroAreaCache); + await db.execute(createMicroAreaCacheMeta); + } + + if (from < 7) { + // Aditiva: as linhas existentes ficam com `owner` nulo = quarentena (D2). + // Nenhum dono as adota; só o envio legado as retira. + final columns = await db.rawQuery( + "PRAGMA table_info('offline_visits')", + ); + final hasOwner = columns.any((column) => column['name'] == 'owner'); + if (!hasOwner) { + await db.execute('ALTER TABLE offline_visits ADD COLUMN owner TEXT;'); + } + await db.execute(createOfflineVisitsOwnerIndex); + } } /// Caminho do arquivo do banco neste dispositivo. @@ -155,11 +201,16 @@ CREATE TABLE IF NOT EXISTS sync_cursor ( version: schemaVersion, onCreate: (db, version) async { await db.execute(createOfflineVisits); + await db.execute(createOfflineVisitsOwnerIndex); await db.execute(createSyncCursor); + await db.execute(createMicroAreaCache); + await db.execute(createMicroAreaCacheMeta); }, onUpgrade: _upgrade, // Um rollback de APK abriria um arquivo v2 pedindo v1 e lançaria, - // deixando o app travado a cada abertura. + // deixando o app travado a cada abertura. ATENÇÃO: o preço é APAGAR o + // banco — voltar o APK para uma build anterior à v7 (fila por dono) + // descarta as visitas não enviadas, o cursor e o cache da microárea. onDowngrade: onDatabaseDowngradeDelete, ); } @@ -170,7 +221,10 @@ CREATE TABLE IF NOT EXISTS sync_cursor ( version: schemaVersion, onCreate: (db, version) async { await db.execute(createOfflineVisits); + await db.execute(createOfflineVisitsOwnerIndex); await db.execute(createSyncCursor); + await db.execute(createMicroAreaCache); + await db.execute(createMicroAreaCacheMeta); }, onUpgrade: _upgrade, onDowngrade: onDatabaseDowngradeDelete, @@ -191,4 +245,44 @@ CREATE TABLE IF NOT EXISTS sync_cursor ( } return databaseFactoryFfi.deleteDatabase(path); } + + /// Sufixo da ÚNICA cópia de recuperação ([moveAside]). + static const recoverySuffix = '.recuperado'; + + /// Arquivos que acompanham o banco (WAL, memória compartilhada, journal): + /// movidos e apagados junto com ele, para nunca misturar gerações. + static const _sidecars = ['-wal', '-shm', '-journal']; + + /// Põe o arquivo do banco de lado em `.recuperado`, em vez de + /// apagá-lo, quando a chave do Keystore não o abre mais. + /// + /// A cópia continua cifrada pelo SQLCipher e a chave dela é apagada logo em + /// seguida (`VisitDatabase`): **nem o app nem o suporte conseguem lê-la**. + /// Ela é só um artefato forense ilegível — os bytes ficam, caso a chave + /// antiga um dia volte (restauração de backup do aparelho ou do Keystore) — + /// e as visitas pendentes que estavam nela NÃO são recuperáveis pelo app. + /// Há UMA cópia: a anterior é sobrescrita. Se + /// mover falhar, lança — quem chama não apaga nada e não troca a chave. + /// "Limpar este aparelho" apaga a cópia ([deleteRecoveryCopy]). + static Future moveAside(String databaseName) async { + final path = await pathFor(databaseName); + final main = File(path); + if (!main.existsSync()) return; + final backup = '$path$recoverySuffix'; + await deleteRecoveryCopy(databaseName); + await main.rename(backup); + for (final sidecar in _sidecars) { + final file = File('$path$sidecar'); + if (file.existsSync()) await file.rename('$backup$sidecar'); + } + } + + /// Apaga a cópia de recuperação e os arquivos que a acompanham, se houver. + static Future deleteRecoveryCopy(String databaseName) async { + final backup = '${await pathFor(databaseName)}$recoverySuffix'; + for (final suffix in ['', ..._sidecars]) { + final file = File('$backup$suffix'); + if (file.existsSync()) await file.delete(); + } + } } diff --git a/apps/acs/lib/core/database/micro_area_cache_store.dart b/apps/acs/lib/core/database/micro_area_cache_store.dart new file mode 100644 index 00000000..cf62f349 --- /dev/null +++ b/apps/acs/lib/core/database/micro_area_cache_store.dart @@ -0,0 +1,106 @@ +import 'dart:convert'; + +import 'package:sinalacs_acs/core/database/encrypted_database.dart'; +import 'package:sinalacs_acs/core/security/database_key_store.dart'; +import 'package:sinalacs_client/sinalacs_client.dart' show MicroAreaPatient; +import 'package:sqflite_common_ffi/sqflite_ffi.dart' show ConflictAlgorithm, Database; + +class CachedMicroArea { + const CachedMicroArea({required this.patients, required this.fetchedAt}); + + final List patients; + final DateTime fetchedAt; +} + +/// Última lista da microárea no aparelho (RF08), na base SQLCipher das visitas. +/// +/// Política em `spec/lgpd_design.md` §5.11: a lista pertence a um dono +/// (`userId|microAreaId`); [read] de **outro** dono apaga o cache em vez de +/// só escondê-lo — dado de outro território não fica no disco esperando uma +/// confusão. +class MicroAreaCacheStore { + MicroAreaCacheStore({ + required DatabaseKeyStore keyStore, + this.databaseName = 'sinalacs_acs.db', + this.allowUnencryptedForTesting = false, + }) : _keyStore = keyStore; + + static const _tabela = 'micro_area_cache'; + static const _meta = 'micro_area_cache_meta'; + static const _chaveDono = 'owner'; + static const _chaveData = 'fetched_at'; + + final DatabaseKeyStore _keyStore; + final String databaseName; + final bool allowUnencryptedForTesting; + Database? _database; + + Future _open() async { + final existente = _database; + if (existente != null && existente.isOpen) return existente; + final passphrase = await _keyStore.readOrCreate(); + return _database = await EncryptedLocalDatabase.open( + databaseName: databaseName, + passphrase: passphrase, + allowUnencryptedForTesting: allowUnencryptedForTesting, + ); + } + + Future read({required String owner}) async { + final db = await _open(); + final meta = { + for (final linha in await db.query(_meta)) linha['key'] as String: linha['value'] as String, + }; + final donoGravado = meta[_chaveDono]; + final data = meta[_chaveData]; + if (donoGravado == null || data == null) return null; + if (donoGravado != owner) { + await clear(); + return null; + } + + final linhas = await db.query(_tabela, orderBy: 'rowid'); + return CachedMicroArea( + fetchedAt: DateTime.parse(data), + patients: [ + for (final linha in linhas) + MicroAreaPatient( + patientId: linha['patient_id'] as String, + name: linha['name'] as String, + isChronic: (linha['is_chronic'] as int) == 1, + chronicConditions: (jsonDecode(linha['chronic_conditions'] as String) as List).cast(), + ), + ], + ); + } + + Future write({ + required String owner, + required List patients, + required DateTime at, + }) async { + final db = await _open(); + await db.transaction((txn) async { + await txn.delete(_tabela); + for (final p in patients) { + await txn.insert(_tabela, { + 'patient_id': p.patientId, + 'name': p.name, + 'is_chronic': p.isChronic ? 1 : 0, + 'chronic_conditions': jsonEncode(p.chronicConditions), + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + await txn.insert(_meta, {'key': _chaveDono, 'value': owner}, conflictAlgorithm: ConflictAlgorithm.replace); + await txn.insert(_meta, {'key': _chaveData, 'value': at.toUtc().toIso8601String()}, + conflictAlgorithm: ConflictAlgorithm.replace); + }); + } + + Future clear() async { + final db = await _open(); + await db.transaction((txn) async { + await txn.delete(_tabela); + await txn.delete(_meta); + }); + } +} diff --git a/apps/acs/lib/core/database/sqlcipher_visit_store.dart b/apps/acs/lib/core/database/sqlcipher_visit_store.dart index d709a909..f2bd4abc 100644 --- a/apps/acs/lib/core/database/sqlcipher_visit_store.dart +++ b/apps/acs/lib/core/database/sqlcipher_visit_store.dart @@ -1,22 +1,47 @@ +import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:sinalacs_acs/core/database/encrypted_database.dart'; import 'package:sinalacs_acs/core/security/database_key_store.dart'; import 'package:sinalacs_acs/core/services/offline_visit_queue.dart'; -import 'package:sqflite_common_ffi/sqflite_ffi.dart' show Database; +import 'package:sqflite_common_ffi/sqflite_ffi.dart' show Database, DatabaseException; -/// Persistência das visitas offline no banco criptografado. -/// -/// Implementa o [VisitStore] que a fila já define — mesmo padrão de -/// `application/` + `infrastructure/` usado no backend: a fila não sabe que -/// existe SQLCipher, e este arquivo não sabe o que é uma FSM de sincronização. -class SqlCipherVisitStore implements VisitStore { - SqlCipherVisitStore({ +const _table = 'offline_visits'; + +/// Abre (e recupera) o banco; uma só instância por processo. +class VisitDatabase { + VisitDatabase({ required DatabaseKeyStore keyStore, this.databaseName = 'sinalacs_acs.db', this.allowUnencryptedForTesting = false, - }) : _keyStore = keyStore; + @visibleForTesting + Future Function(String databaseName, String passphrase, bool allowUnencrypted)? + opener, + @visibleForTesting Duration retryDelay = const Duration(milliseconds: 500), + @visibleForTesting Future Function(String databaseName)? moveAside, + }) : _keyStore = keyStore, + _opener = opener ?? _defaultOpener, + _retryDelay = retryDelay, + _moveAside = moveAside ?? EncryptedLocalDatabase.moveAside; + + /// Espera antes da segunda tentativa de abrir, quando a primeira falhou com + /// cara de chave errada: no Android esse erro (`open_failed`) é o mesmo de + /// uma falha passageira de abertura. + final Duration _retryDelay; - static const _table = 'offline_visits'; + /// Põe o arquivo ilegível de lado (`EncryptedLocalDatabase.moveAside`). + final Future Function(String databaseName) _moveAside; + + static Future _defaultOpener( + String databaseName, + String passphrase, + bool allowUnencrypted, + ) => + EncryptedLocalDatabase.open( + databaseName: databaseName, + passphrase: passphrase, + allowUnencryptedForTesting: allowUnencrypted, + ); + final Future Function(String, String, bool) _opener; final DatabaseKeyStore _keyStore; final String databaseName; @@ -24,66 +49,256 @@ class SqlCipherVisitStore implements VisitStore { final bool allowUnencryptedForTesting; Database? _database; + Future? _opening; /// Abre preguiçosamente, na primeira leitura ou escrita. /// /// É o que permite a fila continuar sendo construída de forma síncrona pela /// UI, sem espalhar `await` pela montagem do app. - Future _open() async { + /// + /// Single-flight: chamadas concorrentes compartilham a MESMA abertura (duas + /// conexões ao mesmo arquivo, ou duas recuperações apagando o arquivo, são + /// o que isto evita). Se a abertura falha, o próximo `open()` tenta de novo. + Future open() { final existing = _database; - if (existing != null && existing.isOpen) return existing; + if (existing != null && existing.isOpen) return Future.value(existing); + + final inFlight = _opening; + if (inFlight != null) return inFlight; + + final attempt = _openOnce(); + _opening = attempt; + return attempt.whenComplete(() { + if (identical(_opening, attempt)) _opening = null; + }).then((_) => attempt); + } + Future _openOnce() async { final passphrase = await _keyStore.readOrCreate(); try { - return _database = await EncryptedLocalDatabase.open( - databaseName: databaseName, - passphrase: passphrase, - allowUnencryptedForTesting: allowUnencryptedForTesting, - ); + return _database = + await _opener(databaseName, passphrase, allowUnencryptedForTesting); } on UnsupportedError { // Plataforma sem SQLCipher: quem chamou precisa saber, não receber um // banco em texto plano por baixo dos panos. rethrow; - } catch (_) { + } catch (error) { + // SÓ a chave errada recomeça o arquivo. Qualquer outro erro (migração, + // E/S, canal da plataforma) sobe: apagar ali destruiria visitas legíveis + // por um problema passageiro. Quem chama segue em RAM + // (`persistenceFailed`) e tenta de novo na próxima abertura. + if (!isWrongDatabaseKeyError(error)) rethrow; + // No Android a chave errada chega como `open_failed`, o mesmo erro de uma + // falha passageira de abertura. Uma nova tentativa, com a MESMA chave, + // separa os dois: se abrir, nada é tocado. + await Future.delayed(_retryDelay); + try { + return _database = + await _opener(databaseName, passphrase, allowUnencryptedForTesting); + } catch (second) { + if (!isWrongDatabaseKeyError(second)) rethrow; + } // A chave não abre este arquivo — tipicamente reinstalação ou restauração // de backup, onde o banco veio e a chave do keystore não. Sem isso o app - // ficaria travado num estado irrecuperável a cada abertura. Descartar o - // arquivo perde visitas ainda não sincronizadas, mas elas já eram - // ilegíveis; ficar travado perderia as próximas também. - await EncryptedLocalDatabase.deleteDatabaseFile(databaseName); + // ficaria travado num estado irrecuperável a cada abertura. O arquivo + // NÃO é apagado: vai para `.recuperado` (uma cópia só, ainda + // cifrada; a chave dela some abaixo, então nem o app nem o suporte a + // leem — é só um artefato forense, caso a chave antiga um dia volte; as + // visitas pendentes dela ficam inacessíveis). No Android, uma falha real + // de abertura que persista nas duas tentativas termina assim também + // (`open_failed` é indistinguível). Se mover falhar, o erro sobe ANTES + // de apagar a chave: o + // arquivo fica e a fila segue em RAM (`persistenceFailed`). + await _moveAside(databaseName); await _keyStore.delete(); - return _database = await EncryptedLocalDatabase.open( - databaseName: databaseName, - passphrase: await _keyStore.readOrCreate(), - allowUnencryptedForTesting: allowUnencryptedForTesting, + return _database = await _opener( + databaseName, + await _keyStore.readOrCreate(), + allowUnencryptedForTesting, ); } } + Future close() async { + await _database?.close(); + _database = null; + } + + /// "Limpar este aparelho" (D9), numa só transação: primeiro reconta as + /// visitas — de QUALQUER dono, inclusive as sem dono (quarentena) — e, se + /// houver alguma, lança [WipeBlocked] sem apagar nada (toda linha de + /// `offline_visits` é visita ainda não enviada: a confirmada pelo servidor + /// sai do disco). Sem visita, apaga as linhas de `offline_visits`, + /// `sync_cursor`, `micro_area_cache` e `micro_area_cache_meta`. + /// + /// O arquivo e a chave do Keystore ficam: é limpeza de dados, não troca de + /// chave. A cópia `.recuperado` de uma recuperação por chave errada, + /// se existir, é apagada depois da transação. + Future wipeAllData() async { + final database = await open(); + await database.transaction((transaction) async { + final rows = await transaction.rawQuery('SELECT COUNT(*) AS total FROM $_table'); + final unsent = rows.single['total']! as int; + if (unsent > 0) throw WipeBlocked(unsent); + await transaction.delete(_table); + await transaction.delete('sync_cursor'); + await transaction.delete('micro_area_cache'); + await transaction.delete('micro_area_cache_meta'); + }); + // Fim da custódia: a cópia de uma recuperação antiga (ilegível ao app) + // também sai. + await EncryptedLocalDatabase.deleteRecoveryCopy(databaseName); + } +} + +/// O erro que o SQLCipher dá quando a chave não abre o arquivo. +/// +/// - `SQLITE_NOTADB` (código 26, "file is not a database"): iOS e FFI. +/// - `open_failed `: o `sqflite_sqlcipher` (3.2.0) no Android +/// converte o "file is not a database" da abertura nisso +/// (`SqfliteSqlCipherPlugin.handleException`), sem o código. A mesma +/// tradução cobre "could not open database"; é o mais estreito que o plugin +/// permite distinguir. +/// +/// Erro de migração (SQL), de E/S, `PlatformException` ou qualquer outro tipo +/// NÃO é chave errada. +bool isWrongDatabaseKeyError(Object error) { + if (error is! DatabaseException) return false; + if (error.getResultCode() == 26) return true; + final message = error.toString().toLowerCase(); + return message.contains('file is not a database') || error.isOpenFailedError(); +} + +OfflineVisitRecord _recordFrom(Map row) => OfflineVisitRecord( + patientId: row['patient_id']! as String, + risk: row['risk']! as String, + status: row['status']! as String, + outcome: row['outcome']! as String, + notes: (row['notes'] as String?) ?? '', + rejectionReason: row['rejection_reason'] as String?, + localId: row['local_id']! as String, + createdAt: DateTime.parse(row['created_at']! as String), + version: row['version']! as int, + ); + +/// Armazenamento por dono sobre UM [VisitDatabase]. +class SqlCipherVisitStorage implements VisitStorage { + SqlCipherVisitStorage({ + required DatabaseKeyStore keyStore, + String databaseName = 'sinalacs_acs.db', + bool allowUnencryptedForTesting = false, + }) : database = VisitDatabase( + keyStore: keyStore, + databaseName: databaseName, + allowUnencryptedForTesting: allowUnencryptedForTesting, + ); + + final VisitDatabase database; + + @override + VisitStore forOwner(String ownerId) => + SqlCipherVisitStore.on(database, owner: requireOwnerId(ownerId)); + + @override + LegacyVisitStore get legacy => SqlCipherLegacyVisitStore(database); + + /// Só `owner` e `COUNT(*)`: nenhuma coluna de conteúdo é lida. + @override + Future> countsByOwner() async { + final db = await database.open(); + final rows = await db.rawQuery( + 'SELECT owner, COUNT(*) AS total FROM $_table WHERE owner IS NOT NULL GROUP BY owner', + ); + return {for (final row in rows) row['owner']! as String: row['total']! as int}; + } + + @override + Future wipeAllData() => database.wipeAllData(); + + Future close() => database.close(); +} + +/// Quarentena (D2): linhas com `owner IS NULL`. Só lê e remove por `localId`. +class SqlCipherLegacyVisitStore implements LegacyVisitStore { + SqlCipherLegacyVisitStore(this._database); + + final VisitDatabase _database; + @override Future> load() async { - final database = await _open(); - final rows = await database.query(_table, orderBy: 'created_at ASC'); - - return [ - for (final row in rows) - OfflineVisitRecord( - patientId: row['patient_id']! as String, - risk: row['risk']! as String, - status: row['status']! as String, - outcome: row['outcome']! as String, - notes: (row['notes'] as String?) ?? '', - rejectionReason: row['rejection_reason'] as String?, - localId: row['local_id']! as String, - createdAt: DateTime.parse(row['created_at']! as String), - version: row['version']! as int, + final database = await _database.open(); + final rows = await database.query( + _table, + where: 'owner IS NULL', + orderBy: 'created_at ASC, rowid ASC', + ); + return [for (final row in rows) _recordFrom(row)]; + } + + @override + Future remove(Iterable localIds) async { + final ids = localIds.toList(); + if (ids.isEmpty) return; + final database = await _database.open(); + await database.transaction((transaction) async { + for (final id in ids) { + await transaction.delete( + _table, + where: 'owner IS NULL AND local_id = ?', + whereArgs: [id], + ); + } + }); + } +} + +/// Persistência das visitas offline de UM dono no banco criptografado. +/// +/// Implementa o [VisitStore] que a fila já define — mesmo padrão de +/// `application/` + `infrastructure/` usado no backend: a fila não sabe que +/// existe SQLCipher, e este arquivo não sabe o que é uma FSM de sincronização. +class SqlCipherVisitStore implements VisitStore { + /// Cria o próprio [VisitDatabase]. + SqlCipherVisitStore({ + required DatabaseKeyStore keyStore, + required String owner, + String databaseName = 'sinalacs_acs.db', + bool allowUnencryptedForTesting = false, + }) : owner = requireOwnerId(owner), + _database = VisitDatabase( + keyStore: keyStore, + databaseName: databaseName, + allowUnencryptedForTesting: allowUnencryptedForTesting, ), - ]; + _ownsDatabase = true; + + /// Visão de [owner] sobre um banco compartilhado. + SqlCipherVisitStore.on(VisitDatabase database, {required String owner}) + : owner = requireOwnerId(owner), + _database = database, + _ownsDatabase = false; + + final VisitDatabase _database; + final bool _ownsDatabase; + final String owner; + + /// Só as linhas deste dono. Nunca `owner IS NULL`: a quarentena não é lida + /// nem adotada por ninguém (D2). + @override + Future> load() async { + final database = await _database.open(); + final rows = await database.query( + _table, + where: 'owner = ?', + whereArgs: [owner], + orderBy: 'created_at ASC, rowid ASC', + ); + return [for (final row in rows) _recordFrom(row)]; } - /// Substitui o conjunto inteiro, em transação. + /// Substitui o conjunto DESTE dono, em transação. /// /// A fila chama `save([..._pending, ..._rejected])`, então o que sai da /// lista sai do disco: uma visita confirmada pelo servidor deixa o @@ -91,13 +306,27 @@ class SqlCipherVisitStore implements VisitStore { /// trava essa propriedade. Uma visita recusada em definitivo continua no /// disco — com `rejection_reason` preenchido — até o ACS descartá-la /// explicitamente; só então ela deixa de fazer parte do que é gravado. + /// + /// Só apaga `WHERE owner = ?`: linhas de outros donos e da quarentena nunca + /// são tocadas. `local_id` é chave primária global; se um `localId` já + /// pertence a outro dono (ou à quarentena), lança [VisitLocalIdConflict] e a + /// transação inteira é desfeita — a linha alheia fica intacta. @override Future save(List visits) async { - final database = await _open(); + ensureNoRepeatedLocalIds(visits); + final database = await _database.open(); await database.transaction((transaction) async { - await transaction.delete(_table); + await transaction.delete(_table, where: 'owner = ?', whereArgs: [owner]); for (final visit in visits) { + final existing = await transaction.query( + _table, + columns: ['local_id'], + where: 'local_id = ?', + whereArgs: [visit.localId], + ); + if (existing.isNotEmpty) throw VisitLocalIdConflict(visit.localId); + await transaction.insert(_table, { 'local_id': visit.localId, 'patient_id': visit.patientId, @@ -108,13 +337,15 @@ class SqlCipherVisitStore implements VisitStore { 'created_at': visit.createdAt.toIso8601String(), 'version': visit.version, 'rejection_reason': visit.rejectionReason, + 'owner': owner, }); } }); } + /// Fecha o banco só se esta visão o criou; visões de [SqlCipherVisitStorage] + /// não fecham o banco compartilhado. Future close() async { - await _database?.close(); - _database = null; + if (_ownsDatabase) await _database.close(); } } diff --git a/apps/acs/lib/core/database/sync_cursor_store.dart b/apps/acs/lib/core/database/sync_cursor_store.dart index 70ad9898..18ffe6e3 100644 --- a/apps/acs/lib/core/database/sync_cursor_store.dart +++ b/apps/acs/lib/core/database/sync_cursor_store.dart @@ -1,54 +1,60 @@ -import 'package:sinalacs_acs/core/database/encrypted_database.dart'; +import 'package:sinalacs_acs/core/database/sqlcipher_visit_store.dart'; import 'package:sinalacs_acs/core/security/database_key_store.dart'; -import 'package:sqflite_common_ffi/sqflite_ffi.dart' show ConflictAlgorithm, Database; +import 'package:sinalacs_acs/core/services/offline_visit_queue.dart' show requireOwnerId; +import 'package:sqflite_common_ffi/sqflite_ffi.dart' show ConflictAlgorithm; -/// Cursor de `visits.pull` por instalação do app (decisão §5.4: "cursor por -/// dispositivo, não por usuário" — reinstalar não deve perder nem duplicar). -/// Vive no mesmo banco criptografado das visitas offline: é dado -/// operacional do dispositivo, sob a mesma política de proteção. +/// Cursor de `visits.pull` POR DONO (usuário/microárea), no mesmo banco +/// criptografado das visitas offline. +/// +/// Antes era um cursor global do aparelho: o ACS B herdava o `since` do ACS A +/// e nunca receberia as visitas anteriores a ele. Agora cada dono tem o seu, +/// na chave `visits_pull|`. +/// +/// A chave legada `visits_pull` (global) fica ÓRFÃ DE PROPÓSITO (D3): nunca +/// mais é lida nem apagada. Adotá-la daria o cursor de um ACS a outro; começar +/// do epoch só custa um pull completo, idempotente pelo dedupe de `localId`. class SyncCursorStore { + /// Cria o próprio [VisitDatabase]. SyncCursorStore({ required DatabaseKeyStore keyStore, - this.databaseName = 'sinalacs_acs.db', - this.allowUnencryptedForTesting = false, - }) : _keyStore = keyStore; + required String owner, + String databaseName = 'sinalacs_acs.db', + bool allowUnencryptedForTesting = false, + }) : _key = _keyFor(owner), + _database = VisitDatabase( + keyStore: keyStore, + databaseName: databaseName, + allowUnencryptedForTesting: allowUnencryptedForTesting, + ); + + /// Visão de [owner] sobre um banco compartilhado com a fila de visitas. + SyncCursorStore.on(VisitDatabase database, {required String owner}) + : _key = _keyFor(owner), + _database = database; static const _table = 'sync_cursor'; - static const _visitsPullKey = 'visits_pull'; - final DatabaseKeyStore _keyStore; - final String databaseName; - final bool allowUnencryptedForTesting; - Database? _database; + static String _keyFor(String owner) => 'visits_pull|${requireOwnerId(owner)}'; - Future _open() async { - final existing = _database; - if (existing != null && existing.isOpen) return existing; - final passphrase = await _keyStore.readOrCreate(); - return _database = await EncryptedLocalDatabase.open( - databaseName: databaseName, - passphrase: passphrase, - allowUnencryptedForTesting: allowUnencryptedForTesting, - ); - } + final String _key; + final VisitDatabase _database; - /// Devolve o `since` da última chamada bem-sucedida a `visits.pull`, ou - /// `null` na primeira sincronização — quem chama trata isso como "desde o - /// início dos tempos" (ou uma janela inicial definida por produto), nunca - /// como erro. + /// Devolve o `since` da última chamada bem-sucedida a `visits.pull` deste + /// dono, ou `null` na primeira sincronização — quem chama trata isso como + /// "desde o início dos tempos", nunca como erro. Future read() async { - final db = await _open(); - final rows = await db.query(_table, where: 'key = ?', whereArgs: [_visitsPullKey]); + final db = await _database.open(); + final rows = await db.query(_table, where: 'key = ?', whereArgs: [_key]); if (rows.isEmpty) return null; return DateTime.parse(rows.first['value'] as String); } - /// Grava o cursor, substituindo o valor anterior. + /// Grava o cursor deste dono, substituindo o valor anterior. Future write(DateTime since) async { - final db = await _open(); + final db = await _database.open(); await db.insert( _table, - {'key': _visitsPullKey, 'value': since.toUtc().toIso8601String()}, + {'key': _key, 'value': since.toUtc().toIso8601String()}, conflictAlgorithm: ConflictAlgorithm.replace, ); } diff --git a/apps/acs/lib/core/network/backend_client.dart b/apps/acs/lib/core/network/backend_client.dart index a053196b..e3a77b90 100644 --- a/apps/acs/lib/core/network/backend_client.dart +++ b/apps/acs/lib/core/network/backend_client.dart @@ -1,13 +1,23 @@ +import 'dart:async'; import 'dart:io'; import 'package:sinalacs_acs/core/network/auth_session.dart'; import 'package:sinalacs_acs/core/network/backend_config.dart'; +import 'package:sinalacs_acs/core/security/session_token_store.dart'; +import 'package:sinalacs_acs/core/security/upload_token_store.dart'; import 'package:sinalacs_client/sinalacs_client.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. +/// A sessão corrente não é a dona das visitas a enviar. Recuperável: o lote +/// fica pendente até o dono entrar de novo. +const sessionOwnerMismatch = BackendFailure( + 'A sessão atual não é a dona destas visitas. Entre com a conta que ' + 'registrou as visitas.', +); + class BackendFailure implements Exception { const BackendFailure(this.message, {this.isRecoverable = true}); @@ -20,6 +30,24 @@ class BackendFailure implements Exception { String toString() => message; } +/// O servidor recusou o token de envio diferido (D7): desconhecido, vencido, +/// revogado, de outro aparelho ou de conta inativa — a recusa é única e não diz +/// o motivo. Não recuperável: o token morreu, e só um novo login do dono emite +/// outro. As visitas do dono continuam no aparelho. +class UploadTokenRefused extends BackendFailure { + const UploadTokenRefused() : super('Envio não autorizado. Entre novamente.', isRecoverable: false); +} + +/// O ACS tem MFA ativa e a senha conferiu: falta o código do autenticador. +class MfaCodeRequired extends BackendFailure { + const MfaCodeRequired() : super('Informe o código do aplicativo autenticador.'); +} + +/// O servidor exige MFA e este ACS ainda não a ativou. +class MfaEnrollmentRequired extends BackendFailure { + const MfaEnrollmentRequired() : super('Ative a verificação em duas etapas antes de entrar.', isRecoverable: false); +} + /// Recusa um host de RPC que não esteja em **HTTPS** (RNF04/L-08). /// /// Existe porque a rede do sistema operacional **não** segura mais nada: a Task @@ -54,12 +82,43 @@ abstract class AcsBackend { bool get isAuthenticated; + /// Chamado quando a sessão venceu e **não** dá para renová-la sozinho (o + /// refresh token foi recusado ou não existe mais). A UI leva a pessoa de + /// volta ao login. + void Function()? onSessionExpired; + + /// `true` se há um refresh token guardado no aparelho (a UI pode tentar + /// [resumeSession] na partida). + Future get hasStoredSession; + + /// Desbloqueia o refresh token guardado (prompt do Keystore, se o store o + /// exige). `notRequired`: o chamador usa o portão de interface. + Future unlockStoredSession({required String reason}); + + /// Tenta abrir uma sessão com o refresh token guardado. `null` se não há + /// token ou se ele foi recusado/não pôde ser usado agora. Não chama + /// [onSessionExpired]: na partida a UI já é a tela de login. + Future resumeSession(); + + /// Encerra a sessão: revoga o refresh token no servidor (best-effort) e + /// apaga o token local. + Future logout(); + /// Autentica o ACS com matrícula e senha (RF07). /// - /// A credencial fica **apenas em memória** — ver `_credentials` em - /// [BackendClient] — para a reautenticação silenciosa que o app já fazia - /// quando o token de 15 minutos expirava. Nada é gravado em disco. - Future login({required String matricula, required String senha}); + /// A senha **não** é retida: a renovação silenciosa usa o refresh token + /// rotativo guardado no Keystore. + /// + /// [totpCode] é o código do autenticador (MFA); sem ele, um ACS com MFA ativa + /// recebe [MfaCodeRequired]. + Future login({required String matricula, required String senha, String? totpCode}); + + /// Começa a ativação da verificação em duas etapas. Sem token: vale a + /// matrícula e a senha. + Future beginTotpEnrollment({required String matricula, required String senha}); + + /// Conclui a ativação com o primeiro código do autenticador. + Future confirmTotpEnrollment({required String matricula, required String senha, required String code}); /// Login de DESENVOLVIMENTO, para `tool/` e `integration_test/` contra a /// stack local. Só funciona com `ENABLE_DEV_LOGIN=true`; **não** é o caminho @@ -68,7 +127,30 @@ abstract class AcsBackend { Future acknowledge({required String alertId}); - Future> syncVisits(List visits); + /// [expectedUserId], quando presente, é o dono do lote: se a sessão que vai + /// assinar a chamada — conferida DEPOIS de o token ser resolvido (talvez por + /// renovação) e imediatamente antes da rede — não é dele, lança + /// [sessionOwnerMismatch] sem enviar nada. O servidor atribui a visita ao ACS + /// do token: o lote de A nunca pode sair com o token de B. + Future> syncVisits(List visits, {String? expectedUserId}); + + /// Envia visitas LEGADAS (sem dono, D4) por `visits.syncLegacy`. A sessão + /// atual é só o transporte: o servidor grava autoria desconhecida e confere + /// o território do paciente contra o de quem transporta. No máximo 200 por + /// chamada (o servidor recusa acima disso). + Future> syncLegacyVisits(List visits, {required String deviceId}); + + /// Envio DIFERIDO (D7) por `visits.syncDeferred`: sem sessão, autenticado + /// pelo token de envio do dono — o autor gravado é o dono do token. No máximo + /// 200 por chamada. Token recusado: [UploadTokenRefused]. + Future> syncDeferredVisits({ + required String uploadToken, + required String deviceId, + required List visits, + }); + + /// Revoga o token de envio no servidor (a fila do dono zerou). Idempotente. + Future revokeUploadToken(String uploadToken); /// Visitas da microárea alteradas desde `since` — reconciliação /// central→dispositivo (RF15, decisão §5). @@ -81,6 +163,23 @@ abstract class AcsBackend { /// partir fora do caminho reativo. Future> listPatients(); + /// Contato da UBS do ACS (RF13): nome e telefone, que pode não estar cadastrado. + Future ubsContact(); + + /// Convite de onboarding de um paciente da própria microárea (RF02). O + /// token em claro volta só nesta resposta e vira o QR Code da tela + /// "Convidar paciente" — nunca é gravado no aparelho. + Future generateInvite({required String patientId}); + + /// Envia um aviso comunitário aos pacientes da microárea do ACS que aceitaram + /// receber avisos (RF14). A microárea vem do token, nunca de parâmetro; + /// [chronicOnly] restringe a quem tem condição crônica. + Future sendNotice({ + required String title, + required String message, + required bool chronicOnly, + }); + void close(); } @@ -105,18 +204,44 @@ class MisconfiguredBackend implements AcsBackend { /// O motivo, como [requireSecureHost] o produziu. final BackendFailure failure; + @override + void Function()? get onSessionExpired => null; + + @override + set onSessionExpired(void Function()? _) {} + @override AuthSession? get session => null; @override bool get isAuthenticated => false; + @override + Future get hasStoredSession async => false; + + @override + Future unlockStoredSession({required String reason}) async => SessionUnlock.notRequired; + + @override + Future resumeSession() async => null; + + @override + Future logout() async {} + /// Recusa uma chamada. `Never` avisa o analisador de que nada abaixo disto /// executa, então cada método abaixo é uma linha só. Never _recusar() => throw failure; @override - Future login({required String matricula, required String senha}) async => + Future login({required String matricula, required String senha, String? totpCode}) async => + _recusar(); + + @override + Future beginTotpEnrollment({required String matricula, required String senha}) async => + _recusar(); + + @override + Future confirmTotpEnrollment({required String matricula, required String senha, required String code}) async => _recusar(); @override @@ -126,7 +251,23 @@ class MisconfiguredBackend implements AcsBackend { Future acknowledge({required String alertId}) async => _recusar(); @override - Future> syncVisits(List visits) async => _recusar(); + Future> syncVisits(List visits, {String? expectedUserId}) async => + _recusar(); + + @override + Future> syncLegacyVisits(List visits, {required String deviceId}) async => + _recusar(); + + @override + Future> syncDeferredVisits({ + required String uploadToken, + required String deviceId, + required List visits, + }) async => + _recusar(); + + @override + Future revokeUploadToken(String uploadToken) async => _recusar(); @override Future> pullVisits({required DateTime since}) async => _recusar(); @@ -134,6 +275,20 @@ class MisconfiguredBackend implements AcsBackend { @override Future> listPatients() async => _recusar(); + @override + Future ubsContact() async => _recusar(); + + @override + Future generateInvite({required String patientId}) async => _recusar(); + + @override + Future sendNotice({ + required String title, + required String message, + required bool chronicOnly, + }) async => + _recusar(); + /// Fechar **não** é uma chamada ao backend: não há o que fechar, e um `close` /// que lançasse derrubaria o `finally` de quem só queria encerrar. @override @@ -148,8 +303,19 @@ class BackendClient implements AcsBackend { /// aceita às cegas. Nunca instale um `badCertificateCallback` que aceite tudo: /// este app já faz verificação de hostname no MQTT e ceder aqui anularia /// RNF04 no ponto onde ele mais importa. - BackendClient({String? host, List? trustedCaBytes}) - : _client = Client( + BackendClient({ + String? host, + List? trustedCaBytes, + SessionTokenStore? tokenStore, + DeviceIdStore? deviceIds, + UploadTokenStore? uploadTokens, + // Sem store, só memória: o Keystore (`secure_session_token_store.dart`) + // depende do Flutter e este arquivo roda também na VM (`tool/live_check.dart`). + // Quem liga o Keystore é o `main.dart`; sem ele, nada sobrevive ao app fechar. + }) : _tokenStore = tokenStore ?? MemorySessionTokenStore(), + _deviceIds = deviceIds ?? MemoryDeviceIdStore(), + _uploadTokens = uploadTokens ?? MemoryUploadTokenStore(), + _client = Client( // Só o host que veio do `--dart-define` (o default de compilação) é // validado. Um host **explícito** passa como veio: é o caminho dos // testes herméticos, que apontam para servidores fake em @@ -179,18 +345,20 @@ class BackendClient implements AcsBackend { AuthSession? _session; - /// Credencial em memória, para reautenticar quando o token de 15 min expira. - /// - /// **Só em memória, nunca em disco.** É o que preserva o comportamento que o - /// app já tinha (reauth silencioso, que antes chamava `developmentLogin`) sem - /// persistir senha nenhuma. A correção durável é o refresh token rotativo que - /// `spec/lgpd_design.md` LGPD-RT06 exige e que este plano deliberadamente não - /// implementa — ver a lacuna registrada no plano. - ({String matricula, String senha})? _credentials; + final SessionTokenStore _tokenStore; + final DeviceIdStore _deviceIds; + + /// Tokens de envio diferido, um por dono (D7). O login grava; o logout NÃO + /// apaga — quem revoga e apaga é o envio diferido, quando a fila do dono + /// zera. + final UploadTokenStore _uploadTokens; @override AuthSession? get session => _session; + @override + void Function()? onSessionExpired; + @override bool get isAuthenticated { final current = _session; @@ -203,13 +371,18 @@ class BackendClient implements AcsBackend { /// Reautentica sozinho quando o token de 15 minutos expirou — sem isso, um /// turno de campo longo passa a falhar com erro de permissão, que esconde a /// causa real. - Future _requireToken() async { + Future _requireToken() async => (await _requireSession()).accessToken; + + /// Sessão válida (renovada se preciso). Quem precisa conferir o DONO do token + /// usa esta: o token enviado é o desta mesma sessão, sem janela entre a + /// conferência e a leitura. + Future _requireSession() async { if (!isAuthenticated) await renewSession(); final current = _session; if (current == null) { throw const BackendFailure('Sessão não iniciada.', isRecoverable: false); } - return current.accessToken; + return current; } /// Autentica contra `auth.loginInstitutional` (RF07). @@ -220,11 +393,16 @@ class BackendClient implements AcsBackend { Future login({ required String matricula, required String senha, + String? totpCode, }) async { + final deviceId = await _storage(_deviceIds.readOrCreate); final result = await _guard( () => _client.auth.loginInstitutional( matricula: matricula, password: senha, + totpCode: totpCode, + // O servidor só emite refresh token a quem informa o aparelho. + deviceId: deviceId, ), ); @@ -236,35 +414,278 @@ class BackendClient implements AcsBackend { ); } - _credentials = (matricula: matricula, senha: senha); - _session = session; + final refreshToken = result.refreshToken; + final uploadToken = result.uploadToken; + // Incremento da época, gravação do token e troca da sessão numa só seção + // crítica: uma renovação (talvez de OUTRO ACS, "Entrar com senha" no + // bloqueio) que já estava em voo fica obsoleta e não grava nada por cima + // deste login (INV-01/RNF06); uma que comece agora espera a seção acabar e + // lê o token NOVO — antes, ela lia o token antigo na janela entre o + // incremento e a troca da sessão e terminava "não obsoleta". + await _exclusive(() async { + _invalidateRenewal(); + // Grava o refresh token ANTES de expor a sessão. Sem token na resposta, + // não deixa um token velho de outra conta para trás. + try { + if (refreshToken == null || refreshToken.isEmpty) { + await _tokenStore.clear(); + } else { + await _tokenStore.write(refreshToken); + } + } catch (_) { + throw _storeLost; + } + // Token de envio diferido (D7), na MESMA seção: nunca fica gravado sob + // um dono com a sessão de outro. Só grava quando veio (o servidor só + // emite com `deviceId` real); resposta sem ele não apaga o do mesmo + // dono, que pode ainda ter visitas a subir. Falha do Keystore aqui não + // derruba o login: sem o token, as visitas deste dono só sobem pela + // sessão dele, como antes. + if (uploadToken != null && uploadToken.isNotEmpty) { + try { + await _uploadTokens.write(session.userId, uploadToken); + } catch (_) {} + } + _session = session; + }); return session; } - /// Reautentica usando a credencial em memória. + /// Ativação da MFA: sem `_requireToken()` (ainda não há token) e sem reter a + /// senha. + @override + Future beginTotpEnrollment({ + required String matricula, + required String senha, + }) => + _guard(() => _client.auth.beginTotpEnrollment(matricula: matricula, password: senha)); + + @override + Future confirmTotpEnrollment({ + required String matricula, + required String senha, + required String code, + }) => + _guard(() => _client.auth.confirmTotpEnrollment(matricula: matricula, password: senha, code: code)); + + Future? _renewal; + + /// Geração da sessão. Login e logout a incrementam; uma renovação que + /// começou numa geração anterior é **obsoleta** e, ao terminar, não grava o + /// token, não expõe a sessão e não avisa a UI. Sem isto, a renovação lenta + /// do ACS A terminava depois do login do ACS B e sobrescrevia o token e a + /// sessão de B: o painel de B passava a usar o JWT de A (INV-01/RNF06). + int _epoch = 0; + + /// Torna obsoleta qualquer renovação em voo. A próxima chamada a + /// [renewSession] não se junta a ela: abre uma nova, na geração atual. /// - /// Sem credencial guardada não há como renovar: quem chama recebe uma falha - /// não recuperável e a tela precisa mandar a pessoa entrar de novo. - Future renewSession() async { - final credentials = _credentials; - if (credentials == null) { + /// Só pode ser chamada dentro de [_exclusive]. + void _invalidateRenewal() { + _epoch++; + _renewal = null; + } + + /// Cauda da fila de seções críticas. Sempre completa sem erro. + Future _commitTail = Future.value(); + + /// Seção crítica **serial e não reentrante**: só uma por vez mexe em + /// `_session`, no token do Keystore e em `_epoch`. Dentro dela: apenas E/S + /// local e atribuições. Nunca rede, nunca `renewSession()`, nunca outro + /// `_exclusive` (deadlock). Fecha a corrida em que uma renovação do usuário + /// anterior, iniciada entre o incremento da época do login e a troca da + /// sessão, lia o token antigo e sobrescrevia o login novo. + Future _exclusive(Future Function() body) { + final done = Completer(); + final previous = _commitTail; + _commitTail = done.future; + return previous.then((_) => body()).whenComplete(done.complete); + } + + /// A renovação obsoleta termina assim: recuperável (repetir usa a sessão + /// atual), sem `onSessionExpired` e sem tocar no token de quem entrou depois. + /// A família rotacionada por ela fica abandonada no servidor (vence sozinha + /// ou cai junto no logout). + static const _staleRenewal = BackendFailure('A sessão mudou neste aparelho. Tente de novo.'); + + /// Renova o JWT com o refresh token guardado. **Single-flight**: chamadas + /// simultâneas compartilham UMA renovação (o token rotativo só vale uma vez). + Future renewSession({bool notify = true}) { + final pending = _renewal; + if (pending != null) return pending; + late final Future flight; + flight = _doRenew(notify: notify).whenComplete(() { + // Só limpa se ainda é ela: um login no meio já trocou (ou zerou) o voo. + if (identical(_renewal, flight)) _renewal = null; + }); + return _renewal = flight; + } + + Future _doRenew({required bool notify}) async { + // Época e token lidos JUNTOS, dentro da seção: um login que esteja no meio + // (época já incrementada, token novo ainda não gravado) termina antes, e + // esta renovação lê o token dele — nunca o da conta anterior. + late final int epoch; + late final String? token; + await _exclusive(() async { + epoch = _epoch; + token = await _storage(_tokenStore.read); + if (token == null) { + _expire(notify); + throw const BackendFailure('Sua sessão expirou. Entre novamente.', isRecoverable: false); + } + }); + bool stale() => epoch != _epoch; + final refreshToken = token!; + // Fora do `_guard`: falha do armazenamento não é "sem conexão". Fora da + // seção também, como a rede abaixo: nada disto mexe em sessão ou token. + final deviceId = await _storage(_deviceIds.readOrCreate); + // `null` = o servidor recusou o refresh token. Tratado aqui, antes de o + // `_guard` transformar a exceção em "sem conexão". + // Falha de rede/timeout sai pelo `_guard` como recuperável, sem apagar o + // token: a repetição cai na tolerância de 30 s do servidor. + final result = await _guard(() async { + try { + return await _client.auth.refreshSession(refreshToken: refreshToken, deviceId: deviceId); + } on SessionExpiredException { + return null; + } + }); + // Confirmação dentro da seção: a checagem de obsolescência é definitiva + // (ninguém intercala entre ela e as gravações abaixo). + return _exclusive(() async { + // Obsoleta: o token guardado agora é de outro login (ou foi apagado pelo + // logout). Nem a recusa nem o filho podem mexer nele. + if (stale()) throw _staleRenewal; + if (result == null) { + try { + await _tokenStore.clear(); + } catch (_) { + // Best-effort: a sessão acabou de qualquer jeito. + } + _expire(notify); + throw const BackendFailure( + 'Sua sessão expirou. Entre novamente com o código do autenticador.', + isRecoverable: false, + ); + } + final next = result.refreshToken; + final hasNext = next != null && next.isNotEmpty; + // Grava o filho ANTES de qualquer outra coisa: o servidor já rotacionou, + // e perder o filho é perder o turno — mesmo se o JWT não puder ser lido. + if (hasNext) { + try { + await _tokenStore.write(next); + } catch (_) { + // O token novo se perdeu e o antigo já foi gasto: sessão acabou. + try { + await _tokenStore.clear(); + } catch (_) {} + _expire(notify); + throw _storeLost; + } + } + final session = AuthSession.tryParse(result.accessToken, result.tokenType); + if (session == null || !hasNext) { + throw const BackendFailure( + 'O servidor devolveu um token que o aplicativo não entendeu.', + isRecoverable: false, + ); + } + return _session = session; + }); + } + + static const _storeLost = BackendFailure( + 'Não foi possível guardar a sessão neste aparelho. Entre novamente.', + isRecoverable: false, + ); + + /// Executa uma operação do armazenamento seguro; qualquer exceção vira + /// [BackendFailure] (nunca uma `PlatformException` crua na UI). + Future _storage(Future Function() op) async { + try { + return await op(); + } catch (_) { throw const BackendFailure( - 'Sua sessão expirou. Entre novamente.', + 'Não foi possível acessar o armazenamento seguro deste aparelho.', isRecoverable: false, ); } - return login( - matricula: credentials.matricula, - senha: credentials.senha, - ); + } + + void _expire(bool notify) { + _session = null; + if (notify) onSessionExpired?.call(); + } + + @override + Future get hasStoredSession async { + try { + return await _tokenStore.contains(); + } catch (_) { + return false; + } + } + + @override + Future unlockStoredSession({required String reason}) async { + try { + return await _tokenStore.unlock(reason: reason); + } catch (_) { + return SessionUnlock.unavailable; // falha fechada + } + } + + @override + Future resumeSession() async { + try { + if (await _tokenStore.read() == null) return null; + return await renewSession(notify: false); + } catch (_) { + return null; + } + } + + @override + Future logout() async { + // Primeira seção, ANTES da chamada de rede: a renovação em voo fica + // obsoleta e não regrava o token nem ressuscita a sessão quando terminar. + final token = await _exclusive(() async { + _invalidateRenewal(); + _session = null; + try { + return await _tokenStore.read(); + } catch (_) { + return null; + } + }); + // A rede fica fora da seção: uma renovação pode começar enquanto isto + // espera — e ainda achar o token guardado. + if (token != null) { + try { + await _client.auth.logout(refreshToken: token); + } catch (_) { + // Best-effort: o aparelho pode estar sem rede. O token local sai de + // qualquer jeito. + } + } + // Segunda seção, DEPOIS da rede: uma renovação que começou durante a + // chamada de logout (ainda achou o token guardado) também fica obsoleta, e + // a confirmação dela, que só roda depois desta seção, vê isso. + await _exclusive(() async { + _session = null; + _invalidateRenewal(); + await _storage(_tokenStore.clear); + }); } /// Login de desenvolvimento, para `tool/` e `integration_test/`. /// /// É a versão antiga de [login], preservada porque as ferramentas rodam /// contra a stack local com `ENABLE_DEV_LOGIN=true` e não têm — nem devem ter - /// — a senha institucional embutida. Não guarda `_credentials`: sem - /// credencial não há renovação, e quem usar isto em produção recebe a falha + /// — a senha institucional embutida. Não grava refresh token: sem + /// ele não há renovação, e quem usar isto em produção recebe a falha /// não recuperável de [renewSession] quando o token expirar. @override Future developmentLogin({required String role}) async { @@ -280,7 +701,10 @@ class BackendClient implements AcsBackend { ); } - _session = session; + await _exclusive(() async { + _invalidateRenewal(); + _session = session; + }); return session; } @@ -303,8 +727,12 @@ class BackendClient implements AcsBackend { /// O lote inteiro roda em uma transação no servidor, e cada visita volta com /// o próprio `syncStatus`: o app casa pelo `localId`, nunca pela posição. @override - Future> syncVisits(List visits) async { - final token = await _requireToken(); + Future> syncVisits(List visits, {String? expectedUserId}) async { + final current = await _requireSession(); + // Depois da resolução do token (a renovação pode ter esperado o login de + // OUTRO ACS e devolvido a sessão dele) e imediatamente antes da rede. + if (expectedUserId != null && current.userId != expectedUserId) throw sessionOwnerMismatch; + final token = current.accessToken; return _guard( () => _client.visits.sync(accessToken: token, visits: visits), // `visits.sync` reaproveita AlertPermissionException para token inválido @@ -315,6 +743,35 @@ class BackendClient implements AcsBackend { ); } + @override + Future> syncLegacyVisits(List visits, {required String deviceId}) async { + final token = await _requireToken(); + return _guard( + () => _client.visits.syncLegacy(accessToken: token, deviceId: deviceId, visits: visits), + permissionMessage: 'Sua sessão não autoriza sincronizar visitas. Entre novamente.', + ); + } + + /// Sem `_requireToken()`: o dono do token de envio pode já ter saído. + @override + Future> syncDeferredVisits({ + required String uploadToken, + required String deviceId, + required List visits, + }) => + _guard(() async { + try { + return await _client.visits.syncDeferred(uploadToken: uploadToken, deviceId: deviceId, visits: visits); + } on SessionExpiredException { + // A recusa única do servidor para o token de envio. + throw const UploadTokenRefused(); + } + }); + + @override + Future revokeUploadToken(String uploadToken) => + _guard(() => _client.visits.revokeUploadToken(uploadToken: uploadToken)); + /// Reconciliação central→dispositivo: visitas da microárea alteradas desde /// `since` (RF15, decisão §5). Espelha [syncVisits] na tradução de falhas. @override @@ -333,6 +790,44 @@ class BackendClient implements AcsBackend { ); } + @override + Future ubsContact() async { + final token = await _requireToken(); + return _guard(() => _client.ubs.myContact(accessToken: token)); + } + + @override + Future generateInvite({required String patientId}) async { + final token = await _requireToken(); + return _guard( + () => _client.onboarding.generateEnrollmentToken( + accessToken: token, + patientId: patientId, + ), + // O servidor recusa com `AlertPermissionException` quando o paciente + // não é da microárea do ACS (INV-01) — "este alerta" não faria sentido. + permissionMessage: 'Este paciente não pertence à sua microárea.', + ); + } + + @override + Future sendNotice({ + required String title, + required String message, + required bool chronicOnly, + }) async { + final token = await _requireToken(); + return _guard( + () => _client.notices.sendSegmented( + accessToken: token, + title: title, + message: message, + audience: chronicOnly ? 'chronic' : 'everyone', + ), + permissionMessage: 'Somente o ACS pode enviar avisos à comunidade.', + ); + } + @override void close() => _client.close(); @@ -346,11 +841,18 @@ class BackendClient implements AcsBackend { }) async { try { return await call(); + } on BackendFailure { + // Já traduzida por quem chamou (ex.: [UploadTokenRefused]). + rethrow; } on EndpointDisabledException { throw const BackendFailure( 'O acesso de desenvolvimento está desativado neste servidor.', isRecoverable: false, ); + } on MfaRequiredException { + throw const MfaCodeRequired(); + } on MfaEnrollmentRequiredException { + throw const MfaEnrollmentRequired(); } on AuthenticationFailedException catch (error) { // A mensagem vem do servidor de propósito: é ela que diferencia "senha // inválida" de "acesso bloqueado por tentativas" — e é igual para @@ -366,6 +868,12 @@ class BackendClient implements AcsBackend { ); } on AlertValidationException catch (error) { throw BackendFailure(error.message, isRecoverable: false); + } on DataRightsException catch (error) { + // Recusa de negócio com texto pronto (aviso vazio ou longo demais). + throw BackendFailure(error.message, isRecoverable: false); + } on NoticeDeliveryException catch (error) { + // O relé de push está fora do ar: tentar de novo faz sentido. + throw BackendFailure(error.message); } on AlertDispatchUnavailableException { throw const BackendFailure( 'Registrado. A rede está instável e a confirmação será entregue assim ' diff --git a/apps/acs/lib/core/network/backend_config.dart b/apps/acs/lib/core/network/backend_config.dart index 9392d13e..ddf40b42 100644 --- a/apps/acs/lib/core/network/backend_config.dart +++ b/apps/acs/lib/core/network/backend_config.dart @@ -75,6 +75,12 @@ class BackendConfig { /// mosquitto-init e não é versionada. static const String mqttCaAsset = 'assets/certs/dev_ca.crt'; + /// Certificado e chave de CLIENTE para o mTLS do broker (spec/stack.md). + /// **Só desenvolvimento**: a chave privada num asset serve para a stack local; em + /// produção o certificado é provisionado por aparelho (aberto). + static const mqttClientCertAsset = 'assets/certs/acs_client.crt'; + static const mqttClientKeyAsset = 'assets/certs/acs_client.key'; + /// CA que assina o certificado do Traefik em :443 (RNF04). /// /// Não confundir com [mqttCaAsset], que é a CA do broker. São duas, e o diff --git a/apps/acs/lib/core/security/auth_bound_session_token_store.dart b/apps/acs/lib/core/security/auth_bound_session_token_store.dart new file mode 100644 index 00000000..c6698af5 --- /dev/null +++ b/apps/acs/lib/core/security/auth_bound_session_token_store.dart @@ -0,0 +1,145 @@ +import 'dart:async'; + +import 'keystore_vault.dart'; +import 'session_token_store.dart'; + +/// Refresh token do ACS cuja decifra só acontece depois de uma autenticação +/// biométrica/PIN imposta pelo Keystore (TEE), não por um portão de interface. +/// +/// - `write` cifra com a chave pública (sem prompt: a rotação de 15 min segue +/// silenciosa) e mantém o valor em RAM. +/// - `read` devolve só a RAM e **nunca** abre prompt (é chamado dentro de +/// `BackendClient._exclusive`). +/// - `unlock` é o único lugar que decifra, uma vez por partida a frio. +/// +/// Sem suporte (sem bloqueio de tela, API < 30 sem biometria forte) ou com +/// falha ao selar, o token fica só em RAM: a próxima partida a frio exige login +/// completo. API 24–29 sem biometria forte: o token fica só em RAM e morre com +/// o processo; a partida a frio exige login completo. É recurso de segurança +/// para aparelhos antigos, não falha. Nunca registre o valor em log. +class AuthBoundSessionTokenStore implements SessionTokenStore { + AuthBoundSessionTokenStore({ + required KeystoreVault vault, + required SessionTokenStore legacy, + Duration unsealTimeout = defaultUnsealTimeout, + }) : _vault = vault, + _legacy = legacy, + _unsealTimeout = unsealTimeout; + + static const alias = 'acs_refresh_token'; + + /// Teto generoso para o prompt: se o lado nativo nunca responder (ex.: o + /// BiometricPrompt descartou o pedido em silêncio), `_unlocking` não pode + /// ficar preso para sempre — senão todo unlock seguinte esperaria o mesmo + /// future e o portão travaria até matar o processo. + static const defaultUnsealTimeout = Duration(minutes: 2); + + final Duration _unsealTimeout; + + final KeystoreVault _vault; + final SessionTokenStore _legacy; + String? _cache; + Future? _unlocking; + // Ligado quando a migração do legado terminou (ou não havia legado): daí em + // diante `contains()` só consulta o cofre, sem tocar no armazenamento legado. + bool _migrated = false; + + @override + Future read() async => _cache; + + @override + Future contains() async { + if (_cache != null) return true; + if (!await _vault.isSupported) return false; + if (!_migrated) await _migrateLegacy(); + return _vault.contains(alias); + } + + @override + Future unlock({required String reason}) { + if (_cache != null) return Future.value(SessionUnlock.unlocked); + return _unlocking ??= _unlock(reason).whenComplete(() => _unlocking = null); + } + + Future _unlock(String reason) async { + try { + await _migrateLegacy(); + final token = await _vault + .unseal(alias, reason: reason) + .timeout(_unsealTimeout); + if (token == null || token.isEmpty) return SessionUnlock.unavailable; + _cache = token; + return SessionUnlock.unlocked; + } on VaultException catch (e) { + switch (e.failure) { + case VaultFailure.cancelled: + return SessionUnlock.cancelled; + case VaultFailure.lockedOut: + return SessionUnlock.lockedOut; + case VaultFailure.invalidated: + // Chave morta (bloqueio de tela removido; em API < 30 também uma + // digital nova) ou blob corrompido: o token é irrecuperável. + await _vault.delete(alias); + return SessionUnlock.unavailable; + case VaultFailure.unavailable: + return SessionUnlock.unavailable; + } + } on TimeoutException { + // Sem resposta do cofre: trata como cancelado e NÃO apaga o token; a + // próxima tentativa abre um prompt novo. + return SessionUnlock.cancelled; + } + } + + @override + Future write(String token) async { + _cache = token; + _migrated = true; // o legado é apagado abaixo; nada mais a migrar + try { + if (await _vault.isSupported) { + await _vault.seal(alias, token); + // Best-effort: o token já está selado; falhar aqui não pode expirar a sessão. + try { + await _legacy.clear(); + } catch (_) {} + return; + } + } on VaultException { + // Degrada para RAM. + } + // Sem cofre: não deixa um blob antigo ressuscitar um token já rotacionado. + await _vault.delete(alias); + await _legacy.clear(); + } + + @override + Future clear() async { + _cache = null; + await _vault.delete(alias); + await _legacy.clear(); + } + + /// Instalações antigas guardam o token no `flutter_secure_storage`. Sela-o + /// (sem prompt) e apaga o original; o `unseal` seguinte já pede biometria. + Future _migrateLegacy() async { + if (_migrated) return; + final old = await _legacy.read(); + if (old == null || old.isEmpty) { + _migrated = true; + return; + } + if (await _vault.contains(alias)) { + await _legacy.clear(); + _migrated = true; + return; + } + try { + await _vault.seal(alias, old); + await _legacy.clear(); + _migrated = true; + } on VaultException { + // Mantém o legado e NÃO liga o flag: a próxima chamada tenta de novo. + // Melhor um token no Keystore comum que perder a sessão. + } + } +} diff --git a/apps/acs/lib/core/security/biometric_gate.dart b/apps/acs/lib/core/security/biometric_gate.dart new file mode 100644 index 00000000..c896a7ba --- /dev/null +++ b/apps/acs/lib/core/security/biometric_gate.dart @@ -0,0 +1,50 @@ +import 'package:local_auth/local_auth.dart'; + +/// Resultado de uma tentativa de desbloqueio local. +enum UnlockResult { unlocked, cancelled, unavailable, lockedOut } + +/// Porta do desbloqueio local (digital/rosto OU bloqueio de tela do aparelho). +abstract interface class BiometricGate { + /// `true` se o aparelho tem biometria cadastrada OU bloqueio de tela seguro. + Future get isAvailable; + Future authenticate({required String reason}); +} + +/// Implementação sobre `local_auth` 3.x. Nunca registra a causa do erro. +class LocalAuthBiometricGate implements BiometricGate { + LocalAuthBiometricGate([LocalAuthentication? auth]) : _auth = auth ?? LocalAuthentication(); + + final LocalAuthentication _auth; + + @override + Future get isAvailable async { + try { + return await _auth.isDeviceSupported(); + } catch (_) { + return false; + } + } + + @override + Future authenticate({required String reason}) async { + try { + final ok = await _auth.authenticate(localizedReason: reason, persistAcrossBackgrounding: true); + return ok ? UnlockResult.unlocked : UnlockResult.cancelled; + } on LocalAuthException catch (e) { + return switch (e.code) { + LocalAuthExceptionCode.temporaryLockout || + LocalAuthExceptionCode.biometricLockout => + UnlockResult.lockedOut, + LocalAuthExceptionCode.noCredentialsSet || + LocalAuthExceptionCode.noBiometricsEnrolled || + LocalAuthExceptionCode.noBiometricHardware || + LocalAuthExceptionCode.biometricHardwareTemporarilyUnavailable || + LocalAuthExceptionCode.uiUnavailable => + UnlockResult.unavailable, + _ => UnlockResult.cancelled, + }; + } catch (_) { + return UnlockResult.unavailable; + } + } +} diff --git a/apps/acs/lib/core/security/keystore_vault.dart b/apps/acs/lib/core/security/keystore_vault.dart new file mode 100644 index 00000000..93cddfaf --- /dev/null +++ b/apps/acs/lib/core/security/keystore_vault.dart @@ -0,0 +1,140 @@ +import 'package:flutter/services.dart'; + +enum VaultFailure { cancelled, lockedOut, invalidated, unavailable } + +class VaultException implements Exception { + const VaultException(this.failure); + final VaultFailure failure; + // Sem mensagem de propósito: nunca carregar detalhe do Keystore para logs. + @override + String toString() => 'VaultException($failure)'; +} + +/// Cofre cuja decifra exige autenticação do usuário imposta pelo Keystore. +abstract interface class KeystoreVault { + Future get isSupported; + Future contains(String alias); + + /// Cifra com a chave pública: NÃO abre prompt. + Future seal(String alias, String plaintext); + + /// Abre o prompt biométrico/PIN e decifra. `null` se não há blob. + /// Lança [VaultException]. + Future unseal(String alias, {required String reason}); + + Future delete(String alias); +} + +class MethodChannelKeystoreVault implements KeystoreVault { + MethodChannelKeystoreVault([MethodChannel? channel]) + : _channel = + channel ?? + const MethodChannel('br.com.prismrr.sinalacs.acs/keystore_vault'); + + final MethodChannel _channel; + + @override + Future get isSupported async { + try { + return await _channel.invokeMethod('isSupported') ?? false; + } catch (_) { + return false; + } + } + + @override + Future contains(String alias) async { + try { + return await _channel.invokeMethod('contains', {'alias': alias}) ?? + false; + } catch (_) { + return false; + } + } + + @override + Future seal(String alias, String plaintext) async { + try { + await _channel.invokeMethod('seal', { + 'alias': alias, + 'plaintext': plaintext, + }); + } on PlatformException { + throw const VaultException(VaultFailure.unavailable); + } on MissingPluginException { + // Sem o lado nativo (VM, plataforma sem o plugin): falha fechada, nunca + // uma exceção crua que o store não sabe tratar. + throw const VaultException(VaultFailure.unavailable); + } + } + + @override + Future unseal(String alias, {required String reason}) async { + try { + return await _channel.invokeMethod('unseal', { + 'alias': alias, + 'reason': reason, + }); + } on PlatformException catch (e) { + throw VaultException(switch (e.code) { + 'cancelled' => VaultFailure.cancelled, + 'lockedOut' => VaultFailure.lockedOut, + 'invalidated' => VaultFailure.invalidated, + _ => VaultFailure.unavailable, + }); + } on MissingPluginException { + throw const VaultException(VaultFailure.unavailable); + } + } + + @override + Future delete(String alias) async { + try { + await _channel.invokeMethod('delete', {'alias': alias}); + } catch (_) {} + } +} + +/// Duplo de teste (RAM). +class FakeKeystoreVault implements KeystoreVault { + bool supported = true; + bool failSeal = false; + VaultFailure? nextUnsealFailure; + int unsealCalls = 0; // = unlockCalls: quantos prompts foram abertos + int readCalls = + 0; // quantas vezes `contains` foi consultado (nunca abre prompt) + int deleteCalls = 0; + final Map sealed = {}; + + @override + Future get isSupported async => supported; + + @override + Future contains(String alias) async { + readCalls++; + return sealed.containsKey(alias); + } + + @override + Future seal(String alias, String plaintext) async { + if (failSeal) throw const VaultException(VaultFailure.unavailable); + sealed[alias] = plaintext; + } + + @override + Future unseal(String alias, {required String reason}) async { + unsealCalls++; + final failure = nextUnsealFailure; + if (failure != null) { + nextUnsealFailure = null; + throw VaultException(failure); + } + return sealed[alias]; + } + + @override + Future delete(String alias) async { + deleteCalls++; + sealed.remove(alias); + } +} diff --git a/apps/acs/lib/core/security/secure_session_token_store.dart b/apps/acs/lib/core/security/secure_session_token_store.dart new file mode 100644 index 00000000..349ddcb3 --- /dev/null +++ b/apps/acs/lib/core/security/secure_session_token_store.dart @@ -0,0 +1,67 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +import 'session_token_store.dart'; + +export 'session_token_store.dart'; + +/// Mesmas opções de [SecureStorageDatabaseKeyStore]: respaldo no Keystore e +/// fora de backup em nuvem. +const FlutterSecureStorage _defaultSecureStorage = FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock_this_device), +); + +class SecureStorageSessionTokenStore implements SessionTokenStore { + SecureStorageSessionTokenStore({FlutterSecureStorage? storage}) : _storage = storage ?? _defaultSecureStorage; + + static const _key = 'acs_refresh_token'; + + final FlutterSecureStorage _storage; + + @override + Future read() async { + final value = await _storage.read(key: _key); + return value == null || value.isEmpty ? null : value; + } + + @override + Future write(String token) => _storage.write(key: _key, value: token); + + @override + Future clear() => _storage.delete(key: _key); + + @override + Future contains() async => await read() != null; + + @override + Future unlock({required String reason}) async => SessionUnlock.notRequired; +} + +class SecureStorageDeviceIdStore implements DeviceIdStore { + SecureStorageDeviceIdStore({FlutterSecureStorage? storage}) : _storage = storage ?? _defaultSecureStorage; + + static const _key = 'acs_device_id'; + + final FlutterSecureStorage _storage; + + @override + Future readOrCreate() { + // Memoiza o Future: chamadas concorrentes na 1ª vez devolvem o mesmo id. + // Uma falha não fica memorizada (a próxima chamada tenta de novo). + final pending = _pending ??= _load(); + return pending.catchError((Object e) { + _pending = null; + throw e; + }); + } + + Future? _pending; + + Future _load() async { + final existing = await _storage.read(key: _key); + if (existing != null && existing.isNotEmpty) return existing; + final created = generateDeviceId(); + await _storage.write(key: _key, value: created); + return created; + } +} diff --git a/apps/acs/lib/core/security/secure_upload_token_store.dart b/apps/acs/lib/core/security/secure_upload_token_store.dart new file mode 100644 index 00000000..c3b862f0 --- /dev/null +++ b/apps/acs/lib/core/security/secure_upload_token_store.dart @@ -0,0 +1,114 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +import 'upload_token_store.dart'; + +export 'upload_token_store.dart'; + +/// Mesmas opções de `SecureStorageSessionTokenStore`: respaldo no Keystore e +/// fora de backup em nuvem. +const FlutterSecureStorage _defaultSecureStorage = FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock_this_device), +); + +/// Tokens de envio diferido no Keystore, um por dono, sob +/// `acs_upload_token|` (D7). Separado de `upload_token_store.dart` +/// para que `backend_client.dart` (que roda também na VM) não puxe o Flutter. +/// +/// [owners] lê um ÍNDICE próprio (`acs_upload_token_owners`, lista JSON de +/// ids) em vez de `readAll()`: listar donos não pode carregar para a memória +/// do Dart os outros segredos do Keystore (refresh token, chave do banco). +/// Índice ausente ou corrompido vale como vazio; a próxima gravação o refaz. +class SecureStorageUploadTokenStore implements UploadTokenStore { + SecureStorageUploadTokenStore({FlutterSecureStorage? storage}) : _storage = storage ?? _defaultSecureStorage; + + static const ownersIndexKey = 'acs_upload_token_owners'; + + final FlutterSecureStorage _storage; + + @override + Future read(String ownerId) async { + final value = await _storage.read(key: uploadTokenKey(ownerId)); + return value == null || value.isEmpty ? null : value; + } + + /// Cauda da fila das mutações. Sempre completa sem erro. + Future _tail = Future.value(); + + /// Toda mutação (token + índice) passa por aqui, uma por vez: o índice é + /// ler-modificar-gravar, e duas intercaladas (o login gravando A enquanto o + /// envio diferido apaga B) perderiam uma das atualizações. + Future _serial(Future Function() body) { + final done = Completer(); + final previous = _tail; + _tail = done.future; + return previous.then((_) => body()).whenComplete(done.complete); + } + + /// Token primeiro, índice depois: um índice que aponta para um token + /// ausente é inofensivo ([read] devolve `null`); o contrário esconderia o + /// token de [owners] — e por isso o envio diferido também procura o token + /// dos donos que têm visitas no disco ([repairIndex]). + @override + Future write(String ownerId, String token) => _serial(() async { + await _storage.write(key: uploadTokenKey(ownerId), value: token); + final index = await _readIndex(); + if (!index.contains(ownerId)) await _writeIndex([...index, ownerId]); + }); + + @override + Future clear(String ownerId) => _serial(() async { + await _storage.delete(key: uploadTokenKey(ownerId)); + final index = await _readIndex(); + if (index.contains(ownerId)) await _writeIndex([for (final o in index) if (o != ownerId) o]); + }); + + /// Na cadeia [_serial]: nenhum `write` do mesmo store entra entre a + /// comparação e o apagamento. + @override + Future compareAndClear(String ownerId, String expected) => _serial(() async { + if (await read(ownerId) != expected) return false; + await _storage.delete(key: uploadTokenKey(ownerId)); + final index = await _readIndex(); + if (index.contains(ownerId)) await _writeIndex([for (final o in index) if (o != ownerId) o]); + return true; + }); + + @override + Future repairIndex(String ownerId) => _serial(() async { + if (await read(ownerId) == null) return; + final index = await _readIndex(); + if (!index.contains(ownerId)) await _writeIndex([...index, ownerId]); + }); + + /// Sem `readAll()` nem `deleteAll()`: o Keystore guarda também o refresh + /// token, o id do aparelho e a chave do banco, que o wipe mantém. + @override + Future clearAll({Iterable alsoOwners = const []}) => _serial(() async { + final owners = {...await _readIndex(), ...alsoOwners}; + for (final owner in owners) { + await _storage.delete(key: uploadTokenKey(owner)); + } + await _storage.delete(key: ownersIndexKey); + }); + + @override + Future> owners() => _readIndex(); + + Future> _readIndex() async { + final raw = await _storage.read(key: ownersIndexKey); + if (raw == null || raw.isEmpty) return []; + try { + final decoded = jsonDecode(raw); + if (decoded is! List) return []; + return [for (final item in decoded) if (item is String && item.isNotEmpty) item]; + } on FormatException { + return []; + } + } + + Future _writeIndex(List owners) => _storage.write(key: ownersIndexKey, value: jsonEncode(owners)); +} diff --git a/apps/acs/lib/core/security/session_token_store.dart b/apps/acs/lib/core/security/session_token_store.dart new file mode 100644 index 00000000..e09d8536 --- /dev/null +++ b/apps/acs/lib/core/security/session_token_store.dart @@ -0,0 +1,79 @@ +import 'dart:math'; + +// Sem `package:flutter` aqui, de propósito: `backend_client.dart` importa este +// arquivo e `tool/live_check.dart` roda na VM do Dart, onde `dart:ui` não +// existe. As implementações sobre o Keystore ficam em +// `secure_session_token_store.dart`, montadas só pelo `main.dart`. + +/// Resultado do desbloqueio do token guardado. `notRequired`: este store não +/// tem prompt próprio (o chamador usa o portão de interface). +enum SessionUnlock { unlocked, notRequired, cancelled, lockedOut, unavailable } + +/// Custódia do refresh token rotativo do ACS (LGPD-RT06). +/// +/// Fica no Android Keystore / iOS Keychain, como a chave do banco local. É o +/// que permite renovar o JWT de 15 minutos sem reter a senha em memória. +/// +/// **Nunca registre o valor em log**, nem em mensagem de erro. +abstract interface class SessionTokenStore { + Future read(); + + Future write(String token); + + Future clear(); + + /// `true` se há token guardado, **sem** decifrá-lo (nunca abre prompt). + Future contains(); + + /// Decifra o token guardado para a memória, abrindo o prompt do sistema se o + /// store o exige. `read()` só enxerga o token depois disto. + Future unlock({required String reason}); +} + +/// Duplo de teste: só em memória. +class MemorySessionTokenStore implements SessionTokenStore { + MemorySessionTokenStore([this._token]); + + String? _token; + + @override + Future read() async => _token; + + @override + Future write(String token) async => _token = token; + + @override + Future clear() async => _token = null; + + @override + Future contains() async => _token != null; + + @override + Future unlock({required String reason}) async => SessionUnlock.notRequired; +} + +/// Identificador estável desta instalação. O servidor amarra a família do +/// refresh token a ele e recusa a troca por outro aparelho. +abstract interface class DeviceIdStore { + Future readOrCreate(); +} + +/// UUID v4 aleatório (sem dependência extra). +String generateDeviceId() { + final random = Random.secure(); + final bytes = List.generate(16, (_) => random.nextInt(256)); + 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)}'; +} + +class MemoryDeviceIdStore implements DeviceIdStore { + MemoryDeviceIdStore([this._id]); + + String? _id; + + @override + Future readOrCreate() async => _id ??= generateDeviceId(); +} diff --git a/apps/acs/lib/core/security/upload_token_store.dart b/apps/acs/lib/core/security/upload_token_store.dart new file mode 100644 index 00000000..952a9b04 --- /dev/null +++ b/apps/acs/lib/core/security/upload_token_store.dart @@ -0,0 +1,84 @@ +// Sem `package:flutter` aqui, de propósito (mesmo motivo de +// `session_token_store.dart`): `backend_client.dart` importa este arquivo e +// `tool/live_check.dart` roda na VM do Dart, onde `dart:ui` não existe. A +// implementação sobre o Keystore fica em `secure_upload_token_store.dart`, +// montada só pelo `main.dart`. + +/// Chave do token de envio de [ownerId] no Keystore (D7). +String uploadTokenKey(String ownerId) => '$uploadTokenKeyPrefix$ownerId'; + +/// Prefixo comum das chaves dos tokens de envio. +const uploadTokenKeyPrefix = 'acs_upload_token|'; + +/// Custódia dos **tokens de envio diferido** (D7 do plano 2026-10-03), um por +/// dono (`userId` do ACS). +/// +/// O token é emitido pelo `auth.loginInstitutional` junto do refresh token e só +/// serve para `visits.syncDeferred` e `visits.revokeUploadToken`: é o que +/// permite subir as visitas de A, com a autoria de A, depois que A saiu. Ele +/// **sobrevive ao "Sair"** enquanto houver visita pendente de A; é revogado e +/// apagado quando a fila de A zera. +/// +/// **Nunca registre o valor em log**, nem em mensagem de erro. +abstract interface class UploadTokenStore { + Future read(String ownerId); + + Future write(String ownerId, String token); + + Future clear(String ownerId); + + /// Apaga o token de [ownerId] SÓ se ainda for [expected], numa operação só + /// (ler, comparar e apagar sem outra mutação no meio). Um login que gravou + /// um token NOVO entre a leitura e o apagamento não perde o novo. Devolve + /// `true` se apagou. + Future compareAndClear(String ownerId, String expected); + + /// Donos que têm token guardado neste aparelho. Pode ser um ÍNDICE (ver + /// `SecureStorageUploadTokenStore`) e, portanto, uma otimização: quem + /// precisa de certeza para um dono conhecido usa [read]. + Future> owners(); + + /// Recoloca [ownerId] no índice de [owners] se o token dele existe (o + /// índice se perdeu, ou a gravação dele falhou depois do token). Nunca + /// regrava o token. + Future repairIndex(String ownerId); + + /// "Limpar este aparelho": apaga o token de todo dono do índice e de + /// [alsoOwners] (donos conhecidos por quem chama, para o caso de o índice + /// ter se perdido), e o próprio índice. Não revoga no servidor: isso é de + /// quem chama, antes. + Future clearAll({Iterable alsoOwners = const []}); +} + +/// Duplo de teste: só em memória. +class MemoryUploadTokenStore implements UploadTokenStore { + MemoryUploadTokenStore([Map? initial]) : _tokens = {...?initial}; + + final Map _tokens; + + @override + Future read(String ownerId) async => _tokens[ownerId]; + + @override + Future write(String ownerId, String token) async => _tokens[ownerId] = token; + + @override + Future clear(String ownerId) async => _tokens.remove(ownerId); + + @override + Future compareAndClear(String ownerId, String expected) async { + if (_tokens[ownerId] != expected) return false; + _tokens.remove(ownerId); + return true; + } + + @override + Future> owners() async => _tokens.keys.toList(); + + /// O índice é o próprio mapa: nada a reparar. + @override + Future repairIndex(String ownerId) async {} + + @override + Future clearAll({Iterable alsoOwners = const []}) async => _tokens.clear(); +} diff --git a/apps/acs/lib/core/services/alert_feed.dart b/apps/acs/lib/core/services/alert_feed.dart index b3ae6c62..a42cd081 100644 --- a/apps/acs/lib/core/services/alert_feed.dart +++ b/apps/acs/lib/core/services/alert_feed.dart @@ -33,6 +33,10 @@ enum AlertFeedFailureKind { /// Falta o certificado da CA nos assets (`scripts/dev/sync_dev_ca.sh`). missingCaAsset, + /// Falta o certificado de cliente do ACS nos assets + /// (`scripts/dev/sync_dev_ca.sh`): sem ele o broker (mTLS) recusa. + missingClientCertificate, + /// O broker respondeu e recusou (credencial, identificador, indisponível). refused, @@ -64,7 +68,7 @@ class AlertFeedFailure implements Exception { /// Se vale a pena tentar de novo sozinho, sem intervenção humana. /// - /// `missingPassword`/`missingCaAsset` exigem recompilar; `refused` herda o + /// `missingPassword`/`missingCaAsset`/`missingClientCertificate` exigem recompilar; `refused` herda o /// veredito do CONNACK; `unreachable` é sempre transitória — falta de rede /// é o caso comum de um ACS em campo. final bool transient; @@ -124,6 +128,21 @@ class MqttAlertFeed implements AlertFeed { ); } + // Certificado e chave de CLIENTE (mTLS), também assets gitignorados. + final ByteData clientCertificate; + final ByteData clientPrivateKey; + try { + clientCertificate = await rootBundle.load(BackendConfig.mqttClientCertAsset); + clientPrivateKey = await rootBundle.load(BackendConfig.mqttClientKeyAsset); + } catch (error) { + throw AlertFeedFailure( + AlertFeedFailureKind.missingClientCertificate, + title: 'Falta o certificado de cliente da central neste aplicativo.', + detail: 'Sem ele o broker recusa a conexão.', + cause: error, + ); + } + final client = MqttSecureClient( config: SecureMqttConfig( brokerHost: BackendConfig.mqttHost, @@ -136,6 +155,8 @@ class MqttAlertFeed implements AlertFeed { username: BackendConfig.mqttUsername, password: BackendConfig.mqttPassword, caCertificate: caCertificate.buffer.asUint8List(), + clientCertificate: clientCertificate.buffer.asUint8List(), + clientPrivateKey: clientPrivateKey.buffer.asUint8List(), ), ); diff --git a/apps/acs/lib/core/services/alert_queue.dart b/apps/acs/lib/core/services/alert_queue.dart index 6fc68358..f175f1ed 100644 --- a/apps/acs/lib/core/services/alert_queue.dart +++ b/apps/acs/lib/core/services/alert_queue.dart @@ -13,6 +13,7 @@ class PrioritizedAlert { this.locationCell, required this.triggeredAt, this.acknowledged = false, + this.escalated = false, }); factory PrioritizedAlert.fromMqtt(ReceivedMqttAlert alert) { @@ -24,6 +25,7 @@ class PrioritizedAlert { locationHash: alert.locationHash, locationCell: alert.locationCell, triggeredAt: alert.triggeredAt, + escalated: alert.escalated, ); } @@ -43,7 +45,11 @@ class PrioritizedAlert { final DateTime triggeredAt; final bool acknowledged; - PrioritizedAlert copyWith({bool? acknowledged}) => PrioritizedAlert( + /// O backoffice reforçou este alerta (#44). Só existe em memória: não é + /// gravado no aparelho nem carrega quem escalonou. + final bool escalated; + + PrioritizedAlert copyWith({bool? acknowledged, bool? escalated}) => PrioritizedAlert( alertId: alertId, patientId: patientId, microAreaId: microAreaId, @@ -52,6 +58,7 @@ class PrioritizedAlert { locationCell: locationCell, triggeredAt: triggeredAt, acknowledged: acknowledged ?? this.acknowledged, + escalated: escalated ?? this.escalated, ); /// Peso da ordenação. Menor vem primeiro. @@ -84,6 +91,10 @@ class AlertQueue extends ChangeNotifier { bool get isEmpty => _alerts.isEmpty; + /// Alertas que o ACS ainda não confirmou à central. Conta todos, qualquer + /// risco: um risco desconhecido também é um chamado que alguém precisa ver. + int get unacknowledgedCount => _alerts.where((a) => !a.acknowledged).length; + /// Insere ou atualiza um alerta. /// /// Devolve `false` quando o alerta é recusado. São duas razões: @@ -93,6 +104,18 @@ class AlertQueue extends ChangeNotifier { /// apontado a um tópico mais amplo; /// - **duplicata**: com QoS 1 a entrega é *at-least-once*, então a mesma /// mensagem pode chegar mais de uma vez e não pode virar dois cartões. + /// + /// O `true` é o "sinal de alerta novo" deste app (não há som nem vibração + /// próprios: o `ChangeNotifier` move a lista, o contador e o banner). + /// + /// **Reforço** (`escalated`, #44): para um alerta ainda não confirmado, + /// marca o cartão, sobe-o à frente dos demais do mesmo risco e devolve + /// `true` (mesmo sinal). Um reforço sobre alerta já confirmado devolve + /// `false`, como qualquer reentrega: nunca desfaz a confirmação nem sinaliza. + /// Idempotência do QoS 1: cartão já marcado `escalated` recebendo outro + /// reforço é duplicata (`false`); o backend escalona cada alerta uma só + /// vez, então o marcador basta. Uma reentrega do original (sem o marcador) + /// não apaga o reforço. bool upsert(PrioritizedAlert alert) { if (alert.microAreaId != microAreaId) return false; @@ -102,7 +125,8 @@ class AlertQueue extends ChangeNotifier { // um alerta que o ACS já atendeu. final existing = _alerts[index]; if (existing.acknowledged) return false; - _alerts[index] = alert; + if (alert.escalated && existing.escalated) return false; + _alerts[index] = existing.escalated ? alert.copyWith(escalated: true) : alert; } else { _alerts.add(alert); } @@ -129,6 +153,8 @@ class AlertQueue extends ChangeNotifier { if (a.acknowledged != b.acknowledged) return a.acknowledged ? 1 : -1; final byRisk = a._riskRank.compareTo(b._riskRank); if (byRisk != 0) return byRisk; + // Reforço da coordenação vem à frente dos demais do mesmo risco. + if (a.escalated != b.escalated) return a.escalated ? -1 : 1; return a.triggeredAt.compareTo(b.triggeredAt); }); } diff --git a/apps/acs/lib/core/services/backend_visit_synchronizer.dart b/apps/acs/lib/core/services/backend_visit_synchronizer.dart index 63d4722f..28d5f74f 100644 --- a/apps/acs/lib/core/services/backend_visit_synchronizer.dart +++ b/apps/acs/lib/core/services/backend_visit_synchronizer.dart @@ -7,35 +7,35 @@ import 'package:sinalacs_client/sinalacs_client.dart'; /// Fica entre a fila (que não conhece o contrato do servidor) e o /// [AcsBackend] (que não conhece a fila) — é a tradução entre os dois, no mesmo /// espírito das implementações de `infrastructure/` no backend. +/// +/// Um sincronizador pertence a UM dono ([ownerId], o `userId` do ACS que +/// registrou as visitas). `visits.sync` atribui a visita ao ACS do token, então +/// enviar o lote de A com a sessão de B daria a B um trabalho que não é dele: +/// [push] confere a sessão NO MOMENTO do envio (não só na construção) e, se ela +/// não é do dono, recusa sem tocar na rede; o backend confere de novo depois +/// de resolver o token ([AcsBackend.syncVisits] com `expectedUserId`). A +/// recusa é recuperável — a fila converte a exceção em +/// `SyncOutcomeKind.error` e o lote continua pendente, esperando o dono +/// entrar de novo. class BackendVisitSynchronizer implements VisitSynchronizer { - BackendVisitSynchronizer({required this.backend}); + BackendVisitSynchronizer({required this.backend, required String ownerId}) + : ownerId = requireOwnerId(ownerId); final AcsBackend backend; + /// `userId` do ACS dono das visitas que este sincronizador envia. + final String ownerId; + @override Future> push(List visits) async { - final results = await backend.syncVisits([ - for (final visit in visits) - VisitSyncEntry( - localId: visit.localId, - patientId: visit.patientId, - scheduledAt: visit.createdAt.toUtc(), - completedAt: visit.createdAt.toUtc(), - status: visit.outcome.isEmpty ? visit.status : visit.outcome, - riskLevelBefore: _riskLevel(visit.risk), - notes: visit.notes.trim().isEmpty - ? const {} - : {'campo': visit.notes.trim()}, - version: visit.version, - // Contrato para geofencing futuro (RF12, decisão §4) — desenho - // apenas. Nenhuma API nativa de geofence foi integrada nesta task, - // nenhuma permissão de localização em primeiro/segundo plano foi - // adicionada ao AndroidManifest.xml, e a escolha de plugin/texto de - // divulgação seguem bloqueados por revisão de produto/jurídico. Todo - // check-in registrado por este app hoje é manual. - arrivalMethod: ArrivalMethod.manual, - ), - ]); + // Checagem cedo (sem sessão do dono, nem monta o lote) e de novo dentro + // do backend, depois de o token ser resolvido: uma renovação pode esperar + // o login de outro ACS e voltar com a sessão DELE. + if (backend.session?.userId != ownerId) throw sessionOwnerMismatch; + final results = await backend.syncVisits( + [for (final visit in visits) visitSyncEntryFor(visit)], + expectedUserId: ownerId, + ); return [ for (final result in results) @@ -47,13 +47,36 @@ class BackendVisitSynchronizer implements VisitSynchronizer { ), ]; } - - /// Risco é sinal clínico: um valor desconhecido vira `green` só para caber no - /// enum, nunca para *rebaixar* um caso — o risco que vale é o do servidor, e - /// esta visita já foi realizada. - RiskLevel _riskLevel(String value) => switch (value.toLowerCase()) { - 'red' || 'vermelho' => RiskLevel.red, - 'yellow' || 'amarelo' => RiskLevel.yellow, - _ => RiskLevel.green, - }; } + +/// Visita da fila → contrato de `visits.sync`/`syncLegacy`/`syncDeferred`. Um +/// só mapeamento para os três caminhos: o servidor deduplica pelo `localId` e +/// confere a versão, então reenviar a mesma visita por outro caminho tem de +/// produzir exatamente a mesma entrada. +VisitSyncEntry visitSyncEntryFor(OfflineVisitRecord visit) => VisitSyncEntry( + localId: visit.localId, + patientId: visit.patientId, + scheduledAt: visit.createdAt.toUtc(), + completedAt: visit.createdAt.toUtc(), + status: visit.outcome.isEmpty ? visit.status : visit.outcome, + riskLevelBefore: _riskLevel(visit.risk), + notes: visit.notes.trim().isEmpty + ? const {} + : {'campo': visit.notes.trim()}, + version: visit.version, + // Contrato para geofencing futuro (RF12, decisão §4) — desenho apenas. + // Nenhuma API nativa de geofence foi integrada, nenhuma permissão de + // localização em segundo plano foi adicionada, e a escolha de + // plugin/texto de divulgação segue bloqueada por revisão de + // produto/jurídico. Todo check-in registrado por este app hoje é manual. + arrivalMethod: ArrivalMethod.manual, + ); + +/// Risco é sinal clínico: um valor desconhecido vira `green` só para caber no +/// enum, nunca para *rebaixar* um caso — o risco que vale é o do servidor, e +/// esta visita já foi realizada. +RiskLevel _riskLevel(String value) => switch (value.toLowerCase()) { + 'red' || 'vermelho' => RiskLevel.red, + 'yellow' || 'amarelo' => RiskLevel.yellow, + _ => RiskLevel.green, + }; diff --git a/apps/acs/lib/core/services/deferred_flush_service.dart b/apps/acs/lib/core/services/deferred_flush_service.dart new file mode 100644 index 00000000..81819a4e --- /dev/null +++ b/apps/acs/lib/core/services/deferred_flush_service.dart @@ -0,0 +1,348 @@ +import 'dart:async'; + +import 'package:sinalacs_acs/core/network/backend_client.dart'; +import 'package:sinalacs_acs/core/security/session_token_store.dart'; +import 'package:sinalacs_acs/core/security/upload_token_store.dart'; +import 'package:sinalacs_acs/core/services/backend_visit_synchronizer.dart'; +import 'package:sinalacs_acs/core/services/offline_visit_queue.dart'; +import 'package:sinalacs_client/sinalacs_client.dart' show SyncStatus, VisitSyncResult; + +/// Tamanho de cada lote do envio diferido e do legado. O servidor recusa acima +/// de 200 (`VisitSyncService.maxLegacyBatch`); 100 deixa folga. +const deferredBatchSize = 100; + +/// Mensagem do servidor para uma visita legada cujo `localId` já existe com +/// autor, em outra versão (D4). Não é recusa de território: precisa de revisão. +const legacyVersionMismatchMessage = 'visita já registrada com autor — versão diferente'; + +/// Resultado de [DeferredFlushService.flushAll]. Só contagens e ids de ACS — +/// nenhum conteúdo de visita. +class FlushReport { + const FlushReport({ + required this.sent, + required this.remaining, + this.needsReview = 0, + this.blockedOwners = const [], + }); + + /// Visitas confirmadas (`synced`) e retiradas do aparelho nesta execução. + final int sent; + + /// Visitas que continuam no aparelho depois da execução: de TODOS os donos + /// (inclusive o da sessão atual, cuja fila é do painel) e da quarentena, + /// pendentes, em conflito ou recusadas. + final int remaining; + + /// Visitas legadas recusadas com [legacyVersionMismatchMessage]: ficam em + /// quarentena e pedem revisão (não são erro de território). + final int needsReview; + + /// Donos (que não são a sessão atual) com visitas no aparelho que este + /// serviço NÃO consegue subir: sem token de envio, ou com o token recusado + /// pelo servidor. Só sobem quando o próprio dono entrar de novo. + final List blockedOwners; +} + +/// Sincronizador da fila de UM dono pelo token de envio dele (`syncDeferred`). +/// +/// O autor gravado no servidor é o dono do token — por isso cada instância tem +/// um só token e a fila temporária de um só dono. Envia em lotes de +/// [deferredBatchSize]; se um lote do meio falhar, devolve o que já foi +/// confirmado (o resto fica pendente e é reenviado, idempotente pelo `localId`). +class DeferredVisitSynchronizer implements VisitSynchronizer { + DeferredVisitSynchronizer({required this.backend, required this.uploadToken, required this.deviceId}); + + final AcsBackend backend; + final String uploadToken; + final String deviceId; + + /// `true` quando o servidor recusou o token (vencido, revogado, outro + /// aparelho, conta inativa). A fila só enxerga a exceção como texto; o + /// serviço precisa saber o tipo. + bool tokenRefused = false; + + @override + Future> push(List visits) async { + final outcomes = []; + for (var start = 0; start < visits.length; start += deferredBatchSize) { + final chunk = visits.sublist(start, (start + deferredBatchSize).clamp(0, visits.length)); + try { + final results = await backend.syncDeferredVisits( + uploadToken: uploadToken, + deviceId: deviceId, + visits: [for (final visit in chunk) visitSyncEntryFor(visit)], + ); + outcomes.addAll(results.map(_outcome)); + } on UploadTokenRefused { + tokenRefused = true; + if (outcomes.isEmpty) rethrow; + break; + } catch (_) { + if (outcomes.isEmpty) rethrow; + break; + } + } + return outcomes; + } +} + +VisitSyncOutcome _outcome(VisitSyncResult result) => VisitSyncOutcome( + localId: result.localId, + status: result.syncStatus.name, + serverVersion: result.serverVersion, + message: result.message, + ); + +/// Sobe o que está preso no aparelho e não pertence à sessão atual (D4/D7). +/// +/// **A fila do dono da sessão atual NÃO é tocada aqui**: ela é do painel +/// (`OfflineVisitQueue` do escopo dele, que sincroniza pela sessão). Enviá-la +/// também por aqui seria um segundo `sync` concorrente sobre o mesmo dono, e +/// `OfflineVisitQueue.sync` não é single-flight. Quem quiser tudo enviado (o +/// "Sair", Task 8) sincroniza a fila do painel e depois chama [flushAll]. +class DeferredFlushService { + DeferredFlushService({ + required AcsBackend backend, + required VisitStorage storage, + required UploadTokenStore tokens, + required DeviceIdStore deviceIds, + }) : _backend = backend, + _storage = storage, + _tokens = tokens, + _deviceIds = deviceIds; + + final AcsBackend _backend; + final VisitStorage _storage; + final UploadTokenStore _tokens; + final DeviceIdStore _deviceIds; + + Future? _inFlight; + + /// `localId`s legados recusados com [legacyVersionMismatchMessage]: não + /// adianta reenviar por ninguém enquanto o app vive. + final Set _legacyNeedsReview = {}; + + /// `localId`s legados recusados por transportador (`userId|microAreaId`): + /// não são reenviados pelo MESMO transportador nesta execução do app (cada + /// reenvio vira linha de auditoria no servidor), mas um ACS de outra + /// microárea ainda pode subi-los. + final Map> _legacyRejectedBy = >{}; + + /// Sobe, nesta ordem: (1) legado, via a sessão ATUAL como transporte; (2) a + /// fila de cada dono que tem token de envio, via `syncDeferred`; (3) revoga + /// e apaga o token de todo dono cuja fila zerou. Uma chamada por vez + /// (single-flight). Nunca lança: erros viram `remaining`/`blockedOwners`. + Future flushAll() { + final pending = _inFlight; + if (pending != null) return pending; + late final Future flight; + flight = _run().whenComplete(() { + if (identical(_inFlight, flight)) _inFlight = null; + }); + return _inFlight = flight; + } + + /// Visitas de OUTROS donos + legado. Só contagem. + Future pendingElsewhere(String currentOwnerId) async { + final counts = await _safe(_storage.countsByOwner) ?? const {}; + final legacy = await _safe(_storage.legacy.load) ?? const []; + var total = legacy.length; + counts.forEach((owner, count) { + if (owner != currentOwnerId) total += count; + }); + return total; + } + + Future _run() async { + var sent = 0; + final blocked = {}; + try { + final deviceId = await _safe(_deviceIds.readOrCreate); + + // (1) Legado: só com uma sessão para transportar. + final transporter = _backend.session; + if (transporter != null && deviceId != null) { + sent += await _flushLegacy(deviceId, '${transporter.userId}|${transporter.microAreaId ?? ''}'); + } + + // (2) e (3): cada dono com token, menos o da sessão atual. O índice de + // `owners()` é só uma otimização: todo dono com visita no disco também + // tem o token procurado pela chave dele (nunca `readAll`), para que um + // índice perdido não deixe as visitas dele presas para sempre. + final indexed = (await _safe(_tokens.owners) ?? const []).toList(); + final withRows = (await _safe(_storage.countsByOwner) ?? const {}) + .entries + .where((e) => e.value > 0) + .map((e) => e.key); + final candidates = {...indexed, ...withRows}; + for (final owner in candidates) { + if (owner == _backend.session?.userId) continue; + final token = await _safe(() => _tokens.read(owner)); + if (token == null) continue; + if (!indexed.contains(owner)) await _safe(() => _tokens.repairIndex(owner)); + + if (deviceId != null) { + final synchronizer = DeferredVisitSynchronizer(backend: _backend, uploadToken: token, deviceId: deviceId); + final queue = OfflineVisitQueue(store: _MergingOwnerStore(_storage.forOwner(owner)), synchronizer: synchronizer); + await queue.restore(); + await queue.sync(); // nunca lança: falha vira SyncOutcome.error + sent += await _removedFromDevice(owner, queue.syncedVisits); + if (synchronizer.tokenRefused) { + // O servidor diz que este token morreu: apagar só o local. As + // visitas do dono FICAM, para o próximo login dele. + blocked.add(owner); + await _clearIfSame(owner, token); + continue; + } + } + + // (3) Fila do dono vazia (nada pendente, em conflito nem recusado): + // revoga no servidor e apaga daqui. Se o dono virou a sessão atual no + // meio, o token fica (ele pode registrar visitas e sair de novo). + if (owner == _backend.session?.userId) continue; + final rows = await _safe(() => _storage.forOwner(owner).load()); + if (rows == null || rows.isNotEmpty) continue; + try { + await _backend.revokeUploadToken(token); + } catch (_) { + continue; // sem rede: tenta de novo no próximo envio + } + await _clearIfSame(owner, token); + } + } catch (_) { + // Nunca lança: o que sobrou é contado abaixo. + } + return _report(sent, blocked); + } + + /// Sobe o legado em lotes de [deferredBatchSize]. Só `synced` sai da + /// quarentena; `rejected` e `error` ficam. Devolve quantas saíram. + Future _flushLegacy(String deviceId, String transporterKey) async { + final rows = await _safe(_storage.legacy.load); + if (rows == null) return 0; + final rejectedHere = _legacyRejectedBy.putIfAbsent(transporterKey, () => {}); + final toSend = [ + for (final row in rows) + if (!_legacyNeedsReview.contains(row.localId) && !rejectedHere.contains(row.localId)) row, + ]; + var removed = 0; + for (var start = 0; start < toSend.length; start += deferredBatchSize) { + final chunk = toSend.sublist(start, (start + deferredBatchSize).clamp(0, toSend.length)); + final List results; + try { + results = await _backend.syncLegacyVisits( + [for (final visit in chunk) visitSyncEntryFor(visit)], + deviceId: deviceId, + ); + } catch (_) { + break; // rede ou sessão: o resto fica para a próxima + } + final sentIds = {for (final visit in chunk) visit.localId}; + final synced = []; + for (final result in results) { + if (!sentIds.contains(result.localId)) continue; + switch (result.syncStatus) { + case SyncStatus.synced: + synced.add(result.localId); + case SyncStatus.rejected: + (result.message == legacyVersionMismatchMessage ? _legacyNeedsReview : rejectedHere).add(result.localId); + default: + break; // error/conflict: retenta depois + } + } + try { + await _storage.legacy.remove(synced); + removed += synced.length; + } catch (_) { + // O servidor já gravou; a linha volta no próximo envio e o reenvio + // do mesmo `localId` é idempotente (`synced`). + break; + } + } + return removed; + } + + /// Quantas das visitas confirmadas pelo servidor de fato saíram do disco do + /// dono. Se a gravação que as removeria falhou, elas continuam lá (e serão + /// reenviadas, idempotentes) e não contam como enviadas. + Future _removedFromDevice(String owner, List synced) async { + if (synced.isEmpty) return 0; + final rows = await _safe(() => _storage.forOwner(owner).load()); + if (rows == null) return 0; + final onDisk = {for (final row in rows) row.localId}; + return synced.where((visit) => !onDisk.contains(visit.localId)).length; + } + + Future _report(int sent, Set blocked) async { + final current = _backend.session?.userId; + final counts = await _safe(_storage.countsByOwner) ?? const {}; + final legacy = await _safe(_storage.legacy.load) ?? const []; + var remaining = legacy.length; + for (final MapEntry(key: owner, value: count) in counts.entries) { + remaining += count; + if (count == 0 || owner == current || blocked.contains(owner)) continue; + // Leitura por chave, não o índice: um dono fora do índice que tem token + // não está bloqueado. + if (await _safe(() => _tokens.read(owner)) == null) blocked.add(owner); + } + blocked.remove(current); + return FlushReport( + sent: sent, + remaining: remaining, + needsReview: legacy.where((row) => _legacyNeedsReview.contains(row.localId)).length, + blockedOwners: List.unmodifiable(blocked), + ); + } + + /// Apaga o token local só se ainda é [token]: um login do dono no meio do + /// envio gravou um token NOVO, que não pode ser apagado por engano. + Future _clearIfSame(String owner, String token) async { + try { + await _tokens.compareAndClear(owner, token); + } catch (_) {} + } + + static Future _safe(Future Function() op) async { + try { + return await op(); + } catch (_) { + return null; + } + } +} + +/// Visão do dono para a fila TEMPORÁRIA do envio diferido. +/// +/// A `OfflineVisitQueue` grava a lista inteira do dono. Se o dono entrar +/// enquanto o lote dele está no servidor e registrar uma visita nova, uma +/// gravação "cega" apagaria essa visita. Por isso, ao gravar, esta visão: +/// mantém as visitas que apareceram no disco depois da leitura, e não +/// ressuscita as que sumiram do disco nesse meio-tempo (a fila do painel já as +/// enviou ou descartou). Sobra uma janela curta, só de E/S local, entre a +/// releitura e a gravação. +class _MergingOwnerStore implements VisitStore { + _MergingOwnerStore(this._inner); + + final VisitStore _inner; + Set _seen = {}; + + @override + Future> load() async { + final rows = await _inner.load(); + _seen = {for (final row in rows) row.localId}; + return rows; + } + + @override + Future save(List visits) async { + final current = await _inner.load(); + final onDisk = {for (final row in current) row.localId}; + final keep = [for (final visit in visits) if (onDisk.contains(visit.localId)) visit]; + final keepIds = {for (final visit in keep) visit.localId}; + final added = [ + for (final row in current) + if (!_seen.contains(row.localId) && !keepIds.contains(row.localId)) row, + ]; + await _inner.save([...keep, ...added]); + } +} diff --git a/apps/acs/lib/core/services/emergency_dialer.dart b/apps/acs/lib/core/services/emergency_dialer.dart new file mode 100644 index 00000000..a13bd6a7 --- /dev/null +++ b/apps/acs/lib/core/services/emergency_dialer.dart @@ -0,0 +1,28 @@ +import 'package:url_launcher/url_launcher.dart'; + +/// Número do SAMU. Constante de domínio (PRD, RF13), não configuração. +const samuNumber = '192'; + +/// Abre o discador do aparelho com o número já digitado. +/// +/// **Nunca liga sozinho:** `tel:` abre o discador e a pessoa confirma a +/// chamada. É o que dispensa `CALL_PHONE` no manifesto (permissão sensível, +/// revisada pela loja) e evita uma ligação por toque acidental. +abstract interface class EmergencyDialer { + /// `true` se o discador foi aberto; `false` se o aparelho não tem como. + Future dial(String number); +} + +class UrlLauncherEmergencyDialer implements EmergencyDialer { + const UrlLauncherEmergencyDialer(); + + @override + Future dial(String number) async { + try { + return await launchUrl(Uri(scheme: 'tel', path: number)); + } catch (_) { + // Qualquer falha vira "não abriu": quem chama mostra o número em texto. + return false; + } + } +} diff --git a/apps/acs/lib/core/services/micro_area_directory.dart b/apps/acs/lib/core/services/micro_area_directory.dart new file mode 100644 index 00000000..b54e24de --- /dev/null +++ b/apps/acs/lib/core/services/micro_area_directory.dart @@ -0,0 +1,91 @@ +import 'dart:developer' as developer; + +import 'package:sinalacs_acs/core/database/micro_area_cache_store.dart'; +import 'package:sinalacs_acs/core/network/auth_session.dart'; +import 'package:sinalacs_acs/core/network/backend_client.dart'; +import 'package:sinalacs_client/sinalacs_client.dart' show MicroAreaPatient; + +/// Validade do cache da microárea (spec/lgpd_design.md §5.11). +const microAreaCacheMaxAge = Duration(hours: 72); + +class MicroAreaSnapshot { + const MicroAreaSnapshot({required this.patients, required this.fetchedAt, required this.fromCache}); + + final List patients; + final DateTime fetchedAt; + + /// `true` quando a lista veio do aparelho porque a central não respondeu. + final bool fromCache; +} + +/// Lista da microárea com cache no aparelho (RF08). +/// +/// Regra única, em um lugar só: o cache entra **apenas** no lugar de uma falha +/// recuperável (`isRecoverable`) e **apenas** para o mesmo `userId|microAreaId` +/// e dentro da validade. Recusa do servidor (`isRecoverable: false`) sobe como +/// veio — um território negado não pode ser contornado por um cache antigo. +class MicroAreaDirectory { + MicroAreaDirectory({ + required Future> Function() fetch, + required AuthSession? Function() session, + required MicroAreaCacheStore store, + DateTime Function()? clock, + this.maxAge = microAreaCacheMaxAge, + }) : _fetch = fetch, + _session = session, + _store = store, + _clock = clock ?? DateTime.now; + + final Future> Function() _fetch; + final AuthSession? Function() _session; + final MicroAreaCacheStore _store; + final DateTime Function() _clock; + final Duration maxAge; + + String? _owner() { + final s = _session(); + if (s == null || s.microAreaId == null) return null; + return '${s.userId}|${s.microAreaId}'; + } + + Future load() async { + final owner = _owner(); + try { + final fresh = await _fetch(); + final at = _clock().toUtc(); + if (owner != null) { + try { + await _store.write(owner: owner, patients: fresh, at: at); + } catch (error, stack) { + // Gravar o cache é conforto; nunca derruba a lista que acabou de chegar. + developer.log('não foi possível gravar o cache da microárea', + name: 'sinalacs.acs.micro_area_directory', error: error, stackTrace: stack); + } + } + return MicroAreaSnapshot(patients: fresh, fetchedAt: at, fromCache: false); + } on BackendFailure catch (falha) { + if (!falha.isRecoverable || owner == null) rethrow; + final guardado = await _lerSemFalhar(owner); + if (guardado == null) rethrow; + if (_clock().toUtc().difference(guardado.fetchedAt) > maxAge) { + await _apagarSemFalhar(); + rethrow; + } + return MicroAreaSnapshot(patients: guardado.patients, fetchedAt: guardado.fetchedAt, fromCache: true); + } + } + + Future _lerSemFalhar(String owner) async { + try { + return await _store.read(owner: owner); + } catch (_) { + return null; // cache ilegível = sem cache; a falha da rede é a que importa + } + } + + Future _apagarSemFalhar() async { + try { + await _store.clear(); + } catch (_) {} + } +} diff --git a/apps/acs/lib/core/services/micro_area_directory_factory.dart b/apps/acs/lib/core/services/micro_area_directory_factory.dart new file mode 100644 index 00000000..515d2dd3 --- /dev/null +++ b/apps/acs/lib/core/services/micro_area_directory_factory.dart @@ -0,0 +1,19 @@ +import 'package:sinalacs_acs/core/database/micro_area_cache_store.dart'; +import 'package:sinalacs_acs/core/network/backend_client.dart'; +import 'package:sinalacs_acs/core/security/database_key_store.dart'; +import 'package:sinalacs_acs/core/services/micro_area_directory.dart'; + +/// Monta o diretório da microárea sobre o backend e a base SQLCipher do aparelho. +/// +/// [cacheStore] existe só para um teste inspecionar o que foi gravado; em +/// produção a chamada não passa nada e usa o padrão, respaldado pelo +/// Keystore/Keychain. +MicroAreaDirectory buildMicroAreaDirectory({ + required AcsBackend backend, + MicroAreaCacheStore? cacheStore, +}) => + MicroAreaDirectory( + fetch: backend.listPatients, + session: () => backend.session, + store: cacheStore ?? MicroAreaCacheStore(keyStore: SecureStorageDatabaseKeyStore()), + ); diff --git a/apps/acs/lib/core/services/mqtt_secure_client.dart b/apps/acs/lib/core/services/mqtt_secure_client.dart index b0734eec..acd787e0 100644 --- a/apps/acs/lib/core/services/mqtt_secure_client.dart +++ b/apps/acs/lib/core/services/mqtt_secure_client.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import 'dart:io'; +import 'dart:typed_data'; import 'package:crypto/crypto.dart'; import 'package:mqtt_client/mqtt_client.dart'; @@ -68,6 +69,24 @@ String? mqttRefusalReason(MqttConnectReturnCode? code) => switch (code) { bool mqttRefusalIsTransient(MqttConnectReturnCode? code) => code == MqttConnectReturnCode.brokerUnavailable; +/// Contexto TLS do cliente MQTT: confia **só** na CA do projeto e, se houver +/// certificado de cliente, o apresenta (mTLS). Hostname segue verificado. +SecurityContext buildMqttSecurityContext(SecureMqttConfig config) { + final contexto = SecurityContext(withTrustedRoots: false); + final ca = config.caCertificate; + if (ca != null) { + contexto.setTrustedCertificatesBytes(ca); + } + final cert = config.clientCertificate; + final chave = config.clientPrivateKey; + if (cert != null && chave != null) { + contexto + ..useCertificateChainBytes(cert) + ..usePrivateKeyBytes(chave); + } + return contexto; +} + class SecureMqttConfig { const SecureMqttConfig({ required this.brokerHost, @@ -79,6 +98,8 @@ class SecureMqttConfig { this.username, this.password, this.caCertificate, + this.clientCertificate, + this.clientPrivateKey, }); final String brokerHost; @@ -102,6 +123,11 @@ class SecureMqttConfig { /// dentro do APK, onde não existe caminho no sistema de arquivos. final List? caCertificate; + /// Certificado e chave privada de CLIENTE (PEM) para o mTLS do broker. Só + /// valem juntos; sem os dois, o cliente não apresenta certificado. + final Uint8List? clientCertificate; + final Uint8List? clientPrivateKey; + String get connectionUri { if (useWebSocket) { final scheme = useTls ? 'wss' : 'ws'; @@ -122,6 +148,7 @@ class ReceivedMqttAlert { required this.locationHash, this.locationCell, required this.triggeredAt, + this.escalated = false, }); final String alertId; @@ -137,6 +164,11 @@ class ReceivedMqttAlert { final String? locationCell; final DateTime triggeredAt; + /// Reforço da coordenação (#44): o backend republica o MESMO alerta com + /// `"escalated": true`. Opcional; só `true` literal conta — ausente, `false` + /// ou de outro tipo é `false`. Não carrega identidade de quem escalonou. + final bool escalated; + static ReceivedMqttAlert? tryParse(String body) { try { final json = jsonDecode(body) as Map; @@ -153,6 +185,7 @@ class ReceivedMqttAlert { locationHash: (json['location_hash'] as String?) ?? (json['location_hash'] ?? 'unknown') as String, locationCell: json['location_cell'] as String?, triggeredAt: DateTime.parse(triggeredAtRaw), + escalated: json['escalated'] == true, ); } on FormatException { return null; @@ -287,8 +320,7 @@ class MqttSecureClient { // é o que o certificado com subjectAltName (infra/docker/mosquitto/init.sh) // passou a satisfazer. Não desligue com `onBadCertificate`: seria abrir o // caminho de spoofing do tópico de alertas. - client.securityContext = SecurityContext(withTrustedRoots: false) - ..setTrustedCertificatesBytes(caCertificate); + client.securityContext = buildMqttSecurityContext(config); } // O cliente é assumido ANTES de conectar. Com `autoReconnect`, uma falha de diff --git a/apps/acs/lib/core/services/offline_visit_queue.dart b/apps/acs/lib/core/services/offline_visit_queue.dart index d52c9bc1..759cb944 100644 --- a/apps/acs/lib/core/services/offline_visit_queue.dart +++ b/apps/acs/lib/core/services/offline_visit_queue.dart @@ -175,6 +175,162 @@ class InMemoryVisitStore implements VisitStore { } } +/// Fábrica de armazenamento por dono. [VisitStore] continua sendo load/save, +/// mas agora SEMPRE de um único dono (id do ACS). +abstract interface class VisitStorage { + VisitStore forOwner(String ownerId); + + /// Quarentena (D2): as visitas legadas, sem dono. + LegacyVisitStore get legacy; + + /// Quantas visitas (pendentes e recusadas) cada dono tem no aparelho. Só a + /// contagem — nenhum conteúdo de visita sai daqui. Não inclui a quarentena. + /// Usado pelo envio diferido para achar donos sem token e para dizer quanto + /// ainda não subiu. + Future> countsByOwner(); + + /// "Limpar este aparelho" (D9): apaga os dados do app neste aparelho — as + /// visitas (que, se existirem, não foram enviadas), o cursor do pull e o + /// cache da microárea — numa transação. **Recusa** com [WipeBlocked], sem + /// apagar nada, se houver QUALQUER visita no aparelho, de qualquer dono ou + /// da quarentena: tudo o que está em `offline_visits` ainda não subiu. + Future wipeAllData(); +} + +/// "Limpar este aparelho" recusado: ainda há [unsent] visitas não enviadas. +/// Só a contagem — nenhum conteúdo de visita. +class WipeBlocked implements Exception { + const WipeBlocked(this.unsent); + + final int unsent; + + @override + String toString() => 'WipeBlocked: $unsent visita(s) ainda não enviada(s)'; +} + +/// Quarentena (D2): as linhas com `owner IS NULL`. Nenhum dono as enxerga. +abstract interface class LegacyVisitStore { + Future> load(); + + /// Remove só estes `localId`s legados (depois do 200 do servidor). + Future remove(Iterable localIds); +} + +/// Lançada quando um `localId` não pode ser gravado: ou ele já pertence a +/// outro dono (ou à quarentena), ou aparece repetido na própria lista. `local_id` +/// é chave primária global: a visita alheia NUNCA é sobrescrita nem apagada, e a +/// gravação inteira é desfeita (tudo ou nada). +class VisitLocalIdConflict implements Exception { + VisitLocalIdConflict(this.localId, {this.repeatedInList = false}); + + final String localId; + + /// true quando o `localId` se repete dentro do mesmo `save`. + final bool repeatedInList; + + @override + String toString() => repeatedInList + ? 'VisitLocalIdConflict: localId $localId repetido na mesma gravação' + : 'VisitLocalIdConflict: localId $localId já pertence a outro dono'; +} + +/// Valida o id de dono: vazio ou só espaços é erro de programação (sem dono, a +/// visita cairia na quarentena ou numa visão compartilhada). +String requireOwnerId(String ownerId) { + if (ownerId.trim().isEmpty) { + throw ArgumentError.value(ownerId, 'ownerId', 'não pode ser vazio'); + } + return ownerId; +} + +/// Falha com [VisitLocalIdConflict] se [visits] repete algum `localId`. +void ensureNoRepeatedLocalIds(List visits) { + final seen = {}; + for (final visit in visits) { + if (!seen.add(visit.localId)) { + throw VisitLocalIdConflict(visit.localId, repeatedInList: true); + } + } +} + +/// [VisitStorage] em memória: uma visão por dono sobre um registro único, com a +/// mesma unicidade global de `localId` do SQLCipher. +class InMemoryVisitStorage implements VisitStorage { + InMemoryVisitStorage({Iterable legacyVisits = const []}) + : _legacy = _InMemoryLegacyVisitStore(legacyVisits); + + final Map> _byOwner = {}; + final _InMemoryLegacyVisitStore _legacy; + final Map _views = {}; + + @override + VisitStore forOwner(String ownerId) => _views.putIfAbsent( + requireOwnerId(ownerId), + () => _InMemoryOwnerVisitStore(this, ownerId), + ); + + @override + LegacyVisitStore get legacy => _legacy; + + @override + Future> countsByOwner() async => { + for (final entry in _byOwner.entries) + if (entry.value.isNotEmpty) entry.key: entry.value.length, + }; + + /// Não há cursor nem cache em memória: só confere e esvazia as visitas. + @override + Future wipeAllData() async { + final unsent = _byOwner.values.fold(0, (total, visits) => total + visits.length) + + _legacy._visits.length; + if (unsent > 0) throw WipeBlocked(unsent); + _byOwner.clear(); + } +} + +class _InMemoryOwnerVisitStore implements VisitStore { + _InMemoryOwnerVisitStore(this._storage, this._owner); + + final InMemoryVisitStorage _storage; + final String _owner; + + @override + Future> load() async => + List.of(_storage._byOwner[_owner] ?? const []); + + @override + Future save(List visits) async { + ensureNoRepeatedLocalIds(visits); + final taken = { + for (final entry in _storage._byOwner.entries) + if (entry.key != _owner) ...entry.value.map((v) => v.localId), + ..._storage._legacy._visits.map((v) => v.localId), + }; + for (final visit in visits) { + if (taken.contains(visit.localId)) { + throw VisitLocalIdConflict(visit.localId); + } + } + _storage._byOwner[_owner] = List.of(visits); + } +} + +class _InMemoryLegacyVisitStore implements LegacyVisitStore { + _InMemoryLegacyVisitStore(Iterable visits) + : _visits = List.of(visits); + + final List _visits; + + @override + Future> load() async => List.of(_visits); + + @override + Future remove(Iterable localIds) async { + final ids = localIds.toSet(); + _visits.removeWhere((visit) => ids.contains(visit.localId)); + } +} + /// Fila offline-first de visitas domiciliares. /// /// Preserva a semântica da `SyncFsm` do backend @@ -273,7 +429,29 @@ class OfflineVisitQueue { } } - Future sync({bool forceConflict = false}) async { + Future? _syncing; + + /// `true` enquanto um [sync] está em voo (o lote pode estar no servidor). + /// Quem descarta filas (o app, ao trocar de dono) não pode descartar esta: + /// uma segunda fila sobre o mesmo dono seria regravada às cegas por esta + /// quando o envio terminasse. + bool get isSyncing => _syncing != null; + + /// Envia o que está pendente. **Single-flight**: chamar de novo com um envio + /// em voo devolve o resultado DESSE envio (o lote nunca sai duas vezes, nem + /// é contado duas vezes); o que foi registrado depois da captura do lote + /// fica para o próximo `sync()`. + Future sync({bool forceConflict = false}) { + final inFlight = _syncing; + if (inFlight != null) return inFlight; + late final Future run; + run = _syncOnce(forceConflict: forceConflict).whenComplete(() { + if (identical(_syncing, run)) _syncing = null; + }); + return _syncing = run; + } + + Future _syncOnce({required bool forceConflict}) async { if (_pending.isEmpty) { return const SyncOutcome(kind: SyncOutcomeKind.empty, processed: 0); } @@ -374,9 +552,16 @@ class OfflineVisitQueue { } } + // `add()` pode ter entrado com o lote no servidor: quem não estava no lote + // capturado continua pendente, depois dos que voltaram para a fila. Refazer + // `_pending` só com o lote apagava essa visita da memória e, no `_persist` + // abaixo, do disco. + final inBatch = {for (final visit in batch) visit.localId}; + final arrived = [for (final visit in _pending) if (!inBatch.contains(visit.localId)) visit]; _pending ..clear() - ..addAll(stillPending); + ..addAll(stillPending) + ..addAll(arrived); await _persist(); // Precedência: recusado > conflito > erro > sincronizado. Recusado é diff --git a/apps/acs/lib/core/services/theme_controller.dart b/apps/acs/lib/core/services/theme_controller.dart new file mode 100644 index 00000000..92889153 --- /dev/null +++ b/apps/acs/lib/core/services/theme_controller.dart @@ -0,0 +1,51 @@ +import 'dart:developer' as developer; + +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Preferência de tema (claro/escuro/automático) do ACS, persistida +/// localmente. +/// +/// `ValueNotifier` simples em vez de framework de estado externo — o app já +/// não usa Provider/Riverpod/Bloc (ver AGENTS.md), e o `MaterialApp` é o +/// único ouvinte. Falha ao ler/gravar a preferência NUNCA pode travar a +/// tela: é uma preferência de UI, não um dado que o app precisa para +/// funcionar, então qualquer erro aqui apenas mantém `ThemeMode.system`. +class ThemeController extends ValueNotifier { + ThemeController([super.value = ThemeMode.system]); + + static const _prefsKey = 'theme_mode'; + + Future restore() async { + try { + final prefs = await SharedPreferences.getInstance(); + final saved = prefs.getString(_prefsKey); + value = ThemeMode.values.firstWhere( + (mode) => mode.name == saved, + orElse: () => ThemeMode.system, + ); + } catch (error, stackTrace) { + developer.log( + 'não foi possível restaurar a preferência de tema', + name: 'sinalacs.acs.theme_controller', + error: error, + stackTrace: stackTrace, + ); + } + } + + Future setThemeMode(ThemeMode mode) async { + value = mode; + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_prefsKey, mode.name); + } catch (error, stackTrace) { + developer.log( + 'não foi possível salvar a preferência de tema', + name: 'sinalacs.acs.theme_controller', + error: error, + stackTrace: stackTrace, + ); + } + } +} diff --git a/apps/acs/lib/core/services/visit_pull_service_factory.dart b/apps/acs/lib/core/services/visit_pull_service_factory.dart index c0ff0e06..97051fda 100644 --- a/apps/acs/lib/core/services/visit_pull_service_factory.dart +++ b/apps/acs/lib/core/services/visit_pull_service_factory.dart @@ -1,3 +1,5 @@ +import 'package:flutter/foundation.dart' show visibleForTesting; +import 'package:sinalacs_acs/core/database/sqlcipher_visit_store.dart'; import 'package:sinalacs_acs/core/database/sync_cursor_store.dart'; import 'package:sinalacs_acs/core/network/backend_client.dart'; import 'package:sinalacs_acs/core/security/database_key_store.dart'; @@ -13,17 +15,33 @@ import 'package:sinalacs_acs/core/services/visit_pull_service.dart'; /// documentação da própria classe). Passar um store diferente perde essa /// garantia sem lançar nenhum erro visível. /// -/// [cursorStore] existe só para o teste poder inspecionar o cursor gravado; -/// em produção a chamada não passa nada e usa o padrão, respaldado pelo -/// Keystore/Keychain do aparelho. +/// [cursorOwner] é o dono do cursor (`visits_pull|`): o cursor de um ACS +/// nunca vale para outro. +/// +/// [cursorDatabase] é o [VisitDatabase] da fila: o cursor é gravado nele +/// (`SyncCursorStore.on`), uma só conexão para o arquivo. É o que o app passa. +/// +/// [cursorStore] existe só para o teste poder inspecionar o cursor gravado. +/// Sem nenhum dos dois, abre um banco próprio respaldado pelo Keystore/Keychain +/// do aparelho. VisitPullService buildVisitPullService({ required AcsBackend backend, required VisitStore localVisits, + required String cursorOwner, + VisitDatabase? cursorDatabase, SyncCursorStore? cursorStore, -}) => - VisitPullService( - backend: backend, - cursorStore: cursorStore ?? - SyncCursorStore(keyStore: SecureStorageDatabaseKeyStore()), - localVisits: localVisits, - ); + @visibleForTesting DatabaseKeyStore? keyStore, + @visibleForTesting String databaseName = 'sinalacs_acs.db', + @visibleForTesting bool allowUnencryptedForTesting = false, +}) { + final cursor = cursorStore ?? + (cursorDatabase != null + ? SyncCursorStore.on(cursorDatabase, owner: cursorOwner) + : SyncCursorStore( + keyStore: keyStore ?? SecureStorageDatabaseKeyStore(), + owner: cursorOwner, + databaseName: databaseName, + allowUnencryptedForTesting: allowUnencryptedForTesting, + )); + return VisitPullService(backend: backend, cursorStore: cursor, localVisits: localVisits); +} diff --git a/apps/acs/lib/core/services/visit_queue_factory.dart b/apps/acs/lib/core/services/visit_queue_factory.dart index 6ba5cae7..c3b7ae58 100644 --- a/apps/acs/lib/core/services/visit_queue_factory.dart +++ b/apps/acs/lib/core/services/visit_queue_factory.dart @@ -1,10 +1,28 @@ import 'package:sinalacs_acs/core/database/sqlcipher_visit_store.dart'; +import 'package:sinalacs_acs/core/network/auth_session.dart'; import 'package:sinalacs_acs/core/network/backend_client.dart'; -import 'package:sinalacs_acs/core/security/database_key_store.dart'; import 'package:sinalacs_acs/core/services/backend_visit_synchronizer.dart'; import 'package:sinalacs_acs/core/services/offline_visit_queue.dart'; +import 'package:sinalacs_acs/core/services/visit_pull_service.dart'; +import 'package:sinalacs_acs/core/services/visit_pull_service_factory.dart'; -/// Monta a fila de visitas como ela roda em produção. +/// Fila + pull de UM dono, prontos para o painel dele. +class OwnerVisitScope { + const OwnerVisitScope({required this.queue, required this.pullService}); + + final OfflineVisitQueue queue; + final VisitPullService pullService; +} + +/// Resolve a fila e o pull do dono de uma sessão. +typedef VisitScopeResolver = OwnerVisitScope Function(AuthSession session); + +/// Dono do cursor de `visits.pull`: usuário E microárea. O mesmo ACS movido de +/// microárea não herda o `since` do território anterior. +String visitCursorOwner(AuthSession session) => + '${session.userId}|${session.microAreaId ?? ''}'; + +/// Monta a fila de visitas de [ownerId] como ela roda em produção. /// /// Existe como função pública, e não como método privado da `State` do app, /// porque a ausência do sincronizador foi um defeito que nenhum teste podia @@ -12,13 +30,38 @@ import 'package:sinalacs_acs/core/services/offline_visit_queue.dart'; /// nunca era exercitada. `sync()` caía no ramo sem remetente e devolvia erro — /// as visitas nunca subiam e o disco nunca era liberado. /// -/// [store] existe para o teste trocar o banco criptografado por memória; o -/// `SqlCipherVisitStore` depende de canal de plataforma e não abre na VM. +/// [store] é obrigatório: é a visão do dono (`VisitStorage.forOwner`). Não há +/// mais padrão que abra o banco sem dono. OfflineVisitQueue buildVisitQueue({ required AcsBackend backend, - VisitStore? store, + required VisitStore store, + required String ownerId, }) => OfflineVisitQueue( - store: store ?? SqlCipherVisitStore(keyStore: SecureStorageDatabaseKeyStore()), - synchronizer: BackendVisitSynchronizer(backend: backend), + store: store, + synchronizer: BackendVisitSynchronizer(backend: backend, ownerId: ownerId), ); + +/// Fila e pull do dono de [session] sobre UM [storage]. +/// +/// O dono da fila é o `userId`; o do cursor, `userId|microAreaId`. Com um +/// [SqlCipherVisitStorage], o cursor é gravado no MESMO [VisitDatabase] da fila +/// (uma conexão por arquivo). [queue], se passada, é reaproveitada — o app +/// mantém uma fila por `userId` e nunca duas sobre o mesmo dono. +OwnerVisitScope buildOwnerVisitScope({ + required AcsBackend backend, + required VisitStorage storage, + required AuthSession session, + OfflineVisitQueue? queue, +}) { + final store = storage.forOwner(session.userId); + return OwnerVisitScope( + queue: queue ?? buildVisitQueue(backend: backend, store: store, ownerId: session.userId), + pullService: buildVisitPullService( + backend: backend, + localVisits: store, + cursorOwner: visitCursorOwner(session), + cursorDatabase: storage is SqlCipherVisitStorage ? storage.database : null, + ), + ); +} diff --git a/apps/acs/lib/main.dart b/apps/acs/lib/main.dart index 67d92960..67a8cd41 100644 --- a/apps/acs/lib/main.dart +++ b/apps/acs/lib/main.dart @@ -1,10 +1,17 @@ import 'dart:io'; +import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart' show rootBundle; import 'package:sinalacs_acs/app/app.dart'; import 'package:sinalacs_acs/core/network/backend_client.dart'; import 'package:sinalacs_acs/core/network/backend_config.dart'; +import 'package:sinalacs_acs/core/security/auth_bound_session_token_store.dart'; +import 'package:sinalacs_acs/core/security/biometric_gate.dart'; +import 'package:sinalacs_acs/core/security/keystore_vault.dart'; +import 'package:sinalacs_acs/core/security/secure_session_token_store.dart'; +import 'package:sinalacs_acs/core/security/secure_upload_token_store.dart'; +import 'package:sinalacs_acs/core/services/micro_area_directory_factory.dart'; /// Sobe o app do ACS. /// @@ -28,11 +35,25 @@ Future main({String? defaultHost}) async { // disfarçada: o `catch` da leitura da CA a capturava, logava *"CA do RPC não // pôde ser carregada"* (falso, a CA tinha carregado) e a segunda construção // do cliente estourava fora de qualquer guarda, antes do `runApp`. + // Uma instância de cada, compartilhada entre o cliente e o envio diferido: + // o id do aparelho é criado uma vez só, e o token de envio que o login + // grava é o mesmo que o envio diferido lê. + final deviceIds = SecureStorageDeviceIdStore(); + final uploadTokens = SecureStorageUploadTokenStore(); + AcsBackend backend; try { backend = BackendClient( host: requireSecureHost(defaultHost ?? BackendConfig.host), trustedCaBytes: caBytes, + // Refresh token e id do aparelho no Keystore/Keychain, nunca em texto. + tokenStore: AuthBoundSessionTokenStore( + vault: MethodChannelKeystoreVault(), + legacy: SecureStorageSessionTokenStore(), + ), + deviceIds: deviceIds, + // Token de envio diferido por dono (D7), no Keystore. + uploadTokens: uploadTokens, ); } on BackendFailure catch (failure) { debugPrint( @@ -45,7 +66,23 @@ Future main({String? defaultHost}) async { // O cliente é construído aqui, e não dentro de `SinalAcsApp`, porque só aqui // a CA já foi lida: `BackendConfig` não importa `rootBundle` de propósito — // `tool/live_check.dart` o importa fora do Flutter, onde `dart:ui` não existe. - runApp(SinalAcsApp(backend: backend)); + // + // O cache da microárea (RF08) também nasce aqui: é a única montagem que usa o + // Keystore do aparelho. + runApp(SinalAcsApp( + backend: backend, + microAreaDirectory: buildMicroAreaDirectory(backend: backend), + // Digital/rosto ou bloqueio de tela: retomada na partida e bloqueio por + // inatividade. + biometricGate: LocalAuthBiometricGate(), + // Envio diferido (legado e filas de quem saiu): depois do login, na + // retomada e quando a rede volta (D8). + uploadTokens: uploadTokens, + deviceIds: deviceIds, + connectivityChanges: Connectivity() + .onConnectivityChanged + .map((results) => results.any((r) => r != ConnectivityResult.none)), + )); } /// Os bytes da CA de desenvolvimento do RPC, ou `null` quando ela não pode ser diff --git a/apps/acs/pubspec.lock b/apps/acs/pubspec.lock index 0f1089bb..952af732 100644 --- a/apps/acs/pubspec.lock +++ b/apps/acs/pubspec.lock @@ -351,6 +351,14 @@ packages: description: flutter source: sdk version: "0.0.0" + intl: + dependency: transitive + description: + name: intl + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" + url: "https://pub.dev" + source: hosted + version: "0.20.3" jni: dependency: transitive description: @@ -415,6 +423,46 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + local_auth: + dependency: "direct main" + description: + name: local_auth + sha256: ecf24edf2283c509ecd217e3595f6f71034b68888d28ad1dae6bfa0857b816ac + url: "https://pub.dev" + source: hosted + version: "3.0.2" + local_auth_android: + dependency: transitive + description: + name: local_auth_android + sha256: "76c389998fe95cff4352644776ab5775f24ddd58f179dbf3df1b9c268cbbadc4" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + local_auth_darwin: + dependency: transitive + description: + name: local_auth_darwin + sha256: b1b2d938cc3511512e552d92e782e055b8dc29c83e21c59e6ecfc6bd79ab0744 + url: "https://pub.dev" + source: hosted + version: "2.0.4" + local_auth_platform_interface: + dependency: transitive + description: + name: local_auth_platform_interface + sha256: f98b8e388588583d3f781f6806e4f4c9f9e189d898d27f0c249b93a1973dd122 + url: "https://pub.dev" + source: hosted + version: "1.1.0" + local_auth_windows: + dependency: transitive + description: + name: local_auth_windows + sha256: d72b8a441e5e6a679902f87c51529fcce65f442d77598484cf35454c3d733eda + url: "https://pub.dev" + source: hosted + version: "2.0.2" matcher: dependency: transitive description: @@ -551,6 +599,22 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.6" + qr: + dependency: transitive + description: + name: qr + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + qr_flutter: + dependency: "direct main" + description: + name: qr_flutter + sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097" + url: "https://pub.dev" + source: hosted + version: "4.1.0" sanitize_html: dependency: transitive description: @@ -591,6 +655,62 @@ packages: url: "https://pub.dev" source: hosted version: "3.4.13" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "1e12aafe408aa50da80edfd679a2a6bf63ba7ab37c7fa98286da459a757b3399" + url: "https://pub.dev" + source: hosted + version: "2.4.28" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea" + url: "https://pub.dev" + source: hosted + version: "2.5.7" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" sinalacs_client: dependency: "direct main" description: @@ -763,6 +883,70 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.2" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "611e87fb320b70d1dd721dc46af89c98aceccea9b31fde49e084591414e0c610" + url: "https://pub.dev" + source: hosted + version: "6.3.33" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "8faa1aab294f1ab4040b43660c887b0418d5fa4f0cffef76a484e6aa1092eb4a" + url: "https://pub.dev" + source: hosted + version: "6.4.2" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "10f86fef4c2c43563fa6c211ff9cf757adf4d3ab762c56bd430664a947d70cd0" + url: "https://pub.dev" + source: hosted + version: "3.2.3" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "5e835a3b869c2d70325349c81c5a45c28e20791265b67b2669da6b08c5cd5201" + url: "https://pub.dev" + source: hosted + version: "3.2.6" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "6c5ad3f22cd4c38e089b81963b3cd7bb83b111b2df5dce008bb066162f42e429" + url: "https://pub.dev" + source: hosted + version: "3.1.6" uuid: dependency: transitive description: diff --git a/apps/acs/pubspec.yaml b/apps/acs/pubspec.yaml index fed18778..7d55f302 100644 --- a/apps/acs/pubspec.yaml +++ b/apps/acs/pubspec.yaml @@ -30,6 +30,14 @@ dependencies: mqtt_client: ^10.0.0 geolocator: ^12.0.0 google_maps_flutter: ^2.10.1 + qr_flutter: ^4.1.0 + # RF13: abre o discador do aparelho (tel:192). O PRD já a nomeia na matriz + # de capacidades; é invólucro de intent do sistema, não framework novo. + url_launcher: ^6.3.0 + # Preferência de tema (claro/escuro/automático) — dado de UI não sensível, + # não precisa do Keystore/Keychain do flutter_secure_storage. + shared_preferences: ^2.3.2 + local_auth: ^3.0.2 dev_dependencies: diff --git a/apps/acs/test/alert_card_escalation_test.dart b/apps/acs/test/alert_card_escalation_test.dart new file mode 100644 index 00000000..e8185e40 --- /dev/null +++ b/apps/acs/test/alert_card_escalation_test.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/app/acs_theme.dart'; +import 'package:sinalacs_acs/app/app.dart'; +import 'package:sinalacs_acs/core/services/alert_queue.dart'; + +import 'support/fakes.dart'; + +/// Parte visível do reforço (#44) no cartão do painel. +void main() { + Future pintar(WidgetTester tester, AlertQueue queue) => tester.pumpWidget(MaterialApp( + theme: buildAcsDarkTheme(), + home: Scaffold(body: DashboardScreen(queue: queue)), + )); + + testWidgets('cartão de reforço mostra rótulo, ícone e frase de leitor de tela', (tester) async { + final handle = tester.ensureSemantics(); + final queue = AlertQueue(microAreaId: seedMicroAreaId) + ..upsert(testAlert(alertId: 'normal', triggeredAt: DateTime.utc(2026, 9, 11, 8))) + ..upsert(testAlert(alertId: 'reforcado', triggeredAt: DateTime.utc(2026, 9, 11, 9), escalated: true)); + await pintar(tester, queue); + + expect(find.byKey(const Key('escalated_reforcado')), findsOneWidget); + expect(find.byKey(const Key('escalated_normal')), findsNothing); + expect(find.text('Reforço da coordenação'), findsOneWidget); + expect(find.descendant(of: find.byKey(const Key('alert_reforcado')), matching: find.byIcon(Icons.campaign)), findsOneWidget); + expect(find.descendant(of: find.byKey(const Key('alert_normal')), matching: find.byIcon(Icons.campaign)), findsNothing); + + // Frase única do cartão: só o reforçado a traz. + expect(find.bySemanticsLabel(RegExp('reforço da coordenação')), findsOneWidget); + + // Texto no token *OnSurface do risco (vermelho), não no preenchimento. + final texto = tester.widget(find.byKey(const Key('escalated_reforcado'))); + expect(texto.style?.color, AcsRiskColors.dark.redOnSurface); + handle.dispose(); + }); + + testWidgets('cartão reforçado já confirmado não quebra e segue confirmado', (tester) async { + final queue = AlertQueue(microAreaId: seedMicroAreaId) + ..upsert(testAlert(alertId: 'a1', escalated: true)) + ..markAcknowledged('a1'); + await pintar(tester, queue); + + expect(find.text('Recebimento confirmado'), findsOneWidget); + expect(find.byKey(const Key('ack_a1')), findsNothing); + expect(tester.takeException(), isNull); + }); +} diff --git a/apps/acs/test/alert_feed_test.dart b/apps/acs/test/alert_feed_test.dart index f331b064..57025f63 100644 --- a/apps/acs/test/alert_feed_test.dart +++ b/apps/acs/test/alert_feed_test.dart @@ -37,4 +37,17 @@ void main() { ); }); }); + + group('certificado de cliente (mTLS)', () { + test('falta do certificado de cliente é uma falha própria e não transitória', () { + expect(AlertFeedFailureKind.values, + contains(AlertFeedFailureKind.missingClientCertificate)); + const falha = AlertFeedFailure( + AlertFeedFailureKind.missingClientCertificate, + title: 'Falta o certificado de cliente da central neste aplicativo.', + detail: 'Sem ele o broker recusa a conexão.', + ); + expect(falha.transient, isFalse); + }); + }); } diff --git a/apps/acs/test/alert_queue_escalation_test.dart b/apps/acs/test/alert_queue_escalation_test.dart new file mode 100644 index 00000000..5d41937f --- /dev/null +++ b/apps/acs/test/alert_queue_escalation_test.dart @@ -0,0 +1,88 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/core/services/alert_queue.dart'; +import 'package:sinalacs_acs/core/services/mqtt_secure_client.dart'; + +import 'support/fakes.dart'; + +/// Reforço do escalonamento (#44): o backend republica o MESMO alerta com +/// `escalated: true`. O "sinal" de alerta novo neste app é `upsert` devolver +/// `true` (+ `notifyListeners`, que move o contador/banner); não há som nem +/// vibração próprios. +void main() { + late AlertQueue queue; + setUp(() => queue = AlertQueue(microAreaId: seedMicroAreaId)); + + test('reforço de alerta não confirmado sobe ao topo e sinaliza', () { + queue.upsert(testAlert(alertId: 'antigo', triggeredAt: DateTime.utc(2026, 9, 11, 8))); + queue.upsert(testAlert(alertId: 'alvo', triggeredAt: DateTime.utc(2026, 9, 11, 9))); + var avisos = 0; + queue.addListener(() => avisos++); + + final sinalizou = queue.upsert(testAlert(alertId: 'alvo', triggeredAt: DateTime.utc(2026, 9, 11, 9), escalated: true)); + + expect(sinalizou, isTrue); + expect(avisos, 1); + expect(queue.alerts.map((a) => a.alertId), ['alvo', 'antigo']); + expect(queue.alerts.first.escalated, isTrue); + expect(queue.alerts, hasLength(2)); + }); + + test('reforço de alerta já confirmado é ignorado', () { + queue.upsert(testAlert(alertId: 'a1')); + queue.markAcknowledged('a1'); + var avisos = 0; + queue.addListener(() => avisos++); + + expect(queue.upsert(testAlert(alertId: 'a1', escalated: true)), isFalse); + + expect(avisos, 0); + expect(queue.alerts.single.acknowledged, isTrue); + expect(queue.alerts.single.escalated, isFalse); + }); + + test('reforço de alerta desconhecido entra como alerta com rótulo', () { + expect(queue.upsert(testAlert(alertId: 'novo', escalated: true)), isTrue); + + expect(queue.alerts.single.alertId, 'novo'); + expect(queue.alerts.single.escalated, isTrue); + expect(queue.unacknowledgedCount, 1); + }); + + test('reforço repetido (QoS 1) não sinaliza duas vezes', () { + queue.upsert(testAlert(alertId: 'a1')); + expect(queue.upsert(testAlert(alertId: 'a1', escalated: true)), isTrue); + var avisos = 0; + queue.addListener(() => avisos++); + + expect(queue.upsert(testAlert(alertId: 'a1', escalated: true)), isFalse); + // A reentrega do original (sem marcador) também não apaga o reforço. + queue.upsert(testAlert(alertId: 'a1')); + + expect(queue.alerts.single.escalated, isTrue); + expect(queue.alerts, hasLength(1)); + }); + + test('envelope sem escalated continua igual (regressão)', () { + final parsed = ReceivedMqttAlert.tryParse( + '{"version":1,"alert_id":"a1","patient_id":"p","micro_area_id":"$seedMicroAreaId","risk_level":"red","location_hash":"h","triggered_at":"2026-09-01T12:00:00.000Z"}'); + expect(parsed?.escalated, isFalse); + expect(PrioritizedAlert.fromMqtt(parsed!).escalated, isFalse); + + expect(queue.upsert(testAlert(alertId: 'a1')), isTrue); + expect(queue.upsert(testAlert(alertId: 'a1')), isTrue); // reentrega comum, como antes + expect(queue.alerts.single.escalated, isFalse); + }); + + test('chave desconhecida no envelope não quebra o parse', () { + final parsed = ReceivedMqttAlert.tryParse( + '{"version":1,"alert_id":"a1","patient_id":"p","micro_area_id":"m","risk_level":"red","location_hash":"h","triggered_at":"2026-09-01T12:00:00.000Z","escalated":true,"chave_futura":{"x":1},"outra":[1,2]}'); + + expect(parsed, isNotNull); + expect(parsed!.escalated, isTrue); + // `escalated` com tipo errado não derruba nem vira reforço. + final torto = ReceivedMqttAlert.tryParse( + '{"version":1,"alert_id":"a1","patient_id":"p","micro_area_id":"m","risk_level":"red","location_hash":"h","triggered_at":"2026-09-01T12:00:00.000Z","escalated":"sim"}'); + expect(torto, isNotNull); + expect(torto!.escalated, isFalse); + }); +} diff --git a/apps/acs/test/android_manifest_test.dart b/apps/acs/test/android_manifest_test.dart new file mode 100644 index 00000000..7c62f29d --- /dev/null +++ b/apps/acs/test/android_manifest_test.dart @@ -0,0 +1,109 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +/// O check-in passivo (RF12) e o mapa dependem de `Geolocator`, que só pede +/// permissão em runtime se ela estiver DECLARADA no manifesto. Sem a +/// declaração o `requestPermission()` não mostra diálogo nenhum e +/// `_loadCurrentPosition` engole o erro: o GPS some em silêncio. Os testes de +/// widget injetam a posição e nunca viram isso — por isso este teste lê o +/// manifesto de release (`main`), o que o APK de produção realmente leva. +void main() { + final manifest = File('android/app/src/main/AndroidManifest.xml').readAsStringSync(); + + bool declares(String permission) => RegExp( + '', + ).hasMatch(manifest); + + test('declara localização precisa e aproximada (RF12)', () { + expect(declares('ACCESS_FINE_LOCATION'), isTrue); + expect(declares('ACCESS_COARSE_LOCATION'), isTrue); + }); + + test('declara INTERNET no manifesto de release, sem depender de plugin', () { + expect(declares('INTERNET'), isTrue); + }); + + test('declara a consulta ao discador (tel:) para o botão do SAMU (RF13)', () { + // Android 11+: sem , `canLaunchUrl(tel:)` devolve false mesmo com discador. + expect( + RegExp(r'\s*') + .hasMatch(manifest), + isTrue, + ); + }); + + test('NÃO pede localização em segundo plano (decisão §4 de 2026-09-16)', () { + expect(declares('ACCESS_BACKGROUND_LOCATION'), isFalse, + reason: 'o geofence é só em primeiro plano; background exigiria revisão da loja e LGPD'); + }); + + test('NÃO deixa o Android copiar o app para a nuvem (INV-04 / LGPD)', () { + // allowBackup é `true` por padrão. Sem `false` explícito, o backup + // automático leva para a conta Google da pessoa o `shared_preferences` e o + // banco local da fila de visitas — dado de saúde fora do controle do + // sistema. A chave do SQLCipher vive no Keystore e não migra, então o + // backup também não restauraria nada que prestasse: só vazaria. + expect( + RegExp(r']*android:allowBackup="false"', dotAll: true).hasMatch(manifest), + isTrue, + ); + }); + + test('o nome na gaveta do aparelho é legível, não o identificador do pacote', () { + expect(manifest, isNot(contains('android:label="sinalacs_acs"'))); + expect(manifest, contains('android:label="SinalACS ACS"')); + }); + + test('declara USE_BIOMETRIC para o desbloqueio por digital', () { + expect(declares('USE_BIOMETRIC'), isTrue); + }); + + test('MainActivity usa FlutterFragmentActivity e mantém FLAG_SECURE', () { + // local_auth falha em tempo de execução com FlutterActivity; e o + // FLAG_SECURE (captura/miniatura) não pode regredir. + final activity = + File('android/app/src/main/kotlin/br/com/prismrr/sinalacs/acs/MainActivity.kt').readAsStringSync(); + expect(activity, contains('class MainActivity : FlutterFragmentActivity()')); + expect(activity, contains('FLAG_SECURE')); + }); + + test('MainActivity registra o cofre do Keystore no canal do Dart', () { + final activity = + File('android/app/src/main/kotlin/br/com/prismrr/sinalacs/acs/MainActivity.kt').readAsStringSync(); + expect(activity, contains('override fun configureFlutterEngine')); + expect(activity, contains('KeystoreVault.CHANNEL')); + expect(activity, contains('KeystoreVault(this)')); + }); + + test('KeystoreVault exige autenticação a cada uso e não registra nada', () { + // flutter test não tem Keystore: a prova real fica no emulador. Aqui se + // guarda o que não pode regredir no código-fonte do cofre. + final vault = + File('android/app/src/main/kotlin/br/com/prismrr/sinalacs/acs/KeystoreVault.kt').readAsStringSync(); + expect(vault, contains('const val CHANNEL = "br.com.prismrr.sinalacs.acs/keystore_vault"')); + expect(vault, contains('.setUserAuthenticationRequired(true)')); + expect(vault, contains('.setInvalidatedByBiometricEnrollment(true)')); + expect(vault, contains('BiometricPrompt.CryptoObject(cipher)')); + expect(vault, contains('MGF1ParameterSpec.SHA1')); + // A biometric 1.1.0 descarta o authenticate em silêncio com o estado já + // salvo: sem esta checagem o canal nunca responderia. + expect(vault, contains('supportFragmentManager.isStateSaved')); + // Blob adulterado (tag GCM) é apagado e vira `invalidated`. + expect(vault, contains('catch (e: AEADBadTagException)')); + for (final code in ['"cancelled"', '"lockedOut"', '"invalidated"', '"unavailable"']) { + expect(vault, contains(code)); + } + // Nada de log nem de mensagem de exceção atravessando o canal. + expect(vault, isNot(contains('android.util.Log'))); + expect(vault, isNot(contains('Log.'))); + expect(vault, isNot(contains('.message'))); + expect(vault, isNot(contains('printStackTrace'))); + expect(vault, isNot(contains('localizedMessage'))); + }); + + test('a dependência do BiometricPrompt está fixada na 1.1.0', () { + final gradle = File('android/app/build.gradle.kts').readAsStringSync(); + expect(gradle, contains('implementation("androidx.biometric:biometric:1.1.0")')); + }); +} diff --git a/apps/acs/test/app_lock_gate_test.dart b/apps/acs/test/app_lock_gate_test.dart new file mode 100644 index 00000000..a278cd8d --- /dev/null +++ b/apps/acs/test/app_lock_gate_test.dart @@ -0,0 +1,437 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/app/acs_theme.dart'; +import 'package:sinalacs_acs/app/app_lock_gate.dart'; +import 'package:sinalacs_acs/core/security/biometric_gate.dart'; + +import 'support/layout_harness.dart' show redimensionar; + +class FakeBiometricGate implements BiometricGate { + UnlockResult result = UnlockResult.unlocked; + int calls = 0; + bool throwOnAuth = false; + Completer? pending; + @override + Future get isAvailable async => true; + @override + Future authenticate({required String reason}) async { + calls++; + if (throwOnAuth) throw StateError('boom'); + if (pending != null) return pending!.future; + return result; + } +} + +class _Painel extends StatefulWidget { + const _Painel({required this.focus}); + final FocusNode focus; + @override + State<_Painel> createState() => _PainelState(); +} + +class _PainelState extends State<_Painel> { + int n = 0; + @override + Widget build(BuildContext context) => Column( + children: [ + TextField(key: const Key('campo'), focusNode: widget.focus), + Text('n=$n'), + TextButton( + onPressed: () => setState(() => n++), + child: const Text('mais'), + ), + ], + ); +} + +void main() { + late FakeBiometricGate gate; + late DateTime now; + late FocusNode focus; + late GlobalKey navKey; + var passwordTaps = 0; + + setUp(() { + gate = FakeBiometricGate(); + now = DateTime(2026, 10, 3, 12); + focus = FocusNode(); + navKey = GlobalKey(); + passwordTaps = 0; + }); + + Future pump(WidgetTester t) => t.pumpWidget( + MaterialApp( + navigatorKey: navKey, + theme: buildAcsDarkTheme(), + builder: (context, child) => AppLockGate( + navigatorKey: navKey, + gate: gate, + clock: () => now, + onUsePassword: () => passwordTaps++, + child: child!, + ), + home: Scaffold(body: _Painel(focus: focus)), + ), + ); + + Future background( + WidgetTester t, + Duration away, { + bool settle = true, + }) async { + t.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); + t.binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden); + t.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); + now = now.add(away); + t.binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden); + t.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); + t.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + if (settle) await t.pumpAndSettle(); + } + + testWidgets('fica desbloqueado se voltar do segundo plano antes do prazo', ( + t, + ) async { + await pump(t); + await background(t, const Duration(seconds: 10)); + expect(find.text('Aplicativo bloqueado'), findsNothing); + expect(gate.calls, 0); + }); + + testWidgets('bloqueia ao voltar depois do prazo e mostra "Desbloquear"', ( + t, + ) async { + gate.result = UnlockResult.cancelled; + await pump(t); + await background(t, const Duration(seconds: 31)); + expect(find.text('Aplicativo bloqueado'), findsOneWidget); + expect(find.text('Desbloquear'), findsOneWidget); + }); + + testWidgets( + 'o filho continua MONTADO e com o mesmo estado enquanto bloqueado', + (t) async { + gate.result = UnlockResult.cancelled; + await pump(t); + await t.enterText(find.byKey(const Key('campo')), 'rascunho'); + await t.tap(find.text('mais')); + await t.pump(); + final navigator = t.state(find.byType(Navigator)); + await background(t, const Duration(minutes: 2)); + expect(find.text('Aplicativo bloqueado'), findsOneWidget); + expect(find.text('n=1', skipOffstage: false), findsOneWidget); + expect(find.text('rascunho', skipOffstage: false), findsOneWidget); + expect(t.state(find.byType(Navigator)), same(navigator)); + }, + ); + + testWidgets('desbloqueio bem-sucedido remove a cobertura', (t) async { + await pump(t); + await background(t, const Duration(minutes: 1)); + expect(gate.calls, 1); + expect(find.text('Aplicativo bloqueado'), findsNothing); + }); + + testWidgets('cancelamento mantém bloqueado, sem laço de prompts', (t) async { + gate.result = UnlockResult.cancelled; + await pump(t); + await background(t, const Duration(minutes: 1)); + expect(gate.calls, 1); + await t.pumpAndSettle(); + expect(gate.calls, 1); + await t.tap(find.text('Desbloquear')); + await t.pumpAndSettle(); + expect(gate.calls, 2); + expect(find.text('Aplicativo bloqueado'), findsOneWidget); + }); + + for (final r in [UnlockResult.lockedOut, UnlockResult.unavailable]) { + testWidgets('$r oferece "Entrar com senha" e NÃO desbloqueia', (t) async { + gate.result = r; + await pump(t); + await background(t, const Duration(minutes: 1)); + expect(find.text('Aplicativo bloqueado'), findsOneWidget); + await t.tap(find.text('Entrar com senha')); + expect(passwordTaps, 1); + expect(find.text('Aplicativo bloqueado'), findsOneWidget); + }); + } + + testWidgets( + 'a cobertura não expõe o conteúdo ao leitor de tela nem a toques', + (t) async { + gate.result = UnlockResult.cancelled; + await pump(t); + await background(t, const Duration(minutes: 1)); + final sem = t.ensureSemantics(); + await t.tap(find.text('mais', skipOffstage: false), warnIfMissed: false); + await t.pump(); + expect(find.text('n=0', skipOffstage: false), findsOneWidget); + expect(find.semantics.byLabel('mais'), findsNothing); + sem.dispose(); + }, + ); + + testWidgets('abre o prompt sozinho ao bloquear', (t) async { + gate.result = UnlockResult.cancelled; + await pump(t); + await background(t, const Duration(minutes: 1)); + expect(gate.calls, 1); + }); + + testWidgets('app que nunca saiu do primeiro plano não bloqueia', (t) async { + await pump(t); + now = now.add(const Duration(hours: 1)); + t.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); + t.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await t.pumpAndSettle(); + expect(find.text('Aplicativo bloqueado'), findsNothing); + expect(gate.calls, 0); + }); + + testWidgets( + 'bloquear tira o foco e o campo não recebe foco nem texto enquanto bloqueado', + (t) async { + gate.result = UnlockResult.cancelled; + await pump(t); + await t.tap(find.byKey(const Key('campo'))); + await t.pump(); + expect(focus.hasFocus, isTrue); + await background(t, const Duration(minutes: 1)); + expect(focus.hasFocus, isFalse); + await t.tap( + find.byKey(const Key('campo'), skipOffstage: false), + warnIfMissed: false, + ); + await t.pump(); + expect(focus.hasFocus, isFalse); + await t + .showKeyboard(find.byKey(const Key('campo'), skipOffstage: false)) + .catchError((_) {}); + expect(focus.hasFocus, isFalse); + }, + ); + + testWidgets( + 'voltar fica engolido enquanto bloqueado e funciona desbloqueado', + (t) async { + gate.result = UnlockResult.cancelled; + final nav = GlobalKey(); + await t.pumpWidget( + MaterialApp( + navigatorKey: nav, + theme: buildAcsDarkTheme(), + builder: (context, child) => AppLockGate( + gate: gate, + clock: () => now, + navigatorKey: nav, + child: child!, + ), + home: const Scaffold(body: Text('raiz')), + ), + ); + nav.currentState!.push( + MaterialPageRoute( + builder: (_) => const Scaffold(body: Text('segunda')), + ), + ); + await t.pumpAndSettle(); + await t.binding.handlePopRoute(); + await t.pumpAndSettle(); + expect(find.text('segunda'), findsNothing); // desbloqueado: voltou + + nav.currentState!.push( + MaterialPageRoute( + builder: (_) => const Scaffold(body: Text('segunda')), + ), + ); + await t.pumpAndSettle(); + await background(t, const Duration(minutes: 1)); + await t.binding.handlePopRoute(); + await t.pumpAndSettle(); + expect( + find.text('segunda', skipOffstage: false), + findsOneWidget, + ); // bloqueado: engolido + }, + ); + + testWidgets('relógio que anda para trás bloqueia', (t) async { + gate.result = UnlockResult.cancelled; + await pump(t); + await background(t, const Duration(hours: -1)); + expect(find.text('Aplicativo bloqueado'), findsOneWidget); + }); + + testWidgets( + 'gate que lança falha fechada: continua bloqueado e oferece senha', + (t) async { + gate.throwOnAuth = true; + await pump(t); + await background(t, const Duration(minutes: 1)); + expect(find.text('Aplicativo bloqueado'), findsOneWidget); + expect(find.text('Entrar com senha'), findsOneWidget); + // _prompting foi liberado: o próximo toque não fica preso (botão de retry some em unavailable) + expect(find.text('Desbloquear'), findsNothing); + }, + ); + + testWidgets( + '"Desbloquear" some em lockedOut e unavailable; aparece após cancelar', + (t) async { + for (final r in [UnlockResult.lockedOut, UnlockResult.unavailable]) { + gate.result = r; + await pump(t); + await background(t, const Duration(minutes: 1)); + expect(find.text('Desbloquear'), findsNothing, reason: '$r'); + await t.pumpWidget(const SizedBox()); + } + gate.result = UnlockResult.cancelled; + await pump(t); + await background(t, const Duration(minutes: 1)); + expect(find.text('Desbloquear'), findsOneWidget); + }, + ); + + testWidgets('dispose do gate bloqueado remove o bloqueio do voltar', ( + t, + ) async { + gate.result = UnlockResult.cancelled; + await pump(t); + navKey.currentState!.push( + MaterialPageRoute( + builder: (_) => const Scaffold(body: Text('segunda')), + ), + ); + await t.pumpAndSettle(); + await background(t, const Duration(minutes: 1)); + // tira o gate da árvore (chave diferente) com o Navigator preservado + await t.pumpWidget( + MaterialApp( + navigatorKey: navKey, + home: const Scaffold(body: Text('raiz')), + ), + ); + await t.pumpAndSettle(); + await t.binding.handlePopRoute(); + await t.pumpAndSettle(); + expect(find.text('segunda'), findsNothing); + }); + + testWidgets( + 'Navigator que aparece depois de bloquear recebe o bloqueio do voltar', + (t) async { + gate.result = UnlockResult.cancelled; + final key = GlobalKey(); + var mostrar = false; + late StateSetter set; + await t.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (c, s) { + set = s; + return AppLockGate( + gate: gate, + clock: () => now, + navigatorKey: key, + child: mostrar + ? Navigator( + key: key, + onGenerateRoute: (_) => MaterialPageRoute( + builder: (_) => const Text('raiz'), + ), + ) + : const SizedBox(), + ); + }, + ), + ), + ); + await background(t, const Duration(minutes: 1)); + expect(key.currentState, isNull); + set(() => mostrar = true); + await t.pumpAndSettle(); + expect(key.currentState, isNotNull); + await t.binding.handlePopRoute(); + await t.pumpAndSettle(); + // com o bloqueio instalado o pop é engolido; sem ele o app sairia (rota única) + expect(find.text('raiz', skipOffstage: false), findsOneWidget); + expect( + key.currentState!.canPop(), + isTrue, + ); // há a rota-bloqueio empilhada + }, + ); + + testWidgets('descartar o gate com authenticate pendente não chama setState', ( + t, + ) async { + gate.pending = Completer(); + await pump(t); + await background(t, const Duration(minutes: 1), settle: false); + await t.pump(); + expect(gate.calls, 1); + await t.pumpWidget(const SizedBox()); + gate.pending!.complete(UnlockResult.unlocked); + await t.pump(); + expect(t.takeException(), isNull); + }); + + testWidgets('a cobertura não mostra dado de paciente', (t) async { + gate.result = UnlockResult.cancelled; + await pump(t); + await background(t, const Duration(minutes: 1)); + final textos = find + .descendant( + of: find + .ancestor( + of: find.text('Aplicativo bloqueado'), + matching: find.byType(SafeArea), + ) + .first, + matching: find.byType(Text), + ) + .evaluate() + .map((e) => (e.widget as Text).data) + .toSet(); + expect(textos, { + 'Aplicativo bloqueado', + 'Alertas continuam chegando; eles estarão no painel ao desbloquear.', + 'Desbloquear', + 'Entrar com senha', + }); + }); + + for (final tema in [buildAcsDarkTheme(), buildAcsLightTheme()]) { + testWidgets( + 'cobertura com fonte 2.0 não estoura (${tema.brightness.name})', + (t) async { + gate.result = UnlockResult.cancelled; + redimensionar(t, const Size(320, 480), escalaDeFonte: 2.0); + addTearDown(() { + t.view.reset(); + t.platformDispatcher.clearTextScaleFactorTestValue(); + }); + await t.pumpWidget( + MaterialApp( + navigatorKey: navKey, + theme: tema, + builder: (context, child) => AppLockGate( + gate: gate, + clock: () => now, + navigatorKey: navKey, + child: child!, + ), + home: const Scaffold(body: Text('x')), + ), + ); + await background(t, const Duration(minutes: 1)); + expect(find.text('Aplicativo bloqueado'), findsOneWidget); + expect(t.takeException(), isNull); + }, + ); + } +} diff --git a/apps/acs/test/auth_bound_session_token_store_test.dart b/apps/acs/test/auth_bound_session_token_store_test.dart new file mode 100644 index 00000000..c1cd2674 --- /dev/null +++ b/apps/acs/test/auth_bound_session_token_store_test.dart @@ -0,0 +1,273 @@ +// apps/acs/test/auth_bound_session_token_store_test.dart +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/core/security/auth_bound_session_token_store.dart'; +import 'package:sinalacs_acs/core/security/keystore_vault.dart'; +import 'package:sinalacs_acs/core/security/session_token_store.dart'; +import 'support/counting_session_token_store.dart'; + +void main() { + late FakeKeystoreVault vault; + late MemorySessionTokenStore legacy; + late AuthBoundSessionTokenStore store; + + setUp(() { + vault = FakeKeystoreVault(); + legacy = MemorySessionTokenStore(); + store = AuthBoundSessionTokenStore(vault: vault, legacy: legacy); + }); + + test('write sela no cofre e mantém em RAM; read nunca abre prompt', () async { + await store.write('tok-1'); + expect(vault.sealed[AuthBoundSessionTokenStore.alias], 'tok-1'); + expect(await store.read(), 'tok-1'); + expect(vault.unsealCalls, 0); + }); + + test('write: legado que lança no clear não derruba o write (token já selado)', () async { + final s = AuthBoundSessionTokenStore(vault: vault, legacy: _ThrowingClearLegacy()); + await s.write('tok-1'); + expect(vault.sealed[AuthBoundSessionTokenStore.alias], 'tok-1'); + expect(await s.read(), 'tok-1'); + }); + + test('partida a frio: contains é true, read é null até unlock', () async { + vault.sealed[AuthBoundSessionTokenStore.alias] = 'tok-1'; + final cold = AuthBoundSessionTokenStore(vault: vault, legacy: legacy); + expect(await cold.contains(), isTrue); + expect(await cold.read(), isNull); + expect(await cold.unlock(reason: 'x'), SessionUnlock.unlocked); + expect(await cold.read(), 'tok-1'); + expect(vault.unsealCalls, 1); + }); + + test( + 'cancelar o prompt NÃO apaga o token e permite tentar de novo', + () async { + vault.sealed[AuthBoundSessionTokenStore.alias] = 'tok-1'; + final cold = AuthBoundSessionTokenStore(vault: vault, legacy: legacy); + vault.nextUnsealFailure = VaultFailure.cancelled; + expect(await cold.unlock(reason: 'x'), SessionUnlock.cancelled); + expect(await cold.contains(), isTrue); + expect(await cold.unlock(reason: 'x'), SessionUnlock.unlocked); + }, + ); + + test('lockedOut e unavailable não devolvem token', () async { + vault.sealed[AuthBoundSessionTokenStore.alias] = 'tok-1'; + final cold = AuthBoundSessionTokenStore(vault: vault, legacy: legacy); + vault.nextUnsealFailure = VaultFailure.lockedOut; + expect(await cold.unlock(reason: 'x'), SessionUnlock.lockedOut); + vault.nextUnsealFailure = VaultFailure.unavailable; + expect(await cold.unlock(reason: 'x'), SessionUnlock.unavailable); + expect(await cold.read(), isNull); + }); + + test('chave invalidada (nova digital) apaga token e cofre', () async { + vault.sealed[AuthBoundSessionTokenStore.alias] = 'tok-1'; + final cold = AuthBoundSessionTokenStore(vault: vault, legacy: legacy); + vault.nextUnsealFailure = VaultFailure.invalidated; + expect(await cold.unlock(reason: 'x'), SessionUnlock.unavailable); + expect(await cold.contains(), isFalse); + // O fake não remove o blob ao invalidar: quem apagou foi o store. + expect(vault.deleteCalls, 1); + expect(vault.sealed, isEmpty); + }); + + test('aparelho sem suporte: token só em RAM, nada persistido', () async { + vault.supported = false; + await store.write('tok-1'); + expect(await store.read(), 'tok-1'); + expect(vault.sealed, isEmpty); + final cold = AuthBoundSessionTokenStore(vault: vault, legacy: legacy); + expect(await cold.contains(), isFalse); + }); + + test('falha ao selar degrada para RAM e não lança', () async { + vault.failSeal = true; + await store.write('tok-1'); + expect(await store.read(), 'tok-1'); + expect(vault.sealed, isEmpty); + }); + + test('clear apaga RAM, cofre e legado', () async { + await store.write('tok-1'); + await legacy.write('velho'); + await store.clear(); + expect(await store.read(), isNull); + expect(await store.contains(), isFalse); + expect(await legacy.read(), isNull); + }); + + test('lockedOut mantém o blob (só a invalidação apaga)', () async { + vault.sealed[AuthBoundSessionTokenStore.alias] = 'tok-1'; + final cold = AuthBoundSessionTokenStore(vault: vault, legacy: legacy); + vault.nextUnsealFailure = VaultFailure.lockedOut; + expect(await cold.unlock(reason: 'x'), SessionUnlock.lockedOut); + expect(vault.sealed[AuthBoundSessionTokenStore.alias], 'tok-1'); + expect(vault.deleteCalls, 0); + }); + + test('write com falha ao selar não deixa blob velho ressuscitar', () async { + vault.sealed[AuthBoundSessionTokenStore.alias] = 'velho'; + vault.failSeal = true; + await store.write('novo'); + expect(vault.sealed, isEmpty); + expect(vault.deleteCalls, 1); + }); + + test('write em aparelho sem suporte não deixa blob velho', () async { + vault.sealed[AuthBoundSessionTokenStore.alias] = 'velho'; + vault.supported = false; + await store.write('novo'); + expect(vault.sealed, isEmpty); + expect(vault.deleteCalls, 1); + }); + + test('clear (logout) apaga o blob e a chave: cofre vazio e delete chamado', () async { + await store.write('tok-1'); + await store.clear(); + expect(vault.sealed, isEmpty); + expect(vault.deleteCalls, 1); + expect(await store.contains(), isFalse); + expect(await store.read(), isNull); + }); + + test('após clear, novo write recria o ciclo normalmente', () async { + await store.write('a'); + await store.clear(); + await store.write('b'); + expect(vault.sealed[AuthBoundSessionTokenStore.alias], 'b'); + }); + + test('desbloqueios concorrentes abrem um único prompt', () async { + vault.sealed[AuthBoundSessionTokenStore.alias] = 'tok-1'; + final cold = AuthBoundSessionTokenStore(vault: vault, legacy: legacy); + final r = await Future.wait([ + cold.unlock(reason: 'x'), + cold.unlock(reason: 'x'), + ]); + expect(r, [SessionUnlock.unlocked, SessionUnlock.unlocked]); + expect(vault.unsealCalls, 1); + }); + + test('MemorySessionTokenStore: notRequired e contains', () async { + final m = MemorySessionTokenStore('a'); + expect(await m.contains(), isTrue); + expect(await m.unlock(reason: 'x'), SessionUnlock.notRequired); + }); + + test('token legado é selado sem prompt e o original é apagado', () async { + await legacy.write('velho'); + expect(await store.contains(), isTrue); + expect(vault.sealed[AuthBoundSessionTokenStore.alias], 'velho'); + expect(await legacy.read(), isNull); + expect(vault.unsealCalls, 0); + expect(await store.read(), isNull); + expect(await store.unlock(reason: 'x'), SessionUnlock.unlocked); + expect(await store.read(), 'velho'); + }); + + test( + 'migração que falha preserva o legado (a sessão não se perde)', + () async { + vault.failSeal = true; + await legacy.write('velho'); + expect(await store.contains(), isFalse); + expect(await legacy.read(), 'velho'); + }, + ); + + test( + 'migração roda antes do 1º unlock: contains() já sela o legado', + () async { + await legacy.write('velho'); + await store.contains(); + expect(vault.sealed[AuthBoundSessionTokenStore.alias], 'velho'); + expect(await legacy.read(), isNull); + }, + ); + + test( + 'app morto entre selar e apagar o legado: o legado é descartado', + () async { + vault.sealed[AuthBoundSessionTokenStore.alias] = 'velho'; + await legacy.write('velho'); + expect(await store.contains(), isTrue); + expect(await legacy.read(), isNull); + }, + ); + + test('contains() é leve após a migração: não relê o legado', () async { + final counting = CountingSessionTokenStore(); + final s = AuthBoundSessionTokenStore(vault: vault, legacy: counting); + await s.contains(); + final reads = counting.reads; + await s.contains(); + await s.contains(); + expect(counting.reads, reads); + }); + + test( + 'migração que falhou não liga o flag: a próxima contains() tenta de novo', + () async { + vault.failSeal = true; + await legacy.write('velho'); + expect(await store.contains(), isFalse); + vault.failSeal = false; + expect(await store.contains(), isTrue); + expect(vault.sealed[AuthBoundSessionTokenStore.alias], 'velho'); + }, + ); + + test( + 'cofre que nunca responde: unlock vira cancelled, não apaga o token e o ' + 'unlock seguinte abre um prompt novo', + () async { + final hanging = _HangOnceVault(); + hanging.sealed[AuthBoundSessionTokenStore.alias] = 'tok-1'; + final cold = AuthBoundSessionTokenStore( + vault: hanging, + legacy: legacy, + unsealTimeout: const Duration(milliseconds: 20), + ); + expect(await cold.unlock(reason: 'x'), SessionUnlock.cancelled); + expect(hanging.sealed[AuthBoundSessionTokenStore.alias], 'tok-1'); + expect(await cold.read(), isNull); + // `_unlocking` foi liberado: o segundo unlock chega ao cofre de novo. + expect(await cold.unlock(reason: 'x'), SessionUnlock.unlocked); + expect(hanging.unsealCalls, 2); + expect(await cold.read(), 'tok-1'); + }, + ); + + test('o teto padrão do prompt é generoso (2 minutos)', () { + expect( + AuthBoundSessionTokenStore.defaultUnsealTimeout, + const Duration(minutes: 2), + ); + }); +} + +/// Legado cujo `clear` lança: o `write` não pode falhar depois do `seal`. +class _ThrowingClearLegacy extends MemorySessionTokenStore { + @override + Future clear() async => throw StateError('legado indisponível'); +} + +/// Simula o BiometricPrompt que descarta o pedido em silêncio: a primeira +/// chamada nunca completa; as seguintes respondem normalmente. +class _HangOnceVault extends FakeKeystoreVault { + bool _hung = false; + + @override + Future unseal(String alias, {required String reason}) { + if (!_hung) { + _hung = true; + unsealCalls++; + return Completer().future; + } + return super.unseal(alias, reason: reason); + } +} diff --git a/apps/acs/test/backend_client_test.dart b/apps/acs/test/backend_client_test.dart index 2ab0d7c6..26ae6563 100644 --- a/apps/acs/test/backend_client_test.dart +++ b/apps/acs/test/backend_client_test.dart @@ -1,23 +1,23 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:sinalacs_acs/core/network/backend_client.dart'; import 'package:sinalacs_acs/core/network/backend_config.dart'; +import 'package:sinalacs_acs/core/security/session_token_store.dart'; import 'support/fake_rpc_server.dart'; /// Testes do `BackendClient` de verdade, contra um servidor que fala o /// protocolo do cliente gerado — ver `support/fake_rpc_server.dart`. /// -/// É o único lugar onde a decisão de projeto desta task é observável: a -/// credencial do ACS vive **apenas em memória** e é ela que renova a sessão de -/// 15 minutos. Nenhum teste daqui usa senha real: as credenciais são sintéticas -/// e o servidor é local. +/// A sessão de 15 minutos é renovada pelo refresh token rotativo; a senha não é +/// retida. Nenhum teste daqui usa senha real: as credenciais são sintéticas e o +/// servidor é local. void main() { late FakeRpcServer server; late BackendClient backend; setUp(() async { server = await FakeRpcServer.start(); - backend = BackendClient(host: server.host); + backend = BackendClient(host: server.host, tokenStore: MemorySessionTokenStore(), deviceIds: MemoryDeviceIdStore()); }); tearDown(() async { @@ -41,6 +41,7 @@ void main() { expect(request.method, 'loginInstitutional'); expect(request.args['matricula'], 'ACS-001'); expect(request.args['password'], 'senha-sintetica'); + expect(request.args['deviceId'], isNotEmpty, reason: 'sem deviceId o servidor não emite refresh token'); }); test('credencial recusada vira falha não recuperável com a mensagem do servidor', () async { @@ -60,42 +61,25 @@ void main() { expect(backend.isAuthenticated, isFalse); }); - test('token expirado renova a sessão reenviando a credencial em memória', () async { - // O comportamento que o app já tinha e que esta task preserva: um turno de - // campo dura mais que o token de 15 minutos, e sem renovação o ACS passa a - // ver erro de permissão no meio do trabalho. + test('token expirado renova a sessão pelo refresh token, sem reenviar a senha', () async { + // Um turno de campo dura mais que o token de 15 minutos. server.tokenLifetime = const Duration(minutes: -1); await backend.login(matricula: 'ACS-001', senha: 'senha-sintetica'); - expect( - backend.isAuthenticated, - isFalse, - reason: 'o token emitido pelo servidor de teste já nasce vencido', - ); + expect(backend.isAuthenticated, isFalse, reason: 'o token emitido já nasce vencido'); expect(server.loginCount, 1); - // Qualquer chamada autenticada passa por `_requireToken` antes de sair. await backend.listPatients(); - expect(server.loginCount, 2, reason: 'a sessão tinha de ser renovada'); - final renewals = - server.requests.where((request) => request.method == 'loginInstitutional'); - final renewal = renewals.last; - expect(renewal.args['matricula'], 'ACS-001'); - expect(renewal.args['password'], 'senha-sintetica'); - // A chamada que disparou a renovação ainda saiu, depois dela. + expect(server.loginCount, 1, reason: 'a senha não é reenviada'); + expect(server.refreshCount, 1); expect(server.requests.last.method, 'listMicroArea'); expect(server.requests.last.args['accessToken'], isNotEmpty); - // E a renovação é a credencial da pessoa, nunca o atalho de - // desenvolvimento: era esse o reauth silencioso que a task aposentou. - expect( - server.requests.map((request) => request.method), - isNot(contains('developmentLogin')), - ); + expect(server.requests.map((r) => r.method), isNot(contains('developmentLogin'))); }); - test('sem credencial em memória a renovação falha de forma não recuperável', () async { - // Uma instância que nunca autenticou: não há credencial de onde tirar. + test('sem refresh token a renovação falha de forma não recuperável', () async { + // Uma instância que nunca autenticou: não há token de onde tirar. await expectLater( backend.listPatients(), throwsA( @@ -124,6 +108,36 @@ void main() { /// mediu que o `network_security_config.xml` não bloqueia o cleartext do /// `dart:io`, então esta validação é a única barreira que sobrou — e é aqui /// que ela fica vermelha se alguém a apagar. + test('generateInvite pede o convite do paciente com o token da sessão', () async { + await backend.login(matricula: 'ACS-001', senha: 'senha-sintetica'); + + final invite = await backend.generateInvite( + patientId: '00000000-0000-4000-8000-000000000005', + ); + + expect(invite.token, 'convite-sintetico'); + expect(invite.expiresAt, DateTime.utc(2026, 9, 29, 10, 15)); + final request = server.requests.last; + expect(request.endpoint, 'onboarding'); + expect(request.method, 'generateEnrollmentToken'); + expect(request.args['patientId'], '00000000-0000-4000-8000-000000000005'); + expect(request.args['accessToken'], isNotEmpty); + }); + + test('generateInvite recusado por território vira mensagem de paciente fora da microárea', () async { + await backend.login(matricula: 'ACS-001', senha: 'senha-sintetica'); + server.rejectInviteWithPermission = true; + + await expectLater( + backend.generateInvite(patientId: '00000000-0000-4000-8000-000000000009'), + throwsA( + isA() + .having((f) => f.message, 'message', 'Este paciente não pertence à sua microárea.') + .having((f) => f.isRecoverable, 'isRecoverable', isFalse), + ), + ); + }); + group('host do RPC', () { test('recusa http — a porta em texto claro não existe mais', () { expect( diff --git a/apps/acs/test/contrast_tokens_light_test.dart b/apps/acs/test/contrast_tokens_light_test.dart new file mode 100644 index 00000000..c41be5af --- /dev/null +++ b/apps/acs/test/contrast_tokens_light_test.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/app/acs_theme.dart'; + +import 'support/contrast.dart'; + +/// Equivalente de `contrast_tokens_test.dart` para o tema claro (issue #9). +/// +/// `yellow`/`green` são desenhados para funcionar como texto sobre fundo +/// ESCURO (ver `AcsRiskColors.dark`); sobre fundo claro eles caem para +/// ~2:1 — por isso `AcsRiskColors.light` usa tons mais escuros +/// (`amber-700`/`emerald-700`) só para leitura como texto, sem alterar o +/// preenchimento (`AcsColors.yellow`/`green`), que continua o mesmo em +/// ambos os temas — a cor clínica não muda de significado. +void main() { + const normalText = 4.5; + const largeTextOrUi = 3.0; + const risk = AcsRiskColors.light; + + final cases = <(String, Color, Color, double)>[ + ('texto principal sobre scaffold', const Color(0xFF111827), AcsLightColors.background, normalText), + ('texto principal sobre card', const Color(0xFF111827), AcsLightColors.surfaceRaised, normalText), + ('redOnSurface sobre card', risk.redOnSurface, AcsLightColors.surfaceRaised, normalText), + ('redOnSurface sobre scaffold', risk.redOnSurface, AcsLightColors.background, normalText), + ('accentOnSurface sobre card', risk.accentOnSurface, AcsLightColors.surfaceRaised, normalText), + ('accentOnSurface sobre scaffold', risk.accentOnSurface, AcsLightColors.background, normalText), + ('accentOnSurface sobre appbar', risk.accentOnSurface, AcsLightColors.surface, normalText), + ('yellowOnSurface sobre card', risk.yellowOnSurface, AcsLightColors.surfaceRaised, normalText), + ('yellowOnSurface sobre scaffold', risk.yellowOnSurface, AcsLightColors.background, normalText), + ('greenOnSurface sobre card', risk.greenOnSurface, AcsLightColors.surfaceRaised, normalText), + ('greenOnSurface sobre scaffold', risk.greenOnSurface, AcsLightColors.background, normalText), + // Preenchimento de botão: continua correto sem token novo. + ('branco sobre botão vermelho (SAMU/pânico)', Colors.white, AcsColors.red, largeTextOrUi), + ('branco sobre botão accent (login)', Colors.white, AcsColors.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 1.4.3', + ); + }); + } + + // Documenta por que o tema claro precisa de tokens próprios: os fills + // desenhados para o escuro falham como texto sobre fundo claro. + test('yellow (fill, desenhado para o escuro) usado como texto sobre card claro fica abaixo de 4.5:1', () { + expect(contrastOn(AcsColors.yellow, AcsLightColors.surfaceRaised), lessThan(normalText)); + }); + + test('green (fill, desenhado para o escuro) usado como texto sobre card claro fica abaixo de 4.5:1', () { + expect(contrastOn(AcsColors.green, AcsLightColors.surfaceRaised), lessThan(normalText)); + }); +} diff --git a/apps/acs/test/contrast_tokens_test.dart b/apps/acs/test/contrast_tokens_test.dart index 66ff5132..7933023f 100644 --- a/apps/acs/test/contrast_tokens_test.dart +++ b/apps/acs/test/contrast_tokens_test.dart @@ -8,30 +8,32 @@ import 'support/contrast.dart'; /// manual no WebAIM que produziu `spec/ux_accessibility_assessment.md`. /// /// A causa do relatório anterior estar errado era medir todo texto contra o -/// fundo do `Scaffold` (`AcsColors.background`), quando texto de risco e de -/// status é renderizado dentro de `Card` (`AcsColors.surfaceRaised`) ou de -/// `AppBar` (`AcsColors.surface`) — por isso cada linha aqui declara a -/// SUPERFÍCIE real, não só o par de cores. +/// fundo do `Scaffold` (`AcsDarkColors.background`), quando texto de risco e +/// de status é renderizado dentro de `Card` (`AcsDarkColors.surfaceRaised`) +/// ou de `AppBar` (`AcsDarkColors.surface`) — por isso cada linha aqui +/// declara a SUPERFÍCIE real, não só o par de cores. Ver +/// `contrast_tokens_light_test.dart` para o equivalente do tema claro. void main() { const normalText = 4.5; const largeTextOrUi = 3.0; + const risk = AcsRiskColors.dark; /// (rótulo, texto/ícone, superfície, limiar) — um por par realmente /// renderizado no app. `branco` sobre qualquer superfície e `yellow`/ /// `green` sobre card já eram os casos "fáceis"; ficam aqui como guarda /// contra regressão, não porque fossem os achados do relatório. - const cases = <(String, Color, Color, double)>[ - ('branco sobre scaffold', Colors.white, AcsColors.background, normalText), - ('branco sobre card', Colors.white, AcsColors.surfaceRaised, normalText), - ('yellow sobre card (badge de risco amarelo)', AcsColors.yellow, AcsColors.surfaceRaised, normalText), - ('green sobre card (badge de risco verde)', AcsColors.green, AcsColors.surfaceRaised, normalText), + final cases = <(String, Color, Color, double)>[ + ('branco sobre scaffold', Colors.white, AcsDarkColors.background, normalText), + ('branco sobre card', Colors.white, AcsDarkColors.surfaceRaised, normalText), + ('yellow sobre card (badge de risco amarelo)', AcsColors.yellow, AcsDarkColors.surfaceRaised, normalText), + ('green sobre card (badge de risco verde)', AcsColors.green, AcsDarkColors.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). - ('redOnSurface sobre card', AcsColors.redOnSurface, AcsColors.surfaceRaised, normalText), - ('accentOnSurface sobre card', AcsColors.accentOnSurface, AcsColors.surfaceRaised, normalText), - ('redOnSurface sobre scaffold', AcsColors.redOnSurface, AcsColors.background, normalText), - ('accentOnSurface sobre scaffold', AcsColors.accentOnSurface, AcsColors.background, normalText), - ('accentOnSurface sobre appbar', AcsColors.accentOnSurface, AcsColors.surface, normalText), + ('redOnSurface sobre card', risk.redOnSurface, AcsDarkColors.surfaceRaised, normalText), + ('accentOnSurface sobre card', risk.accentOnSurface, AcsDarkColors.surfaceRaised, normalText), + ('redOnSurface sobre scaffold', risk.redOnSurface, AcsDarkColors.background, normalText), + ('accentOnSurface sobre scaffold', risk.accentOnSurface, AcsDarkColors.background, normalText), + ('accentOnSurface sobre appbar', risk.accentOnSurface, AcsDarkColors.surface, normalText), // Preenchimento de botão: texto branco sobre a cor de fundo — o teste que // prova que não precisamos trocar `red`/`accent` como fill. ('branco sobre botão vermelho (SAMU/pânico)', Colors.white, AcsColors.red, largeTextOrUi), @@ -54,17 +56,17 @@ void main() { // 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(AcsColors.red, AcsColors.surfaceRaised), lessThan(normalText)); + expect(contrastOn(AcsColors.red, AcsDarkColors.surfaceRaised), lessThan(normalText)); }); test('accent (fill) usado como texto sobre card fica abaixo de 4.5:1', () { - expect(contrastOn(AcsColors.accent, AcsColors.surfaceRaised), lessThan(normalText)); + expect(contrastOn(AcsColors.accent, AcsDarkColors.surfaceRaised), lessThan(normalText)); }); test('#EF4444 (correção sugerida pelo relatório antigo) continua abaixo de 4.5:1 sobre card', () { // Documenta por que a Fase 2 não adotou a sugestão original do relatório: // clarear o token único não bastava. - final ratio = contrastOn(const Color(0xFFEF4444), AcsColors.surfaceRaised); + final ratio = contrastOn(const Color(0xFFEF4444), AcsDarkColors.surfaceRaised); expect(ratio, lessThan(normalText)); }); } diff --git a/apps/acs/test/deferred_flush_service_test.dart b/apps/acs/test/deferred_flush_service_test.dart new file mode 100644 index 00000000..a824c871 --- /dev/null +++ b/apps/acs/test/deferred_flush_service_test.dart @@ -0,0 +1,588 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/core/network/auth_session.dart'; +import 'package:sinalacs_acs/core/network/backend_client.dart'; +import 'package:sinalacs_acs/core/security/session_token_store.dart'; +import 'package:sinalacs_acs/core/security/upload_token_store.dart'; +import 'package:sinalacs_acs/core/services/deferred_flush_service.dart'; +import 'package:sinalacs_acs/core/services/offline_visit_queue.dart'; +import 'package:sinalacs_client/sinalacs_client.dart'; + +import 'support/fakes.dart'; + +/// Envio diferido (D4/D7 do plano 2026-10-03): o legado sobe pela sessão atual +/// só como transporte; a fila de cada ACS que saiu sobe com o token DELE. Nada +/// sai do aparelho antes do `synced`. Ids e pacientes sintéticos. +void main() { + const acsA = 'acs-a'; + const acsB = 'acs-b'; + const acsC = 'acs-c'; + const aparelho = 'aparelho-sintetico-1'; + + OfflineVisitRecord visita(String localId, {int n = 1}) => + OfflineVisitRecord(localId: localId, patientId: syntheticPatientId(n), risk: 'green', status: 'PENDENTE'); + + AuthSession sessao(String userId) => AuthSession( + accessToken: 'jwt-de-$userId', + tokenType: 'Bearer', + userId: userId, + role: 'acs', + microAreaId: seedMicroAreaId, + expiresAt: DateTime.now().toUtc().add(const Duration(minutes: 15)), + ); + + late FakeAcsBackend backend; + late MemoryUploadTokenStore tokens; + late InMemoryVisitStorage storage; + + DeferredFlushService servico({VisitStorage? sobre}) => DeferredFlushService( + backend: backend, + storage: sobre ?? storage, + tokens: tokens, + deviceIds: MemoryDeviceIdStore(aparelho), + ); + + List ids(List visitas) => [for (final v in visitas) v.localId]; + + setUp(() { + backend = FakeAcsBackend(); + tokens = MemoryUploadTokenStore(); + storage = InMemoryVisitStorage(); + }); + + group('legado (quarentena)', () { + test('legado sobe pela sessão atual e SAI do aparelho só depois do synced', () async { + storage = InMemoryVisitStorage(legacyVisits: [visita('leg-1'), visita('leg-2')]); + backend.session = sessao(acsB); + // Primeiro a rede cai: nada sai do aparelho. + backend.legacyFailure = const BackendFailure('Sem conexão com o servidor.'); + + final falhou = await servico().flushAll(); + expect(ids(await storage.legacy.load()), ['leg-1', 'leg-2']); + expect(falhou.sent, 0); + expect(falhou.remaining, 2); + + backend.legacyFailure = null; + final report = await servico().flushAll(); + + expect(backend.legacyBatches, hasLength(2)); + final lote = backend.legacyBatches.last; + expect(lote.transportUserId, acsB, reason: 'a sessão atual só transporta'); + expect(lote.deviceId, aparelho); + expect([for (final e in lote.visits) e.localId], ['leg-1', 'leg-2']); + expect(await storage.legacy.load(), isEmpty); + expect(report.sent, 2); + expect(report.remaining, 0); + expect(backend.deferredBatches, isEmpty, reason: 'legado nunca vai com token de envio'); + }); + + test('sem sessão, o legado não sobe e continua contado', () async { + storage = InMemoryVisitStorage(legacyVisits: [visita('leg-1')]); + + final report = await servico().flushAll(); + + expect(backend.legacyBatches, isEmpty); + expect(report.remaining, 1); + expect(ids(await storage.legacy.load()), ['leg-1']); + }); + + test('legado de outra microárea: rejected → continua em quarentena', () async { + storage = InMemoryVisitStorage(legacyVisits: [visita('leg-fora'), visita('leg-ok')]); + backend.session = sessao(acsB); + backend.legacyResultFor = (e) => e.localId == 'leg-fora' + ? VisitSyncResult(localId: e.localId, syncStatus: SyncStatus.rejected, message: 'paciente fora da sua microárea') + : VisitSyncResult(localId: e.localId, syncStatus: SyncStatus.synced, serverVersion: 1); + + final report = await servico().flushAll(); + + expect(ids(await storage.legacy.load()), ['leg-fora']); + expect(report.sent, 1); + expect(report.remaining, 1); + expect(report.needsReview, 0, reason: 'território alheio não é caso de revisão'); + }); + + test("'versão diferente' fica em quarentena e é contada como revisão", () async { + storage = InMemoryVisitStorage(legacyVisits: [visita('leg-versao')]); + backend.session = sessao(acsB); + backend.legacyResultFor = (e) => VisitSyncResult( + localId: e.localId, + syncStatus: SyncStatus.rejected, + message: 'visita já registrada com autor — versão diferente', + ); + + final report = await servico().flushAll(); + + expect(ids(await storage.legacy.load()), ['leg-versao']); + expect(report.needsReview, 1); + expect(report.remaining, 1); + }); + + test('error por visita: fica para a próxima', () async { + storage = InMemoryVisitStorage(legacyVisits: [visita('leg-1')]); + backend.session = sessao(acsB); + backend.legacyResultFor = + (e) => VisitSyncResult(localId: e.localId, syncStatus: SyncStatus.error, message: 'paciente desconhecido'); + + final report = await servico().flushAll(); + + expect(ids(await storage.legacy.load()), ['leg-1']); + expect(report.remaining, 1); + }); + + test('lotes de no máximo 100 (o servidor recusa acima de 200)', () async { + storage = InMemoryVisitStorage(legacyVisits: [for (var i = 0; i < 230; i++) visita('leg-$i', n: i + 1)]); + backend.session = sessao(acsB); + + final report = await servico().flushAll(); + + expect([for (final l in backend.legacyBatches) l.visits.length], [100, 100, 30]); + expect(report.sent, 230); + expect(await storage.legacy.load(), isEmpty); + }); + + test('app morto depois do 200 e antes de apagar: o reenvio volta synced e a linha é apagada', () async { + final falha = _RemocaoLegadaFalhaUmaVez(InMemoryVisitStorage(legacyVisits: [visita('leg-1')])); + backend.session = sessao(acsB); + + final primeiro = await servico(sobre: falha).flushAll(); + expect(ids(await falha.legacy.load()), ['leg-1'], reason: 'a remoção falhou: a linha continua'); + expect(primeiro.remaining, 1); + + final segundo = await servico(sobre: falha).flushAll(); + expect([for (final l in backend.legacyBatches) l.visits.single.localId], ['leg-1', 'leg-1']); + expect(await falha.legacy.load(), isEmpty); + expect(segundo.remaining, 0); + }); + }); + + group('fila de quem saiu (token de envio)', () { + test('fila de A sobe com o token de A mesmo com B logado, e a autoria enviada é a de A', () async { + await storage.forOwner(acsA).save([visita('a-1'), visita('a-2')]); + await storage.forOwner(acsB).save([visita('b-1')]); + await tokens.write(acsA, 'upload-a'); + await tokens.write(acsB, 'upload-b'); + backend.session = sessao(acsB); + + final report = await servico().flushAll(); + + expect(backend.deferredBatches, hasLength(1)); + final lote = backend.deferredBatches.single; + expect(lote.uploadToken, 'upload-a', reason: 'a autoria vem do token: o servidor grava A como autor'); + expect(lote.deviceId, aparelho); + expect([for (final e in lote.visits) e.localId], ['a-1', 'a-2']); + expect(backend.syncedVisitBatches, isEmpty, reason: 'nada de A sai pela sessão de B'); + expect(await storage.forOwner(acsA).load(), isEmpty); + // A fila de B (dono da sessão) é do painel: o serviço não a envia. + expect(ids(await storage.forOwner(acsB).load()), ['b-1']); + expect(await tokens.read(acsB), 'upload-b', reason: 'o token do dono da sessão não é revogado'); + expect(report.sent, 2); + expect(report.remaining, 1, reason: 'a visita de B continua no aparelho'); + }); + + test('depois que a fila de A zera, o token de A é revogado no servidor e apagado do Keystore', () async { + await storage.forOwner(acsA).save([visita('a-1')]); + await tokens.write(acsA, 'upload-a'); + + final report = await servico().flushAll(); + + expect(backend.revokedUploadTokens, ['upload-a']); + expect(await tokens.read(acsA), isNull); + expect(await tokens.owners(), isEmpty); + expect(report.remaining, 0); + }); + + test('dono sem visita nenhuma: token revogado na hora', () async { + await tokens.write(acsA, 'upload-a'); + + await servico().flushAll(); + + expect(backend.deferredBatches, isEmpty); + expect(backend.revokedUploadTokens, ['upload-a']); + expect(await tokens.read(acsA), isNull); + }); + + test('recusada em definitivo fica visível a A e o token de A continua', () async { + await storage.forOwner(acsA).save([visita('a-1'), visita('a-2')]); + await tokens.write(acsA, 'upload-a'); + backend.deferredResultFor = (e) => e.localId == 'a-1' + ? VisitSyncResult(localId: e.localId, syncStatus: SyncStatus.rejected, message: 'paciente fora da sua microárea') + : VisitSyncResult(localId: e.localId, syncStatus: SyncStatus.synced, serverVersion: 1); + + final report = await servico().flushAll(); + + final restantes = await storage.forOwner(acsA).load(); + expect(ids(restantes), ['a-1']); + expect(restantes.single.rejectionReason, 'paciente fora da sua microárea'); + expect(backend.revokedUploadTokens, isEmpty); + expect(await tokens.read(acsA), 'upload-a'); + expect(report.remaining, 1); + }); + + test('erro de rede no meio: nada é apagado; o próximo flushAll reenvia o mesmo localId (idempotente)', () async { + await storage.forOwner(acsA).save([visita('a-1'), visita('a-2')]); + await tokens.write(acsA, 'upload-a'); + backend.deferredFailure = const BackendFailure('Sem conexão com o servidor.'); + + final falhou = await servico().flushAll(); + + expect(ids(await storage.forOwner(acsA).load()), ['a-1', 'a-2']); + expect(await tokens.read(acsA), 'upload-a'); + expect(backend.revokedUploadTokens, isEmpty); + expect(falhou.remaining, 2); + expect(falhou.blockedOwners, isEmpty, reason: 'rede não é bloqueio'); + + backend.deferredFailure = null; + final report = await servico().flushAll(); + + expect([for (final l in backend.deferredBatches) [for (final e in l.visits) e.localId]], [ + ['a-1', 'a-2'], + ['a-1', 'a-2'], + ]); + expect(await storage.forOwner(acsA).load(), isEmpty); + expect(report.remaining, 0); + }); + + test('token de A recusado (vencido/revogado): a fila de A permanece, A entra em blockedOwners', () async { + await storage.forOwner(acsA).save([visita('a-1')]); + await tokens.write(acsA, 'upload-a'); + backend.refusedUploadTokens.add('upload-a'); + + final report = await servico().flushAll(); + + expect(ids(await storage.forOwner(acsA).load()), ['a-1']); + expect(report.blockedOwners, [acsA]); + expect(report.remaining, 1); + expect(await tokens.read(acsA), isNull, reason: 'o servidor disse que o token morreu'); + expect(backend.revokedUploadTokens, isEmpty); + }); + + test('token presente mas fora do índice: o dono com visitas é achado pelo disco e sobe com o token dele', + () async { + await storage.forOwner(acsA).save([visita('a-1')]); + await storage.forOwner(acsB).save([visita('b-1')]); + final semIndice = _IndicePerdido({acsA: 'upload-a', acsB: 'upload-b'}); + backend.session = sessao(acsB); + + final report = await DeferredFlushService( + backend: backend, + storage: storage, + tokens: semIndice, + deviceIds: MemoryDeviceIdStore(aparelho), + ).flushAll(); + + expect(backend.deferredBatches, hasLength(1)); + expect(backend.deferredBatches.single.uploadToken, 'upload-a'); + expect(await storage.forOwner(acsA).load(), isEmpty); + expect(report.blockedOwners, isEmpty); + expect(semIndice.reparados, [acsA], reason: 'o índice é reparado; o dono da sessão não é lido'); + expect(semIndice.lidos, isNot(contains(acsB))); + }); + + test('dono com visitas e sem token: bloqueado, nada enviado', () async { + await storage.forOwner(acsA).save([visita('a-1')]); + + final report = await servico().flushAll(); + + expect(backend.deferredBatches, isEmpty); + expect(report.blockedOwners, [acsA]); + expect(report.remaining, 1); + }); + + test('app morto depois do 200 e antes de apagar: o reenvio do mesmo localId volta synced e a linha é apagada', + () async { + final falha = _GravacaoDoDonoFalhaUmaVez(storage); + await storage.forOwner(acsA).save([visita('a-1')]); + await tokens.write(acsA, 'upload-a'); + + final primeiro = await servico(sobre: falha).flushAll(); + expect(ids(await storage.forOwner(acsA).load()), ['a-1'], reason: 'o 200 chegou, mas o disco não foi limpo'); + expect(primeiro.remaining, 1); + expect(primeiro.sent, 0, reason: 'sent conta só o que saiu do aparelho'); + expect(await tokens.read(acsA), 'upload-a', reason: 'ainda há visita: o token fica'); + + final segundo = await servico(sobre: falha).flushAll(); + expect([for (final l in backend.deferredBatches) l.visits.single.localId], ['a-1', 'a-1']); + expect(await storage.forOwner(acsA).load(), isEmpty); + expect(segundo.remaining, 0); + expect(segundo.sent, 1); + expect(await tokens.read(acsA), isNull); + }); + + test('token recusado durante novo login de A: o token NOVO de A não é apagado', () async { + await storage.forOwner(acsA).save([visita('a-1')]); + await tokens.write(acsA, 'upload-a'); + final gate = backend.deferredGate = Completer(); + + final voo = servico().flushAll(); + await pumpEventQueue(); + // A entra de novo neste aparelho: o login grava um token novo, e o + // servidor revoga o anterior (mesmo usuário + aparelho). + await tokens.write(acsA, 'upload-a-novo'); + backend.refusedUploadTokens.add('upload-a'); + gate.complete(); + final report = await voo; + + expect(report.blockedOwners, [acsA]); + expect(await tokens.read(acsA), 'upload-a-novo'); + expect(ids(await storage.forOwner(acsA).load()), ['a-1']); + }); + + test('fila vazia revogada enquanto A entra de novo: o token NOVO de A não é apagado', () async { + await tokens.write(acsA, 'upload-a'); + final gate = backend.revokeGate = Completer(); + + final voo = servico().flushAll(); + await pumpEventQueue(); + await tokens.write(acsA, 'upload-a-novo'); + gate.complete(); + await voo; + + expect(backend.revokedUploadTokens, ['upload-a']); + expect(await tokens.read(acsA), 'upload-a-novo'); + }); + + test('flushAll nunca envia visita de A com o token de B (verifica o token usado por lote)', () async { + await storage.forOwner(acsA).save([for (var i = 0; i < 230; i++) visita('a-$i', n: i + 1)]); + await storage.forOwner(acsC).save([visita('c-1'), visita('c-2')]); + await tokens.write(acsA, 'upload-a'); + await tokens.write(acsC, 'upload-c'); + backend.session = sessao(acsB); + + final report = await servico().flushAll(); + + final dono = {'upload-a': 'a-', 'upload-c': 'c-'}; + for (final lote in backend.deferredBatches) { + final prefixo = dono[lote.uploadToken]!; + expect(lote.visits.every((e) => e.localId.startsWith(prefixo)), isTrue, + reason: 'lote com ${lote.uploadToken} levou visita de outro dono'); + expect(lote.visits.length, lessThanOrEqualTo(100)); + } + expect([ + for (final l in backend.deferredBatches) + if (l.uploadToken == 'upload-a') l.visits.length + ], [100, 100, 30]); + expect(report.sent, 232); + expect(backend.revokedUploadTokens, unorderedEquals(['upload-a', 'upload-c'])); + }); + + test('A volta a ser a sessão atual: o serviço não envia a fila dela (é do painel)', () async { + await storage.forOwner(acsA).save([visita('a-1')]); + await tokens.write(acsA, 'upload-a'); + backend.session = sessao(acsA); + + final report = await servico().flushAll(); + + expect(backend.deferredBatches, isEmpty); + expect(backend.revokedUploadTokens, isEmpty); + expect(report.blockedOwners, isEmpty); + expect(report.remaining, 1); + }); + + test('A entra enquanto o envio de A está em voo: a visita nova de A não se perde', () async { + await storage.forOwner(acsA).save([visita('a-1')]); + await tokens.write(acsA, 'upload-a'); + final gate = backend.deferredGate = Completer(); + + final voo = servico().flushAll(); + await pumpEventQueue(); + // O painel de A grava uma visita nova enquanto o lote está no servidor. + await storage.forOwner(acsA).save([visita('a-1'), visita('a-novo')]); + gate.complete(); + await voo; + + expect(ids(await storage.forOwner(acsA).load()), ['a-novo']); + expect(await tokens.read(acsA), 'upload-a'); + }); + }); + + test('flushAll simultâneos viram uma só execução (single-flight)', () async { + await storage.forOwner(acsA).save([visita('a-1')]); + await tokens.write(acsA, 'upload-a'); + final gate = backend.deferredGate = Completer(); + final service = servico(); + + final primeiro = service.flushAll(); + final segundo = service.flushAll(); + await pumpEventQueue(); + gate.complete(); + final relatorios = await Future.wait([primeiro, segundo]); + + expect(backend.deferredBatches, hasLength(1)); + expect(identical(relatorios[0], relatorios[1]), isTrue); + + // Terminada a execução, uma nova chamada roda de novo. + backend.deferredGate = null; + await storage.forOwner(acsA).save([visita('a-2')]); + await tokens.write(acsA, 'upload-a2'); + await service.flushAll(); + expect(backend.deferredBatches, hasLength(2)); + }); + + test('flushAll nunca lança: falha do Keystore vira relatório', () async { + await storage.forOwner(acsA).save([visita('a-1')]); + final service = DeferredFlushService( + backend: backend, + storage: storage, + tokens: _TokensQueFalham(), + deviceIds: MemoryDeviceIdStore(aparelho), + ); + + final report = await service.flushAll(); + + expect(report.remaining, 1); + expect(backend.deferredBatches, isEmpty); + }); + + test('pendingElsewhere conta outros donos + legado e não expõe conteúdo', () async { + storage = InMemoryVisitStorage(legacyVisits: [visita('leg-1'), visita('leg-2')]); + await storage.forOwner(acsA).save([visita('a-1'), visita('a-2'), visita('a-3')]); + await storage.forOwner(acsB).save([visita('b-1')]); + await storage.forOwner(acsC).save([visita('c-1')]); + + final int contagem = await servico().pendingElsewhere(acsB); + + expect(contagem, 3 + 1 + 2); + expect(backend.legacyBatches, isEmpty); + expect(backend.deferredBatches, isEmpty); + }); +} + +/// `LegacyVisitStore.remove` falha uma vez, como o app morto depois do 200. +class _RemocaoLegadaFalhaUmaVez implements VisitStorage { + _RemocaoLegadaFalhaUmaVez(this._inner) : legacy = _LegadoFalha(_inner.legacy); + + final VisitStorage _inner; + + @override + final LegacyVisitStore legacy; + + @override + VisitStore forOwner(String ownerId) => _inner.forOwner(ownerId); + + @override + Future> countsByOwner() => _inner.countsByOwner(); + + @override + Future wipeAllData() => _inner.wipeAllData(); +} + +class _LegadoFalha implements LegacyVisitStore { + _LegadoFalha(this._inner); + + final LegacyVisitStore _inner; + bool _falhou = false; + + @override + Future> load() => _inner.load(); + + @override + Future remove(Iterable localIds) async { + if (!_falhou) { + _falhou = true; + throw StateError('processo morto'); + } + await _inner.remove(localIds); + } +} + +/// A primeira gravação da fila de um dono falha (o 200 chegou, o disco não +/// foi limpo). +class _GravacaoDoDonoFalhaUmaVez implements VisitStorage { + _GravacaoDoDonoFalhaUmaVez(this._inner); + + final VisitStorage _inner; + bool _falhou = false; + + @override + LegacyVisitStore get legacy => _inner.legacy; + + @override + Future> countsByOwner() => _inner.countsByOwner(); + + @override + VisitStore forOwner(String ownerId) => _DonoFalha(this, _inner.forOwner(ownerId)); + + @override + Future wipeAllData() => _inner.wipeAllData(); +} + +class _DonoFalha implements VisitStore { + _DonoFalha(this._dono, this._inner); + + final _GravacaoDoDonoFalhaUmaVez _dono; + final VisitStore _inner; + + @override + Future> load() => _inner.load(); + + @override + Future save(List visits) async { + if (!_dono._falhou) { + _dono._falhou = true; + throw StateError('processo morto'); + } + await _inner.save(visits); + } +} + +class _TokensQueFalham implements UploadTokenStore { + @override + Future clear(String ownerId) => Future.error(StateError('Keystore')); + + @override + Future> owners() => Future.error(StateError('Keystore')); + + @override + Future read(String ownerId) => Future.error(StateError('Keystore')); + + @override + Future write(String ownerId, String token) => Future.error(StateError('Keystore')); + + @override + Future repairIndex(String ownerId) => Future.error(StateError('Keystore')); + + @override + Future clearAll({Iterable alsoOwners = const []}) => Future.error(StateError('Keystore')); + + @override + Future compareAndClear(String ownerId, String expected) => Future.error(StateError('Keystore')); +} + +/// Tokens guardados cujo ÍNDICE se perdeu: `owners()` não lista ninguém, mas +/// a leitura por chave funciona. +class _IndicePerdido implements UploadTokenStore { + _IndicePerdido(this._tokens); + + final Map _tokens; + final List lidos = []; + final List reparados = []; + + @override + Future> owners() async => const []; + + @override + Future read(String ownerId) async { + lidos.add(ownerId); + return _tokens[ownerId]; + } + + @override + Future write(String ownerId, String token) async => _tokens[ownerId] = token; + + @override + Future clear(String ownerId) async => _tokens.remove(ownerId); + + @override + Future repairIndex(String ownerId) async => reparados.add(ownerId); + + @override + Future clearAll({Iterable alsoOwners = const []}) async => _tokens.clear(); + + @override + Future compareAndClear(String ownerId, String expected) async { + if (_tokens[ownerId] != expected) return false; + _tokens.remove(ownerId); + return true; + } +} diff --git a/apps/acs/test/deferred_flush_triggers_test.dart b/apps/acs/test/deferred_flush_triggers_test.dart new file mode 100644 index 00000000..16a69477 --- /dev/null +++ b/apps/acs/test/deferred_flush_triggers_test.dart @@ -0,0 +1,109 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/app/app.dart'; +import 'package:sinalacs_acs/core/security/session_token_store.dart'; +import 'package:sinalacs_acs/core/security/upload_token_store.dart'; +import 'package:sinalacs_acs/core/services/offline_visit_queue.dart'; + +import 'support/fakes.dart'; +import 'support/layout_harness.dart' show assentar; + +/// Gatilhos do envio diferido no app (D8): depois de abrir o painel, na +/// retomada e quando a rede volta. Nunca bloqueia a UI. Ids sintéticos. +void main() { + const acsA = 'acs-a'; + const acsB = 'acs-b'; + + OfflineVisitRecord visita(String localId) => + OfflineVisitRecord(localId: localId, patientId: seedPatientId, risk: 'green', status: 'PENDENTE'); + + late InMemoryVisitStorage storage; + late FakeAcsBackend backend; + late MemoryUploadTokenStore tokens; + late StreamController rede; + + setUp(() async { + storage = InMemoryVisitStorage(); + await storage.forOwner(acsA).save([visita('a-1')]); + tokens = MemoryUploadTokenStore({acsA: 'upload-a'}); + backend = FakeAcsBackend()..nextUserId = acsB; + rede = StreamController.broadcast(); + }); + + tearDown(() => rede.close()); + + Future abrirApp(WidgetTester tester, {bool comEnvio = true}) async { + await tester.pumpWidget(SinalAcsApp( + backend: backend, + visitStorage: storage, + uploadTokens: comEnvio ? tokens : null, + deviceIds: comEnvio ? MemoryDeviceIdStore('aparelho-sintetico') : null, + connectivityChanges: rede.stream, + biometricGate: FakeBiometricGate(), + feedBuilder: (q) => FakeAlertFeed(q), + )); + await assentar(tester); + } + + Future entrar(WidgetTester tester) async { + await tester.enterText(find.byKey(const Key('matricula_field')), 'ACS-001'); + await tester.enterText(find.byKey(const Key('senha_field')), 'senha-sintetica'); + final botao = find.byKey(const Key('login_button')); + await tester.ensureVisible(botao); + await tester.pump(); + await tester.tap(botao); + await assentar(tester); + expect(find.text('Painel operacional'), findsOneWidget); + } + + List enviadosCom(String token) => [ + for (final lote in backend.deferredBatches) + if (lote.uploadToken == token) ...lote.visits.map((e) => e.localId), + ]; + + testWidgets('abrir o painel de B sobe a fila de A com o token de A', (tester) async { + await abrirApp(tester); + expect(backend.deferredBatches, isEmpty, reason: 'antes do login nada dispara'); + + await entrar(tester); + + expect(enviadosCom('upload-a'), ['a-1']); + expect(await storage.forOwner(acsA).load(), isEmpty); + expect(backend.revokedUploadTokens, ['upload-a']); + }); + + testWidgets('retomada e volta da rede disparam de novo', (tester) async { + await abrirApp(tester); + await entrar(tester); + final antes = backend.deferredBatches.length; + + await storage.forOwner(acsA).save([visita('a-2')]); + await tokens.write(acsA, 'upload-a2'); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await assentar(tester); + expect(enviadosCom('upload-a2'), ['a-2']); + + await storage.forOwner(acsA).save([visita('a-3')]); + await tokens.write(acsA, 'upload-a3'); + rede.add(false); + await assentar(tester); + expect(enviadosCom('upload-a3'), isEmpty, reason: 'sem rede não dispara'); + rede.add(true); + await assentar(tester); + expect(enviadosCom('upload-a3'), ['a-3']); + expect(backend.deferredBatches.length, antes + 2); + }); + + testWidgets('sem token store nem id do aparelho (padrão dos testes) nada é enviado', (tester) async { + await abrirApp(tester, comEnvio: false); + await entrar(tester); + rede.add(true); + await assentar(tester); + + expect(backend.deferredBatches, isEmpty); + expect(backend.legacyBatches, isEmpty); + }); +} diff --git a/apps/acs/test/device_wipe_test.dart b/apps/acs/test/device_wipe_test.dart new file mode 100644 index 00000000..28fe92ee --- /dev/null +++ b/apps/acs/test/device_wipe_test.dart @@ -0,0 +1,568 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/app/app.dart'; +import 'package:sinalacs_acs/core/database/encrypted_database.dart'; +import 'package:sinalacs_acs/core/database/micro_area_cache_store.dart'; +import 'package:sinalacs_acs/core/database/sqlcipher_visit_store.dart'; +import 'package:sinalacs_acs/core/network/backend_client.dart'; +import 'package:sinalacs_acs/core/security/database_key_store.dart'; +import 'package:sinalacs_acs/core/security/session_token_store.dart'; +import 'package:sinalacs_acs/core/security/upload_token_store.dart'; +import 'package:sinalacs_acs/core/services/micro_area_directory.dart'; +import 'package:sinalacs_acs/core/services/offline_visit_queue.dart'; +import 'package:sinalacs_client/sinalacs_client.dart'; + +import 'support/fakes.dart'; +import 'support/layout_harness.dart' show assentar, irParaDaBarra, irParaDoMais; + +/// "Limpar este aparelho" (D9): só apaga com TUDO enviado — de todos os donos, +/// da quarentena e da memória. Nunca apaga visita não enviada. Ids, pacientes +/// e tokens sintéticos. +void main() { + const acsA = 'acs-a'; + const acsB = 'acs-b'; + const tabelas = ['offline_visits', 'sync_cursor', 'micro_area_cache', 'micro_area_cache_meta']; + const semRede = BackendFailure('Sem conexão com o servidor.'); + + OfflineVisitRecord pendente(String localId) => + OfflineVisitRecord(localId: localId, patientId: seedPatientId, risk: 'red', status: 'PENDENTE'); + + SqlCipherVisitStorage abrirBanco(String dbName) => SqlCipherVisitStorage( + keyStore: InMemoryDatabaseKeyStore(), + databaseName: dbName, + allowUnencryptedForTesting: true, + ); + + /// Linha sem dono (quarentena, D2), como um aparelho que veio do schema v6. + Future gravarLegada(SqlCipherVisitStorage storage, String localId, {String? rejectionReason}) async { + final db = await storage.database.open(); + await db.insert('offline_visits', { + 'local_id': localId, + 'patient_id': seedPatientId, + 'risk': 'red', + 'status': 'PENDENTE', + 'outcome': 'PENDENTE', + 'created_at': DateTime.utc(2026, 10, 1).toIso8601String(), + 'version': 1, + 'rejection_reason': rejectionReason, + }); + } + + /// Cursor do pull e cache da microárea: o que o wipe apaga junto. + Future gravarCursorECache(SqlCipherVisitStorage storage) async { + final db = await storage.database.open(); + await db.insert('sync_cursor', {'key': 'visits_pull|$acsA|$seedMicroAreaId', 'value': '2026-10-03T10:00:00.000Z'}); + await db.insert('micro_area_cache', { + 'patient_id': syntheticPatientId(7), + 'name': 'Paciente Sintético', + 'is_chronic': 0, + 'chronic_conditions': '[]', + }); + await db.insert('micro_area_cache_meta', {'key': 'owner', 'value': '$acsA|$seedMicroAreaId'}); + } + + Future> contarTabelas(SqlCipherVisitStorage storage) async { + final db = await storage.database.open(); + return { + for (final tabela in tabelas) + tabela: ((await db.rawQuery('SELECT COUNT(*) AS n FROM $tabela')).single['n']! as int), + }; + } + + group('VisitDatabase.wipeAllData (banco real, FFI)', () { + TestWidgetsFlutterBinding.ensureInitialized(); + const dbName = 'device_wipe_unit_test.db'; + + setUp(() => EncryptedLocalDatabase.deleteDatabaseFile(dbName)); + tearDown(() => EncryptedLocalDatabase.deleteDatabaseFile(dbName)); + + test('recusa com visita de um dono e não apaga nada', () async { + final storage = abrirBanco(dbName); + addTearDown(storage.close); + await storage.forOwner(acsA).save([pendente('a-1')]); + await gravarCursorECache(storage); + final antes = await contarTabelas(storage); + + await expectLater(storage.database.wipeAllData(), throwsA(isA().having((e) => e.unsent, 'unsent', 1))); + expect(await contarTabelas(storage), antes); + }); + + test('recusa com uma linha SEM dono (quarentena) e não apaga nada', () async { + final storage = abrirBanco(dbName); + addTearDown(storage.close); + await gravarLegada(storage, 'legada-1'); + await gravarCursorECache(storage); + final antes = await contarTabelas(storage); + + await expectLater(storage.database.wipeAllData(), throwsA(isA().having((e) => e.unsent, 'unsent', 1))); + expect(await contarTabelas(storage), antes); + expect(await storage.legacy.load(), hasLength(1)); + }); + + test('recusa com só uma visita RECUSADA de um dono e não apaga nada', () async { + final storage = abrirBanco(dbName); + addTearDown(storage.close); + await storage.forOwner(acsA).save([ + OfflineVisitRecord( + localId: 'a-recusada', + patientId: seedPatientId, + risk: 'red', + status: 'PENDENTE', + rejectionReason: 'paciente fora da sua microárea', + ), + ]); + await gravarCursorECache(storage); + final antes = await contarTabelas(storage); + + await expectLater(storage.database.wipeAllData(), throwsA(isA().having((e) => e.unsent, 'unsent', 1))); + expect(await contarTabelas(storage), antes); + }); + + test('recusa com só uma visita RECUSADA sem dono (quarentena) e não apaga nada', () async { + final storage = abrirBanco(dbName); + addTearDown(storage.close); + await gravarLegada(storage, 'legada-recusada', rejectionReason: 'visita já registrada com autor — versão diferente'); + await gravarCursorECache(storage); + final antes = await contarTabelas(storage); + + await expectLater(storage.database.wipeAllData(), throwsA(isA().having((e) => e.unsent, 'unsent', 1))); + expect(await contarTabelas(storage), antes); + }); + + test('sem visita pendente apaga cursor e cache; o arquivo continua e reabre', () async { + final storage = abrirBanco(dbName); + addTearDown(storage.close); + await gravarCursorECache(storage); + + await storage.database.wipeAllData(); + + expect(await contarTabelas(storage), {for (final t in tabelas) t: 0}); + expect(File(await EncryptedLocalDatabase.pathFor(dbName)).existsSync(), isTrue, + reason: 'limpeza de dados, não troca de chave nem apagamento do arquivo'); + await storage.forOwner(acsA).save([pendente('depois')]); + expect(await storage.forOwner(acsA).load(), hasLength(1), reason: 'o banco segue utilizável'); + }); + }); + + group('"Limpar este aparelho" no app', () { + const dbName = 'device_wipe_widget_test.db'; + + late SqlCipherVisitStorage storage; + late FakeAcsBackend backend; + late MemoryUploadTokenStore tokens; + + setUp(() async { + await EncryptedLocalDatabase.deleteDatabaseFile(dbName); + storage = abrirBanco(dbName); + backend = FakeAcsBackend()..nextUserId = acsA; + tokens = MemoryUploadTokenStore({acsA: 'upload-a', acsB: 'upload-b'}); + }); + + tearDown(() async { + await storage.close(); + await EncryptedLocalDatabase.deleteDatabaseFile(dbName); + }); + + Future abrirApp(WidgetTester tester, {VisitStorage? visitStorage}) async { + await tester.pumpWidget(SinalAcsApp( + backend: backend, + visitStorage: visitStorage ?? storage, + uploadTokens: tokens, + deviceIds: MemoryDeviceIdStore('aparelho-sintetico'), + feedBuilder: (q) => FakeAlertFeed(q), + )); + await assentar(tester); + } + + Future entrarComo(WidgetTester tester, String userId) async { + backend.nextUserId = userId; + await tester.enterText(find.byKey(const Key('matricula_field')), 'ACS-001'); + await tester.enterText(find.byKey(const Key('senha_field')), 'senha-sintetica'); + final botao = find.byKey(const Key('login_button')); + await tester.ensureVisible(botao); + await tester.pump(); + await tester.tap(botao); + await assentar(tester); + expect(find.text('Painel operacional'), findsOneWidget); + } + + Future tocarEmLimpar(WidgetTester tester) async { + await irParaDoMais(tester, 'Preferências'); + final limpar = find.byKey(const Key('wipe_button')); + await tester.ensureVisible(limpar); + await tester.pump(); + await tester.tap(limpar); + await assentar(tester); + } + + Future io(WidgetTester tester, Future Function() op) async => (await tester.runAsync(op)) as T; + + /// Bloqueado: diálogo com a contagem e o MOTIVO certo, nada apagado, sem + /// logout. [motivo]: 'envio' (não enviada), 'revisao' (recusada) ou + /// 'semToken' (dono sem chave de envio). Nunca fala de revisão sem visita + /// recusada. + Future esperarBloqueio( + WidgetTester tester, { + required int visitas, + required Map antes, + bool tokenDeAFica = true, + String motivo = 'envio', + }) async { + expect(find.byKey(const Key('wipe_blocked')), findsOneWidget); + expect(find.byKey(const Key('wipe_confirm')), findsNothing); + final um = visitas == 1; + final conecte = find.textContaining('conecte e tente de novo; se não puderem ser enviadas, procure a coordenação'); + final revisao = find.textContaining('de revisão pelo ACS dono (reenvie ou descarte-as ao entrar com a conta dele)'); + final semToken = find.textContaining('entre com a conta do agente para enviar'); + switch (motivo) { + case 'envio': + expect(find.textContaining(um ? '1 visita ainda não foi enviada' : '$visitas visitas ainda não foram enviadas'), + findsOneWidget); + expect(conecte, findsOneWidget); + expect(revisao, findsNothing); + expect(semToken, findsNothing); + case 'revisao': + expect(find.textContaining(um ? '1 visita precisa de revisão' : '$visitas visitas precisam de revisão'), + findsOneWidget); + expect(revisao, findsOneWidget); + expect(conecte, findsNothing); + case 'semToken': + expect(semToken, findsOneWidget); + expect(revisao, findsNothing); + expect(conecte, findsNothing); + } + expect(find.textContaining('revisão'), motivo == 'revisao' ? findsWidgets : findsNothing); + await tester.tap(find.text('Entendi')); + await assentar(tester); + + expect(await io(tester, () => contarTabelas(storage)), antes, reason: 'o wipe bloqueado não apaga nada'); + if (tokenDeAFica) expect(await tokens.read(acsA), 'upload-a'); + expect(backend.logoutCount, 0); + expect(find.text('Painel operacional'), findsOneWidget); + } + + testWidgets('bloqueia com 1 visita do PRÓPRIO ACS não enviada', (tester) async { + await io(tester, () async { + await storage.forOwner(acsA).save([pendente('a-1')]); + await gravarCursorECache(storage); + }); + backend.syncFailure = semRede; + final antes = await io(tester, () => contarTabelas(storage)); + await abrirApp(tester); + await entrarComo(tester, acsA); + + await tocarEmLimpar(tester); + + expect(backend.callLog, contains('syncVisits'), reason: 'tentou enviar antes de decidir'); + await esperarBloqueio(tester, visitas: 1, antes: antes); + expect(await io(tester, () => storage.forOwner(acsA).load()), hasLength(1)); + }); + + testWidgets('bloqueia com 1 visita de OUTRO ACS não enviada', (tester) async { + await io(tester, () async { + await storage.forOwner(acsB).save([pendente('b-1')]); + await gravarCursorECache(storage); + }); + backend.deferredFailure = semRede; + final antes = await io(tester, () => contarTabelas(storage)); + await abrirApp(tester); + await entrarComo(tester, acsA); + + await tocarEmLimpar(tester); + + await esperarBloqueio(tester, visitas: 1, antes: antes); + expect(await io(tester, () => storage.forOwner(acsB).load()), hasLength(1)); + expect(await tokens.read(acsB), 'upload-b', reason: 'o token de B segue enquanto a visita dele não sobe'); + }); + + testWidgets('bloqueia com 1 visita LEGADA (sem dono) não enviada', (tester) async { + await io(tester, () async { + await gravarLegada(storage, 'legada-1'); + await gravarCursorECache(storage); + }); + backend.legacyFailure = semRede; + final antes = await io(tester, () => contarTabelas(storage)); + await abrirApp(tester); + await entrarComo(tester, acsA); + + await tocarEmLimpar(tester); + + await esperarBloqueio(tester, visitas: 1, antes: antes); + expect(await io(tester, () => storage.legacy.load()), hasLength(1)); + }); + + testWidgets('bloqueia com 1 visita que só existe na MEMÓRIA (fila de outro ACS que não gravou)', (tester) async { + await io(tester, () => gravarCursorECache(storage)); + backend.syncFailure = semRede; + backend.deferredFailure = semRede; + final antes = await io(tester, () => contarTabelas(storage)); + late FakeAlertFeed feed; + await tester.pumpWidget(SinalAcsApp( + backend: backend, + visitStorage: _GravacaoDeAFalha(storage), + uploadTokens: tokens, + deviceIds: MemoryDeviceIdStore('aparelho-sintetico'), + feedBuilder: (q) => feed = FakeAlertFeed(q), + )); + await assentar(tester); + await entrarComo(tester, acsA); + + // A registra uma visita; a gravação falha e ela fica só na RAM. + feed.deliver(testAlert(alertId: 'alerta-ram', riskLevel: 'yellow')); + await assentar(tester); + await tester.tap(find.text('Iniciar rota de visita')); + await assentar(tester); + await tester.tap(find.byKey(const Key('arrival_confirmation'))); + await assentar(tester); + final salvar = find.byKey(const Key('save_visit')); + await tester.ensureVisible(salvar); + await tester.pump(); + await tester.tap(salvar); + await assentar(tester); + await tester.pump(const Duration(seconds: 10)); // o SnackBar da falha some + await assentar(tester); + + // A sai (sem rede); B entra no mesmo aparelho e tenta limpar. + await irParaDoMais(tester, 'Preferências'); + final sair = find.byKey(const Key('logout_button')); + await tester.ensureVisible(sair); + await tester.pump(); + await tester.tap(sair); + await assentar(tester); + await tester.tap(find.byKey(const Key('logout_confirm'))); + await assentar(tester); + backend.logoutCount = 0; + await entrarComo(tester, acsB); + + await tocarEmLimpar(tester); + // O token de A pode ter sido revogado pelo envio diferido (o disco de A + // está vazio); a visita só em RAM sobe pela sessão de A, que ganha + // outro token ao entrar. + await esperarBloqueio(tester, visitas: 1, antes: antes, tokenDeAFica: false); + + // A visita de A continua na memória: A volta e a encontra. + await irParaDoMais(tester, 'Preferências'); + await tester.ensureVisible(sair); + await tester.pump(); + await tester.tap(sair); + await assentar(tester); + await tester.tap(find.byKey(const Key('logout_confirm'))); + await assentar(tester); + await entrarComo(tester, acsA); + await irParaDaBarra(tester, 'Visita'); + final contador = find.byKey(const Key('pending_visits_count')); + await tester.ensureVisible(contador); + await tester.pump(); + expect(tester.widget(contador).data, 'Pendentes de sincronização: 1 (em memória)'); + }); + + testWidgets('bloqueia com 1 visita RECUSADA do próprio ACS, pedindo revisão (não "conecte")', (tester) async { + await io(tester, () async { + await storage.forOwner(acsA).save([ + OfflineVisitRecord( + localId: 'a-recusada', + patientId: seedPatientId, + risk: 'red', + status: 'PENDENTE', + rejectionReason: 'paciente fora da sua microárea', + ), + ]); + await gravarCursorECache(storage); + }); + final antes = await io(tester, () => contarTabelas(storage)); + await abrirApp(tester); + await entrarComo(tester, acsA); + + await tocarEmLimpar(tester); + + await esperarBloqueio(tester, visitas: 1, antes: antes, motivo: 'revisao'); + }); + + testWidgets('bloqueia com visita de OUTRO ACS sem chave de envio: "entre com a conta do agente"', (tester) async { + tokens = MemoryUploadTokenStore({acsA: 'upload-a'}); + await io(tester, () async { + await storage.forOwner(acsB).save([pendente('b-1')]); + await gravarCursorECache(storage); + }); + final antes = await io(tester, () => contarTabelas(storage)); + await abrirApp(tester); + await entrarComo(tester, acsA); + + await tocarEmLimpar(tester); + + await esperarBloqueio(tester, visitas: 1, antes: antes, motivo: 'semToken'); + }); + + /// Abre o diálogo de confirmação (tudo enviado até ali). + Future ateAConfirmacao(WidgetTester tester) async { + await tocarEmLimpar(tester); + expect(find.byKey(const Key('wipe_confirm')), findsOneWidget); + } + + testWidgets('visita que entra na MEMÓRIA depois da confirmação aberta: a limpeza recusa e nada é apagado', (tester) async { + await io(tester, () => gravarCursorECache(storage)); + // Fila do painel que não grava: o que entrar nela só existe na RAM. + final fila = OfflineVisitQueue(store: FailingVisitStore(failOnLoad: false)); + await tester.pumpWidget(SinalAcsApp( + backend: backend, + visitStorage: storage, + visitQueue: fila, + uploadTokens: tokens, + deviceIds: MemoryDeviceIdStore('aparelho-sintetico'), + feedBuilder: (q) => FakeAlertFeed(q), + )); + await assentar(tester); + await entrarComo(tester, acsA); + final antes = await io(tester, () => contarTabelas(storage)); + await ateAConfirmacao(tester); + + await fila.add(pendente('chegou-depois')); + expect(fila.persistenceFailed, isTrue); + await tester.tap(find.byKey(const Key('wipe_confirm'))); + await assentar(tester); + + await esperarBloqueio(tester, visitas: 1, antes: antes); + expect(fila.pendingCount, 1); + }); + + testWidgets('visita que chega ao DISCO depois da confirmação aberta: a transação recusa e nada é apagado', (tester) async { + await io(tester, () => gravarCursorECache(storage)); + await abrirApp(tester); + await entrarComo(tester, acsA); + await ateAConfirmacao(tester); + + await io(tester, () => storage.forOwner(acsB).save([pendente('b-depois')])); + final antes = await io(tester, () => contarTabelas(storage)); + await tester.tap(find.byKey(const Key('wipe_confirm'))); + await assentar(tester); + + await esperarBloqueio(tester, visitas: 1, antes: antes); + expect(await io(tester, () => storage.forOwner(acsB).load()), hasLength(1)); + }); + + testWidgets('atualização da área em voo na confirmação: nada de cache nem cursor é regravado depois da limpeza', + (tester) async { + final syncAt = DateTime.utc(2026, 10, 3, 10); + backend.pullEntries = [ + VisitSyncEntry( + localId: 'remota-1', + patientId: seedPatientId, + scheduledAt: DateTime.utc(2026, 10, 3, 9), + status: 'realizada', + riskLevelBefore: RiskLevel.green, + notes: const {}, + version: 1, + syncAt: syncAt, + arrivalMethod: ArrivalMethod.manual, + ), + ]; + Completer? listaGate; + final directory = MicroAreaDirectory( + fetch: () async { + await listaGate?.future; + return [ + MicroAreaPatient(patientId: syntheticPatientId(5), name: 'Paciente Sintético', isChronic: false, chronicConditions: const []), + ]; + }, + session: () => backend.session, + store: MicroAreaCacheStore(keyStore: InMemoryDatabaseKeyStore(), databaseName: dbName, allowUnencryptedForTesting: true), + ); + await tester.pumpWidget(SinalAcsApp( + backend: backend, + visitStorage: storage, + microAreaDirectory: directory, + uploadTokens: tokens, + deviceIds: MemoryDeviceIdStore('aparelho-sintetico'), + feedBuilder: (q) => FakeAlertFeed(q), + )); + await assentar(tester); + await entrarComo(tester, acsA); + final carregado = await io(tester, () => contarTabelas(storage)); + expect(carregado['micro_area_cache'], 1); + expect(carregado['sync_cursor'], 1); + + await ateAConfirmacao(tester); + // Volta do segundo plano: atualização da área em voo, presa na rede. + listaGate = Completer(); + backend.pullGate = Completer(); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pump(); + + await tester.tap(find.byKey(const Key('wipe_confirm'))); + await assentar(tester); + // A rede responde depois que a pessoa confirmou. + listaGate.complete(); + backend.pullGate!.complete(); + await assentar(tester); + await assentar(tester); + + expect(find.byKey(const Key('login_button')), findsOneWidget); + expect(await io(tester, () => contarTabelas(storage)), {for (final t in tabelas) t: 0}, + reason: 'nada do território é regravado depois de "dados apagados"'); + }); + + testWidgets('com TUDO enviado: confirma, esvazia as tabelas e as chaves de envio, sai e volta ao login', (tester) async { + await io(tester, () async { + await storage.forOwner(acsA).save([pendente('a-1')]); + await storage.forOwner(acsB).save([pendente('b-1')]); + await gravarLegada(storage, 'legada-1'); + await gravarCursorECache(storage); + }); + await abrirApp(tester); + await entrarComo(tester, acsA); + + await tocarEmLimpar(tester); + + expect(find.byKey(const Key('wipe_blocked')), findsNothing); + expect( + find.text('Isto apaga neste aparelho as visitas já enviadas, o cache da microárea, o cursor de sincronização e as chaves de envio. Não pode ser desfeito.'), + findsOneWidget, + ); + await tester.tap(find.text('Cancelar')); + await assentar(tester); + expect(await io(tester, () => contarTabelas(storage)), isNot({for (final t in tabelas) t: 0}), + reason: 'cancelar não apaga'); + expect(backend.logoutCount, 0); + + await tocarEmLimpar(tester); + await tester.tap(find.byKey(const Key('wipe_confirm'))); + await assentar(tester); + await assentar(tester); // E/S real do banco (FFI) + transição para o login + + expect(await io(tester, () => contarTabelas(storage)), {for (final t in tabelas) t: 0}); + expect(await tokens.owners(), isEmpty); + expect(await tokens.read(acsA), isNull); + expect(await tokens.read(acsB), isNull); + expect(backend.revokedUploadTokens, containsAll(['upload-a', 'upload-b'])); + expect(backend.logoutCount, 1); + expect(backend.callLog.lastIndexOf('revokeUploadToken'), lessThan(backend.callLog.lastIndexOf('logout'))); + expect(find.byKey(const Key('login_button')), findsOneWidget); + expect(find.text('Painel operacional'), findsNothing); + expect(find.text('Os dados deste aparelho foram apagados.'), findsOneWidget); + }); + }); +} + +/// Só a visão de `acs-a` recusa gravar: a visita de A fica só na RAM. +class _GravacaoDeAFalha implements VisitStorage { + _GravacaoDeAFalha(this._inner); + + final VisitStorage _inner; + + @override + LegacyVisitStore get legacy => _inner.legacy; + + @override + Future> countsByOwner() => _inner.countsByOwner(); + + @override + Future wipeAllData() => _inner.wipeAllData(); + + @override + VisitStore forOwner(String ownerId) => + ownerId == 'acs-a' ? FailingVisitStore(failOnLoad: false) : _inner.forOwner(ownerId); +} diff --git a/apps/acs/test/encrypted_database_test.dart b/apps/acs/test/encrypted_database_test.dart index 4fd8daa1..0b6c70e1 100644 --- a/apps/acs/test/encrypted_database_test.dart +++ b/apps/acs/test/encrypted_database_test.dart @@ -187,6 +187,7 @@ CREATE TABLE IF NOT EXISTS offline_visits ( // A migração roda uma vez. Se rodasse a cada abertura, ela apagaria a // fila do dia toda vez que o app abrisse. final store = SqlCipherVisitStore( + owner: 'acs-teste', keyStore: InMemoryDatabaseKeyStore(), databaseName: nome, allowUnencryptedForTesting: true, @@ -245,6 +246,170 @@ CREATE TABLE IF NOT EXISTS offline_visits ( expect(linhas.single['rejection_reason'], isNull); await atual.close(); }); + + test('a v5 ganha as tabelas do cache da microárea sem perder a visita pendente', () async { + sqfliteFfiInit(); + final legado = await databaseFactoryFfi.openDatabase( + await EncryptedLocalDatabase.pathFor(nome), + options: OpenDatabaseOptions( + version: 5, + onCreate: (db, version) async { + await db.execute(EncryptedLocalDatabase.createOfflineVisits); + await db.execute(EncryptedLocalDatabase.createSyncCursor); + }, + ), + ); + await legado.insert('offline_visits', { + 'local_id': '00000000-0000-4000-8000-00000000000c', + 'patient_id': seedPatientId, + 'risk': 'red', + 'status': 'PENDENTE', + 'outcome': '', + 'notes': '', + 'created_at': DateTime.utc(2026).toIso8601String(), + 'version': 1, + }); + await legado.close(); + + final atual = await EncryptedLocalDatabase.open( + databaseName: nome, + passphrase: chave, + allowUnencryptedForTesting: true, + ); + + final novas = await atual.rawQuery( + "SELECT name FROM sqlite_master WHERE name IN ('micro_area_cache','micro_area_cache_meta')", + ); + expect(novas, hasLength(2)); + expect(await atual.query('offline_visits'), hasLength(1)); + expect(EncryptedLocalDatabase.schemaVersion, 7); + await atual.close(); + }); + + test('a v6 ganha a coluna owner; as linhas existentes ficam com owner nulo', () async { + sqfliteFfiInit(); + final legado = await databaseFactoryFfi.openDatabase( + await EncryptedLocalDatabase.pathFor(nome), + options: OpenDatabaseOptions( + version: 6, + onCreate: (db, version) async { + await db.execute(''' +CREATE TABLE offline_visits ( + local_id TEXT PRIMARY KEY, patient_id TEXT NOT NULL, risk TEXT NOT NULL, + status TEXT NOT NULL, outcome TEXT NOT NULL, notes TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, version INTEGER NOT NULL, rejection_reason TEXT +)'''); + }, + ), + ); + await legado.insert('offline_visits', { + 'local_id': 'local-v6-1', + 'patient_id': seedPatientId, + 'risk': 'red', + 'status': 'PENDENTE', + 'outcome': '', + 'notes': '', + 'created_at': DateTime.utc(2026).toIso8601String(), + 'version': 1, + }); + await legado.close(); + + final atual = await EncryptedLocalDatabase.open( + databaseName: nome, + passphrase: chave, + allowUnencryptedForTesting: true, + ); + + final colunas = [ + for (final row in await atual.rawQuery('PRAGMA table_info(offline_visits)')) + row['name']! as String, + ]; + expect(colunas, contains('owner')); + final linhas = await atual.query('offline_visits'); + expect(linhas.single['owner'], isNull); + await atual.close(); + }); + + Future temIndiceOwner(Database db) async => (await db.rawQuery( + "SELECT name FROM sqlite_master WHERE type='index' AND name='offline_visits_owner_idx'", + )) + .isNotEmpty; + + Future abrirVersaoAntigaEAtualizar(int versao, String create) async { + sqfliteFfiInit(); + final legado = await databaseFactoryFfi.openDatabase( + await EncryptedLocalDatabase.pathFor(nome), + options: OpenDatabaseOptions( + version: versao, + onCreate: (db, v) => db.execute(create), + ), + ); + await legado.close(); + } + + const base = ''' +CREATE TABLE offline_visits ( + local_id TEXT PRIMARY KEY, patient_id TEXT NOT NULL, risk TEXT NOT NULL, + status TEXT NOT NULL, outcome TEXT NOT NULL, notes TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, version INTEGER NOT NULL, rejection_reason TEXT +)'''; + + for (final versao in [3, 5, 6]) { + test('upgrade v$versao → v7 cria owner e offline_visits_owner_idx', () async { + await abrirVersaoAntigaEAtualizar(versao, base); + final atual = await EncryptedLocalDatabase.open( + databaseName: nome, + passphrase: chave, + allowUnencryptedForTesting: true, + ); + expect(await temIndiceOwner(atual), isTrue); + await atual.close(); + }); + } + + test('v1 → v7 recria com owner e offline_visits_owner_idx', () async { + final legado = await abrirV1( + 'CREATE TABLE IF NOT EXISTS local_queue (id TEXT PRIMARY KEY)', + ); + await legado.close(); + final atual = await EncryptedLocalDatabase.open( + databaseName: nome, + passphrase: chave, + allowUnencryptedForTesting: true, + ); + final colunas = [ + for (final row in await atual.rawQuery('PRAGMA table_info(offline_visits)')) + row['name']! as String, + ]; + expect(colunas, contains('owner')); + expect(await temIndiceOwner(atual), isTrue); + await atual.close(); + }); + + test('criação limpa tem offline_visits_owner_idx', () async { + final atual = await EncryptedLocalDatabase.open( + databaseName: nome, + passphrase: chave, + allowUnencryptedForTesting: true, + ); + expect(await temIndiceOwner(atual), isTrue); + await atual.close(); + }); + + test('criação limpa já tem a coluna owner', () async { + final atual = await EncryptedLocalDatabase.open( + databaseName: nome, + passphrase: chave, + allowUnencryptedForTesting: true, + ); + expect(EncryptedLocalDatabase.schemaVersion, 7); + final colunas = [ + for (final row in await atual.rawQuery('PRAGMA table_info(offline_visits)')) + row['name']! as String, + ]; + expect(colunas, contains('owner')); + await atual.close(); + }); }); group('SqlCipherVisitStore', () { @@ -260,6 +425,7 @@ CREATE TABLE IF NOT EXISTS offline_visits ( setUp(() async { await EncryptedLocalDatabase.deleteDatabaseFile('store_test.db'); store = SqlCipherVisitStore( + owner: 'acs-teste', keyStore: InMemoryDatabaseKeyStore(), databaseName: 'store_test.db', allowUnencryptedForTesting: true, @@ -309,6 +475,7 @@ CREATE TABLE IF NOT EXISTS offline_visits ( setUp(() async { await EncryptedLocalDatabase.deleteDatabaseFile('queue_test.db'); store = SqlCipherVisitStore( + owner: 'acs-teste', keyStore: InMemoryDatabaseKeyStore(), databaseName: 'queue_test.db', allowUnencryptedForTesting: true, diff --git a/apps/acs/test/escalation_dialer_test.dart b/apps/acs/test/escalation_dialer_test.dart new file mode 100644 index 00000000..e1b9939a --- /dev/null +++ b/apps/acs/test/escalation_dialer_test.dart @@ -0,0 +1,99 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/app/app.dart'; +import 'package:sinalacs_acs/core/services/emergency_dialer.dart'; + +import 'support/fakes.dart'; + +class _FakeDialer implements EmergencyDialer { + _FakeDialer({this.opens = true, this.throws = false}); + + final bool opens; + final bool throws; + final dialed = []; + + @override + Future dial(String number) async { + dialed.add(number); + if (throws) throw StateError('sem discador'); + return opens; + } +} + +class _GatedDialer implements EmergencyDialer { + _GatedDialer(this._gate, this.dialed); + + final Completer _gate; + final List dialed; + + @override + Future dial(String number) { + dialed.add(number); + return _gate.future; + } +} + +Future _pump(WidgetTester tester, _FakeDialer dialer) => tester.pumpWidget(MaterialApp( + home: Scaffold( + body: EscalationScreen(alert: testAlert(alertId: 'a1'), dialer: dialer), + ), + )); + +void main() { + testWidgets('o botão do SAMU abre o discador com 192', (tester) async { + final dialer = _FakeDialer(); + await _pump(tester, dialer); + + await tester.tap(find.text('Ligar para o SAMU (192)')); + await tester.pump(); + + expect(dialer.dialed, ['192']); + expect(find.text('Discagem não está integrada neste protótipo.'), findsNothing); + }); + + testWidgets('sem discador, mostra o número em texto em vez de falhar em silêncio', (tester) async { + final dialer = _FakeDialer(opens: false); + await _pump(tester, dialer); + + await tester.tap(find.text('Ligar para o SAMU (192)')); + await tester.pump(); + + expect(find.textContaining('Ligue manualmente para 192'), findsOneWidget); + }); + + testWidgets('um erro do discador cai no mesmo aviso, sem derrubar a tela', (tester) async { + final dialer = _FakeDialer(throws: true); + await _pump(tester, dialer); + + await tester.tap(find.text('Ligar para o SAMU (192)')); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect(find.textContaining('Ligue manualmente para 192'), findsOneWidget); + }); + + testWidgets('toque duplo não abre dois discadores', (tester) async { + // O discador real leva tempo; o segundo toque chega com o primeiro em voo. + final aberto = Completer(); + final dialed = []; + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: EscalationScreen( + alert: testAlert(alertId: 'a1'), + dialer: _GatedDialer(aberto, dialed), + ), + ), + )); + + await tester.tap(find.text('Ligar para o SAMU (192)')); + await tester.pump(); + await tester.tap(find.text('Ligar para o SAMU (192)'), warnIfMissed: false); + await tester.pump(); + aberto.complete(true); + await tester.pump(); + + expect(dialed, ['192']); + }); +} diff --git a/apps/acs/test/escalation_ubs_test.dart b/apps/acs/test/escalation_ubs_test.dart new file mode 100644 index 00000000..3585624f --- /dev/null +++ b/apps/acs/test/escalation_ubs_test.dart @@ -0,0 +1,111 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/app/app.dart'; +import 'package:sinalacs_acs/core/network/backend_client.dart'; +import 'package:sinalacs_acs/core/network/backend_scope.dart'; +import 'package:sinalacs_acs/core/services/emergency_dialer.dart'; +import 'package:sinalacs_client/sinalacs_client.dart' show UbsContact; + +import 'support/fakes.dart'; + +class _Dialer implements EmergencyDialer { + _Dialer({this.abre = true}); + final bool abre; + final discados = []; + + @override + Future dial(String number) async { + discados.add(number); + return abre; + } +} + +Future _abrir(WidgetTester tester, FakeAcsBackend backend, _Dialer dialer) => + tester.pumpWidget(MaterialApp( + home: BackendScope( + backend: backend, + child: Scaffold( + body: EscalationScreen(alert: testAlert(alertId: 'a1'), dialer: dialer), + ), + ), + )); + +void main() { + testWidgets('o botão da UBS abre o discador com o telefone cadastrado, só dígitos e +', (tester) async { + final backend = FakeAcsBackend(); + final dialer = _Dialer(); + await _abrir(tester, backend, dialer); + + await tester.tap(find.byKey(const Key('escalation_ubs'))); + await tester.pumpAndSettle(); + + expect(dialer.discados, ['+551155500100']); + expect(backend.ubsContactCount, 1); + }); + + testWidgets('UBS sem telefone: avisa e não liga', (tester) async { + final backend = FakeAcsBackend()..ubsContactResult = UbsContact(name: 'UBS Sem Fone'); + final dialer = _Dialer(); + await _abrir(tester, backend, dialer); + + await tester.tap(find.byKey(const Key('escalation_ubs'))); + await tester.pumpAndSettle(); + + expect(dialer.discados, isEmpty); + expect(find.textContaining('UBS Sem Fone ainda não cadastrou um telefone'), findsOneWidget); + }); + + testWidgets('sem discador, mostra o telefone em texto', (tester) async { + final backend = FakeAcsBackend(); + final dialer = _Dialer(abre: false); + await _abrir(tester, backend, dialer); + + await tester.tap(find.byKey(const Key('escalation_ubs'))); + await tester.pumpAndSettle(); + + expect(find.textContaining('Ligue manualmente para +55 11 5550-0100'), findsOneWidget); + }); + + testWidgets('falha ao obter o contato mostra a mensagem do backend e não liga', (tester) async { + final backend = FakeAcsBackend()..ubsContactFailure = const BackendFailure('Sem conexão.'); + final dialer = _Dialer(); + await _abrir(tester, backend, dialer); + + await tester.tap(find.byKey(const Key('escalation_ubs'))); + await tester.pumpAndSettle(); + + expect(dialer.discados, isEmpty); + expect(find.text('Sem conexão.'), findsOneWidget); + }); + + testWidgets('SAMU continua ativo enquanto a consulta da UBS está pendente', (tester) async { + final backend = FakeAcsBackend()..ubsContactGate = Completer(); + final dialer = _Dialer(); + await _abrir(tester, backend, dialer); + + await tester.tap(find.byKey(const Key('escalation_ubs'))); + await tester.pump(); + await tester.tap(find.text('Ligar para o SAMU (192)')); + await tester.pump(); + + expect(dialer.discados, ['192']); + + backend.ubsContactGate!.complete(); + await tester.pumpAndSettle(); + expect(dialer.discados, ['192', '+551155500100']); + }); + + testWidgets('telefone sem dígitos (n/d) vale como não cadastrado e não disca', (tester) async { + final backend = FakeAcsBackend()..ubsContactResult = UbsContact(name: 'UBS X', phone: 'n/d'); + final dialer = _Dialer(); + await _abrir(tester, backend, dialer); + + await tester.tap(find.byKey(const Key('escalation_ubs'))); + await tester.pumpAndSettle(); + + expect(dialer.discados, isEmpty); + expect(find.textContaining('UBS X ainda não cadastrou um telefone'), findsOneWidget); + }); +} diff --git a/apps/acs/test/invite_screen_test.dart b/apps/acs/test/invite_screen_test.dart new file mode 100644 index 00000000..55a8339e --- /dev/null +++ b/apps/acs/test/invite_screen_test.dart @@ -0,0 +1,349 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:sinalacs_acs/app/app.dart'; +import 'package:sinalacs_acs/core/network/backend_client.dart'; +import 'package:sinalacs_client/sinalacs_client.dart' show MicroAreaPatient; + +import 'support/fakes.dart'; +import 'support/semantics_scan.dart'; + +/// Mesmo caminho de `entrar` em `login_flow_test.dart` (não importável entre +/// arquivos de teste). Credenciais sintéticas. +Future entrar(WidgetTester tester) async { + await tester.enterText(find.byKey(const Key('matricula_field')), 'ACS-001'); + await tester.enterText( + find.byKey(const Key('senha_field')), + 'senha-sintetica', + ); + await tester.tap(find.byKey(const Key('login_button'))); + await tester.pumpAndSettle(); +} + +Future abrirConvite(WidgetTester tester) async { + await tester.tap(find.text('Mais')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Convidar paciente')); + await tester.pumpAndSettle(); +} + +Future tapKey(WidgetTester tester, String key) async { + final finder = find.byKey(Key(key)); + await tester.ensureVisible(finder); + await tester.pumpAndSettle(); + await tester.tap(finder); + await tester.pumpAndSettle(); +} + +FakeAcsBackend backendComPacientes() => FakeAcsBackend() + ..patients = [ + MicroAreaPatient( + patientId: syntheticPatientId(5), + name: 'Fulano de Tal', + isChronic: false, + chronicConditions: const [], + ), + MicroAreaPatient( + patientId: syntheticPatientId(6), + name: 'Ciclana da Silva', + isChronic: true, + chronicConditions: const ['diabetes'], + ), + ]; + +void main() { + testWidgets('escolher o paciente e gerar mostra o QR com validade', ( + tester, + ) async { + tester.view.physicalSize = const Size(800, 2000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final backend = backendComPacientes(); + await tester.pumpWidget( + SinalAcsApp( + backend: backend, + feedBuilder: (queue) => FakeAlertFeed(queue), + ), + ); + await entrar(tester); + await abrirConvite(tester); + + final gerar = tester.widget( + find.byKey(const Key('generate_invite_button')), + ); + expect( + gerar.onPressed, + isNull, + reason: 'sem paciente escolhido não há convite', + ); + + await tapKey(tester, 'invite_patient_${syntheticPatientId(6)}'); + await tapKey(tester, 'generate_invite_button'); + + expect(backend.inviteCalls, [syntheticPatientId(6)]); + expect(find.byType(QrImageView), findsOneWidget); + expect(find.byKey(const Key('invite_expires_at')), findsOneWidget); + expect(find.textContaining('Válido até'), findsOneWidget); + expect(find.text('convite-sintetico-1'), findsOneWidget); + }); + + testWidgets('gerar de novo substitui o convite exibido', (tester) async { + tester.view.physicalSize = const Size(800, 2000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final backend = backendComPacientes(); + await tester.pumpWidget( + SinalAcsApp( + backend: backend, + feedBuilder: (queue) => FakeAlertFeed(queue), + ), + ); + await entrar(tester); + await abrirConvite(tester); + + await tapKey(tester, 'invite_patient_${syntheticPatientId(5)}'); + await tapKey(tester, 'generate_invite_button'); + await tapKey(tester, 'generate_invite_button'); + + expect(backend.inviteCalls, hasLength(2)); + expect(find.text('convite-sintetico-2'), findsOneWidget); + expect(find.text('convite-sintetico-1'), findsNothing); + }); + + testWidgets('trocar de paciente esconde o convite anterior', (tester) async { + // Um QR na tela atribuído ao nome errado ativaria o cadastro de outra + // pessoa no aparelho de quem ler. + tester.view.physicalSize = const Size(800, 2000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final backend = backendComPacientes(); + await tester.pumpWidget( + SinalAcsApp( + backend: backend, + feedBuilder: (queue) => FakeAlertFeed(queue), + ), + ); + await entrar(tester); + await abrirConvite(tester); + + await tapKey(tester, 'invite_patient_${syntheticPatientId(5)}'); + await tapKey(tester, 'generate_invite_button'); + expect(find.byType(QrImageView), findsOneWidget); + + await tapKey(tester, 'invite_patient_${syntheticPatientId(6)}'); + expect(find.byType(QrImageView), findsNothing); + expect(find.text('convite-sintetico-1'), findsNothing); + }); + + testWidgets('convite que chega depois da troca de paciente é descartado', ( + tester, + ) async { + // Rede lenta: o ACS pede o convite de um paciente e troca de paciente + // antes da resposta. O token que chega é do primeiro — mostrá-lo sob o + // nome do segundo ativaria a sessão da pessoa errada. + tester.view.physicalSize = const Size(800, 2000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final gate = Completer(); + final backend = backendComPacientes()..inviteGate = gate; + await tester.pumpWidget( + SinalAcsApp( + backend: backend, + feedBuilder: (queue) => FakeAlertFeed(queue), + ), + ); + await entrar(tester); + await abrirConvite(tester); + + await tapKey(tester, 'invite_patient_${syntheticPatientId(5)}'); + await tester.tap(find.byKey(const Key('generate_invite_button'))); + await tester.pump(); + await tapKey(tester, 'invite_patient_${syntheticPatientId(6)}'); + + gate.complete(); + await tester.pumpAndSettle(); + + expect(find.byType(QrImageView), findsNothing); + expect(find.text('convite-sintetico-1'), findsNothing); + expect(find.textContaining('Convite de Ciclana'), findsNothing); + expect( + tester + .widget(find.byKey(const Key('generate_invite_button'))) + .onPressed, + isNotNull, + reason: 'a geração abandonada não pode deixar o botão travado', + ); + }); + + testWidgets('recusa do servidor mostra o motivo e nenhum QR', (tester) async { + tester.view.physicalSize = const Size(800, 2000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final backend = backendComPacientes() + ..inviteFailure = const BackendFailure( + 'Este paciente não pertence à sua microárea.', + isRecoverable: false, + ); + await tester.pumpWidget( + SinalAcsApp( + backend: backend, + feedBuilder: (queue) => FakeAlertFeed(queue), + ), + ); + await entrar(tester); + await abrirConvite(tester); + + await tapKey(tester, 'invite_patient_${syntheticPatientId(5)}'); + await tapKey(tester, 'generate_invite_button'); + + expect(find.byKey(const Key('invite_error')), findsOneWidget); + expect( + find.text('Este paciente não pertence à sua microárea.'), + findsOneWidget, + ); + expect(find.byType(QrImageView), findsNothing); + }); + + testWidgets('falha ao carregar pacientes permite tentar de novo', ( + tester, + ) async { + final backend = backendComPacientes() + ..listPatientsFailure = const BackendFailure( + 'Sem conexão com o servidor.', + ); + await tester.pumpWidget( + SinalAcsApp( + backend: backend, + feedBuilder: (queue) => FakeAlertFeed(queue), + ), + ); + await entrar(tester); + await abrirConvite(tester); + + expect(find.text('Sem conexão com o servidor.'), findsOneWidget); + backend.listPatientsFailure = null; + await tapKey(tester, 'invite_retry'); + expect(find.text('Fulano de Tal'), findsOneWidget); + }); + + testWidgets('microárea sem paciente explica e não oferece geração', ( + tester, + ) async { + await tester.pumpWidget( + SinalAcsApp( + backend: FakeAcsBackend(), + feedBuilder: (queue) => FakeAlertFeed(queue), + ), + ); + await entrar(tester); + await abrirConvite(tester); + + expect(find.byKey(const Key('invite_no_patients')), findsOneWidget); + expect(find.byKey(const Key('generate_invite_button')), findsNothing); + }); + + testWidgets('tela de convite não tem botão inerte para leitor de tela', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + await tester.pumpWidget( + SinalAcsApp( + backend: backendComPacientes(), + feedBuilder: (queue) => FakeAlertFeed(queue), + ), + ); + await entrar(tester); + await abrirConvite(tester); + expectNenhumBotaoInerte(tester); + handle.dispose(); + }); + + testWidgets('convite que expira com a tela aberta some e pede um novo', (tester) async { + tester.view.physicalSize = const Size(800, 2000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final backend = backendComPacientes(); + await tester.pumpWidget( + SinalAcsApp(backend: backend, feedBuilder: (queue) => FakeAlertFeed(queue)), + ); + await entrar(tester); + await abrirConvite(tester); + await tapKey(tester, 'invite_patient_${syntheticPatientId(5)}'); + await tapKey(tester, 'generate_invite_button'); + expect(find.byType(QrImageView), findsOneWidget); + + await tester.pump(const Duration(minutes: 15, seconds: 1)); + + expect(find.byType(QrImageView), findsNothing); + expect(find.byKey(const Key('invite_token_text')), findsNothing); + expect(find.byKey(const Key('invite_expired')), findsOneWidget); + + await tapKey(tester, 'generate_invite_button'); + expect(find.byType(QrImageView), findsOneWidget); + expect(find.byKey(const Key('invite_expired')), findsNothing); + }); + + testWidgets('trocar de paciente cancela o aviso de expiração do convite anterior', (tester) async { + tester.view.physicalSize = const Size(800, 2000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final backend = backendComPacientes(); + await tester.pumpWidget( + SinalAcsApp(backend: backend, feedBuilder: (queue) => FakeAlertFeed(queue)), + ); + await entrar(tester); + await abrirConvite(tester); + await tapKey(tester, 'invite_patient_${syntheticPatientId(5)}'); + await tapKey(tester, 'generate_invite_button'); + await tapKey(tester, 'invite_patient_${syntheticPatientId(6)}'); + + await tester.pump(const Duration(minutes: 16)); + + expect(find.byKey(const Key('invite_expired')), findsNothing, + reason: 'o convite já tinha saído da tela; nada a avisar'); + }); + + testWidgets('relógio do aparelho adiantado não esconde um convite válido', (tester) async { + // O servidor carimba `expiresAt` com o relógio dele. Com o do aparelho 30 + // minutos à frente, o convite chega "já vencido" para o aparelho — e o + // servidor ainda o aceitaria. A tela mede a validade a partir do recebimento. + tester.view.physicalSize = const Size(800, 2000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final backend = backendComPacientes()..inviteLifetime = const Duration(minutes: -30); + await tester.pumpWidget( + SinalAcsApp(backend: backend, feedBuilder: (queue) => FakeAlertFeed(queue)), + ); + await entrar(tester); + await abrirConvite(tester); + await tapKey(tester, 'invite_patient_${syntheticPatientId(5)}'); + await tapKey(tester, 'generate_invite_button'); + + expect(find.byType(QrImageView), findsOneWidget); + expect(find.byKey(const Key('invite_expired')), findsNothing); + + await tester.pump(const Duration(minutes: 15, seconds: 1)); + expect(find.byKey(const Key('invite_expired')), findsOneWidget); + }); + + testWidgets('relógio do aparelho atrasado não deixa o QR na tela além dos 15 minutos', (tester) async { + tester.view.physicalSize = const Size(800, 2000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final backend = backendComPacientes()..inviteLifetime = const Duration(minutes: 45); + await tester.pumpWidget( + SinalAcsApp(backend: backend, feedBuilder: (queue) => FakeAlertFeed(queue)), + ); + await entrar(tester); + await abrirConvite(tester); + await tapKey(tester, 'invite_patient_${syntheticPatientId(5)}'); + await tapKey(tester, 'generate_invite_button'); + expect(find.byType(QrImageView), findsOneWidget); + + await tester.pump(const Duration(minutes: 15, seconds: 1)); + + expect(find.byKey(const Key('invite_expired')), findsOneWidget); + }); +} diff --git a/apps/acs/test/keystore_vault_channel_test.dart b/apps/acs/test/keystore_vault_channel_test.dart new file mode 100644 index 00000000..293f9917 --- /dev/null +++ b/apps/acs/test/keystore_vault_channel_test.dart @@ -0,0 +1,91 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/core/security/keystore_vault.dart'; + +/// Fixa o contrato do canal que o `KeystoreVault.kt` cumpre: códigos de erro +/// `cancelled`/`lockedOut`/`invalidated`/qualquer outro (= `unavailable`), e +/// plugin ausente sempre como falha fechada. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('br.com.prismrr.sinalacs.acs/keystore_vault'); + final vault = MethodChannelKeystoreVault(); + + void mock(Future Function(MethodCall) h) => TestDefaultBinaryMessengerBinding + .instance + .defaultBinaryMessenger + .setMockMethodCallHandler(channel, h); + + tearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null), + ); + + for (final entry in { + 'cancelled': VaultFailure.cancelled, + 'lockedOut': VaultFailure.lockedOut, + 'invalidated': VaultFailure.invalidated, + 'qualquer-outro': VaultFailure.unavailable, + }.entries) { + test('unseal traduz o código ${entry.key}', () async { + mock((_) async => throw PlatformException(code: entry.key)); + await expectLater( + () => vault.unseal('a', reason: 'x'), + throwsA( + isA().having((e) => e.failure, 'failure', entry.value), + ), + ); + }); + } + + test('plugin ausente vira unavailable e isSupported false', () async { + await expectLater( + () => vault.unseal('a', reason: 'x'), + throwsA( + isA().having( + (e) => e.failure, + 'failure', + VaultFailure.unavailable, + ), + ), + ); + expect(await vault.isSupported, isFalse); + }); + + test('plugin ausente em seal também vira VaultException(unavailable)', () async { + await expectLater( + () => vault.seal('a', 't'), + throwsA( + isA().having( + (e) => e.failure, + 'failure', + VaultFailure.unavailable, + ), + ), + ); + }); + + test('seal que falha vira VaultException(unavailable)', () async { + mock((_) async => throw PlatformException(code: 'seal_failed')); + await expectLater( + () => vault.seal('a', 't'), + throwsA( + isA().having( + (e) => e.failure, + 'failure', + VaultFailure.unavailable, + ), + ), + ); + }); + + test('unseal devolve o texto e null quando não há blob', () async { + mock((call) async { + expect(call.method, 'unseal'); + expect(call.arguments, {'alias': 'a', 'reason': 'x'}); + return 'tok'; + }); + expect(await vault.unseal('a', reason: 'x'), 'tok'); + mock((_) async => null); + expect(await vault.unseal('a', reason: 'x'), isNull); + }); +} diff --git a/apps/acs/test/login_flow_test.dart b/apps/acs/test/login_flow_test.dart index be143782..dc2d5479 100644 --- a/apps/acs/test/login_flow_test.dart +++ b/apps/acs/test/login_flow_test.dart @@ -705,7 +705,7 @@ void main() { // recusaria a visita por identificador inválido. final backend = FakeAcsBackend(); final visitQueue = OfflineVisitQueue( - synchronizer: BackendVisitSynchronizer(backend: backend), + synchronizer: BackendVisitSynchronizer(backend: backend, ownerId: seedAcsId), ); late FakeAlertFeed feed; @@ -977,7 +977,7 @@ void main() { final backend = FakeAcsBackend() ..syncFailure = const BackendFailure('Sem conexão com o servidor.'); final visitQueue = OfflineVisitQueue( - synchronizer: BackendVisitSynchronizer(backend: backend), + synchronizer: BackendVisitSynchronizer(backend: backend, ownerId: seedAcsId), ); late FakeAlertFeed feed; @@ -1021,7 +1021,7 @@ void main() { message: 'paciente fora da sua microárea', ); final visitQueue = OfflineVisitQueue( - synchronizer: BackendVisitSynchronizer(backend: backend), + synchronizer: BackendVisitSynchronizer(backend: backend, ownerId: seedAcsId), ); late FakeAlertFeed feed; @@ -1369,6 +1369,7 @@ void main() { final queue = buildVisitQueue( backend: FakeAcsBackend(), store: InMemoryVisitStore(), + ownerId: seedAcsId, ); expect(queue.synchronizer, isA()); @@ -1413,6 +1414,7 @@ void main() { backend: backend, cursorStore: SyncCursorStore( keyStore: InMemoryDatabaseKeyStore(), + owner: 'acs-a', databaseName: dbName, allowUnencryptedForTesting: true, ), @@ -1551,6 +1553,7 @@ void main() { backend: backend, cursorStore: SyncCursorStore( keyStore: InMemoryDatabaseKeyStore(), + owner: 'acs-a', databaseName: dbName, allowUnencryptedForTesting: true, ), diff --git a/apps/acs/test/mfa_login_test.dart b/apps/acs/test/mfa_login_test.dart new file mode 100644 index 00000000..59bbb03e --- /dev/null +++ b/apps/acs/test/mfa_login_test.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/app/app.dart'; + +import 'support/fakes.dart'; + +Future entrar(WidgetTester tester) async { + await tester.enterText(find.byKey(const Key('matricula_field')), 'ACS-001'); + await tester.enterText(find.byKey(const Key('senha_field')), 'senha-sintetica'); + await tester.tap(find.byKey(const Key('login_button'))); +} + +void main() { + // 1. MFA exigida: depois de matrícula+senha, aparece o campo de código e o painel NÃO abre. + testWidgets('pede o código do autenticador depois de matrícula e senha', (tester) async { + final backend = FakeAcsBackend()..expectedTotpCode = '123456'; + await tester.pumpWidget(SinalAcsApp(backend: backend, feedBuilder: (q) => FakeAlertFeed(q))); + await entrar(tester); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('totp_field')), findsOneWidget); + expect(find.byKey(const Key('login_button')), findsOneWidget, reason: 'o painel não abriu'); + }); + + // 2. Código certo: entra, e o app reenviou matrícula+senha COM o código. + testWidgets('com o código certo, entra', (tester) async { + final backend = FakeAcsBackend()..expectedTotpCode = '123456'; + await tester.pumpWidget(SinalAcsApp(backend: backend, feedBuilder: (q) => FakeAlertFeed(q))); + await entrar(tester); + await tester.pumpAndSettle(); + + await tester.enterText(find.byKey(const Key('totp_field')), '123456'); + await tester.tap(find.byKey(const Key('login_button'))); + await tester.pumpAndSettle(); + + expect(backend.lastTotpCode, '123456'); + expect(find.byKey(const Key('login_button')), findsNothing, reason: 'o painel abriu'); + }); + + // 3. Código errado: erro visível, painel fechado, campo continua. + testWidgets('com o código errado, mostra o erro e fica no login', (tester) async { + final backend = FakeAcsBackend()..expectedTotpCode = '123456'; + await tester.pumpWidget(SinalAcsApp(backend: backend, feedBuilder: (q) => FakeAlertFeed(q))); + await entrar(tester); + await tester.pumpAndSettle(); + + await tester.enterText(find.byKey(const Key('totp_field')), '000000'); + await tester.tap(find.byKey(const Key('login_button'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('login_error')), findsOneWidget); + expect(find.byKey(const Key('totp_field')), findsOneWidget); + }); + + // 4. Servidor exige MFA e o ACS não a tem: vai para a tela de ativação, com segredo e campo de código. + testWidgets('sem MFA ativada, leva à ativação e confirma com o código', (tester) async { + final backend = FakeAcsBackend()..mfaEnrollmentRequired = true; + await tester.pumpWidget(SinalAcsApp(backend: backend, feedBuilder: (q) => FakeAlertFeed(q))); + await entrar(tester); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('mfa_secret')), findsOneWidget); + expect(find.textContaining('GEZDGNBVGY3TQOJQ'), findsOneWidget); + + await tester.enterText(find.byKey(const Key('mfa_code_field')), '654321'); + await tester.tap(find.byKey(const Key('mfa_confirm_button'))); + await tester.pumpAndSettle(); + + expect(backend.confirmedCode, '654321'); + expect(find.text('Aguarde o próximo código do autenticador.'), findsOneWidget); + // Voltou ao login, pronto para entrar de novo (agora com MFA). + expect(find.byKey(const Key('login_button')), findsOneWidget); + }); + + // 5. O código é só dígitos: o campo recusa letras e passa de 6. + testWidgets('o campo de código aceita só 6 dígitos', (tester) async { + final backend = FakeAcsBackend()..expectedTotpCode = '123456'; + await tester.pumpWidget(SinalAcsApp(backend: backend, feedBuilder: (q) => FakeAlertFeed(q))); + await entrar(tester); + await tester.pumpAndSettle(); + + await tester.enterText(find.byKey(const Key('totp_field')), '12ab34567'); + + final campo = tester.widget(find.byKey(const Key('totp_field'))); + expect(campo.controller!.text, '123456', reason: 'letras caem fora e o 7º dígito não entra'); + expect(campo.keyboardType, TextInputType.number); + }); + + testWidgets('falha ao iniciar a ativação mostra o erro e deixa tentar de novo', (tester) async { + final backend = FakeAcsBackend() + ..mfaEnrollmentRequired = true + ..enrollmentFailuresLeft = 1; + await tester.pumpWidget(SinalAcsApp(backend: backend, feedBuilder: (q) => FakeAlertFeed(q))); + await entrar(tester); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('mfa_error')), findsOneWidget); + expect(find.byKey(const Key('mfa_secret')), findsNothing); + + await tester.tap(find.byKey(const Key('mfa_retry_button'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('mfa_secret')), findsOneWidget); + expect(find.byKey(const Key('mfa_error')), findsNothing); + }); +} diff --git a/apps/acs/test/micro_area_cache_store_test.dart b/apps/acs/test/micro_area_cache_store_test.dart new file mode 100644 index 00000000..8734e0b3 --- /dev/null +++ b/apps/acs/test/micro_area_cache_store_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/core/database/encrypted_database.dart'; +import 'package:sinalacs_acs/core/database/micro_area_cache_store.dart'; +import 'package:sinalacs_acs/core/security/database_key_store.dart'; +import 'package:sinalacs_client/sinalacs_client.dart' show MicroAreaPatient; + +import 'support/fakes.dart'; + +/// Roda na VM, sem SQLCipher (`allowUnencryptedForTesting`): prova a lógica de +/// persistência e de dono, não a criptografia (isso vive em `integration_test/`). +MicroAreaPatient _paciente(int n, {bool cronico = false}) => MicroAreaPatient( + patientId: syntheticPatientId(n), + name: 'Paciente Sintético $n', + isChronic: cronico, + chronicConditions: cronico ? ['hipertensão', 'diabetes'] : const [], + ); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const nome = 'micro_area_cache_test.db'; + late MicroAreaCacheStore store; + + setUp(() async { + await EncryptedLocalDatabase.deleteDatabaseFile(nome); + store = MicroAreaCacheStore( + keyStore: InMemoryDatabaseKeyStore(), + databaseName: nome, + allowUnencryptedForTesting: true, + ); + }); + tearDown(() => EncryptedLocalDatabase.deleteDatabaseFile(nome)); + + test('sem nada gravado, read devolve null', () async { + expect(await store.read(owner: 'u1|m1'), isNull); + }); + + test('grava e lê de volta a lista, a data e as condições crônicas', () async { + final em = DateTime.utc(2026, 10, 2, 8); + await store.write(owner: 'u1|m1', patients: [_paciente(1), _paciente(2, cronico: true)], at: em); + + final lido = await store.read(owner: 'u1|m1'); + + expect(lido!.fetchedAt, em); + expect(lido.patients.map((p) => p.patientId), [syntheticPatientId(1), syntheticPatientId(2)]); + expect(lido.patients.last.isChronic, isTrue); + expect(lido.patients.last.chronicConditions, ['hipertensão', 'diabetes']); + }); + + test('outro dono apaga e não serve', () async { + await store.write(owner: 'u1|m1', patients: [_paciente(1)], at: DateTime.utc(2026, 10, 2)); + + expect(await store.read(owner: 'u2|m1'), isNull, reason: 'outro usuário'); + expect(await store.read(owner: 'u1|m1'), isNull, reason: 'o cache do dono antigo foi APAGADO, não só escondido'); + }); + + test('regravar substitui a lista inteira (paciente que saiu da microárea some)', () async { + await store.write(owner: 'u1|m1', patients: [_paciente(1), _paciente(2)], at: DateTime.utc(2026, 10, 2)); + await store.write(owner: 'u1|m1', patients: [_paciente(2)], at: DateTime.utc(2026, 10, 3)); + + final lido = await store.read(owner: 'u1|m1'); + expect(lido!.patients.map((p) => p.patientId), [syntheticPatientId(2)]); + }); + + test('clear apaga tudo', () async { + await store.write(owner: 'u1|m1', patients: [_paciente(1)], at: DateTime.utc(2026, 10, 2)); + await store.clear(); + expect(await store.read(owner: 'u1|m1'), isNull); + }); +} diff --git a/apps/acs/test/micro_area_cache_ui_test.dart b/apps/acs/test/micro_area_cache_ui_test.dart new file mode 100644 index 00000000..87fc78f8 --- /dev/null +++ b/apps/acs/test/micro_area_cache_ui_test.dart @@ -0,0 +1,81 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/app/app.dart'; +import 'package:sinalacs_acs/core/network/backend_client.dart'; +import 'package:sinalacs_acs/core/services/micro_area_directory.dart'; +import 'package:sinalacs_acs/core/services/offline_visit_queue.dart'; +import 'package:sinalacs_client/sinalacs_client.dart' show MicroAreaPatient; + +import 'login_flow_test.dart' show entrar; +import 'support/fakes.dart'; +import 'support/memory_micro_area_store.dart'; + +void main() { + guardaDoMain(); + late FakeAcsBackend backend; + + setUp(() { + backend = FakeAcsBackend() + ..patients = [ + MicroAreaPatient( + patientId: syntheticPatientId(5), + name: 'Fulano de Tal', + isChronic: false, + chronicConditions: const [], + ), + ]; + }); + + Future abrirVisita(WidgetTester tester) async { + await tester.pumpWidget(SinalAcsApp( + backend: backend, + feedBuilder: (queue) => FakeAlertFeed(queue), + visitQueue: OfflineVisitQueue(), + microAreaDirectory: MicroAreaDirectory( + fetch: backend.listPatients, + session: () => backend.session, + store: MemoryMicroAreaStore(), + ), + )); + await entrar(tester); + await tester.pumpAndSettle(); + } + + testWidgets('sem rede, a tela de visita serve o cache e avisa de quando é', (tester) async { + await abrirVisita(tester); + // A primeira carga (ao abrir o painel) gravou a lista. + backend.listPatientsFailure = const BackendFailure('Sem conexão.'); + + await tester.tap(find.text('Visita')); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('micro_area_cache_notice')), findsOneWidget); + expect(find.byKey(const Key('patient_picker')), findsOneWidget); + expect(find.text('Fulano de Tal'), findsOneWidget); + expect(find.byKey(const Key('patient_directory_error')), findsNothing); + }); + + testWidgets('recusa do servidor não cai no cache: mostra o erro, sem aviso', (tester) async { + await abrirVisita(tester); + backend.listPatientsFailure = const BackendFailure('Sessão inválida.', isRecoverable: false); + + await tester.tap(find.text('Visita')); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('micro_area_cache_notice')), findsNothing); + expect(find.byKey(const Key('patient_directory_error')), findsOneWidget); + expect(find.text('Sessão inválida.'), findsOneWidget); + }); +} + +// O padrão de `SinalAcsApp` é sem cache (o Keystore não existe no `flutter test`): +// quem liga o cache de produção é o `main.dart`, e esta guarda impede que ele +// perca a ligação sem ninguém notar. +void guardaDoMain() { + test('o main.dart liga o cache da microárea de produção', () { + final fonte = File('lib/main.dart').readAsStringSync(); + expect(fonte, contains('microAreaDirectory: buildMicroAreaDirectory(backend: backend)')); + }); +} diff --git a/apps/acs/test/micro_area_directory_test.dart b/apps/acs/test/micro_area_directory_test.dart new file mode 100644 index 00000000..d4852244 --- /dev/null +++ b/apps/acs/test/micro_area_directory_test.dart @@ -0,0 +1,130 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/core/network/auth_session.dart'; +import 'package:sinalacs_acs/core/network/backend_client.dart'; +import 'package:sinalacs_acs/core/services/micro_area_directory.dart'; +import 'package:sinalacs_client/sinalacs_client.dart' show MicroAreaPatient; + +import 'support/fakes.dart'; +import 'support/memory_micro_area_store.dart'; + +AuthSession _sessao({String userId = 'u1', String? microAreaId = 'm1'}) => AuthSession( + accessToken: 't', + tokenType: 'Bearer', + userId: userId, + role: 'acs', + microAreaId: microAreaId, + expiresAt: DateTime.utc(2030), + ); + +MicroAreaPatient _p(int n) => + MicroAreaPatient(patientId: syntheticPatientId(n), name: 'P$n', isChronic: false, chronicConditions: const []); + +void main() { + late MemoryMicroAreaStore store; + late DateTime agora; + late AuthSession? sessao; + late Future> Function() busca; + + MicroAreaDirectory diretorio() => MicroAreaDirectory( + fetch: () => busca(), + session: () => sessao, + store: store, + clock: () => agora, + ); + + setUp(() { + store = MemoryMicroAreaStore(); + agora = DateTime.utc(2026, 10, 2, 8); + sessao = _sessao(); + busca = () async => [_p(1), _p(2)]; + }); + + test('com rede: devolve a lista fresca e grava', () async { + final r = await diretorio().load(); + + expect(r.fromCache, isFalse); + expect(r.patients, hasLength(2)); + expect(store.patients, hasLength(2)); + expect(store.owner, 'u1|m1'); + }); + + test('sem rede, com cache do mesmo dono: serve o cache e diz a data', () async { + await diretorio().load(); + agora = agora.add(const Duration(hours: 5)); + busca = () async => throw const BackendFailure('Sem conexão.'); + + final r = await diretorio().load(); + + expect(r.fromCache, isTrue); + expect(r.patients, hasLength(2)); + expect(r.fetchedAt, DateTime.utc(2026, 10, 2, 8)); + }); + + test('sem rede e sem cache: a falha original sobe', () async { + busca = () async => throw const BackendFailure('Sem conexão.'); + await expectLater(diretorio().load(), throwsA(isA())); + }); + + test('recusa do servidor NUNCA cai no cache', () async { + await diretorio().load(); + busca = () async => throw const BackendFailure('Sessão inválida.', isRecoverable: false); + + await expectLater(diretorio().load(), throwsA(isA())); + }); + + test('outro dono apaga e não serve', () async { + await diretorio().load(); + sessao = _sessao(userId: 'u2'); + busca = () async => throw const BackendFailure('Sem conexão.'); + + await expectLater(diretorio().load(), throwsA(isA())); + expect(store.owner, isNull, reason: 'o cache do outro usuário foi apagado'); + }); + + test('outra microárea apaga e não serve', () async { + await diretorio().load(); + sessao = _sessao(microAreaId: 'm2'); + busca = () async => throw const BackendFailure('Sem conexão.'); + + await expectLater(diretorio().load(), throwsA(isA())); + expect(store.owner, isNull); + }); + + test('vencido não serve e é apagado', () async { + await diretorio().load(); + agora = agora.add(const Duration(hours: 73)); + busca = () async => throw const BackendFailure('Sem conexão.'); + + await expectLater(diretorio().load(), throwsA(isA())); + expect(store.owner, isNull); + }); + + test('exatamente 72 h ainda serve', () async { + await diretorio().load(); + agora = agora.add(const Duration(hours: 72)); + busca = () async => throw const BackendFailure('Sem conexão.'); + + expect((await diretorio().load()).fromCache, isTrue); + }); + + test('falha ao gravar o cache não derruba a lista fresca', () async { + store.falhaAoGravar = true; + final r = await diretorio().load(); + expect(r.patients, hasLength(2)); + expect(r.fromCache, isFalse); + }); + + test('sem sessão não há dono: nada é lido nem gravado', () async { + sessao = null; + final r = await diretorio().load(); + expect(r.fromCache, isFalse); + expect(store.owner, isNull); + }); + + test('lista vazia fresca substitui o cache (a microárea esvaziou)', () async { + await diretorio().load(); + busca = () async => []; + await diretorio().load(); + expect(store.patients, isEmpty); + }); +} diff --git a/apps/acs/test/mqtt_secure_client_test.dart b/apps/acs/test/mqtt_secure_client_test.dart index e18fd87b..87519a5e 100644 --- a/apps/acs/test/mqtt_secure_client_test.dart +++ b/apps/acs/test/mqtt_secure_client_test.dart @@ -1,3 +1,6 @@ +import 'dart:io'; +import 'dart:typed_data'; + import 'package:flutter_test/flutter_test.dart'; import 'package:mqtt_client/mqtt_client.dart'; import 'package:sinalacs_acs/core/services/mqtt_secure_client.dart'; @@ -159,4 +162,77 @@ void main() { expect(mqttRefusalIsTransient(null), isFalse); }); }); + + group('mTLS: certificado de cliente', () { + test('a config guarda certificado e chave de cliente quando informados', () { + final cert = Uint8List.fromList([1, 2, 3]); + final chave = Uint8List.fromList([4, 5, 6]); + final config = SecureMqttConfig( + brokerHost: 'localhost', + port: 8883, + clientId: 'x', + topic: 't', + clientCertificate: cert, + clientPrivateKey: chave, + ); + expect(config.clientCertificate, cert); + expect(config.clientPrivateKey, chave); + }); + + test('sem certificado de cliente os dois campos ficam nulos', () { + const config = SecureMqttConfig( + brokerHost: 'localhost', + port: 8883, + clientId: 'x', + topic: 't', + ); + expect(config.clientCertificate, isNull); + expect(config.clientPrivateKey, isNull); + }); + + final temOpenssl = () { + try { + return Process.runSync('openssl', ['version']).exitCode == 0; + } on ProcessException { + return false; + } + }(); + + test('buildMqttSecurityContext monta com um par PEM válido', () { + final dir = Directory.systemTemp.createTempSync('mqtt_ctx_'); + addTearDown(() => dir.deleteSync(recursive: true)); + final r = Process.runSync('openssl', [ + 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '1', + '-subj', '/CN=teste', '-keyout', '${dir.path}/k.pem', + '-out', '${dir.path}/c.pem', + ]); + expect(r.exitCode, 0); + final cert = File('${dir.path}/c.pem').readAsBytesSync(); + final chave = File('${dir.path}/k.pem').readAsBytesSync(); + final config = SecureMqttConfig( + brokerHost: 'localhost', + port: 8883, + clientId: 'x', + topic: 't', + caCertificate: cert, + clientCertificate: cert, + clientPrivateKey: chave, + ); + expect(() => buildMqttSecurityContext(config), returnsNormally); + }, skip: temOpenssl ? false : 'openssl ausente'); + + test('buildMqttSecurityContext recusa bytes que não são PEM', () { + final lixo = Uint8List.fromList(List.filled(32, 7)); + final config = SecureMqttConfig( + brokerHost: 'localhost', + port: 8883, + clientId: 'x', + topic: 't', + caCertificate: lixo, + clientCertificate: lixo, + clientPrivateKey: lixo, + ); + expect(() => buildMqttSecurityContext(config), throwsA(isA())); + }); + }); } diff --git a/apps/acs/test/no_fixed_white_text_test.dart b/apps/acs/test/no_fixed_white_text_test.dart new file mode 100644 index 00000000..7dfc6019 --- /dev/null +++ b/apps/acs/test/no_fixed_white_text_test.dart @@ -0,0 +1,22 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +/// Guarda contra texto/borda em branco translúcido fixo (`Colors.white24/54/70`): +/// foi escrito para o tema escuro e some sobre o fundo claro (medido no emulador, +/// 2026-10-01). Use `Theme.of(context).colorScheme.onSurfaceVariant` (texto) ou +/// `outlineVariant` (borda), que seguem o tema ativo. +void main() { + test('lib/ não usa Colors.white24/54/70 fixos', () { + final padrao = RegExp(r'Colors\.white(24|54|70)\b'); + final achados = []; + for (final arquivo in Directory('lib').listSync(recursive: true).whereType()) { + if (!arquivo.path.endsWith('.dart')) continue; + final linhas = arquivo.readAsLinesSync(); + for (var i = 0; i < linhas.length; i++) { + if (padrao.hasMatch(linhas[i])) achados.add('${arquivo.path}:${i + 1}'); + } + } + expect(achados, isEmpty, reason: 'cores brancas fixas somem no tema claro'); + }); +} diff --git a/apps/acs/test/notices_screen_test.dart b/apps/acs/test/notices_screen_test.dart new file mode 100644 index 00000000..36535f51 --- /dev/null +++ b/apps/acs/test/notices_screen_test.dart @@ -0,0 +1,114 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/app/app.dart'; +import 'package:sinalacs_acs/core/network/backend_client.dart'; + +import 'support/fakes.dart'; +import 'support/semantics_scan.dart'; + +/// Mesmo caminho de `entrar` em `login_flow_test.dart` (não importável entre +/// arquivos de teste). Credenciais sintéticas. +Future entrar(WidgetTester tester) async { + await tester.enterText(find.byKey(const Key('matricula_field')), 'ACS-001'); + await tester.enterText(find.byKey(const Key('senha_field')), 'senha-sintetica'); + await tester.tap(find.byKey(const Key('login_button'))); + await tester.pumpAndSettle(); +} + +Future abrirAvisos(WidgetTester tester, FakeAcsBackend backend) async { + tester.view.physicalSize = const Size(800, 2000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget( + SinalAcsApp(backend: backend, feedBuilder: (queue) => FakeAlertFeed(queue)), + ); + await entrar(tester); + await tester.tap(find.text('Mais')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Avisos à comunidade')); + await tester.pumpAndSettle(); +} + +Future preencher(WidgetTester tester, {String titulo = 'Vacinação', String mensagem = 'Amanhã, das 8h às 12h.'}) async { + await tester.enterText(find.byKey(const Key('notice_title_field')), titulo); + await tester.enterText(find.byKey(const Key('notice_message_field')), mensagem); + await tester.pump(); +} + +void main() { + testWidgets('enviar chama o backend com título, mensagem e público e mostra o resultado', (tester) async { + final backend = FakeAcsBackend(); + await abrirAvisos(tester, backend); + await preencher(tester); + await tester.tap(find.byKey(const Key('notice_chronic_switch'))); + await tester.pump(); + await tester.tap(find.byKey(const Key('notice_send_button'))); + await tester.pumpAndSettle(); + + expect(backend.notices, [('Vacinação', 'Amanhã, das 8h às 12h.', true)]); + expect(find.textContaining('2 de 3'), findsOneWidget); + }); + + testWidgets('botão desabilitado com título ou mensagem vazios', (tester) async { + await abrirAvisos(tester, FakeAcsBackend()); + FilledButton botao() => + tester.widget(find.byKey(const Key('notice_send_button'))); + expect(botao().onPressed, isNull); + + await tester.enterText(find.byKey(const Key('notice_title_field')), 'Só título'); + await tester.pump(); + expect(botao().onPressed, isNull); + + await tester.enterText(find.byKey(const Key('notice_message_field')), ' '); + await tester.pump(); + expect(botao().onPressed, isNull); + }); + + testWidgets('falha do envio mostra o erro do servidor e mantém o texto digitado', (tester) async { + final backend = FakeAcsBackend() + ..noticeFailure = const BackendFailure('Não foi possível entregar o aviso agora.'); + await abrirAvisos(tester, backend); + await preencher(tester, titulo: 'T', mensagem: 'M'); + await tester.tap(find.byKey(const Key('notice_send_button'))); + await tester.pumpAndSettle(); + + expect(find.textContaining('Não foi possível entregar'), findsOneWidget); + expect( + tester.widget(find.byKey(const Key('notice_message_field'))).controller!.text, + 'M', + ); + expect(find.textContaining('2 de 3'), findsNothing); + }); + + testWidgets('o aviso pede para não escrever dado de saúde e mostra os limites', (tester) async { + await abrirAvisos(tester, FakeAcsBackend()); + expect(find.textContaining('Não escreva nome nem condição de saúde'), findsOneWidget); + expect(tester.widget(find.byKey(const Key('notice_title_field'))).maxLength, 60); + expect(tester.widget(find.byKey(const Key('notice_message_field'))).maxLength, 240); + }); + + testWidgets('toque duplo no envio manda um aviso só', (tester) async { + final gate = Completer(); + final backend = FakeAcsBackend()..noticeGate = gate; + await abrirAvisos(tester, backend); + await preencher(tester); + await tester.tap(find.byKey(const Key('notice_send_button'))); + await tester.pump(); + await tester.tap(find.byKey(const Key('notice_send_button')), warnIfMissed: false); + await tester.pump(); + expect(backend.notices, hasLength(1)); + + gate.complete(); + await tester.pumpAndSettle(); + expect(find.textContaining('2 de 3'), findsOneWidget); + }); + + testWidgets('a tela de avisos não cria nó de botão inerte', (tester) async { + final handle = tester.ensureSemantics(); + await abrirAvisos(tester, FakeAcsBackend()); + expectNenhumBotaoInerte(tester); + handle.dispose(); + }); +} diff --git a/apps/acs/test/offline_burst_test.dart b/apps/acs/test/offline_burst_test.dart new file mode 100644 index 00000000..02a855df --- /dev/null +++ b/apps/acs/test/offline_burst_test.dart @@ -0,0 +1,87 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/core/services/offline_visit_queue.dart'; + +import 'support/fakes.dart'; + +/// M2.4 / RNF02: "100 registros offline sincronizam em < 5s após rede" e +/// sincronização > 99,5%. Aqui a parte determinística: nada se perde, nada +/// duplica, e a fila sobrevive a um reinício. O tempo contra o servidor de +/// verdade é medido no dispositivo (`integration_test/full_journey_e2e.dart`). +void main() { + OfflineVisitRecord visita(int n) => OfflineVisitRecord( + patientId: syntheticPatientId(n), + risk: 'yellow', + status: 'PENDENTE', + localId: 'burst-$n', + createdAt: DateTime.utc(2026, 10, 2, 8).add(Duration(minutes: n)), + ); + + test('100 visitas enfileiradas, reiniciadas e sincronizadas: nenhuma se perde', () async { + final disco = InMemoryVisitStore(); + final antes = OfflineVisitQueue(store: disco); + for (var n = 0; n < 100; n++) { + await antes.add(visita(n)); + } + expect(antes.pendingCount, 100); + + // Reinício do app: outra fila sobre o mesmo "disco". + final envio = FakeVisitSynchronizer(); + final depois = OfflineVisitQueue(store: disco, synchronizer: envio); + await depois.restore(); + expect(depois.pendingCount, 100, reason: 'o que estava no disco volta inteiro'); + + final resultado = await depois.sync(); + + expect(resultado.kind, SyncOutcomeKind.synced); + expect(depois.pendingCount, 0); + expect(depois.syncedCount, 100); + expect(envio.batches, hasLength(1), reason: 'um lote só: uma ida e volta ao servidor'); + final enviados = envio.batches.single.map((v) => v.localId).toSet(); + expect(enviados, hasLength(100), reason: 'nenhum localId repetido (dedupe do servidor)'); + }); + + test('uma recusada e um conflito no meio não seguram as demais', () async { + final envio = FakeVisitSynchronizer( + statusFor: (v) => switch (v.localId) { + 'burst-40' => 'rejected', + 'burst-70' => 'conflict', + _ => 'synced', + }, + messageFor: (v) => v.localId == 'burst-40' ? 'paciente de outra microárea' : null, + ); + final fila = OfflineVisitQueue(synchronizer: envio); + for (var n = 0; n < 100; n++) { + await fila.add(visita(n)); + } + + await fila.sync(); + + expect(fila.syncedCount, 98); + expect(fila.rejectedCount, 1, reason: 'a recusada fica visível no aparelho até o ACS descartar'); + expect(fila.rejectedVisits.single.localId, 'burst-40'); + expect(fila.rejectedVisits.single.rejectionReason, 'paciente de outra microárea'); + expect(fila.conflictCount, 1, reason: 'o conflito fica registrado'); + // Conflito VOLTA para a fila (`_applyOutcomes`: "precisa de resolução, não de + // descarte"); a recusada, não — ela sai da retentativa e fica só visível. + expect(fila.pendingCount, 1); + expect(fila.pendingVisits.single.localId, 'burst-70'); + expect(fila.pendingVisits.single.status, 'CONFLITO'); + }); + + test('sem rede, as 100 continuam pendentes e a próxima tentativa as leva', () async { + final envio = FakeVisitSynchronizer(throwOnPush: true); + final fila = OfflineVisitQueue(synchronizer: envio); + for (var n = 0; n < 100; n++) { + await fila.add(visita(n)); + } + + final falha = await fila.sync(); + expect(falha.kind, SyncOutcomeKind.error); + expect(fila.pendingCount, 100, reason: 'falha de rede não perde o lote'); + + envio.throwOnPush = false; + await fila.sync(); + expect(fila.pendingCount, 0); + expect(fila.syncedCount, 100); + }); +} diff --git a/apps/acs/test/offline_visit_queue_concurrency_test.dart b/apps/acs/test/offline_visit_queue_concurrency_test.dart new file mode 100644 index 00000000..162d1329 --- /dev/null +++ b/apps/acs/test/offline_visit_queue_concurrency_test.dart @@ -0,0 +1,95 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/core/services/offline_visit_queue.dart'; + +import 'support/fakes.dart' show seedPatientId; + +/// Visita registrada enquanto um `sync()` está em voo, e `sync()` concorrentes. +/// Ids e paciente sintéticos. +void main() { + OfflineVisitRecord visita(String localId) => + OfflineVisitRecord(localId: localId, patientId: seedPatientId, risk: 'red', status: 'PENDENTE'); + + for (final (nome, criarStore) in <(String, VisitStore Function())>[ + ('InMemoryVisitStore', InMemoryVisitStore.new), + ('visão de um dono (InMemoryVisitStorage)', () => InMemoryVisitStorage().forOwner('acs-a')), + ('store com E/S assíncrona', _StoreLento.new), + ]) { + test('visita adicionada DURANTE o envio continua pendente e no disco ($nome)', () async { + final store = criarStore(); + final envio = _SincronizadorPreso(); + final fila = OfflineVisitQueue(store: store, synchronizer: envio); + await fila.restore(); + await fila.add(visita('x')); + + final emVoo = fila.sync(); + await envio.chegou.future; + expect(fila.isSyncing, isTrue); + await fila.add(visita('y')); // registrada em campo com o lote no servidor + envio.solta.complete(); + await emVoo; + + expect(fila.isSyncing, isFalse); + expect(fila.pendingVisits.map((v) => v.localId), ['y'], reason: 'x subiu; y não pode sumir da memória'); + expect((await store.load()).map((v) => v.localId), ['y'], reason: 'nem do disco'); + + // E a próxima sincronização a envia. + final segunda = _SincronizadorPreso()..solta.complete(); + final outra = OfflineVisitQueue(store: store, synchronizer: segunda); + await outra.restore(); + await outra.sync(); + expect(segunda.lotes.single.map((v) => v.localId), ['y']); + }); + } + + test('dois sync() concorrentes enviam o lote UMA vez e devolvem o mesmo resultado', () async { + final envio = _SincronizadorPreso(); + final fila = OfflineVisitQueue(store: InMemoryVisitStore(), synchronizer: envio); + await fila.add(visita('x')); + + final primeiro = fila.sync(); + final segundo = fila.sync(); + await envio.chegou.future; + envio.solta.complete(); + final resultados = await Future.wait([primeiro, segundo]); + + expect(envio.lotes, hasLength(1)); + expect(resultados.map((r) => r.kind), everyElement(SyncOutcomeKind.synced)); + expect(fila.pendingCount, 0); + expect(fila.syncedCount, 1, reason: 'sem contar duas vezes'); + }); +} + +/// Sincronizador que avisa quando o lote chegou e só responde quando liberado. +class _SincronizadorPreso implements VisitSynchronizer { + final Completer chegou = Completer(); + final Completer solta = Completer(); + final List> lotes = >[]; + + @override + Future> push(List visits) async { + lotes.add(List.of(visits)); + if (!chegou.isCompleted) chegou.complete(); + await solta.future; + return [for (final v in visits) VisitSyncOutcome(localId: v.localId, status: 'synced', serverVersion: v.version + 1)]; + } +} + +/// Store cujas leituras e gravações levam um tempo (como o SQLCipher). +class _StoreLento implements VisitStore { + List _linhas = []; + + @override + Future> load() async { + await Future.delayed(const Duration(milliseconds: 2)); + return List.of(_linhas); + } + + @override + Future save(List visits) async { + final copia = List.of(visits); + await Future.delayed(const Duration(milliseconds: 2)); + _linhas = copia; + } +} diff --git a/apps/acs/test/pending_alerts_banner_test.dart b/apps/acs/test/pending_alerts_banner_test.dart new file mode 100644 index 00000000..ea84cb40 --- /dev/null +++ b/apps/acs/test/pending_alerts_banner_test.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/app/acs_theme.dart'; +import 'package:sinalacs_acs/app/pending_alerts_banner.dart'; + +void main() { + Widget host(ValueNotifier n) => MaterialApp( + theme: buildAcsDarkTheme(), + home: Scaffold( + body: PendingAlertsBanner(listenable: n, count: () => n.value), + ), + ); + + testWidgets('sem alerta pendente não mostra nada', (t) async { + await t.pumpWidget(host(ValueNotifier(0))); + expect(find.byKey(const Key('reauth_pending_alerts')), findsNothing); + }); + + testWidgets('mostra a contagem e acompanha as mudanças', (t) async { + final n = ValueNotifier(1); + await t.pumpWidget(host(n)); + expect(find.text('1 alerta vermelho aguardando. Entre para ver.'), findsOneWidget); + + n.value = 3; + await t.pump(); + expect(find.text('3 alertas vermelhos aguardando. Entre para ver.'), findsOneWidget); + + n.value = 0; + await t.pump(); + expect(find.byKey(const Key('reauth_pending_alerts')), findsNothing); + }); + + testWidgets('é região viva (SC 4.1.3)', (t) async { + final h = t.ensureSemantics(); + await t.pumpWidget(host(ValueNotifier(2))); + // O nó de semântica é o do `Semantics` interno, não o do `Material` com a chave. + expect( + t.getSemantics(find.bySemanticsLabel('2 alertas vermelhos aguardando. Entre para ver.')), + matchesSemantics(isLiveRegion: true, label: '2 alertas vermelhos aguardando. Entre para ver.'), + ); + h.dispose(); + }); +} diff --git a/apps/acs/test/refresh_session_test.dart b/apps/acs/test/refresh_session_test.dart new file mode 100644 index 00000000..ec92dd6b --- /dev/null +++ b/apps/acs/test/refresh_session_test.dart @@ -0,0 +1,540 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/core/network/backend_client.dart'; +import 'package:sinalacs_acs/core/security/auth_bound_session_token_store.dart'; +import 'package:sinalacs_acs/core/security/keystore_vault.dart'; +import 'package:sinalacs_acs/core/security/secure_session_token_store.dart'; +import 'package:sinalacs_acs/core/services/backend_visit_synchronizer.dart'; +import 'package:sinalacs_acs/core/services/offline_visit_queue.dart'; + +import 'support/fake_rpc_server.dart'; +import 'support/gated_session_token_store.dart'; + +/// Renovação silenciosa por refresh token rotativo (LGPD-RT06). Credenciais +/// sintéticas, servidor local. +void main() { + late FakeRpcServer server; + late MemorySessionTokenStore store; + late MemoryDeviceIdStore devices; + late BackendClient backend; + + BackendClient novo() => BackendClient(host: server.host, tokenStore: store, deviceIds: devices); + + setUp(() async { + server = await FakeRpcServer.start(); + store = MemorySessionTokenStore(); + devices = MemoryDeviceIdStore(); + backend = novo(); + }); + + tearDown(() async { + backend.close(); + await server.stop(); + }); + + Future entrar() => backend.login(matricula: 'ACS-001', senha: 'senha-sintetica'); + + test('JWT vencido é renovado por refresh, sem reenviar senha nem código', () async { + server.tokenLifetime = const Duration(minutes: -1); + await entrar(); + + await backend.listPatients(); + + expect(server.loginCount, 1); + expect(server.refreshCount, 1); + }); + + test('chamadas simultâneas com JWT vencido compartilham UM refresh (single-flight)', () async { + server.tokenLifetime = const Duration(minutes: -1); + await entrar(); + server.refreshDelay = const Duration(milliseconds: 200); + + await Future.wait([backend.listPatients(), backend.listPatients(), backend.listPatients()]); + + expect(server.refreshCount, 1); + }); + + test('o refresh token rotativo é regravado a cada renovação', () async { + server.tokenLifetime = const Duration(minutes: -1); + await entrar(); + expect(await store.read(), 'refresh-0'); + + await backend.listPatients(); + + expect(await store.read(), 'refresh-1'); + expect(server.requests.where((r) => r.method == 'refreshSession').single.args['refreshToken'], 'refresh-0'); + }); + + test('recusa do refresh (SessionExpiredException) limpa o token e chama onSessionExpired', () async { + server.tokenLifetime = const Duration(minutes: -1); + var avisos = 0; + backend.onSessionExpired = () => avisos++; + await entrar(); + server.rejectRefresh = true; + + await expectLater( + backend.listPatients(), + throwsA(isA() + .having((f) => f.isRecoverable, 'isRecoverable', isFalse) + .having((f) => f.message, 'message', + 'Sua sessão expirou. Entre novamente com o código do autenticador.')), + ); + expect(avisos, 1); + expect(await store.read(), isNull); + }); + + test('falha de REDE no refresh é recuperável e NÃO apaga o token', () async { + server.tokenLifetime = const Duration(minutes: -1); + var avisos = 0; + backend.onSessionExpired = () => avisos++; + await entrar(); + server.failRefreshOnce = true; + + await expectLater( + backend.listPatients(), + throwsA(isA().having((f) => f.isRecoverable, 'isRecoverable', isTrue)), + ); + expect(await store.read(), 'refresh-0'); + expect(avisos, 0); + + await backend.listPatients(); + expect(server.refreshCount, 2); + expect(await store.read(), isNotNull); + }); + + test('resumeSession: sem token salvo devolve null; com token, devolve sessão', () async { + expect(await backend.hasStoredSession, isFalse); + expect(await backend.resumeSession(), isNull); + expect(server.refreshCount, 0); + + await store.write('refresh-0'); + expect(await backend.hasStoredSession, isTrue); + final session = await backend.resumeSession(); + + expect(session, isNotNull); + expect(session!.microAreaId, server.microAreaId); + expect(backend.isAuthenticated, isTrue); + expect(await store.read(), 'refresh-1'); + }); + + test('resumeSession recusado devolve null, apaga o token e NÃO avisa a UI', () async { + var avisos = 0; + backend.onSessionExpired = () => avisos++; + await store.write('refresh-0'); + server.rejectRefresh = true; + + expect(await backend.resumeSession(), isNull); + expect(await store.read(), isNull); + expect(avisos, 0); + }); + + test('logout com o store vinculado apaga o blob e a chave do cofre', () async { + final vault = FakeKeystoreVault(); + final b = BackendClient( + host: server.host, + tokenStore: AuthBoundSessionTokenStore(vault: vault, legacy: MemorySessionTokenStore()), + deviceIds: devices, + ); + addTearDown(b.close); + await b.login(matricula: 'ACS-001', senha: 'senha-sintetica'); + expect(vault.sealed, isNotEmpty); + await b.logout(); + expect(vault.sealed, isEmpty); + expect(vault.deleteCalls, greaterThan(0)); + expect(await b.hasStoredSession, isFalse); + }); + + test('logout revoga no servidor e apaga o token local, mesmo se o servidor estiver fora', () async { + await entrar(); + await backend.logout(); + expect(server.loggedOut, ['refresh-0']); + expect(await store.read(), isNull); + expect(backend.session, isNull); + + await store.write('refresh-9'); + await server.stop(); + await backend.logout(); + expect(await store.read(), isNull); + // tearDown chama stop() de novo: é idempotente. + }); + + test('a senha não é guardada: BackendClient não mantém credencial após o login', () async { + server.tokenLifetime = const Duration(minutes: -1); + await entrar(); + await backend.listPatients(); + await backend.listPatients(); + + expect(server.loginCount, 1); + final comSenha = server.requests.where((r) => r.args.containsKey('password')); + expect(comSenha.map((r) => r.method), ['loginInstitutional']); + expect(server.requests.where((r) => r.method == 'refreshSession').every((r) => !r.args.containsKey('password')), isTrue); + }); + + test('login sem refreshToken na resposta limpa um token antigo', () async { + await store.write('velho'); + server.omitRefreshToken = true; + await entrar(); + expect(await store.read(), isNull); + }); + + test('o deviceId enviado ao login e ao refresh é o mesmo e persiste entre instâncias', () async { + server.tokenLifetime = const Duration(minutes: -1); + await entrar(); + await backend.listPatients(); + + final segunda = novo(); + addTearDown(segunda.close); + expect(await segunda.resumeSession(), isNotNull); + + final ids = server.requests + .where((r) => r.method == 'loginInstitutional' || r.method == 'refreshSession') + .map((r) => r.args['deviceId']) + .toSet(); + expect(ids, hasLength(1)); + expect(ids.single, isNotEmpty); + }); + + test('a segunda renovação envia o filho rotacionado, não o token do login', () async { + server.tokenLifetime = const Duration(minutes: -1); + await entrar(); + await backend.listPatients(); + await backend.listPatients(); + expect(server.refreshTokensSeen, ['refresh-0', 'refresh-1']); + }); + + test('login com deviceId em branco não recebe refreshToken (como o backend real)', () async { + final r = await HttpClientProbe.login(server, deviceId: ' '); + expect(r.containsKey('refreshToken'), isFalse); + }); + + group('falhas do armazenamento seguro', () { + test('login: falha ao gravar vira BackendFailure, não PlatformException', () async { + final b = BackendClient(host: server.host, tokenStore: ThrowingStore(failWrite: true), deviceIds: devices); + addTearDown(b.close); + await expectLater(b.login(matricula: 'ACS-001', senha: 'x'), throwsA(isA())); + }); + + test('renovação: falha ao gravar o filho => sessão perdida, avisa a UI, não é "sem conexão"', () async { + server.tokenLifetime = const Duration(minutes: -1); + final inner = ThrowingStore(); + final b = BackendClient(host: server.host, tokenStore: inner, deviceIds: devices); + addTearDown(b.close); + var avisos = 0; + b.onSessionExpired = () => avisos++; + await b.login(matricula: 'ACS-001', senha: 'x'); + inner.failWrite = true; + await expectLater( + b.listPatients(), + throwsA(isA() + .having((f) => f.isRecoverable, 'isRecoverable', isFalse) + .having((f) => f.message, 'message', contains('guardar a sessão'))), + ); + expect(avisos, 1); + expect(b.session, isNull); + }); + + test('renovação: falha ao ler o deviceId não vira "Sem conexão"', () async { + server.tokenLifetime = const Duration(minutes: -1); + final b = BackendClient(host: server.host, tokenStore: store, deviceIds: ThrowingDevices()); + addTearDown(b.close); + await store.write('refresh-0'); + await expectLater( + b.listPatients(), + throwsA(isA().having((f) => f.message, 'message', isNot(contains('Sem conexão')))), + ); + }); + + test('resumeSession com leitura falhando devolve null', () async { + final b = BackendClient(host: server.host, tokenStore: ThrowingStore(failRead: true), deviceIds: devices); + addTearDown(b.close); + expect(await b.resumeSession(), isNull); + expect(await b.hasStoredSession, isFalse); + }); + + test('logout com clear falhando lança BackendFailure, mas sessão em memória cai', () async { + final b = BackendClient(host: server.host, tokenStore: ThrowingStore(failClear: true), deviceIds: devices); + addTearDown(b.close); + await b.login(matricula: 'ACS-001', senha: 'x'); + await expectLater(b.logout(), throwsA(isA())); + expect(b.session, isNull); + }); + }); + + test('logout com renovação em voo: nada é regravado nem ressuscitado', () async { + server.tokenLifetime = const Duration(minutes: -1); + var avisos = 0; + backend.onSessionExpired = () => avisos++; + await entrar(); + server.refreshDelay = const Duration(milliseconds: 200); + + final renovacao = backend.listPatients().then((_) => null, onError: (Object e) => e); + await Future.delayed(const Duration(milliseconds: 50)); + await backend.logout(); + await renovacao; + + expect(await store.read(), isNull); + expect(backend.session, isNull); + expect(avisos, 0, reason: 'renovação obsoleta não manda a UI para o login'); + }); + + test('renovação em voo + login de OUTRO usuário: vale o login, não a renovação (INV-01)', () async { + server.tokenLifetime = const Duration(minutes: -1); + var avisos = 0; + backend.onSessionExpired = () => avisos++; + await entrar(); // ACS A, refresh-0 + server.refreshDelay = const Duration(milliseconds: 200); + + final renovacao = backend.listPatients().then((_) => null, onError: (Object e) => e); + await Future.delayed(const Duration(milliseconds: 50)); + server + ..userId = '00000000-0000-4000-8000-0000000000bb' + ..tokenLifetime = const Duration(minutes: 15); + final sessaoB = await backend.login(matricula: 'ACS-002', senha: 'senha-sintetica-b'); + final tokenB = server.loginTokens.last; + expect(tokenB, isNot('refresh-0'), reason: 'o login de B emite um token próprio'); + expect(await store.read(), tokenB); + final resultado = await renovacao; + + expect(resultado, isA(), reason: 'a renovação obsoleta não entrega sessão a ninguém'); + expect(identical(backend.session, sessaoB), isTrue, reason: 'o painel de B segue com o JWT de B'); + expect(await store.read(), tokenB, reason: 'o filho da renovação de A não sobrescreve o token de B'); + expect(avisos, 0); + }); + + test('renovação iniciada durante a chamada de logout não regrava o token', () async { + server.tokenLifetime = const Duration(minutes: -1); + var avisos = 0; + backend.onSessionExpired = () => avisos++; + await entrar(); + server + ..logoutDelay = const Duration(milliseconds: 100) + ..refreshDelay = const Duration(milliseconds: 300); + + final saida = backend.logout(); + await Future.delayed(const Duration(milliseconds: 50)); + final renovacao = backend.listPatients().then((_) => null, onError: (Object e) => e); + await saida; + await renovacao; + + expect(await store.read(), isNull); + expect(backend.session, isNull); + expect(avisos, 0); + }); + + test('refresh token novo vazio é tratado como ausente (e o JWT ilegível ainda persiste o filho)', () async { + server.tokenLifetime = const Duration(minutes: -1); + await entrar(); + server.emptyRefreshToken = true; + await expectLater(backend.listPatients(), throwsA(isA())); + expect(await store.read(), 'refresh-0', reason: 'vazio não sobrescreve'); + }); + + test('JWT ilegível não perde o filho rotacionado', () async { + server.tokenLifetime = const Duration(minutes: -1); + await entrar(); + server.garbageAccessToken = true; + await expectLater(backend.listPatients(), throwsA(isA())); + expect(await store.read(), 'refresh-1'); + }); + + group('corrida do login (janela da gravação)', () { + /// Espera, em tempo real, até [condicao] valer: a requisição passa por um + /// socket de verdade, então `pumpEventQueue` sozinho não basta. + Future esperarAte(bool Function() condicao, {String? motivo}) async { + final limite = DateTime.now().add(const Duration(seconds: 5)); + while (!condicao()) { + if (DateTime.now().isAfter(limite)) fail(motivo ?? 'condição não chegou a valer'); + await Future.delayed(const Duration(milliseconds: 5)); + } + } + + test('renovação iniciada com o login parado na gravação ESPERA e usa o token NOVO', () async { + final gated = GatedSessionTokenStore(); + final b = BackendClient(host: server.host, tokenStore: gated, deviceIds: MemoryDeviceIdStore('aparelho-1')); + addTearDown(b.close); + await b.login(matricula: 'ACS-A', senha: 'senha-sintetica'); // token do login #0, gravado + server.tokenLifetime = const Duration(minutes: 15); + + gated.writeGate = Completer(); + final login = b.login(matricula: 'ACS-B', senha: 'senha-sintetica'); // para na escrita + await esperarAte(() => gated.writes == 2, motivo: 'o login B não chegou à escrita'); + await pumpEventQueue(); + expect(gated.writes, 2, reason: 'o login B chegou à escrita e está parado nela'); + + final renovacao = b.renewSession(); // começa DENTRO da janela + await pumpEventQueue(); + // Tempo real para uma requisição indevida chegar ao servidor. + await Future.delayed(const Duration(milliseconds: 100)); + expect(server.refreshTokensSeen, isEmpty, reason: 'a renovação não pode ler o token antes de o login confirmar'); + + gated.writeGate!.complete(); + await login; + await renovacao; + + // Leu o token do login de B, não o de A: + expect(server.refreshTokensSeen, [server.loginTokens.last]); + expect(await gated.read(), isNot(server.loginTokens.first)); + expect(b.session!.userId, isNotNull); + }); + + test('renovação que começou ANTES do login e termina DEPOIS da gravação continua obsoleta', () async { + final gated = GatedSessionTokenStore(); + final b = BackendClient(host: server.host, tokenStore: gated, deviceIds: MemoryDeviceIdStore('aparelho-1')); + addTearDown(b.close); + server.tokenLifetime = const Duration(minutes: -1); + var avisos = 0; + b.onSessionExpired = () => avisos++; + await b.login(matricula: 'ACS-A', senha: 'senha-sintetica'); + server.refreshDelay = const Duration(milliseconds: 300); + + final renovacao = b.listPatients().then((_) => null, onError: (Object e) => e); + await esperarAte(() => server.refreshCount == 1, motivo: 'a renovação de A não saiu'); + server + ..userId = '00000000-0000-4000-8000-0000000000bb' + ..tokenLifetime = const Duration(minutes: 15); + final sessaoB = await b.login(matricula: 'ACS-B', senha: 'senha-sintetica-b'); + final resultado = await renovacao; + + expect(resultado, isA().having((f) => f.isRecoverable, 'isRecoverable', isTrue)); + expect(identical(b.session, sessaoB), isTrue, reason: 'a sessão é a de B'); + expect(await gated.read(), server.loginTokens.last, reason: 'o token é o do login de B'); + expect(server.refreshTokensSeen, [server.loginTokens.first]); + expect(avisos, 0); + }); + + test('sync de A com JWT vencido e login de B confirmando no meio: o lote de A NÃO sai com o token de B', + () async { + const acsB = '00000000-0000-4000-8000-0000000000bb'; + final gated = GatedSessionTokenStore(); + final b = BackendClient(host: server.host, tokenStore: gated, deviceIds: MemoryDeviceIdStore('aparelho-1')); + addTearDown(b.close); + server.tokenLifetime = const Duration(minutes: -1); + final sessaoA = await b.login(matricula: 'ACS-A', senha: 'senha-sintetica'); // JWT de A já vencido + server + ..userId = acsB + ..tokenLifetime = const Duration(minutes: 15); + + // Login de B parado na gravação: a sessão exposta ainda é a de A. + gated.writeGate = Completer(); + final login = b.login(matricula: 'ACS-B', senha: 'senha-sintetica-b'); + await esperarAte(() => gated.writes == 2, motivo: 'o login B não chegou à escrita'); + expect(b.session!.userId, sessaoA.userId); + + // A fila de A sincroniza: a guarda síncrona passa (sessão de A), a + // renovação espera a seção do login de B e leria o token DE B. + final fila = OfflineVisitQueue( + synchronizer: BackendVisitSynchronizer(backend: b, ownerId: sessaoA.userId), + ); + await fila.add(OfflineVisitRecord( + localId: 'local-a1', + patientId: '00000000-0000-4000-8000-0000000000a1', + risk: 'red', + status: 'PENDENTE', + )); + final envio = fila.sync(); + await pumpEventQueue(); + + gated.writeGate!.complete(); + await login; + final resultado = await envio; + + expect(b.session!.userId, acsB); + expect(resultado.kind, SyncOutcomeKind.error); + expect(fila.pendingCount, 1, reason: 'o lote de A continua pendente'); + expect(server.requests.where((r) => r.method == 'sync'), isEmpty, + reason: 'nenhum visits.sync saiu com o token de B'); + }); + + test('logout: renovação iniciada durante a rede do logout não ressuscita a sessão', () async { + final gated = GatedSessionTokenStore(); + final b = BackendClient(host: server.host, tokenStore: gated, deviceIds: MemoryDeviceIdStore('aparelho-1')); + addTearDown(b.close); + server.tokenLifetime = const Duration(minutes: -1); + var avisos = 0; + b.onSessionExpired = () => avisos++; + await b.login(matricula: 'ACS-A', senha: 'senha-sintetica'); + server + ..logoutDelay = const Duration(milliseconds: 150) + ..refreshDelay = const Duration(milliseconds: 300); + + final saida = b.logout(); + await esperarAte(() => server.loggedOut.isNotEmpty, motivo: 'o logout não chegou ao servidor'); + final renovacao = b.listPatients().then((_) => null, onError: (Object e) => e); + await saida; + final resultado = await renovacao; + + expect(resultado, isA()); + expect(await gated.read(), isNull); + expect(b.session, isNull); + expect(avisos, 0); + }); + }); + + test('SecureStorageDeviceIdStore: chamadas concorrentes na 1ª vez devolvem o mesmo id', () async { + final storage = SlowStorage(); + final ids = SecureStorageDeviceIdStore(storage: storage); + final r = await Future.wait([ids.readOrCreate(), ids.readOrCreate(), ids.readOrCreate()]); + expect(r.toSet(), hasLength(1)); + expect(storage.writes, 1); + }); +} + +class HttpClientProbe { + static Future> login(FakeRpcServer s, {required String deviceId}) async { + final c = HttpClient(); + try { + final req = await c.postUrl(Uri.parse('${s.host}auth')); + req.headers.contentType = ContentType.json; + req.write(jsonEncode({'method': 'loginInstitutional', 'matricula': 'a', 'password': 'b', 'deviceId': deviceId})); + final res = await req.close(); + return jsonDecode(await utf8.decoder.bind(res).join()) as Map; + } finally { + c.close(force: true); + } + } +} + +class ThrowingStore implements SessionTokenStore { + ThrowingStore({this.failRead = false, this.failWrite = false, this.failClear = false}); + bool failRead, failWrite, failClear; + String? _t; + @override + Future read() async => failRead ? throw PlatformException(code: 'x') : _t; + @override + Future write(String token) async => failWrite ? throw PlatformException(code: 'x') : _t = token; + @override + Future clear() async => failClear ? throw PlatformException(code: 'x') : _t = null; + @override + Future contains() async => await read() != null; + @override + Future unlock({required String reason}) async => SessionUnlock.notRequired; +} + +class ThrowingDevices implements DeviceIdStore { + @override + Future readOrCreate() async => throw PlatformException(code: 'x'); +} + +class SlowStorage implements FlutterSecureStorage { + final Map data = {}; + int writes = 0; + @override + Future read({required String key, IOSOptions? iOptions, AndroidOptions? aOptions, LinuxOptions? lOptions, WebOptions? webOptions, MacOsOptions? mOptions, WindowsOptions? wOptions}) async { + await Future.delayed(const Duration(milliseconds: 20)); + return data[key]; + } + + @override + Future write({required String key, required String? value, IOSOptions? iOptions, AndroidOptions? aOptions, LinuxOptions? lOptions, WebOptions? webOptions, MacOsOptions? mOptions, WindowsOptions? wOptions}) async { + writes++; + data[key] = value!; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/apps/acs/test/secure_window_test.dart b/apps/acs/test/secure_window_test.dart new file mode 100644 index 00000000..2175bd5e --- /dev/null +++ b/apps/acs/test/secure_window_test.dart @@ -0,0 +1,61 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +/// A lista da microárea mostra nome e condições crônicas (LGPD §5.6). Sem +/// `FLAG_SECURE`, o sistema deixa a tela ser capturada, gravada e reduzida a +/// miniatura nos "apps recentes" — dado de saúde fora do controle do app. +/// Este teste lê a `MainActivity` porque `flutter test` não tem janela +/// Android; a prova na janela de verdade é `scripts/qa/acs_secure_window.sh`. +void main() { + final fonte = File( + 'android/app/src/main/kotlin/br/com/prismrr/sinalacs/acs/MainActivity.kt', + ).readAsStringSync(); + + final gradle = File('android/app/build.gradle.kts').readAsStringSync(); + + test('FLAG_SECURE só é dispensada pela constante de compilação ALLOW_SCREEN_CAPTURE', () { + final inicio = fonte.indexOf('override fun onCreate'); + final fim = fonte.indexOf('override fun configureFlutterEngine'); + final corpo = fonte.substring(inicio, fim); + // A aplicação da flag está condicionada à constante, e a constante não é + // lida de nenhum outro lugar (nada de SharedPreferences, intent, menu). + expect(corpo, matches(RegExp(r'if\s*\(\s*!BuildConfig\.ALLOW_SCREEN_CAPTURE\s*\)'))); + expect(fonte, isNot(contains('getSharedPreferences'))); + expect(fonte, isNot(contains('getIntent'))); + expect(fonte, isNot(contains('getBooleanExtra'))); + }); + + test('o Gradle liga ALLOW_SCREEN_CAPTURE só no debug e barra a propriedade em release', () { + expect(gradle, contains('buildConfig = true')); + // Padrão false para todo build. + expect(gradle, matches(RegExp(r'defaultConfig\s*\{[\s\S]*?ALLOW_SCREEN_CAPTURE",\s*"false"'))); + // Só o buildType debug lê a propriedade. + expect(gradle, matches(RegExp(r'debug\s*\{[\s\S]*?licenca\("sinalacs\.allowScreenCapture"\)'))); + // E o release que a recebe falha. + expect(gradle, contains('captura de tela')); + }); + + test('a janela do ACS é FLAG_SECURE (sem captura, gravação nem miniatura)', () { + expect(fonte, contains('override fun onCreate')); + expect(fonte, contains('WindowManager.LayoutParams.FLAG_SECURE')); + expect(fonte, contains('window.setFlags(')); + }); + + test('a flag é aplicada DENTRO de onCreate, não só mencionada no arquivo', () { + // Corpo de `onCreate`: da assinatura até o `}` que fecha o método (a classe + // é pequena e o método não tem blocos aninhados fora do `window.setFlags(`). + final inicio = fonte.indexOf('override fun onCreate'); + final fim = fonte.indexOf('override fun configureFlutterEngine'); + expect(inicio, isNonNegative); + expect(fim, greaterThan(inicio)); + final corpo = fonte.substring(inicio, fim); + expect(corpo, contains('window.setFlags(')); + expect(corpo, contains('WindowManager.LayoutParams.FLAG_SECURE')); + // E não está comentada: nenhuma linha com a chamada começa por `//`. + expect( + corpo.split('\n').where((l) => l.contains('window.setFlags(')).any((l) => l.trimLeft().startsWith('//')), + isFalse, + ); + }); +} diff --git a/apps/acs/test/send_notice_login_test.dart b/apps/acs/test/send_notice_login_test.dart new file mode 100644 index 00000000..c69f6212 --- /dev/null +++ b/apps/acs/test/send_notice_login_test.dart @@ -0,0 +1,34 @@ +import 'package:flutter_test/flutter_test.dart'; + +import '../tool/send_notice_login.dart'; + +void main() { + test('sem variáveis, não há credencial e vale o login de desenvolvimento', () { + expect(acsCredentialsFromEnv(const {}), isNull); + expect(acsCredentialsFromEnv(const {'ACS_MATRICULA': '', 'ACS_PASSWORD': ''}), isNull); + }); + + test('com as duas variáveis, devolve matrícula e senha', () { + final credentials = acsCredentialsFromEnv(const {'ACS_MATRICULA': 'E2E-1', 'ACS_PASSWORD': 'segredo'})!; + + expect(credentials.matricula, 'E2E-1'); + expect(credentials.password, 'segredo'); + }); + + test('só uma das duas é um erro que nomeia a que falta, sem mostrar valores', () { + expect( + () => acsCredentialsFromEnv(const {'ACS_MATRICULA': 'E2E-1'}), + throwsA(isA().having((e) => e.message, 'message', allOf(contains('ACS_PASSWORD'), isNot(contains('E2E-1'))))), + ); + expect( + () => acsCredentialsFromEnv(const {'ACS_PASSWORD': 'segredo'}), + throwsA(isA().having((e) => e.message, 'message', allOf(contains('ACS_MATRICULA'), isNot(contains('segredo'))))), + ); + }); + + test('toString da credencial nunca mostra a senha', () { + final credentials = acsCredentialsFromEnv(const {'ACS_MATRICULA': 'E2E-1', 'ACS_PASSWORD': 'segredo-xyz'})!; + + expect('$credentials'.contains('segredo-xyz'), isFalse); + }); +} diff --git a/apps/acs/test/session_expiry_mfa_test.dart b/apps/acs/test/session_expiry_mfa_test.dart new file mode 100644 index 00000000..eaf5e3d8 --- /dev/null +++ b/apps/acs/test/session_expiry_mfa_test.dart @@ -0,0 +1,63 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/core/network/backend_client.dart'; +import 'package:sinalacs_acs/core/security/session_token_store.dart'; +import 'package:sinalacs_acs/core/services/backend_visit_synchronizer.dart'; +import 'package:sinalacs_acs/core/services/offline_visit_queue.dart'; + +import 'support/fake_rpc_server.dart'; + +/// A renovação silenciosa de 15 minutos usa o refresh token, nunca a senha (o +/// código TOTP não se reaproveita). Se o servidor recusa o refresh, a sessão +/// acaba e a pessoa entra de novo. +void main() { + late FakeRpcServer server; + late BackendClient backend; + + setUp(() async { + server = await FakeRpcServer.start() + ..mfaRequired = true + ..rejectRefresh = true + ..tokenLifetime = const Duration(minutes: -1); + backend = BackendClient(host: server.host, tokenStore: MemorySessionTokenStore(), deviceIds: MemoryDeviceIdStore()); + }); + + tearDown(() async { + backend.close(); + await server.stop(); + }); + + const mensagem = 'Sua sessão expirou. Entre novamente com o código do autenticador.'; + + test('refresh recusado falha de forma NÃO recuperável, avisa a UI uma vez e esquece o token', () async { + var avisos = 0; + backend.onSessionExpired = () => avisos++; + await backend.login(matricula: 'ACS-001', senha: 'senha-sintetica', totpCode: '123456'); + expect(server.loginCount, 1); + + await expectLater( + backend.listPatients(), + throwsA(isA() + .having((f) => f.message, 'message', mensagem) + .having((f) => f.isRecoverable, 'isRecoverable', isFalse)), + ); + expect(avisos, 1); + expect(server.loginCount, 1, reason: 'a senha nunca é reenviada'); + expect(server.refreshCount, 1); + + // O token foi descartado: nova chamada não bate mais no servidor. + await expectLater(backend.listPatients(), throwsA(isA())); + expect(server.refreshCount, 1); + }); + + test('visita pendente continua pendente quando a sessão MFA expira', () async { + await backend.login(matricula: 'ACS-001', senha: 'senha-sintetica', totpCode: '123456'); + final fila = OfflineVisitQueue(synchronizer: BackendVisitSynchronizer(backend: backend, ownerId: backend.session!.userId)); + await fila.add(OfflineVisitRecord(patientId: '00000000-0000-4000-8000-0000000000a1', risk: 'red', status: 'PENDENTE')); + + final resultado = await fila.sync(); + + expect(resultado.kind, SyncOutcomeKind.error); + expect(resultado.message, mensagem); + expect(fila.pendingCount, 1); + }); +} diff --git a/apps/acs/test/session_reauth_test.dart b/apps/acs/test/session_reauth_test.dart new file mode 100644 index 00000000..2f01dd4a --- /dev/null +++ b/apps/acs/test/session_reauth_test.dart @@ -0,0 +1,161 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/app/app.dart'; +import 'package:sinalacs_acs/core/services/offline_visit_queue.dart'; +import 'package:sinalacs_client/sinalacs_client.dart' show MicroAreaPatient; + +import 'support/fakes.dart'; +import 'support/layout_harness.dart' show assentar, irParaDaBarra; + +const _aviso = 'Sua sessão expirou. Entre novamente com o código do autenticador.'; + +/// Sessão vencida com MFA: a reautenticação é empilhada SOBRE o painel. Nada do +/// que o painel guarda em memória (alertas já entregues por MQTT, formulário, +/// fila de visitas) pode se perder — um alerta vermelho nunca some em silêncio. +void main() { + late FakeAcsBackend backend; + late FakeAlertFeed feed; + late OfflineVisitQueue fila; + + Future abrir(WidgetTester tester) async { + backend = FakeAcsBackend() + ..expectedTotpCode = '123456' + ..patients = [ + MicroAreaPatient(patientId: syntheticPatientId(5), name: 'Fulano de Tal', isChronic: false, chronicConditions: const []), + ]; + fila = OfflineVisitQueue(); + await fila.add(OfflineVisitRecord(patientId: seedPatientId, risk: 'red', status: 'PENDENTE')); + await tester.pumpWidget(SinalAcsApp(backend: backend, visitQueue: fila, feedBuilder: (q) => feed = FakeAlertFeed(q))); + await tester.enterText(find.byKey(const Key('matricula_field')), 'ACS-001'); + await tester.enterText(find.byKey(const Key('senha_field')), 'senha-sintetica'); + await _tocarEntrar(tester); + await assentar(tester); + await _digitarCodigo(tester); + expect(find.byKey(const Key('login_button')), findsNothing, reason: 'o painel abriu'); + feed.deliver(testAlert(alertId: 'alerta-vermelho')); + await assentar(tester); + expect(find.byKey(const Key('alert_alerta-vermelho')), findsOneWidget); + } + + testWidgets('(a) a sessão expira: login empilhado, e o alerta continua na fila sem confirmação', (tester) async { + await abrir(tester); + + backend.expireSession(); + await assentar(tester); + expect(find.byKey(const Key('login_button')), findsOneWidget); + expect(find.text(_aviso), findsOneWidget); + + await tester.enterText(find.byKey(const Key('matricula_field')), 'ACS-001'); + await tester.enterText(find.byKey(const Key('senha_field')), 'senha-sintetica'); + await _tocarEntrar(tester); + await assentar(tester); + await _digitarCodigo(tester); + + expect(find.byKey(const Key('login_button')), findsNothing, reason: 'voltou ao painel'); + expect(find.byKey(const Key('alert_alerta-vermelho')), findsOneWidget); + expect(find.text('Confirmar recebimento'), findsOneWidget, reason: 'segue sem confirmação'); + expect(backend.acknowledgedAlertIds, isEmpty); + expect(fila.pendingCount, 1); + }); + + testWidgets('(b) confirmar depois de expirar mostra a mensagem e o alerta segue sem confirmação', (tester) async { + await abrir(tester); + backend.sessionExpired = true; // vence sem avisar a UI: o aviso vem da própria chamada + + await tester.tap(find.text('Confirmar recebimento')); + await assentar(tester); + + expect(find.text(_aviso), findsWidgets, reason: 'mensagem visível (tela de reautenticação)'); + expect(find.byType(SnackBar), findsOneWidget); + expect(backend.acknowledgedAlertIds, isEmpty); + + // A mensagem cobre o rodapé da tela de login: dispensa para tocar no botão. + ScaffoldMessenger.of(tester.element(find.byKey(const Key('login_button')))).hideCurrentSnackBar(); + await assentar(tester); + await tester.enterText(find.byKey(const Key('matricula_field')), 'ACS-001'); + await tester.enterText(find.byKey(const Key('senha_field')), 'senha-sintetica'); + await _tocarEntrar(tester); + await assentar(tester); + await _digitarCodigo(tester); + expect(find.text('Confirmar recebimento'), findsOneWidget, reason: 'o alerta não sumiu nem foi confirmado'); + }); + + testWidgets('(c) o formulário de visita pela metade sobrevive à reautenticação', (tester) async { + await abrir(tester); + await irParaDaBarra(tester, 'Visita'); + await tester.enterText(find.byKey(const Key('patient_search')), 'busca em andamento'); + await tester.pump(); + + backend.expireSession(); + await assentar(tester); + await tester.enterText(find.byKey(const Key('matricula_field')), 'ACS-001'); + await tester.enterText(find.byKey(const Key('senha_field')), 'senha-sintetica'); + await _tocarEntrar(tester); + await assentar(tester); + await _digitarCodigo(tester); + + expect(tester.widget(find.byKey(const Key('patient_search'))).controller!.text, 'busca em andamento'); + }); + + testWidgets('(e) com a reautenticação por cima, o aviso conta os alertas aguardando e acompanha os novos', (tester) async { + await abrir(tester); + + backend.expireSession(); + await assentar(tester); + expect(find.byKey(const Key('login_button')), findsOneWidget); + expect(find.text('1 alerta vermelho aguardando. Entre para ver.'), findsOneWidget); + + feed.deliver(testAlert(alertId: 'segundo-alerta')); + await assentar(tester); + expect(find.text('2 alertas vermelhos aguardando. Entre para ver.'), findsOneWidget); + // O aviso não cobre o formulário: o campo segue tocável. + await tester.enterText(find.byKey(const Key('matricula_field')), 'ACS-001'); + expect(tester.widget(find.byKey(const Key('matricula_field'))).controller!.text, 'ACS-001'); + }); + + testWidgets('(d) outro usuário entra: a fila do anterior não é mantida', (tester) async { + await abrir(tester); + backend.expireSession(); + await assentar(tester); + backend.nextUserId = '00000000-0000-4000-8000-0000000000ff'; + + await tester.enterText(find.byKey(const Key('matricula_field')), 'ACS-002'); + await tester.enterText(find.byKey(const Key('senha_field')), 'senha-sintetica'); + await _tocarEntrar(tester); + await assentar(tester); + await _digitarCodigo(tester); + + expect(find.byKey(const Key('login_button')), findsNothing, reason: 'painel novo aberto'); + expect(find.byKey(const Key('alert_alerta-vermelho')), findsNothing, reason: 'RNF06: nada do usuário anterior'); + }); + + testWidgets('(e) duas expirações seguidas empilham uma única reautenticação', (tester) async { + await abrir(tester); + + backend.expireSession(); + backend.expireSession(); + await assentar(tester); + + expect(find.byKey(const Key('login_button')), findsOneWidget); + // Um único `pop` volta ao painel: só havia uma rota por cima. + tester.state(find.byType(Navigator).first).pop(); + await assentar(tester); + expect(find.byKey(const Key('login_button')), findsNothing); + }); +} + +/// Depois de matrícula e senha o fake pede o código: digita e entra. +Future _digitarCodigo(WidgetTester tester) async { + await tester.enterText(find.byKey(const Key('totp_field')), '123456'); + await _tocarEntrar(tester); + await assentar(tester); +} + +/// Com o aviso de alertas aguardando no topo da reautenticação, o botão pode +/// ficar abaixo da dobra de 800x600 (o formulário é uma `ListView`): rola até +/// ele antes de tocar, como a pessoa faria. +Future _tocarEntrar(WidgetTester tester) async { + await tester.ensureVisible(find.byKey(const Key('login_button'))); + await tester.pump(); + await tester.tap(find.byKey(const Key('login_button'))); +} diff --git a/apps/acs/test/session_resume_test.dart b/apps/acs/test/session_resume_test.dart new file mode 100644 index 00000000..4053cd1b --- /dev/null +++ b/apps/acs/test/session_resume_test.dart @@ -0,0 +1,734 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/app/app.dart'; +import 'package:sinalacs_acs/core/network/backend_client.dart'; +import 'package:sinalacs_acs/core/security/biometric_gate.dart'; +import 'package:sinalacs_acs/core/security/auth_bound_session_token_store.dart'; +import 'package:sinalacs_acs/core/security/keystore_vault.dart'; +import 'package:sinalacs_acs/core/security/session_token_store.dart'; +import 'package:sinalacs_acs/core/security/upload_token_store.dart'; +import 'package:sinalacs_acs/core/services/backend_visit_synchronizer.dart'; +import 'package:sinalacs_acs/core/services/offline_visit_queue.dart'; +import 'package:sinalacs_client/sinalacs_client.dart' show MicroAreaPatient; + +import 'support/fake_rpc_server.dart'; +import 'support/fakes.dart'; +import 'support/layout_harness.dart' show assentar, irParaDaBarra, irParaDoMais; + +/// Retomada da sessão por digital na partida, "Entrar com senha" na cobertura +/// do bloqueio e "Sair". Credenciais e pacientes sintéticos. +void main() { + late FakeAlertFeed feed; + late FakeBiometricGate gate; + late OfflineVisitQueue fila; + late GlobalKey navKey; + + setUp(() { + gate = FakeBiometricGate(); + fila = OfflineVisitQueue(); + navKey = GlobalKey(); + }); + + Future abrirApp(WidgetTester tester, AcsBackend backend, {Duration? lockAfter}) async { + await tester.pumpWidget(SinalAcsApp( + backend: backend, + visitQueue: fila, + biometricGate: gate, + navigatorKey: navKey, + lockAfter: lockAfter, + feedBuilder: (q) => feed = FakeAlertFeed(q), + )); + await assentar(tester); + } + + Finder painel() => find.text('Painel operacional'); + Finder formulario() => find.byKey(const Key('login_button')); + + Future entrarComSenha(WidgetTester tester) async { + await tester.enterText(find.byKey(const Key('matricula_field')), 'ACS-001'); + await tester.enterText(find.byKey(const Key('senha_field')), 'senha-sintetica'); + await tester.ensureVisible(formulario()); + await tester.pump(); + await tester.tap(formulario()); + await assentar(tester); + } + + /// Segundo plano e volta: com `lockAfter: Duration.zero` sempre bloqueia. + Future irEVoltar(WidgetTester tester) async { + for (final s in const [AppLifecycleState.inactive, AppLifecycleState.hidden, AppLifecycleState.paused, + AppLifecycleState.hidden, AppLifecycleState.inactive, AppLifecycleState.resumed]) { + tester.binding.handleAppLifecycleStateChanged(s); + } + await assentar(tester); + } + + group('partida a frio', () { + testWidgets('partida com refresh token salvo: digital OK → entra no painel sem digitar nada', (tester) async { + final backend = FakeAcsBackend()..storedRefreshToken = 'refresh-salvo'; + await abrirApp(tester, backend); + + expect(gate.reasons, ['Entrar no SinalACS']); + expect(backend.resumeCount, 1); + expect(backend.loginCount, 0, reason: 'nenhuma senha digitada'); + expect(painel(), findsOneWidget); + expect(formulario(), findsNothing); + expect(feed.startedTopicMicroArea, seedMicroAreaId); + }); + + testWidgets('partida com token salvo e digital cancelada → fica na tela de login completa', (tester) async { + gate.result = UnlockResult.cancelled; + final backend = FakeAcsBackend()..storedRefreshToken = 'refresh-salvo'; + await abrirApp(tester, backend); + + expect(gate.calls, 1); + expect(backend.resumeCount, 0, reason: 'sem desbloqueio não se toca no token'); + expect(formulario(), findsOneWidget); + expect(painel(), findsNothing); + expect(find.byKey(const Key('login_aviso')), findsNothing); + expect(tester.widget(formulario()).onPressed, isNotNull, reason: 'formulário utilizável'); + expect(tester.widget(find.byKey(const Key('matricula_field'))).controller!.text, isEmpty, + reason: 'nada da sessão guardada aparece no formulário'); + }); + + testWidgets('partida sem token salvo → tela de login normal, sem prompt biométrico', (tester) async { + final backend = FakeAcsBackend(); + await abrirApp(tester, backend); + + expect(gate.calls, 0); + expect(backend.resumeCount, 0); + expect(formulario(), findsOneWidget); + }); + + testWidgets('aparelho sem nenhum bloqueio de tela (unavailable) NÃO retoma a sessão salva (P5)', (tester) async { + gate.available = false; + final backend = FakeAcsBackend()..storedRefreshToken = 'refresh-salvo'; + await abrirApp(tester, backend); + + expect(gate.calls, 0); + expect(backend.resumeCount, 0); + expect(formulario(), findsOneWidget); + expect(painel(), findsNothing); + }); + + testWidgets('sem rede na retomada → aviso com "Tentar de novo", token mantido; com rede entra', (tester) async { + final backend = FakeAcsBackend() + ..storedRefreshToken = 'refresh-salvo' + ..resumeOffline = true; + await abrirApp(tester, backend); + + expect(find.text('Sem conexão para retomar a sessão. Tente de novo ou entre com senha.'), findsOneWidget); + expect(backend.storedRefreshToken, 'refresh-salvo'); + expect(formulario(), findsOneWidget); + + backend.resumeOffline = false; + await tester.ensureVisible(find.byKey(const Key('resume_retry'))); + await tester.tap(find.byKey(const Key('resume_retry'))); + await assentar(tester); + + expect(gate.calls, 2, reason: 'a nova tentativa pede o desbloqueio de novo'); + expect(painel(), findsOneWidget); + }); + }); + + testWidgets('toque duplo em "Tentar de novo" faz UMA retomada só', (tester) async { + final backend = FakeAcsBackend() + ..storedRefreshToken = 'refresh-salvo' + ..resumeOffline = true; + await abrirApp(tester, backend); + expect(gate.calls, 1); + backend.resumeOffline = false; + + final tentar = find.byKey(const Key('resume_retry')); + await tester.ensureVisible(tentar); + await tester.pump(); + final prompt = Completer(); + gate.pending = prompt; + final botao = tester.widget(tentar); + botao.onPressed!(); + botao.onPressed!(); + await tester.pump(); + expect(tester.widget(formulario()).onPressed, isNull, reason: 'login desligado durante a retomada'); + expect(find.byKey(const Key('resume_retry')), findsNothing, reason: 'sem segundo toque possível'); + prompt.complete(UnlockResult.unlocked); + await assentar(tester); + + expect(gate.calls, 2, reason: 'um prompt a mais, não dois'); + expect(backend.resumeCount, 2, reason: 'a primeira (offline) e UMA nova'); + expect(painel(), findsOneWidget); + }); + + group('cliente real contra servidor local', () { + late FakeRpcServer server; + late MemorySessionTokenStore store; + late BackendClient backend; + HttpOverrides? overridesDoTeste; + + setUp(() async { + // O binding de widget troca o HttpClient por um que responde 400 a tudo; + // aqui a chamada precisa chegar ao servidor local de verdade. + overridesDoTeste = HttpOverrides.current; + HttpOverrides.global = null; + server = await FakeRpcServer.start(); + store = MemorySessionTokenStore(); + backend = BackendClient(host: server.host, tokenStore: store, deviceIds: MemoryDeviceIdStore()); + }); + + tearDown(() async { + backend.close(); + await server.stop(); + HttpOverrides.global = overridesDoTeste; + }); + + /// A requisição nasce no relógio falso do teste: o prazo (timeout) dela + /// fica pendente nele. Desmonta e deixa o relógio passar do prazo. + Future encerrar(WidgetTester tester) async { + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(minutes: 5)); + } + + testWidgets('refresh recusado na partida → tela de login com aviso, token apagado', (tester) async { + await tester.runAsync(() => store.write('refresh-revogado')); + server.rejectRefresh = true; + await abrirApp(tester, backend); + + expect(server.refreshCount, 1); + expect(find.text('Sua sessão expirou. Entre novamente.'), findsOneWidget); + expect(formulario(), findsOneWidget); + expect(await tester.runAsync(store.read), isNull); + await encerrar(tester); + }); + + testWidgets('após login completo, o token novo substitui o salvo', (tester) async { + await tester.runAsync(() => store.write('refresh-antigo')); + gate.result = UnlockResult.cancelled; + await abrirApp(tester, backend); + expect(formulario(), findsOneWidget); + + await entrarComSenha(tester); + + expect(painel(), findsOneWidget); + expect(server.loginCount, 1); + expect(await tester.runAsync(store.read), 'refresh-0'); + await encerrar(tester); + }); + + testWidgets('retomada da sessão respeita a microárea devolvida pelo servidor', (tester) async { + await tester.runAsync(() => store.write('refresh-salvo')); + server.microAreaId = otherMicroAreaId; + await abrirApp(tester, backend); + + expect(painel(), findsOneWidget); + expect(feed.startedTopicMicroArea, otherMicroAreaId, reason: 'o território vem do token novo, não de cache'); + expect(await tester.runAsync(store.read), 'refresh-1', reason: 'token rotacionado'); + await encerrar(tester); + }); + }); + + group('cliente real com token amarrado ao cofre do Keystore', () { + late FakeRpcServer server; + late FakeKeystoreVault vault; + late AuthBoundSessionTokenStore store; + late BackendClient backend; + HttpOverrides? overridesDoTeste; + + setUp(() async { + overridesDoTeste = HttpOverrides.current; + HttpOverrides.global = null; + server = await FakeRpcServer.start(); + vault = FakeKeystoreVault()..sealed[AuthBoundSessionTokenStore.alias] = 'tok-1'; + store = AuthBoundSessionTokenStore(vault: vault, legacy: MemorySessionTokenStore()); + backend = BackendClient(host: server.host, tokenStore: store, deviceIds: MemoryDeviceIdStore()); + }); + + tearDown(() async { + backend.close(); + await server.stop(); + HttpOverrides.global = overridesDoTeste; + }); + + Future encerrar(WidgetTester tester) async { + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(minutes: 5)); + } + + testWidgets('o cofre desbloqueia e o gate de UI NÃO é chamado', (tester) async { + await abrirApp(tester, backend); + + expect(vault.unsealCalls, 1); + expect(gate.calls, 0, reason: 'a digital já foi pedida pelo cofre'); + expect(painel(), findsOneWidget); + await encerrar(tester); + }); + + testWidgets('prompt do cofre cancelado: fica no login, token continua guardado', (tester) async { + vault.nextUnsealFailure = VaultFailure.cancelled; + await abrirApp(tester, backend); + + expect(vault.unsealCalls, 1); + expect(gate.calls, 0); + expect(server.refreshCount, 0); + expect(formulario(), findsOneWidget); + expect(painel(), findsNothing); + expect(await tester.runAsync(store.contains), isTrue); + await encerrar(tester); + }); + + testWidgets('cancelado: aparece "Tentar de novo" e, ao tocar, o painel abre', (tester) async { + vault.nextUnsealFailure = VaultFailure.cancelled; + await abrirApp(tester, backend); + + expect(find.byKey(const Key('resume_incomplete')), findsOneWidget); + expect(find.text('Desbloqueio não concluído. Tente de novo ou entre com senha.'), findsOneWidget); + expect(find.text('Sua sessão expirou. Entre novamente.'), findsNothing); + await tester.ensureVisible(find.byKey(const Key('resume_retry'))); + await tester.tap(find.byKey(const Key('resume_retry'))); + await assentar(tester); + + expect(vault.unsealCalls, 2); + expect(painel(), findsOneWidget); + await encerrar(tester); + }); + + testWidgets('lockedOut também mostra "Tentar de novo"', (tester) async { + vault.nextUnsealFailure = VaultFailure.lockedOut; + await abrirApp(tester, backend); + + expect(find.byKey(const Key('resume_retry')), findsOneWidget); + expect(find.byKey(const Key('resume_incomplete')), findsOneWidget); + await encerrar(tester); + }); + + for (final falha in [VaultFailure.lockedOut, VaultFailure.unavailable]) { + testWidgets('cofre $falha: não retoma a sessão', (tester) async { + vault.nextUnsealFailure = falha; + await abrirApp(tester, backend); + + expect(server.refreshCount, 0); + expect(gate.calls, 0); + expect(formulario(), findsOneWidget); + expect(painel(), findsNothing); + await encerrar(tester); + }); + } + + testWidgets('chave invalidada: aviso de sessão expirada e login completo', (tester) async { + vault.nextUnsealFailure = VaultFailure.invalidated; + await abrirApp(tester, backend); + + expect(find.text('Sua sessão expirou. Entre novamente.'), findsOneWidget); + expect(formulario(), findsOneWidget); + expect(server.refreshCount, 0); + expect(find.byKey(const Key('resume_retry')), findsNothing); + expect(await tester.runAsync(store.contains), isFalse); + await encerrar(tester); + }); + }); + + group('bloqueio com painel aberto', () { + late FakeAcsBackend backend; + + Future abrirPainel(WidgetTester tester, {bool comVisita = true}) async { + backend = FakeAcsBackend() + ..patients = [ + MicroAreaPatient(patientId: syntheticPatientId(5), name: 'Paciente Sintético', isChronic: false, chronicConditions: const []), + ]; + if (comVisita) await fila.add(OfflineVisitRecord(patientId: seedPatientId, risk: 'red', status: 'PENDENTE')); + await abrirApp(tester, backend, lockAfter: Duration.zero); + await entrarComSenha(tester); + expect(painel(), findsOneWidget); + feed.deliver(testAlert(alertId: 'alerta-antes')); + await assentar(tester); + } + + testWidgets('alerta vermelho recebido com o app BLOQUEADO está na fila ao desbloquear', (tester) async { + await abrirPainel(tester); + gate.result = UnlockResult.cancelled; + await irEVoltar(tester); + expect(find.text('Aplicativo bloqueado'), findsOneWidget); + + feed.deliver(testAlert(alertId: 'alerta-no-bloqueio')); + await assentar(tester); + + gate.result = UnlockResult.unlocked; + await tester.tap(find.text('Desbloquear')); + await assentar(tester); + + expect(find.text('Aplicativo bloqueado'), findsNothing); + expect(find.byKey(const Key('alert_alerta-no-bloqueio')), findsOneWidget); + expect(find.byKey(const Key('alert_alerta-antes')), findsOneWidget); + expect(feed.stopped, isFalse); + }); + + testWidgets('"Entrar com senha" do bloqueio leva ao login completo e preserva o painel por baixo', (tester) async { + await abrirPainel(tester); + await irParaDaBarra(tester, 'Visita'); + await tester.enterText(find.byKey(const Key('patient_search')), 'busca em andamento'); + await tester.pump(); + + gate.result = UnlockResult.cancelled; + await irEVoltar(tester); + expect(find.text('Aplicativo bloqueado'), findsOneWidget); + + await tester.tap(find.text('Entrar com senha')); + await assentar(tester); + + // A cobertura sai, mas o que aparece é o formulário: o painel segue + // escondido sob a rota opaca e não descartável. + expect(find.text('Aplicativo bloqueado'), findsNothing); + expect(formulario(), findsOneWidget); + expect(find.byKey(const Key('patient_search')), findsNothing); + await tester.binding.handlePopRoute(); + await assentar(tester); + expect(formulario(), findsOneWidget, reason: 'voltar não descarta a reautenticação'); + + // Alerta que chega durante a reautenticação também fica. + feed.deliver(testAlert(alertId: 'alerta-na-senha')); + await entrarComSenha(tester); + + expect(formulario(), findsNothing, reason: 'voltou ao painel'); + expect(tester.widget(find.byKey(const Key('patient_search'))).controller!.text, 'busca em andamento'); + expect(feed.stopped, isFalse, reason: 'o feed MQTT não foi derrubado'); + expect(fila.pendingCount, 1); + expect(navKey.currentState!.canPop(), isFalse, reason: 'sem rota-bloqueio esquecida'); + + await irParaDaBarra(tester, 'Fila'); + expect(find.byKey(const Key('alert_alerta-antes')), findsOneWidget); + expect(find.byKey(const Key('alert_alerta-na-senha')), findsOneWidget); + + // O gate recriado continua bloqueando. + await irEVoltar(tester); + expect(find.text('Aplicativo bloqueado'), findsOneWidget); + }); + + testWidgets('"Entrar com senha": o painel nunca fica à vista, tocável ou na semântica em quadro algum', (tester) async { + final semantics = tester.ensureSemantics(); + await abrirPainel(tester); + await irParaDaBarra(tester, 'Visita'); + gate.result = UnlockResult.cancelled; + await irEVoltar(tester); + expect(find.text('Aplicativo bloqueado'), findsOneWidget); + + void semPainel(String quadro) { + expect(find.byKey(const Key('patient_search')), findsNothing, reason: 'visível no $quadro'); + expect(find.byKey(const Key('patient_search'), skipOffstage: false).hitTestable(), findsNothing, + reason: 'tocável no $quadro'); + expect(find.bySemanticsLabel('Buscar paciente pelo nome'), findsNothing, reason: 'na semântica no $quadro'); + } + + await tester.tap(find.text('Entrar com senha')); + await tester.pump(); + semPainel('1º quadro'); + for (var i = 2; i <= 6; i++) { + await tester.pump(const Duration(milliseconds: 16)); + semPainel('quadro $i'); + } + await tester.pump(const Duration(milliseconds: 150)); + semPainel('meio da transição'); + await assentar(tester); + semPainel('fim'); + expect(formulario(), findsOneWidget); + semantics.dispose(); + }); + + testWidgets('"Entrar com senha" limpa senha e código digitados antes do bloqueio', (tester) async { + backend = FakeAcsBackend()..expectedTotpCode = '123456'; + gate.result = UnlockResult.cancelled; + await abrirApp(tester, backend, lockAfter: Duration.zero); + await entrarComSenha(tester); // pede o código + await tester.enterText(find.byKey(const Key('totp_field')), '654'); + await irEVoltar(tester); + expect(find.text('Aplicativo bloqueado'), findsOneWidget); + + await tester.tap(find.text('Entrar com senha')); + await assentar(tester); + + expect(find.text('Aplicativo bloqueado'), findsNothing); + expect(tester.widget(find.byKey(const Key('senha_field'))).controller!.text, isEmpty); + expect(find.byKey(const Key('totp_field')), findsNothing); + }); + + testWidgets('"Entrar com senha" descarta a tela de ativação da MFA (segredo)', (tester) async { + backend = FakeAcsBackend()..mfaEnrollmentRequired = true; + gate.result = UnlockResult.cancelled; + await abrirApp(tester, backend, lockAfter: Duration.zero); + await entrarComSenha(tester); + expect(find.byKey(const Key('mfa_secret')), findsOneWidget); + await irEVoltar(tester); + + await tester.tap(find.text('Entrar com senha')); + await assentar(tester); + + expect(find.byKey(const Key('mfa_secret'), skipOffstage: false), findsNothing); + expect(formulario(), findsOneWidget); + expect(tester.widget(find.byKey(const Key('senha_field'))).controller!.text, isEmpty); + expect(navKey.currentState!.canPop(), isFalse); + }); + + testWidgets('"Entrar com senha" e outro usuário entra: o painel anterior é descartado (RNF06)', (tester) async { + await abrirPainel(tester); + gate.result = UnlockResult.cancelled; + await irEVoltar(tester); + await tester.tap(find.text('Entrar com senha')); + await assentar(tester); + + final feedAnterior = feed; + backend.nextUserId = '00000000-0000-4000-8000-0000000000ff'; + await entrarComSenha(tester); + + expect(painel(), findsOneWidget); + expect(find.byKey(const Key('alert_alerta-antes'), skipOffstage: false), findsNothing); + expect(feedAnterior.stopped, isTrue); + expect(navKey.currentState!.canPop(), isFalse); + }); + + testWidgets('"Sair" em Ajustes pede confirmação, chama logout e volta ao login', (tester) async { + await abrirPainel(tester); + await irParaDoMais(tester, 'Preferências'); + + final sair = find.byKey(const Key('logout_button')); + await tester.ensureVisible(sair); + await tester.pump(); + await tester.tap(sair); + await assentar(tester); + expect(find.byKey(const Key('logout_pending_visits')), findsOneWidget); + + await tester.tap(find.text('Cancelar')); + await assentar(tester); + expect(backend.logoutCount, 0); + expect(painel(), findsOneWidget); + + await tester.tap(sair); + await assentar(tester); + final saida = Completer(); + backend.logoutGate = saida; + await tester.tap(find.byKey(const Key('logout_confirm'))); + await tester.pump(); + await tester.pump(); + expect(find.byKey(const Key('logout_progress')), findsOneWidget, reason: 'progresso enquanto sai'); + expect(tester.widget(sair).onPressed, isNull); + saida.complete(); + await assentar(tester); + + expect(backend.logoutCount, 1); + expect(backend.storedRefreshToken, isNull); + expect(formulario(), findsOneWidget); + expect(painel(), findsNothing); + expect(feed.stopped, isTrue, reason: 'o painel foi descartado e o feed parou'); + expect(fila.pendingCount, 1, reason: 'a visita não sincronizada continua no aparelho'); + expect(gate.calls, 0, reason: 'a tela depois de Sair não tenta retomar a sessão'); + expect(navKey.currentState!.canPop(), isFalse); + }); + + Future abrirSair(WidgetTester tester) async { + await irParaDoMais(tester, 'Preferências'); + final sair = find.byKey(const Key('logout_button')); + await tester.ensureVisible(sair); + await tester.pump(); + await tester.tap(sair); + await assentar(tester); + } + + testWidgets('"Sair" sem alerta nem visita pendente: "Tudo foi enviado" e botão "Sair"', (tester) async { + await abrirPainel(tester, comVisita: false); + // O único alerta do painel já foi confirmado. + await irParaDaBarra(tester, 'Fila'); + final ack = find.byKey(const Key('ack_alerta-antes')); + await tester.ensureVisible(ack); + await tester.pump(); + await tester.tap(ack); + await assentar(tester); + expect(backend.acknowledgedAlertIds, ['alerta-antes']); + await abrirSair(tester); + + expect(find.text('Tudo foi enviado. Nenhuma visita fica pendente neste aparelho.'), findsOneWidget); + expect(find.byKey(const Key('logout_pending_visits')), findsNothing); + expect(find.byKey(const Key('logout_pending_alerts')), findsNothing); + expect(find.descendant(of: find.byKey(const Key('logout_confirm')), matching: find.text('Sair')), findsOneWidget); + }); + + testWidgets('"Sair" com 2 alertas não confirmados: diz quantos e o botão explicita a perda', (tester) async { + await abrirPainel(tester); + feed.deliver(testAlert(alertId: 'alerta-dois')); + await assentar(tester); + await abrirSair(tester); + + expect(find.text('1 visita ainda não enviada continua neste aparelho. Ela segue protegida e sobe sozinha quando o aplicativo estiver aberto com conexão (em até 7 dias).'), + findsOneWidget); + expect( + find.text('Há 2 alertas ainda não confirmados. Eles saem deste aparelho, mas continuam pendentes no servidor.'), + findsOneWidget, + ); + expect(find.descendant(of: find.byKey(const Key('logout_confirm')), matching: find.text('Sair mesmo assim')), + findsOneWidget); + + await tester.tap(find.byKey(const Key('logout_confirm'))); + await assentar(tester); + + expect(backend.logoutCount, 1, reason: 'a saída não é bloqueada'); + expect(formulario(), findsOneWidget); + expect(fila.pendingCount, 1, reason: 'a visita não sincronizada continua no aparelho'); + }); + + testWidgets('"Sair" envia a fila do próprio ACS ANTES do logout e diz que tudo foi enviado', (tester) async { + backend = FakeAcsBackend(); + fila = OfflineVisitQueue(synchronizer: BackendVisitSynchronizer(backend: backend, ownerId: seedAcsId)); + await fila.add(OfflineVisitRecord(localId: 'local-propria', patientId: seedPatientId, risk: 'red', status: 'PENDENTE')); + await abrirApp(tester, backend); + await entrarComSenha(tester); + expect(backend.callLog, isEmpty, reason: 'nada sobe antes do Sair'); + + await abrirSair(tester); + expect(backend.callLog, ['syncVisits'], reason: 'a fila do painel sobe ao tocar em Sair, antes da confirmação'); + expect(find.text('Tudo foi enviado. Nenhuma visita fica pendente neste aparelho.'), findsOneWidget); + expect(find.descendant(of: find.byKey(const Key('logout_confirm')), matching: find.text('Sair')), findsOneWidget); + + await tester.tap(find.byKey(const Key('logout_confirm'))); + await assentar(tester); + + expect(backend.callLog, ['syncVisits', 'logout']); + expect(backend.syncedVisitBatches.single.single.localId, 'local-propria'); + expect(fila.pendingCount, 0); + expect(formulario(), findsOneWidget); + }); + + testWidgets('"Sair" explica a espera enquanto envia as pendentes', (tester) async { + backend = FakeAcsBackend()..syncGate = Completer(); + fila = OfflineVisitQueue(synchronizer: BackendVisitSynchronizer(backend: backend, ownerId: seedAcsId)); + await fila.add(OfflineVisitRecord(localId: 'local-lenta', patientId: seedPatientId, risk: 'red', status: 'PENDENTE')); + await abrirApp(tester, backend); + await entrarComSenha(tester); + await irParaDoMais(tester, 'Preferências'); + final sair = find.byKey(const Key('logout_button')); + await tester.ensureVisible(sair); + await tester.pump(); + await tester.tap(sair); + await tester.pump(); + await tester.pump(); + + expect(find.byKey(const Key('logout_progress')), findsOneWidget); + expect(find.text('Enviando as visitas pendentes antes de sair…'), findsOneWidget); + + backend.syncGate!.complete(); + await assentar(tester); + expect(find.text('Enviando as visitas pendentes antes de sair…'), findsNothing, reason: 'some quando o diálogo abre'); + expect(find.byKey(const Key('logout_confirm')), findsOneWidget); + }); + + testWidgets('"Sair" sem rede: diz quantas ficam, "Sair mesmo assim", e sai do mesmo jeito', (tester) async { + backend = FakeAcsBackend()..syncFailure = const BackendFailure('Sem conexão com o servidor.'); + fila = OfflineVisitQueue(synchronizer: BackendVisitSynchronizer(backend: backend, ownerId: seedAcsId)); + await fila.add(OfflineVisitRecord(localId: 'local-1', patientId: seedPatientId, risk: 'red', status: 'PENDENTE')); + await fila.add(OfflineVisitRecord(localId: 'local-2', patientId: seedPatientId, risk: 'green', status: 'PENDENTE')); + await abrirApp(tester, backend); + await entrarComSenha(tester); + // Nenhum alerta na fila: o "Sair mesmo assim" vem só das visitas. + await abrirSair(tester); + + expect(backend.callLog, ['syncVisits'], reason: 'tentou enviar antes'); + expect( + find.text('2 visitas ainda não enviadas continuam neste aparelho. Elas seguem protegidas e sobem sozinhas quando o aplicativo estiver aberto com conexão (em até 7 dias).'), + findsOneWidget, + ); + expect(find.byKey(const Key('logout_pending_alerts')), findsNothing); + expect(find.descendant(of: find.byKey(const Key('logout_confirm')), matching: find.text('Sair mesmo assim')), + findsOneWidget); + + await tester.tap(find.byKey(const Key('logout_confirm'))); + await assentar(tester); + + expect(backend.callLog, ['syncVisits', 'logout'], reason: 'a falha de envio não bloqueia a saída'); + expect(fila.pendingCount, 2, reason: 'nada se perde'); + expect(formulario(), findsOneWidget); + }); + + testWidgets('"Sair" com 1 alerta não confirmado usa o singular', (tester) async { + await abrirPainel(tester); + await abrirSair(tester); + expect( + find.text('Há 1 alerta ainda não confirmado. Ele sai deste aparelho, mas continua pendente no servidor.'), + findsOneWidget, + ); + }); + }); + + group('"Sair" com a fila de cada dono (armazenamento do app)', () { + const acsA = 'acs-a'; + late InMemoryVisitStorage storage; + late FakeAcsBackend backend; + late MemoryUploadTokenStore tokens; + + OfflineVisitRecord pendente(String localId) => + OfflineVisitRecord(localId: localId, patientId: seedPatientId, risk: 'red', status: 'PENDENTE'); + + setUp(() async { + storage = InMemoryVisitStorage(); + await storage.forOwner(acsA).save([pendente('local-a1')]); + backend = FakeAcsBackend()..nextUserId = acsA; + tokens = MemoryUploadTokenStore({acsA: 'upload-a'}); + }); + + Future abrirApp(WidgetTester tester) async { + await tester.pumpWidget(SinalAcsApp( + backend: backend, + visitStorage: storage, + uploadTokens: tokens, + deviceIds: MemoryDeviceIdStore('aparelho-sintetico'), + feedBuilder: (q) => FakeAlertFeed(q), + )); + await assentar(tester); + } + + Future pendentesNaTela(WidgetTester tester) async { + await irParaDaBarra(tester, 'Visita'); + final contador = find.byKey(const Key('pending_visits_count')); + await tester.ensureVisible(contador); + await tester.pump(); + return tester.widget(contador).data!; + } + + Future sair(WidgetTester tester) async { + await irParaDoMais(tester, 'Preferências'); + final botao = find.byKey(const Key('logout_button')); + await tester.ensureVisible(botao); + await tester.pump(); + await tester.tap(botao); + await assentar(tester); + await tester.tap(find.byKey(const Key('logout_confirm'))); + await assentar(tester); + expect(find.byKey(const Key('login_button')), findsOneWidget); + } + + testWidgets('depois do "Sair", o próximo login do MESMO ACS relê a fila do disco', (tester) async { + backend + ..syncFailure = const BackendFailure('Sem conexão com o servidor.') + ..deferredFailure = const BackendFailure('Sem conexão com o servidor.'); + await abrirApp(tester); + await entrarComSenha(tester); + expect(await pendentesNaTela(tester), 'Pendentes de sincronização: 1'); + + await sair(tester); + expect(await tokens.read(acsA), 'upload-a', reason: 'D7: o token do dono fica enquanto houver visita dele'); + + // Fora do painel, o envio diferido subiu a visita e a tirou do disco. + await storage.forOwner(acsA).save([]); + + await entrarComSenha(tester); + expect(await pendentesNaTela(tester), 'Pendentes de sincronização: 0', + reason: 'a fila memoizada de antes do Sair mostraria a visita que já saiu'); + }); + + testWidgets('"Sair" com tudo enviado: a fila do dono zera e o token de envio dele é revogado', (tester) async { + await abrirApp(tester); + await entrarComSenha(tester); + await sair(tester); + + expect(await storage.forOwner(acsA).load(), isEmpty); + expect(backend.callLog.indexOf('syncVisits'), lessThan(backend.callLog.indexOf('logout'))); + expect(backend.revokedUploadTokens, ['upload-a']); + expect(await tokens.read(acsA), isNull); + }); + }); +} diff --git a/apps/acs/test/session_store_wiring_test.dart b/apps/acs/test/session_store_wiring_test.dart new file mode 100644 index 00000000..c90f1f66 --- /dev/null +++ b/apps/acs/test/session_store_wiring_test.dart @@ -0,0 +1,40 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +/// O padrão do `BackendClient` guarda o refresh token só em memória, porque +/// `backend_client.dart` roda também na VM do Dart (`tool/live_check.dart`), +/// onde o Keystore (`flutter_secure_storage` → `dart:ui`) não compila. Quem liga +/// o Keystore é o `main.dart`; sem ele o app perderia a sessão a cada partida +/// a frio e a retomada por digital nunca aconteceria. Estas guardas impedem que +/// as duas pontas se percam sem ninguém notar. +void main() { + test('o main.dart liga o refresh token e o id do aparelho ao Keystore', () { + final fonte = File('lib/main.dart').readAsStringSync(); + expect(fonte, contains('tokenStore: AuthBoundSessionTokenStore(')); + expect(fonte, contains('vault: MethodChannelKeystoreVault()')); + expect(fonte, contains('legacy: SecureStorageSessionTokenStore()')); + expect(fonte, contains('final deviceIds = SecureStorageDeviceIdStore();')); + expect(fonte, contains('deviceIds: deviceIds')); + }); + + test('o main.dart liga o token de envio diferido ao Keystore, no cliente E no app', () { + final fonte = File('lib/main.dart').readAsStringSync(); + expect(fonte, contains('final uploadTokens = SecureStorageUploadTokenStore();')); + expect(RegExp(r'uploadTokens: uploadTokens').allMatches(fonte), hasLength(2)); + expect(fonte, contains('connectivityChanges: Connectivity()')); + }); + + test('o caminho importado pelo backend_client não puxa o Flutter', () { + for (final arquivo in [ + 'lib/core/security/session_token_store.dart', + 'lib/core/security/upload_token_store.dart', + 'lib/core/network/backend_client.dart', + ]) { + final imports = File(arquivo).readAsLinesSync().where((l) => l.startsWith('import ')).join('\n'); + expect(imports, isNot(contains('package:flutter')), reason: arquivo); + expect(imports, isNot(contains('secure_session_token_store.dart')), reason: arquivo); + expect(imports, isNot(contains('secure_upload_token_store.dart')), reason: arquivo); + } + }); +} diff --git a/apps/acs/test/shell_dispose_test.dart b/apps/acs/test/shell_dispose_test.dart new file mode 100644 index 00000000..26d7fe8f --- /dev/null +++ b/apps/acs/test/shell_dispose_test.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/app/app.dart'; + +import 'support/fakes.dart'; + +/// Feed que avisa a desconexão ao parar, como o `MqttAlertFeed` real faz: +/// `stop()` → `MqttClient.disconnect` → `onConnectionChanged(false)`. +class _FeedQueAvisaAoParar extends FakeAlertFeed { + _FeedQueAvisaAoParar(super.queue); + + @override + void stop() { + super.stop(); + onConnectionChanged?.call(false); + } +} + +void main() { + testWidgets('desmontar o painel não chama setState nele mesmo ao parar o feed', (tester) async { + await tester.pumpWidget(SinalAcsApp( + backend: FakeAcsBackend(), + feedBuilder: (queue) => _FeedQueAvisaAoParar(queue), + )); + await tester.enterText(find.byKey(const Key('matricula_field')), 'ACS-001'); + await tester.enterText(find.byKey(const Key('senha_field')), 'senha-sintetica'); + await tester.tap(find.byKey(const Key('login_button'))); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(find.textContaining('ACS •'), findsOneWidget, reason: 'o painel precisa estar aberto'); + + await tester.pumpWidget(const SizedBox.shrink()); + + expect(tester.takeException(), isNull); + }); +} diff --git a/apps/acs/test/support/counting_session_token_store.dart b/apps/acs/test/support/counting_session_token_store.dart new file mode 100644 index 00000000..4fed739d --- /dev/null +++ b/apps/acs/test/support/counting_session_token_store.dart @@ -0,0 +1,14 @@ +import 'package:sinalacs_acs/core/security/session_token_store.dart'; + +/// MemorySessionTokenStore que conta quantas vezes `read()` foi chamado. +class CountingSessionTokenStore extends MemorySessionTokenStore { + CountingSessionTokenStore([super.token]); + + int reads = 0; + + @override + Future read() { + reads++; + return super.read(); + } +} diff --git a/apps/acs/test/support/fake_rpc_server.dart b/apps/acs/test/support/fake_rpc_server.dart index 911115c6..c8adffa7 100644 --- a/apps/acs/test/support/fake_rpc_server.dart +++ b/apps/acs/test/support/fake_rpc_server.dart @@ -53,6 +53,84 @@ class FakeRpcServer { /// formato que o backend real usa (`AuthenticationFailedException`). String? rejectWith; + /// Simula um ACS com MFA ativa: `loginInstitutional` sem `totpCode` recusa + /// com `MfaRequiredException`, como o backend real. + bool mfaRequired = false; + + /// Faz `generateEnrollmentToken` recusar como o backend recusa paciente de + /// outra microárea (`AlertPermissionException`, INV-01). + bool rejectInviteWithPermission = false; + + /// Contador do refresh token rotativo: cada renovação emite o próximo + /// `refresh-`; um login zera o contador. + int _refreshSeq = 0; + + /// Refresh tokens emitidos pelos logins, na ordem. O primeiro login emite + /// `refresh-0` (os testes antigos dependem do nome); os seguintes emitem + /// `login-`, distintos entre si — é o que deixa um teste saber se o token + /// guardado é o do login novo ou o de uma renovação da conta anterior. + final List loginTokens = []; + + String _nextLoginToken() { + _refreshSeq = 0; + final n = loginTokens.length; + final token = n == 0 ? 'refresh-0' : 'login-$n'; + loginTokens.add(token); + return token; + } + + /// Faz `refreshSession` recusar com `SessionExpiredException`. + bool rejectRefresh = false; + + /// Atraso antes de responder `refreshSession` (provoca concorrência). + Duration refreshDelay = Duration.zero; + + /// Derruba a conexão na próxima `refreshSession` (resposta perdida): o + /// servidor processa o pedido, mas o cliente não recebe nada. + bool failRefreshOnce = false; + + /// Faz o login responder sem `refreshToken` (cliente sem deviceId). + bool omitRefreshToken = false; + + /// Tokens de envio diferido emitidos pelos logins, na ordem (D7). Só com + /// `deviceId` real — nem em branco, nem o sentinela do servidor. + final List uploadTokensIssued = []; + + /// Faz o login responder sem `uploadToken`. + bool omitUploadToken = false; + + /// Tokens de envio que `syncDeferred` recusa com `SessionExpiredException`. + final Set refusedUploadTokens = {}; + + /// Tokens que `revokeUploadToken` recebeu. + final List revokedUploadTokens = []; + + String? _nextUploadToken(Map decoded) { + final deviceId = (decoded['deviceId'] as String?)?.trim() ?? ''; + if (omitUploadToken || deviceId.isEmpty || deviceId == 'nao-aplicavel-login-institucional') return null; + final token = 'upload-${uploadTokensIssued.length}'; + uploadTokensIssued.add(token); + return token; + } + + /// `refreshToken` recebido em cada `refreshSession`, na ordem. + List get refreshTokensSeen => requests + .where((r) => r.method == 'refreshSession') + .map((r) => r.args['refreshToken'] as String) + .toList(); + + /// Faz `refreshSession` devolver um accessToken ilegível. + bool garbageAccessToken = false; + + /// Faz `refreshSession` devolver um refresh token vazio. + bool emptyRefreshToken = false; + + /// Tokens que `logout` recebeu. + final List loggedOut = []; + + /// Atraso antes de responder `logout` (rede lenta na saída). + Duration logoutDelay = Duration.zero; + /// Endereço para passar a `BackendClient(host: ...)`. Porta efêmera do SO: /// dois testes em paralelo não brigam por porta. String get host => 'http://127.0.0.1:${_server.port}/'; @@ -60,6 +138,9 @@ class FakeRpcServer { int get loginCount => requests.where((r) => r.method == 'loginInstitutional').length; + int get refreshCount => + requests.where((r) => r.method == 'refreshSession').length; + static Future start() async { final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); final fake = FakeRpcServer._(server); @@ -89,14 +170,91 @@ class FakeRpcServer { return; } + if (method == 'loginInstitutional' && mfaRequired && decoded['totpCode'] == null) { + await _respond(request, HttpStatus.badRequest, { + 'className': 'MfaRequiredException', + 'data': {'message': 'Informe o código do autenticador.'}, + }); + return; + } + + if (method == 'refreshSession') { + if (refreshDelay > Duration.zero) await Future.delayed(refreshDelay); + if (failRefreshOnce) { + failRefreshOnce = false; + _refreshSeq++; // o servidor rotacionou, mas a resposta se perdeu + final socket = await request.response.detachSocket(writeHeaders: false); + socket.destroy(); + return; + } + if (rejectRefresh) { + await _respond(request, HttpStatus.badRequest, { + 'className': 'SessionExpiredException', + 'data': {'message': 'Sessão expirada.'}, + }); + return; + } + } + + if (method == 'logout') { + loggedOut.add(decoded['refreshToken'] as String? ?? ''); + if (logoutDelay > Duration.zero) await Future.delayed(logoutDelay); + await _respond(request, HttpStatus.ok, null); + return; + } + + if (method == 'syncDeferred' && refusedUploadTokens.contains(decoded['uploadToken'])) { + await _respond(request, HttpStatus.badRequest, { + 'className': 'SessionExpiredException', + 'data': {'message': 'Envio não autorizado. Entre novamente.'}, + }); + return; + } + + if (method == 'revokeUploadToken') { + revokedUploadTokens.add(decoded['uploadToken'] as String? ?? ''); + await _respond(request, HttpStatus.ok, null); + return; + } + + if (method == 'generateEnrollmentToken' && rejectInviteWithPermission) { + await _respond(request, HttpStatus.badRequest, { + 'className': 'AlertPermissionException', + 'data': {'message': 'Paciente fora da microárea do ACS.'}, + }); + return; + } + final payload = switch (method) { - 'loginInstitutional' || 'developmentLogin' => { + 'loginInstitutional' => { + 'accessToken': _token(), + 'tokenType': 'Bearer', + if (!omitRefreshToken && ((decoded['deviceId'] as String?)?.trim().isNotEmpty ?? false)) 'refreshToken': _nextLoginToken(), + if (_nextUploadToken(decoded) case final upload?) 'uploadToken': upload, + }, + // `visits.syncLegacy`/`syncDeferred`: tudo `synced`, mesma versão. + 'syncLegacy' || 'syncDeferred' => [ + for (final visit in (decoded['visits'] as List).cast>()) + {'localId': visit['localId'], 'syncStatus': 'synced', 'serverVersion': visit['version']}, + ], + 'refreshSession' => { + 'accessToken': garbageAccessToken ? 'lixo' : _token(), + 'tokenType': 'Bearer', + 'refreshToken': emptyRefreshToken ? '' : 'refresh-${++_refreshSeq}', + }, + 'developmentLogin' => { 'accessToken': _token(), 'tokenType': 'Bearer', }, // Corpo de `patients.listMicroArea`: uma lista vazia basta para o teste // de renovação — o que importa é que a chamada autenticada aconteceu. 'listMicroArea' => const [], + // Corpo de `onboarding.generateEnrollmentToken`. Token sintético: o real + // tem 43 caracteres base64url, mas o cliente não valida o formato. + 'generateEnrollmentToken' => { + 'token': 'convite-sintetico', + 'expiresAt': '2026-09-29T10:15:00.000Z', + }, _ => null, }; diff --git a/apps/acs/test/support/fakes.dart b/apps/acs/test/support/fakes.dart index 40947642..4b9e1346 100644 --- a/apps/acs/test/support/fakes.dart +++ b/apps/acs/test/support/fakes.dart @@ -1,5 +1,8 @@ +import 'dart:async'; import 'package:sinalacs_acs/core/network/auth_session.dart'; import 'package:sinalacs_acs/core/network/backend_client.dart'; +import 'package:sinalacs_acs/core/security/biometric_gate.dart'; +import 'package:sinalacs_acs/core/security/session_token_store.dart'; import 'package:sinalacs_acs/core/services/alert_feed.dart'; import 'package:sinalacs_acs/core/services/alert_queue.dart'; import 'package:sinalacs_acs/core/services/offline_visit_queue.dart'; @@ -45,6 +48,10 @@ class FakeAcsBackend implements AcsBackend { @override AuthSession? get session => _session; + /// Troca a sessão corrente sem passar pelo login (ex.: outro ACS entrou + /// enquanto o lote de um anterior estava em voo). + set session(AuthSession? value) => _session = value; + @override bool get isAuthenticated => _session != null; @@ -54,14 +61,100 @@ class FakeAcsBackend implements AcsBackend { /// branco não chamou nada". ({String matricula, String senha})? lastCredentials; + @override + void Function()? onSessionExpired; + + /// Refresh token "no Keystore". O login por senha grava um novo; a recusa + /// e o logout apagam; a falta de rede mantém — como o `BackendClient`. + String? storedRefreshToken; + int _refreshSeq = 0; + + /// Retomada sem rede: devolve `null` e MANTÉM o token. + bool resumeOffline = false; + + /// Servidor recusa o refresh token: devolve `null` e APAGA o token. + bool rejectResume = false; + + int resumeCount = 0; + int logoutCount = 0; + + /// Ordem das chamadas que importam ao "Sair" e ao "Limpar este aparelho": + /// `syncVisits`, `syncLegacy`, `syncDeferred`, `revokeUploadToken`, `logout`. + final List callLog = []; + + @override + Future get hasStoredSession async => storedRefreshToken != null; + + @override + Future unlockStoredSession({required String reason}) async => SessionUnlock.notRequired; + + @override + Future resumeSession() async { + resumeCount++; + if (storedRefreshToken == null || resumeOffline) return null; + if (rejectResume) { + storedRefreshToken = null; + return null; + } + storedRefreshToken = 'refresh-${++_refreshSeq}'; + return _issueSession(); + } + + /// Se definido, `logout()` espera por ele (rede lenta). + Completer? logoutGate; + + @override + Future logout() async { + logoutCount++; + callLog.add('logout'); + await logoutGate?.future; + storedRefreshToken = null; + _session = null; + } + + /// Simula o servidor com MFA ativa: sem [totpCode] igual a [expectedTotpCode], levanta [MfaCodeRequired]. + String? expectedTotpCode; + bool mfaEnrollmentRequired = false; + String? lastTotpCode; + TotpEnrollmentStart enrollmentStart = + TotpEnrollmentStart(secretBase32: 'GEZDGNBVGY3TQOJQ', otpauthUri: 'otpauth://totp/SinalACS:ACS-001?secret=GEZDGNBVGY3TQOJQ'); + String? confirmedCode; + + @override + Future confirmTotpEnrollment({required String matricula, required String senha, required String code}) async { + confirmedCode = code; + } + + @override + Future beginTotpEnrollment({required String matricula, required String senha}) async { + if (enrollmentFailuresLeft > 0) { + enrollmentFailuresLeft--; + throw const BackendFailure('Sem conexão com o servidor.'); + } + return enrollmentStart; + } + + /// Quantas chamadas a [beginTotpEnrollment] ainda falham antes de funcionar. + int enrollmentFailuresLeft = 0; + @override Future login({ required String matricula, required String senha, + String? totpCode, }) async { loginCount++; lastCredentials = (matricula: matricula, senha: senha); - return _issueSession(); + lastTotpCode = totpCode; + if (sessionExpired && expectedTotpCode != null && totpCode == null) throw const MfaCodeRequired(); + if (mfaEnrollmentRequired) throw const MfaEnrollmentRequired(); + if (expectedTotpCode != null && totpCode != expectedTotpCode) { + throw totpCode == null ? const MfaCodeRequired() : const BackendFailure('Código de verificação inválido.', isRecoverable: false); + } + sessionExpired = false; + final session = await _issueSession(); + storedRefreshToken = 'refresh-${++_refreshSeq}'; + return session; } /// Ferramenta de desenvolvimento (`tool/`, `integration_test/`). Sem @@ -78,7 +171,7 @@ class FakeAcsBackend implements AcsBackend { final session = AuthSession( accessToken: 'token-de-teste', tokenType: 'Bearer', - userId: seedAcsId, + userId: nextUserId, role: 'acs', microAreaId: microAreaId, expiresAt: DateTime.now().toUtc().add(const Duration(minutes: 15)), @@ -87,8 +180,29 @@ class FakeAcsBackend implements AcsBackend { return session; } + /// Sessão vencida com MFA: toda chamada autenticada falha como o + /// `BackendClient` real (não recuperável) e avisa a UI uma única vez. + bool sessionExpired = false; + + /// `userId` da próxima sessão emitida (padrão: o ACS do seed). + String nextUserId = seedAcsId; + + /// Vence a sessão agora, como a renovação que recebeu `MfaRequiredException`. + void expireSession() { + sessionExpired = true; + _session = null; + onSessionExpired?.call(); + } + @override Future acknowledge({required String alertId}) async { + if (sessionExpired) { + onSessionExpired?.call(); + throw const BackendFailure( + 'Sua sessão expirou. Entre novamente com o código do autenticador.', + isRecoverable: false, + ); + } acknowledgedAlertIds.add(alertId); return AlertAckResult( alertId: alertId, @@ -102,14 +216,21 @@ class FakeAcsBackend implements AcsBackend { /// Falha da chamada inteira, como uma queda de rede. BackendFailure? syncFailure; + /// Se definido, `syncVisits` espera por ele (lote no servidor, rede lenta). + Completer? syncGate; + /// Resultado por visita; ausente significa `synced`. VisitSyncResult Function(VisitSyncEntry entry)? syncResultFor; @override - Future> syncVisits(List visits) async { + Future> syncVisits(List visits, {String? expectedUserId}) async { + // Como o BackendClient: a conferência do dono vem antes de qualquer envio. + if (expectedUserId != null && _session?.userId != expectedUserId) throw sessionOwnerMismatch; + callLog.add('syncVisits'); syncedVisitBatches.add(List.of(visits)); final failure = syncFailure; if (failure != null) throw failure; + await syncGate?.future; final custom = syncResultFor; if (custom != null) return [for (final visit in visits) custom(visit)]; @@ -124,6 +245,98 @@ class FakeAcsBackend implements AcsBackend { ]; } + /// Lotes de `syncLegacyVisits`, com o `userId` da sessão que transportou. + final List<({String? transportUserId, String deviceId, List visits})> legacyBatches = []; + + /// Falha da chamada legada inteira, como uma queda de rede. + BackendFailure? legacyFailure; + + /// Resultado por visita legada; ausente significa `synced`. + VisitSyncResult Function(VisitSyncEntry entry)? legacyResultFor; + + @override + Future> syncLegacyVisits(List visits, {required String deviceId}) async { + // Como o BackendClient: sem sessão não há transporte. + final transport = _session; + if (transport == null) throw const BackendFailure('Sessão não iniciada.', isRecoverable: false); + if (visits.length > 200) throw const BackendFailure('lote acima do limite', isRecoverable: false); + callLog.add('syncLegacy'); + legacyBatches.add((transportUserId: transport.userId, deviceId: deviceId, visits: List.of(visits))); + final failure = legacyFailure; + if (failure != null) throw failure; + return [for (final visit in visits) legacyResultFor?.call(visit) ?? _synced(visit)]; + } + + /// Lotes de `syncDeferredVisits`, com o token de envio usado em cada um. + final List<({String uploadToken, String deviceId, List visits})> deferredBatches = []; + + /// Falha da chamada diferida inteira, como uma queda de rede. + BackendFailure? deferredFailure; + + /// Tokens de envio que o "servidor" recusa (vencidos/revogados). + final Set refusedUploadTokens = {}; + + /// Resultado por visita diferida; ausente significa `synced`. + VisitSyncResult Function(VisitSyncEntry entry)? deferredResultFor; + + /// Se definido, `syncDeferredVisits` espera por ele (lote em voo). + Completer? deferredGate; + + @override + Future> syncDeferredVisits({ + required String uploadToken, + required String deviceId, + required List visits, + }) async { + if (visits.length > 200) throw const BackendFailure('lote acima do limite', isRecoverable: false); + callLog.add('syncDeferred'); + deferredBatches.add((uploadToken: uploadToken, deviceId: deviceId, visits: List.of(visits))); + // A recusa é decidida quando o servidor responde (depois da espera): o + // token pode ter sido revogado enquanto o lote estava em voo. + await deferredGate?.future; + if (refusedUploadTokens.contains(uploadToken)) throw const UploadTokenRefused(); + final failure = deferredFailure; + if (failure != null) throw failure; + return [for (final visit in visits) deferredResultFor?.call(visit) ?? _synced(visit)]; + } + + /// Tokens de envio revogados, na ordem. + final List revokedUploadTokens = []; + + /// Se definido, `revokeUploadToken` espera por ele (revogação em voo). + Completer? revokeGate; + + @override + Future revokeUploadToken(String uploadToken) async { + callLog.add('revokeUploadToken'); + revokedUploadTokens.add(uploadToken); + await revokeGate?.future; + } + + /// Reenvio do mesmo `localId` na mesma versão volta `synced` (idempotente). + static VisitSyncResult _synced(VisitSyncEntry visit) => + VisitSyncResult(localId: visit.localId, syncStatus: SyncStatus.synced, serverVersion: visit.version); + + /// Contato que `ubsContact()` devolve; `UbsContact(name: ...)` sem telefone simula uma UBS sem número. + UbsContact ubsContactResult = UbsContact(name: 'UBS Teste', phone: '+55 11 5550-0100'); + + /// Falha da chamada, como uma queda de rede. + BackendFailure? ubsContactFailure; + + int ubsContactCount = 0; + + /// Se definido, `ubsContact()` espera por ele (simula rede lenta). + Completer? ubsContactGate; + + @override + Future ubsContact() async { + ubsContactCount++; + await ubsContactGate?.future; + final falha = ubsContactFailure; + if (falha != null) throw falha; + return ubsContactResult; + } + /// Falha não classificada (não é `BackendFailure`), para exercitar o ramo /// `catch (error, ...)` genérico de `_loadMicroAreaPatients` em `app.dart` — /// algo que nenhum `BackendFailure` simula. Checada antes de @@ -140,6 +353,57 @@ class FakeAcsBackend implements AcsBackend { return patients; } + /// patientIds pedidos em `generateInvite`, na ordem. + final List inviteCalls = []; + + /// Falha da geração do convite, como paciente fora da microárea. + BackendFailure? inviteFailure; + + /// Quando definido, `generateInvite` só responde depois que ele completa — + /// simula a rede lenta de campo. + Completer? inviteGate; + + /// Validade do convite devolvido por `generateInvite`, contada de agora. + Duration inviteLifetime = const Duration(minutes: 15); + + @override + Future generateInvite({required String patientId}) async { + inviteCalls.add(patientId); + await inviteGate?.future; + final failure = inviteFailure; + if (failure != null) throw failure; + return EnrollmentTokenResult( + token: 'convite-sintetico-${inviteCalls.length}', + expiresAt: DateTime.now().toUtc().add(inviteLifetime), + ); + } + + /// Avisos pedidos em `sendNotice`, na ordem: (título, mensagem, só crônicos). + final List<(String, String, bool)> notices = <(String, String, bool)>[]; + + /// Falha do envio, como o relé de push fora do ar. + BackendFailure? noticeFailure; + + /// Quando definido, `sendNotice` só responde depois que ele completa — + /// simula a rede lenta de campo. + Completer? noticeGate; + + /// Resultado devolvido por `sendNotice`. + NoticeSendResult noticeResult = NoticeSendResult(recipients: 3, accepted: 2); + + @override + Future sendNotice({ + required String title, + required String message, + required bool chronicOnly, + }) async { + notices.add((title, message, chronicOnly)); + await noticeGate?.future; + final failure = noticeFailure; + if (failure != null) throw failure; + return noticeResult; + } + /// Entradas que `pullVisits` devolve. Vazio por padrão. List pullEntries = const []; @@ -155,9 +419,13 @@ class FakeAcsBackend implements AcsBackend { /// o cursor lido é exatamente o que chega ao backend. final List pullSinceCalls = []; + /// Se definido, `pullVisits` espera por ele (pull em voo). + Completer? pullGate; + @override Future> pullVisits({required DateTime since}) async { pullSinceCalls.add(since); + await pullGate?.future; final unclassified = pullUnclassifiedFailure; if (unclassified != null) throw unclassified; final failure = pullFailure; @@ -299,8 +567,10 @@ PrioritizedAlert testAlert({ // "sem GPS no paciente", que é o estado mais comum e não deve fabricar // marcador nenhum no mapa. String? locationCell, + bool escalated = false, }) { return PrioritizedAlert( + escalated: escalated, alertId: alertId, patientId: seedPatientId, microAreaId: microAreaId, @@ -310,3 +580,28 @@ PrioritizedAlert testAlert({ triggeredAt: triggeredAt ?? DateTime.utc(2026, 9, 11, 12), ); } + +/// Desbloqueio local controlado pelo teste. +class FakeBiometricGate implements BiometricGate { + FakeBiometricGate({this.available = true, this.result = UnlockResult.unlocked}); + + bool available; + UnlockResult result; + int calls = 0; + final List reasons = []; + + /// Se definido, `authenticate` só responde quando ele completar. + Completer? pending; + + @override + Future get isAvailable async => available; + + @override + Future authenticate({required String reason}) async { + calls++; + reasons.add(reason); + final wait = pending; + if (wait != null) return wait.future; + return result; + } +} diff --git a/apps/acs/test/support/gated_session_token_store.dart b/apps/acs/test/support/gated_session_token_store.dart new file mode 100644 index 00000000..e7434d13 --- /dev/null +++ b/apps/acs/test/support/gated_session_token_store.dart @@ -0,0 +1,34 @@ +import 'dart:async'; + +import 'package:sinalacs_acs/core/security/session_token_store.dart'; + +/// Memória com uma comporta na escrita: enquanto [writeGate] não completa, o +/// `write` fica pendente. Serve para parar o login exatamente na janela entre +/// o incremento da época e a troca da sessão. +class GatedSessionTokenStore implements SessionTokenStore { + GatedSessionTokenStore([String? token]) : _inner = MemorySessionTokenStore(token); + + final MemorySessionTokenStore _inner; + Completer? writeGate; + int writes = 0; + + @override + Future read() => _inner.read(); + + @override + Future write(String token) async { + writes++; + final gate = writeGate; + if (gate != null) await gate.future; + await _inner.write(token); + } + + @override + Future clear() => _inner.clear(); + + @override + Future contains() => _inner.contains(); + + @override + Future unlock({required String reason}) => _inner.unlock(reason: reason); +} diff --git a/apps/acs/test/support/layout_harness.dart b/apps/acs/test/support/layout_harness.dart new file mode 100644 index 00000000..a0736bf8 --- /dev/null +++ b/apps/acs/test/support/layout_harness.dart @@ -0,0 +1,162 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/app/app.dart'; +import 'package:sinalacs_acs/core/security/biometric_gate.dart'; + +import 'fakes.dart'; + +/// Ferramentas para provar que as telas do ACS cabem na janela em que foram +/// postas. Mesmo método do `apps/admin/test/support/layout_harness.dart`, e as +/// mesmas três armadilhas — errar qualquer uma produz um teste que passa +/// sempre: +/// +/// 1. `RenderFlex` só denuncia o estouro quando **pinta**; checar antes de +/// assentar os quadros não vê nada. +/// 2. Item de `ListView` fora da viewport não é construído e nunca reclama; +/// daí [percorrerTelaInteira]. +/// 3. `takeException()` consome **uma** exceção por chamada; [esperarSemEstouro] +/// esvazia todas. +/// +/// Uma quarta é só do ACS: `pumpAndSettle()` **nunca assenta** na aba Área, porque +/// o spinner do pull anima sem parar contra o fake. [assentar] alterna quadros +/// e tempo real, como o `settleRealAsync` de `login_flow_test.dart`. + +const destinosDaBarra = ['Área', 'Fila', 'Mapa', 'Visita']; + +/// Itens da folha "Mais", pelo rótulo que `_moreItem` mostra. +const itensDoMais = [ + 'Acionamento', + 'Geofencing', + 'Avisos à comunidade', + 'Convidar paciente', + 'Preferências', +]; + +/// Troca o tamanho da janela de uma sessão já aberta. [tamanho] é em dp, porque +/// `devicePixelRatio` é forçado a 1. +void redimensionar(WidgetTester tester, Size tamanho, {double escalaDeFonte = 1.0}) { + tester.view.devicePixelRatio = 1.0; + tester.view.physicalSize = tamanho; + tester.platformDispatcher.textScaleFactorTestValue = escalaDeFonte; +} + +Future assentar(WidgetTester tester) async { + for (var i = 0; i < 30; i++) { + await tester.pump(const Duration(milliseconds: 16)); + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 20))); + } + await tester.pump(); +} + +/// Falha se algum quadro pintado desde a última checagem reportou estouro. +void esperarSemEstouro(WidgetTester tester, String contexto) { + final erros = []; + Object? erro; + while ((erro = tester.takeException()) != null) { + erros.add(erro.toString().split('\n').first); + } + expect(erros, isEmpty, reason: 'estouro de layout em $contexto: ${erros.join(' | ')}'); +} + +/// Abre o painel já logado (login pela tela, contra o `FakeAcsBackend`), numa +/// janela de tamanho e escala de fonte fixos. Confere a tela de login ANTES de +/// entrar: ela também tem cabeçalho. +/// +/// Devolve o feed fake, para o teste entregar um alerta de outro risco depois do +/// login. Os cartões da Fila só são construídos quando rolam até a viewport: use +/// [rolarAte] para chegar neles, não `ensureVisible`. +Future abrirPainel( + WidgetTester tester, { + required Size tamanho, + double escalaDeFonte = 1.0, + BiometricGate? biometricGate, +}) async { + redimensionar(tester, tamanho, escalaDeFonte: escalaDeFonte); + addTearDown(tester.view.reset); + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + + final alerta = testAlert(alertId: 'escala-1', locationCell: testLocationCell); + late FakeAlertFeed feed; + await tester.pumpWidget(SinalAcsApp( + backend: FakeAcsBackend(), + biometricGate: biometricGate, + feedBuilder: (queue) { + queue.upsert(alerta); // `initialAlert` só seleciona; a tela lê a fila + return feed = FakeAlertFeed(queue); + }, + )); + await tester.pump(); + esperarSemEstouro(tester, 'tela de login'); + + await tester.enterText(find.byKey(const Key('matricula_field')), 'ACS-001'); + await tester.enterText(find.byKey(const Key('senha_field')), 'senha-sintetica'); + // No aparelho o `enterText` abre o teclado de verdade, e ele come ~300 dp da + // janela: a medida de layout seria a de "com teclado". Larga o foco e espera + // o sistema recolhê-lo (tempo real; na VM não há teclado e isso é só um quadro). + FocusManager.instance.primaryFocus?.unfocus(); + await assentar(tester); + final entrar = find.byKey(const Key('login_button')); + await tester.ensureVisible(entrar); // numa janela baixa o botão fica abaixo da dobra + // Em paisagem a rolagem ainda anima depois do primeiro quadro e o botão não + // recebe toque nesse intervalo; a tela de login assenta (a aba Área é que não). + await tester.pumpAndSettle(); + await tester.tap(entrar); + await assentar(tester); + return feed; +} + +/// Rola a tela até o fim, checando estouro a cada passo. +Future percorrerTelaInteira(WidgetTester tester, String contexto) async { + esperarSemEstouro(tester, '$contexto (topo)'); + // `TextField` também tem um `Scrollable` (restorationId 'editable'), e `.last` + // o escolhia nas abas com campo de texto: o arrasto caía fora da tela e a + // rolagem não acontecia, esvaziando a varredura justo na aba Visita. + final rolaveis = find.byWidgetPredicate((w) => w is Scrollable && w.restorationId != 'editable'); + if (rolaveis.evaluate().isEmpty) return; // ex.: o mapa não rola + for (var passo = 1; passo <= 8; passo++) { + await tester.drag(rolaveis.last, const Offset(0, -320)); + await assentar(tester); + esperarSemEstouro(tester, '$contexto (rolagem $passo)'); + } +} + +/// Rola a página até [alvo] existir e ficar visível. `ensureVisible` não serve +/// para item de lista preguiçosa: fora da viewport ele ainda não foi construído +/// e o finder não acha nada (`Bad state: No element`). +Future rolarAte(WidgetTester tester, Finder alvo) async { + final pagina = find.byWidgetPredicate((w) => w is Scrollable && w.restorationId != 'editable').first; + await tester.scrollUntilVisible(alvo, 200, scrollable: pagina, maxScrolls: 60); + await assentar(tester); +} + +Future irParaDaBarra(WidgetTester tester, String destino) async { + final alvo = find.descendant(of: find.byType(NavigationBar), matching: find.text(destino)); + await tester.tap(alvo); + await assentar(tester); + esperarSemEstouro(tester, 'navegação para $destino'); +} + +Future irParaDoMais(WidgetTester tester, String item) async { + final mais = find.descendant(of: find.byType(NavigationBar), matching: find.text('Mais')); + await tester.tap(mais); + await assentar(tester); + esperarSemEstouro(tester, 'folha "Mais"'); + final alvo = find.text(item); + await tester.ensureVisible(alvo); + await tester.pump(); + await tester.tap(alvo); + await assentar(tester); + esperarSemEstouro(tester, 'navegação para $item'); +} + +/// Visita os quatro destinos da barra e os cinco itens de "Mais". +Future percorrerPainelInteiro(WidgetTester tester, String contexto) async { + for (final destino in destinosDaBarra) { + await irParaDaBarra(tester, destino); + await percorrerTelaInteira(tester, '$contexto / $destino'); + } + for (final item in itensDoMais) { + await irParaDoMais(tester, item); + await percorrerTelaInteira(tester, '$contexto / $item'); + } +} diff --git a/apps/acs/test/support/memory_micro_area_store.dart b/apps/acs/test/support/memory_micro_area_store.dart new file mode 100644 index 00000000..be753662 --- /dev/null +++ b/apps/acs/test/support/memory_micro_area_store.dart @@ -0,0 +1,42 @@ +import 'package:sinalacs_acs/core/database/micro_area_cache_store.dart'; +import 'package:sinalacs_client/sinalacs_client.dart' show MicroAreaPatient; + +/// Store em memória: o que se testa aqui é a REGRA (quando serve cache), não o SQLite. +class MemoryMicroAreaStore implements MicroAreaCacheStore { + @override + String get databaseName => 'em_memoria'; + @override + bool get allowUnencryptedForTesting => true; + + String? owner; + List? patients; + DateTime? at; + int clears = 0; + bool falhaAoGravar = false; + + @override + Future read({required String owner}) async { + if (this.owner == null) return null; + if (this.owner != owner) { + await clear(); + return null; + } + return CachedMicroArea(patients: patients!, fetchedAt: at!); + } + + @override + Future write({required String owner, required List patients, required DateTime at}) async { + if (falhaAoGravar) throw StateError('disco cheio'); + this.owner = owner; + this.patients = patients; + this.at = at; + } + + @override + Future clear() async { + clears++; + owner = null; + patients = null; + at = null; + } +} diff --git a/apps/acs/test/support/totp.dart b/apps/acs/test/support/totp.dart new file mode 100644 index 00000000..f020bd45 --- /dev/null +++ b/apps/acs/test/support/totp.dart @@ -0,0 +1,34 @@ +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; + +/// Gerador TOTP (RFC 6238, SHA-1, 6 dígitos, 30 s) SÓ para testes: a prova de +/// MFA no emulador precisa produzir o código que o autenticador produziria. +String totpCode(Uint8List secret, DateTime at) { + final passo = at.toUtc().millisecondsSinceEpoch ~/ 1000 ~/ 30; + final mensagem = ByteData(8)..setUint64(0, passo); + final h = Hmac(sha1, secret).convert(mensagem.buffer.asUint8List()).bytes; + final o = h[h.length - 1] & 0x0f; + final bin = ((h[o] & 0x7f) << 24) | (h[o + 1] << 16) | (h[o + 2] << 8) | h[o + 3]; + return (bin % 1000000).toString().padLeft(6, '0'); +} + +/// Decodifica base32 (RFC 4648, sem preenchimento) — o que o servidor entrega em `secretBase32`. +Uint8List base32Decode(String texto) { + const alfabeto = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + var bits = 0; + var valor = 0; + final saida = []; + for (final c in texto.toUpperCase().split('')) { + final i = alfabeto.indexOf(c); + if (i < 0) continue; + valor = (valor << 5) | i; + bits += 5; + if (bits >= 8) { + saida.add((valor >> (bits - 8)) & 0xff); + bits -= 8; + valor &= (1 << bits) - 1; + } + } + return Uint8List.fromList(saida); +} diff --git a/apps/acs/test/sync_cursor_store_test.dart b/apps/acs/test/sync_cursor_store_test.dart index f261a188..458c0979 100644 --- a/apps/acs/test/sync_cursor_store_test.dart +++ b/apps/acs/test/sync_cursor_store_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:sinalacs_acs/core/database/encrypted_database.dart'; +import 'package:sinalacs_acs/core/database/sqlcipher_visit_store.dart'; import 'package:sinalacs_acs/core/database/sync_cursor_store.dart'; import 'package:sinalacs_acs/core/security/database_key_store.dart'; @@ -18,6 +19,7 @@ void main() { test('sem cursor gravado, read() devolve null', () async { final store = SyncCursorStore( keyStore: InMemoryDatabaseKeyStore(), + owner: 'acs-a', databaseName: nome, allowUnencryptedForTesting: true, ); @@ -28,6 +30,7 @@ void main() { test('write grava e um read seguinte devolve o mesmo valor', () async { final store = SyncCursorStore( keyStore: InMemoryDatabaseKeyStore(), + owner: 'acs-a', databaseName: nome, allowUnencryptedForTesting: true, ); @@ -42,6 +45,7 @@ void main() { final keyStore = InMemoryDatabaseKeyStore(); final first = SyncCursorStore( keyStore: keyStore, + owner: 'acs-a', databaseName: nome, allowUnencryptedForTesting: true, ); @@ -50,6 +54,7 @@ void main() { final second = SyncCursorStore( keyStore: keyStore, + owner: 'acs-a', databaseName: nome, allowUnencryptedForTesting: true, ); @@ -60,6 +65,7 @@ void main() { test('escrever de novo substitui o cursor anterior', () async { final store = SyncCursorStore( keyStore: InMemoryDatabaseKeyStore(), + owner: 'acs-a', databaseName: nome, allowUnencryptedForTesting: true, ); @@ -69,5 +75,55 @@ void main() { expect(await store.read(), DateTime.utc(2026, 6, 1)); }); + + group('por dono', () { + late VisitDatabase db; + late SyncCursorStore cursorA; + late SyncCursorStore cursorB; + + setUp(() { + db = VisitDatabase( + keyStore: InMemoryDatabaseKeyStore(), + databaseName: nome, + allowUnencryptedForTesting: true, + ); + cursorA = SyncCursorStore.on(db, owner: 'acs-a'); + cursorB = SyncCursorStore.on(db, owner: 'acs-b'); + }); + tearDown(() => db.close()); + + test('o cursor de A nao vale para B', () async { + await cursorA.write(DateTime.utc(2026, 10, 3)); + + expect(await cursorB.read(), isNull); + expect(await cursorA.read(), DateTime.utc(2026, 10, 3)); + }); + + test('gravar o cursor de B nao altera o de A', () async { + await cursorA.write(DateTime.utc(2026, 10, 3)); + await cursorB.write(DateTime.utc(2026, 11, 1)); + + expect(await cursorA.read(), DateTime.utc(2026, 10, 3)); + expect(await cursorB.read(), DateTime.utc(2026, 11, 1)); + }); + + test('a chave global legada fica orfa: nunca e lida', () async { + final database = await db.open(); + await database.insert('sync_cursor', { + 'key': 'visits_pull', + 'value': DateTime.utc(2026, 1, 1).toIso8601String(), + }); + + expect(await cursorA.read(), isNull); + }); + + test('dono em branco e recusado', () { + expect(() => SyncCursorStore.on(db, owner: ' '), throwsA(anything)); + expect( + () => SyncCursorStore(keyStore: InMemoryDatabaseKeyStore(), owner: ''), + throwsA(anything), + ); + }); + }); }); } diff --git a/apps/acs/test/text_scale_test.dart b/apps/acs/test/text_scale_test.dart new file mode 100644 index 00000000..3f13bd32 --- /dev/null +++ b/apps/acs/test/text_scale_test.dart @@ -0,0 +1,205 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:sinalacs_acs/app/app.dart'; +import 'package:sinalacs_acs/app/mfa_enrollment_screen.dart'; + +import 'support/fakes.dart'; +import 'support/layout_harness.dart'; + +/// WCAG 1.4.4 (RNF05): o conteúdo precisa sobreviver 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 +/// acessibilidade de quem precisa dela. +void main() { + // 360x800 é o aparelho comum; 320x640 é o piso de largura; 800x360 é a paisagem, + // em que a altura útil encolhe justamente quando o cabeçalho cresce com a fonte. + const janelas = { + '360x800': Size(360, 800), + '320x640': Size(320, 640), + '800x360 (paisagem)': Size(800, 360), + }; + for (final janela in janelas.entries) { + for (final escala in const [1.3, 2.0]) { + final rotulo = '${(escala * 100).toInt()}%'; + testWidgets('não estoura o layout com fonte a $rotulo em ${janela.key}', (tester) async { + await abrirPainel(tester, tamanho: janela.value, escalaDeFonte: escala); + await percorrerPainelInteiro(tester, 'fonte a $rotulo em ${janela.key}'); + }); + } + } + + // Alcançar não é só existir na árvore: o botão que grava a visita precisa estar + // habilitado, receber toque (nada o cobre, nem a barra de navegação) e manter + // o alvo mínimo de 48 dp. Desabilitado não prova nada, daí a chegada confirmada. + for (final janela in janelas.entries) { + testWidgets('save_visit fica alcançável a 200% em ${janela.key}', (tester) async { + // Alerta amarelo: o vermelho só oferece "Acionar SAMU", e a rota de visita + // parte do cartão do alerta na Fila. + final feed = await abrirPainel(tester, tamanho: janela.value, escalaDeFonte: 2.0); + feed.deliver(testAlert(alertId: 'alerta-visita', riskLevel: 'yellow')); + await assentar(tester); + await irParaDaBarra(tester, 'Fila'); + final iniciar = find.text('Iniciar rota de visita'); + await rolarAte(tester, iniciar); + await tester.tap(iniciar); + await assentar(tester); + final chegada = find.byKey(const Key('arrival_confirmation')); + await rolarAte(tester, chegada); + await tester.tap(chegada); + await assentar(tester); + + final salvar = find.byKey(const Key('save_visit')); + await rolarAte(tester, salvar); + expect(tester.widget(salvar).onPressed, isNotNull, + reason: 'botão desabilitado não prova alcance'); + expect(salvar.hitTestable(), findsOneWidget); + expect(tester.getSize(salvar).height, greaterThanOrEqualTo(48)); + esperarSemEstouro(tester, 'botão Salvar a 200% em ${janela.key}'); + }); + } + + // O chip de conexão corta o texto com elipse a 200% (`maxLines: 1`): o estado + // continua legível pelo ícone e pela cor, e o nome acessível tem de seguir + // inteiro para quem usa leitor de tela. WCAG 1.4.1 (não só cor) e 4.1.2. + group('chip de conexão a 200% em 320x640', () { + const tamanho = Size(320, 640); + + testWidgets('com a central conectada o rótulo semântico é completo', (tester) async { + final semantica = tester.ensureSemantics(); + try { + final feed = await abrirPainel(tester, tamanho: tamanho, escalaDeFonte: 2.0); + feed.onConnectionChanged?.call(true); + await assentar(tester); + + expect(tester.getSemantics(find.byKey(const Key('broker_status'))).label, contains('Alertas em tempo real')); + esperarSemEstouro(tester, 'chip conectado a 200%'); + } finally { + semantica.dispose(); + } + }); + + testWidgets('sem a central o rótulo semântico é completo', (tester) async { + final semantica = tester.ensureSemantics(); + try { + final feed = await abrirPainel(tester, tamanho: tamanho, escalaDeFonte: 2.0); + feed.onConnectionChanged?.call(false); + await assentar(tester); + + expect(tester.getSemantics(find.byKey(const Key('broker_status'))).label, contains('Sem central')); + esperarSemEstouro(tester, 'chip sem central a 200%'); + } finally { + semantica.dispose(); + } + }); + + testWidgets('na tela de login (sem estado) o rótulo semântico é "Offline ready"', (tester) async { + final semantica = tester.ensureSemantics(); + try { + redimensionar(tester, tamanho, escalaDeFonte: 2.0); + addTearDown(tester.view.reset); + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + await tester.pumpWidget(SinalAcsApp(backend: FakeAcsBackend(), feedBuilder: (q) => FakeAlertFeed(q))); + await assentar(tester); + + expect(tester.getSemantics(find.byKey(const Key('broker_status'))).label, contains('Offline ready')); + esperarSemEstouro(tester, 'chip no login a 200%'); + } finally { + semantica.dispose(); + } + }); + }); + + testWidgets('o cabeçalho cresce quando a escala muda em tempo de execução', (tester) async { + // No Android a preferência de tamanho de fonte muda em Configurações, com o + // app já aberto em segundo plano: o cabeçalho tem de acompanhar. + await abrirPainel(tester, tamanho: const Size(360, 800)); + final alturaPadrao = tester.getSize(find.byType(AppBar)).height; + + redimensionar(tester, const Size(360, 800), escalaDeFonte: 2.0); + await assentar(tester); + final alturaAmpliada = tester.getSize(find.byType(AppBar)).height; + + expect(alturaAmpliada, greaterThan(alturaPadrao)); + esperarSemEstouro(tester, 'cabeçalho com fonte a 200%'); + }); + + testWidgets('Preferências com "Sair e encerrar o turno" não estoura a 200% em 360x800', (tester) async { + await abrirPainel(tester, tamanho: const Size(360, 800), escalaDeFonte: 2.0); + await irParaDoMais(tester, 'Preferências'); + await percorrerTelaInteira(tester, 'Preferências a 200%'); + final sair = find.byKey(const Key('logout_button')); + await tester.ensureVisible(sair); + await assentar(tester); + expect(sair.hitTestable(), findsOneWidget); + esperarSemEstouro(tester, 'botão Sair a 200%'); + + final limpar = find.byKey(const Key('wipe_button')); + await tester.ensureVisible(limpar); + await assentar(tester); + expect(limpar.hitTestable(), findsOneWidget); + expect(tester.getSize(limpar).height, greaterThanOrEqualTo(48)); + esperarSemEstouro(tester, 'botão Limpar este aparelho a 200%'); + }); + + testWidgets('diálogos do "Sair" e do "Limpar este aparelho" não estouram a 200% em 360x800', (tester) async { + await abrirPainel(tester, tamanho: const Size(360, 800), escalaDeFonte: 2.0); + await irParaDoMais(tester, 'Preferências'); + + final sair = find.byKey(const Key('logout_button')); + await tester.ensureVisible(sair); + await assentar(tester); + await tester.tap(sair); + await assentar(tester); + expect(find.byKey(const Key('logout_confirm')), findsOneWidget); + esperarSemEstouro(tester, 'diálogo do Sair a 200%'); + await tester.tap(find.text('Cancelar')); + await assentar(tester); + + // Sem banco legível na VM, a conferência falha fechada: diálogo de bloqueio. + final limpar = find.byKey(const Key('wipe_button')); + await tester.ensureVisible(limpar); + await assentar(tester); + await tester.tap(limpar); + await assentar(tester); + expect(find.byKey(const Key('wipe_blocked')), findsOneWidget); + esperarSemEstouro(tester, 'diálogo de bloqueio da limpeza a 200%'); + }); + + // Telas novas da MFA: o campo de código no login e a ativação (QR + chave + campo). + for (final escala in const [1.3, 2.0]) { + final rotulo = '${(escala * 100).toInt()}%'; + + testWidgets('login com o campo de código não estoura com fonte a $rotulo em 360x800', (tester) async { + redimensionar(tester, const Size(360, 800), escalaDeFonte: escala); + addTearDown(tester.view.reset); + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + final backend = FakeAcsBackend()..expectedTotpCode = '123456'; + await tester.pumpWidget(SinalAcsApp(backend: backend, feedBuilder: (q) => FakeAlertFeed(q))); + await tester.enterText(find.byKey(const Key('matricula_field')), 'ACS-001'); + await tester.enterText(find.byKey(const Key('senha_field')), 'senha-sintetica'); + final entrar = find.byKey(const Key('login_button')); + await tester.ensureVisible(entrar); + await tester.pump(); + await tester.tap(entrar); + await assentar(tester); + + expect(find.byKey(const Key('totp_field')), findsOneWidget); + await percorrerTelaInteira(tester, 'login com código a $rotulo'); + }); + + testWidgets('tela de ativação da MFA não estoura com fonte a $rotulo em 360x800', (tester) async { + redimensionar(tester, const Size(360, 800), escalaDeFonte: escala); + addTearDown(tester.view.reset); + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + await tester.pumpWidget(MaterialApp( + home: MfaEnrollmentScreen(backend: FakeAcsBackend(), matricula: 'ACS-001', senha: 'senha-sintetica'), + )); + await assentar(tester); + + expect(find.byKey(const Key('mfa_secret')), findsOneWidget); + await percorrerTelaInteira(tester, 'ativação da MFA a $rotulo'); + }); + } +} diff --git a/apps/acs/test/theme_controller_test.dart b/apps/acs/test/theme_controller_test.dart new file mode 100644 index 00000000..eb3c5e51 --- /dev/null +++ b/apps/acs/test/theme_controller_test.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sinalacs_acs/core/services/theme_controller.dart'; + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + test('sem nada gravado, abre em Automático', () async { + final controller = ThemeController(); + await controller.restore(); + expect(controller.value, ThemeMode.system); + }); + + test('a escolha sobrevive a um reinício', () async { + final primeira = ThemeController(); + await primeira.setThemeMode(ThemeMode.light); + + final depoisDoReinicio = ThemeController(); + await depoisDoReinicio.restore(); + expect(depoisDoReinicio.value, ThemeMode.light); + }); + + test('valor inválido na preferência (arquivo corrompido ou de versão futura) cai em Automático', () async { + SharedPreferences.setMockInitialValues({'theme_mode': 'sepia'}); + final controller = ThemeController(ThemeMode.dark); + await controller.restore(); + expect(controller.value, ThemeMode.system); + }); + + test('o ouvinte é avisado ao trocar', () async { + final controller = ThemeController(); + var avisos = 0; + controller.addListener(() => avisos++); + await controller.setThemeMode(ThemeMode.dark); + expect(avisos, 1); + expect(controller.value, ThemeMode.dark); + }); +} diff --git a/apps/acs/test/totp_support_test.dart b/apps/acs/test/totp_support_test.dart new file mode 100644 index 00000000..777da9aa --- /dev/null +++ b/apps/acs/test/totp_support_test.dart @@ -0,0 +1,27 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'support/totp.dart'; + +void main() { + test('vetor RFC 6238: SHA-1, t=59 s dá 287082', () { + final segredo = ascii.encode('12345678901234567890'); + expect(totpCode(segredo, DateTime.fromMillisecondsSinceEpoch(59000, isUtc: true)), '287082'); + }); + + test('base32Decode devolve os 20 bytes ASCII do segredo de teste', () { + expect(base32Decode('GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ'), ascii.encode('12345678901234567890')); + }); + + test('vetor RFC 6238: SHA-1, t=1111111109 s dá 081804', () { + final segredo = ascii.encode('12345678901234567890'); + expect(totpCode(segredo, DateTime.fromMillisecondsSinceEpoch(1111111109000, isUtc: true)), '081804'); + }); + + test('as duas cópias do gerador (test/ e integration_test/) são idênticas byte a byte', () { + expect(File('integration_test/support/totp.dart').readAsBytesSync(), + File('test/support/totp.dart').readAsBytesSync()); + }); +} diff --git a/apps/acs/test/upload_token_store_test.dart b/apps/acs/test/upload_token_store_test.dart new file mode 100644 index 00000000..44dc217f --- /dev/null +++ b/apps/acs/test/upload_token_store_test.dart @@ -0,0 +1,369 @@ +import 'dart:io'; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/core/network/backend_client.dart'; +import 'package:sinalacs_acs/core/security/secure_upload_token_store.dart'; +import 'package:sinalacs_acs/core/security/session_token_store.dart'; +import 'package:sinalacs_client/sinalacs_client.dart'; + +import 'support/fake_rpc_server.dart'; + +/// Token de envio diferido (D7): um por dono no Keystore, gravado pelo login +/// na mesma seção crítica do refresh token, e que SOBREVIVE ao "Sair". +/// Credenciais e ids sintéticos. +void main() { + group('MemoryUploadTokenStore', () { + test('compareAndClear só apaga o valor esperado', () async { + final store = MemoryUploadTokenStore({'acs-a': 'upload-a'}); + expect(await store.compareAndClear('acs-a', 'outro'), isFalse); + expect(await store.read('acs-a'), 'upload-a'); + expect(await store.compareAndClear('acs-a', 'upload-a'), isTrue); + expect(await store.read('acs-a'), isNull); + }); + + test('clearAll esvazia', () async { + final store = MemoryUploadTokenStore({'acs-a': 'upload-a', 'acs-b': 'upload-b'}); + await store.clearAll(); + expect(await store.owners(), isEmpty); + expect(await store.read('acs-a'), isNull); + }); + + test('repairIndex é inofensivo (o índice é o próprio mapa)', () async { + final store = MemoryUploadTokenStore({'acs-a': 'upload-a'}); + await store.repairIndex('acs-a'); + await store.repairIndex('acs-x'); + expect(await store.owners(), ['acs-a']); + }); + + test('um token por dono; owners lista só quem tem token', () async { + final store = MemoryUploadTokenStore(); + await store.write('acs-a', 'upload-a'); + await store.write('acs-b', 'upload-b'); + await store.write('acs-a', 'upload-a2'); + + expect(await store.read('acs-a'), 'upload-a2'); + expect(await store.owners(), unorderedEquals(['acs-a', 'acs-b'])); + + await store.clear('acs-a'); + expect(await store.read('acs-a'), isNull); + expect(await store.owners(), ['acs-b']); + }); + }); + + group('SecureStorageUploadTokenStore', () { + test("chave 'acs_upload_token|', índice de donos e nunca readAll", () async { + final keystore = _KeystoreFalso({ + 'acs_refresh_token': 'refresh-sintetico', + 'acs_device_id': 'aparelho-sintetico', + }); + final store = SecureStorageUploadTokenStore(storage: keystore); + + await store.write('acs-a', 'upload-a'); + await store.write('acs-b', 'upload-b'); + await store.write('acs-a', 'upload-a2'); + + expect(keystore.data['acs_upload_token|acs-a'], 'upload-a2'); + expect(await store.read('acs-b'), 'upload-b'); + expect(await store.owners(), ['acs-a', 'acs-b']); + + await store.clear('acs-a'); + expect(await store.read('acs-a'), isNull); + expect(await store.owners(), ['acs-b']); + expect(keystore.data['acs_refresh_token'], 'refresh-sintetico'); + expect(keystore.readAllCalls, 0, reason: 'listar donos não carrega os outros segredos do Keystore'); + expect(keystore.keysRead.where((k) => !k.startsWith('acs_upload_token')), isEmpty); + }); + + test('write(A) e clear(B) concorrentes não perdem atualização do índice', () async { + for (var rodada = 0; rodada < 5; rodada++) { + final keystore = _KeystoreFalso()..latencia = const Duration(milliseconds: 5); + final store = SecureStorageUploadTokenStore(storage: keystore); + await store.write('acs-b', 'upload-b'); + + await Future.wait([store.write('acs-a', 'upload-a'), store.clear('acs-b')]); + + expect(await store.owners(), ['acs-a'], reason: 'rodada $rodada'); + expect(await store.read('acs-b'), isNull); + } + }); + + test('compareAndClear(velho) concorrente com write(novo): o token NOVO sobrevive', () async { + for (var rodada = 0; rodada < 5; rodada++) { + final keystore = _KeystoreFalso()..latencia = const Duration(milliseconds: 5); + final store = SecureStorageUploadTokenStore(storage: keystore); + await store.write('acs-a', 'upload-velho'); + + final resultados = await Future.wait([ + store.compareAndClear('acs-a', 'upload-velho'), + store.write('acs-a', 'upload-novo').then((_) => true), + ]); + + expect(resultados.first, isTrue, reason: 'rodada $rodada: o velho foi apagado'); + expect(await store.read('acs-a'), 'upload-novo', reason: 'rodada $rodada'); + expect(await store.owners(), ['acs-a'], reason: 'rodada $rodada'); + } + }); + + test('compareAndClear com outro valor não apaga', () async { + final store = SecureStorageUploadTokenStore(storage: _KeystoreFalso()); + await store.write('acs-a', 'upload-novo'); + expect(await store.compareAndClear('acs-a', 'upload-velho'), isFalse); + expect(await store.read('acs-a'), 'upload-novo'); + }); + + test('repairIndex recoloca no índice um dono cujo token existe; sem token, nada', () async { + final keystore = _KeystoreFalso({'acs_upload_token|acs-a': 'upload-a'}); + final store = SecureStorageUploadTokenStore(storage: keystore); + expect(await store.owners(), isEmpty); + + await store.repairIndex('acs-a'); + await store.repairIndex('acs-sem-token'); + + expect(await store.owners(), ['acs-a']); + expect(await store.read('acs-a'), 'upload-a', reason: 'o reparo nunca regrava o token'); + }); + + test('clearAll apaga os tokens do índice e os pedidos fora dele, mantém os outros segredos', () async { + final keystore = _KeystoreFalso({ + 'acs_refresh_token': 'refresh-sintetico', + 'acs_device_id': 'aparelho-sintetico', + 'sinalacs_db_key': 'chave-sintetica', + 'acs_upload_token|acs-fora-do-indice': 'upload-x', + }); + final store = SecureStorageUploadTokenStore(storage: keystore); + await store.write('acs-a', 'upload-a'); + await store.write('acs-b', 'upload-b'); + + await store.clearAll(alsoOwners: ['acs-fora-do-indice']); + + expect(await store.owners(), isEmpty); + expect(keystore.data.keys.where((k) => k.startsWith('acs_upload_token')), isEmpty); + expect(keystore.data['acs_refresh_token'], 'refresh-sintetico'); + expect(keystore.data['acs_device_id'], 'aparelho-sintetico'); + expect(keystore.data['sinalacs_db_key'], 'chave-sintetica', reason: 'o wipe não troca a chave do banco'); + expect(keystore.readAllCalls, 0); + }); + + test('índice ausente ou corrompido vale como vazio, e a próxima gravação o refaz', () async { + for (final corrompido in [null, '', 'não é json', '{"a":1}', '[1, null]']) { + final keystore = _KeystoreFalso({if (corrompido != null) 'acs_upload_token_owners': corrompido}); + final store = SecureStorageUploadTokenStore(storage: keystore); + + expect(await store.owners(), isEmpty, reason: '$corrompido'); + await store.write('acs-a', 'upload-a'); + expect(await store.owners(), ['acs-a'], reason: '$corrompido'); + } + }); + }); + + group('BackendClient', () { + late FakeRpcServer server; + late MemorySessionTokenStore refresh; + late MemoryUploadTokenStore uploads; + late BackendClient backend; + + setUp(() async { + server = await FakeRpcServer.start(); + refresh = MemorySessionTokenStore(); + uploads = MemoryUploadTokenStore(); + backend = BackendClient( + host: server.host, + tokenStore: refresh, + deviceIds: MemoryDeviceIdStore('aparelho-sintetico'), + uploadTokens: uploads, + ); + }); + + tearDown(() async { + backend.close(); + await server.stop(); + }); + + Future entrarComo(String userId) { + server.userId = userId; + return backend.login(matricula: 'ACS-001', senha: 'senha-sintetica'); + } + + test('login grava o uploadToken sob o userId do dono; o de outro dono fica', () async { + await entrarComo('acs-a'); + await entrarComo('acs-b'); + + expect(await uploads.read('acs-a'), server.uploadTokensIssued[0]); + expect(await uploads.read('acs-b'), server.uploadTokensIssued[1]); + expect(server.uploadTokensIssued[0], isNot(server.uploadTokensIssued[1])); + }); + + test('logout NÃO apaga o token de envio (D7: sobrevive ao Sair)', () async { + await entrarComo('acs-a'); + await backend.logout(); + + expect(await refresh.read(), isNull); + expect(await uploads.read('acs-a'), server.uploadTokensIssued.single); + }); + + test('sem deviceId (ou com o sentinela) o servidor não emite: nada gravado', () async { + for (final semAparelho in ['', 'nao-aplicavel-login-institucional']) { + final cliente = BackendClient( + host: server.host, + deviceIds: MemoryDeviceIdStore(semAparelho), + uploadTokens: uploads, + ); + server.userId = 'acs-a'; + await cliente.login(matricula: 'ACS-001', senha: 'senha-sintetica'); + cliente.close(); + } + + expect(server.uploadTokensIssued, isEmpty); + expect(await uploads.owners(), isEmpty); + }); + + test('login sem uploadToken na resposta não apaga o token já guardado do mesmo dono', () async { + await entrarComo('acs-a'); + final antigo = await uploads.read('acs-a'); + server.omitUploadToken = true; + await entrarComo('acs-a'); + + expect(await uploads.read('acs-a'), antigo); + }); + + VisitSyncEntry entrada(String localId) => VisitSyncEntry( + localId: localId, + patientId: '00000000-0000-4000-8000-000000000001', + scheduledAt: DateTime.utc(2026, 10, 3), + completedAt: DateTime.utc(2026, 10, 3), + status: 'PENDENTE', + riskLevelBefore: RiskLevel.green, + notes: const {}, + version: 1, + arrivalMethod: ArrivalMethod.manual, + ); + + test('syncDeferredVisits vai com o token de envio, sem accessToken', () async { + final results = await backend.syncDeferredVisits( + uploadToken: 'upload-sintetico', + deviceId: 'aparelho-sintetico', + visits: [entrada('a-1')], + ); + + expect(results.single.syncStatus, SyncStatus.synced); + final pedido = server.requests.singleWhere((r) => r.method == 'syncDeferred'); + expect(pedido.endpoint, 'visits'); + expect(pedido.args['uploadToken'], 'upload-sintetico'); + expect(pedido.args['deviceId'], 'aparelho-sintetico'); + expect(pedido.args.containsKey('accessToken'), isFalse); + }); + + test('token de envio recusado vira UploadTokenRefused (não "sem conexão")', () async { + server.refusedUploadTokens.add('upload-morto'); + + await expectLater( + backend.syncDeferredVisits(uploadToken: 'upload-morto', deviceId: 'aparelho-sintetico', visits: [entrada('a-1')]), + throwsA(isA().having((f) => f.isRecoverable, 'isRecoverable', isFalse)), + ); + }); + + test('syncLegacyVisits usa a sessão atual como transporte', () async { + await entrarComo('acs-b'); + + final results = await backend.syncLegacyVisits([entrada('leg-1')], deviceId: 'aparelho-sintetico'); + + expect(results.single.localId, 'leg-1'); + final pedido = server.requests.singleWhere((r) => r.method == 'syncLegacy'); + expect(pedido.args['accessToken'], isNotNull); + expect(pedido.args['deviceId'], 'aparelho-sintetico'); + }); + + test('revokeUploadToken envia o token ao servidor', () async { + await backend.revokeUploadToken('upload-sintetico'); + + expect(server.revokedUploadTokens, ['upload-sintetico']); + }); + }); + + test('o caminho importado pelo backend_client não puxa o Flutter (token de envio)', () { + final imports = + File('lib/core/security/upload_token_store.dart').readAsLinesSync().where((l) => l.startsWith('import ')).join('\n'); + expect(imports, isNot(contains('package:flutter'))); + final client = File('lib/core/network/backend_client.dart').readAsStringSync(); + expect(client, isNot(contains('secure_upload_token_store.dart'))); + }); +} + +/// Keystore em memória que registra o que foi lido e RECUSA `readAll`. +class _KeystoreFalso implements FlutterSecureStorage { + _KeystoreFalso([Map? inicial]) : data = {...?inicial}; + + final Map data; + final List keysRead = []; + int readAllCalls = 0; + + /// Atraso de cada operação (provoca intercalação entre chamadas). + Duration latencia = Duration.zero; + + Future _espera() => latencia > Duration.zero ? Future.delayed(latencia) : Future.value(); + + @override + Future read({ + required String key, + IOSOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + MacOsOptions? mOptions, + WindowsOptions? wOptions, + }) async { + keysRead.add(key); + await _espera(); + return data[key]; + } + + @override + Future write({ + required String key, + required String? value, + IOSOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + MacOsOptions? mOptions, + WindowsOptions? wOptions, + }) async { + await _espera(); + if (value == null) { + data.remove(key); + } else { + data[key] = value; + } + } + + @override + Future delete({ + required String key, + IOSOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + MacOsOptions? mOptions, + WindowsOptions? wOptions, + }) async { + await _espera(); + data.remove(key); + } + + @override + Future> readAll({ + IOSOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + MacOsOptions? mOptions, + WindowsOptions? wOptions, + }) async { + readAllCalls++; + return Map.of(data); + } + + @override + dynamic noSuchMethod(Invocation invocation) => throw UnimplementedError('${invocation.memberName}'); +} diff --git a/apps/acs/test/visit_owner_flow_test.dart b/apps/acs/test/visit_owner_flow_test.dart new file mode 100644 index 00000000..b4740042 --- /dev/null +++ b/apps/acs/test/visit_owner_flow_test.dart @@ -0,0 +1,555 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/app/app.dart'; +import 'package:sinalacs_acs/core/database/encrypted_database.dart'; +import 'package:sinalacs_acs/core/database/sqlcipher_visit_store.dart'; +import 'package:sinalacs_acs/core/database/sync_cursor_store.dart'; +import 'package:sinalacs_acs/core/network/auth_session.dart'; +import 'package:sinalacs_acs/core/network/backend_client.dart'; +import 'package:sinalacs_acs/core/security/biometric_gate.dart'; +import 'package:sinalacs_acs/core/security/database_key_store.dart'; +import 'package:sinalacs_acs/core/services/backend_visit_synchronizer.dart'; +import 'package:sinalacs_acs/core/services/offline_visit_queue.dart'; +import 'package:sinalacs_acs/core/services/visit_pull_service.dart'; +import 'package:sinalacs_acs/core/services/visit_queue_factory.dart'; +import 'package:sinalacs_client/sinalacs_client.dart'; + +import 'support/fakes.dart'; +import 'support/layout_harness.dart' show assentar, irParaDaBarra, irParaDoMais; + +/// Fila e pull de visitas POR SESSÃO: depois de "Sair" ou do login de outro +/// ACS, as visitas do anterior não aparecem, não sobem, não são atribuídas a +/// outro e não são descartáveis por ele. Ids e pacientes sintéticos. +void main() { + const acsA = 'acs-a'; + const acsB = 'acs-b'; + + OfflineVisitRecord pendente(String localId) => + OfflineVisitRecord(localId: localId, patientId: seedPatientId, risk: 'red', status: 'PENDENTE'); + + OfflineVisitRecord recusada(String localId) => OfflineVisitRecord( + localId: localId, + patientId: seedPatientId, + risk: 'red', + status: 'PENDENTE', + rejectionReason: 'paciente fora da sua microárea', + ); + + AuthSession sessao({required String userId, String microAreaId = seedMicroAreaId}) => AuthSession( + accessToken: 'token-de-teste', + tokenType: 'Bearer', + userId: userId, + role: 'acs', + microAreaId: microAreaId, + expiresAt: DateTime.now().toUtc().add(const Duration(minutes: 15)), + ); + + group('no app', () { + late InMemoryVisitStorage storage; + late FakeAcsBackend backend; + late FakeBiometricGate gate; + late GlobalKey navKey; + + setUp(() async { + storage = InMemoryVisitStorage(); + // A: 1 pendente e 1 recusada. B: 2 pendentes e 1 recusada. + await storage.forOwner(acsA).save([pendente('local-a1'), recusada('local-a2')]); + await storage.forOwner(acsB).save([pendente('local-b2'), pendente('local-b3'), recusada('local-b1')]); + backend = FakeAcsBackend()..nextUserId = acsA; + gate = FakeBiometricGate(); + navKey = GlobalKey(); + }); + + Future abrirApp(WidgetTester tester, {Duration? lockAfter}) async { + await tester.pumpWidget(SinalAcsApp( + backend: backend, + visitStorage: storage, + biometricGate: gate, + navigatorKey: navKey, + lockAfter: lockAfter, + feedBuilder: (q) => FakeAlertFeed(q), + )); + await assentar(tester); + } + + Finder painel() => find.text('Painel operacional'); + Finder formulario() => find.byKey(const Key('login_button')); + + Future entrarComo(WidgetTester tester, String userId) async { + backend.nextUserId = userId; + await tester.enterText(find.byKey(const Key('matricula_field')), 'ACS-001'); + await tester.enterText(find.byKey(const Key('senha_field')), 'senha-sintetica'); + await tester.ensureVisible(formulario()); + await tester.pump(); + await tester.tap(formulario()); + await assentar(tester); + expect(painel(), findsOneWidget); + } + + /// "Sair" SEM rede: o "Sair" tenta enviar a fila do dono antes (provado em + /// `session_resume_test.dart`); aqui ela precisa ficar no aparelho para + /// provar o isolamento entre donos. + Future sair(WidgetTester tester) async { + final antes = backend.syncFailure; + backend.syncFailure = const BackendFailure('Sem conexão com o servidor.'); + await irParaDoMais(tester, 'Preferências'); + final botao = find.byKey(const Key('logout_button')); + await tester.ensureVisible(botao); + await tester.pump(); + await tester.tap(botao); + await assentar(tester); + await tester.tap(find.byKey(const Key('logout_confirm'))); + await assentar(tester); + expect(formulario(), findsOneWidget); + backend.syncFailure = antes; + } + + Future irEVoltar(WidgetTester tester) async { + for (final s in const [AppLifecycleState.inactive, AppLifecycleState.hidden, AppLifecycleState.paused, + AppLifecycleState.hidden, AppLifecycleState.inactive, AppLifecycleState.resumed]) { + tester.binding.handleAppLifecycleStateChanged(s); + } + await assentar(tester); + } + + /// Abre a aba Visita e devolve o texto do contador de pendentes. + Future pendentesNaTela(WidgetTester tester) async { + await irParaDaBarra(tester, 'Visita'); + final contador = find.byKey(const Key('pending_visits_count')); + await tester.ensureVisible(contador); + await tester.pump(); + return tester.widget(contador).data!; + } + + testWidgets('Sair e entrar com OUTRO ACS: o novo não vê, não envia nem descarta a visita do anterior', + (tester) async { + await abrirApp(tester); + await entrarComo(tester, acsA); + expect(await pendentesNaTela(tester), 'Pendentes de sincronização: 1'); + expect(find.byKey(const Key('rejected_visits_count')), findsOneWidget); + + await sair(tester); + await entrarComo(tester, acsB); + + expect(await pendentesNaTela(tester), 'Pendentes de sincronização: 2', reason: 'só as visitas de B'); + expect(tester.widget(find.byKey(const Key('rejected_visits_count'))).data, contains(': 1')); + + // "Sincronizar agora" de B: nenhum localId de A sai do aparelho. + // (A tentou enviar a própria fila ao sair, sem rede: lote dele, sessão dele.) + final lotesAntes = backend.syncedVisitBatches.length; + final sincronizar = find.byKey(const Key('sync_visits')); + await tester.ensureVisible(sincronizar); + await tester.pump(); + await tester.tap(sincronizar); + await assentar(tester); + final enviados = [ + for (final lote in backend.syncedVisitBatches.skip(lotesAntes)) ...lote.map((e) => e.localId), + ]; + expect(enviados, unorderedEquals(['local-b2', 'local-b3'])); + + // Descartar a recusada de B não toca no armazenamento de A. + final descartar = find.byKey(const Key('discard_rejected')); + await tester.ensureVisible(descartar); + await tester.pump(); + await tester.tap(descartar); + await assentar(tester); + await tester.tap(find.text('Descartar')); + await assentar(tester); + + expect(await storage.forOwner(acsB).load(), isEmpty); + final deA = await storage.forOwner(acsA).load(); + expect(deA.map((v) => v.localId), unorderedEquals(['local-a1', 'local-a2'])); + }); + + testWidgets('o mesmo ACS que sai e volta recupera as próprias visitas', (tester) async { + await abrirApp(tester); + await entrarComo(tester, acsA); + expect(await pendentesNaTela(tester), 'Pendentes de sincronização: 1'); + await sair(tester); + + await entrarComo(tester, acsB); + expect(await pendentesNaTela(tester), 'Pendentes de sincronização: 2'); + await sair(tester); + + await entrarComo(tester, acsA); + expect(await pendentesNaTela(tester), 'Pendentes de sincronização: 1'); + expect(find.byKey(const Key('rejected_visits_count')), findsOneWidget); + }); + + testWidgets('reautenticação do MESMO usuário pelo bloqueio preserva o painel e a fila', (tester) async { + backend.patients = [ + MicroAreaPatient(patientId: syntheticPatientId(5), name: 'Paciente Sintético', isChronic: false, chronicConditions: const []), + ]; + await abrirApp(tester, lockAfter: Duration.zero); + await entrarComo(tester, acsA); + expect(await pendentesNaTela(tester), 'Pendentes de sincronização: 1'); + await tester.ensureVisible(find.byKey(const Key('patient_search'))); + await tester.pump(); + await tester.enterText(find.byKey(const Key('patient_search')), 'busca em andamento'); + await tester.pump(); + + gate.result = UnlockResult.cancelled; + await irEVoltar(tester); + await tester.tap(find.text('Entrar com senha')); + await assentar(tester); + await entrarComo(tester, acsA); + + expect(tester.widget(find.byKey(const Key('patient_search'))).controller!.text, 'busca em andamento'); + expect(await pendentesNaTela(tester), 'Pendentes de sincronização: 1'); + }); + + testWidgets('login de outro usuário pelo bloqueio ("Entrar com senha") também troca a fila', (tester) async { + await abrirApp(tester, lockAfter: Duration.zero); + await entrarComo(tester, acsA); + expect(await pendentesNaTela(tester), 'Pendentes de sincronização: 1'); + + gate.result = UnlockResult.cancelled; + await irEVoltar(tester); + await tester.tap(find.text('Entrar com senha')); + await assentar(tester); + await entrarComo(tester, acsB); + + expect(await pendentesNaTela(tester), 'Pendentes de sincronização: 2'); + expect(navKey.currentState!.canPop(), isFalse, reason: 'o painel de A foi descartado'); + }); + + testWidgets('retomada de sessão na partida abre a fila do dono da sessão retomada', (tester) async { + backend + ..storedRefreshToken = 'refresh-salvo' + ..nextUserId = acsB; + await abrirApp(tester); + + expect(backend.resumeCount, 1); + expect(painel(), findsOneWidget); + expect(await pendentesNaTela(tester), 'Pendentes de sincronização: 2'); + }); + + testWidgets('fila de A só em RAM (gravação falhando) sobrevive a B entrar e A voltar', (tester) async { + // A visão de A recusa gravar: a visita registrada existe só na memória. + final memoria = _GravacaoDeAFalha(storage); + late FakeAlertFeed feed; + await tester.pumpWidget(SinalAcsApp( + backend: backend, + visitStorage: memoria, + biometricGate: gate, + navigatorKey: navKey, + feedBuilder: (q) => feed = FakeAlertFeed(q), + )); + await assentar(tester); + await storage.forOwner(acsA).save([]); + await entrarComo(tester, acsA); + + feed.deliver(testAlert(alertId: 'alerta-ram', riskLevel: 'yellow')); + await assentar(tester); + await tester.tap(find.text('Iniciar rota de visita')); + await assentar(tester); + await tester.tap(find.byKey(const Key('arrival_confirmation'))); + await assentar(tester); + final salvar = find.byKey(const Key('save_visit')); + await tester.ensureVisible(salvar); + await tester.pump(); + await tester.tap(salvar); + await assentar(tester); + if (find.byType(BackButton).evaluate().isNotEmpty) { + await tester.tap(find.byType(BackButton).first); + await assentar(tester); + } + expect(await pendentesNaTela(tester), 'Pendentes de sincronização: 1 (em memória)'); + expect(await storage.forOwner(acsA).load(), isEmpty, reason: 'nada chegou ao disco'); + // O aviso da gravação (SnackBar) cobre o botão de sair até sumir. + await tester.pump(const Duration(seconds: 10)); + await assentar(tester); + + await sair(tester); + await entrarComo(tester, acsB); + await sair(tester); + await entrarComo(tester, acsA); + + expect(await pendentesNaTela(tester), 'Pendentes de sincronização: 1 (em memória)', + reason: 'a visita só existia em RAM: a fila de A não pode ser descartada'); + }); + + testWidgets('A sai com o envio preso além do teto, B entra, A volta: a MESMA fila; a visita nova de A sobrevive ao fim do envio antigo', + (tester) async { + late FakeAlertFeed feed; + await tester.pumpWidget(SinalAcsApp( + backend: backend, + visitStorage: storage, + biometricGate: gate, + navigatorKey: navKey, + feedBuilder: (q) => feed = FakeAlertFeed(q), + )); + await assentar(tester); + await storage.forOwner(acsA).save([pendente('local-a1')]); + await entrarComo(tester, acsA); + + // "Sair": o lote de A fica preso no servidor além do teto de 20 s. + backend.syncGate = Completer(); + await irParaDoMais(tester, 'Preferências'); + final botao = find.byKey(const Key('logout_button')); + await tester.ensureVisible(botao); + await tester.pump(); + await tester.tap(botao); + await tester.pump(const Duration(seconds: 21)); + await assentar(tester); + await tester.tap(find.byKey(const Key('logout_confirm'))); + await assentar(tester); + expect(formulario(), findsOneWidget); + + await entrarComo(tester, acsB); + await sair(tester); + await entrarComo(tester, acsA); + + // A registra uma visita nova com o envio antigo ainda em voo. + feed.deliver(testAlert(alertId: 'alerta-volta', riskLevel: 'yellow')); + await assentar(tester); + await tester.tap(find.text('Iniciar rota de visita')); + await assentar(tester); + await tester.tap(find.byKey(const Key('arrival_confirmation'))); + await assentar(tester); + final salvar = find.byKey(const Key('save_visit')); + await tester.ensureVisible(salvar); + await tester.pump(); + await tester.tap(salvar); + await assentar(tester); + final antes = (await storage.forOwner(acsA).load()).map((v) => v.localId).toSet(); + expect(antes, hasLength(2), reason: 'local-a1 (em voo) e a nova'); + + // O envio antigo termina (local-a1 confirmada). + backend.syncGate!.complete(); + await assentar(tester); + + final depois = (await storage.forOwner(acsA).load()).map((v) => v.localId).toList(); + expect(depois, hasLength(1), reason: 'a visita nova não pode ser apagada pela gravação da fila antiga'); + expect(depois.single, isNot('local-a1')); + }); + + testWidgets('a quarentena (linhas sem dono) não aparece para ninguém', (tester) async { + storage = InMemoryVisitStorage(legacyVisits: [pendente('local-legado')]); + await abrirApp(tester); + await entrarComo(tester, acsA); + expect(await pendentesNaTela(tester), 'Pendentes de sincronização: 0'); + expect(backend.syncedVisitBatches, isEmpty); + expect(await storage.legacy.load(), hasLength(1)); + }); + }); + + group('no app, com o banco real (SQLCipher sem cifra, FFI)', () { + const dbName = 'visit_owner_flow_widget_test.db'; + + setUp(() => EncryptedLocalDatabase.deleteDatabaseFile(dbName)); + tearDown(() => EncryptedLocalDatabase.deleteDatabaseFile(dbName)); + + testWidgets('o pull de cada ACS usa o cursor DELE, no mesmo banco da fila', (tester) async { + final storage = SqlCipherVisitStorage( + keyStore: InMemoryDatabaseKeyStore(), + databaseName: dbName, + allowUnencryptedForTesting: true, + ); + final syncAt = DateTime.utc(2026, 10, 3, 10); + final backend = FakeAcsBackend() + ..pullEntries = [ + VisitSyncEntry( + localId: 'remota-1', + patientId: seedPatientId, + scheduledAt: DateTime.utc(2026, 10, 3, 9), + status: 'realizada', + riskLevelBefore: RiskLevel.green, + notes: const {}, + version: 1, + syncAt: syncAt, + arrivalMethod: ArrivalMethod.manual, + ), + ]; + await tester.pumpWidget(SinalAcsApp( + backend: backend, + visitStorage: storage, + feedBuilder: (q) => FakeAlertFeed(q), + )); + await assentar(tester); + + Future entrarComo(String userId) async { + backend.nextUserId = userId; + await tester.enterText(find.byKey(const Key('matricula_field')), 'ACS-001'); + await tester.enterText(find.byKey(const Key('senha_field')), 'senha-sintetica'); + await tester.ensureVisible(find.byKey(const Key('login_button'))); + await tester.pump(); + await tester.tap(find.byKey(const Key('login_button'))); + await assentar(tester); + expect(find.text('Painel operacional'), findsOneWidget); + } + + Future sair() async { + await irParaDoMais(tester, 'Preferências'); + final botao = find.byKey(const Key('logout_button')); + await tester.ensureVisible(botao); + await tester.pump(); + await tester.tap(botao); + await assentar(tester); + await tester.tap(find.byKey(const Key('logout_confirm'))); + await assentar(tester); + } + + await entrarComo(acsA); + expect(backend.pullSinceCalls, [VisitPullService.epoch], reason: 'primeiro pull de A'); + expect(find.byKey(const Key('pull_error'), skipOffstage: false), findsNothing, + reason: 'o cursor abriu no banco compartilhado, não num banco à parte pelo Keystore'); + final chaves = await tester.runAsync(() async { + final db = await storage.database.open(); + return [for (final row in await db.query('sync_cursor')) row['key']! as String]; + }); + expect(chaves, contains('visits_pull|$acsA|$seedMicroAreaId')); + + await sair(); + await entrarComo(acsB); + expect(backend.pullSinceCalls, [VisitPullService.epoch, VisitPullService.epoch], + reason: 'B não herda o cursor de A'); + + await sair(); + await entrarComo(acsA); + expect(backend.pullSinceCalls, [VisitPullService.epoch, VisitPullService.epoch, syncAt], + reason: 'A retoma o PRÓPRIO cursor'); + + await tester.pumpWidget(const SizedBox()); + await tester.runAsync(storage.close); + }); + }); + + group('sincronizador com dono', () { + test('BackendVisitSynchronizer recusa enviar com a sessão de outro usuário', () async { + final backend = FakeAcsBackend()..session = sessao(userId: acsB); + final sync = BackendVisitSynchronizer(backend: backend, ownerId: acsA); + + await expectLater( + sync.push([pendente('local-a1')]), + throwsA(isA().having((f) => f.isRecoverable, 'isRecoverable', isTrue)), + ); + expect(backend.syncedVisitBatches, isEmpty, reason: 'nada saiu'); + }); + + test('BackendVisitSynchronizer recusa enviar sem sessão', () async { + final backend = FakeAcsBackend(); + final sync = BackendVisitSynchronizer(backend: backend, ownerId: acsA); + + await expectLater(sync.push([pendente('local-a1')]), throwsA(isA())); + expect(backend.syncedVisitBatches, isEmpty); + }); + + test('BackendVisitSynchronizer envia com a sessão do próprio dono', () async { + final backend = FakeAcsBackend()..session = sessao(userId: acsA); + final sync = BackendVisitSynchronizer(backend: backend, ownerId: acsA); + + final resultado = await sync.push([pendente('local-a1')]); + + expect(resultado.single.status, 'synced'); + expect(backend.syncedVisitBatches.single.single.localId, 'local-a1'); + }); + + test('a fila de A em voo, com B logado: o lote de A continua PENDENTE (não é enviado nem perdido)', () async { + final storage = InMemoryVisitStorage(); + final backend = FakeAcsBackend()..session = sessao(userId: acsA); + final fila = OfflineVisitQueue( + store: storage.forOwner(acsA), + synchronizer: BackendVisitSynchronizer(backend: backend, ownerId: acsA), + ); + await fila.add(pendente('local-a1')); + backend.session = sessao(userId: acsB); + + final r = await fila.sync(); + + expect(r.kind, SyncOutcomeKind.error); + expect(fila.pendingCount, 1); + expect(backend.syncedVisitBatches, isEmpty); + expect((await storage.forOwner(acsA).load()).single.localId, 'local-a1'); + }); + + test('buildVisitQueue liga o sincronizador ao dono pedido', () { + final queue = buildVisitQueue(backend: FakeAcsBackend(), store: InMemoryVisitStore(), ownerId: acsA); + expect(queue.synchronizer, isA().having((s) => s.ownerId, 'ownerId', acsA)); + }); + }); + + group('fiação de produção por dono (um só VisitDatabase)', () { + TestWidgetsFlutterBinding.ensureInitialized(); + const dbName = 'visit_owner_flow_test.db'; + + setUp(() => EncryptedLocalDatabase.deleteDatabaseFile(dbName)); + tearDown(() => EncryptedLocalDatabase.deleteDatabaseFile(dbName)); + + VisitSyncEntry entrada(String localId, DateTime syncAt) => VisitSyncEntry( + localId: localId, + patientId: seedPatientId, + scheduledAt: DateTime.utc(2026, 10, 3, 9), + status: 'realizada', + riskLevelBefore: RiskLevel.green, + notes: const {}, + version: 1, + syncAt: syncAt, + arrivalMethod: ArrivalMethod.manual, + ); + + test('fila e cursor do dono compartilham UM VisitDatabase', () async { + final storage = SqlCipherVisitStorage( + keyStore: InMemoryDatabaseKeyStore(), + databaseName: dbName, + allowUnencryptedForTesting: true, + ); + addTearDown(storage.close); + final syncAt = DateTime.utc(2026, 10, 3, 10); + final backend = FakeAcsBackend() + ..session = sessao(userId: acsA) + ..pullEntries = [entrada('local-a1', syncAt), entrada('de-outro-aparelho', syncAt)]; + + final escopo = buildOwnerVisitScope(backend: backend, storage: storage, session: sessao(userId: acsA)); + await escopo.queue.restore(); + await escopo.queue.add(pendente('local-a1')); + + // Sem o banco compartilhado o pull abriria outro banco pelo Keystore + // (inexistente na VM) e lançaria. + await escopo.pullService.pullAndMerge(); + + expect(escopo.pullService.lastPulled.map((e) => e.localId), ['de-outro-aparelho'], + reason: 'dedupe pela fila do MESMO dono'); + const cursorOwner = '$acsA|$seedMicroAreaId'; + expect(await SyncCursorStore.on(storage.database, owner: cursorOwner).read(), syncAt); + final db = await storage.database.open(); + final linhas = await db.query('sync_cursor', where: 'key = ?', whereArgs: ['visits_pull|$cursorOwner']); + expect(linhas, hasLength(1)); + expect( + await db.query('offline_visits', where: 'owner = ?', whereArgs: [acsA]), + hasLength(1), + reason: 'a visita da fila está no mesmo arquivo do cursor', + ); + + // B, no mesmo aparelho: cursor próprio (epoch) e nenhuma visita de A. + final backendB = FakeAcsBackend()..session = sessao(userId: acsB); + final escopoB = buildOwnerVisitScope(backend: backendB, storage: storage, session: sessao(userId: acsB)); + await escopoB.queue.restore(); + expect(escopoB.queue.pendingCount, 0); + await escopoB.pullService.pullAndMerge(); + expect(backendB.pullSinceCalls, [VisitPullService.epoch]); + }); + }); +} + +/// Só a visão de `acs-a` recusa gravar (`persistenceFailed` na fila de A). +class _GravacaoDeAFalha implements VisitStorage { + _GravacaoDeAFalha(this._inner); + + final VisitStorage _inner; + + @override + LegacyVisitStore get legacy => _inner.legacy; + + @override + Future> countsByOwner() => _inner.countsByOwner(); + + @override + Future wipeAllData() => _inner.wipeAllData(); + + @override + VisitStore forOwner(String ownerId) => + ownerId == 'acs-a' ? FailingVisitStore(failOnLoad: false) : _inner.forOwner(ownerId); +} diff --git a/apps/acs/test/visit_pull_service_factory_test.dart b/apps/acs/test/visit_pull_service_factory_test.dart index b1f1e6f4..eb7402df 100644 --- a/apps/acs/test/visit_pull_service_factory_test.dart +++ b/apps/acs/test/visit_pull_service_factory_test.dart @@ -1,8 +1,10 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:sinalacs_acs/core/database/encrypted_database.dart'; +import 'package:sinalacs_acs/core/database/sqlcipher_visit_store.dart'; import 'package:sinalacs_acs/core/database/sync_cursor_store.dart'; import 'package:sinalacs_acs/core/security/database_key_store.dart'; import 'package:sinalacs_acs/core/services/offline_visit_queue.dart'; +import 'package:sinalacs_acs/core/services/visit_pull_service.dart'; import 'package:sinalacs_acs/core/services/visit_pull_service_factory.dart'; import 'package:sinalacs_client/sinalacs_client.dart'; @@ -35,6 +37,7 @@ void main() { ]; final cursorStore = SyncCursorStore( keyStore: InMemoryDatabaseKeyStore(), + owner: 'acs-a', databaseName: dbName, allowUnencryptedForTesting: true, ); @@ -54,6 +57,7 @@ void main() { final service = buildVisitPullService( backend: backend, localVisits: localVisits, + cursorOwner: 'acs-a', cursorStore: cursorStore, ); await service.pullAndMerge(); @@ -65,4 +69,44 @@ void main() { expect(service.lastPulled, isEmpty); }, ); + + test('sem cursorStore injetado, o cursor é gravado na chave do cursorOwner', () async { + final keyStore = InMemoryDatabaseKeyStore(); + VisitPullService build(String owner, FakeAcsBackend backend) => buildVisitPullService( + backend: backend, + localVisits: InMemoryVisitStore(), + cursorOwner: owner, + keyStore: keyStore, + databaseName: dbName, + allowUnencryptedForTesting: true, + ); + final backendA = FakeAcsBackend() + ..pullEntries = [ + VisitSyncEntry( + localId: 'x', + patientId: seedPatientId, + scheduledAt: DateTime.utc(2026, 9, 12, 9), + status: 'realizada', + riskLevelBefore: RiskLevel.green, + notes: const {}, + version: 1, + syncAt: DateTime.utc(2026, 9, 12, 10), + arrivalMethod: ArrivalMethod.manual, + ), + ]; + await build('acs-a', backendA).pullAndMerge(); + + final db = VisitDatabase( + keyStore: keyStore, + databaseName: dbName, + allowUnencryptedForTesting: true, + ); + addTearDown(db.close); + final rows = await (await db.open()).query('sync_cursor'); + expect(rows.map((r) => r['key']), ['visits_pull|acs-a']); + + final backendB = FakeAcsBackend(); + await build('acs-b', backendB).pullAndMerge(); + expect(backendB.pullSinceCalls.single, DateTime.utc(2000)); + }); } diff --git a/apps/acs/test/visit_pull_service_test.dart b/apps/acs/test/visit_pull_service_test.dart index 87c375ef..dfd031b9 100644 --- a/apps/acs/test/visit_pull_service_test.dart +++ b/apps/acs/test/visit_pull_service_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:sinalacs_acs/core/database/encrypted_database.dart'; +import 'package:sinalacs_acs/core/database/sqlcipher_visit_store.dart'; import 'package:sinalacs_acs/core/database/sync_cursor_store.dart'; import 'package:sinalacs_acs/core/security/database_key_store.dart'; import 'package:sinalacs_acs/core/services/offline_visit_queue.dart'; @@ -39,6 +40,7 @@ void main() { backend = FakeAcsBackend(); cursorStore = SyncCursorStore( keyStore: InMemoryDatabaseKeyStore(), + owner: 'acs-a', databaseName: dbName, allowUnencryptedForTesting: true, ); @@ -165,4 +167,60 @@ void main() { expect(service.lastPulled, hasLength(2)); }); + + group('por dono', () { + late VisitDatabase db; + late SqlCipherVisitStorage storage; + + setUp(() { + db = VisitDatabase( + keyStore: InMemoryDatabaseKeyStore(), + databaseName: dbName, + allowUnencryptedForTesting: true, + ); + storage = SqlCipherVisitStorage( + keyStore: InMemoryDatabaseKeyStore(), + databaseName: dbName, + allowUnencryptedForTesting: true, + ); + }); + tearDown(() async { + await db.close(); + await storage.close(); + }); + + test('pullAndMerge de B usa since=epoch mesmo com cursor de A gravado', () async { + await SyncCursorStore.on(db, owner: 'acs-a').write(DateTime.utc(2026, 10, 3)); + final service = VisitPullService( + backend: backend, + cursorStore: SyncCursorStore.on(db, owner: 'acs-b'), + localVisits: InMemoryVisitStore(), + ); + + await service.pullAndMerge(); + + expect(backend.pullSinceCalls.single, DateTime.utc(2000)); + }); + + test('dedupe de localId enxerga so a fila do dono', () async { + await storage.forOwner('acs-a').save([ + OfflineVisitRecord( + localId: 'local-a1', + patientId: seedPatientId, + risk: 'green', + status: 'PENDENTE', + ), + ]); + backend.pullEntries = [entry(localId: 'local-a1', syncAt: DateTime.utc(2026, 10, 3))]; + final service = VisitPullService( + backend: backend, + cursorStore: SyncCursorStore.on(db, owner: 'acs-b'), + localVisits: storage.forOwner('acs-b'), + ); + + await service.pullAndMerge(); + + expect(service.lastPulled.map((e) => e.localId), ['local-a1']); + }); + }); } diff --git a/apps/acs/test/visit_storage_owner_test.dart b/apps/acs/test/visit_storage_owner_test.dart new file mode 100644 index 00000000..1e92e797 --- /dev/null +++ b/apps/acs/test/visit_storage_owner_test.dart @@ -0,0 +1,525 @@ +import 'dart:io'; + +import 'package:flutter/services.dart' show PlatformException; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/core/database/encrypted_database.dart'; +import 'package:sinalacs_acs/core/database/sqlcipher_visit_store.dart'; +import 'package:sinalacs_acs/core/security/database_key_store.dart'; +import 'package:sinalacs_acs/core/services/offline_visit_queue.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +import 'support/fakes.dart'; + +OfflineVisitRecord _visita(String localId, {bool rejeitada = false}) => + OfflineVisitRecord( + patientId: seedPatientId, + risk: 'red', + status: 'PENDENTE', + outcome: 'realizada', + localId: localId, + rejectionReason: rejeitada ? 'recusada' : null, + ); + +Future _contar(Database db, String where) async => + (await db.rawQuery('SELECT COUNT(*) AS n FROM offline_visits WHERE $where')) + .single['n']! as int; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const nome = 'visit_storage_owner_test.db'; + late SqlCipherVisitStorage storage; + + SqlCipherVisitStorage abrir() => SqlCipherVisitStorage( + keyStore: InMemoryDatabaseKeyStore(), + databaseName: nome, + allowUnencryptedForTesting: true, + ); + + setUp(() async { + await EncryptedLocalDatabase.deleteDatabaseFile(nome); + await EncryptedLocalDatabase.deleteRecoveryCopy(nome); + storage = abrir(); + }); + + tearDown(() async { + await storage.close(); + await EncryptedLocalDatabase.deleteDatabaseFile(nome); + await EncryptedLocalDatabase.deleteRecoveryCopy(nome); + }); + + test('cada dono vê só as próprias visitas', () async { + final a = storage.forOwner('acs-a'), b = storage.forOwner('acs-b'); + await a.save([_visita('local-a1'), _visita('local-a2', rejeitada: true)]); + expect(await b.load(), isEmpty); + expect((await a.load()).map((v) => v.localId), ['local-a1', 'local-a2']); + }); + + test('save de B nunca apaga nem altera as linhas de A', () async { + final a = storage.forOwner('acs-a'), b = storage.forOwner('acs-b'); + await a.save([_visita('local-a1')]); + await b.save([_visita('local-b1')]); + await b.save([]); + expect((await a.load()).map((v) => v.localId), ['local-a1']); + final db = await storage.database.open(); + expect(await _contar(db, "owner='acs-a'"), 1); + expect(await _contar(db, "owner='acs-b'"), 0); + }); + + test('countsByOwner conta por dono (pendentes + recusadas), sem a quarentena', () async { + await storage.forOwner('acs-a').save([_visita('local-a1'), _visita('local-a2', rejeitada: true)]); + await storage.forOwner('acs-b').save([_visita('local-b1')]); + final db = await storage.database.open(); + await db.insert('offline_visits', { + 'local_id': 'leg-1', + 'patient_id': seedPatientId, + 'risk': 'red', + 'status': 'PENDENTE', + 'outcome': '', + 'notes': '', + 'created_at': DateTime.utc(2026, 1, 1).toIso8601String(), + 'version': 1, + }); + + expect(await storage.countsByOwner(), {'acs-a': 2, 'acs-b': 1}); + }); + + test('migração v6 → v7 preserva as linhas em QUARENTENA: nenhum dono as vê', () async { + sqfliteFfiInit(); + final v6 = await databaseFactoryFfi.openDatabase( + await EncryptedLocalDatabase.pathFor(nome), + options: OpenDatabaseOptions( + version: 6, + onCreate: (db, version) => db.execute(''' +CREATE TABLE offline_visits ( + local_id TEXT PRIMARY KEY, patient_id TEXT NOT NULL, risk TEXT NOT NULL, + status TEXT NOT NULL, outcome TEXT NOT NULL, notes TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, version INTEGER NOT NULL, rejection_reason TEXT +)'''), + ), + ); + for (final (i, id) in ['local-v6-1', 'local-v6-2'].indexed) { + await v6.insert('offline_visits', { + 'local_id': id, + 'patient_id': seedPatientId, + 'risk': 'red', + 'status': 'PENDENTE', + 'outcome': '', + 'notes': '', + 'created_at': DateTime.utc(2026, 1, 1 + i).toIso8601String(), + 'version': 1, + }); + } + await v6.close(); + + expect(await storage.forOwner('acs-a').load(), isEmpty); + expect(await storage.forOwner('acs-b').load(), isEmpty); + expect((await storage.legacy.load()).map((v) => v.localId), + ['local-v6-1', 'local-v6-2']); + await storage.forOwner('acs-a').save([_visita('local-a1')]); + expect((await storage.legacy.load()).length, 2); + // save vazio de A também não toca na quarentena. + await storage.forOwner('acs-a').save([]); + expect((await storage.legacy.load()).length, 2); + }); + + test('legacy.remove apaga só os localIds pedidos e só os legados', () async { + final db = await storage.database.open(); + for (final id in ['leg-1', 'leg-2']) { + await db.insert('offline_visits', { + 'local_id': id, + 'patient_id': seedPatientId, + 'risk': 'red', + 'status': 'PENDENTE', + 'outcome': '', + 'notes': '', + 'created_at': DateTime.utc(2026).toIso8601String(), + 'version': 1, + }); + } + await storage.forOwner('acs-a').save([_visita('dono-1')]); + + await storage.legacy.remove(['leg-1', 'dono-1', 'inexistente']); + + expect((await storage.legacy.load()).map((v) => v.localId), ['leg-2']); + expect((await storage.forOwner('acs-a').load()).map((v) => v.localId), + ['dono-1']); + }); + + test('local_id é único globalmente: B não consegue sobrescrever a visita de A', () async { + // Comportamento escolhido: o save de B LANÇA VisitLocalIdConflict e a + // transação inteira é desfeita; a linha de A fica intacta (e as de B + // anteriores ao save também, pois o DELETE de B é revertido). + final a = storage.forOwner('acs-a'), b = storage.forOwner('acs-b'); + await a.save([_visita('local-x')]); + await b.save([_visita('local-b1')]); + + await expectLater( + b.save([_visita('local-b2'), _visita('local-x')]), + throwsA(isA()), + ); + + expect((await a.load()).map((v) => v.localId), ['local-x']); + expect((await b.load()).map((v) => v.localId), ['local-b1']); + }); + + test('o mesmo dono pode regravar o próprio local_id', () async { + final a = storage.forOwner('acs-a'); + await a.save([_visita('local-x')]); + await a.save([_visita('local-x'), _visita('local-y')]); + expect((await a.load()).map((v) => v.localId), ['local-x', 'local-y']); + }); + + test('InMemoryVisitStorage: um store por dono e quarentena própria', () async { + final mem = InMemoryVisitStorage(); + await mem.forOwner('a').save([_visita('l1')]); + expect(await mem.forOwner('b').load(), isEmpty); + expect(identical(mem.forOwner('a'), mem.forOwner('a')), isTrue); + expect(await mem.legacy.load(), isEmpty); + }); + + group('VisitDatabase single-flight', () { + test('N open() concorrentes abrem UMA só conexão', () async { + var aberturas = 0; + final db = VisitDatabase( + keyStore: InMemoryDatabaseKeyStore(), + databaseName: nome, + allowUnencryptedForTesting: true, + opener: (n, p, u) async { + aberturas++; + return EncryptedLocalDatabase.open( + databaseName: n, + passphrase: p, + allowUnencryptedForTesting: u, + ); + }, + ); + final todos = await Future.wait([for (var i = 0; i < 8; i++) db.open()]); + expect(aberturas, 1); + expect(todos.every((d) => identical(d, todos.first)), isTrue); + await db.close(); + }); + + test('no caminho de recuperação (chave errada) também abre uma só vez', () async { + // Arquivo criado com outra chave: a 1ª abertura falha e dispara apagar+recriar. + final antigo = await EncryptedLocalDatabase.open( + databaseName: nome, + passphrase: 'b' * 64, + allowUnencryptedForTesting: true, + ); + await antigo.close(); + + var aberturas = 0; + final db = VisitDatabase( + keyStore: InMemoryDatabaseKeyStore(), + databaseName: nome, + allowUnencryptedForTesting: true, + retryDelay: Duration.zero, + opener: (n, p, u) async { + aberturas++; + if (aberturas <= 2) { + throw _ErroDoBanco('open_failed /dados/$n'); // chave errada, como o Android relata + } + return EncryptedLocalDatabase.open( + databaseName: n, + passphrase: p, + allowUnencryptedForTesting: u, + ); + }, + ); + final todos = await Future.wait([for (var i = 0; i < 8; i++) db.open()]); + expect(aberturas, 3, reason: 'falha + nova tentativa + recriação, não por chamador'); + expect(todos.every((d) => identical(d, todos.first)), isTrue); + await db.close(); + }); + + test('falha de abertura não fica memoizada: a próxima chamada tenta de novo', () async { + var aberturas = 0; + final db = VisitDatabase( + keyStore: InMemoryDatabaseKeyStore(), + databaseName: nome, + allowUnencryptedForTesting: true, + opener: (n, p, u) async { + aberturas++; + if (aberturas <= 2) throw UnsupportedError('sem sqlcipher'); + return EncryptedLocalDatabase.open( + databaseName: n, + passphrase: p, + allowUnencryptedForTesting: u, + ); + }, + ); + await expectLater(db.open(), throwsA(isA())); + await expectLater(db.open(), throwsA(isA())); + expect((await db.open()).isOpen, isTrue); + await db.close(); + }); + }); + + group('recuperação (arquivo de lado, chave nova) só para chave errada, confirmada', () { + /// Abre o banco de verdade depois de [falhas] erros programados. + VisitDatabase banco( + List falhas, { + required List chamadas, + InMemoryDatabaseKeyStore? chaves, + Future Function(String databaseName)? moveAside, + }) { + return VisitDatabase( + keyStore: chaves ?? InMemoryDatabaseKeyStore(), + databaseName: nome, + allowUnencryptedForTesting: true, + retryDelay: Duration.zero, + moveAside: moveAside, + opener: (n, p, u) async { + chamadas.add('abrir'); + if (falhas.isNotEmpty) throw falhas.removeAt(0); + return EncryptedLocalDatabase.open(databaseName: n, passphrase: p, allowUnencryptedForTesting: u); + }, + ); + } + + Future arquivoComUmaVisita() async { + final s = SqlCipherVisitStorage(keyStore: InMemoryDatabaseKeyStore(), databaseName: nome, allowUnencryptedForTesting: true); + await s.forOwner('acs-a').save([_visita('sobrevive')]); + await s.close(); + } + + Future caminho() => EncryptedLocalDatabase.pathFor(nome); + Future copia() async => File('${await caminho()}.recuperado'); + + for (final (rotulo, erro) in <(String, Object)>[ + ('open_failed (Android: SQLCipher sem a chave certa)', _ErroDoBanco('open_failed /dados/x.db')), + ('file is not a database (código 26)', _ErroDoBanco('file is not a database (code 26)')), + ]) { + test('chave errada UMA vez ($rotulo) e a nova tentativa abre: NÃO recupera, nada muda', () async { + await arquivoComUmaVisita(); + final chamadas = []; + final chaves = InMemoryDatabaseKeyStore(initialKey: 'c' * 64); + final db = banco([erro], chamadas: chamadas, chaves: chaves); + + final aberto = await db.open(); + + expect(chamadas, ['abrir', 'abrir'], reason: 'falha + nova tentativa'); + expect(await chaves.readOrCreate(), 'c' * 64, reason: 'a chave fica'); + expect((await aberto.query('offline_visits')).map((r) => r['local_id']), ['sobrevive']); + expect((await copia()).existsSync(), isFalse, reason: 'sem cópia de recuperação'); + await db.close(); + }); + + test('chave errada DUAS vezes ($rotulo): move o arquivo para .recuperado e recomeça', () async { + await arquivoComUmaVisita(); + final bytesAntigos = await File(await caminho()).readAsBytes(); + final chamadas = []; + final chaves = InMemoryDatabaseKeyStore(initialKey: 'c' * 64); + final db = banco([erro, erro], chamadas: chamadas, chaves: chaves); + + final aberto = await db.open(); + + expect(chamadas, ['abrir', 'abrir', 'abrir'], reason: 'falha + nova tentativa + recriação'); + expect(await chaves.readOrCreate(), isNot('c' * 64), reason: 'a chave que não abria foi trocada'); + expect(await aberto.query('offline_visits'), isEmpty, reason: 'banco novo, vazio'); + expect(await (await copia()).readAsBytes(), bytesAntigos, reason: 'os bytes antigos ficam na cópia (artefato forense, ilegível sem a chave antiga)'); + await db.close(); + }); + } + + test('uma segunda recuperação SOBRESCREVE a cópia anterior (só existe uma)', () async { + final chave = _ErroDoBanco('open_failed /dados/x.db'); + await arquivoComUmaVisita(); + final primeira = banco([chave, chave], chamadas: []); + await primeira.open(); + final viaPrimeira = SqlCipherVisitStore.on(primeira, owner: 'acs-b'); + await viaPrimeira.save([_visita('segunda-geracao')]); + await primeira.close(); + final bytesDaSegunda = await File(await caminho()).readAsBytes(); + + final segunda = banco([chave, chave], chamadas: []); + await segunda.open(); + await segunda.close(); + + expect(await (await copia()).readAsBytes(), bytesDaSegunda); + final dir = Directory(File(await caminho()).parent.path); + expect(dir.listSync().where((f) => f.path.contains('.recuperado') && !f.path.endsWith('-journal')).length, 1); + }); + + test('se mover o arquivo falha: o erro sobe, o arquivo e a chave ficam intactos', () async { + await arquivoComUmaVisita(); + final bytesAntigos = await File(await caminho()).readAsBytes(); + final chave = _ErroDoBanco('open_failed /dados/x.db'); + final chaves = InMemoryDatabaseKeyStore(initialKey: 'c' * 64); + const falhaAoMover = FileSystemException('sem espaço para mover'); + final chamadas = []; + final db = banco( + [chave, chave], + chamadas: chamadas, + chaves: chaves, + moveAside: (_) async => throw falhaAoMover, + ); + + await expectLater(db.open(), throwsA(same(falhaAoMover))); + + expect(chamadas, ['abrir', 'abrir'], reason: 'sem recriação'); + expect(await chaves.readOrCreate(), 'c' * 64); + expect(await File(await caminho()).readAsBytes(), bytesAntigos); + expect((await copia()).existsSync(), isFalse); + }); + + test('"Limpar este aparelho" apaga também a cópia .recuperado; bloqueado, não', () async { + final storage = SqlCipherVisitStorage(keyStore: InMemoryDatabaseKeyStore(), databaseName: nome, allowUnencryptedForTesting: true); + await storage.forOwner('acs-a').save([_visita('pendente')]); + final backup = await copia(); + await backup.writeAsBytes([1, 2, 3]); + await File('${backup.path}-wal').writeAsBytes([4]); + + await expectLater(storage.database.wipeAllData(), throwsA(isA())); + expect(backup.existsSync(), isTrue, reason: 'nada é apagado quando o wipe é recusado'); + + await storage.forOwner('acs-a').save([]); + await storage.database.wipeAllData(); + expect(backup.existsSync(), isFalse, reason: 'fim da custódia: a cópia também sai'); + expect(File('${backup.path}-wal').existsSync(), isFalse); + await storage.close(); + }); + + for (final (rotulo, erro) in <(String, Object)>[ + ('falha de migração (SQL)', _ErroDoBanco('duplicate column name: owner')), + ('E/S (disco cheio)', _ErroDoBanco('disk I/O error (code 10)')), + ('PlatformException do canal', PlatformException(code: 'sqlite_error', message: 'canal indisponível')), + ('StateError', StateError('qualquer outra coisa')), + ]) { + test('outro erro — $rotulo: NÃO apaga nada e o erro sobe', () async { + await arquivoComUmaVisita(); + final chamadas = []; + final chaves = InMemoryDatabaseKeyStore(initialKey: 'c' * 64); + final db = banco([erro], chamadas: chamadas, chaves: chaves); + + await expectLater(db.open(), throwsA(same(erro))); + + expect(chamadas, ['abrir'], reason: 'sem segunda abertura'); + expect(await chaves.readOrCreate(), 'c' * 64, reason: 'a chave fica'); + final depois = SqlCipherVisitStorage(keyStore: InMemoryDatabaseKeyStore(), databaseName: nome, allowUnencryptedForTesting: true); + expect((await depois.forOwner('acs-a').load()).map((v) => v.localId), ['sobrevive'], reason: 'o arquivo fica'); + await depois.close(); + }); + } + + test('com o erro subindo, a fila segue na memória (persistenceFailed) e não perde nada', () async { + await arquivoComUmaVisita(); + final storage = SqlCipherVisitStorage(keyStore: InMemoryDatabaseKeyStore(), databaseName: nome, allowUnencryptedForTesting: true); + await storage.close(); + final chamadas = []; + final db = banco([_ErroDoBanco('disk I/O error (code 10)')], chamadas: chamadas); + final fila = OfflineVisitQueue(store: SqlCipherVisitStore.on(db, owner: 'acs-a')); + + await fila.restore(); + expect(fila.persistenceFailed, isTrue); + await fila.add(_visita('em-campo')); + expect(fila.pendingCount, 1); + await db.close(); + }); + }); + + group('dono inválido', () { + for (final ruim in ['', ' ', '\t\n']) { + test('"${ruim.trim()}" (${ruim.length} chars) é recusado em todos os pontos', () { + expect(() => storage.forOwner(ruim), throwsArgumentError); + expect( + () => SqlCipherVisitStore( + keyStore: InMemoryDatabaseKeyStore(), + owner: ruim, + databaseName: nome, + allowUnencryptedForTesting: true, + ), + throwsArgumentError, + ); + expect( + () => SqlCipherVisitStore.on(storage.database, owner: ruim), + throwsArgumentError, + ); + expect(() => InMemoryVisitStorage().forOwner(ruim), throwsArgumentError); + }); + } + }); + + group('unicidade global de local_id', () { + test('colisão com localId em QUARENTENA: o save lança e a linha legada fica', () async { + final db = await storage.database.open(); + await db.insert('offline_visits', { + 'local_id': 'leg-1', + 'patient_id': seedPatientId, + 'risk': 'red', + 'status': 'PENDENTE', + 'outcome': '', + 'notes': '', + 'created_at': DateTime.utc(2026).toIso8601String(), + 'version': 1, + }); + final a = storage.forOwner('acs-a'); + await a.save([_visita('a-1')]); + + await expectLater( + a.save([_visita('a-2'), _visita('leg-1')]), + throwsA(isA()), + ); + expect((await storage.legacy.load()).map((v) => v.localId), ['leg-1']); + expect((await a.load()).map((v) => v.localId), ['a-1']); + }); + + test('localId repetido na mesma lista: conflito com mensagem exata (SQLCipher)', () async { + final a = storage.forOwner('acs-a'); + await a.save([_visita('a-1')]); + await expectLater( + a.save([_visita('x'), _visita('x')]), + throwsA(isA() + .having((e) => e.repeatedInList, 'repeatedInList', isTrue) + .having((e) => e.toString(), 'msg', contains('repetido'))), + ); + expect((await a.load()).map((v) => v.localId), ['a-1']); + }); + + test('InMemory: mesma unicidade, tudo ou nada', () async { + final mem = InMemoryVisitStorage(legacyVisits: [_visita('leg-1')]); + final a = mem.forOwner('a'), b = mem.forOwner('b'); + await a.save([_visita('x')]); + await b.save([_visita('b-1')]); + + await expectLater( + b.save([_visita('b-2'), _visita('x')]), + throwsA(isA() + .having((e) => e.repeatedInList, 'repeatedInList', isFalse)), + ); + await expectLater( + b.save([_visita('leg-1')]), + throwsA(isA()), + ); + expect((await a.load()).map((v) => v.localId), ['x']); + expect((await b.load()).map((v) => v.localId), ['b-1']); + expect((await mem.legacy.load()).map((v) => v.localId), ['leg-1']); + + await expectLater( + b.save([_visita('y'), _visita('y')]), + throwsA(isA() + .having((e) => e.repeatedInList, 'repeatedInList', isTrue)), + ); + expect((await b.load()).map((v) => v.localId), ['b-1']); + + // o próprio dono pode regravar os seus + await b.save([_visita('b-1'), _visita('b-3')]); + expect((await b.load()).length, 2); + }); + }); +} + +/// Erro do sqflite como o plugin entrega (mensagem do SQLite/plugin). +class _ErroDoBanco extends DatabaseException { + _ErroDoBanco(super.message); + + @override + Object? get result => null; + + @override + int? getResultCode() { + final m = RegExp(r'code (\d+)').firstMatch(toString()); + return m == null ? null : int.parse(m.group(1)!); + } +} diff --git a/apps/acs/test_driver/integration_test.dart b/apps/acs/test_driver/integration_test.dart new file mode 100644 index 00000000..e10308b8 --- /dev/null +++ b/apps/acs/test_driver/integration_test.dart @@ -0,0 +1,6 @@ +import 'package:integration_test/integration_test_driver.dart'; + +/// Driver do `flutter drive`, usado por `scripts/qa/acs_gps_e2e.sh` (só ele +/// aceita `--use-application-binary`, preciso para manter a permissão de +/// localização concedida por `pm grant`). +Future main() => integrationDriver(); diff --git a/apps/acs/tool/live_check.dart b/apps/acs/tool/live_check.dart index 70447387..cd2e12c3 100644 --- a/apps/acs/tool/live_check.dart +++ b/apps/acs/tool/live_check.dart @@ -109,6 +109,8 @@ Future main(List args) async { // CA do BROKER (MQTT). A do RPC é a de [_devRpcCaBytes], e continuam sendo // duas: apontar esta para a CA do Traefik derruba a leg do MQTT. final caPath = _arg(args, 'ca', '../../infra/docker/mosquitto/runtime/certs/ca.crt'); + final clientCertPath = _arg(args, 'client-cert', '../../infra/docker/mosquitto/runtime/certs/acs-area-12.crt'); + final clientKeyPath = _arg(args, 'client-key', '../../infra/docker/mosquitto/runtime/certs/acs-area-12.key'); final emitMetrics = args.contains('--emit-metrics'); final visitCount = _intArg(args, 'visit-count', 1).clamp(1, 1000); @@ -201,6 +203,9 @@ Future main(List args) async { username: _arg(args, 'mqtt-user', BackendConfig.mqttUsername), password: mqttPassword, caCertificate: File(caPath).readAsBytesSync(), + // mTLS: o broker exige certificado de cliente (Task 7). + clientCertificate: File(clientCertPath).readAsBytesSync(), + clientPrivateKey: File(clientKeyPath).readAsBytesSync(), ), ); @@ -253,7 +258,7 @@ Future main(List args) async { // O único pedaço fora do caminho real é o store: SQLCipher não abre na VM. final queue = OfflineVisitQueue( store: InMemoryVisitStore(), - synchronizer: BackendVisitSynchronizer(backend: backend), + synchronizer: BackendVisitSynchronizer(backend: backend, ownerId: backend.session!.userId), ); for (var index = 0; index < visitCount; index++) { await queue.add(OfflineVisitRecord( diff --git a/apps/acs/tool/send_notice.dart b/apps/acs/tool/send_notice.dart new file mode 100644 index 00000000..c992e634 --- /dev/null +++ b/apps/acs/tool/send_notice.dart @@ -0,0 +1,85 @@ +/// Envia UM aviso comunitário como ACS de desenvolvimento e imprime só as contagens. +/// +/// dart run tool/send_notice.dart --title "SinalACS e2e" --message "Teste do Gorush" \ +/// [--chronic] [--host https://localhost/] +/// +/// Entra com `ACS_MATRICULA` e `ACS_PASSWORD` (login institucional, RF07) quando +/// definidas; sem elas, precisa da stack de pé com `ENABLE_DEV_LOGIN=true`. Nunca imprime token nem +/// destinatários. Saída: `recipients= accepted=` e código 0; uma recusa ou +/// falha de envio imprime a MENSAGEM e sai com 2 (para um script distinguir "falhou" +/// de "0 destinatários", que sai com 0). +library; + +import 'dart:io'; + +import 'package:sinalacs_acs/core/network/backend_client.dart'; + +import 'send_notice_login.dart'; + +String _arg(List args, String name, String fallback) { + final index = args.indexOf('--$name'); + return index >= 0 && index + 1 < args.length ? args[index + 1] : fallback; +} + +/// Mesma CA que `tool/live_check.dart` usa: a que assina o certificado do Traefik. +File _devRpcCaFile() { + final repoRoot = Directory.fromUri(Platform.script).parent.parent.parent.parent; + return File('${repoRoot.path}/infra/docker/traefik/runtime/certs/ca.crt'); +} + +Future main(List args) async { + if (args.contains('--help') || args.contains('-h')) { + stdout.writeln('uso: dart run tool/send_notice.dart --title --message ' + '[--chronic] [--host https://localhost/]'); + return; + } + final title = _arg(args, 'title', ''); + final message = _arg(args, 'message', ''); + if (title.isEmpty || message.isEmpty) { + stderr.writeln('erro: informe --title e --message.'); + exitCode = 2; + return; + } + final String host; + try { + host = requireSecureHost(_arg(args, 'host', 'https://localhost/')); + } on BackendFailure catch (failure) { + stderr.writeln('erro: ${failure.message}'); + exitCode = 2; + return; + } + final AcsCredentials? credentials; + try { + credentials = acsCredentialsFromEnv(Platform.environment); + } on ArgumentError catch (error) { + stderr.writeln('erro: ${error.message}'); + exitCode = 2; + return; + } + final caFile = _devRpcCaFile(); + if (!await caFile.exists()) { + stderr.writeln('erro: a CA do RPC não existe em ${caFile.path}. Suba a stack.'); + exitCode = 2; + return; + } + + final backend = BackendClient(host: host, trustedCaBytes: await caFile.readAsBytes()); + try { + if (credentials == null) { + await backend.developmentLogin(role: 'acs'); + } else { + await backend.login(matricula: credentials.matricula, senha: credentials.password); + } + final result = await backend.sendNotice( + title: title, + message: message, + chronicOnly: args.contains('--chronic'), + ); + stdout.writeln('recipients=${result.recipients} accepted=${result.accepted}'); + } on BackendFailure catch (failure) { + stderr.writeln('falhou: ${failure.message}'); + exitCode = 2; + } finally { + backend.close(); + } +} diff --git a/apps/acs/tool/send_notice_login.dart b/apps/acs/tool/send_notice_login.dart new file mode 100644 index 00000000..1e740cbe --- /dev/null +++ b/apps/acs/tool/send_notice_login.dart @@ -0,0 +1,24 @@ +/// Credencial institucional do ACS para as ferramentas de linha de comando. +/// +/// Vem de `ACS_MATRICULA` e `ACS_PASSWORD` no ambiente, e não de flags: um +/// argumento de linha de comando aparece em `ps` para qualquer usuário da +/// máquina. Sem nenhuma das duas, a ferramenta usa o login de desenvolvimento +/// (stack com `ENABLE_DEV_LOGIN=true`). +class AcsCredentials { + const AcsCredentials({required this.matricula, required this.password}); + + final String matricula; + final String password; + + @override + String toString() => 'AcsCredentials($matricula)'; // nunca a senha +} + +AcsCredentials? acsCredentialsFromEnv(Map env) { + final matricula = env['ACS_MATRICULA'] ?? ''; + final password = env['ACS_PASSWORD'] ?? ''; + if (matricula.isEmpty && password.isEmpty) return null; + if (matricula.isEmpty) throw ArgumentError('defina também ACS_MATRICULA (só ACS_PASSWORD veio).'); + if (password.isEmpty) throw ArgumentError('defina também ACS_PASSWORD (só ACS_MATRICULA veio).'); + return AcsCredentials(matricula: matricula, password: password); +} diff --git a/apps/admin/.gitignore b/apps/admin/.gitignore index 3820a95c..ba6d2380 100644 --- a/apps/admin/.gitignore +++ b/apps/admin/.gitignore @@ -27,6 +27,7 @@ migrate_working_dir/ **/doc/api/ **/ios/Flutter/.last_build_id .dart_tool/ +.flutter-plugins .flutter-plugins-dependencies .pub-cache/ .pub/ diff --git a/apps/admin/README.md b/apps/admin/README.md index f338d8a6..d4c47877 100644 --- a/apps/admin/README.md +++ b/apps/admin/README.md @@ -1,6 +1,6 @@ # SinalACS — Backoffice Admin -Backoffice administrativo (Flutter Web e Android, desktop-first com layout compacto para celular): login de desenvolvimento, painel de indicadores por risco clínico, listagem de microáreas e vínculo ACS, consulta filtrável de alertas e logs de auditoria. +Backoffice administrativo (Flutter Web e Android, desktop-first com layout compacto para celular): login institucional real (matrícula + senha + TOTP, `auth.loginStaff`), painel de indicadores por risco clínico, listagem de microáreas **com a gestão de contas de ACS** (cadastro, vínculo de microárea, redefinição de senha e de MFA, ativação/desativação) e a seção Equipe do backoffice para o administrador, consulta filtrável de alertas e logs de auditoria (escopados à UBS para o coordenador desde a #43), **avisos agendados** (agendar, cancelar, histórico) e, só para o administrador, **versões dos termos** (rascunho, revisão jurídica, publicação com 15 dias de antecedência), desde a #46. ## Rodar no Android @@ -8,8 +8,14 @@ Backoffice administrativo (Flutter Web e Android, desktop-first com layout compa flutter run -d emulator-5554 # emulador de celular flutter build apk --debug # valida a configuração Gradle flutter test integration_test -d emulator-5554 +flutter test integration_test/admin_mobile_smoke_test.dart -d emulator-5554 # navegação e layout (hermético) +flutter test integration_test/admin_notices_mobile_test.dart -d emulator-5554 # Avisos e Termos (hermético) ``` -Os testes de integração deste app são **herméticos** — rodam sobre `MockAdminDataSource` e não precisam do `docker compose`, do seed nem de `--dart-define`, ao contrário dos apps ACS e do paciente. +`admin_mobile_smoke_test.dart` e `admin_notices_mobile_test.dart` são **herméticos** (sobre `MockAdminDataSource`, sem backend); os `*_e2e.dart` é que exigem a stack. + +O login é real e **sem atalho de desenvolvimento**: precisa da stack no ar (`docker compose up`, que semeia `ADM-001` com a senha de `DEV_ADMIN_PASSWORD` do `.env`), de `./scripts/dev/sync_dev_ca.sh` (copia a CA do RPC para `assets/certs/`) e de um host HTTPS (`--dart-define=SINALACS_HOST=https://10.0.2.2/`, o padrão no emulador). O seed cria `ADM-001` **sem código de ativação**: antes do primeiro login, emita um em `backend/sinalacs_server` com `SERVERPOD_DATABASE_PASSWORD="$(grep -E '^POSTGRES_PASSWORD=' ../../.env | cut -d= -f2-)" dart run bin/issue_staff_activation_code.dart ADM-001 --issued-by seu-nome` (a senha do banco não tem padrão; troque `seu-nome` por quem emite) (válido por 24 h, mostrado uma única vez); com ele, a conta ativa a verificação em duas etapas pela própria tela. Depois, um administrador redefine a MFA de outro membro da equipe pela seção Equipe, que emite o código novo. Os dados do painel vêm do backend desde a #40 (o `MockAdminDataSource` só serve aos testes de widget). Os roteiros de prova contra o banco de teste são `./scripts/qa/admin_login_e2e.sh` (login e MFA do staff) e `./scripts/qa/admin_acs_gestao_e2e.sh` (gestão de contas: cadastro, vínculo, redefinições e desativação), ambos verdes no emulador `emulator-5554` — o primeiro também em celular Android 15; ver `PROGRESS.md`. + +Os testes de integração `*_test.dart` deste app são **herméticos** — rodam sobre `MockAdminDataSource` e não precisam do `docker compose`, do seed nem de `--dart-define`, ao contrário dos apps ACS e do paciente. Ver o [README.md](../../README.md) na raiz do repositório para pré-requisitos, comandos de execução/teste e o estado atual do projeto. Documentação visual das telas em [docs/telas-admin.md](../../docs/telas-admin.md). diff --git a/apps/admin/integration_test/admin_acs_gestao_e2e.dart b/apps/admin/integration_test/admin_acs_gestao_e2e.dart new file mode 100644 index 00000000..b7552777 --- /dev/null +++ b/apps/admin/integration_test/admin_acs_gestao_e2e.dart @@ -0,0 +1,617 @@ +/// Gestão de contas do backoffice (issue #43) no emulador, contra o BANCO DE +/// TESTE: cadastro de ACS com senha inicial mostrada uma vez, vínculo de +/// microárea, redefinição da MFA do ACS e da equipe, (des)ativação do acesso e +/// o escopo do papel `coordinator`. Rodado por +/// `scripts/qa/admin_acs_gestao_e2e.sh`. +/// +/// Cada mutação feita pela TELA é conferida por RPC direto no servidor real +/// (`sinalacs_client`), e o script confere o resto no banco de teste por SQL: +/// nenhuma prova se apoia só no que a tela desenhou. +/// +/// O relé (`scripts/qa/otp_relay.py`) entrega as credenciais sintéticas da +/// execução: `/admin` (bloco `staff` do manifesto) e `/coordenador` (bloco +/// `coordinator`), cada uma **uma única vez** — daí a memorização. +/// +/// Os testes dependem da ordem, como em `admin_login_e2e.dart`: o ACS criado no +/// primeiro é o que o coordenador enxerga e o que o administrador desativa e +/// redefine; o segredo TOTP nasce na ativação da MFA e é usado nos logins +/// seguintes; e o código de ativação do coordenador, emitido pelo reset do +/// administrador, é o que o RPC final consome. +/// +/// Sufixo `_e2e.dart` (não `_test.dart`): `flutter test integration_test` +/// descobre `*_test.dart` e o `e2e.sh --full` rodaria isto contra a stack de +/// desenvolvimento, onde não há fixtures nem relé. +/// +/// PRIVACIDADE: só fixtures sintéticas. Senha, código de ativação e segredo +/// TOTP nunca vão para log; a única linha impressa é a marcadora +/// `E2E_ACS_CRIADO`, com nome e matrícula sintéticos, que o script lê para os +/// asserts em SQL (o manifesto fica no host, o teste roda no aparelho). +@Timeout(Duration(minutes: 6)) +library; + +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data' show Uint8List; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show rootBundle; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:sinalacs_admin/app/admin_accounts.dart' show chaveConfirmarAcao; +import 'package:sinalacs_admin/app/app.dart'; +import 'package:sinalacs_admin/core/auth/admin_auth_bootstrap.dart'; +import 'package:sinalacs_admin/core/auth/backend_config.dart'; +import 'package:sinalacs_client/sinalacs_client.dart'; + +import 'support/e2e_admin.dart'; +import 'support/totp.dart'; + +/// Aparelho do login do ACS criado (D8 do plano 2026-10-03): com ele o login +/// emite refresh token, e é o token de envio diferido que a desativação revoga. +const _deviceId = 'e2e-dev-1'; + +int _passo(DateTime t) => t.toUtc().millisecondsSinceEpoch ~/ 1000 ~/ 30; + +/// Credencial do relé: matrícula, senha e o código de ativação da MFA (#48). +typedef _CredencialDoRele = ({String matricula, String senha, String activationCode}); + +/// Estado de UMA conta do backoffice no aparelho. +/// +/// O `segredoBase32` só existe na ativação da MFA (a tela mostra uma vez); o +/// `ultimoPasso` é o último passo TOTP ACEITO pelo servidor para esta conta — +/// o replay é barrado, então o próximo login precisa de um passo maior, e a +/// contagem é por conta (`user_credentials.totpLastStep`). +class _ContaDoBackoffice { + _ContaDoBackoffice({required this.matricula, required this.senha, this.codigoDeAtivacao}); + + final String matricula; + final String senha; + final String? codigoDeAtivacao; + String? segredoBase32; + int? ultimoPasso; + + Uint8List get segredo => base32Decode(segredoBase32!); +} + +late _ContaDoBackoffice _admin; +late _ContaDoBackoffice _coordenador; + +/// Id do ACS criado (o `sub` do token) e nome/matrícula sintéticos da execução: +/// é por eles que os testes seguintes acham o cartão e que o script monta os +/// asserts em SQL. +late String _acsCriadoId; +late String _acsCriadoNome; +late String _acsCriadaMatricula; + +Client? _rpc; + +/// Cliente RPC para as provas diretas no servidor — a MESMA CA e o mesmo host +/// que o app usa (`buildAdminWiring`), para a prova falar com o mesmo backend. +Future _clienteRpc() async { + final existente = _rpc; + if (existente != null) return existente; + final ca = (await rootBundle.load(adminRpcCaAsset)).buffer.asUint8List(); + return _rpc = Client( + AdminBackendConfig.requireSecureHost(AdminBackendConfig.host), + securityContext: SecurityContext()..setTrustedCertificatesBytes(ca), + )..connectivityMonitor = null; +} + +/// Id do usuário que o servidor pôs no token (`sub`), lido **sem** verificar a +/// assinatura (papel do servidor, como em `AdminSession.fromToken`): a lista de +/// ACS mostra nome e matrícula, nunca o id, e é o id que endereça o cartão +/// (`acs_`) — o ACS criado pela tela nasce com um UUID que só o servidor +/// conhece. +String _subDoToken(String accessToken) { + final partes = accessToken.split('.'); + if (partes.length != 3) throw StateError('token sem três seções'); + final payload = jsonDecode(utf8.decode(base64Url.decode(base64Url.normalize(partes[1])))) as Map; + return payload['sub'] as String; +} + +/// Credencial do coordenador sintético (bloco `coordinator` do manifesto), +/// entregue pelo relé em `/coordenador` uma única vez. +/// +/// A busca vive aqui, e não em `support/e2e_admin.dart`, porque aquele apoio +/// atende só `/admin` e esta tarefa não o altera; `relayBase` é o mesmo. +Future<_CredencialDoRele> _coordenadorDoRele() async { + final jaBuscada = _credencialDoCoordenador; + if (jaBuscada != null) return jaBuscada; + return _credencialDoCoordenador = await _buscar('/coordenador'); +} + +_CredencialDoRele? _credencialDoCoordenador; + +Future<_CredencialDoRele> _buscar(String rota) async { + final client = HttpClient(); + try { + final request = await client.getUrl(Uri.parse(relayBase).replace(path: rota)); + final response = await request.close(); + final body = await utf8.decodeStream(response); + if (response.statusCode != 200) throw StateError('o relé respondeu ${response.statusCode} a $rota'); + final json = jsonDecode(body) as Map; + return ( + matricula: json['matricula'] as String, + senha: json['senha'] as String, + activationCode: json['activationCode'] as String, + ); + } on SocketException { + throw StateError('o relé não está no ar (scripts/qa/otp_relay.py + adb reverse tcp:8765 tcp:8765)'); + } finally { + client.close(force: true); + } +} + +Future _pumpUntil( + WidgetTester tester, + bool Function() done, { + Duration timeout = const Duration(seconds: 30), + Duration step = const Duration(milliseconds: 200), +}) async { + final fim = DateTime.now().add(timeout); + while (!done() && DateTime.now().isBefore(fim)) { + await tester.pump(step); + } + if (done()) return; + final telas = [ + for (final k in const ['login_error', 'login_aviso', 'activation_error', 'mfa_error', 'cadastro_erro']) + if (find.byKey(Key(k)).evaluate().isNotEmpty) + '$k=${find.descendant(of: find.byKey(Key(k)), matching: find.byType(Text), matchRoot: true).evaluate().map((e) => (e.widget as Text).data).join(' ')}', + ]; + fail('condição não atingida em ${timeout.inSeconds}s; tela: ${telas.isEmpty ? '(nada)' : telas.join('; ')}'); +} + +bool _existe(String chave) => find.byKey(Key(chave)).evaluate().isNotEmpty; + +bool get _painelAberto => find.text('Painel de Indicadores').evaluate().isNotEmpty; + +Finder get _lista => find.byType(Scrollable).last; + +/// Vai ao topo da lista. +/// +/// A lista das Microáreas é mais alta que a tela e **preguiçosa**: um cartão que +/// saiu de cena não existe mais na árvore, e `ensureVisible` não acha o que não +/// foi construído. Rolar não é zelo: um `tap` num widget fora da tela não lança +/// nada — apenas erra o alvo, e o teste seguiria como se tivesse tocado. +Future _rolarAoTopo(WidgetTester tester) async { + for (var i = 0; i < 20; i++) { + if (find.byType(Scrollable).evaluate().isEmpty) { + await tester.pump(const Duration(milliseconds: 200)); + continue; + } + final posicao = tester.state(_lista).position; + if (posicao.pixels <= 0) return; + await tester.drag(_lista, const Offset(0, 400)); + await tester.pump(const Duration(milliseconds: 200)); + } +} + +/// Rola para baixo — em passos de 320dp, como o harness de layout — até +/// [condicao] valer, e FALHA dizendo o que faltou. +/// +/// A condição é a própria asserção, e não "o cartão existe": depois de uma +/// escrita a tela troca a lista pelo indicador e a recarrega do servidor, então +/// um cartão achado no instante seguinte ao diálogo ainda é o antigo. Rolar +/// dentro da espera é o que faz a condição olhar para o item certo quando a +/// lista é preguiçosa. +Future _rolarAte( + WidgetTester tester, + bool Function() condicao, { + required String oQue, + Duration timeout = const Duration(seconds: 60), +}) async { + final fim = DateTime.now().add(timeout); + while (!condicao() && DateTime.now().isBefore(fim)) { + if (find.byType(Scrollable).evaluate().isNotEmpty) { + await tester.drag(_lista, const Offset(0, -320)); + } + await tester.pump(const Duration(milliseconds: 250)); + } + if (!condicao()) fail('$oQue não aconteceu em ${timeout.inSeconds}s'); +} + +/// Rola até o fim da tela. +/// +/// A ausência de uma seção só é significativa depois de passar por tudo: um +/// widget abaixo da dobra não está na árvore, e `findsNothing` passaria por ele +/// nunca ter sido construído — não por não existir. +Future _rolarAteOFim(WidgetTester tester) async { + var anterior = -1.0; + for (var i = 0; i < 40; i++) { + final posicao = tester.state(_lista).position; + if (posicao.pixels >= posicao.maxScrollExtent || posicao.pixels == anterior) return; + anterior = posicao.pixels; + await tester.drag(_lista, const Offset(0, -320)); + await tester.pump(const Duration(milliseconds: 250)); + } +} + +Future _tocar(WidgetTester tester, Finder alvo) async { + await _rolarAoTopo(tester); + await _rolarAte(tester, () => alvo.evaluate().isNotEmpty, oQue: 'o alvo do toque na tela'); + await tester.ensureVisible(alvo); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(alvo); +} + +/// Espera a seção sair do estado ocupado, olhando um botão dela. +/// +/// Durante uma escrita a seção desabilita os botões, e tocar num botão +/// desabilitado **não lança**: apenas não acontece nada, e o passo seguinte +/// esperaria por um diálogo que nunca abriria. A segunda redefinição de MFA não +/// muda nada visível no cartão (a MFA já estava redefinida; o que muda é o +/// segredo pendente, que a tela não mostra) — então é este, e não o texto do +/// cartão, o sinal de que a escrita terminou. +Future _esperarAcoesLivres(WidgetTester tester, String chaveDoBotao) => _rolarAte( + tester, + () { + final botao = find.byKey(Key(chaveDoBotao)); + if (botao.evaluate().isEmpty) return false; + return tester.widget(botao).onPressed != null; + }, + oQue: 'a seção livre para a próxima ação', + ); + +Future _digitar(WidgetTester tester, String chave, String texto) async { + final campo = find.byKey(Key(chave)); + await tester.ensureVisible(campo); + await tester.pump(const Duration(milliseconds: 100)); + await tester.enterText(campo, texto); +} + +/// Escolhe a microárea num dos dropdowns dos diálogos (#43). +/// +/// `.last` no item: o nome também está no cartão da microárea, atrás do diálogo, +/// e no próprio botão quando ele já tem valor — o item aberto é o último. +Future _escolherMicroArea(WidgetTester tester, String chaveDoCampo, String nome) async { + await _tocar(tester, find.byKey(Key(chaveDoCampo))); + await tester.pump(const Duration(milliseconds: 600)); + await tester.tap(find.text(nome).last); + await tester.pump(const Duration(milliseconds: 600)); +} + +Future _abrirApp(WidgetTester tester) async { + final ca = (await rootBundle.load(adminRpcCaAsset)).buffer.asUint8List(); + final fiacao = buildAdminWiring(caBytes: ca); + await tester.pumpWidget(SinalAdminApp(auth: fiacao.auth, dataSourceFor: fiacao.dataSourceFor)); + await tester.pump(const Duration(milliseconds: 500)); +} + +Future _irPara(WidgetTester tester, String destino) async { + // Na barra inferior do celular o rótulo é curto ('Áreas'); o nome completo + // fica no tooltip. + final porTexto = find.text(destino); + await _tocar(tester, (porTexto.evaluate().isNotEmpty ? porTexto : find.byTooltip(destino)).last); + await tester.pump(const Duration(milliseconds: 300)); +} + +/// Entra pela tela. No primeiro acesso a conta não tem MFA e a tela abre a +/// ativação (o código vem do relé); nas seguintes, o campo do código aparece +/// direto. +Future _entrar(WidgetTester tester, _ContaDoBackoffice conta) async { + await _digitar(tester, 'matricula_field', conta.matricula); + await _digitar(tester, 'senha_field', conta.senha); + await _tocar(tester, find.byKey(const Key('login_button'))); + + await _pumpUntil( + tester, + () => _existe('activation_code_field') || _existe('totp_field') || _existe('login_error') || _painelAberto, + ); + if (_existe('activation_code_field')) await _ativarMfa(tester, conta); + + await _pumpUntil(tester, () => _existe('totp_field') || _existe('login_error') || _painelAberto); + if (_existe('totp_field')) await _informarCodigo(tester, conta); + + await _pumpUntil(tester, () => _painelAberto || _existe('login_error')); + expect( + _existe('login_error'), + isFalse, + reason: _existe('login_error') ? (tester.widget(find.byKey(const Key('login_error'))).data ?? '') : '', + ); + expect(_painelAberto, isTrue, reason: 'o login não abriu o painel'); +} + +/// Ativação da MFA pela tela (primeiro acesso): o código de ativação libera o +/// segredo, e o código do autenticador confirma. O passo confirmado já foi +/// aceito pelo servidor, então o login seguinte precisa de um passo maior. +Future _ativarMfa(WidgetTester tester, _ContaDoBackoffice conta) async { + await _digitar(tester, 'activation_code_field', conta.codigoDeAtivacao!); + await _tocar(tester, find.byKey(const Key('activation_continue'))); + await _pumpUntil(tester, () => _existe('mfa_secret') || _existe('mfa_error'), timeout: const Duration(seconds: 60)); + expect(_existe('mfa_error'), isFalse, + reason: _existe('mfa_error') ? (tester.widget(find.byKey(const Key('mfa_error'))).data ?? '') : ''); + conta.segredoBase32 = tester.widget(find.byKey(const Key('mfa_secret'))).data!; + expect(conta.segredoBase32, isNotEmpty); + + final instante = DateTime.now(); + conta.ultimoPasso = _passo(instante); + await _digitar(tester, 'mfa_code_field', totpCode(conta.segredo, instante)); + await _tocar(tester, find.byKey(const Key('mfa_confirm_button'))); +} + +/// Informa o código do autenticador num passo NOVO: o servidor barra o replay +/// (o `UPDATE` de `totpLastStep` só avança), então reentrar exige esperar o +/// relógio de 30 s passar do passo da ativação ou do login anterior. +Future _informarCodigo(WidgetTester tester, _ContaDoBackoffice conta) async { + await _pumpUntil( + tester, + () => _passo(DateTime.now()) > conta.ultimoPasso!, + timeout: const Duration(seconds: 40), + step: const Duration(milliseconds: 500), + ); + final instante = DateTime.now(); + conta.ultimoPasso = _passo(instante); + await _digitar(tester, 'totp_field', totpCode(conta.segredo, instante)); + await _tocar(tester, find.byKey(const Key('login_button'))); +} + +/// Cadastro de ACS pela tela: "Novo ACS" → nome, matrícula e microárea → +/// confirmar → a senha inicial aparece uma única vez. Devolve a senha lida. +Future _cadastrarAcs( + WidgetTester tester, { + required String nome, + required String matricula, + required String microArea, +}) async { + await _tocar(tester, find.byKey(const Key('novo_acs'))); + await _pumpUntil(tester, () => _existe('acs_nome_field')); + // O botão de confirmar só habilita com os três campos preenchidos. + expect( + tester.widget(find.byKey(chaveConfirmarAcao)).onPressed, + isNull, + reason: 'o formulário vazio não pode habilitar o cadastro', + ); + await _digitar(tester, 'acs_nome_field', nome); + await _digitar(tester, 'acs_matricula_field', matricula); + await _escolherMicroArea(tester, 'acs_microarea_field', microArea); + expect(tester.widget(find.byKey(chaveConfirmarAcao)).onPressed, isNotNull); + + await _tocar(tester, find.byKey(chaveConfirmarAcao)); + // Derivar o Argon2id da senha inicial é do servidor: o diálogo da credencial + // só abre depois da resposta. + await _pumpUntil(tester, () => _existe('senha_inicial') || _existe('cadastro_erro'), timeout: const Duration(seconds: 60)); + expect(_existe('cadastro_erro'), isFalse, + reason: _existe('cadastro_erro') ? (tester.widget(find.byKey(const Key('cadastro_erro'))).data ?? '') : ''); + final senha = tester.widget(find.byKey(const Key('senha_inicial'))).data!; + expect(senha, isNotEmpty); + await _tocar(tester, find.byKey(const Key('fechar_credencial'))); + await _pumpUntil(tester, () => !_existe('senha_inicial')); + return senha; +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('admin cadastra um ACS, vincula, redefine a MFA e desativa — cada passo provado por RPC', + (tester) async { + expect(AdminBackendConfig.host, startsWith('https://')); + await _abrirApp(tester); + final cred = await adminCredentialFromRelay(); + _admin = _ContaDoBackoffice(matricula: cred.matricula, senha: cred.senha, codigoDeAtivacao: cred.activationCode); + await _entrar(tester, _admin); + + await _irPara(tester, 'Microáreas'); + await _pumpUntil(tester, () => _existe('novo_acs')); + + // 1. Cadastro pela tela (passos 1 do plano 12). + final carimbo = DateTime.now().millisecondsSinceEpoch; + _acsCriadoNome = 'E2E ACS $carimbo'; + _acsCriadaMatricula = 'E2E-ACS-$carimbo'; + final senhaInicial = await _cadastrarAcs( + tester, + nome: _acsCriadoNome, + matricula: _acsCriadaMatricula, + microArea: 'Microárea E2E', + ); + + // A ponte aparelho → host: o manifesto (com o id) fica no host, o teste roda + // no emulador, e o script precisa da matrícula e do nome para os asserts em + // SQL. Nome e matrícula são sintéticos — nada de dado real. + // ignore: avoid_print + print('E2E_ACS_CRIADO matricula=$_acsCriadaMatricula nome=$_acsCriadoNome'); + + // 2. Prova por RPC: a senha mostrada uma única vez é a credencial de + // verdade. Com `deviceId` o login emite o refresh token que a desativação + // vai ter de revogar. + final client = await _clienteRpc(); + final login = await client.auth.loginInstitutional( + matricula: _acsCriadaMatricula, + password: senhaInicial, + deviceId: _deviceId, + ); + expect(login.accessToken, isNotEmpty); + expect(login.refreshToken, isNotNull, reason: 'login com deviceId emite refresh token'); + final tokenDeRefresh = login.refreshToken!; + _acsCriadoId = _subDoToken(login.accessToken); + + // 3. MFA do ACS ativada por RPC: a tela do ACS não é o assunto deste e2e; o + // que se prova adiante é que a redefinição da coordenação apaga este + // segredo, e o `beginTotpEnrollment` seguinte já responde outro. + final inicio = await client.auth.beginTotpEnrollment( + matricula: _acsCriadaMatricula, + password: senhaInicial, + ); + final segredoDoAcs = base32Decode(inicio.secretBase32); + await client.auth.confirmTotpEnrollment( + matricula: _acsCriadaMatricula, + password: senhaInicial, + code: totpCode(segredoDoAcs, DateTime.now()), + ); + + // 4. Vínculo de microárea pela tela. O diálogo não pede segunda confirmação, + // mas só habilita com uma microárea DIFERENTE da atual. + final cartaoDoAcs = find.byKey(Key('acs_$_acsCriadoId')); + await _tocar(tester, find.byKey(Key('vincular_acs_$_acsCriadoId'))); + await _pumpUntil(tester, () => _existe('vincular_microarea_field')); + expect( + tester.widget(find.byKey(chaveConfirmarAcao)).onPressed, + isNull, + reason: 'sem uma microárea nova escolhida o vínculo não pode habilitar', + ); + await _escolherMicroArea(tester, 'vincular_microarea_field', 'Outra Microárea E2E'); + expect(tester.widget(find.byKey(chaveConfirmarAcao)).onPressed, isNotNull); + await _tocar(tester, find.byKey(chaveConfirmarAcao)); + + // O estado que a tela mostra vem do servidor: o cartão passa a exibir o + // território novo depois de a lista recarregar. + await _rolarAte( + tester, + () => find.descendant(of: cartaoDoAcs, matching: find.text('Território: Outra Microárea E2E')).evaluate().isNotEmpty, + oQue: 'o cartão do ACS no território novo', + ); + + // 5. Redefinição da MFA do ACS pela tela (ação destrutiva: confirmação). + await _tocar(tester, find.byKey(Key('redefinir_mfa_$_acsCriadoId'))); + await _pumpUntil(tester, () => _existe('confirmar_acao')); + await _tocar(tester, find.byKey(chaveConfirmarAcao)); + await _rolarAte( + tester, + () => find.descendant(of: cartaoDoAcs, matching: find.text('MFA não ativada')).evaluate().isNotEmpty, + oQue: 'o cartão do ACS com a MFA redefinida', + ); + + // Prova por RPC: com o segredo apagado, `beginTotpEnrollment` volta a + // responder — e com um segredo NOVO, que é o que prova que o antigo não + // vale mais. + final reinicio = await client.auth.beginTotpEnrollment( + matricula: _acsCriadaMatricula, + password: senhaInicial, + ); + expect(reinicio.secretBase32, isNotEmpty); + expect(reinicio.secretBase32, isNot(equals(inicio.secretBase32))); + + // A prova acima gravou um segredo PENDENTE (`totpSecretEncrypted`), e o + // assert final do script exige a conta com as quatro colunas `totp*` nulas: + // a última palavra sobre a MFA desta conta tem de ser a redefinição. + await _tocar(tester, find.byKey(Key('redefinir_mfa_$_acsCriadoId'))); + await _pumpUntil(tester, () => _existe('confirmar_acao')); + await _tocar(tester, find.byKey(chaveConfirmarAcao)); + await _rolarAte( + tester, + () => find.descendant(of: cartaoDoAcs, matching: find.text('MFA não ativada')).evaluate().isNotEmpty, + oQue: 'o cartão do ACS sem MFA pendente', + ); + await _esperarAcoesLivres(tester, 'desativar_acs_$_acsCriadoId'); + + // 6. Desativação pela tela (destrutiva: confirmação). + await _tocar(tester, find.byKey(Key('desativar_acs_$_acsCriadoId'))); + await _pumpUntil(tester, () => _existe('confirmar_acao')); + await _tocar(tester, find.byKey(chaveConfirmarAcao)); + await _rolarAte( + tester, + () => find.descendant(of: cartaoDoAcs, matching: find.text('Inativo')).evaluate().isNotEmpty, + oQue: 'o cartão do ACS desativado', + ); + + // 7. Prova por RPC: o login passa a recusar com a mensagem de acesso + // inativo, e a renovação da sessão cai — a família inteira foi revogada na + // mesma transação da flag. + await expectLater( + client.auth.loginInstitutional(matricula: _acsCriadaMatricula, password: senhaInicial, deviceId: _deviceId), + throwsA(isA().having((e) => e.message, 'message', 'Este acesso está inativo.')), + ); + await expectLater( + client.auth.refreshSession(refreshToken: tokenDeRefresh, deviceId: _deviceId), + throwsA(isA()), + ); + }); + + testWidgets('coordenador entra, não vê a equipe do backoffice e cadastra outro ACS na própria UBS', + (tester) async { + await _abrirApp(tester); + final cred = await _coordenadorDoRele(); + _coordenador = + _ContaDoBackoffice(matricula: cred.matricula, senha: cred.senha, codigoDeAtivacao: cred.activationCode); + await _entrar(tester, _coordenador); + + await _irPara(tester, 'Microáreas'); + await _pumpUntil(tester, () => _existe('novo_acs')); + + // Escopo POSITIVO: o ACS criado pelo administrador é da mesma UBS (as duas + // microáreas são dela), então o coordenador o enxerga. + final cartaoDoAcs = find.byKey(Key('acs_$_acsCriadoId')); + await _rolarAte(tester, () => cartaoDoAcs.evaluate().isNotEmpty, oQue: 'o ACS criado pelo administrador na lista do coordenador'); + expect( + find.descendant(of: cartaoDoAcs, matching: find.text('Território: Outra Microárea E2E')), + findsOneWidget, + ); + + // Escopo do PAPEL: a seção de equipe do backoffice não existe para o + // coordenador (o servidor também recusa essa listagem para ele). A + // asserção vale depois de rolar a tela inteira — um rodapé que nunca foi + // construído também não apareceria. + await _rolarAteOFim(tester); + expect(find.text('Equipe do backoffice'), findsNothing); + expect(find.textContaining('A redefinição de MFA é feita por outro administrador'), findsNothing); + + // E o coordenador cadastra na própria UBS: cadastro não é exclusividade do + // administrador, é escopo. + final carimbo = DateTime.now().millisecondsSinceEpoch; + final senhaInicial = await _cadastrarAcs( + tester, + nome: 'E2E ACS B $carimbo', + matricula: 'E2E-ACS-B-$carimbo', + microArea: 'Microárea E2E', + ); + await _rolarAte( + tester, + () => find.text('E2E ACS B $carimbo').evaluate().isNotEmpty, + oQue: 'o segundo ACS cadastrado pelo coordenador', + ); + + // Prova por RPC: a senha inicial que a tela mostrou entra no servidor de + // verdade (mesma prova do primeiro cadastro, agora pelo caminho do papel + // `coordinator`). + final client = await _clienteRpc(); + final login = await client.auth.loginInstitutional( + matricula: 'E2E-ACS-B-$carimbo', + password: senhaInicial, + deviceId: _deviceId, + ); + expect(login.accessToken, isNotEmpty); + }); + + testWidgets('admin redefine a MFA do coordenador e o código emitido pela tela ativa a MFA nova', + (tester) async { + await _abrirApp(tester); + await _entrar(tester, _admin); + await _irPara(tester, 'Microáreas'); + await _pumpUntil(tester, () => _existe('novo_acs')); + + // O relé entrega matrícula, senha e código de ativação, nunca o UUID — quem + // lê o id do manifesto é o script. O cartão se acha pela matrícula, e o + // botão é o único do cartão (é ele que carrega a chave + // `redefinir_mfa_staff_`). + final cartaoDoCoordenador = find.ancestor( + of: find.text('Matrícula: ${_coordenador.matricula} • Coordenador'), + matching: find.byType(Card), + ); + await _rolarAte(tester, () => cartaoDoCoordenador.evaluate().isNotEmpty, oQue: 'o cartão do coordenador'); + final botaoRedefinirMfa = find.descendant(of: cartaoDoCoordenador, matching: find.byType(OutlinedButton)); + expect(botaoRedefinirMfa, findsOneWidget); + + await _tocar(tester, botaoRedefinirMfa); + await _pumpUntil(tester, () => _existe('confirmar_acao')); + await _tocar(tester, find.byKey(chaveConfirmarAcao)); + + // O código de ativação novo existe só nesta resposta (#48): a tela mostra + // uma vez e o servidor guarda só o hash. + await _pumpUntil(tester, () => _existe('codigo_ativacao'), timeout: const Duration(seconds: 60)); + final codigoNovo = tester.widget(find.byKey(const Key('codigo_ativacao'))).data!; + expect(codigoNovo, isNotEmpty); + expect(codigoNovo, isNot(equals(_coordenador.codigoDeAtivacao))); + await _tocar(tester, find.byKey(const Key('fechar_credencial'))); + await _pumpUntil(tester, () => !_existe('codigo_ativacao')); + + // Prova por RPC: o código emitido pela tela ativa a MFA nova — e o segredo + // é outro, porque o antigo foi apagado pela redefinição. + final client = await _clienteRpc(); + final reinicio = await client.auth.beginStaffTotpEnrollment( + matricula: _coordenador.matricula, + password: _coordenador.senha, + activationCode: codigoNovo, + ); + expect(reinicio.secretBase32, isNotEmpty); + expect(reinicio.secretBase32, isNot(equals(_coordenador.segredoBase32))); + }); +} diff --git a/apps/admin/integration_test/admin_alertas_escalonar_e2e.dart b/apps/admin/integration_test/admin_alertas_escalonar_e2e.dart new file mode 100644 index 00000000..9afe6f39 --- /dev/null +++ b/apps/admin/integration_test/admin_alertas_escalonar_e2e.dart @@ -0,0 +1,379 @@ +/// Escalonamento de alerta vermelho vencido e atualização quase em tempo real +/// do backoffice (issue #44) no emulador, contra o BANCO DE TESTE. Rodado por +/// `scripts/qa/admin_alertas_escalonar_e2e.sh`. +/// +/// O coordenador sintético (relé, `/coordenador`) entra pela tela e, **sem +/// nenhuma atualização manual** nos trechos que provam o pulso (nem filtro, nem +/// pull-to-refresh, nem troca de aba: só `tester.pump` e o tempo real +/// passando), vê: +/// +/// 1. no Painel, "Abertos (pendentes)" = 1 (o vencido semeado); o script insere +/// o alerta novo e o Painel passa a 2 SOZINHO, no próximo pulso (≤ ~31 s). +/// O Painel desenha `monitoring.lastPulse?.indicators`, e o +/// `fetchDashboardIndicators` só roda ao montar a tela — por isso a prova é +/// feita sem trocar de aba (trocar remontaria a tela e releria); +/// 2. em Alertas (aberta DEPOIS, com leitura nova): o vencido semeado com o +/// botão "Escalonar", e o novo ainda no prazo, **sem** o botão; +/// 3. com a aba de Alertas parada, o botão do novo aparece sozinho depois do +/// prazo (≤ 35 s nominais: um pulso de 30 s mais a recarga da lista, que o +/// pulso dispara porque `overdueRed` muda); +/// 4. o escalonamento pela tela, com motivo: a linha continua "Pendente" e +/// ganha "Escalonado às"; o painel continua contando o alerta como aberto; +/// 5. o reescalonamento por RPC direto devolve `alreadyEscalated` com o MESMO +/// `escalatedAt`; +/// 6. a perda de conexão (o script pausa o Traefik, a única rota do aparelho +/// até o backend; o backend e a conexão MQTT dele ficam no ar): o selo de +/// dado desatualizado aparece com os dados anteriores na tela e some +/// sozinho quando a rota volta. +/// +/// Linha do tempo (relógio do banco; I = instante do INSERT do novo, ~1 s +/// depois de `E2E_DISPARAR_ALERTA`): `triggeredAt` = I + 20 s, prazo vence em +/// I + 50 s. Pulso de 30 s com fase qualquer: o Painel vê o 2 em ≤ I + 30 s; a +/// aba de Alertas abre em ≤ I + ~33 s, ainda ≥ 17 s antes do prazo (o novo +/// entra sem botão); o botão aparece no 1º pulso depois de I + 50 s, ou seja +/// em ≤ I + 80 s mais a recarga. +/// +/// **A conversa com o script** é por linhas marcadoras impressas aqui +/// (`E2E_DISPARAR_ALERTA`, `E2E_CORTAR_BACKEND`; `E2E_PAINEL_ATUALIZOU` e +/// `E2E_BOTAO_APARECEU` são só evidência, com o atraso medido): o teste roda no aparelho e +/// não alcança o banco nem o Docker; o script lê a saída do `flutter test` em +/// tempo real e age no host. Os ids dos alertas chegam por `--dart-define` +/// (UUIDs sintéticos, não são segredo). +/// +/// Sufixo `_e2e.dart` (não `_test.dart`): `flutter test integration_test` +/// descobre `*_test.dart` e o `e2e.sh --full` rodaria isto contra a stack de +/// desenvolvimento, onde não há fixtures, relé nem o prazo de 30 s. +/// +/// PRIVACIDADE: só fixtures sintéticas. Senha, código de ativação, segredo TOTP +/// e token nunca vão para log; as linhas impressas levam só ids sintéticos. +@Timeout(Duration(minutes: 12)) +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show rootBundle; +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/auth/admin_auth_backend.dart' show AdminSession; +import 'package:sinalacs_admin/core/auth/admin_auth_bootstrap.dart'; +import 'package:sinalacs_admin/core/auth/backend_config.dart'; +import 'package:sinalacs_client/sinalacs_client.dart' as api; + +import 'support/e2e_admin.dart' show relayBase; +import 'support/totp.dart'; + +/// Alerta semeado já vencido (`E2E_SEED_ALERT_ESCALATION=1`). +const _alertaVencido = String.fromEnvironment('E2E_ALERTA_VENCIDO'); + +/// Alerta que o script insere quando este teste pede (`E2E_DISPARAR_ALERTA`). +const _alertaNovo = String.fromEnvironment('E2E_ALERTA_NOVO'); + +/// Prazo da stack de e2e (`RED_ALERT_ESCALATION_SECONDS` em +/// `docker-compose.e2e.yml`, `e2eEscalationThresholdSeconds` no backend). +const _prazo = Duration(seconds: 30); + +/// Atraso máximo aceito entre o prazo vencer e o botão aparecer: 35 s +/// nominais (pulso de 30 s + a recarga da lista) mais 5 s de tolerância para a +/// diferença entre o relógio do aparelho e o do servidor. +const _atrasoMaximoDoBotao = Duration(seconds: 40); + +/// Atraso máximo aceito para o Painel passar de 1 a 2 depois do pedido de +/// disparo: um pulso (30 s) + o INSERT (~1 s) + folga. +const _atrasoMaximoDoPainel = Duration(seconds: 40); + +/// Quanto tempo o script deixa o Traefik pausado depois de `E2E_CORTAR_BACKEND` +/// (o nome do marcador ficou da 1ª versão, que pausava o backend). +/// O selo pode levar ~80–90 s depois do corte (último sucesso logo antes dele + +/// 2 pulsos falhos de 30 s com prazo de 20 s cada, e o selo só é redesenhado +/// quando um pulso falho notifica); 120 s deixa folga para vê-lo antes da volta. +const _segundosDeCorte = 120; + +int _passo(DateTime t) => t.toUtc().millisecondsSinceEpoch ~/ 1000 ~/ 30; + +/// A sessão que o login da tela abriu: a fábrica da fonte de dados recebe a +/// `AdminSession` do servidor, e é o token dela que o RPC direto usa — sem um +/// segundo login (que consumiria outro passo TOTP). +AdminSession? _sessao; + +api.Client? _rpc; + +/// Cliente RPC para as provas diretas — a MESMA CA e o mesmo host que o app +/// usa (`buildAdminWiring`). +Future _clienteRpc() async { + final existente = _rpc; + if (existente != null) return existente; + final ca = (await rootBundle.load(adminRpcCaAsset)).buffer.asUint8List(); + return _rpc = api.Client( + AdminBackendConfig.requireSecureHost(AdminBackendConfig.host), + securityContext: SecurityContext()..setTrustedCertificatesBytes(ca), + )..connectivityMonitor = null; +} + +String get _token { + final sessao = _sessao; + if (sessao == null) fail('o login não abriu sessão (a fábrica da fonte de dados não foi chamada)'); + return sessao.accessToken; +} + +/// Credencial do coordenador sintético (bloco `coordinator` do manifesto), +/// entregue pelo relé em `/coordenador` uma única vez. +Future<({String matricula, String senha, String activationCode})> _coordenadorDoRele() async { + final client = HttpClient(); + try { + final request = await client.getUrl(Uri.parse(relayBase).replace(path: '/coordenador')); + final response = await request.close(); + final body = await utf8.decodeStream(response); + if (response.statusCode != 200) throw StateError('o relé respondeu ${response.statusCode} a /coordenador'); + final json = jsonDecode(body) as Map; + return ( + matricula: json['matricula'] as String, + senha: json['senha'] as String, + activationCode: json['activationCode'] as String, + ); + } on SocketException { + throw StateError('o relé não está no ar (scripts/qa/otp_relay.py + adb reverse tcp:8765 tcp:8765)'); + } finally { + client.close(force: true); + } +} + +/// Espera [done] só bombeando quadros e deixando o tempo real passar — nunca +/// rola, toca ou troca filtro. É o que sustenta o "sem atualização manual". +Future _pumpUntil( + WidgetTester tester, + bool Function() done, { + Duration timeout = const Duration(seconds: 30), + Duration step = const Duration(milliseconds: 200), + String? motivo, +}) async { + final fim = DateTime.now().add(timeout); + while (!done() && DateTime.now().isBefore(fim)) { + await tester.pump(step); + } + if (done()) return; + final textos = find.byType(Text).evaluate().map((e) => (e.widget as Text).data).whereType().take(40).join(' | '); + fail('condição não atingida em ${timeout.inSeconds}s${motivo == null ? '' : ': $motivo'}; tela: $textos'); +} + +bool _existe(String chave) => find.byKey(Key(chave)).evaluate().isNotEmpty; + +bool get _painelAberto => find.text('Painel de Indicadores').evaluate().isNotEmpty; + +/// "Abertos (pendentes)" do Painel mostra [valor]? A linha é a `_InfoRow` +/// (o `Padding` mais próximo do rótulo), para o número não casar com outro +/// contador da tela. +bool _abertosNoPainel(Finder rotulo, String valor) { + if (rotulo.evaluate().isEmpty) return false; + final linha = find.ancestor(of: rotulo, matching: find.byType(Padding)).first; + return find.descendant(of: linha, matching: find.text(valor)).evaluate().isNotEmpty; +} + +Finder _cartao(String alertId) => find.byKey(Key('alert_$alertId')); + +bool _noCartao(String alertId, Finder alvo) => + find.descendant(of: _cartao(alertId), matching: alvo).evaluate().isNotEmpty; + +/// O botão é `OutlinedButton.icon` (uma subclasse privada): `find.byType` não o +/// acharia, o rótulo exato acha — e o título do diálogo ("Escalonar alerta +/// #…?") não casa com ele. +bool _temBotaoEscalonar(String alertId) => _noCartao(alertId, find.text('Escalonar')); + +bool _temSeloEscalonado(String alertId) => _noCartao(alertId, find.textContaining('Escalonado às')); + +Future _tocar(WidgetTester tester, Finder alvo) async { + await tester.ensureVisible(alvo); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(alvo); + await tester.pump(const Duration(milliseconds: 300)); +} + +Future _digitar(WidgetTester tester, String chave, String texto) async { + final campo = find.byKey(Key(chave)); + await tester.ensureVisible(campo); + await tester.pump(const Duration(milliseconds: 100)); + await tester.enterText(campo, texto); +} + +/// Troca de aba pela navegação: no celular o painel se chama "Painel" na barra +/// inferior; no trilho lateral, "Indicadores". +Future _irPara(WidgetTester tester, List rotulos) async { + for (final rotulo in rotulos) { + final alvo = find.text(rotulo); + if (alvo.evaluate().isNotEmpty) { + await _tocar(tester, alvo.last); + return; + } + } + fail('nenhum destino ${rotulos.join('/')} na navegação'); +} + +Future _entrar(WidgetTester tester) async { + final cred = await _coordenadorDoRele(); + await _digitar(tester, 'matricula_field', cred.matricula); + await _digitar(tester, 'senha_field', cred.senha); + await _tocar(tester, find.byKey(const Key('login_button'))); + + await _pumpUntil( + tester, + () => _existe('activation_code_field') || _existe('totp_field') || _existe('login_error') || _painelAberto, + motivo: 'resposta do login', + ); + // Primeiro acesso: o coordenador da fixture nasce sem MFA. + expect(_existe('activation_code_field'), isTrue, reason: 'o coordenador da fixture deveria ativar a MFA'); + await _digitar(tester, 'activation_code_field', cred.activationCode); + await _tocar(tester, find.byKey(const Key('activation_continue'))); + await _pumpUntil(tester, () => _existe('mfa_secret') || _existe('mfa_error'), timeout: const Duration(seconds: 60)); + expect(_existe('mfa_error'), isFalse); + final segredo = base32Decode(tester.widget(find.byKey(const Key('mfa_secret'))).data!); + final ativacao = DateTime.now(); + await _digitar(tester, 'mfa_code_field', totpCode(segredo, ativacao)); + await _tocar(tester, find.byKey(const Key('mfa_confirm_button'))); + + await _pumpUntil(tester, () => _existe('totp_field') || _existe('login_error') || _painelAberto, + timeout: const Duration(seconds: 60)); + if (_existe('totp_field')) { + // O passo da ativação já foi aceito e o replay é barrado: o login usa o + // passo seguinte. + await _pumpUntil(tester, () => _passo(DateTime.now()) > _passo(ativacao), + timeout: const Duration(seconds: 40), step: const Duration(milliseconds: 500)); + await _digitar(tester, 'totp_field', totpCode(segredo, DateTime.now())); + await _tocar(tester, find.byKey(const Key('login_button'))); + } + await _pumpUntil(tester, () => _painelAberto || _existe('login_error'), timeout: const Duration(seconds: 60)); + expect(_existe('login_error'), isFalse, + reason: _existe('login_error') ? (tester.widget(find.byKey(const Key('login_error'))).data ?? '') : ''); +} + +/// A linha do alerta como o SERVIDOR a vê agora (`admin.alerts`, escopo do +/// coordenador). Cada chamada grava uma linha `read` — o script conta. +Future _alertaNoServidor(String alertId) async { + final client = await _clienteRpc(); + final pagina = await client.admin.alerts(accessToken: _token, limit: 100, offset: 0); + return pagina.items.singleWhere((a) => a.id == alertId, + orElse: () => fail('o alerta $alertId não está na lista do coordenador')); +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('coordenador escalona o alerta vencido sem atualizar a tela e vê o selo de desatualizado na queda', + (tester) async { + expect(AdminBackendConfig.host, startsWith('https://')); + expect(_alertaVencido, isNotEmpty, reason: 'falta --dart-define=E2E_ALERTA_VENCIDO (rode pelo script)'); + expect(_alertaNovo, isNotEmpty, reason: 'falta --dart-define=E2E_ALERTA_NOVO (rode pelo script)'); + + final ca = (await rootBundle.load(adminRpcCaAsset)).buffer.asUint8List(); + final fiacao = buildAdminWiring(caBytes: ca); + await tester.pumpWidget(SinalAdminApp( + auth: fiacao.auth, + dataSourceFor: (sessao) { + _sessao = sessao; + return fiacao.dataSourceFor(sessao); + }, + )); + await tester.pump(const Duration(milliseconds: 500)); + await _entrar(tester); + expect(_painelAberto, isTrue); + + // 1. Painel parado: 1 vermelho aberto (o vencido semeado). Pede o alerta + // novo e espera o 2 SEM trocar de aba — só o pulso pode trazê-lo. + final rotuloAbertos = find.text('Abertos (pendentes)'); + await tester.scrollUntilVisible(rotuloAbertos, 200, scrollable: find.byType(Scrollable).last, maxScrolls: 30); + await _pumpUntil(tester, () => _abertosNoPainel(rotuloAbertos, '1'), motivo: 'o painel com 1 vermelho aberto'); + final pedido = DateTime.now(); + // ignore: avoid_print + print('E2E_DISPARAR_ALERTA id=$_alertaNovo'); + await _pumpUntil(tester, () => _abertosNoPainel(rotuloAbertos, '2'), + timeout: _atrasoMaximoDoPainel + const Duration(seconds: 20), + motivo: 'o painel passar a 2 abertos sozinho (pulso), sem troca de aba'); + final atrasoDoPainel = DateTime.now().difference(pedido); + // ignore: avoid_print + print('E2E_PAINEL_ATUALIZOU atraso_ms=${atrasoDoPainel.inMilliseconds}'); + expect(atrasoDoPainel, lessThanOrEqualTo(_atrasoMaximoDoPainel), + reason: 'o painel demorou ${atrasoDoPainel.inSeconds}s para refletir o alerta novo'); + + // 2. Alertas, aberta agora (leitura nova). Daqui até o escalonamento a aba + // fica parada e ninguém a recarrega à mão. + await _irPara(tester, const ['Alertas']); + await _pumpUntil(tester, () => _cartao(_alertaVencido).evaluate().isNotEmpty && _cartao(_alertaNovo).evaluate().isNotEmpty, + motivo: 'os dois alertas na lista do coordenador'); + expect(_temBotaoEscalonar(_alertaVencido), isTrue, reason: 'o alerta de 10 min já venceu o prazo de 30 s'); + expect(_temBotaoEscalonar(_alertaNovo), isFalse, reason: 'antes do prazo não há botão'); + expect(_noCartao(_alertaNovo, find.text('Status: Pendente')), isTrue); + final novoNoServidor = await _alertaNoServidor(_alertaNovo); + expect(novoNoServidor.overdue, isFalse, reason: 'o servidor também o vê no prazo'); + final vence = novoNoServidor.triggeredAt.toUtc().add(_prazo); + + // 3. Sem tocar em nada: o botão aparece depois do prazo (o pulso vê + // `overdueRed` mudar e a lista recarrega). + await _pumpUntil(tester, () => _temBotaoEscalonar(_alertaNovo), + timeout: vence.difference(DateTime.now().toUtc()) + _atrasoMaximoDoBotao + const Duration(seconds: 10), + motivo: 'o botão Escalonar aparecer sozinho depois do prazo'); + final atraso = DateTime.now().toUtc().difference(vence); + // ignore: avoid_print + print('E2E_BOTAO_APARECEU atraso_ms=${atraso.inMilliseconds}'); + expect(atraso, lessThanOrEqualTo(_atrasoMaximoDoBotao), reason: 'o botão demorou ${atraso.inSeconds}s após o prazo'); + + // 4. Escalona pela tela, com motivo. + await _tocar(tester, find.descendant(of: _cartao(_alertaNovo), matching: find.text('Escalonar'))); + await _pumpUntil(tester, () => find.text('Confirmar escalonamento').evaluate().isNotEmpty, motivo: 'o diálogo'); + final confirmar = find.widgetWithText(FilledButton, 'Confirmar escalonamento'); + expect(tester.widget(confirmar).onPressed, isNull, reason: 'sem motivo não confirma'); + await _tocar(tester, find.text('Sem confirmação no prazo')); + expect(tester.widget(confirmar).onPressed, isNotNull); + await _tocar(tester, confirmar); + await _pumpUntil(tester, () => _temSeloEscalonado(_alertaNovo), timeout: const Duration(seconds: 60), + motivo: 'a linha com "Escalonado às"'); + expect(_noCartao(_alertaNovo, find.text('Status: Pendente')), isTrue, reason: 'escalonar não confirma o alerta'); + expect(_temBotaoEscalonar(_alertaNovo), isFalse); + expect(_temBotaoEscalonar(_alertaVencido), isTrue, reason: 'o outro alerta vencido não foi tocado'); + + // 5. Reescalonar por RPC direto: idempotente, com o carimbo ORIGINAL. + final escalonado = await _alertaNoServidor(_alertaNovo); + expect(escalonado.escalatedAt, isNotNull); + expect(escalonado.status, api.AlertStatus.pending); + final client = await _clienteRpc(); + final repetido = await client.admin.escalateAlert( + accessToken: _token, + alertId: _alertaNovo, + reason: api.EscalationReason.noAckTimeout, + ); + expect(repetido.alreadyEscalated, isTrue); + expect(repetido.escalatedAt.isAtSameMomentAs(escalonado.escalatedAt!), isTrue, + reason: 'o reescalonamento devolve o escalatedAt original'); + + // 6. O painel continua contando os dois vermelhos como abertos: escalonar + // não tira o alerta da conta. (A troca de aba aqui não prova o pulso — essa + // prova é a do passo 1; aqui só se confere que o número não caiu.) + final indicadores = await client.admin.indicators(accessToken: _token); + expect(indicadores.openRedAlerts, 2, reason: 'o vencido semeado + o novo escalonado, ambos pendentes'); + await _irPara(tester, const ['Painel', 'Indicadores']); + await _pumpUntil(tester, () => _painelAberto, motivo: 'o painel'); + await tester.scrollUntilVisible(rotuloAbertos, 200, scrollable: find.byType(Scrollable).last, maxScrolls: 30); + await _pumpUntil(tester, () => _abertosNoPainel(rotuloAbertos, '2'), motivo: 'o painel com 2 vermelhos abertos'); + + // 7. Perda de conexão: o script pausa o Traefik (o backend fica no ar). O + // selo aparece com o dado anterior na tela e some sozinho quando a rota volta. + await _irPara(tester, const ['Alertas']); + await _pumpUntil(tester, () => _temSeloEscalonado(_alertaNovo), motivo: 'a lista de volta, com o escalonado'); + expect(_existe('stale_badge'), isFalse, reason: 'com o backend no ar o dado está em dia'); + // ignore: avoid_print + print('E2E_CORTAR_BACKEND segundos=$_segundosDeCorte'); + await _pumpUntil(tester, () => _existe('stale_badge'), + timeout: const Duration(seconds: _segundosDeCorte + 30), step: const Duration(milliseconds: 500), + motivo: 'o selo de desatualizado com o Traefik pausado'); + expect(find.descendant(of: find.byKey(const Key('stale_badge')), matching: find.textContaining('sem atualização há')), + findsOneWidget); + expect(_temSeloEscalonado(_alertaNovo), isTrue, reason: 'a queda não apaga o dado anterior'); + expect(_cartao(_alertaVencido), findsOneWidget); + await _pumpUntil(tester, () => !_existe('stale_badge'), + timeout: const Duration(seconds: _segundosDeCorte + 90), step: const Duration(milliseconds: 500), + motivo: 'o selo sumir sozinho com a rota de volta'); + expect(_temSeloEscalonado(_alertaNovo), isTrue); + // ignore: avoid_print + print('E2E_ESCALONAMENTO_OK'); + }); +} diff --git a/apps/admin/integration_test/admin_auditoria_e2e.dart b/apps/admin/integration_test/admin_auditoria_e2e.dart new file mode 100644 index 00000000..297f15b8 --- /dev/null +++ b/apps/admin/integration_test/admin_auditoria_e2e.dart @@ -0,0 +1,652 @@ +/// Trilha de auditoria completa do backoffice (issue #45) no emulador, contra o +/// BANCO DE TESTE. Rodado só por `scripts/qa/admin_auditoria_e2e.sh`. +/// +/// O seed (`E2E_SEED_AUDIT_TRAIL=1`) grava 60 linhas sintéticas encadeadas +/// (autores administrador, coordenador e ACS; linhas 30 e 31 com o mesmo +/// `timestamp`); as contagens esperadas chegam do manifesto por +/// `--dart-define`, nunca repetidas à mão aqui. +/// +/// Jornada do administrador (1º teste): +/// 1. login pela tela (ativação da MFA da fixture); +/// 2. Auditoria: "Cadeia íntegra" ao abrir e depois de "Verificar novamente"; +/// 3. filtros pela tela — ação, período (atalho de 30 dias + fim digitado no +/// seletor) e busca — e "Carregar mais" até o fim sem repetir nem pular +/// `sequence` (conferido também por RPC direto, página a página); +/// `E2E_FILTROS_OK`; +/// 4. `E2E_ADULTERAR sequence=`: o SCRIPT faz `UPDATE` no banco de e2e e o +/// teste espera o servidor ver a linha `invalid` (RPC); depois de vencido o +/// cache de 60 s da verificação, "Verificar novamente" mostra "Falha de +/// integridade" com a linha `n` e, varrendo a lista inteira na tela, só a +/// linha `n` tem o selo "Adulterado?"; `E2E_ADULTERACAO_DETECTADA`; +/// 5. exportação do período: contagem e intervalo de `sequence` na tela, +/// "Copiar CSV" e leitura de volta pela área de transferência +/// (`Clipboard.getData`), conferindo o SHA-256 mostrado, as nove colunas e +/// a ausência de UUID, hash e IP; `E2E_EXPORT_FEITO`. +/// +/// Jornada do coordenador (2º teste): vê só as linhas de autores da UBS dele +/// (o ACS das fixtures), não vê a seção "Exportar" em lugar nenhum da tela e o +/// `admin.exportAuditLogs` chamado direto é recusado; `E2E_FIM`. +/// +/// O script confere depois, no banco, a linha `export`/`success` ligada a +/// `audit_exports`, a recusa `denied` do coordenador e o verificador CLI antes +/// e depois de restaurar a linha adulterada. +/// +/// Sufixo `_e2e.dart` (não `_test.dart`): a descoberta do `flutter test +/// integration_test` e o `e2e.sh --full` não o rodam contra a stack de dev. +/// +/// PRIVACIDADE: só fixtures sintéticas. Senha, código de ativação, segredo +/// TOTP, token e o conteúdo do CSV nunca vão para log; as linhas impressas +/// levam só números. +@Timeout(Duration(minutes: 12)) +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart' show sha256; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show Clipboard, rootBundle; +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/auth/admin_auth_backend.dart' show AdminSession; +import 'package:sinalacs_admin/core/auth/admin_auth_bootstrap.dart'; +import 'package:sinalacs_admin/core/auth/backend_config.dart'; +import 'package:sinalacs_client/sinalacs_client.dart' as api; + +import 'support/e2e_admin.dart'; +import 'support/totp.dart'; + +// Bloco `auditTrail` do manifesto, passado pelo script. +const _linhasSemeadas = int.fromEnvironment('E2E_AUDIT_ROWS'); +const _linhasDeExportacao = int.fromEnvironment('E2E_AUDIT_EXPORT_ROWS'); +const _linhasDoAcs = int.fromEnvironment('E2E_AUDIT_ACS_ROWS'); +const _linhasNoPeriodo = int.fromEnvironment('E2E_AUDIT_PERIOD_ROWS'); +const _primeiraNoPeriodo = int.fromEnvironment('E2E_AUDIT_PERIOD_FIRST'); +const _ultimaNoPeriodo = int.fromEnvironment('E2E_AUDIT_PERIOD_LAST'); +const _sequenciaAdulterada = int.fromEnvironment('E2E_AUDIT_TAMPER_SEQUENCE'); + +/// As nove colunas do CSV (D6), na ordem do servidor (`AuditCsv.columns`). +const _colunas = 'sequence,timestamp_utc,autor,papel_autor,acao,recurso,rotulo_recurso,resultado,selo'; + +/// Cache da verificação global no servidor (`AuditChainStatusService.cacheTtl`) +/// mais folga: antes disso, "Verificar novamente" devolveria o retrato antigo. +const _cacheDaVerificacao = Duration(seconds: 62); + +final _reSequencia = RegExp(r'· nº (\d+)$'); +const _rotulosDeSelo = {'Íntegro', 'Adulterado?', 'Não verificável'}; + +int _passo(DateTime t) => t.toUtc().millisecondsSinceEpoch ~/ 1000 ~/ 30; + +typedef _Credencial = ({String matricula, String senha, String activationCode}); + +/// Sessão aberta pelo login da tela (capturada na fábrica da fonte de dados): +/// é o token dela que as provas por RPC usam, sem segundo login. +AdminSession? _sessao; + +api.Client? _rpc; + +Future _clienteRpc() async { + final existente = _rpc; + if (existente != null) return existente; + final ca = (await rootBundle.load(adminRpcCaAsset)).buffer.asUint8List(); + return _rpc = api.Client( + AdminBackendConfig.requireSecureHost(AdminBackendConfig.host), + securityContext: SecurityContext()..setTrustedCertificatesBytes(ca), + )..connectivityMonitor = null; +} + +String get _token { + final sessao = _sessao; + if (sessao == null) fail('o login não abriu sessão (a fábrica da fonte de dados não foi chamada)'); + return sessao.accessToken; +} + +Future<_Credencial> _coordenadorDoRele() async { + final client = HttpClient(); + try { + final request = await client.getUrl(Uri.parse(relayBase).replace(path: '/coordenador')); + final response = await request.close(); + final body = await utf8.decodeStream(response); + if (response.statusCode != 200) throw StateError('o relé respondeu ${response.statusCode} a /coordenador'); + final json = jsonDecode(body) as Map; + return ( + matricula: json['matricula'] as String, + senha: json['senha'] as String, + activationCode: json['activationCode'] as String, + ); + } on SocketException { + throw StateError('o relé não está no ar (scripts/qa/otp_relay.py + adb reverse tcp:8765 tcp:8765)'); + } finally { + client.close(force: true); + } +} + +String _textosDaTela() => + find.byType(Text).evaluate().map((e) => (e.widget as Text).data).whereType().take(40).join(' | '); + +Future _pumpUntil( + WidgetTester tester, + bool Function() done, { + Duration timeout = const Duration(seconds: 30), + Duration step = const Duration(milliseconds: 200), + String? motivo, +}) async { + final fim = DateTime.now().add(timeout); + while (!done() && DateTime.now().isBefore(fim)) { + await tester.pump(step); + } + if (done()) return; + fail('condição não atingida em ${timeout.inSeconds}s${motivo == null ? '' : ': $motivo'}; tela: ${_textosDaTela()}'); +} + +bool _existe(String chave) => find.byKey(Key(chave)).evaluate().isNotEmpty; + +bool get _painelAberto => find.text('Painel de Indicadores').evaluate().isNotEmpty; + +/// A lista da tela (o único rolável vertical do corpo). Os campos de texto +/// também têm um `Scrollable`, mas horizontal. +Finder get _lista => find + .byWidgetPredicate((w) => w is Scrollable && axisDirectionToAxis(w.axisDirection) == Axis.vertical) + .first; + +ScrollPosition _posicao(WidgetTester tester) => tester.state(_lista).position; + +/// A lista é preguiçosa: o que está fora da janela não existe na árvore. Pula +/// para o topo e desce de 300 em 300 até [alvo] existir; `false` se chegou ao +/// fim sem achar — que, por ter passado por tudo, é prova de ausência. +Future _rolarAte(WidgetTester tester, Finder alvo) async { + _posicao(tester).jumpTo(0); + await tester.pump(const Duration(milliseconds: 150)); + for (var i = 0; i < 400; i++) { + if (alvo.evaluate().isNotEmpty) { + await tester.ensureVisible(alvo.first); + await tester.pump(const Duration(milliseconds: 200)); + return true; + } + final p = _posicao(tester); + if (p.pixels >= p.maxScrollExtent) return false; + p.jumpTo((p.pixels + 300).clamp(0, p.maxScrollExtent)); + await tester.pump(const Duration(milliseconds: 120)); + } + return false; +} + +Future _tocar(WidgetTester tester, Finder alvo, {required String oQue}) async { + if (!await _rolarAte(tester, alvo)) fail('$oQue não está na tela; tela: ${_textosDaTela()}'); + await tester.tap(alvo.first); + await tester.pump(const Duration(milliseconds: 300)); +} + +Future _tocarChave(WidgetTester tester, String chave) => + _tocar(tester, find.byKey(Key(chave)), oQue: chave); + +Future _digitar(WidgetTester tester, String chave, String texto) async { + final campo = find.byKey(Key(chave)); + if (!await _rolarAte(tester, campo)) fail('campo $chave não está na tela'); + await tester.enterText(campo, texto); + await tester.pump(const Duration(milliseconds: 100)); +} + +/// Toque fora da lista da Auditoria (login, ativação): rola até o alvo e +/// deixa o quadro assentar ANTES do toque — sem o `pump`, o toque usa a +/// posição de antes da rolagem e erra o botão (1ª execução real). +Future _tocarFora(WidgetTester tester, String chave) async { + final alvo = find.byKey(Key(chave)); + await tester.pump(const Duration(milliseconds: 300)); + await tester.ensureVisible(alvo); + await tester.pump(const Duration(milliseconds: 400)); + await tester.tap(alvo); + await tester.pump(const Duration(milliseconds: 300)); +} + +/// Login de uma conta de staff da fixture: primeiro acesso, sem MFA — ativa +/// pela tela com o código de ativação e entra com o passo TOTP seguinte (o da +/// ativação já foi aceito e o replay é barrado). +Future _entrar(WidgetTester tester, _Credencial cred) async { + await tester.enterText(find.byKey(const Key('matricula_field')), cred.matricula); + await tester.enterText(find.byKey(const Key('senha_field')), cred.senha); + await _tocarFora(tester, 'login_button'); + await _pumpUntil( + tester, + () => _existe('activation_code_field') || _existe('totp_field') || _existe('login_error') || _painelAberto, + motivo: 'resposta do login', + ); + expect(_existe('activation_code_field'), isTrue, reason: 'a conta da fixture deveria ativar a MFA'); + await tester.enterText(find.byKey(const Key('activation_code_field')), cred.activationCode); + await _tocarFora(tester, 'activation_continue'); + await _pumpUntil(tester, () => _existe('mfa_secret') || _existe('mfa_error'), timeout: const Duration(seconds: 60)); + expect(_existe('mfa_error'), isFalse); + final segredo = base32Decode(tester.widget(find.byKey(const Key('mfa_secret'))).data!); + final ativacao = DateTime.now(); + await tester.enterText(find.byKey(const Key('mfa_code_field')), totpCode(segredo, ativacao)); + await _tocarFora(tester, 'mfa_confirm_button'); + await _pumpUntil(tester, () => _existe('totp_field') || _existe('login_error') || _painelAberto, + timeout: const Duration(seconds: 60)); + if (_existe('totp_field')) { + await _pumpUntil(tester, () => _passo(DateTime.now()) > _passo(ativacao), + timeout: const Duration(seconds: 40), step: const Duration(milliseconds: 500)); + await tester.enterText(find.byKey(const Key('totp_field')), totpCode(segredo, DateTime.now())); + await _tocarFora(tester, 'login_button'); + } + await _pumpUntil(tester, () => _painelAberto || _existe('login_error'), timeout: const Duration(seconds: 60)); + expect(_existe('login_error'), isFalse, + reason: _existe('login_error') ? (tester.widget(find.byKey(const Key('login_error'))).data ?? '') : ''); +} + +Future _abrirApp(WidgetTester tester) async { + final ca = (await rootBundle.load(adminRpcCaAsset)).buffer.asUint8List(); + final fiacao = buildAdminWiring(caBytes: ca); + _sessao = null; + await tester.pumpWidget(SinalAdminApp( + auth: fiacao.auth, + dataSourceFor: (sessao) { + _sessao = sessao; + return fiacao.dataSourceFor(sessao); + }, + )); + await tester.pump(const Duration(milliseconds: 500)); +} + +Future _abrirAuditoria(WidgetTester tester) async { + // Na barra inferior do celular o rótulo curto é 'Trilha'; o nome completo + // fica no tooltip. + final porTexto = find.text('Auditoria'); + final destino = porTexto.evaluate().isNotEmpty ? porTexto : find.byTooltip('Auditoria'); + expect(destino, findsWidgets, reason: 'a navegação não tem "Auditoria"'); + await tester.tap(destino.last); + await tester.pump(const Duration(milliseconds: 500)); + await _pumpUntil(tester, () => _existe('audit_chain_status'), motivo: 'a tela de Auditoria (registro do acesso)'); +} + +String? _texto(String chave) { + final f = find.byKey(Key(chave)); + if (f.evaluate().isEmpty) return null; + final w = f.evaluate().first.widget; + if (w is Text) return w.data; + if (w is SelectableText) return w.data; + return find.descendant(of: f, matching: find.byType(Text)).evaluate().map((e) => (e.widget as Text).data).join(' '); +} + +/// Espera a lista terminar de (re)carregar e devolve "N registros carregados" +/// como número (0 para "nenhum registro"). +Future _esperarLista(WidgetTester tester) async { + final fim = DateTime.now().add(const Duration(seconds: 45)); + while (DateTime.now().isBefore(fim)) { + await tester.pump(const Duration(milliseconds: 250)); + _posicao(tester).jumpTo(0); + await tester.pump(const Duration(milliseconds: 100)); + final contagem = find.byKey(const Key('audit_count')); + final carregando = find.byKey(const Key('audit_loading')); + final vazio = find.textContaining('Nenhum registro encontrado'); + await _rolarAte(tester, find.byWidgetPredicate((w) => + w.key == const Key('audit_count') || + w.key == const Key('audit_loading') || + // Só os textos de lista vazia: "Nenhum filtro aplicado" (subtítulo + // dos filtros) também começa com "Nenhum" (3ª execução real). + (w is Text && ((w.data ?? '').startsWith('Nenhum registro') || (w.data ?? '').startsWith('Nenhum acesso'))))); + if (carregando.evaluate().isNotEmpty) continue; + if (vazio.evaluate().isNotEmpty) return 0; + if (contagem.evaluate().isNotEmpty) { + final m = RegExp(r'^(\d+) registros? carregados?$').firstMatch(_texto('audit_count') ?? ''); + if (m != null) return int.parse(m[1]!); + } + } + fail('a lista não terminou de carregar; tela: ${_textosDaTela()}'); +} + +/// Abre os filtros se estiverem fechados. A pergunta "já está aberto?" exige +/// rolar até lá: com a lista rolada para baixo o formulário sai da árvore, e +/// um `_existe` cru diria "fechado" e o toque o FECHARIA (2ª execução real). +Future _abrirFiltros(WidgetTester tester) async { + if (await _rolarAte(tester, find.byKey(const Key('audit_filter_apply')))) return; + await _tocar(tester, find.text('Filtros'), oQue: 'o título dos filtros'); + await tester.pump(const Duration(milliseconds: 400)); + expect(_existe('audit_filter_apply'), isTrue, reason: 'os filtros não abriram'); +} + +Future _aplicar(WidgetTester tester) async { + await _tocarChave(tester, 'audit_filter_apply'); + expect(_existe('audit_filter_error'), isFalse, reason: _texto('audit_filter_error')); + return _esperarLista(tester); +} + +Future _limpar(WidgetTester tester) async { + await _abrirFiltros(tester); + await _tocarChave(tester, 'audit_filter_clear'); + return _esperarLista(tester); +} + +/// Período de 30 dias atrás até ontem, pela tela: o atalho preenche o início e +/// o fim é digitado no seletor de data (dd/mm/aaaa) com a hora atual. As +/// linhas semeadas no período têm de 28 a ~2,3 dias: dias de folga nas duas +/// pontas, nenhuma linha gravada durante o teste cai dentro. +Future _preencherPeriodo(WidgetTester tester) async { + await _abrirFiltros(tester); + await _tocarChave(tester, 'audit_filter_preset_30d'); + await _tocarChave(tester, 'audit_filter_to'); + await tester.pump(const Duration(milliseconds: 600)); + final ontem = DateTime.now().subtract(const Duration(days: 1)); + String dois(int v) => v.toString().padLeft(2, '0'); + final campoData = find.descendant(of: find.byType(Dialog), matching: find.byType(TextField)); + expect(campoData, findsOneWidget, reason: 'o seletor de data deveria abrir no modo de digitação'); + await tester.enterText(campoData, '${dois(ontem.day)}/${dois(ontem.month)}/${ontem.year}'); + await tester.pump(const Duration(milliseconds: 200)); + await tester.tap(find.text('Confirmar').last); + await tester.pump(const Duration(milliseconds: 800)); + expect(find.text('Hora do fim'), findsOneWidget, reason: 'o seletor de hora deveria abrir depois da data'); + await tester.tap(find.text('Confirmar').last); + await tester.pump(const Duration(milliseconds: 800)); + expect(find.byType(Dialog), findsNothing); + expect(_texto('audit_filter_to'), isNot(contains('não definido'))); +} + +/// Sequências na ordem da tela (de cima para baixo) e o selo de cada uma, +/// varrendo a lista inteira — o que a tela realmente mostra. +Future> _varrer(WidgetTester tester) async { + final vistos = {}; + final ordem = []; + final cartoes = find.byWidgetPredicate( + (w) => w is Card && w.key is ValueKey && (w.key! as ValueKey).value.startsWith('audit_')); + _posicao(tester).jumpTo(0); + await tester.pump(const Duration(milliseconds: 150)); + for (var i = 0; i < 600; i++) { + for (final el in cartoes.evaluate()) { + final textos = find + .descendant(of: find.byElementPredicate((e) => e == el), matching: find.byType(Text)) + .evaluate() + .map((e) => (e.widget as Text).data) + .whereType(); + int? seq; + String? selo; + for (final t in textos) { + final m = _reSequencia.firstMatch(t); + if (m != null) seq = int.parse(m[1]!); + if (_rotulosDeSelo.contains(t)) selo = t; + } + if (seq != null && selo != null && !vistos.containsKey(seq)) { + vistos[seq] = selo; + ordem.add(seq); + } + } + final p = _posicao(tester); + if (p.pixels >= p.maxScrollExtent) break; + p.jumpTo((p.pixels + 300).clamp(0, p.maxScrollExtent)); + await tester.pump(const Duration(milliseconds: 100)); + } + return [for (final s in ordem) (s, vistos[s]!)]; +} + +/// Toca "Carregar mais" até o botão sumir; devolve quantas páginas vieram. +Future _carregarTudo(WidgetTester tester) async { + var paginas = 1; + while (await _rolarAte(tester, find.byKey(const Key('audit_load_more')))) { + final antes = await _esperarLista(tester); + await _tocarChave(tester, 'audit_load_more'); + final fim = DateTime.now().add(const Duration(seconds: 30)); + var depois = antes; + while (depois == antes && DateTime.now().isBefore(fim)) { + await tester.pump(const Duration(milliseconds: 300)); + depois = await _esperarLista(tester); + } + expect(depois, greaterThan(antes), reason: '"Carregar mais" não trouxe nada'); + paginas++; + if (paginas > 20) fail('paginação sem fim'); + } + return paginas; +} + +/// Todas as páginas da trilha por RPC direto (escopo da sessão). +Future> _trilhaPorRpc({api.AdminAuditFilter? filtro, int? antesDe}) async { + final client = await _clienteRpc(); + final itens = []; + int? cursor = antesDe; + for (var i = 0; i < 50; i++) { + final pagina = + await client.admin.auditLogs(accessToken: _token, filter: filtro, limit: 50, beforeSequence: cursor); + itens.addAll(pagina.items); + cursor = pagina.nextBeforeSequence; + if (cursor == null) return itens; + } + fail('paginação por RPC sem fim'); +} + +/// Instante em que a tela pediu a última verificação global. +late DateTime _ultimaVerificacao; + +Future _verificarCadeia(WidgetTester tester, {required String esperado}) async { + _posicao(tester).jumpTo(0); + await tester.pump(const Duration(milliseconds: 200)); + await _pumpUntil(tester, () { + final b = find.byKey(const Key('audit_chain_retry')); + return b.evaluate().isNotEmpty && tester.widget(b).onPressed != null; + }, timeout: const Duration(seconds: 60), motivo: 'o botão "Verificar novamente" livre'); + _ultimaVerificacao = DateTime.now(); + await tester.tap(find.byKey(const Key('audit_chain_retry'))); + await tester.pump(const Duration(milliseconds: 200)); + await _pumpUntil(tester, () => !_existe('audit_chain_verifying') && _texto('audit_chain_title') == esperado, + timeout: const Duration(seconds: 90), motivo: 'o painel da cadeia com "$esperado"'); + return _texto('audit_chain_status') ?? ''; +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('admin consulta, filtra e pagina a trilha, vê a adulteração e exporta o CSV', (tester) async { + expect(AdminBackendConfig.host, startsWith('https://')); + expect(_linhasSemeadas, greaterThan(50), reason: 'faltam os --dart-define E2E_AUDIT_* (rode pelo script)'); + for (final (nome, valor) in [ + ('E2E_AUDIT_EXPORT_ROWS', _linhasDeExportacao), + ('E2E_AUDIT_ACS_ROWS', _linhasDoAcs), + ('E2E_AUDIT_PERIOD_ROWS', _linhasNoPeriodo), + ('E2E_AUDIT_PERIOD_FIRST', _primeiraNoPeriodo), + ('E2E_AUDIT_PERIOD_LAST', _ultimaNoPeriodo), + ('E2E_AUDIT_TAMPER_SEQUENCE', _sequenciaAdulterada), + ]) { + expect(valor, greaterThan(0), reason: 'falta --dart-define=$nome (rode pelo script)'); + } + expect(_sequenciaAdulterada, inInclusiveRange(_primeiraNoPeriodo, _ultimaNoPeriodo)); + + await _abrirApp(tester); + final cred = await adminCredentialFromRelay(); + await _entrar(tester, cred); + expect(_sessao?.role, 'admin'); + await _abrirAuditoria(tester); + + // 1. Integridade: íntegra ao abrir e depois de "Verificar novamente". + await _pumpUntil(tester, () => _texto('audit_chain_title') == 'Cadeia íntegra', + timeout: const Duration(seconds: 60), motivo: 'a cadeia íntegra ao abrir'); + final painel = await _verificarCadeia(tester, esperado: 'Cadeia íntegra'); + expect(painel, contains('registros conferidos')); + // ignore: avoid_print + print('E2E_CADEIA_INTEGRA'); + + // 2. Filtros pela tela. + final total = await _esperarLista(tester); + expect(total, 50, reason: 'a primeira página tem 50 (há $_linhasSemeadas linhas semeadas)'); + + await _abrirFiltros(tester); + await _tocarChave(tester, 'audit_filter_action'); + await tester.pump(const Duration(milliseconds: 600)); + await tester.tap(find.text('Exportação').last); + await tester.pump(const Duration(milliseconds: 600)); + expect(await _aplicar(tester), _linhasDeExportacao, reason: 'filtro por ação = export'); + final exportacoes = await _varrer(tester); + expect(exportacoes, hasLength(_linhasDeExportacao)); + expect(await _rolarAte(tester, find.textContaining('export • ')), isTrue); + + expect(await _limpar(tester), 50); + await _preencherPeriodo(tester); + expect(await _aplicar(tester), _linhasNoPeriodo, reason: 'filtro por período (30 dias até ontem)'); + final noPeriodo = await _varrer(tester); + expect(noPeriodo.map((e) => e.$1).toList(), + [for (var s = _ultimaNoPeriodo; s >= _primeiraNoPeriodo; s--) s], + reason: 'o período traz exatamente as semeadas nele, em ordem decrescente de sequence'); + // As linhas 30 e 31 têm o mesmo timestamp: a ordem é a da sequence. + expect(noPeriodo.indexWhere((e) => e.$1 == 31), lessThan(noPeriodo.indexWhere((e) => e.$1 == 30))); + + expect(await _limpar(tester), 50); + await _abrirFiltros(tester); + await _digitar(tester, 'audit_filter_search', 'escalated'); + expect(await _aplicar(tester), 1, reason: 'busca "escalated"'); + expect(await _rolarAte(tester, find.textContaining('Resultado: escalated:no_ack_timeout · nº ')), isTrue); + expect(find.textContaining('Resultado: escalated:no_ack_timeout · nº '), findsOneWidget); + + // 3. Paginação sem filtro: "Carregar mais" até o fim, sem repetir nem pular. + expect(await _limpar(tester), 50); + final paginas = await _carregarTudo(tester); + expect(paginas, greaterThanOrEqualTo(2)); + final tudo = await _varrer(tester); + final sequencias = tudo.map((e) => e.$1).toList(); + expect(sequencias.toSet(), hasLength(sequencias.length), reason: 'sequence repetida na tela'); + expect(sequencias, [for (var s = sequencias.first; s >= 1; s--) s], + reason: 'a tela tem de mostrar de ${sequencias.first} a 1, sem buraco'); + expect(await _esperarLista(tester), sequencias.length); + // Por RPC: página 2 começa depois da 1, sem interseção. + final client = await _clienteRpc(); + final p1 = await client.admin.auditLogs(accessToken: _token, limit: 50); + final p2 = await client.admin.auditLogs(accessToken: _token, limit: 50, beforeSequence: p1.nextBeforeSequence); + final s1 = p1.items.map((e) => e.sequence).toSet(); + expect(p2.items.where((e) => s1.contains(e.sequence)), isEmpty); + expect(p2.items.first.sequence, lessThan(p1.items.last.sequence)); + // ignore: avoid_print + print('E2E_FILTROS_OK paginas=$paginas linhas=${sequencias.length}'); + + // 4. Adulteração no banco de e2e, pelo script. + // ignore: avoid_print + print('E2E_ADULTERAR sequence=$_sequenciaAdulterada'); + final fimDaEspera = DateTime.now().add(const Duration(seconds: 90)); + api.AdminAuditEntry? vista; + while (DateTime.now().isBefore(fimDaEspera)) { + final pagina = await client.admin.auditLogs( + accessToken: _token, limit: 1, beforeSequence: _sequenciaAdulterada + 1); + vista = pagina.items.single; + if (vista.integrity == api.AuditRowSeal.invalid) break; + await tester.pump(const Duration(seconds: 2)); + } + expect(vista?.sequence, _sequenciaAdulterada); + expect(vista?.integrity, api.AuditRowSeal.invalid, reason: 'o script não adulterou a linha (ou o selo não viu)'); + + // Só depois de vencido o cache da última verificação, senão o servidor + // devolveria o retrato íntegro de antes da adulteração. + while (DateTime.now().difference(_ultimaVerificacao) <= _cacheDaVerificacao) { + await tester.pump(const Duration(milliseconds: 500)); + } + final quebra = await _verificarCadeia(tester, esperado: 'Falha de integridade na cadeia'); + expect(quebra, contains('Primeiro registro com falha: nº $_sequenciaAdulterada.')); + expect(quebra, isNot(contains('Cadeia íntegra'))); + + expect(await _limpar(tester), 50); + await _carregarTudo(tester); + final depois = await _varrer(tester); + final adulteradas = [for (final (s, selo) in depois) if (selo == 'Adulterado?') s]; + expect(adulteradas, [_sequenciaAdulterada], reason: 'só a linha adulterada pode aparecer como "Adulterado?"'); + expect(depois.where((e) => e.$2 != 'Íntegro' && e.$1 != _sequenciaAdulterada), isEmpty, + reason: 'toda outra linha é "Íntegro"'); + expect(depois.map((e) => e.$1).toList(), [for (var s = depois.first.$1; s >= 1; s--) s]); + final porRpc = await _trilhaPorRpc(); + expect([for (final e in porRpc) if (e.integrity != api.AuditRowSeal.verified) e.sequence], [_sequenciaAdulterada]); + // ignore: avoid_print + print('E2E_ADULTERACAO_DETECTADA sequence=$_sequenciaAdulterada linhas=${depois.length}'); + + // 5. Exportação do período, com a cadeia (ainda) adulterada. + await _preencherPeriodo(tester); + expect(await _aplicar(tester), _linhasNoPeriodo); + await _tocarChave(tester, 'audit_export_button'); + await _pumpUntil(tester, () => _existe('audit_export_confirm'), motivo: 'a confirmação da exportação'); + expect(find.textContaining('registrad'), findsWidgets, reason: 'a confirmação avisa que fica registrado'); + await tester.tap(find.byKey(const Key('audit_export_confirm'))); + await tester.pump(const Duration(milliseconds: 500)); + await _pumpUntil(tester, () => _existe('audit_export_result') || _existe('audit_export_error'), + timeout: const Duration(seconds: 60), motivo: 'o resultado da exportação'); + expect(_existe('audit_export_error'), isFalse, reason: _texto('audit_export_error')); + expect(await _rolarAte(tester, find.byKey(const Key('audit_export_result'))), isTrue); + expect(_texto('audit_export_summary'), '$_linhasNoPeriodo registros exportados'); + expect(find.text('Registros de nº $_primeiraNoPeriodo a nº $_ultimaNoPeriodo.'), findsOneWidget); + expect(_texto('audit_export_chain'), contains('falha de integridade'), + reason: 'a cadeia estava adulterada no momento da exportação'); + final shaNaTela = _texto('audit_export_sha256')!; + expect(shaNaTela, matches(RegExp(r'^[0-9a-f]{64}$'))); + + await _tocarChave(tester, 'audit_export_copy'); + await _pumpUntil(tester, () => _existe('audit_export_clipboard_warning'), motivo: 'o aviso da área de transferência'); + final copiado = await Clipboard.getData(Clipboard.kTextPlain); + final csv = copiado?.text; + if (csv == null || csv.isEmpty) { + // ignore: avoid_print + print('E2E_CLIPBOARD caminho=indisponivel'); + fail('Clipboard.getData não devolveu o CSV no aparelho'); + } + // ignore: avoid_print + print('E2E_CLIPBOARD caminho=clipboard'); + expect(sha256.convert(utf8.encode(csv)).toString(), shaNaTela, + reason: 'o que foi copiado é exatamente o arquivo cujo SHA-256 o servidor devolveu'); + final linhas = csv.split('\r\n'); + expect(linhas.first, _colunas); + expect(linhas.last, isEmpty, reason: 'CRLF no fim de cada linha'); + final dados = linhas.sublist(1, linhas.length - 1); + expect(dados, hasLength(_linhasNoPeriodo)); + for (final l in dados) { + expect(l.split(','), hasLength(9), reason: 'nove campos por linha'); + } + final seqsNoCsv = [for (final l in dados) int.parse(l.split(',').first)]; + expect(seqsNoCsv.toSet(), {for (var s = _primeiraNoPeriodo; s <= _ultimaNoPeriodo; s++) s}); + final selos = {for (final l in dados) int.parse(l.split(',').first): l.split(',').last}; + expect(selos[_sequenciaAdulterada], 'invalid'); + expect(selos.entries.where((e) => e.key != _sequenciaAdulterada && e.value != 'verified'), isEmpty); + expect(RegExp(r'[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}').hasMatch(csv), isFalse, + reason: 'nenhum UUID no CSV'); + expect(RegExp(r'[0-9a-fA-F]{32,}').hasMatch(csv), isFalse, reason: 'nenhum hash no CSV'); + expect(RegExp(r'\b\d{1,3}(\.\d{1,3}){3}\b').hasMatch(csv), isFalse, reason: 'nenhum IP no CSV'); + expect(csv, isNot(contains('sem-requisicao-http'))); + // ignore: avoid_print + print('E2E_EXPORT_FEITO linhas=${dados.length} primeira=$_primeiraNoPeriodo ultima=$_ultimaNoPeriodo'); + }); + + testWidgets('coordenador vê só a UBS dele, não tem "Exportar" e a exportação por RPC é recusada', (tester) async { + await _abrirApp(tester); + await _entrar(tester, await _coordenadorDoRele()); + expect(_sessao?.role, 'coordinator'); + await _abrirAuditoria(tester); + + // Escopo: só as linhas cujo autor tem microárea na UBS dele — as do ACS + // semeadas. As do administrador, as do próprio coordenador e as gravadas + // pelo teste anterior (staff, sem microárea) não aparecem. + expect(await _esperarLista(tester), _linhasDoAcs); + final vistas = await _varrer(tester); + expect(vistas, hasLength(_linhasDoAcs)); + expect(find.textContaining('(Administrador)'), findsNothing); + final porRpc = await _trilhaPorRpc(); + expect(porRpc, hasLength(_linhasDoAcs)); + expect(porRpc.every((e) => e.userLabel.endsWith('(ACS)')), isTrue, reason: 'só autores ACS no escopo'); + + // A cadeia segue adulterada (o script restaura depois): o coordenador não + // a vê como íntegra. + // O painel fica no topo: depois da varredura a lista está no fim e ele + // saiu da árvore. + _posicao(tester).jumpTo(0); + await tester.pump(const Duration(milliseconds: 200)); + await _pumpUntil(tester, () => !_existe('audit_chain_verifying') && _texto('audit_chain_title') != null, + timeout: const Duration(seconds: 60), motivo: 'o painel da cadeia do coordenador'); + expect(_texto('audit_chain_title'), 'Falha de integridade na cadeia'); + + // Nenhuma seção de exportação em lugar nenhum da tela (varrida até o fim). + expect(await _rolarAte(tester, find.byKey(const Key('audit_export_section'))), isFalse); + expect(await _rolarAte(tester, find.text('Exportar CSV')), isFalse); + + // O servidor é a autoridade: o RPC direto é recusado (e auditado `denied`). + final client = await _clienteRpc(); + final agora = DateTime.now().toUtc(); + await expectLater( + client.admin.exportAuditLogs( + accessToken: _token, + filter: api.AdminAuditFilter(from: agora.subtract(const Duration(days: 30)), to: agora), + ), + throwsA(isA()), + ); + // ignore: avoid_print + print('E2E_COORDENADOR_OK linhas=${vistas.length}'); + // ignore: avoid_print + print('E2E_FIM'); + }); +} diff --git a/apps/admin/integration_test/admin_login_e2e.dart b/apps/admin/integration_test/admin_login_e2e.dart new file mode 100644 index 00000000..01181f6e --- /dev/null +++ b/apps/admin/integration_test/admin_login_e2e.dart @@ -0,0 +1,236 @@ +/// Login real do backoffice (staff) contra o BANCO DE TESTE, pela tela, no +/// emulador: senha errada recusada, primeiro acesso -> ativação da MFA (QR/segredo +/// na tela) -> login com o código -> "Painel de Indicadores", e código errado +/// recusado. Rodada por `scripts/qa/admin_login_e2e.sh`. +/// +/// Os dados do painel seguem no `MockAdminDataSource` (issue #41): só o LOGIN é +/// real aqui. A credencial é a do administrador sintético da execução (bloco +/// `staff` do manifesto), entregue pelo relé em `/admin` uma única vez. +/// +/// Os testes dependem da ordem (o segredo TOTP nasce na ativação e é usado nos +/// seguintes), como em `apps/acs/integration_test/full_journey_e2e.dart`. +/// +/// Sufixo `_e2e.dart` (não `_test.dart`): `flutter test integration_test` descobre +/// `*_test.dart` e o `e2e.sh --full` rodaria isto sem fixtures nem relé. +/// +/// PRIVACIDADE: só fixtures sintéticas; a senha e o segredo nunca vão para log. +@Timeout(Duration(minutes: 4)) +library; + +import 'dart:typed_data' show Uint8List; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show rootBundle; +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/auth/admin_auth_bootstrap.dart'; +import 'package:sinalacs_admin/core/auth/backend_config.dart'; + +import 'support/e2e_admin.dart'; +import 'support/totp.dart'; + +int _passo(DateTime t) => t.toUtc().millisecondsSinceEpoch ~/ 1000 ~/ 30; + +Future _pumpUntil(WidgetTester tester, bool Function() done, + {Duration timeout = const Duration(seconds: 30), Duration step = const Duration(milliseconds: 200)}) async { + final fim = DateTime.now().add(timeout); + while (!done() && DateTime.now().isBefore(fim)) { + await tester.pump(step); + } + if (done()) return; + final telas = [ + for (final k in const ['login_error', 'login_aviso', 'mfa_error']) + if (find.byKey(Key(k)).evaluate().isNotEmpty) + '$k=${find.descendant(of: find.byKey(Key(k)), matching: find.byType(Text), matchRoot: true).evaluate().map((e) => (e.widget as Text).data).join(' ')}', + ]; + fail('condição não atingida em ${timeout.inSeconds}s; tela: ${telas.isEmpty ? '(nada)' : telas.join('; ')}'); +} + +bool _existe(String chave) => find.byKey(Key(chave)).evaluate().isNotEmpty; + +/// Toca num botão que pode estar abaixo da dobra (teclado virtual aberto). +Future _tocar(WidgetTester tester, String chave) async { + final alvo = find.byKey(Key(chave)); + await tester.ensureVisible(alvo); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(alvo); +} + +Future _digitar(WidgetTester tester, String chave, String texto) async { + final campo = find.byKey(Key(chave)); + await tester.ensureVisible(campo); + await tester.pump(const Duration(milliseconds: 100)); + await tester.enterText(campo, texto); +} + +Future _abrirApp(WidgetTester tester) async { + final ca = (await rootBundle.load(adminRpcCaAsset)).buffer.asUint8List(); + final fiacao = buildAdminWiring(caBytes: ca); + await tester.pumpWidget(SinalAdminApp(auth: fiacao.auth, dataSourceFor: fiacao.dataSourceFor)); + await tester.pump(const Duration(milliseconds: 500)); +} + +/// Segredo TOTP lido da tela de ativação e o passo em que foi confirmado. +Uint8List? _segredo; +int? _passoDaAtivacao; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('senha errada: recusa com a mensagem genérica e o painel não abre', (tester) async { + expect(AdminBackendConfig.host, startsWith('https://')); + await _abrirApp(tester); + final cred = await adminCredentialFromRelay(); + await _digitar(tester, 'matricula_field', cred.matricula); + await _digitar(tester, 'senha_field', '${cred.senha}-errada'); + await _tocar(tester, 'login_button'); + await _pumpUntil(tester, () => _existe('login_error')); + + final texto = tester.widget(find.byKey(const Key('login_error'))).data; + expect(texto, 'Matrícula ou senha inválidos.'); + expect(find.text('Painel de Indicadores'), findsNothing); + expect(_existe('login_button'), isTrue); + }); + + testWidgets('código de ativação errado: recusado, e o QR não aparece (#48)', (tester) async { + await _abrirApp(tester); + final cred = await adminCredentialFromRelay(); + await _digitar(tester, 'matricula_field', cred.matricula); + await _digitar(tester, 'senha_field', cred.senha); + await _tocar(tester, 'login_button'); + + // Conta sem MFA: o app abre a etapa do código de ativação, ainda sem segredo. + await _pumpUntil(tester, () => _existe('activation_code_field')); + expect(_existe('mfa_secret'), isFalse); + + // O código certo com o último caractere trocado: não vale em lugar nenhum. + final c = cred.activationCode; + final errado = '${c.substring(0, c.length - 1)}${c.endsWith('A') ? 'B' : 'A'}'; + await _digitar(tester, 'activation_code_field', errado); + await _tocar(tester, 'activation_continue'); + await _pumpUntil(tester, () => _existe('activation_error')); + + expect(tester.widget(find.byKey(const Key('activation_error'))).data, 'Código de ativação inválido ou expirado.'); + expect(_existe('mfa_secret'), isFalse); + expect(find.text('Painel de Indicadores'), findsNothing); + }); + + testWidgets('primeiro acesso: pede a ativação da MFA, mostra o segredo e confirma com o código', (tester) async { + await _abrirApp(tester); + final cred = await adminCredentialFromRelay(); + await _digitar(tester, 'matricula_field', cred.matricula); + await _digitar(tester, 'senha_field', cred.senha); + await _tocar(tester, 'login_button'); + + // Conta sem TOTP ativado: o servidor responde MfaEnrollmentRequired e o app abre a + // etapa do código de ativação (#48); só com ele o servidor devolve o segredo. + await _pumpUntil(tester, () => _existe('activation_code_field')); + await _digitar(tester, 'activation_code_field', cred.activationCode); + await _tocar(tester, 'activation_continue'); + await _pumpUntil(tester, () => _existe('mfa_secret')); + expect(find.text('Painel de Indicadores'), findsNothing); + final segredoBase32 = tester.widget(find.byKey(const Key('mfa_secret'))).data!; + _segredo = base32Decode(segredoBase32); + expect(_segredo, isNotEmpty); + + // Um único instante: o passo registrado é o do código enviado (dois + // `DateTime.now()` na virada de 30 s dariam passos diferentes). + final instanteDaAtivacao = DateTime.now(); + _passoDaAtivacao = _passo(instanteDaAtivacao); + await _digitar(tester, 'mfa_code_field', totpCode(_segredo!, instanteDaAtivacao)); + await _tocar(tester, 'mfa_confirm_button'); + + // Confirmado: volta ao login, que agora pede o código do autenticador. + await _pumpUntil(tester, () => _existe('totp_field')); + expect(_existe('login_aviso'), isTrue); + expect(find.text('Painel de Indicadores'), findsNothing); + }); + + testWidgets('código TOTP errado: recusado, e o painel não abre', (tester) async { + expect(_segredo, isNotNull, reason: 'roda depois da ativação (o arquivo inteiro, em ordem)'); + await _abrirApp(tester); + final cred = await adminCredentialFromRelay(); + await _digitar(tester, 'matricula_field', cred.matricula); + await _digitar(tester, 'senha_field', cred.senha); + await _tocar(tester, 'login_button'); + // Com a MFA ativa o servidor pede o código. + await _pumpUntil(tester, () => _existe('totp_field')); + + // Um código que NÃO é válido em nenhum passo da janela de +-1 do servidor. + final agora = DateTime.now(); + final validos = { + for (final d in const [-30, 0, 30]) totpCode(_segredo!, agora.add(Duration(seconds: d))), + }; + final errado = List.generate(1000000, (i) => i.toString().padLeft(6, '0')).firstWhere((c) => !validos.contains(c)); + await _digitar(tester, 'totp_field', errado); + await _tocar(tester, 'login_button'); + await _pumpUntil(tester, () => _existe('login_error')); + + expect(tester.widget(find.byKey(const Key('login_error'))).data, 'Código de verificação inválido.'); + expect(find.text('Painel de Indicadores'), findsNothing); + }); + + testWidgets('com o código do passo seguinte à ativação, o login abre o Painel de Indicadores', (tester) async { + expect(_segredo, isNotNull); + await _abrirApp(tester); + final cred = await adminCredentialFromRelay(); + await _digitar(tester, 'matricula_field', cred.matricula); + await _digitar(tester, 'senha_field', cred.senha); + await _tocar(tester, 'login_button'); + await _pumpUntil(tester, () => _existe('totp_field')); + + // O código da ativação já foi usado e o replay é barrado: espera o relógio + // passar para um passo MAIOR que o da ativação e usa o do passo ATUAL + // (dentro da janela de +-1 do servidor). + await _pumpUntil(tester, () => _passo(DateTime.now()) > _passoDaAtivacao!, timeout: const Duration(seconds: 40)); + final instante = DateTime.now(); + expect(_passo(instante), greaterThan(_passoDaAtivacao!)); + await _digitar(tester, 'totp_field', totpCode(_segredo!, instante)); + await _tocar(tester, 'login_button'); + await _pumpUntil(tester, () => find.text('Painel de Indicadores').evaluate().isNotEmpty || _existe('login_error')); + + expect(_existe('login_error'), isFalse, + reason: _existe('login_error') ? (tester.widget(find.byKey(const Key('login_error'))).data ?? '') : ''); + expect(find.text('Painel de Indicadores'), findsOneWidget); + + // Dados REAIS (#40): o seed semeia 3 alertas na microárea (vermelho pendente, + // vermelho reconhecido 60 s depois e verde pendente), então o painel deve + // mostrar Vermelho 2, Amarelo 0, Verde 1, 1 aberto, 1 reconhecido e TMRAV 60s. + await _pumpUntil(tester, () => find.text('60s').evaluate().isNotEmpty); + expect(find.text('2'), findsOneWidget, reason: 'Vermelho = 2'); + expect(find.text('0'), findsOneWidget, reason: 'Amarelo = 0'); + expect(find.text('—'), findsNothing, reason: 'há amostra: o TMRAV não pode ser o traço'); + + // Alertas: 3 cartões, só com rótulo (`Paciente #XXXX`), nunca nome de paciente. + await tester.tap(find.text('Alertas').last); + await tester.pump(const Duration(milliseconds: 300)); + final rotulo = RegExp(r'^Paciente #[0-9A-F]{4}$'); + await _pumpUntil(tester, () => find.byWidgetPredicate((w) => w is Text && rotulo.hasMatch(w.data ?? '')).evaluate().length == 3); + expect(find.text('Risco: Vermelho'), findsNWidgets(2)); + expect(find.text('Risco: Verde'), findsOneWidget); + expect(find.textContaining('Paciente E2E'), findsNothing, reason: 'nome de paciente nunca aparece'); + + // Auditoria: a leitura do próprio admin ficou registrada pelo servidor. + final auditoriaPorTexto = find.text('Auditoria'); + await tester.tap((auditoriaPorTexto.evaluate().isNotEmpty ? auditoriaPorTexto : find.byTooltip('Auditoria')).last); + await tester.pump(const Duration(milliseconds: 300)); + // Desde a #45 o painel da cadeia e os filtros ficam acima da lista, que é + // preguiçosa: num celular a linha de indicadores nasce abaixo da dobra. + await _pumpUntil(tester, () => find.byKey(const Key('audit_count')).evaluate().isNotEmpty); + // A verificação da cadeia (#45) é a 5ª leitura `admin_*` que o script + // confere no banco: espera-se ela voltar, com o painel ainda no topo e + // antes de rolar, para a chamada não ficar em voo quando o app fechar. + await _pumpUntil(tester, () { + final titulo = find.byKey(const Key('audit_chain_title'), skipOffstage: false); + if (titulo.evaluate().isEmpty) return false; + return tester.widget(titulo).data != 'Verificando a integridade da trilha…'; + }); + await tester.scrollUntilVisible( + find.textContaining('admin_indicators'), + 300, + scrollable: find.descendant(of: find.byType(AuditLogScreen), matching: find.byType(Scrollable)).first, + ); + expect(find.textContaining('(Administrador)'), findsWidgets); + }); +} diff --git a/apps/admin/integration_test/admin_mobile_smoke_test.dart b/apps/admin/integration_test/admin_mobile_smoke_test.dart index 7bbc72c1..6e60368f 100644 --- a/apps/admin/integration_test/admin_mobile_smoke_test.dart +++ b/apps/admin/integration_test/admin_mobile_smoke_test.dart @@ -5,15 +5,17 @@ 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'; +import '../test/support/fake_admin_auth.dart'; +import '../test/support/layout_harness.dart' show entrarComCredenciais; /// 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`, +/// do app ACS e do app do paciente. Aqui o painel roda sobre o +/// `MockAdminDataSource` (o fluxo com backend real é `scripts/qa/admin_login_e2e.sh`), então aqui **não** é preciso `docker compose up`, /// nem seed, nem `--dart-define`. Basta: /// -/// flutter test integration_test -d emulator-5554 +/// flutter test integration_test/admin_mobile_smoke_test.dart -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 @@ -23,24 +25,23 @@ 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.pumpWidget(SinalAdminApp(dataSource: dataSource, auth: FakeAdminAuth())); await tester.pumpAndSettle(); + await entrarComCredenciais(tester); } Future irPara(WidgetTester tester, String destino) async { - final alvo = find.text(destino).last; + // Em celular a barra inferior usa rótulos curtos ('Áreas', 'Trilha'); o + // nome completo fica no tooltip. + final porTexto = find.text(destino); + final alvo = porTexto.evaluate().isNotEmpty ? porTexto.last : find.byTooltip(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 { + testWidgets('abre o backoffice no dispositivo e navega pelos destinos', (tester) async { await entrar(tester); expect(find.text('Painel de Indicadores'), findsOneWidget); @@ -50,6 +51,8 @@ void main() { expect(find.text('Alertas da UBS'), findsOneWidget); await irPara(tester, 'Auditoria'); expect(find.text('Logs de auditoria'), findsOneWidget); + await irPara(tester, 'Avisos'); + expect(find.text('Avisos às comunidades'), findsOneWidget); }); testWidgets('escolhe a navegação conforme a largura real do aparelho', (tester) async { diff --git a/apps/admin/integration_test/admin_notices_mobile_test.dart b/apps/admin/integration_test/admin_notices_mobile_test.dart new file mode 100644 index 00000000..80412fde --- /dev/null +++ b/apps/admin/integration_test/admin_notices_mobile_test.dart @@ -0,0 +1,87 @@ +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 '../test/support/fake_admin_auth.dart'; +import '../test/support/layout_harness.dart' show entrarComCredenciais; + +/// Avisos e Termos no runtime real do Android (Issue #46). +/// +/// **Hermético**: roda sobre o `MockAdminDataSource`, sem `docker compose`, +/// seed nem `--dart-define`: +/// +/// flutter test integration_test/admin_notices_mobile_test.dart -d emulator-5554 +/// +/// Nenhum texto jurídico real: tudo é `[TEXTO PLACEHOLDER]`. +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + Future entrar(WidgetTester tester, {String role = 'admin'}) async { + await tester.pumpWidget(SinalAdminApp(auth: FakeAdminAuth()..role = role)); + await tester.pumpAndSettle(); + await entrarComCredenciais(tester); + } + + Future tocar(WidgetTester tester, Finder alvo) async { + await tester.ensureVisible(alvo); + await tester.pumpAndSettle(); + await tester.tap(alvo); + await tester.pumpAndSettle(); + } + + Future abrirAvisos(WidgetTester tester) async { + final porTexto = find.text('Avisos'); + await tocar(tester, porTexto.evaluate().isNotEmpty ? porTexto.last : find.byTooltip('Avisos').last); + expect(find.text('Avisos às comunidades'), findsOneWidget); + } + + testWidgets('admin agenda e cancela um aviso e publica uma versão dos termos', (tester) async { + await entrar(tester); + await abrirAvisos(tester); + + await tocar(tester, find.byKey(const Key('notice_new_button'))); + await tester.enterText(find.byKey(const Key('notice_title')), 'Aviso de teste'); + await tester.enterText(find.byKey(const Key('notice_message')), '[TEXTO PLACEHOLDER]'); + await tocar(tester, find.byKey(const Key('notice_area'))); + await tocar(tester, find.byType(DropdownMenuItem).last); + await tocar(tester, find.byKey(const Key('notice_submit'))); + + expect(find.text('Aviso de teste'), findsOneWidget); + expect(find.text('Agendado'), findsOneWidget); + + final cancelar = find.byWidgetPredicate((w) => w.key is Key && (w.key as Key).toString().contains('notice_cancel_') && !(w.key as Key).toString().contains('confirm')); + await tocar(tester, cancelar.first); + await tocar(tester, find.byKey(const Key('notice_cancel_confirm'))); + expect(find.text('Cancelado'), findsOneWidget); + + await tocar(tester, find.byKey(const Key('notices_tab_terms'))); + await tocar(tester, find.byKey(const Key('terms_new_button'))); + await tester.enterText(find.byKey(const Key('terms_version')), '2099.1'); + await tester.enterText(find.byKey(const Key('terms_summary')), '[TEXTO PLACEHOLDER]'); + await tocar(tester, find.byKey(const Key('terms_submit'))); + + expect(find.text('Versão 2099.1'), findsOneWidget); + expect(find.text('Aguardando revisão jurídica'), findsOneWidget); + final publicar = find.widgetWithText(FilledButton, 'Publicar'); + expect(publicar, findsOneWidget); + expect(tester.widget(publicar).onPressed, isNull); + + await tocar(tester, find.textContaining('Marcar como revisado').first); + await tocar(tester, find.byKey(const Key('terms_review_confirm'))); + final publicarHabilitado = find.text('Publicar'); + await tocar(tester, publicarHabilitado.first); + await tocar(tester, find.byKey(const Key('terms_publish_confirm'))); + + expect(find.textContaining('Publicada em'), findsOneWidget); + }); + + testWidgets('coordenador abre Avisos e não vê a sub-aba Termos', (tester) async { + await entrar(tester, role: 'coordinator'); + await abrirAvisos(tester); + + expect(find.byKey(const Key('notices_tab_history')), findsOneWidget); + expect(find.byKey(const Key('notices_tab_terms')), findsNothing); + expect(find.text('Termos'), findsNothing); + }); +} diff --git a/apps/admin/integration_test/admin_titular_e2e.dart b/apps/admin/integration_test/admin_titular_e2e.dart new file mode 100644 index 00000000..a2f2f0e6 --- /dev/null +++ b/apps/admin/integration_test/admin_titular_e2e.dart @@ -0,0 +1,155 @@ +/// Atendimento a pedidos do titular (#42) pela tela do backoffice, no emulador, +/// contra o BANCO DE TESTE: login com MFA (ativação pela tela), fila com o pedido +/// vencido primeiro e destacado, correção (iniciar análise -> atender com nota) e +/// exclusão (recusa sem motivo bloqueada -> atender com confirmação). As asserções +/// de banco (anonimização, auditoria, nota cifrada) ficam em +/// `scripts/qa/admin_titular_e2e.sh`. Sufixo `_e2e.dart` pelo mesmo motivo de +/// `admin_login_e2e.dart`: o `flutter test integration_test` não deve descobri-lo. +/// +/// PRIVACIDADE: só fixtures sintéticas; senha e segredo nunca vão para log. +@Timeout(Duration(minutes: 6)) +library; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show rootBundle; +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/auth/admin_auth_bootstrap.dart'; +import 'package:sinalacs_admin/core/auth/backend_config.dart'; + +import 'support/e2e_admin.dart'; +import 'support/totp.dart'; + +/// Texto sintético da nota: o script confere que NÃO aparece nos logs do backend. +const notaDaCorrecao = 'E2E-NOTA telefone corrigido no cadastro'; + +int _passo(DateTime t) => t.toUtc().millisecondsSinceEpoch ~/ 1000 ~/ 30; + +Future _pumpUntil(WidgetTester tester, bool Function() done, + {Duration timeout = const Duration(seconds: 30), String? motivo}) async { + final fim = DateTime.now().add(timeout); + while (!done() && DateTime.now().isBefore(fim)) { + await tester.pump(const Duration(milliseconds: 200)); + } + if (!done()) { + final textos = find.byType(Text).evaluate().map((e) => (e.widget as Text).data).whereType().take(40).join(' | '); + fail('condição não atingida em ${timeout.inSeconds}s${motivo == null ? '' : ': $motivo'}; tela: $textos'); + } +} + +bool _existe(String chave) => find.byKey(Key(chave)).evaluate().isNotEmpty; + +Future _tocar(WidgetTester tester, String chave) async { + final alvo = find.byKey(Key(chave)); + if (alvo.evaluate().isEmpty) { + // ListView preguiçoso: o que está abaixo da dobra ainda não foi construído. + await tester.scrollUntilVisible(alvo, 200, scrollable: find.byType(Scrollable).last, maxScrolls: 30); + } + await tester.ensureVisible(alvo); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(alvo); + await tester.pump(const Duration(milliseconds: 300)); +} + +Future _digitar(WidgetTester tester, String chave, String texto) async { + final campo = find.byKey(Key(chave)); + if (campo.evaluate().isEmpty) { + await tester.scrollUntilVisible(campo, 200, scrollable: find.byType(Scrollable).last, maxScrolls: 30); + } + await tester.ensureVisible(campo); + await tester.pump(const Duration(milliseconds: 100)); + await tester.enterText(campo, texto); + await tester.pump(const Duration(milliseconds: 100)); +} + +bool _textoNaTela(String trecho) => find.textContaining(trecho).evaluate().isNotEmpty; + +/// O detalhe é um ListView preguiçoso: o que está abaixo da dobra ainda não existe. +Future _rolarAte(WidgetTester tester, String trecho) async { + await _pumpUntil(tester, () => _existe('data_request_back') && find.text('Texto do pedido').evaluate().isNotEmpty && find.byType(Scrollable).evaluate().isNotEmpty, + motivo: 'detalhe carregado'); + await tester.scrollUntilVisible(find.textContaining(trecho), 200, + scrollable: find.byType(Scrollable).last, maxScrolls: 30); + await tester.pump(const Duration(milliseconds: 200)); +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('admin atende a correção e a exclusão do titular', (tester) async { + expect(AdminBackendConfig.host, startsWith('https://')); + final ca = (await rootBundle.load(adminRpcCaAsset)).buffer.asUint8List(); + final fiacao = buildAdminWiring(caBytes: ca); + await tester.pumpWidget(SinalAdminApp(auth: fiacao.auth, dataSourceFor: fiacao.dataSourceFor)); + await tester.pump(const Duration(milliseconds: 500)); + + // --- Login: ativação da MFA pela tela, depois o código do passo seguinte. + final cred = await adminCredentialFromRelay(); + await _digitar(tester, 'matricula_field', cred.matricula); + await _digitar(tester, 'senha_field', cred.senha); + await _tocar(tester, 'login_button'); + await _pumpUntil(tester, () => _existe('activation_code_field')); + await _digitar(tester, 'activation_code_field', cred.activationCode); + await _tocar(tester, 'activation_continue'); + await _pumpUntil(tester, () => _existe('mfa_secret')); + final segredo = base32Decode(tester.widget(find.byKey(const Key('mfa_secret'))).data!); + final instanteDaAtivacao = DateTime.now(); + final passoDaAtivacao = _passo(instanteDaAtivacao); + await _digitar(tester, 'mfa_code_field', totpCode(segredo, instanteDaAtivacao)); + await _tocar(tester, 'mfa_confirm_button'); + await _pumpUntil(tester, () => _existe('totp_field')); + + await _digitar(tester, 'matricula_field', cred.matricula); + await _digitar(tester, 'senha_field', cred.senha); + // O código da ativação já foi usado e o replay é barrado: espera o passo seguinte. + await _pumpUntil(tester, () => _passo(DateTime.now()) > passoDaAtivacao, timeout: const Duration(seconds: 40)); + await _digitar(tester, 'totp_field', totpCode(segredo, DateTime.now())); + await _tocar(tester, 'login_button'); + await _pumpUntil(tester, () => find.text('Painel de Indicadores').evaluate().isNotEmpty); + + // --- Fila: o vencido vem primeiro e destacado. + // Celular: a barra inferior usa o rótulo curto "Pedidos"; no trilho, o completo. + final destino = find.text('Pedidos do titular').evaluate().isNotEmpty + ? find.text('Pedidos do titular').last + : find.descendant(of: find.byKey(const Key('admin_navigation_bar')), matching: find.text('Pedidos')); + await tester.tap(destino); + await tester.pump(const Duration(milliseconds: 500)); + await _pumpUntil(tester, () => find.byWidgetPredicate((w) => w.key.toString().contains('data_request_')).evaluate().length >= 2, + motivo: 'os dois pedidos semeados'); + final cartoes = find.byWidgetPredicate( + (w) => w is Card && (w.key?.toString().contains('data_request_') ?? false)); + expect(cartoes, findsNWidgets(2)); + expect(find.descendant(of: cartoes.first, matching: find.text('Correção de dados')), findsOneWidget, + reason: 'o pedido vencido (correção) aparece primeiro'); + expect(find.descendant(of: cartoes.first, matching: find.byKey(const Key('overdue_icon'))), findsOneWidget); + expect(find.descendant(of: cartoes.last, matching: find.byKey(const Key('overdue_icon'))), findsNothing); + expect(find.textContaining('Fulano'), findsNothing); + expect(find.textContaining('Paciente E2E'), findsNothing, reason: 'nome de titular nunca aparece'); + + // --- Correção: lê o texto, inicia a análise e atende com a nota. + await tester.tap(cartoes.first); + await _rolarAte(tester, 'E2E-CORRECAO'); + await _tocar(tester, 'start_review_button'); + await _pumpUntil(tester, () => _existe('data_request_action_notice') && !_existe('start_review_button'), + motivo: 'análise iniciada'); + await _digitar(tester, 'resolution_field', notaDaCorrecao); + await _tocar(tester, 'complete_button'); + await _pumpUntil(tester, () => !_existe('complete_button'), motivo: 'correção atendida'); + await _rolarAte(tester, notaDaCorrecao); + expect(_textoNaTela('Resposta ao titular'), isTrue); + await _tocar(tester, 'data_request_back'); + + // --- Exclusão: recusar sem motivo é bloqueado; atender pede confirmação. + await _pumpUntil(tester, () => find.text('Exclusão dos dados').evaluate().isNotEmpty, motivo: 'lista de volta'); + await tester.tap(find.text('Exclusão dos dados').first); + await _rolarAte(tester, 'E2E-EXCLUSAO'); + expect(tester.widget(find.byKey(const Key('reject_button'))).onPressed, isNull, + reason: 'recusa sem motivo está bloqueada'); + await _tocar(tester, 'complete_button'); + await _pumpUntil(tester, () => _existe('confirm_action'), motivo: 'diálogo de confirmação da exclusão'); + await _tocar(tester, 'confirm_action'); + await _pumpUntil(tester, () => !_existe('complete_button') && _existe('data_request_action_notice'), + motivo: 'exclusão atendida'); + }); +} diff --git a/apps/admin/integration_test/support/e2e_admin.dart b/apps/admin/integration_test/support/e2e_admin.dart new file mode 100644 index 00000000..9716389a --- /dev/null +++ b/apps/admin/integration_test/support/e2e_admin.dart @@ -0,0 +1,32 @@ +import 'dart:convert'; +import 'dart:io'; + +const relayBase = String.fromEnvironment('OTP_RELAY_BASE', defaultValue: 'http://localhost:8765'); + +/// Matrícula, senha e código de ativação (#48) do administrador da fixture (sintéticas, novas a cada +/// execução), servidas pelo relé em `/admin` **uma única vez** (opt-in, via +/// `E2E_FIXTURES_FILE`); a primeira chamada busca, as seguintes reaproveitam. +Future<({String matricula, String senha, String activationCode})> adminCredentialFromRelay() => + _credencial ??= _buscar(); + +Future<({String matricula, String senha, String activationCode})>? _credencial; + +Future<({String matricula, String senha, String activationCode})> _buscar() async { + final client = HttpClient(); + try { + final request = await client.getUrl(Uri.parse(relayBase).replace(path: '/admin')); + final response = await request.close(); + final body = await utf8.decodeStream(response); + if (response.statusCode != 200) throw StateError('o relé respondeu ${response.statusCode} a /admin'); + final json = jsonDecode(body) as Map; + return ( + matricula: json['matricula'] as String, + senha: json['senha'] as String, + activationCode: json['activationCode'] as String, + ); + } on SocketException { + throw StateError('o relé não está no ar (scripts/qa/otp_relay.py + adb reverse tcp:8765 tcp:8765)'); + } finally { + client.close(force: true); + } +} diff --git a/apps/admin/integration_test/support/totp.dart b/apps/admin/integration_test/support/totp.dart new file mode 100644 index 00000000..f020bd45 --- /dev/null +++ b/apps/admin/integration_test/support/totp.dart @@ -0,0 +1,34 @@ +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; + +/// Gerador TOTP (RFC 6238, SHA-1, 6 dígitos, 30 s) SÓ para testes: a prova de +/// MFA no emulador precisa produzir o código que o autenticador produziria. +String totpCode(Uint8List secret, DateTime at) { + final passo = at.toUtc().millisecondsSinceEpoch ~/ 1000 ~/ 30; + final mensagem = ByteData(8)..setUint64(0, passo); + final h = Hmac(sha1, secret).convert(mensagem.buffer.asUint8List()).bytes; + final o = h[h.length - 1] & 0x0f; + final bin = ((h[o] & 0x7f) << 24) | (h[o + 1] << 16) | (h[o + 2] << 8) | h[o + 3]; + return (bin % 1000000).toString().padLeft(6, '0'); +} + +/// Decodifica base32 (RFC 4648, sem preenchimento) — o que o servidor entrega em `secretBase32`. +Uint8List base32Decode(String texto) { + const alfabeto = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + var bits = 0; + var valor = 0; + final saida = []; + for (final c in texto.toUpperCase().split('')) { + final i = alfabeto.indexOf(c); + if (i < 0) continue; + valor = (valor << 5) | i; + bits += 5; + if (bits >= 8) { + saida.add((valor >> (bits - 8)) & 0xff); + bits -= 8; + valor &= (1 << bits) - 1; + } + } + return Uint8List.fromList(saida); +} diff --git a/apps/admin/lib/app/admin_accounts.dart b/apps/admin/lib/app/admin_accounts.dart new file mode 100644 index 00000000..a98608ef --- /dev/null +++ b/apps/admin/lib/app/admin_accounts.dart @@ -0,0 +1,839 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:sinalacs_admin/app/admin_theme.dart'; +import 'package:sinalacs_admin/core/data/admin_data_source.dart'; + +/// Seções e diálogos da gestão de contas do backoffice (#43). +/// +/// A tela Microáreas deixou de ser somente leitura: aqui ficam a seção de ACS +/// (cadastro, vínculo, senha, MFA e ativação) e a de Equipe do backoffice (só o +/// administrador), junto dos diálogos que elas abrem. +/// +/// Regra de fluxo, provada por `test/acs_management_screen_test.dart`: +/// **toda** ação destrutiva — desativar, redefinir senha, redefinir MFA do ACS +/// e do staff — passa por [_ConfirmacaoDestrutiva], e nada é chamado no servidor +/// antes de o operador confirmar. Reativar e vincular não são destrutivas: o +/// vínculo escolhe a microárea num diálogo, mas não pede uma segunda +/// confirmação. +/// +/// Este arquivo não importa `app.dart` de propósito (é `app.dart` quem compõe +/// estas seções): o pouco que as duas telas compartilham — o vocabulário de +/// papel — mora em [_papelDoBackoffice], com a mesma tradução de +/// `adminRoleLabel`. + +/// Botão que confirma o diálogo aberto — o destrutivo, o de cadastro e o de +/// vínculo usam a mesma chave: quem opera (e o e2e da Tarefa 12) aprende uma +/// tecla só, e [chaveCancelarAcao] é sempre o caminho de volta. +const chaveConfirmarAcao = Key('confirmar_acao'); +const chaveCancelarAcao = Key('cancelar_acao'); + +/// Rótulo do papel na tela, sem o índice do enum do servidor (`role` chega +/// pelo nome: `admin`/`coordinator`). +String _papelDoBackoffice(String role) => switch (role) { + 'admin' => 'Administrador', + 'coordinator' => 'Coordenador', + _ => 'Equipe', + }; + +/// Texto de uma falha de escrita, na régua do #41: [AdminValidationFailure] é +/// sobre a entrada do próprio operador e vai à tela com a mensagem do servidor +/// ('Já existe um ACS com esta matrícula.'); qualquer outra recusa continua com +/// texto fixo — o motivo de uma recusa de permissão nunca vira texto do +/// servidor. +String mensagemDaFalhaEscrita(Object erro) => + erro is AdminValidationFailure ? erro.message : 'Não foi possível concluir a ação. Tente novamente.'; + +/// Resultado do cadastro para o formulário: a credencial quando deu certo, ou a +/// mensagem a mostrar **dentro** do diálogo. Os dois nulos = sessão vencida (o +/// login assume e o diálogo morre com a árvore). +typedef _ResultadoDoCadastro = ({NewAcsCredential? credencial, String? erro}); + +/// Abre [_ConfirmacaoDestrutiva] e devolve `true` só se o operador confirmou. +Future confirmarDestrutiva( + BuildContext context, { + required String titulo, + required String consequencia, + required String rotuloConfirmar, +}) async => + await showDialog( + context: context, + builder: (_) => _ConfirmacaoDestrutiva( + titulo: titulo, + consequencia: consequencia, + rotuloConfirmar: rotuloConfirmar, + ), + ) ?? + false; + +/// Ciclo de vida comum das escritas das duas seções. +/// +/// Existe para que "uma escrita por vez, falha tratada num lugar só e lista +/// recarregada no sucesso" não seja reescrito — e divergindo — em cada seção. +mixin _Escritas on State { + bool ocupado = false; + + VoidCallback get aoRecarregar; + VoidCallback? get aoVencer; + + /// Roda [acao] e devolve `true` quando ela terminou. A senha nova e o código + /// de ativação saem por uma variável capturada pela própria [acao]: é o + /// chamador que sabe qual valor mostrar depois. + /// + /// `AdminSessionExpired` entrega a tela ao login ([aoVencer]) sem mensagem: a + /// sessão vencida não é uma falha da ação, e "tentar de novo" falharia igual. + Future escrever(Future Function() acao) async { + if (ocupado) return false; + setState(() => ocupado = true); + var deuCerto = false; + try { + await acao(); + deuCerto = true; + } on AdminSessionExpired { + aoVencer?.call(); + } catch (erro) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(mensagemDaFalhaEscrita(erro)))); + } + } finally { + if (mounted) setState(() => ocupado = false); + } + if (deuCerto && mounted) aoRecarregar(); + return deuCerto; + } +} + +/// Seção de ACS da tela Microáreas: lista o estado de cada conta e oferece as +/// ações da issue #43. +class AcsManagementSection extends StatefulWidget { + const AcsManagementSection({ + required this.dataSource, + required this.microAreas, + required this.acs, + required this.onRecarregar, + this.aoVencerSessao, + super.key, + }); + + final AdminDataSource dataSource; + + /// Vêm da carga única da tela: o formulário e o diálogo de vínculo escolhem a + /// microárea daqui, sem uma segunda busca. + final List microAreas; + final List acs; + + /// Recarrega a lista depois de uma escrita — o estado que a tela mostra vem + /// do servidor, nunca de uma cópia local atualizada à mão. + final VoidCallback onRecarregar; + final VoidCallback? aoVencerSessao; + + @override + State createState() => _AcsManagementSectionState(); +} + +class _AcsManagementSectionState extends State with _Escritas { + @override + VoidCallback get aoRecarregar => widget.onRecarregar; + + @override + VoidCallback? get aoVencer => widget.aoVencerSessao; + + Future _novoAcs() async { + final credencial = await showDialog( + context: context, + builder: (_) => _FormularioNovoAcs( + microAreas: widget.microAreas, + cadastrar: _cadastrar, + ), + ); + if (credencial == null || !mounted) return; + await _mostrarCredencial( + titulo: 'Senha inicial de ${credencial.acs.name}', + explicacao: 'Entregue esta senha ao ACS. É ela que dá o primeiro acesso; a ' + 'troca de senha pelo próprio ACS ainda não existe no produto.', + valor: credencial.initialPassword, + chaveDoValor: const Key('senha_inicial'), + ); + if (mounted) widget.onRecarregar(); + } + + /// Cadastro pedido pelo formulário: a falha de validação é mostrada **dentro** + /// do formulário, onde está o texto que o operador precisa corrigir — fechar o + /// diálogo a cada recusa faria o operador digitar tudo de novo. + Future<_ResultadoDoCadastro> _cadastrar(String nome, String matricula, String microAreaId) async { + try { + final credencial = await widget.dataSource.createAcs( + name: nome, + enrollmentId: matricula, + microAreaId: microAreaId, + ); + return (credencial: credencial, erro: null); + } on AdminSessionExpired { + widget.aoVencerSessao?.call(); + return const (credencial: null, erro: null); + } catch (erro) { + return (credencial: null, erro: mensagemDaFalhaEscrita(erro)); + } + } + + Future _vincular(AcsSummary acs) async { + final escolhida = await showDialog( + context: context, + builder: (_) => _DialogoDeVinculo(acs: acs, microAreas: widget.microAreas), + ); + if (escolhida == null || !mounted) return; + await escrever(() => widget.dataSource.setAcsMicroArea(acsId: acs.id, microAreaId: escolhida)); + } + + Future _alternarAcesso(AcsSummary acs) async { + if (!acs.active) { + // Reativar não é destrutivo: um passo só, sem diálogo. + await escrever(() => widget.dataSource.setAcsActive(acsId: acs.id, active: true)); + return; + } + final confirmado = await confirmarDestrutiva( + context, + titulo: 'Desativar o acesso de ${acs.name}?', + consequencia: 'O ACS perde o acesso na hora e o login passa a responder ' + '"Este acesso está inativo.": as sessões abertas e os tokens de envio ' + 'diferido são revogados no servidor. O vínculo e o histórico de visitas ' + 'continuam guardados, e a conta pode ser reativada depois.', + rotuloConfirmar: 'Desativar', + ); + if (!confirmado || !mounted) return; + await escrever(() => widget.dataSource.setAcsActive(acsId: acs.id, active: false)); + } + + Future _redefinirSenha(AcsSummary acs) async { + final confirmado = await confirmarDestrutiva( + context, + titulo: 'Redefinir a senha de ${acs.name}?', + consequencia: 'A senha atual deixa de valer imediatamente e uma senha nova ' + 'é gerada pelo servidor, mostrada uma única vez. As sessões já abertas ' + 'seguem ativas até vencer.', + rotuloConfirmar: 'Redefinir senha', + ); + if (!confirmado || !mounted) return; + + String? nova; + final deuCerto = await escrever(() async { + nova = await widget.dataSource.resetAcsPassword(acsId: acs.id); + }); + if (!deuCerto || nova == null || !mounted) return; + await _mostrarCredencial( + titulo: 'Nova senha de ${acs.name}', + explicacao: 'Entregue esta senha ao ACS. A anterior não vale mais.', + valor: nova!, + chaveDoValor: const Key('senha_inicial'), + ); + } + + Future _redefinirMfa(AcsSummary acs) async { + final confirmado = await confirmarDestrutiva( + context, + titulo: 'Redefinir a MFA de ${acs.name}?', + consequencia: 'A verificação em duas etapas atual deixa de valer e o ACS ' + 'precisa ativá-la de novo na próxima entrada. A conta não é ' + 'desbloqueada por esta ação.', + rotuloConfirmar: 'Redefinir MFA', + ); + if (!confirmado || !mounted) return; + await escrever(() => widget.dataSource.resetAcsMfa(acsId: acs.id)); + } + + Future _mostrarCredencial({ + required String titulo, + required String explicacao, + required String valor, + required Key chaveDoValor, + }) => + showDialog( + context: context, + builder: (_) => _CredencialMostradaUmaVez( + titulo: titulo, + explicacao: explicacao, + valor: valor, + chaveDoValor: chaveDoValor, + ), + ); + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 12, + runSpacing: 8, + children: [ + const Text('Agentes de saúde (ACS)', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + FilledButton.icon( + key: const Key('novo_acs'), + style: FilledButton.styleFrom(minimumSize: const Size(48, 52)), + onPressed: ocupado ? null : _novoAcs, + icon: const Icon(Icons.person_add_alt_outlined), + label: const Text('Novo ACS'), + ), + ], + ), + const SizedBox(height: 4), + const Text('Cadastro, vínculo de microárea, senha e MFA. Cada ação é auditada no backoffice.'), + const SizedBox(height: 12), + if (widget.acs.isEmpty) + const Text('Nenhum ACS no seu escopo ainda.') + else + for (final acs in widget.acs) + Card( + key: Key('acs_${acs.id}'), + margin: const EdgeInsets.only(bottom: 12), + child: _CartaoAcs( + acs: acs, + ocupado: ocupado, + aoVincular: () => _vincular(acs), + aoAlternarAcesso: () => _alternarAcesso(acs), + aoRedefinirSenha: () => _redefinirSenha(acs), + aoRedefinirMfa: () => _redefinirMfa(acs), + ), + ), + ], + ); +} + +/// Seção de contas de equipe do backoffice — só o administrador a vê (o +/// servidor recusa a listagem para o coordenador, então não é só a tela que a +/// esconde). +class StaffManagementSection extends StatefulWidget { + const StaffManagementSection({ + required this.dataSource, + required this.staff, + required this.onRecarregar, + this.aoVencerSessao, + super.key, + }); + + final AdminDataSource dataSource; + final List staff; + final VoidCallback onRecarregar; + final VoidCallback? aoVencerSessao; + + @override + State createState() => _StaffManagementSectionState(); +} + +class _StaffManagementSectionState extends State with _Escritas { + @override + VoidCallback get aoRecarregar => widget.onRecarregar; + + @override + VoidCallback? get aoVencer => widget.aoVencerSessao; + + Future _redefinirMfa(StaffSummary conta) async { + final confirmado = await confirmarDestrutiva( + context, + titulo: 'Redefinir a MFA de ${conta.name}?', + consequencia: 'A verificação em duas etapas atual deixa de valer. Um código ' + 'de ativação novo é emitido aqui, vale 24 horas e é mostrado uma única ' + 'vez — entregue a ${conta.name} para a próxima entrada.', + rotuloConfirmar: 'Redefinir MFA', + ); + if (!confirmado || !mounted) return; + + String? codigo; + final deuCerto = await escrever(() async { + codigo = (await widget.dataSource.resetStaffMfa(staffId: conta.id)).code; + }); + if (!deuCerto || codigo == null || !mounted) return; + await showDialog( + context: context, + builder: (_) => _CredencialMostradaUmaVez( + titulo: 'Código de ativação de ${conta.name}', + explicacao: 'Entregue este código a ${conta.name}: ele ativa a MFA na ' + 'próxima entrada. Vale 24 horas e a MFA antiga já não vale mais.', + valor: codigo!, + chaveDoValor: const Key('codigo_ativacao'), + ), + ); + } + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Equipe do backoffice', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + const SizedBox(height: 4), + const Text('Contas de coordenação e administração. A redefinição de MFA é feita por outro administrador.'), + const SizedBox(height: 12), + if (widget.staff.isEmpty) + const Text('Nenhuma conta de equipe no seu escopo.') + else + for (final conta in widget.staff) + Card( + key: Key('staff_${conta.id}'), + margin: const EdgeInsets.only(bottom: 12), + child: _CartaoStaff( + conta: conta, + ocupado: ocupado, + aoRedefinirMfa: () => _redefinirMfa(conta), + ), + ), + ], + ); +} + +class _CartaoAcs extends StatelessWidget { + const _CartaoAcs({ + required this.acs, + required this.ocupado, + required this.aoVincular, + required this.aoAlternarAcesso, + required this.aoRedefinirSenha, + required this.aoRedefinirMfa, + }); + + final AcsSummary acs; + final bool ocupado; + final VoidCallback aoVincular; + final VoidCallback aoAlternarAcesso; + final VoidCallback aoRedefinirSenha; + final VoidCallback aoRedefinirMfa; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(acs.name, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + const SizedBox(height: 4), + Text('Matrícula: ${acs.enrollmentId}'), + Text('UBS: ${acs.ubsName}'), + // "Território" e não "Microárea: ${nome}": o nome já começa por + // "Microárea NN —" e o rótulo repetido leria mal. O território é o + // vocabulário da territorialização (INV-01) no produto. + Text('Território: ${acs.microAreaName ?? 'sem vínculo'}'), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + Chip( + avatar: Icon(acs.active ? Icons.check_circle_outline : Icons.remove_circle_outline, size: 18), + label: Text(acs.active ? 'Ativo' : 'Inativo'), + backgroundColor: AdminColors.surface, + ), + Chip( + avatar: Icon(acs.mfaActive ? Icons.lock_outline : Icons.lock_open_outlined, size: 18), + label: Text(acs.mfaActive ? 'MFA ativa' : 'MFA não ativada'), + backgroundColor: AdminColors.surface, + ), + ], + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + OutlinedButton.icon( + key: Key('vincular_acs_${acs.id}'), + style: OutlinedButton.styleFrom(minimumSize: const Size(48, 52)), + onPressed: ocupado ? null : aoVincular, + icon: const Icon(Icons.map_outlined), + label: const Text('Vincular'), + ), + OutlinedButton.icon( + key: Key('redefinir_senha_${acs.id}'), + style: OutlinedButton.styleFrom(minimumSize: const Size(48, 52)), + onPressed: ocupado ? null : aoRedefinirSenha, + icon: const Icon(Icons.key_outlined), + label: const Text('Redefinir senha'), + ), + OutlinedButton.icon( + key: Key('redefinir_mfa_${acs.id}'), + style: OutlinedButton.styleFrom(minimumSize: const Size(48, 52)), + onPressed: ocupado ? null : aoRedefinirMfa, + icon: const Icon(Icons.phonelink_lock_outlined), + label: const Text('Redefinir MFA'), + ), + OutlinedButton.icon( + key: Key('${acs.active ? 'desativar' : 'ativar'}_acs_${acs.id}'), + style: OutlinedButton.styleFrom(minimumSize: const Size(48, 52)), + onPressed: ocupado ? null : aoAlternarAcesso, + icon: Icon(acs.active ? Icons.block_outlined : Icons.play_circle_outline), + label: Text(acs.active ? 'Desativar' : 'Ativar'), + ), + ], + ), + ], + ), + ); +} + +class _CartaoStaff extends StatelessWidget { + const _CartaoStaff({required this.conta, required this.ocupado, required this.aoRedefinirMfa}); + + final StaffSummary conta; + final bool ocupado; + final VoidCallback aoRedefinirMfa; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(conta.name, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + const SizedBox(height: 4), + Text('Matrícula: ${conta.enrollmentId} • ${_papelDoBackoffice(conta.role)}'), + Text(conta.ubsName == null ? 'Acesso ao sistema inteiro' : 'UBS: ${conta.ubsName}'), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + Chip( + avatar: Icon(conta.active ? Icons.check_circle_outline : Icons.remove_circle_outline, size: 18), + label: Text(conta.active ? 'Ativa' : 'Inativa'), + backgroundColor: AdminColors.surface, + ), + Chip( + avatar: Icon(conta.mfaActive ? Icons.lock_outline : Icons.lock_open_outlined, size: 18), + label: Text(conta.mfaActive ? 'MFA ativa' : 'MFA não ativada'), + backgroundColor: AdminColors.surface, + ), + ], + ), + const SizedBox(height: 12), + OutlinedButton.icon( + key: Key('redefinir_mfa_staff_${conta.id}'), + style: OutlinedButton.styleFrom(minimumSize: const Size(48, 52)), + onPressed: ocupado ? null : aoRedefinirMfa, + icon: const Icon(Icons.phonelink_lock_outlined), + label: const Text('Redefinir MFA'), + ), + ], + ), + ); +} + +/// Diálogo de confirmação das ações destrutivas. +/// +/// O título diz o que vai acontecer, o corpo diz a consequência em texto claro +/// (o que se perde, o que continua guardado) e o rótulo que confirma usa +/// [AdminColors.redOnSurface] — o token de texto, nunca o `red` de +/// preenchimento, que como texto cai para ~3:1 sobre a superfície do diálogo. +class _ConfirmacaoDestrutiva extends StatelessWidget { + const _ConfirmacaoDestrutiva({ + required this.titulo, + required this.consequencia, + required this.rotuloConfirmar, + }); + + final String titulo; + final String consequencia; + final String rotuloConfirmar; + + /// O tema não define mínimo global de botão, e o `AlertDialog` do Material 3 + /// nasce com alvos abaixo dos 48dp da WCAG 2.5.5 — como em `login_screen.dart`. + static final _estilo = ButtonStyle( + minimumSize: WidgetStateProperty.all(const Size(48, 52)), + ); + + @override + Widget build(BuildContext context) => AlertDialog( + // `scrollable`: a consequência de uma ação destrutiva é uma frase longa + // e, com a fonte do sistema a 200% (WCAG 1.4.4), não caberia na altura + // do diálogo sem cortar texto. + scrollable: true, + title: Text(titulo), + content: Text(consequencia), + actions: [ + TextButton( + key: chaveCancelarAcao, + style: _estilo, + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancelar'), + ), + TextButton( + key: chaveConfirmarAcao, + style: _estilo, + onPressed: () => Navigator.of(context).pop(true), + child: Text( + rotuloConfirmar, + style: const TextStyle(color: AdminColors.redOnSurface, fontWeight: FontWeight.bold), + ), + ), + ], + ); +} + +/// Formulário de cadastro de ACS. +/// +/// O cadastro é feito por [cadastrar] (a seção) enquanto o diálogo continua +/// aberto: assim uma recusa de validação aparece junto dos campos que o +/// operador precisa corrigir, em vez de fechar o formulário e perder o que foi +/// digitado. +class _FormularioNovoAcs extends StatefulWidget { + const _FormularioNovoAcs({required this.microAreas, required this.cadastrar}); + + final List microAreas; + final Future<_ResultadoDoCadastro> Function(String nome, String matricula, String microAreaId) cadastrar; + + @override + State<_FormularioNovoAcs> createState() => _FormularioNovoAcsState(); +} + +class _FormularioNovoAcsState extends State<_FormularioNovoAcs> { + final _nome = TextEditingController(); + final _matricula = TextEditingController(); + String? _microAreaId; + String? _erro; + bool _enviando = false; + + @override + void dispose() { + _nome.dispose(); + _matricula.dispose(); + super.dispose(); + } + + bool get _completo => _nome.text.trim().isNotEmpty && _matricula.text.trim().isNotEmpty && _microAreaId != null; + + Future _enviar() async { + if (!_completo || _enviando) return; + setState(() { + _enviando = true; + _erro = null; + }); + final resultado = await widget.cadastrar(_nome.text.trim(), _matricula.text.trim(), _microAreaId!); + if (!mounted) return; + if (resultado.credencial != null) { + Navigator.of(context).pop(resultado.credencial); + return; + } + setState(() { + _enviando = false; + _erro = resultado.erro; + }); + } + + @override + Widget build(BuildContext context) => AlertDialog( + // `scrollable`: a consequência de uma ação destrutiva é uma frase longa + // e, com a fonte do sistema a 200% (WCAG 1.4.4), não caberia na altura + // do diálogo sem cortar texto. + scrollable: true, + title: const Text('Novo ACS'), + content: SizedBox( + width: 360, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + key: const Key('acs_nome_field'), + controller: _nome, + textCapitalization: TextCapitalization.words, + onChanged: (_) => setState(() {}), + decoration: const InputDecoration(labelText: 'Nome completo'), + ), + const SizedBox(height: 16), + TextField( + key: const Key('acs_matricula_field'), + controller: _matricula, + onChanged: (_) => setState(() {}), + decoration: const InputDecoration(labelText: 'Matrícula / CNS'), + ), + const SizedBox(height: 16), + DropdownButtonFormField( + key: const Key('acs_microarea_field'), + initialValue: _microAreaId, + isExpanded: true, + decoration: const InputDecoration(labelText: 'Microárea'), + hint: const Text('Escolha a microárea'), + items: [ + for (final area in widget.microAreas) + DropdownMenuItem(value: area.id, child: Text(area.name, overflow: TextOverflow.ellipsis)), + ], + onChanged: (valor) => setState(() => _microAreaId = valor), + ), + const Padding( + padding: EdgeInsets.only(top: 8), + child: Text('A UBS do ACS vem da microárea escolhida, nunca do formulário.'), + ), + if (_erro != null) + Padding( + padding: const EdgeInsets.only(top: 16), + child: Semantics( + liveRegion: true, + child: Text( + _erro!, + key: const Key('cadastro_erro'), + style: const TextStyle(color: AdminColors.redOnSurface, fontWeight: FontWeight.bold), + ), + ), + ), + ], + ), + ), + actions: [ + TextButton( + key: chaveCancelarAcao, + style: _ConfirmacaoDestrutiva._estilo, + onPressed: _enviando ? null : () => Navigator.of(context).pop(), + child: const Text('Cancelar'), + ), + FilledButton( + key: chaveConfirmarAcao, + style: FilledButton.styleFrom(minimumSize: const Size(48, 52)), + onPressed: _completo && !_enviando ? _enviar : null, + child: Text(_enviando ? 'Cadastrando…' : 'Cadastrar'), + ), + ], + ); +} + +/// Diálogo de vínculo: escolher a microárea e confirmar. Não é destrutivo, mas +/// a escolha é explícita — o vínculo muda quem enxerga o território. +class _DialogoDeVinculo extends StatefulWidget { + const _DialogoDeVinculo({required this.acs, required this.microAreas}); + + final AcsSummary acs; + final List microAreas; + + @override + State<_DialogoDeVinculo> createState() => _DialogoDeVinculoState(); +} + +class _DialogoDeVinculoState extends State<_DialogoDeVinculo> { + String? _microAreaId; + + @override + void initState() { + super.initState(); + _microAreaId = widget.acs.microAreaId; + } + + @override + Widget build(BuildContext context) => AlertDialog( + // `scrollable`: a consequência de uma ação destrutiva é uma frase longa + // e, com a fonte do sistema a 200% (WCAG 1.4.4), não caberia na altura + // do diálogo sem cortar texto. + scrollable: true, + title: Text('Vincular ${widget.acs.name} a uma microárea'), + content: SizedBox( + width: 360, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('A microárea define o território do ACS: ele passa a ver só os pacientes dela.'), + const SizedBox(height: 16), + DropdownButtonFormField( + key: const Key('vincular_microarea_field'), + initialValue: _microAreaId, + isExpanded: true, + decoration: const InputDecoration(labelText: 'Microárea'), + hint: const Text('Escolha a microárea'), + items: [ + for (final area in widget.microAreas) + DropdownMenuItem(value: area.id, child: Text(area.name, overflow: TextOverflow.ellipsis)), + ], + onChanged: (valor) => setState(() => _microAreaId = valor), + ), + ], + ), + ), + actions: [ + TextButton( + key: chaveCancelarAcao, + style: _ConfirmacaoDestrutiva._estilo, + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancelar'), + ), + FilledButton( + key: chaveConfirmarAcao, + style: FilledButton.styleFrom(minimumSize: const Size(48, 52)), + onPressed: _microAreaId == null || _microAreaId == widget.acs.microAreaId + ? null + : () => Navigator.of(context).pop(_microAreaId), + child: const Text('Vincular'), + ), + ], + ); +} + +/// Diálogo de uma credencial que só existe nesta resposta do servidor — a senha +/// inicial do ACS e o código de ativação do staff. Depois de fechado não há +/// como vê-la de novo (o servidor guarda só o hash), então o aviso e o botão de +/// copiar estão aqui. +class _CredencialMostradaUmaVez extends StatelessWidget { + const _CredencialMostradaUmaVez({ + required this.titulo, + required this.explicacao, + required this.valor, + required this.chaveDoValor, + }); + + final String titulo; + final String explicacao; + final String valor; + + /// `senha_inicial` ou `codigo_ativacao`: é daqui que os testes e o e2e leem a + /// credencial. + final Key chaveDoValor; + + @override + Widget build(BuildContext context) => AlertDialog( + // `scrollable`: a consequência de uma ação destrutiva é uma frase longa + // e, com a fonte do sistema a 200% (WCAG 1.4.4), não caberia na altura + // do diálogo sem cortar texto. + scrollable: true, + title: Text(titulo), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(explicacao), + const SizedBox(height: 12), + Card( + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 4, 4, 4), + child: Row( + children: [ + Expanded( + child: SelectableText( + valor, + key: chaveDoValor, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, letterSpacing: 1), + ), + ), + Semantics( + label: 'Copiar', + button: true, + child: IconButton( + icon: const Icon(Icons.copy_outlined), + onPressed: () => Clipboard.setData(ClipboardData(text: valor)), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 12), + const Text( + 'Esta credencial não será mostrada de novo. Copie ou entregue agora.', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ], + ), + actions: [ + FilledButton( + key: const Key('fechar_credencial'), + style: FilledButton.styleFrom(minimumSize: const Size(48, 52)), + onPressed: () => Navigator.of(context).pop(), + child: const Text('Fechar'), + ), + ], + ); +} diff --git a/apps/admin/lib/app/admin_async_states.dart b/apps/admin/lib/app/admin_async_states.dart new file mode 100644 index 00000000..84676a2d --- /dev/null +++ b/apps/admin/lib/app/admin_async_states.dart @@ -0,0 +1,66 @@ +/// Estados assíncronos compartilhados pelas telas do backoffice. Saíram de +/// `app.dart` (eram privados) quando a tela de pedidos do titular (#42) ganhou +/// arquivo próprio e passou a precisar dos mesmos erro/retry e sessão vencida. +library; + +import 'package:flutter/material.dart'; + +import 'package:sinalacs_admin/core/data/admin_data_source.dart'; + +/// 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 AdminAsyncError extends StatelessWidget { + const AdminAsyncError({required this.error, required this.fallback, required this.onRetry, super.key}); + + /// O que o `FutureBuilder` capturou. [AdminDataFailure] traz o texto próprio + /// da falha; [AdminSessionExpired] leva ao login em vez de oferecer retry. + final Object? error; + + /// Texto da tela quando a falha não traz mensagem própria. + final String fallback; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + if (error is AdminSessionExpired) { + // Navegar durante o build não pode: adia para depois do frame. + final aoVencer = AdminSessionScope.maybeOf(context); + WidgetsBinding.instance.addPostFrameCallback((_) => aoVencer?.call()); + return const Center(child: CircularProgressIndicator()); + } + final message = error is AdminDataFailure ? (error! as AdminDataFailure).message : fallback; + return 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')), + ], + ), + ), + ); + } +} + +/// Entrega às telas a ação de encerrar a sessão vencida (volta ao login), sem +/// passar um callback por cada construtor. +class AdminSessionScope extends InheritedWidget { + const AdminSessionScope({required this.aoVencer, required super.child, super.key}); + + final VoidCallback aoVencer; + + static VoidCallback? maybeOf(BuildContext context) => + context.getInheritedWidgetOfExactType()?.aoVencer; + + @override + bool updateShouldNotify(AdminSessionScope old) => false; +} diff --git a/apps/admin/lib/app/admin_header.dart b/apps/admin/lib/app/admin_header.dart new file mode 100644 index 00000000..987704a5 --- /dev/null +++ b/apps/admin/lib/app/admin_header.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:sinalacs_admin/app/admin_layout.dart'; +import 'package:sinalacs_admin/app/admin_theme.dart'; + +class AdminHeader extends StatelessWidget implements PreferredSizeWidget { + const AdminHeader(this.eyebrow, this.title, {required this.height, super.key}); + + 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/app/admin_layout.dart b/apps/admin/lib/app/admin_layout.dart index 4fcd2fb0..8de07fbc 100644 --- a/apps/admin/lib/app/admin_layout.dart +++ b/apps/admin/lib/app/admin_layout.dart @@ -36,3 +36,16 @@ abstract final class AdminBreakpoints { /// 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); + +/// Altura da barra de navegação inferior, acompanhando a escala de fonte. +/// +/// O `NavigationBar` tem 80dp fixos: a 200% de fonte o rótulo de uma linha já +/// passava ~10dp da borda de baixo e era cortado (o Flutter não reporta isso +/// como estouro). Acima de 100% a barra cresce a partir de 96dp — com cinco +/// destinos num celular de 320dp "Microáreas" quebra em várias linhas a 130% +/// e 80dp escalados não bastavam —, com teto para não comer a tela. +double adminNavigationBarHeight(BuildContext context) { + final escala = MediaQuery.textScalerOf(context); + if (escala.scale(1) <= 1) return 80; + return escala.scale(96).clamp(80.0, 136.0); +} diff --git a/apps/admin/lib/app/admin_theme.dart b/apps/admin/lib/app/admin_theme.dart index 7075197e..15aa3f59 100644 --- a/apps/admin/lib/app/admin_theme.dart +++ b/apps/admin/lib/app/admin_theme.dart @@ -24,6 +24,13 @@ abstract final class AdminColors { /// compartilhada é o passo -400 da mesma cor, não o valor literal. static const redOnSurface = Color(0xFFF87171); static const accentOnSurface = Color(0xFF818CF8); + + /// Prazo LGPD vencido num pedido do titular (#42), como TEXTO/ÍCONE sobre + /// `surfaceRaised`/`background`. Laranja-400 de propósito: vermelho, amarelo + /// e verde são só risco clínico (`spec/ui_design.md`), e prazo não é risco. + /// Nunca aparece sozinho: vem com ícone e o texto "Vencido há N dias" + /// (WCAG 1.4.1). Contraste em `test/contrast_tokens_test.dart`. + static const overdueOnSurface = Color(0xFFFB923C); } /// Cor de texto/ícone equivalente a uma cor clínica de preenchimento. diff --git a/apps/admin/lib/app/app.dart b/apps/admin/lib/app/app.dart index 291bb2dc..c2049f71 100644 --- a/apps/admin/lib/app/app.dart +++ b/apps/admin/lib/app/app.dart @@ -1,156 +1,59 @@ -import 'package:flutter/foundation.dart' show kDebugMode; import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'dart:async'; + +export 'package:sinalacs_admin/app/audit_log_screen.dart' show AuditLogScreen, auditChainReasonText, formatAuditTimestamp; + +import 'package:sinalacs_admin/app/admin_async_states.dart'; +import 'package:sinalacs_admin/app/admin_accounts.dart'; +import 'package:sinalacs_admin/app/admin_header.dart'; +import 'package:sinalacs_admin/app/audit_log_screen.dart'; +import 'package:sinalacs_admin/app/data_requests_screen.dart'; +import 'package:sinalacs_admin/app/escalate_alert_dialog.dart'; import 'package:sinalacs_admin/app/admin_layout.dart'; +import 'package:sinalacs_admin/app/login_screen.dart'; +import 'package:sinalacs_admin/app/monitoring_controller.dart'; +import 'package:sinalacs_admin/app/notices_screen.dart'; +import 'package:sinalacs_admin/app/overdue_red_banner.dart'; +import 'package:sinalacs_admin/app/stale_badge.dart'; +import 'package:sinalacs_admin/core/auth/admin_auth_backend.dart'; +import 'package:sinalacs_admin/core/auth/admin_auth_bootstrap.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(); + /// [dataSourceFor] é o caminho de produção: a fonte de dados nasce da sessão + /// que o login devolveu (o token vive nela). [dataSource] fixa uma fonte pronta + /// e existe para os testes de tela; sem nenhum dos dois, o app cai no + /// [MockAdminDataSource] — **só** para teste, `main.dart` sempre passa + /// [dataSourceFor]. + SinalAdminApp({super.key, AdminDataSource? dataSource, AdminDataSourceFactory? dataSourceFor, required this.auth}) + : dataSourceFor = dataSourceFor ?? _fixa(dataSource ?? MockAdminDataSource()); - final AdminDataSource dataSource; + final AdminDataSourceFactory dataSourceFor; - /// Repassado para [LoginScreen]; `null` mantém o default (`kDebugMode`). - final bool? devLoginEnabled; + static AdminDataSourceFactory _fixa(AdminDataSource fonte) => (_) => fonte; + + /// A única porta de entrada do painel: sem sessão devolvida por + /// [AdminAuthBackend.login] não existe caminho que abra [AdminHomeShell]. + final AdminAuthBackend auth; @override Widget build(BuildContext context) => MaterialApp( title: 'SinalACS Admin', debugShowCheckedModeBanner: false, theme: buildAdminTheme(), - home: LoginScreen(dataSource: dataSource, devLoginEnabled: devLoginEnabled), + // pt-BR fixo: o backoffice só tem textos em português, e os seletores + // de data/hora do Material seguem o locale (dd/MM/aaaa, 24 h). + locale: const Locale('pt', 'BR'), + supportedLocales: const [Locale('pt', 'BR')], + localizationsDelegates: GlobalMaterialLocalizations.delegates, + home: LoginScreen(auth: auth, dataSourceFor: dataSourceFor), ); } -/// 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 } +enum AdminDestination { indicators, microAreas, alerts, auditLog, dataRequests, notices } extension on AdminDestination { String get label => switch (this) { @@ -158,6 +61,22 @@ extension on AdminDestination { AdminDestination.microAreas => 'Microáreas', AdminDestination.alerts => 'Alertas', AdminDestination.auditLog => 'Auditoria', + AdminDestination.dataRequests => 'Pedidos do titular', + AdminDestination.notices => 'Avisos', + }; + + /// Rótulo da barra inferior do celular, onde com cinco destinos cada um tem + /// 64–72dp de largura: "Pedidos do titular" quebrava em quatro linhas (a + /// última cortada abaixo da barra) e "Indicadores" partia no meio da + /// palavra. O nome completo segue no `tooltip` (toque longo e leitor de + /// tela) e no NavigationRail. "Painel" é o título da própria tela. + String get shortLabel => switch (this) { + AdminDestination.indicators => 'Painel', + AdminDestination.dataRequests => 'Pedidos', + // Com seis destinos (#46) "Microáreas" quebrava e era cortada em 320–360dp. + AdminDestination.microAreas => 'Áreas', + AdminDestination.auditLog => 'Trilha', + _ => label, }; IconData get icon => switch (this) { @@ -165,35 +84,116 @@ extension on AdminDestination { AdminDestination.microAreas => Icons.map_outlined, AdminDestination.alerts => Icons.warning_amber_outlined, AdminDestination.auditLog => Icons.fact_check_outlined, + AdminDestination.dataRequests => Icons.privacy_tip_outlined, + AdminDestination.notices => Icons.campaign_outlined, }; } +/// Relógio injetável: deixa o teste da sessão vencida sem `sleep`. +typedef AdminClock = DateTime Function(); + class AdminHomeShell extends StatefulWidget { - const AdminHomeShell({required this.dataSource, super.key}); + const AdminHomeShell({ + required this.dataSource, + required this.dataSourceFor, + required this.session, + required this.auth, + this.now = DateTime.now, + super.key, + }); final AdminDataSource dataSource; + /// Usada só para reconstruir o login quando a sessão vence: a próxima sessão + /// tem outro token e portanto outra fonte de dados. + final AdminDataSourceFactory dataSourceFor; + + /// Sessão devolvida por [AdminAuthBackend.login]. Sem ela o painel não abre. + final AdminSession session; + + /// Usada só para reconstruir o login quando a sessão vence. + final AdminAuthBackend auth; + final AdminClock now; + @override State createState() => _AdminHomeShellState(); } class _AdminHomeShellState extends State { AdminDestination destination = AdminDestination.indicators; + Timer? _vencimento; + + /// Um só controlador de pulso (#44) para o painel e a lista de alertas. As + /// abas são trocadas por `switch` (não há IndexedStack): só a tela atual + /// fica viva, e a de Painel e a de Alertas se anexam ao mesmo controlador — + /// um timer e um pulso por ciclo, e a troca entre as duas não reabre a + /// sessão de monitoramento. Nas demais abas não há pulso. + late final MonitoringController _monitoramento; + + @override + void initState() { + super.initState(); + _monitoramento = MonitoringController(dataSource: widget.dataSource, now: widget.now, onSessionExpired: _encerrar); + final restante = widget.session.expiresAt.difference(widget.now()); + if (restante <= Duration.zero) { + // Navegar durante o build não pode: adia para depois do primeiro frame. + WidgetsBinding.instance.addPostFrameCallback((_) => _encerrarSeVencida()); + } else { + // O disparo do Timer já é o vencimento: não reconfere o relógio. + _vencimento = Timer(restante, _encerrar); + } + } + + @override + void dispose() { + _vencimento?.cancel(); + _monitoramento.dispose(); + super.dispose(); + } + + /// Volta ao login, descartando o painel e a sessão, quando ela venceu. + void _encerrarSeVencida() { + if (!mounted || widget.now().isBefore(widget.session.expiresAt)) return; + _encerrar(); + } + + void _encerrar() { + if (!mounted) return; + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute( + builder: (_) => LoginScreen( + auth: widget.auth, + dataSourceFor: widget.dataSourceFor, + aviso: 'Sessão encerrada. Entre novamente.', + ), + ), + (_) => false, + ); + } - 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), + Widget _content() => AdminSessionScope(aoVencer: _encerrar, child: _telaAtual()); + + Widget _telaAtual() => switch (destination) { + AdminDestination.indicators => IndicatorsScreen(dataSource: widget.dataSource, monitoring: _monitoramento), + AdminDestination.microAreas => + MicroAreasScreen(dataSource: widget.dataSource, role: widget.session.role), + AdminDestination.alerts => AlertsScreen(dataSource: widget.dataSource, monitoring: _monitoramento), + AdminDestination.auditLog => AuditLogScreen(dataSource: widget.dataSource, role: widget.session.role), + AdminDestination.dataRequests => DataRequestsScreen(dataSource: widget.dataSource, now: widget.now), + AdminDestination.notices => + NoticesScreen(dataSource: widget.dataSource, role: widget.session.role, now: widget.now), }; - void _select(int index) => setState(() => destination = AdminDestination.values[index]); + void _select(int index) { + setState(() => destination = AdminDestination.values[index]); + _encerrarSeVencida(); + } @override Widget build(BuildContext context) => LayoutBuilder( builder: (context, constraints) { final content = _content(); - final header = _Header('Backoffice • admin.dev', 'Painel administrativo', height: adminHeaderHeight(context)); + final header = AdminHeader('Backoffice • ${adminRoleLabel(widget.session.role)}', '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. @@ -236,11 +236,16 @@ class _AdminHomeShellState extends State { body: SafeArea(top: false, bottom: false, child: content), bottomNavigationBar: NavigationBar( key: const Key('admin_navigation_bar'), + height: adminNavigationBarHeight(context), selectedIndex: destination.index, onDestinationSelected: _select, destinations: [ for (final value in AdminDestination.values) - NavigationDestination(icon: Icon(value.icon), label: value.label), + NavigationDestination( + icon: Icon(value.icon), + label: value.shortLabel, + tooltip: value.shortLabel == value.label ? null : value.label, + ), ], ), ); @@ -248,6 +253,15 @@ class _AdminHomeShellState extends State { ); } +/// Rótulo do papel para o cabeçalho. Valor desconhecido não quebra a tela nem +/// aparece cru: o token só chega aqui com papel de staff, mas o cabeçalho não +/// deve depender disso. +String adminRoleLabel(String role) => switch (role) { + 'admin' => 'Administrador', + 'coordinator' => 'Coordenador', + _ => 'Equipe', + }; + String riskLabel(RiskLevel level) => switch (level) { RiskLevel.red => 'Vermelho', RiskLevel.yellow => 'Amarelo', @@ -260,19 +274,6 @@ Color riskColor(RiskLevel level) => switch (level) { 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', @@ -280,50 +281,43 @@ String statusLabel(AlertStatus status) => switch (status) { 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')), - ], - ), - ), - ); -} +/// Opções do filtro de status da tela Alertas: todos menos `escalated`, que +/// não tem escritor (ver o comentário no dropdown). +final List _statusFiltraveis = [ + for (final status in AlertStatus.values) + if (status != AlertStatus.escalated) status, +]; class IndicatorsScreen extends StatefulWidget { - const IndicatorsScreen({required this.dataSource, super.key}); + const IndicatorsScreen({required this.dataSource, this.monitoring, super.key}); final AdminDataSource dataSource; + /// Pulso compartilhado do shell (#44); sem ele a tela cria o próprio. + final MonitoringController? monitoring; + @override State createState() => _IndicatorsScreenState(); } -class _IndicatorsScreenState extends State { - late Future _future = widget.dataSource.fetchDashboardIndicators(); +class _IndicatorsScreenState extends State with MonitoredScreenState { + @override + MonitoringController? get injectedMonitoring => widget.monitoring; + @override + AdminDataSource get monitoredDataSource => widget.dataSource; + + late Future _future = _carregar(); + + /// Carregamento inicial (e retry). Depois dele os números vêm do pulso: + /// `fetchDashboardIndicators` nunca é chamado em laço (ADR D5). + Future _carregar() async { + final dados = await widget.dataSource.fetchDashboardIndicators(); + monitoring.reportSuccess(); + return dados; + } void _retry() => setState(() { - _future = widget.dataSource.fetchDashboardIndicators(); + _future = _carregar(); }); @override @@ -331,12 +325,15 @@ class _IndicatorsScreenState extends State { future: _future, builder: (context, snapshot) { if (snapshot.hasError) { - return _AsyncError(message: 'Não foi possível carregar os indicadores.', onRetry: _retry); + return AdminAsyncError(error: snapshot.error, fallback: 'Não foi possível carregar os indicadores.', onRetry: _retry); } if (!snapshot.hasData) return const Center(child: CircularProgressIndicator()); - final data = snapshot.data!; + // O pulso mais recente vence o carregamento inicial; falha do pulso + // mantém o último dado e só acende o selo. + final data = monitoring.lastPulse?.indicators ?? snapshot.data!; return _page([ - const Text('Painel de Indicadores', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + MonitoringStaleBadge(monitoring), + TitleWithOverdueRed('Painel de Indicadores', monitoring), const SizedBox(height: 4), const Text('Contadores por risco clínico da UBS'), const SizedBox(height: 16), @@ -374,7 +371,7 @@ class _IndicatorsScreenState extends State { const SizedBox(height: 8), _InfoRow('Abertos (pendentes)', '${data.openRedAlerts}'), _InfoRow('Reconhecidos', '${data.acknowledgedRedAlerts}'), - _InfoRow('TMRAV (tempo médio de resposta)', '${data.tmravSeconds}s'), + _InfoRow('TMRAV (tempo médio de resposta)', data.tmravSeconds == null ? '—' : '${data.tmravSeconds}s'), ]); }, ); @@ -408,24 +405,45 @@ class _CounterCard extends StatelessWidget { ); } +/// O que a tela Microáreas carrega numa tarefa só: as seções de gestão (#43) +/// precisam das microáreas (opções de vínculo), dos ACS e — só para o +/// administrador — das contas de equipe. +typedef _DadosDaTela = ({ + List areas, + List acs, + List staff, +}); + class MicroAreasScreen extends StatefulWidget { - const MicroAreasScreen({required this.dataSource, super.key}); + const MicroAreasScreen({required this.dataSource, required this.role, super.key}); final AdminDataSource dataSource; + /// Papel da sessão (`admin`/`coordinator`). A seção de Equipe do backoffice + /// só existe para o administrador — `StaffManagementSection` não é montada + /// para o coordenador, e o servidor também recusa essa listagem para ele. + final String role; + @override State createState() => _MicroAreasScreenState(); } class _MicroAreasScreenState extends State { - late Future> _future = _load(); + late Future<_DadosDaTela> _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 { + /// + /// As três leituras vêm numa tarefa só, em série e depois do registro: a tela + /// é uma página, e meia página carregada ao lado de outra pela metade (com a + /// seção de ACS vazia, por exemplo) pareceria "não há nada" em vez de erro. + Future<_DadosDaTela> _load() async { await widget.dataSource.recordAccess(actionType: 'view', resourceType: 'micro_areas'); - return widget.dataSource.fetchMicroAreas(); + final areas = await widget.dataSource.fetchMicroAreas(); + final acs = await widget.dataSource.fetchAcs(); + final staff = widget.role == 'admin' ? await widget.dataSource.fetchStaff() : const []; + return (areas: areas, acs: acs, staff: staff); } void _retry() => setState(() { @@ -433,22 +451,25 @@ class _MicroAreasScreenState extends State { }); @override - Widget build(BuildContext context) => FutureBuilder>( + Widget build(BuildContext context) => FutureBuilder<_DadosDaTela>( future: _future, builder: (context, snapshot) { if (snapshot.hasError) { - return _AsyncError(message: 'Não foi possível carregar as microáreas.', onRetry: _retry); + return AdminAsyncError(error: snapshot.error, fallback: 'Não foi possível carregar as microáreas.', onRetry: _retry); } if (!snapshot.hasData) return const Center(child: CircularProgressIndicator()); - final areas = snapshot.data!; + final dados = snapshot.data!; + // Sessão vencida no meio de uma escrita da seção: as seções não têm o + // `AdminSessionScope` no escopo delas, então recebem a ação pronta. + final aoVencer = AdminSessionScope.maybeOf(context); 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 Text('Território de cada microárea e gestão das contas de ACS — cadastro, vínculo, senha e MFA.'), const SizedBox(height: 16), - for (final area in areas) + for (final area in dados.areas) Card( key: Key('micro_area_${area.id}'), margin: const EdgeInsets.only(bottom: 12), @@ -462,6 +483,27 @@ class _MicroAreasScreenState extends State { ), ), ), + const SizedBox(height: 16), + const Divider(), + const SizedBox(height: 16), + AcsManagementSection( + dataSource: widget.dataSource, + microAreas: dados.areas, + acs: dados.acs, + onRecarregar: _retry, + aoVencerSessao: aoVencer, + ), + if (widget.role == 'admin') ...[ + const SizedBox(height: 28), + const Divider(), + const SizedBox(height: 16), + StaffManagementSection( + dataSource: widget.dataSource, + staff: dados.staff, + onRecarregar: _retry, + aoVencerSessao: aoVencer, + ), + ], ], ); }, @@ -469,15 +511,27 @@ class _MicroAreasScreenState extends State { } class AlertsScreen extends StatefulWidget { - const AlertsScreen({required this.dataSource, super.key}); + const AlertsScreen({required this.dataSource, this.monitoring, super.key}); final AdminDataSource dataSource; + /// Pulso compartilhado do shell (#44); sem ele a tela cria o próprio. + final MonitoringController? monitoring; + @override State createState() => _AlertsScreenState(); } -class _AlertsScreenState extends State { +class _AlertsScreenState extends State with MonitoredScreenState { + @override + MonitoringController? get injectedMonitoring => widget.monitoring; + @override + AdminDataSource get monitoredDataSource => widget.dataSource; + + /// Quem reage ao pulso é a lista (`_AlertsList`), não esta casca. + @override + void onMonitoringChanged() {} + String? _microAreaFilter; AlertStatus? _statusFilter; late Future _accessRecorded = widget.dataSource.recordAccess(actionType: 'view', resourceType: 'alerts'); @@ -487,8 +541,9 @@ class _AlertsScreenState extends State { future: _accessRecorded, builder: (context, accessSnapshot) { if (accessSnapshot.hasError) { - return _AsyncError( - message: 'Não foi possível registrar o acesso a esta tela.', + return AdminAsyncError( + error: accessSnapshot.error, + fallback: 'Não foi possível registrar o acesso a esta tela.', onRetry: () => setState(() { _accessRecorded = widget.dataSource.recordAccess(actionType: 'view', resourceType: 'alerts'); }), @@ -497,7 +552,7 @@ class _AlertsScreenState extends State { if (accessSnapshot.connectionState != ConnectionState.done) { return const Center(child: CircularProgressIndicator()); } - return _AlertsList(dataSource: widget.dataSource, microAreaFilter: _microAreaFilter, statusFilter: _statusFilter, onFilterChanged: (microArea, status) => setState(() { + return _AlertsList(dataSource: widget.dataSource, monitoring: monitoring, microAreaFilter: _microAreaFilter, statusFilter: _statusFilter, onFilterChanged: (microArea, status) => setState(() { _microAreaFilter = microArea; _statusFilter = status; })); @@ -505,42 +560,152 @@ class _AlertsScreenState extends State { ); } -class _AlertsList extends StatelessWidget { +class _AlertsList extends StatefulWidget { const _AlertsList({ required this.dataSource, + required this.monitoring, required this.microAreaFilter, required this.statusFilter, required this.onFilterChanged, }); final AdminDataSource dataSource; + final MonitoringController monitoring; final String? microAreaFilter; final AlertStatus? statusFilter; final void Function(String? microArea, AlertStatus? status) onFilterChanged; + @override + State<_AlertsList> createState() => _AlertsListState(); +} + +typedef _DadosDosAlertas = ({List alerts, List microAreas}); + +/// A lista guarda o último dado bom: o pulso (#44) a recarrega quando a +/// revisão do [MonitoringController] sobe, sem spinner e sem apagar nada, e +/// uma falha dessa recarga só conta para o selo. Era `StatelessWidget` com o +/// `Future` criado no `build`, o que relia tudo a cada reconstrução. +class _AlertsListState extends State<_AlertsList> { + _DadosDosAlertas? _dados; + Object? _erro; + bool _carregando = true; + + /// Descarta a resposta de uma leitura que um filtro novo já superou. + int _geracao = 0; + late int _revisaoVista; + + @override + void initState() { + super.initState(); + // Lida agora, não no primeiro aviso: o 1º pulso só faz a linha de base. + _revisaoVista = widget.monitoring.revision; + widget.monitoring.addListener(_aoPulsar); + _buscar(pelaAtualizacao: false); + } + + @override + void didUpdateWidget(_AlertsList antigo) { + super.didUpdateWidget(antigo); + if (antigo.microAreaFilter != widget.microAreaFilter || antigo.statusFilter != widget.statusFilter) { + _carregar(); + } + } + + @override + void dispose() { + widget.monitoring.removeListener(_aoPulsar); + super.dispose(); + } + + void _aoPulsar() { + final revisao = widget.monitoring.revision; + if (revisao != _revisaoVista) { + _revisaoVista = revisao; + _buscar(pelaAtualizacao: true); + } + // Rebuild para o selo (aparece ou some com o estado do controlador). + if (mounted) setState(() {}); + } + + /// Leitura pedida pela pessoa (filtro novo, retry): mostra o spinner e, + /// se falhar, o erro em tela. Mantém o dado anterior por baixo, como o + /// `FutureBuilder` mantinha, para os filtros não ficarem sem opções. + void _carregar() { + setState(() { + _erro = null; + _carregando = true; + }); + _buscar(pelaAtualizacao: false); + } + + Future _buscar({required bool pelaAtualizacao}) async { + final geracao = ++_geracao; + try { + final dados = await _load(pelaAtualizacao: pelaAtualizacao); + if (!mounted || geracao != _geracao) return; + setState(() { + _dados = dados; + _erro = null; + _carregando = false; + }); + widget.monitoring.reportSuccess(); + } catch (erro) { + if (!mounted || geracao != _geracao) return; + // `_carregando`: a recarga do pulso superou uma leitura pedida pela + // pessoa, que ainda espera resposta; aí a falha vai para a tela. + if (pelaAtualizacao && !_carregando && erro is! AdminSessionExpired) { + // Falha da atualização automática: as linhas ficam, só o selo muda. + widget.monitoring.reportFailure(); + return; + } + setState(() { + _erro = erro; + _carregando = false; + }); + } + } + /// 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(); + /// + /// Na recarga pelo pulso só os alertas são relidos: cada leitura de + /// microáreas grava mais uma linha `read` na auditoria, e as opções do + /// filtro não mudam com um alerta novo. Sem dado anterior (o 1º + /// carregamento falhou), as microáreas são buscadas também. + Future<_DadosDosAlertas> _load({bool pelaAtualizacao = false}) async { + final alerts = await widget.dataSource.fetchAlerts(microAreaId: widget.microAreaFilter, status: widget.statusFilter); + final anteriores = pelaAtualizacao ? _dados?.microAreas : null; + final microAreas = anteriores ?? await widget.dataSource.fetchMicroAreas(); return (alerts: alerts, microAreas: microAreas); } + /// Depois de escalonar (ou do servidor dizer que o estado mudou): relê a + /// lista pelo mesmo fluxo do pulso (filtros mantidos, dado antigo mantido em + /// falha) e pede um pulso para a linha de base não gerar outra recarga. + void _aposEscalonar() { + _buscar(pelaAtualizacao: true); + widget.monitoring.refreshNow(); + } + + String? get microAreaFilter => widget.microAreaFilter; + AlertStatus? get statusFilter => widget.statusFilter; + void Function(String? microArea, AlertStatus? status) get onFilterChanged => widget.onFilterChanged; + @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)); + Widget build(BuildContext context) => Builder( + builder: (context) { + if (_erro != null) { + return AdminAsyncError(error: _erro, fallback: 'Não foi possível carregar os alertas.', onRetry: _carregar); } - final alerts = snapshot.data?.alerts ?? const []; - final microAreas = snapshot.data?.microAreas ?? const []; + final alerts = _dados?.alerts ?? const []; + final microAreas = _dados?.microAreas ?? const []; return ListView( padding: const EdgeInsets.all(20), children: [ - const Text('Alertas da UBS', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + MonitoringStaleBadge(widget.monitoring), + TitleWithOverdueRed('Alertas da UBS', widget.monitoring), const SizedBox(height: 4), const Text('Consulta somente leitura — nenhuma reclassificação de risco é permitida aqui.'), const SizedBox(height: 16), @@ -557,7 +722,7 @@ class _AlertsList extends StatelessWidget { items: [ const DropdownMenuItem(value: null, child: Text('Todas')), for (final area in microAreas) - DropdownMenuItem(value: area.name, child: Text(area.name, overflow: TextOverflow.ellipsis)), + DropdownMenuItem(value: area.id, child: Text(area.name, overflow: TextOverflow.ellipsis)), ], onChanged: (value) => onFilterChanged(value, statusFilter), ), @@ -569,14 +734,18 @@ class _AlertsList extends StatelessWidget { decoration: const InputDecoration(labelText: 'Status'), items: [ const DropdownMenuItem(value: null, child: Text('Todos')), - for (final status in AlertStatus.values) + // Sem "Escalonado" (F1, #44): nada grava `AlertStatus.escalated` + // (escalonar mantém o alerta `pending`, D3), então o filtro só + // devolveria vazio. O rótulo segue em [statusLabel] para as + // linhas legadas. + for (final status in _statusFiltraveis) DropdownMenuItem(value: status, child: Text(statusLabel(status), overflow: TextOverflow.ellipsis)), ], onChanged: (value) => onFilterChanged(microAreaFilter, value), ), ), const SizedBox(height: 16), - if (snapshot.connectionState == ConnectionState.waiting) + if (_carregando) const Center(child: CircularProgressIndicator()) else if (alerts.isEmpty) const Padding(padding: EdgeInsets.only(top: 24), child: Text('Nenhum alerta para o filtro selecionado.')) @@ -597,6 +766,13 @@ class _AlertsList extends StatelessWidget { const SizedBox(height: 6), Text('Risco: ${riskLabel(alert.riskLevel)}', style: TextStyle(color: adminOnSurface(riskColor(alert.riskLevel)), fontWeight: FontWeight.bold)), Text('Status: ${statusLabel(alert.status)}'), + if (alert.escalatedAt != null) ...[ + const SizedBox(height: 6), + EscalatedBadge(alert.escalatedAt!), + ] else if (alert.overdue) ...[ + const SizedBox(height: 8), + EscalateAlertButton(alert: alert, dataSource: widget.dataSource, onChanged: _aposEscalonar), + ], ], ), ), @@ -636,69 +812,6 @@ class _FiltrosDeAlertas extends StatelessWidget { ); } -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: [ @@ -717,22 +830,20 @@ Widget _page(List children) => ListView( /// 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. +/// Usado pela tela de Microáreas. A de Auditoria (`audit_log_screen.dart`) +/// tem a mesma regra na própria linha, que ganhou o selo de integridade (#45). class _LinhaComSelo extends StatelessWidget { - const _LinhaComSelo({required this.titulo, required this.descricao, required this.selo, this.icone}); + const _LinhaComSelo({required this.titulo, required this.descricao, required this.selo}); 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 @@ -785,58 +896,3 @@ class _InfoRow extends StatelessWidget { ); } -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/app/audit_log_screen.dart b/apps/admin/lib/app/audit_log_screen.dart new file mode 100644 index 00000000..922bae63 --- /dev/null +++ b/apps/admin/lib/app/audit_log_screen.dart @@ -0,0 +1,1446 @@ +/// Tela de Auditoria do backoffice (#45): consulta filtrada da trilha, selo por +/// linha e estado da verificação global da cadeia. +/// +/// Saiu de `app.dart` quando ganhou estado próprio (filtro, cursor, verificação). +/// +/// **A tela nunca decide integridade.** O app não tem o segredo da cadeia: o +/// selo de cada linha e o estado da cadeia são exatamente o que o servidor +/// devolveu. Só `verified` aparece como sucesso (verde, "Íntegro"/"Cadeia +/// íntegra"); `broken`, `unavailable` e `inconclusive` têm textos próprios e +/// nenhum deles reaproveita o texto de sucesso (D3). A cor nunca é o único +/// sinal: todo estado tem ícone, rótulo e `Semantics` (WCAG 1.4.1). +/// +/// **Exportação (Task 11, D5).** Só aparece para o administrador (o servidor +/// continua sendo a autoridade e recusa os outros papéis) e só com um período +/// de início **e** fim aplicado. O CSV volta na resposta do RPC e fica apenas +/// no estado desta tela: nunca é gravado em disco nem em preferências, nunca é +/// registrado em log e é descartado ao fechar o resultado, mudar o filtro ou +/// sair da tela. A entrega é pela área de transferência ("Copiar CSV"); baixar +/// um arquivo de verdade é pendência (P5). +library; + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:sinalacs_admin/app/admin_async_states.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'; + +/// 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)}'; +} + +/// Texto em português de cada motivo fechado do servidor (D9). Nenhum afirma +/// ataque ou adulteração: a mesma quebra aparece se a chave da cadeia for +/// trocada no servidor. +String auditChainReasonText(AuditChainReason motivo) => switch (motivo) { + AuditChainReason.hashMismatch => 'O conteúdo de um registro não confere com o selo gravado.', + AuditChainReason.linkBroken => 'O encadeamento entre dois registros consecutivos não confere.', + AuditChainReason.sequenceGap => 'Há uma lacuna na numeração dos registros.', + AuditChainReason.tailTruncated => 'Registros recentes que já haviam sido conferidos não foram encontrados.', + AuditChainReason.checkpointInvalid => 'O último ponto de conferência gravado não confere com o seu selo.', + AuditChainReason.timeBudget => 'A verificação excedeu o tempo limite e conferiu só parte da trilha.', + AuditChainReason.databaseError => 'O banco de dados não respondeu à verificação.', + AuditChainReason.secretUnavailable => 'A chave de verificação não está disponível no servidor.', +}; + +/// Alvo mínimo dos botões desta tela (WCAG 2.5.5), o mesmo da gestão de contas. +const _alvoMinimo = Size(48, 52); + +class AuditLogScreen extends StatefulWidget { + const AuditLogScreen({required this.dataSource, required this.role, this.now, super.key}); + + final AdminDataSource dataSource; + + /// Papel da sessão (`AdminSession.role`, pelo nome do `UserRole` do + /// servidor). Só decide se o botão de exportar aparece: quem autoriza é o + /// servidor. + final String role; + + /// Relógio dos atalhos de período ("últimos 7 dias") e do valor inicial do + /// seletor de data; só os testes o trocam. + final DateTime Function()? now; + + @override + State createState() => _AuditLogScreenState(); +} + +/// Atalhos de período: preenchem só o início (o fim fica aberto, até agora). +const _atalhos = [ + ('audit_filter_preset_24h', 'Últimas 24 h', Duration(hours: 24)), + ('audit_filter_preset_7d', 'Últimos 7 dias', Duration(days: 7)), + ('audit_filter_preset_30d', 'Últimos 30 dias', Duration(days: 30)), +]; + +/// Janela máxima entre início e fim, a mesma do servidor (D1). +const _janelaMaxima = Duration(days: 366); + +const _rotuloDoPapel = { + AuditActorRole.patient: 'Paciente', + AuditActorRole.acs: 'ACS', + AuditActorRole.coordinator: 'Coordenador', + AuditActorRole.admin: 'Administrador', +}; + +const _rotuloDaAcao = {'read': 'Leitura', 'write': 'Escrita', 'login': 'Login', 'export': 'Exportação'}; + +/// Mesma regra do servidor para tipo de recurso e resultado (D1). +final _rotuloValido = RegExp(r'^[a-z0-9_:]{1,64}$'); + +class _AuditLogScreenState extends State { + // Registro do próprio acesso (PRD §4.2.2): sem ele, nada é mostrado. + bool _acessoRegistrado = false; + Object? _erroDoRegistro; + + // Filtro aplicado e o rascunho do formulário. + AuditLogFilter _filtro = const AuditLogFilter(); + final _busca = TextEditingController(); + final _matricula = TextEditingController(); + final _recurso = TextEditingController(); + final _resultado = TextEditingController(); + AuditActorRole? _papel; + String? _acao; + // Período, em hora local enquanto rascunho; vai ao filtro em UTC. + DateTime? _inicio; + DateTime? _fim; + String? _erroDoFiltro; + + /// Muda a cada "Limpar": recria os campos do formulário, porque um + /// `DropdownButtonFormField` guarda o próprio valor e ignora um + /// `initialValue` novo. + int _limpezas = 0; + + bool _filtrosAbertos = false; + + // Lista paginada por keyset. + final List _itens = []; + final Set _vistos = {}; + int? _cursor; + bool _carregandoLista = false; + Object? _erroDaLista; + bool _carregandoMais = false; + Object? _erroDoMais; + + /// Cada recarga da lista (filtro novo, limpar, tentar de novo) aumenta a + /// geração: resposta de uma geração antiga é descartada, e "Carregar mais" + /// de um filtro antigo não se mistura à lista do filtro novo. + int _geracao = 0; + + // Verificação global da cadeia. + ChainStatus? _cadeia; + Object? _erroDaCadeia; + bool _verificando = false; + + // Exportação (só administrador). O CSV mora só aqui, em memória. + bool _confirmandoExportacao = false; + bool _exportando = false; + AuditExportResult? _exportacao; + AuditExportFailure? _erroDaExportacao; + + /// O CSV do resultado atual já foi para a área de transferência do sistema. + bool _csvCopiado = false; + + bool get _podeExportar => widget.role == 'admin'; + + @override + void initState() { + super.initState(); + unawaited(_iniciar()); + } + + @override + void dispose() { + // O CSV não sobrevive à tela. + _exportacao = null; + _busca.dispose(); + _matricula.dispose(); + _recurso.dispose(); + _resultado.dispose(); + super.dispose(); + } + + /// Registra o acesso e só então carrega lista e cadeia. Sem `setState` + /// antes do primeiro `await`: é chamada também de `initState`. + Future _iniciar() async { + try { + await widget.dataSource.recordAccess(actionType: 'view', resourceType: 'audit_logs'); + } catch (e) { + if (mounted) setState(() => _erroDoRegistro = e); + return; + } + if (!mounted) return; + setState(() => _acessoRegistrado = true); + unawaited(_recarregarLista()); + unawaited(_verificarCadeia()); + } + + Future _recarregarLista() async { + final geracao = ++_geracao; + setState(() { + _itens.clear(); + _vistos.clear(); + _cursor = null; + _erroDaLista = null; + _erroDoMais = null; + _carregandoMais = false; + _carregandoLista = true; + }); + try { + final pagina = await widget.dataSource.fetchAuditLogs(filter: _filtro); + if (!mounted || geracao != _geracao) return; + setState(() { + _acrescentar(pagina); + _carregandoLista = false; + }); + } catch (e) { + if (!mounted || geracao != _geracao) return; + setState(() { + _erroDaLista = e; + _carregandoLista = false; + }); + } + } + + Future _carregarMais() async { + final cursor = _cursor; + if (cursor == null || _carregandoMais) return; + final geracao = _geracao; + setState(() { + _carregandoMais = true; + _erroDoMais = null; + }); + try { + final pagina = await widget.dataSource.fetchAuditLogs(filter: _filtro, beforeSequence: cursor); + if (!mounted || geracao != _geracao) return; + setState(() { + _acrescentar(pagina); + _carregandoMais = false; + }); + } catch (e) { + if (!mounted || geracao != _geracao) return; + if (e is AdminSessionExpired) { + AdminSessionScope.maybeOf(context)?.call(); + return; + } + setState(() { + _erroDoMais = e; + _carregandoMais = false; + }); + } + } + + /// Junta a página à lista sem repetir `sequence` já mostrada. + void _acrescentar(AuditLogPage pagina) { + for (final e in pagina.items) { + if (_vistos.add(e.sequence)) _itens.add(e); + } + _cursor = pagina.nextBeforeSequence; + } + + Future _verificarCadeia() async { + setState(() { + _verificando = true; + _erroDaCadeia = null; + }); + try { + final status = await widget.dataSource.verifyAuditChain(); + if (!mounted) return; + setState(() { + _cadeia = status; + _verificando = false; + }); + } catch (e) { + if (!mounted) return; + if (e is AdminSessionExpired) { + AdminSessionScope.maybeOf(context)?.call(); + return; + } + setState(() { + // Falha ao consultar não é estado da cadeia: o resultado anterior sai + // da tela para não parecer atual. + _cadeia = null; + _erroDaCadeia = e; + _verificando = false; + }); + } + } + + void _aplicarFiltros() { + String? limpo(TextEditingController c) { + final t = c.text.trim(); + return t.isEmpty ? null : t; + } + + final busca = limpo(_busca); + final matricula = limpo(_matricula); + final recurso = limpo(_recurso)?.toLowerCase(); + final resultado = limpo(_resultado)?.toLowerCase(); + + String? erro; + if (busca != null && (busca.length < 2 || busca.length > 64)) { + erro = 'A busca deve ter de 2 a 64 caracteres.'; + } else if (matricula != null && matricula.length > 32) { + erro = 'A matrícula deve ter no máximo 32 caracteres.'; + } else if (recurso != null && !_rotuloValido.hasMatch(recurso)) { + erro = 'Tipo de recurso: use só letras sem acento, números, "_" ou ":".'; + } else if (resultado != null && !_rotuloValido.hasMatch(resultado)) { + erro = 'Resultado: use só letras sem acento, números, "_" ou ":".'; + } + final de = _inicio?.toUtc(); + final ate = _fim?.toUtc(); + if (erro == null && de != null && ate != null) { + // Mesmas regras do servidor (D1): `to` é exclusivo, então fim igual ao + // início é período vazio e é recusado como o servidor recusaria. + if (!de.isBefore(ate)) { + erro = 'O início do período deve ser anterior ao fim.'; + } else if (ate.difference(de) > _janelaMaxima) { + erro = 'O período é de no máximo 366 dias.'; + } + } + if (erro != null) { + setState(() => _erroDoFiltro = erro); + return; + } + + _erroDoFiltro = null; + _descartarExportacao(); + _filtro = AuditLogFilter( + search: busca, + actorEnrollmentId: matricula, + actorRole: _papel, + actionType: _acao, + resourceType: recurso, + result: resultado, + from: de, + to: ate, + ); + unawaited(_recarregarLista()); + } + + /// O resultado e o erro da exportação valem para o filtro em que foram + /// pedidos: um filtro novo os descarta (e o CSV com eles). + void _descartarExportacao() { + _exportacao = null; + _erroDaExportacao = null; + _csvCopiado = false; + } + + /// A exportação exige início e fim no filtro APLICADO (o que a lista mostra); + /// as demais regras do período já foram checadas ao aplicar. + bool get _periodoParaExportar => _filtro.from != null && _filtro.to != null; + + Future _exportar() async { + // Toque duplo: a segunda chamada chega antes do quadro que desabilita o botão. + if (_confirmandoExportacao || _exportando || !_periodoParaExportar) return; + final filtro = _filtro; + setState(() => _confirmandoExportacao = true); + final confirmou = await showDialog( + context: context, + builder: (context) => _ConfirmacaoDeExportacao(filtro: filtro), + ); + if (!mounted) return; + if (confirmou != true) { + setState(() => _confirmandoExportacao = false); + return; + } + setState(() { + _confirmandoExportacao = false; + _exportando = true; + _descartarExportacao(); + }); + try { + final resultado = await widget.dataSource.exportAuditLogs(filtro); + if (!mounted) return; + setState(() { + _exportando = false; + // Filtro trocado durante a chamada (Enter num campo de texto): o + // resultado é de outro recorte e não pode aparecer sob o filtro novo. + if (identical(_filtro, filtro)) _exportacao = resultado; + }); + } on AdminSessionExpired { + if (!mounted) return; + AdminSessionScope.maybeOf(context)?.call(); + } catch (e) { + if (!mounted) return; + setState(() { + _exportando = false; + // Nada do erro vai ao log nem à tela além do texto fixo da falha; e, + // como o resultado, só vale para o filtro em que foi pedido. + if (identical(_filtro, filtro)) { + _erroDaExportacao = e is AuditExportFailure ? e : const AuditExportFailure(AuditExportFailureKind.failed); + } + }); + } + } + + Future _copiarCsv(AuditExportResult r) async { + await Clipboard.setData(ClipboardData(text: r.csv)); + if (!mounted) return; + setState(() => _csvCopiado = true); + ScaffoldMessenger.maybeOf(context)?.showSnackBar( + const SnackBar(content: Text(_avisoDaAreaDeTransferencia)), + ); + } + + /// Enquanto a confirmação está aberta ou a exportação corre, o filtro + /// aplicado não muda pelos botões (aplicar, limpar, período, atalhos). + bool get _exportacaoEmCurso => _confirmandoExportacao || _exportando; + + DateTime _agora() => (widget.now ?? DateTime.now)().toLocal(); + + /// Data (digitada, dd/mm/aaaa; o ícone do diálogo troca para o calendário) + /// e depois hora (relógio de 24 h) pelos seletores do Material, no locale + /// pt-BR. Cancelar qualquer um dos dois não muda nada. + Future _escolherInstante({required bool inicio}) async { + final agora = _agora(); + final atual = (inicio ? _inicio : _fim) ?? agora; + final rotulo = inicio ? 'Início do período' : 'Fim do período'; + final data = await showDatePicker( + context: context, + initialDate: atual, + firstDate: DateTime(2000), + lastDate: DateTime(agora.year + 1, 12, 31), + initialEntryMode: DatePickerEntryMode.input, + helpText: rotulo, + cancelText: 'Cancelar', + confirmText: 'Confirmar', + fieldLabelText: 'Data (dd/mm/aaaa)', + errorFormatText: 'Data inválida. Use dd/mm/aaaa.', + errorInvalidText: 'Data fora do intervalo permitido.', + ); + if (data == null || !mounted) return; + final hora = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime(atual), + // Relógio, e não digitação, como modo inicial: o modo de digitação do + // `showTimePicker` estoura o próprio diálogo com fonte ≥ 150% (defeito + // do Material medido nesta tarefa); o relógio aguenta 200% em 320dp. O + // ícone de teclado do diálogo continua disponível. + initialEntryMode: TimePickerEntryMode.dial, + helpText: inicio ? 'Hora do início' : 'Hora do fim', + cancelText: 'Cancelar', + confirmText: 'Confirmar', + hourLabelText: 'Hora', + minuteLabelText: 'Minuto', + errorInvalidText: 'Hora inválida.', + // Relógio de 24 h, o formato da trilha; sem isso o seletor segue a + // preferência do aparelho e pode pedir AM/PM. + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(alwaysUse24HourFormat: true), + child: child!, + ), + ); + if (hora == null || !mounted) return; + final valor = DateTime(data.year, data.month, data.day, hora.hour, hora.minute); + setState(() { + if (inicio) { + _inicio = valor; + } else { + _fim = valor; + } + }); + } + + void _aplicarAtalho(Duration janela) => setState(() { + _inicio = _agora().subtract(janela); + _fim = null; + }); + + void _limparFiltros() { + _busca.clear(); + _matricula.clear(); + _recurso.clear(); + _resultado.clear(); + _papel = null; + _acao = null; + _inicio = null; + _fim = null; + _erroDoFiltro = null; + _limpezas++; + _descartarExportacao(); + _filtro = const AuditLogFilter(); + unawaited(_recarregarLista()); + } + + @override + Widget build(BuildContext context) { + if (_erroDoRegistro != null) { + return AdminAsyncError( + error: _erroDoRegistro, + fallback: 'Não foi possível registrar o acesso a esta tela.', + onRetry: () { + setState(() => _erroDoRegistro = null); + unawaited(_iniciar()); + }, + ); + } + if (!_acessoRegistrado) { + return const Center(child: CircularProgressIndicator()); + } + 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 a esta tela também é auditado. A coordenação vê apenas os registros dos autores com microárea na sua UBS.', + ), + const SizedBox(height: 16), + _PainelDaCadeia( + status: _cadeia, + erro: _erroDaCadeia, + verificando: _verificando, + onVerificar: _verificando ? null : _verificarCadeia, + ), + const SizedBox(height: 16), + _formularioDeFiltros(), + const SizedBox(height: 16), + if (_podeExportar) ...[ + _SecaoDeExportacao( + filtro: _filtro, + periodoCompleto: _periodoParaExportar, + exportando: _exportando, + resultado: _exportacao, + erro: _erroDaExportacao, + copiado: _csvCopiado, + onExportar: (_confirmandoExportacao || _exportando || !_periodoParaExportar) ? null : _exportar, + onCopiar: _copiarCsv, + onFechar: () => setState(_descartarExportacao), + ), + const SizedBox(height: 16), + ], + ..._lista(), + ], + ); + } + + List _lista() { + if (_carregandoLista) { + return const [ + Padding( + padding: EdgeInsets.only(top: 24), + child: Center(child: CircularProgressIndicator(key: Key('audit_loading'))), + ), + ]; + } + if (_erroDaLista != null) { + return [ + AdminAsyncError( + error: _erroDaLista, + fallback: 'Não foi possível carregar os logs de auditoria.', + onRetry: _recarregarLista, + ), + ]; + } + if (_itens.isEmpty) { + return [ + Padding( + padding: const EdgeInsets.only(top: 24), + child: Text( + _filtro.isEmpty ? 'Nenhum acesso registrado ainda.' : 'Nenhum registro encontrado para estes filtros.', + ), + ), + ]; + } + final n = _itens.length; + return [ + Semantics( + liveRegion: true, + child: Text(n == 1 ? '1 registro carregado' : '$n registros carregados', key: const Key('audit_count')), + ), + const SizedBox(height: 8), + for (final entry in _itens) _LinhaDeAuditoria(entry: entry), + if (_cursor != null) ...[ + const SizedBox(height: 8), + if (_erroDoMais != null) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Semantics( + liveRegion: true, + child: Text( + _erroDoMais is AdminDataFailure + ? (_erroDoMais! as AdminDataFailure).message + : 'Não foi possível carregar mais registros.', + ), + ), + ), + Align( + alignment: Alignment.centerLeft, + child: OutlinedButton.icon( + key: const Key('audit_load_more'), + style: OutlinedButton.styleFrom(minimumSize: _alvoMinimo), + onPressed: _carregandoMais ? null : _carregarMais, + icon: _carregandoMais + ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.expand_more), + label: const Text('Carregar mais'), + ), + ), + ], + ]; + } + + /// Quantos campos do filtro APLICADO estão preenchidos (o período conta um). + int get _filtrosAtivos { + final f = _filtro; + return [ + f.search, + f.actorEnrollmentId, + f.actorRole, + f.actionType, + f.resourceType, + f.result, + f.from, + ].where((v) => v != null).length; + } + + /// Filtros recolhidos por padrão: abertos, empurrariam a lista para baixo da + /// dobra. O título diz quantos estão aplicados, para a lista filtrada nunca + /// parecer a trilha inteira. + Widget _formularioDeFiltros() { + final ativos = _filtrosAtivos; + return Card( + child: ExpansionTile( + key: const Key('audit_filters_toggle'), + // A lista descarta o que sai da janela: o estado aberto/fechado mora + // aqui, senão os filtros se fechariam sozinhos ao rolar e voltar. + initiallyExpanded: _filtrosAbertos, + onExpansionChanged: (aberto) => setState(() => _filtrosAbertos = aberto), + shape: const Border(), + collapsedShape: const Border(), + minTileHeight: 56, + leading: const Icon(Icons.filter_alt_outlined), + title: const Text('Filtros', style: TextStyle(fontWeight: FontWeight.bold)), + subtitle: Text(switch (ativos) { + 0 => 'Nenhum filtro aplicado', + 1 => '1 filtro aplicado', + _ => '$ativos filtros aplicados', + }, key: const Key('audit_filters_summary')), + childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + expandedCrossAxisAlignment: CrossAxisAlignment.start, + children: [_camposDoFiltro()], + ), + ); + } + + Widget _camposDoFiltro() => LayoutBuilder( + builder: (context, constraints) { + final compacto = constraints.maxWidth < AdminBreakpoints.stacked; + final largura = compacto ? constraints.maxWidth : ((constraints.maxWidth - 24) / 3).clamp(200.0, 280.0); + Widget campo(Widget filho) => SizedBox(width: largura, child: filho); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + KeyedSubtree( + key: ValueKey(_limpezas), + child: Wrap( + spacing: 12, + runSpacing: 12, + children: [ + campo( + TextField( + key: const Key('audit_filter_search'), + controller: _busca, + textInputAction: TextInputAction.search, + onSubmitted: (_) => _aplicarFiltros(), + decoration: const InputDecoration( + labelText: 'Busca', + helperText: 'Início da matrícula, tipo de recurso ou resultado.', + helperMaxLines: 3, + ), + ), + ), + campo( + TextField( + key: const Key('audit_filter_enrollment'), + controller: _matricula, + textInputAction: TextInputAction.search, + onSubmitted: (_) => _aplicarFiltros(), + decoration: const InputDecoration(labelText: 'Matrícula do autor'), + ), + ), + campo( + DropdownButtonFormField( + key: const Key('audit_filter_role'), + initialValue: _papel, + isExpanded: true, + decoration: const InputDecoration(labelText: 'Papel do autor'), + items: [ + const DropdownMenuItem( + value: null, + child: Text('Todos os papéis', overflow: TextOverflow.ellipsis), + ), + for (final p in AuditActorRole.values) + DropdownMenuItem( + value: p, + child: Text(_rotuloDoPapel[p]!, overflow: TextOverflow.ellipsis), + ), + ], + onChanged: (v) => setState(() => _papel = v), + ), + ), + campo( + DropdownButtonFormField( + key: const Key('audit_filter_action'), + initialValue: _acao, + isExpanded: true, + decoration: const InputDecoration(labelText: 'Ação'), + items: [ + const DropdownMenuItem( + value: null, + child: Text('Todas as ações', overflow: TextOverflow.ellipsis), + ), + for (final MapEntry(:key, :value) in _rotuloDaAcao.entries) + DropdownMenuItem( + value: key, + child: Text(value, overflow: TextOverflow.ellipsis), + ), + ], + onChanged: (v) => setState(() => _acao = v), + ), + ), + campo( + TextField( + key: const Key('audit_filter_resource'), + controller: _recurso, + textInputAction: TextInputAction.search, + onSubmitted: (_) => _aplicarFiltros(), + decoration: const InputDecoration(labelText: 'Tipo de recurso'), + ), + ), + campo( + TextField( + key: const Key('audit_filter_result'), + controller: _resultado, + textInputAction: TextInputAction.search, + onSubmitted: (_) => _aplicarFiltros(), + decoration: const InputDecoration(labelText: 'Resultado'), + ), + ), + campo( + _CampoDeInstante( + chave: 'audit_filter_from', + rotulo: 'Início', + valor: _inicio, + onEscolher: _exportacaoEmCurso ? null : () => _escolherInstante(inicio: true), + onLimpar: _exportacaoEmCurso ? null : () => setState(() => _inicio = null), + ), + ), + campo( + _CampoDeInstante( + chave: 'audit_filter_to', + rotulo: 'Fim', + valor: _fim, + onEscolher: _exportacaoEmCurso ? null : () => _escolherInstante(inicio: false), + onLimpar: _exportacaoEmCurso ? null : () => setState(() => _fim = null), + ), + ), + Wrap( + spacing: 8, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + for (final (chave, rotulo, janela) in _atalhos) + TextButton( + key: Key(chave), + style: TextButton.styleFrom(minimumSize: _alvoMinimo), + onPressed: _exportacaoEmCurso ? null : () => _aplicarAtalho(janela), + child: Text(rotulo), + ), + ], + ), + ], + ), + ), + if (_erroDoFiltro != null) ...[ + const SizedBox(height: 12), + Semantics( + liveRegion: true, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.error_outline, color: AdminColors.redOnSurface, size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + _erroDoFiltro!, + key: const Key('audit_filter_error'), + style: const TextStyle(color: AdminColors.redOnSurface), + ), + ), + ], + ), + ), + ], + const SizedBox(height: 12), + Wrap( + spacing: 12, + runSpacing: 8, + children: [ + FilledButton.icon( + key: const Key('audit_filter_apply'), + style: FilledButton.styleFrom(minimumSize: _alvoMinimo), + onPressed: _exportacaoEmCurso ? null : _aplicarFiltros, + icon: const Icon(Icons.filter_alt_outlined), + label: const Text('Aplicar filtros'), + ), + OutlinedButton.icon( + key: const Key('audit_filter_clear'), + style: OutlinedButton.styleFrom(minimumSize: _alvoMinimo), + onPressed: _exportacaoEmCurso ? null : _limparFiltros, + icon: const Icon(Icons.clear), + label: const Text('Limpar'), + ), + ], + ), + ], + ); + }, + ); +} + +/// Aviso depois de "Copiar CSV": a área de transferência é do sistema, não do app. +const _avisoDaAreaDeTransferencia = + 'CSV copiado. Ele fica na área de transferência do sistema até você copiar outra coisa ' + 'e pode ser lido por outros apps e teclados. Cole onde precisa e depois copie outro texto.'; + +/// Período do filtro em texto, para a seção e a confirmação. +String _periodoEmTexto(AuditLogFilter f) { + final de = f.from, ate = f.to; + if (de == null || ate == null) return 'período não definido'; + return '${formatAuditTimestamp(de)} a ${formatAuditTimestamp(ate)}'; +} + +/// Confirmação antes de exportar. O botão de confirmar só fecha o diálogo se +/// ele ainda é a rota do topo: um segundo toque durante a animação de saída +/// fecharia a tela de baixo. +class _ConfirmacaoDeExportacao extends StatelessWidget { + const _ConfirmacaoDeExportacao({required this.filtro}); + + final AuditLogFilter filtro; + + @override + Widget build(BuildContext context) { + void fechar(bool valor) { + if (ModalRoute.of(context)?.isCurrent ?? false) Navigator.of(context).pop(valor); + } + + return AlertDialog( + scrollable: true, + title: const Text('Exportar a trilha em CSV?'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Registros de ${_periodoEmTexto(filtro)}, com os filtros aplicados.'), + const SizedBox(height: 12), + const Text( + 'Será registrado na trilha de auditoria: quem exportou, quando, os filtros, ' + 'a quantidade de registros e o SHA-256 do arquivo.', + style: TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 12), + const Text( + 'O app não grava o arquivo. Depois de gerado, ele pode ser copiado para a área de transferência ' + 'do sistema, onde fica até você copiar outra coisa.', + ), + ], + ), + actions: [ + TextButton( + key: const Key('audit_export_cancel'), + style: TextButton.styleFrom(minimumSize: _alvoMinimo), + onPressed: () => fechar(false), + child: const Text('Cancelar'), + ), + FilledButton( + key: const Key('audit_export_confirm'), + style: FilledButton.styleFrom(minimumSize: _alvoMinimo), + onPressed: () => fechar(true), + child: const Text('Exportar'), + ), + ], + ); + } +} + +/// Seção de exportação (só administrador): botão, motivo de estar desabilitado, +/// erro com texto fixo e o resultado da última exportação. +class _SecaoDeExportacao extends StatelessWidget { + const _SecaoDeExportacao({ + required this.filtro, + required this.periodoCompleto, + required this.exportando, + required this.resultado, + required this.erro, + required this.copiado, + required this.onExportar, + required this.onCopiar, + required this.onFechar, + }); + + final AuditLogFilter filtro; + final bool periodoCompleto; + final bool exportando; + final AuditExportResult? resultado; + final AuditExportFailure? erro; + final bool copiado; + final VoidCallback? onExportar; + final ValueChanged onCopiar; + final VoidCallback onFechar; + + @override + Widget build(BuildContext context) { + final r = resultado; + final e = erro; + return Card( + key: const Key('audit_export_section'), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Exportar trilha (CSV)', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + const SizedBox(height: 4), + const Text( + 'Gera um CSV do período e dos filtros aplicados, com até 5 mil registros. ' + 'Cada exportação é registrada na trilha.', + ), + const SizedBox(height: 8), + if (periodoCompleto) + Text('Período aplicado: ${_periodoEmTexto(filtro)}.') + else + const Row( + key: Key('audit_export_hint'), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.info_outline, color: Colors.white70, size: 20), + SizedBox(width: 8), + Expanded(child: Text('Para exportar, aplique um período com início e fim.')), + ], + ), + const SizedBox(height: 12), + FilledButton.icon( + key: const Key('audit_export_button'), + style: FilledButton.styleFrom(minimumSize: _alvoMinimo), + onPressed: onExportar, + icon: exportando + ? const SizedBox( + key: Key('audit_export_progress'), + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.download_outlined), + label: Text(exportando ? 'Exportando…' : 'Exportar CSV'), + ), + if (e != null) ...[ + const SizedBox(height: 12), + Semantics( + key: const Key('audit_export_error_region'), + container: true, + liveRegion: true, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.error_outline, color: AdminColors.redOnSurface, size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + e.message, + key: const Key('audit_export_error'), + style: const TextStyle(color: AdminColors.redOnSurface), + ), + ), + ], + ), + ), + if (e.canRetry) ...[ + const SizedBox(height: 8), + OutlinedButton.icon( + key: const Key('audit_export_retry'), + style: OutlinedButton.styleFrom(minimumSize: _alvoMinimo), + onPressed: onExportar, + icon: const Icon(Icons.refresh), + label: const Text('Tentar novamente'), + ), + ], + ], + if (r != null) ...[ + const SizedBox(height: 16), + _ResultadoDaExportacao(resultado: r, copiado: copiado, onCopiar: () => onCopiar(r), onFechar: onFechar), + ], + ], + ), + ), + ); + } +} + +/// Estado da cadeia no momento da exportação, com a mesma regra da tela: só +/// `verified` é íntegra; os outros três têm texto de atenção próprio. +({IconData icone, Color cor, String texto}) _cadeiaNaExportacao(AuditIntegrityState estado) => switch (estado) { + AuditIntegrityState.verified => ( + icone: Icons.verified_outlined, + cor: AdminColors.green, + texto: 'Cadeia íntegra no momento da exportação.', + ), + AuditIntegrityState.broken => ( + icone: Icons.gpp_bad_outlined, + cor: AdminColors.redOnSurface, + texto: 'Atenção: a cadeia apresentou falha de integridade no momento da exportação. ' + 'O arquivo foi gerado, mas não comprova uma trilha sem alteração. Avise a administração do sistema.', + ), + AuditIntegrityState.unavailable => ( + icone: Icons.warning_amber_outlined, + cor: AdminColors.overdueOnSurface, + texto: 'Atenção: a verificação da cadeia estava indisponível no momento da exportação. ' + 'O arquivo não comprova uma trilha sem alteração.', + ), + AuditIntegrityState.inconclusive => ( + icone: Icons.warning_amber_outlined, + cor: AdminColors.overdueOnSurface, + texto: 'Atenção: a verificação da cadeia foi inconclusiva no momento da exportação. ' + 'O arquivo não comprova uma trilha sem alteração.', + ), +}; + +class _ResultadoDaExportacao extends StatelessWidget { + const _ResultadoDaExportacao({ + required this.resultado, + required this.copiado, + required this.onCopiar, + required this.onFechar, + }); + + final AuditExportResult resultado; + + /// O CSV já foi copiado: mostra o aviso da área de transferência. + final bool copiado; + final VoidCallback onCopiar; + final VoidCallback onFechar; + + @override + Widget build(BuildContext context) { + final r = resultado; + final n = r.rowCount; + final resumo = n == 1 ? '1 registro exportado' : '$n registros exportados'; + final intervalo = (n == 0 || r.firstSequence == null || r.lastSequence == null) + ? 'Nenhum registro no período.' + : 'Registros de nº ${r.firstSequence} a nº ${r.lastSequence}.'; + final cadeia = _cadeiaNaExportacao(r.chainState); + return DecoratedBox( + key: const Key('audit_export_result'), + decoration: BoxDecoration( + border: Border.all(color: AdminColors.border), + borderRadius: BorderRadius.circular(8), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Semantics( + key: const Key('audit_export_summary'), + container: true, + liveRegion: true, + child: Text(resumo, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + ), + const SizedBox(height: 4), + Text(intervalo), + Text('Arquivo: ${r.fileName}'), + Text('Gerado em ${formatAuditTimestamp(r.generatedAt)}.'), + const SizedBox(height: 8), + const Text('SHA-256 do arquivo:', style: TextStyle(color: Colors.white70)), + SelectableText(r.contentSha256, key: const Key('audit_export_sha256')), + const SizedBox(height: 8), + Semantics( + key: const Key('audit_export_chain_region'), + container: true, + liveRegion: true, + child: Row( + key: const Key('audit_export_chain'), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(cadeia.icone, color: cadeia.cor, size: 20), + const SizedBox(width: 8), + Expanded(child: Text(cadeia.texto, style: TextStyle(color: cadeia.cor))), + ], + ), + ), + const SizedBox(height: 8), + const Text( + 'O app não grava o CSV em arquivo. Ao fechar, mudar o filtro ou sair desta tela, ele some daqui; ' + 'o que você copiar fica na área de transferência do sistema.', + ), + if (copiado) ...[ + const SizedBox(height: 8), + Semantics( + key: const Key('audit_export_clipboard_warning'), + container: true, + liveRegion: true, + child: const Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.content_paste_outlined, color: AdminColors.overdueOnSurface, size: 20), + SizedBox(width: 8), + Expanded( + child: Text(_avisoDaAreaDeTransferencia, style: TextStyle(color: AdminColors.overdueOnSurface)), + ), + ], + ), + ), + ], + const SizedBox(height: 12), + Wrap( + spacing: 12, + runSpacing: 8, + children: [ + FilledButton.icon( + key: const Key('audit_export_copy'), + style: FilledButton.styleFrom(minimumSize: _alvoMinimo), + onPressed: onCopiar, + icon: const Icon(Icons.copy_outlined), + label: const Text('Copiar CSV'), + ), + OutlinedButton.icon( + key: const Key('audit_export_close'), + style: OutlinedButton.styleFrom(minimumSize: _alvoMinimo), + onPressed: onFechar, + icon: const Icon(Icons.close), + label: const Text('Fechar'), + ), + ], + ), + ], + ), + ), + ); + } +} + +/// Um extremo do período: botão que abre data + hora e, preenchido, um botão +/// para limpar só ele. O rótulo visível é o nome acessível (WCAG 2.5.3). +class _CampoDeInstante extends StatelessWidget { + const _CampoDeInstante({ + required this.chave, + required this.rotulo, + required this.valor, + required this.onEscolher, + required this.onLimpar, + }); + + final String chave; + final String rotulo; + final DateTime? valor; + final VoidCallback? onEscolher; + final VoidCallback? onLimpar; + + @override + Widget build(BuildContext context) { + final v = valor; + final texto = v == null ? '$rotulo: não definido' : '$rotulo: ${formatAuditTimestamp(v)}'; + return Row( + children: [ + Expanded( + child: OutlinedButton.icon( + key: Key(chave), + style: OutlinedButton.styleFrom(minimumSize: _alvoMinimo, alignment: Alignment.centerLeft), + onPressed: onEscolher, + icon: const Icon(Icons.event_outlined), + label: Text(texto), + ), + ), + if (v != null) + IconButton( + key: Key('${chave}_clear'), + tooltip: 'Limpar ${rotulo.toLowerCase()} do período', + constraints: const BoxConstraints(minWidth: 48, minHeight: 48), + onPressed: onLimpar, + icon: const Icon(Icons.close), + ), + ], + ); + } +} + +/// Uma linha da trilha com o selo que o servidor deu a ela. +class _LinhaDeAuditoria extends StatelessWidget { + const _LinhaDeAuditoria({required this.entry}); + + final AuditLogEntry entry; + + @override + Widget build(BuildContext context) { + final conteudo = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('${entry.actionType} • ${entry.resourceType}', style: const TextStyle(fontWeight: FontWeight.w600)), + const SizedBox(height: 2), + Text('${entry.userLabel} — ${formatAuditTimestamp(entry.timestamp)}'), + Text('Resultado: ${entry.result} · nº ${entry.sequence}', style: const TextStyle(color: Colors.white70)), + ], + ); + final selo = _SeloDaLinha(key: Key('audit_seal_${entry.id}'), seal: entry.seal); + return Card( + key: Key('audit_${entry.id}'), + margin: const EdgeInsets.only(bottom: 8), + child: Padding( + padding: const EdgeInsets.all(12), + child: LayoutBuilder( + builder: (context, constraints) { + // Em tela estreita o selo desce para baixo do conteúdo, em vez de + // espremer o rótulo do autor contra a borda. + if (constraints.maxWidth < AdminBreakpoints.stacked) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [conteudo, const SizedBox(height: 8), selo], + ); + } + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: conteudo), + const SizedBox(width: 12), + ConstrainedBox(constraints: const BoxConstraints(maxWidth: 200), child: selo), + ], + ); + }, + ), + ), + ); + } +} + +/// Aparência de cada selo: ícone, rótulo, cor de TEXTO e frase do leitor de tela. +({IconData icone, String rotulo, Color cor, String semantica}) _aparenciaDoSelo(AuditRowSeal seal) => switch (seal) { + AuditRowSeal.verified => ( + icone: Icons.verified_outlined, + rotulo: 'Íntegro', + cor: AdminColors.green, + semantica: 'Selo do registro: íntegro', + ), + AuditRowSeal.invalid => ( + icone: Icons.gpp_bad_outlined, + rotulo: 'Adulterado?', + cor: AdminColors.redOnSurface, + semantica: 'Selo do registro: possível adulteração — o conteúdo não confere com o selo gravado', + ), + AuditRowSeal.unverifiable => ( + icone: Icons.help_outline, + rotulo: 'Não verificável', + cor: Colors.white70, + semantica: 'Selo do registro: não verificável — o registro anterior não está disponível para conferência', + ), +}; + +class _SeloDaLinha extends StatelessWidget { + const _SeloDaLinha({required this.seal, super.key}); + + final AuditRowSeal seal; + + @override + Widget build(BuildContext context) { + final a = _aparenciaDoSelo(seal); + return Semantics( + container: true, + label: a.semantica, + excludeSemantics: true, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(a.icone, color: a.cor, size: 18), + const SizedBox(width: 6), + Flexible( + child: Text( + a.rotulo, + style: TextStyle(color: a.cor, fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +/// Painel do estado global da cadeia, exatamente como o servidor o devolveu. +class _PainelDaCadeia extends StatelessWidget { + const _PainelDaCadeia({ + required this.status, + required this.erro, + required this.verificando, + required this.onVerificar, + }); + + final ChainStatus? status; + final Object? erro; + final bool verificando; + final VoidCallback? onVerificar; + + static const _naoEIntegra = 'Isso não significa que a cadeia está íntegra.'; + + @override + Widget build(BuildContext context) { + final (icone, cor, titulo, linhas) = _conteudo(); + return Card( + key: const Key('audit_chain_status'), + child: Padding( + padding: const EdgeInsets.all(16), + child: LayoutBuilder( + builder: (context, constraints) { + // Largo, o botão fica ao lado do estado: a lista começa mais acima. + final largo = constraints.maxWidth >= AdminBreakpoints.stacked; + final botao = OutlinedButton.icon( + key: const Key('audit_chain_retry'), + style: OutlinedButton.styleFrom(minimumSize: _alvoMinimo), + onPressed: onVerificar, + icon: const Icon(Icons.refresh), + label: const Text('Verificar novamente'), + ); + final cabecalho = Semantics( + liveRegion: true, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + icone, + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Integridade da trilha', style: TextStyle(color: Colors.white70)), + Text( + titulo, + key: const Key('audit_chain_title'), + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: cor), + ), + ], + ), + ), + ], + ), + ); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (largo) + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: cabecalho), + const SizedBox(width: 12), + botao, + ], + ) + else + cabecalho, + for (final linha in linhas) ...[const SizedBox(height: 6), Text(linha)], + if (!largo) ...[const SizedBox(height: 12), botao], + ], + ); + }, + ), + ), + ); + } + + (Widget, Color?, String, List) _conteudo() { + final s = status; + if (verificando) { + // Durante a verificação o resultado anterior nunca aparece como atual: + // o título é "Verificando…" e o anterior vira uma linha rotulada. + final anterior = s == null + ? null + : 'Resultado anterior: ${_doResultado(s).$3} (verificado em ${formatAuditTimestamp(s.verifiedAt)}).'; + return ( + const SizedBox( + key: Key('audit_chain_verifying'), + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + null, + 'Verificando a integridade da trilha…', + [?anterior], + ); + } + if (s == null) { + final detalhe = erro is AdminDataFailure ? (erro! as AdminDataFailure).message : null; + return ( + const Icon(Icons.sync_problem, color: Colors.white70), + null, + 'Não foi possível verificar a integridade agora.', + [?detalhe, _naoEIntegra], + ); + } + return _doResultado(s); + } + + /// Título, ícone, cor e linhas de um resultado do servidor, exatamente como veio. + (Widget, Color?, String, List) _doResultado(ChainStatus s) { + final motivo = s.reasonCode == null ? null : auditChainReasonText(s.reasonCode!); + final conferidos = s.checkedRows == null + ? null + : (s.checkedRows == 1 ? '1 registro conferido.' : '${s.checkedRows} registros conferidos.'); + final quando = 'Verificado em ${formatAuditTimestamp(s.verifiedAt)}.'; + + return switch (s.state) { + AuditIntegrityState.verified => ( + const Icon(Icons.verified_outlined, color: AdminColors.green), + AdminColors.green, + 'Cadeia íntegra', + [ + [?conferidos, quando].join(' '), + ], + ), + AuditIntegrityState.broken => ( + const Icon(Icons.gpp_bad_outlined, color: AdminColors.redOnSurface), + AdminColors.redOnSurface, + 'Falha de integridade na cadeia', + [ + ?motivo, + if (s.firstBrokenSequence != null) 'Primeiro registro com falha: nº ${s.firstBrokenSequence}.', + switch (s.brokenInScope) { + true => 'A falha atinge registros que você vê nesta tela.', + false => 'Nenhum registro que você vê foi apontado como ponto da falha.', + null => 'Não foi informado se a falha atinge registros que você vê.', + }, + 'Pode indicar alteração indevida de registros ou troca da chave de verificação no servidor; ' + 'sozinha, não é prova de adulteração. Avise a administração do sistema.', + ?conferidos, + quando, + ], + ), + AuditIntegrityState.unavailable => ( + const Icon(Icons.cloud_off_outlined, color: Colors.white70), + null, + 'Verificação indisponível', + ['O servidor não conseguiu concluir a verificação agora. $_naoEIntegra', ?motivo, quando], + ), + AuditIntegrityState.inconclusive => ( + const Icon(Icons.help_outline, color: Colors.white70), + null, + 'Verificação inconclusiva', + [ + 'A verificação não chegou a uma conclusão (trilha vazia ou conferida só em parte). $_naoEIntegra', + ?motivo, + ?conferidos, + quando, + ], + ), + }; + } +} diff --git a/apps/admin/lib/app/data_requests_screen.dart b/apps/admin/lib/app/data_requests_screen.dart new file mode 100644 index 00000000..c0f9a96e --- /dev/null +++ b/apps/admin/lib/app/data_requests_screen.dart @@ -0,0 +1,585 @@ +/// Tela "Pedidos do titular" (#42): a fila de pedidos de exclusão e correção +/// (LGPD Art. 18) ordenada pelo prazo, o detalhe com o texto decifrado e as +/// decisões do coordenador/admin. +/// +/// Nenhum texto de pedido, nota ou motivo vai para log: só para a tela. +library; + +import 'package:flutter/material.dart'; + +import 'package:sinalacs_admin/app/admin_async_states.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'; + +String dataRequestTypeLabel(DataRequestType type) => switch (type) { + DataRequestType.deletion => 'Exclusão dos dados', + DataRequestType.correction => 'Correção de dados', + }; + +String dataRequestStatusLabel(DataRequestStatus status) => switch (status) { + DataRequestStatus.open => 'Aberto', + DataRequestStatus.inReview => 'Em análise', + DataRequestStatus.completed => 'Atendido', + DataRequestStatus.rejected => 'Recusado', + }; + +/// "Vencido há N dias", contado do prazo até [agora]. Menos de um dia de +/// atraso conta como 1: o servidor já disse que venceu, e "há 0 dias" leria +/// como "no prazo". +String overdueLabel(DateTime dueAt, DateTime agora) { + final dias = agora.difference(dueAt).inDays; + final n = dias < 1 ? 1 : dias; + return n == 1 ? 'Vencido há 1 dia' : 'Vencido há $n dias'; +} + +String _dois(int valor) => valor.toString().padLeft(2, '0'); + +String _data(DateTime instante) { + final local = instante.toLocal(); + return '${_dois(local.day)}/${_dois(local.month)}/${local.year}'; +} + +String _dataHora(DateTime instante) { + final local = instante.toLocal(); + return '${_data(local)} ${_dois(local.hour)}:${_dois(local.minute)}'; +} + +const _alvoMinimo = Size(48, 52); + +class DataRequestsScreen extends StatefulWidget { + const DataRequestsScreen({required this.dataSource, this.now = DateTime.now, super.key}); + + final AdminDataSource dataSource; + + /// Relógio injetável: o "Vencido há N dias" depende dele. + final DateTime Function() now; + + @override + State createState() => _DataRequestsScreenState(); +} + +class _DataRequestsScreenState extends State { + DataRequestStatus? _filtro; + String? _selecionado; + late Future> _lista = _carregar(); + + /// Registra o acesso antes de expor a fila, como as outras telas sensíveis + /// (no backend é no-op: o servidor já audita a leitura). + Future> _carregar() async { + await widget.dataSource.recordAccess(actionType: 'view', resourceType: 'data_subject_requests'); + return widget.dataSource.fetchDataRequests(status: _filtro); + } + + void _recarregar() => setState(() { + _lista = _carregar(); + }); + + @override + Widget build(BuildContext context) { + final id = _selecionado; + if (id != null) { + return _DetalheDoPedido( + key: ValueKey(id), + dataSource: widget.dataSource, + id: id, + now: widget.now, + onVoltar: () => setState(() => _selecionado = null), + onAlterado: _recarregar, + ); + } + return FutureBuilder>( + future: _lista, + builder: (context, snapshot) { + if (snapshot.hasError) { + return AdminAsyncError(error: snapshot.error, fallback: 'Não foi possível carregar os pedidos.', onRetry: _recarregar); + } + final pedidos = snapshot.data ?? const []; + return ListView( + padding: const EdgeInsets.all(20), + children: [ + const Text('Pedidos do titular (LGPD)', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + const SizedBox(height: 4), + const Text('Exclusão e correção pedidas pelos pacientes, do prazo mais próximo para o mais distante.'), + const SizedBox(height: 16), + DropdownButtonFormField( + key: const Key('data_requests_status_filter'), + initialValue: _filtro, + isExpanded: true, + hint: const Text('Todos'), + decoration: const InputDecoration(labelText: 'Status'), + items: [ + const DropdownMenuItem(value: null, child: Text('Todos')), + for (final status in DataRequestStatus.values) + DropdownMenuItem(value: status, child: Text(dataRequestStatusLabel(status), overflow: TextOverflow.ellipsis)), + ], + onChanged: (valor) => setState(() { + _filtro = valor; + _lista = _carregar(); + }), + ), + const SizedBox(height: 16), + if (snapshot.connectionState == ConnectionState.waiting) + const Center(child: CircularProgressIndicator()) + else if (pedidos.isEmpty) + Padding( + padding: const EdgeInsets.only(top: 24), + child: Text(_filtro == null ? 'Nenhum pedido pendente.' : 'Nenhum pedido para o filtro selecionado.'), + ) + else + for (final pedido in pedidos) + _LinhaDoPedido( + pedido: pedido, + agora: widget.now(), + onTap: () => setState(() => _selecionado = pedido.id), + ), + ], + ); + }, + ); + } +} + +/// Uma linha da fila, lida pelo leitor de tela como uma frase só. +class _LinhaDoPedido extends StatelessWidget { + const _LinhaDoPedido({required this.pedido, required this.agora, required this.onTap}); + + final DataRequestSummary pedido; + final DateTime agora; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) => Card( + key: Key('data_request_${pedido.id}'), + margin: const EdgeInsets.only(bottom: 12), + clipBehavior: Clip.antiAlias, + shape: pedido.overdue + ? RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AdminColors.overdueOnSurface, width: 2), + ) + : null, + child: MergeSemantics( + child: Semantics( + button: true, + hint: 'Abrir o pedido', + child: InkWell( + onTap: onTap, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 48), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 12, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text(dataRequestTypeLabel(pedido.type), style: const TextStyle(fontWeight: FontWeight.bold)), + _SeloDeStatus(pedido.status), + ], + ), + const SizedBox(height: 4), + Text(pedido.patientLabel), + Text('Prazo: ${_data(pedido.dueAt)}'), + if (pedido.overdue) ...[ + const SizedBox(height: 6), + _AvisoDeVencido(dueAt: pedido.dueAt, agora: agora), + ], + ], + ), + ), + ), + ), + ), + ), + ); +} + +/// Ícone + texto: o atraso não depende só da cor (WCAG 1.4.1). +class _AvisoDeVencido extends StatelessWidget { + const _AvisoDeVencido({required this.dueAt, required this.agora}); + + final DateTime dueAt; + final DateTime agora; + + @override + Widget build(BuildContext context) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.schedule, key: Key('overdue_icon'), size: 18, color: AdminColors.overdueOnSurface), + const SizedBox(width: 6), + Flexible( + child: Text( + overdueLabel(dueAt, agora), + style: const TextStyle(color: AdminColors.overdueOnSurface, fontWeight: FontWeight.bold), + ), + ), + ], + ); +} + +class _SeloDeStatus extends StatelessWidget { + const _SeloDeStatus(this.status); + + final DataRequestStatus status; + + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: AdminColors.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AdminColors.border), + ), + child: Text(dataRequestStatusLabel(status)), + ); +} + +class _DetalheDoPedido extends StatefulWidget { + const _DetalheDoPedido({ + required this.dataSource, + required this.id, + required this.now, + required this.onVoltar, + required this.onAlterado, + super.key, + }); + + final AdminDataSource dataSource; + final String id; + final DateTime Function() now; + final VoidCallback onVoltar; + + /// Avisa a fila de que uma decisão foi tentada: ela relê a lista. + final VoidCallback onAlterado; + + @override + State<_DetalheDoPedido> createState() => _DetalheDoPedidoState(); +} + +class _DetalheDoPedidoState extends State<_DetalheDoPedido> { + late Future _detalhe = widget.dataSource.fetchDataRequest(widget.id); + final _resposta = TextEditingController(); + + /// Uma decisão por vez: o botão fica desabilitado enquanto a chamada está + /// pendente, e [_agir] ignora um segundo toque que escape disso. + bool _enviando = false; + String? _erroAcao; + String? _aviso; + + @override + void initState() { + super.initState(); + _resposta.addListener(_textoMudou); + } + + @override + void dispose() { + _resposta.dispose(); + super.dispose(); + } + + void _textoMudou() => setState(() {}); + + void _reler() => setState(() { + _detalhe = widget.dataSource.fetchDataRequest(widget.id); + }); + + Future _agir(Future Function() chamada) async { + if (_enviando) return; + setState(() { + _enviando = true; + _erroAcao = null; + _aviso = null; + }); + var atualizarLista = true; + try { + await chamada(); + if (!mounted) return; + _resposta.clear(); + _aviso = 'Pedido atualizado.'; + _reler(); + } on AdminSessionExpired { + atualizarLista = false; + if (mounted) AdminSessionScope.maybeOf(context)?.call(); + } on AdminDataFailure catch (falha) { + if (mounted) setState(() => _erroAcao = falha.message); + } catch (_) { + // O texto da exceção não vai para a tela nem para log. + if (mounted) setState(() => _erroAcao = 'Não foi possível concluir a ação. Tente novamente.'); + } finally { + if (mounted) setState(() => _enviando = false); + if (atualizarLista && mounted) widget.onAlterado(); + } + } + + Future _confirmar({required String titulo, required String texto, required String confirmar}) async { + final ok = await showDialog( + context: context, + builder: (contexto) => AlertDialog( + title: Text(titulo), + content: Text(texto), + actions: [ + TextButton( + style: TextButton.styleFrom(minimumSize: _alvoMinimo), + onPressed: () => Navigator.of(contexto).pop(false), + child: const Text('Cancelar'), + ), + FilledButton( + key: const Key('confirm_action'), + style: FilledButton.styleFrom(minimumSize: _alvoMinimo), + onPressed: () => Navigator.of(contexto).pop(true), + child: Text(confirmar), + ), + ], + ), + ); + return ok ?? false; + } + + Future _atender(DataRequestDetail pedido) async { + final texto = _resposta.text.trim(); + if (pedido.type == DataRequestType.deletion) { + final ok = await _confirmar( + titulo: 'Atender a exclusão?', + texto: 'Atender a exclusão anonimiza os dados do titular (nome, CPF, condições crônicas e respostas de ' + 'triagem) e encerra o acesso dele ao aplicativo. A ação é irreversível.', + confirmar: 'Confirmar exclusão', + ); + if (!ok) return; + } + await _agir(() => widget.dataSource.completeDataRequest(pedido.id, note: texto.isEmpty ? null : texto)); + } + + Future _recusar(DataRequestDetail pedido) async { + final motivo = _resposta.text.trim(); + final ok = await _confirmar( + titulo: 'Recusar o pedido?', + texto: 'O titular verá o motivo informado. A recusa é definitiva para este pedido.', + confirmar: 'Recusar pedido', + ); + if (!ok) return; + await _agir(() => widget.dataSource.rejectDataRequest(pedido.id, reason: motivo)); + } + + Widget _voltar() => Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + key: const Key('data_request_back'), + style: TextButton.styleFrom(minimumSize: _alvoMinimo), + onPressed: widget.onVoltar, + icon: const Icon(Icons.arrow_back), + label: const Text('Voltar à lista'), + ), + ); + + @override + Widget build(BuildContext context) => FutureBuilder( + future: _detalhe, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Column( + children: [ + Padding(padding: const EdgeInsets.fromLTRB(12, 12, 12, 0), child: _voltar()), + Expanded( + child: AdminAsyncError(error: snapshot.error, fallback: 'Não foi possível carregar o pedido.', onRetry: _reler), + ), + ], + ); + } + final pedido = snapshot.data; + // Só o primeiro carregamento mostra o spinner; ao reler depois de uma + // decisão, o conteúdo antigo fica até o novo chegar. + if (pedido == null) return const Center(child: CircularProgressIndicator()); + return ListView( + padding: const EdgeInsets.all(20), + children: [ + _voltar(), + const SizedBox(height: 8), + Text(dataRequestTypeLabel(pedido.type), style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + const SizedBox(height: 12), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _Campo('Paciente', pedido.patientLabel), + _Campo('Situação', dataRequestStatusLabel(pedido.status)), + _Campo('Recebido em', _dataHora(pedido.createdAt)), + _Campo('Prazo', _data(pedido.dueAt)), + if (pedido.overdue) ...[ + const SizedBox(height: 4), + _AvisoDeVencido(dueAt: pedido.dueAt, agora: widget.now()), + ], + if (pedido.decidedAt != null) ...[ + const SizedBox(height: 4), + Text('Decidido em ${_dataHora(pedido.decidedAt!)}'), + ], + ], + ), + ), + ), + if (pedido.details != null) ...[ + const SizedBox(height: 16), + _Bloco(titulo: 'Texto do pedido', texto: pedido.details!), + ], + if (pedido.resolution != null) ...[ + const SizedBox(height: 16), + _Bloco( + titulo: pedido.status == DataRequestStatus.rejected ? 'Motivo da recusa' : 'Resposta ao titular', + texto: pedido.resolution!, + ), + ], + if (_aviso != null) ...[ + const SizedBox(height: 16), + Semantics(liveRegion: true, child: Text(_aviso!, key: const Key('data_request_action_notice'))), + ], + if (pedido.status == DataRequestStatus.open || pedido.status == DataRequestStatus.inReview) + ..._acoes(pedido), + ], + ); + }, + ); + + List _acoes(DataRequestDetail pedido) { + final correcao = pedido.type == DataRequestType.correction; + final texto = _resposta.text; + final valido = dataRequestTextIsValid(texto); + // Correção: a nota é obrigatória. Exclusão: opcional, mas, se veio, tem de + // passar na mesma régua. + final podeAtender = !_enviando && (correcao ? valido : (texto.trim().isEmpty || valido)); + final podeRecusar = !_enviando && valido; + return [ + const SizedBox(height: 20), + const Divider(), + const SizedBox(height: 12), + const Text('Decisão', style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 12), + TextField( + key: const Key('resolution_field'), + controller: _resposta, + enabled: !_enviando, + minLines: 3, + maxLines: 6, + maxLength: dataRequestTextMax, + decoration: InputDecoration( + labelText: correcao ? 'Resposta ao titular (obrigatória)' : 'Resposta ao titular (obrigatória para recusar)', + helperText: 'De $dataRequestTextMin a $dataRequestTextMax caracteres. O titular vê este texto.', + helperMaxLines: 3, + alignLabelWithHint: true, + ), + ), + const SizedBox(height: 12), + LayoutBuilder( + builder: (context, constraints) { + final empilhado = constraints.maxWidth < AdminBreakpoints.stacked; + Widget largo(Widget botao) => empilhado ? SizedBox(width: double.infinity, child: botao) : botao; + return Wrap( + spacing: 12, + runSpacing: 12, + children: [ + if (pedido.status == DataRequestStatus.open) + largo( + OutlinedButton( + key: const Key('start_review_button'), + style: OutlinedButton.styleFrom(minimumSize: _alvoMinimo), + onPressed: _enviando ? null : () => _agir(() => widget.dataSource.startDataRequestReview(pedido.id)), + child: const Text('Iniciar análise'), + ), + ), + largo( + MergeSemantics( + child: Semantics( + hint: correcao ? 'Exige a resposta ao titular' : 'Pede confirmação: a exclusão é irreversível', + child: FilledButton( + key: const Key('complete_button'), + style: FilledButton.styleFrom(minimumSize: _alvoMinimo), + onPressed: podeAtender ? () => _atender(pedido) : null, + child: const Text('Atender'), + ), + ), + ), + ), + largo( + MergeSemantics( + child: Semantics( + hint: 'Exige o motivo e pede confirmação', + child: OutlinedButton( + key: const Key('reject_button'), + style: OutlinedButton.styleFrom(minimumSize: _alvoMinimo), + onPressed: podeRecusar ? () => _recusar(pedido) : null, + child: const Text('Recusar'), + ), + ), + ), + ), + ], + ); + }, + ), + if (_enviando) ...[ + const SizedBox(height: 12), + const LinearProgressIndicator(), + ], + if (_erroAcao != null) ...[ + const SizedBox(height: 12), + Semantics( + liveRegion: true, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.error_outline, size: 20), + const SizedBox(width: 8), + Expanded(child: Text(_erroAcao!, key: const Key('data_request_action_error'))), + ], + ), + ), + ], + ]; + } +} + +class _Campo extends StatelessWidget { + const _Campo(this.rotulo, this.valor); + + final String rotulo; + final String valor; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Text.rich( + TextSpan(children: [ + TextSpan(text: '$rotulo: ', style: const TextStyle(color: Colors.white70)), + TextSpan(text: valor, style: const TextStyle(fontWeight: FontWeight.bold)), + ]), + ), + ); +} + +/// Texto livre (pedido, nota, motivo) num `Text` comum: `'; + await _abrir( + tester, + _FakePedidos([ + _pedido( + 'req-9', + type: DataRequestType.correction, + status: DataRequestStatus.rejected, + dueAt: _agora.add(const Duration(days: 2)), + details: script, + resolution: '$script', + decidedAt: _agora, + ), + ]), + ); + await _abrirDetalhe(tester, 'req-9'); + + expect(find.text(script), findsOneWidget); + expect(find.text('$script'), findsOneWidget); + }); +} diff --git a/apps/admin/test/error_handling_test.dart b/apps/admin/test/error_handling_test.dart index bc83fc31..5bee8b2d 100644 --- a/apps/admin/test/error_handling_test.dart +++ b/apps/admin/test/error_handling_test.dart @@ -1,14 +1,16 @@ 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'; import 'package:sinalacs_admin/core/data/mock_admin_data_source.dart'; import 'support/failing_admin_data_source.dart'; +import 'support/fake_admin_auth.dart'; +import 'support/layout_harness.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(); + await tester.pumpWidget(SinalAdminApp(dataSource: dataSource, auth: FakeAdminAuth())); + await entrarComCredenciais(tester); if (destination != 'Indicadores') { await tester.tap(find.text(destination).last); await tester.pumpAndSettle(); @@ -16,6 +18,54 @@ Future _loginTo(WidgetTester tester, FailingAdminDataSource dataSource, St } void main() { + group('falhas do backend (#41): texto próprio e sessão vencida', () { + testWidgets('AdminDataFailure: a tela mostra a mensagem da falha e o retry recupera', (tester) async { + final dataSource = FailingAdminDataSource(inner: MockAdminDataSource()) + ..nextError = const AdminDataFailure('Não foi possível conectar ao servidor.'); + await _loginTo(tester, dataSource, 'Indicadores'); + + expect(find.text('Não foi possível conectar ao servidor.'), findsOneWidget); + expect(find.text('Não foi possível carregar os indicadores.'), findsNothing); + + await tester.tap(find.text('Tentar novamente')); + await tester.pumpAndSettle(); + expect(find.text('Painel de Indicadores'), findsOneWidget); + }); + + testWidgets('segunda falha seguida continua mostrando o erro, sem spinner', (tester) async { + final dataSource = FailingAdminDataSource(inner: MockAdminDataSource()) + ..nextError = const AdminDataFailure('Acesso restrito ao backoffice.'); + await _loginTo(tester, dataSource, 'Indicadores'); + dataSource.nextError = const AdminDataFailure('Acesso restrito ao backoffice.'); + + await tester.tap(find.text('Tentar novamente')); + await tester.pumpAndSettle(); + + expect(find.text('Acesso restrito ao backoffice.'), findsOneWidget); + expect(find.byType(CircularProgressIndicator), findsNothing); + }); + + testWidgets('AdminSessionExpired: volta ao login com o aviso, sem oferecer retry', (tester) async { + final dataSource = FailingAdminDataSource(inner: MockAdminDataSource()) + ..nextError = const AdminSessionExpired(); + await _loginTo(tester, dataSource, 'Indicadores'); + + expect(find.text('Sessão encerrada. Entre novamente.'), findsOneWidget); + expect(find.text('Tentar novamente'), findsNothing); + }); + + testWidgets('AdminSessionExpired na auditoria também volta ao login', (tester) async { + final dataSource = FailingAdminDataSource(inner: MockAdminDataSource()); + await _loginTo(tester, dataSource, 'Indicadores'); + dataSource.nextError = const AdminSessionExpired(); + + await tester.tap(find.text('Auditoria').last); + await tester.pumpAndSettle(); + + expect(find.text('Sessão encerrada. Entre novamente.'), findsOneWidget); + }); + }); + 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; @@ -41,7 +91,11 @@ void main() { await tester.tap(find.text('Tentar novamente')); await tester.pumpAndSettle(); - expect(find.textContaining('Microárea 12'), findsOneWidget); + // A lista voltou. Aqui a asserção mudou de `find.textContaining` para a + // chave do cartão: desde a gestão de contas (#43) o nome da microárea + // também aparece no cartão do ACS vinculado a ela, então contar ocorrências + // do texto deixou de ser a medida de "a lista está de volta". + expect(find.byKey(const Key('micro_area_ma-12')), findsOneWidget); }); testWidgets('Alertas: erro no carregamento não deve aparecer como "nenhum alerta"', (tester) async { @@ -61,12 +115,22 @@ void main() { 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); + // Desde a Task 11 a seção de exportação (admin) também fica acima da + // lista e empurra o erro para baixo da dobra a 800x600: as duas buscas + // olham fora da janela, a negativa para não passar só por estar abaixo. + expect(find.text('Não foi possível carregar os logs de auditoria.', skipOffstage: false), findsOneWidget); + expect(find.text('Nenhum acesso registrado ainda.', skipOffstage: false), findsNothing); + // Desde a #45 o painel da cadeia e os filtros ficam acima da lista: a + // 800x600 o botão nasce perto da dobra e é trazido à janela antes do toque + // (um toque fora da janela acerta o vazio sem falhar). + await tester.scrollUntilVisible(find.text('Tentar novamente'), 200, scrollable: rolagemDaAuditoria()); + await tester.pumpAndSettle(); await tester.tap(find.text('Tentar novamente')); await tester.pumpAndSettle(); + await tester.ensureVisible(find.textContaining('view • audit_logs', skipOffstage: false)); + await tester.pumpAndSettle(); expect(find.textContaining('view • audit_logs'), findsOneWidget); }); }); diff --git a/apps/admin/test/indicators_stale_test.dart b/apps/admin/test/indicators_stale_test.dart new file mode 100644 index 00000000..a0050f0f --- /dev/null +++ b/apps/admin/test/indicators_stale_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 'package:sinalacs_admin/app/app.dart'; +import 'package:sinalacs_admin/app/monitoring_controller.dart'; +import 'package:sinalacs_admin/core/data/admin_data_source.dart'; + +import 'support/scripted_pulse_data_source.dart'; + +/// Painel de indicadores alimentado pelo pulso (#44, Task 11; ADR D5): o +/// dashboard não consulta `fetchDashboardIndicators` em laço. +void main() { + late DateTime agora; + late ScriptedPulseDataSource fonte; + + setUp(() { + agora = DateTime(2026, 10, 11, 9); + fonte = ScriptedPulseDataSource(now: () => agora); + }); + + Future montarPainel(WidgetTester tester) async { + final monitoramento = MonitoringController(dataSource: fonte, now: () => agora); + await tester.pumpWidget(MaterialApp( + theme: buildAdminTheme(), + home: Scaffold(body: IndicatorsScreen(dataSource: fonte, monitoring: monitoramento)), + )); + await tester.pump(); + await tester.pump(); + } + + Future ciclo(WidgetTester tester, {Duration relogio = const Duration(seconds: 30)}) async { + agora = agora.add(relogio); + await tester.pump(const Duration(seconds: 30)); + await tester.pump(); + await tester.pump(); + } + + testWidgets('painel atualiza contagens e TMRAV pelo pulso', (tester) async { + await montarPainel(tester); + expect(find.text('78s'), findsOneWidget); + + fonte.indicadores = const DashboardIndicators( + countsByRisk: {RiskLevel.red: 9, RiskLevel.yellow: 2, RiskLevel.green: 1}, + openRedAlerts: 4, + acknowledgedRedAlerts: 5, + tmravSeconds: 42, + ); + await ciclo(tester); + + expect(find.text('42s'), findsOneWidget); + expect(find.text('78s'), findsNothing); + expect( + find.descendant(of: find.byKey(const Key('risk_counter_red')), matching: find.text('9')), + findsOneWidget, + ); + expect(fonte.buscasDeIndicadores, 1, reason: 'só o carregamento inicial lê os indicadores; o resto vem do pulso'); + }); + + testWidgets('painel mostra o selo com dado velho', (tester) async { + await montarPainel(tester); + fonte.falhaDoPulso = StateError('rede fora'); + + // 31 s sem sucesso: ainda tolerado. + await ciclo(tester, relogio: const Duration(seconds: 31)); + expect(find.byKey(const Key('stale_badge')), findsNothing); + + // 61 s: desatualizado. + await ciclo(tester); + expect(find.byKey(const Key('stale_badge')), findsOneWidget); + expect(find.text('Dados de 09:00:00 — sem atualização há 2 min'), findsOneWidget); + // O dado velho continua na tela, sem erro em tela cheia. + expect(find.text('Painel de Indicadores'), findsOneWidget); + expect(find.text('78s'), findsOneWidget); + expect(find.text('Tentar novamente'), findsNothing); + + fonte.falhaDoPulso = null; + await ciclo(tester); + expect(find.byKey(const Key('stale_badge')), findsNothing); + }); +} diff --git a/apps/admin/test/layout_harness_sanity_test.dart b/apps/admin/test/layout_harness_sanity_test.dart index fc02f4f5..9a79fd7a 100644 --- a/apps/admin/test/layout_harness_sanity_test.dart +++ b/apps/admin/test/layout_harness_sanity_test.dart @@ -25,4 +25,35 @@ void main() { throwsA(isA()), ); }); + + testWidgets('percorrerTelaInteira falha quando a tela não acaba dentro do teto de passos', (tester) async { + // O outro jeito de o harness ficar cego: a tela cresce, o teto de rolagem + // não, e o rodapé deixa de ser checado sem nada ficar vermelho. A tela aqui + // é de propósito mais longa que `tetoDeRolagem` arrastos de 320dp. + addTearDown(tester.view.reset); + tester.view.devicePixelRatio = 1.0; + tester.view.physicalSize = const Size(360, 800); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: ListView( + children: [for (var i = 0; i < 100; i++) const SizedBox(height: 200, child: Text('item'))], + ), + ), + )); + await tester.pumpAndSettle(); + + // `try`/`catch` e não `expectLater(..., throwsA(...))`: a falha do harness + // chega depois de trinta `pumpAndSettle`, e o `expectLater` de um Future + // assim deixa o `TestFailure` escapar para o tratador de erro do teste + // ("depois que o teste terminou") em vez de o capturar. + Object? erro; + try { + await percorrerTelaInteira(tester, 'lista mais longa que o teto'); + } catch (e) { + erro = e; + } + expect(erro, isA(), reason: 'o teto de rolagem tem de falhar alto, não voltar em silêncio'); + expect('$erro', contains('tetoDeRolagem'), reason: 'a falha precisa dizer que o teto de rolagem acabou'); + }); } diff --git a/apps/admin/test/login_dev_gate_test.dart b/apps/admin/test/login_dev_gate_test.dart deleted file mode 100644 index ed624ed3..00000000 --- a/apps/admin/test/login_dev_gate_test.dart +++ /dev/null @@ -1,31 +0,0 @@ -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 index 94a6c66b..07bb1abb 100644 --- a/apps/admin/test/login_flow_test.dart +++ b/apps/admin/test/login_flow_test.dart @@ -2,14 +2,17 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:sinalacs_admin/app/app.dart'; +import 'support/fake_admin_auth.dart'; +import 'support/layout_harness.dart'; + void main() { testWidgets('deve autenticar e abrir o painel de indicadores', (tester) async { - await tester.pumpWidget(SinalAdminApp()); + await tester.pumpWidget(SinalAdminApp(auth: FakeAdminAuth())); - expect(find.byKey(const Key('dev_banner')), findsOneWidget); + // Sem banner de "ambiente de desenvolvimento": o login agora é real. + expect(find.byKey(const Key('dev_banner')), findsNothing); - await tester.tap(find.byKey(const Key('login_button'))); - await tester.pumpAndSettle(); + await entrarComCredenciais(tester); expect(find.text('Painel de Indicadores'), findsOneWidget); expect(find.text('Vermelho'), findsOneWidget); @@ -19,7 +22,7 @@ void main() { }); testWidgets('deve expor rótulo semântico e alvo de toque acessível no login do admin', (tester) async { - await tester.pumpWidget(SinalAdminApp()); + await tester.pumpWidget(SinalAdminApp(auth: FakeAdminAuth())); final loginButton = tester.widget(find.byKey(const Key('login_button'))); final minimumSize = loginButton.style?.minimumSize?.resolve({}) ?? const Size(0, 0); diff --git a/apps/admin/test/login_real_test.dart b/apps/admin/test/login_real_test.dart new file mode 100644 index 00000000..1b188d95 --- /dev/null +++ b/apps/admin/test/login_real_test.dart @@ -0,0 +1,307 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_admin/app/app.dart'; +import 'package:sinalacs_admin/core/auth/admin_auth_backend.dart'; + +import 'support/fake_admin_auth.dart'; + +Future _preencher(WidgetTester tester) async { + await tester.enterText(find.byKey(const Key('matricula_field')), 'ADM-001'); + await tester.enterText( + find.byKey(const Key('senha_field')), + 'segredo-de-teste', + ); +} + +Future _entrar(WidgetTester tester) async { + await tester.tap(find.byKey(const Key('login_button'))); + await tester.pumpAndSettle(); +} + +const _codigo = 'ABCD-EFGH-JKLM-NPQR-STUV-WXYZ-23'; + +/// Etapa 1 da ativação (#48): o código de uso único vem antes do QR. +Future _informarCodigoDeAtivacao( + WidgetTester tester, [ + String codigo = _codigo, +]) async { + await tester.enterText( + find.byKey(const Key('activation_code_field')), + codigo, + ); + await tester.tap(find.byKey(const Key('activation_continue'))); + await tester.pumpAndSettle(); +} + +void main() { + testWidgets( + 'credencial recusada mostra o erro em região viva e não abre o painel', + (tester) async { + final auth = FakeAdminAuth() + ..failWith = const AdminAuthFailure('Matrícula ou senha inválidos.'); + await tester.pumpWidget(SinalAdminApp(auth: auth)); + await _preencher(tester); + await _entrar(tester); + + expect(find.byKey(const Key('login_error')), findsOneWidget); + expect(find.text('Matrícula ou senha inválidos.'), findsOneWidget); + expect(find.text('Painel de Indicadores'), findsNothing); + final regiaoViva = find.ancestor( + of: find.byKey(const Key('login_error')), + matching: find.byWidgetPredicate( + (w) => w is Semantics && w.properties.liveRegion == true, + ), + ); + expect(regiaoViva, findsOneWidget); + expect(auth.loginCalls, 1); + }, + ); + + testWidgets('MfaCodeRequired mostra totp_field e reenvia com o código', ( + tester, + ) async { + final auth = FakeAdminAuth()..requiresTotp = true; + await tester.pumpWidget(SinalAdminApp(auth: auth)); + expect(find.byKey(const Key('totp_field')), findsNothing); + + await _preencher(tester); + await _entrar(tester); + expect(find.byKey(const Key('totp_field')), findsOneWidget); + expect(find.text('Painel de Indicadores'), findsNothing); + expect( + auth.lastTotpCode, + isNull, + reason: 'a primeira chamada vai sem código', + ); + + await tester.enterText(find.byKey(const Key('totp_field')), '123456'); + await _entrar(tester); + + expect(auth.lastTotpCode, '123456'); + expect(auth.loginCalls, 2); + expect(find.text('Painel de Indicadores'), findsOneWidget); + }); + + testWidgets('o campo do código só aceita dígitos e no máximo 6', ( + tester, + ) async { + final auth = FakeAdminAuth()..requiresTotp = true; + await tester.pumpWidget(SinalAdminApp(auth: auth)); + await _preencher(tester); + await _entrar(tester); + + await tester.enterText(find.byKey(const Key('totp_field')), '12ab34567890'); + final campo = tester.widget(find.byKey(const Key('totp_field'))); + expect(campo.controller!.text, '123456'); + expect(campo.keyboardType, TextInputType.number); + }); + + testWidgets('mudar matrícula ou senha descarta o campo do código', ( + tester, + ) async { + final auth = FakeAdminAuth()..requiresTotp = true; + await tester.pumpWidget(SinalAdminApp(auth: auth)); + await _preencher(tester); + await _entrar(tester); + await tester.enterText(find.byKey(const Key('totp_field')), '123456'); + expect(find.byKey(const Key('totp_field')), findsOneWidget); + + await tester.enterText(find.byKey(const Key('matricula_field')), 'ADM-002'); + await tester.pump(); + expect(find.byKey(const Key('totp_field')), findsNothing); + + // Mesmo vale para a senha, e o código antigo não vaza para o reenvio. + await _entrar(tester); + expect(find.byKey(const Key('totp_field')), findsOneWidget); + expect( + tester + .widget(find.byKey(const Key('totp_field'))) + .controller! + .text, + isEmpty, + ); + await tester.enterText(find.byKey(const Key('senha_field')), 'outra-senha'); + await tester.pump(); + expect(find.byKey(const Key('totp_field')), findsNothing); + }); + + testWidgets( + 'MfaEnrollmentRequired abre a etapa do código de ativação, sem QR e sem chamar begin', + (tester) async { + final auth = FakeAdminAuth() + ..failWith = const AdminMfaEnrollmentRequired(); + await tester.pumpWidget(SinalAdminApp(auth: auth)); + await _preencher(tester); + await _entrar(tester); + + expect(find.byKey(const Key('activation_code_field')), findsOneWidget); + expect(find.byKey(const Key('mfa_secret')), findsNothing); + expect(find.byKey(const Key('mfa_code_field')), findsNothing); + expect(find.text('Painel de Indicadores'), findsNothing); + expect( + auth.enrollmentBegins, + 0, + reason: 'sem o código, o app nem pede o segredo', + ); + }, + ); + + testWidgets('código de ativação vazio não chama o servidor e pede o código', ( + tester, + ) async { + final auth = FakeAdminAuth()..failWith = const AdminMfaEnrollmentRequired(); + await tester.pumpWidget(SinalAdminApp(auth: auth)); + await _preencher(tester); + await _entrar(tester); + + await tester.tap(find.byKey(const Key('activation_continue'))); + await tester.pumpAndSettle(); + + expect(find.text('Informe o código de ativação.'), findsOneWidget); + expect(auth.enrollmentBegins, 0); + }); + + testWidgets( + 'com o código, begin recebe exatamente o que foi digitado e o QR aparece', + (tester) async { + final auth = FakeAdminAuth() + ..failWith = const AdminMfaEnrollmentRequired(); + await tester.pumpWidget(SinalAdminApp(auth: auth)); + await _preencher(tester); + await _entrar(tester); + await _informarCodigoDeAtivacao(tester); + + expect(auth.enrollmentBegins, 1); + expect(auth.lastActivationCode, _codigo); + expect(find.byKey(const Key('mfa_secret')), findsOneWidget); + expect(find.text('JBSWY3DPEHPK3PXP'), findsOneWidget); + expect(find.byKey(const Key('mfa_code_field')), findsOneWidget); + }, + ); + + testWidgets('código de ativação recusado mostra o erro e o QR não aparece', ( + tester, + ) async { + final auth = FakeAdminAuth() + ..failWith = const AdminMfaEnrollmentRequired() + ..beginFailWith = const AdminAuthFailure( + 'Código de ativação inválido ou expirado.', + ); + await tester.pumpWidget(SinalAdminApp(auth: auth)); + await _preencher(tester); + await _entrar(tester); + await _informarCodigoDeAtivacao(tester, 'ERRADO'); + + expect(find.byKey(const Key('activation_error')), findsOneWidget); + expect( + find.text('Código de ativação inválido ou expirado.'), + findsOneWidget, + ); + expect(find.byKey(const Key('mfa_secret')), findsNothing); + expect( + find.byKey(const Key('activation_code_field')), + findsOneWidget, + reason: 'dá para tentar de novo', + ); + }); + + testWidgets( + 'confirmar a ativação envia o mesmo código, volta ao login com o aviso e pede o código', + (tester) async { + final auth = FakeAdminAuth() + ..failWith = const AdminMfaEnrollmentRequired(); + await tester.pumpWidget(SinalAdminApp(auth: auth)); + await _preencher(tester); + await _entrar(tester); + await _informarCodigoDeAtivacao(tester); + + await tester.enterText(find.byKey(const Key('mfa_code_field')), '654321'); + await tester.tap(find.byKey(const Key('mfa_confirm_button'))); + await tester.pumpAndSettle(); + + expect(auth.lastEnrollmentCode, '654321'); + expect(auth.lastActivationCode, _codigo); + expect(find.byKey(const Key('mfa_secret')), findsNothing); + expect( + find.text('Verificação ativada. Entre com o código do aplicativo.'), + findsOneWidget, + ); + expect(find.byKey(const Key('totp_field')), findsOneWidget); + }, + ); + + testWidgets('falha ao confirmar a ativação mostra o erro e fica na tela', ( + tester, + ) async { + final auth = FakeAdminAuth() + ..failWith = const AdminMfaEnrollmentRequired() + ..confirmFailWith = const AdminAuthFailure('Código inválido.'); + await tester.pumpWidget(SinalAdminApp(auth: auth)); + await _preencher(tester); + await _entrar(tester); + await _informarCodigoDeAtivacao(tester); + + await tester.enterText(find.byKey(const Key('mfa_code_field')), '000000'); + await tester.tap(find.byKey(const Key('mfa_confirm_button'))); + await tester.pumpAndSettle(); + + expect(find.text('Código inválido.'), findsOneWidget); + expect(find.byKey(const Key('mfa_secret')), findsOneWidget); + }); + + testWidgets( + 'não existe caminho de entrada sem chamar o backend (sem bypass)', + (tester) async { + final auth = FakeAdminAuth(); + await tester.pumpWidget(SinalAdminApp(auth: auth)); + await _entrar(tester); // campos vazios não autenticam + + expect(find.text('Painel de Indicadores'), findsNothing); + expect(auth.loginCalls, 0, reason: 'campo vazio nem chega ao backend'); + expect(find.byKey(const Key('login_error')), findsOneWidget); + expect(find.byKey(const Key('dev_banner')), findsNothing); + expect(find.byKey(const Key('dev_login_disabled_notice')), findsNothing); + }, + ); + + testWidgets('sessão vencida volta ao login com a mensagem', (tester) async { + final auth = FakeAdminAuth( + session: AdminSession( + accessToken: 't', + userId: 'ADM-001', + role: 'admin', + expiresAt: DateTime.now().subtract(const Duration(minutes: 1)), + ), + ); + await tester.pumpWidget(SinalAdminApp(auth: auth)); + await _preencher(tester); + await _entrar(tester); + + expect(find.byKey(const Key('login_button')), findsOneWidget); + expect(find.text('Sessão encerrada. Entre novamente.'), findsOneWidget); + expect(find.text('Painel de Indicadores'), findsNothing); + }); + + testWidgets('sessão que vence com o painel aberto volta ao login', ( + tester, + ) async { + final auth = FakeAdminAuth( + session: AdminSession( + accessToken: 't', + userId: 'ADM-001', + role: 'admin', + expiresAt: DateTime.now().add(const Duration(seconds: 5)), + ), + ); + await tester.pumpWidget(SinalAdminApp(auth: auth)); + await _preencher(tester); + await _entrar(tester); + expect(find.text('Painel de Indicadores'), findsOneWidget); + + await tester.pump(const Duration(seconds: 6)); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('login_button')), findsOneWidget); + expect(find.text('Sessão encerrada. Entre novamente.'), findsOneWidget); + }); +} diff --git a/apps/admin/test/micro_areas_screen_test.dart b/apps/admin/test/micro_areas_screen_test.dart index 379640a8..2d8797c3 100644 --- a/apps/admin/test/micro_areas_screen_test.dart +++ b/apps/admin/test/micro_areas_screen_test.dart @@ -1,12 +1,14 @@ 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'; +import 'support/fake_admin_auth.dart'; +import 'support/layout_harness.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(); + testWidgets('deve listar microáreas com o ACS vinculado e a gestão das contas de ACS', (tester) async { + await tester.pumpWidget(SinalAdminApp(auth: FakeAdminAuth())); + await entrarComCredenciais(tester); await tester.tap(find.text('Microáreas').last); await tester.pumpAndSettle(); @@ -16,7 +18,13 @@ void main() { 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); + // A tela deixou de ser somente leitura (#43): cadastro e vínculo de ACS + // vivem aqui agora. O que continua fora dela é a classificação de risco — + // a triagem é determinística e não se altera por tela nenhuma (PRD §2.4). + // A lista é preguiçosa: a seção de ACS pode estar abaixo da dobra. + await tester.scrollUntilVisible(find.byKey(const Key('novo_acs')), 200, scrollable: find.byType(Scrollable).last); + expect(find.byKey(const Key('novo_acs')), findsOneWidget); + expect(find.byKey(const Key('vincular_acs_acs-1')), findsOneWidget); + expect(find.byType(DropdownButtonFormField), findsNothing); }); } diff --git a/apps/admin/test/mock_admin_data_source_test.dart b/apps/admin/test/mock_admin_data_source_test.dart index f98c4f07..7ebffb09 100644 --- a/apps/admin/test/mock_admin_data_source_test.dart +++ b/apps/admin/test/mock_admin_data_source_test.dart @@ -19,7 +19,7 @@ void main() { final dataSource = MockAdminDataSource(); final filtered = await dataSource.fetchAlerts( - microAreaName: 'Microárea 07 — Centro', + microAreaId: 'ma-07', status: AlertStatus.acknowledged, ); @@ -33,10 +33,332 @@ void main() { await dataSource.recordAccess(actionType: 'view', resourceType: 'micro_areas'); await dataSource.recordAccess(actionType: 'view', resourceType: 'alerts'); - final log = await dataSource.fetchAuditLogs(); + final log = (await dataSource.fetchAuditLogs()).items; expect(log, hasLength(2)); expect(log.first.resourceType, 'alerts'); expect(log.last.resourceType, 'micro_areas'); + expect(log.first.sequence, greaterThan(log.last.sequence)); + expect(log.map((e) => e.seal), everyElement(AuditRowSeal.verified)); + }); + + group('auditoria (#45): filtro, cursor e estado da cadeia configuráveis', () { + AuditLogEntry linha(int seq, String label, String acao) => AuditLogEntry( + id: 'e$seq', + sequence: seq, + userLabel: label, + actionType: acao, + resourceType: 'session', + timestamp: DateTime.utc(2026, 10, 1).add(Duration(hours: seq)), + result: 'success', + seal: seq.isEven ? AuditRowSeal.verified : AuditRowSeal.invalid, + ); + + test('keyset por sequence decrescente: a página seguinte começa abaixo do cursor', () async { + final ds = MockAdminDataSource(auditSeed: [for (var s = 5; s >= 1; s--) linha(s, 'ADM-1 (Administrador)', 'read')]); + final p1 = await ds.fetchAuditLogs(limit: 2); + expect(p1.items.map((e) => e.sequence), [5, 4]); + expect(p1.nextBeforeSequence, 4); + final p2 = await ds.fetchAuditLogs(limit: 2, beforeSequence: p1.nextBeforeSequence); + expect(p2.items.map((e) => e.sequence), [3, 2]); + final p3 = await ds.fetchAuditLogs(limit: 2, beforeSequence: p2.nextBeforeSequence); + expect(p3.items.map((e) => e.sequence), [1]); + expect(p3.nextBeforeSequence, isNull); + expect(ds.auditQueries.map((q) => q.beforeSequence), [null, 4, 2]); + }); + + test('filtros combinam em E e o selo vem da semente, sem recalcular', () async { + final ds = MockAdminDataSource(auditSeed: [ + linha(4, 'ACS-12 (ACS)', 'read'), + linha(3, 'ACS-12 (ACS)', 'write'), + linha(2, 'COORD-1 (Coordenador)', 'read'), + linha(1, 'Paciente #A18F', 'login'), + ]); + final acsLeitura = await ds.fetchAuditLogs( + filter: const AuditLogFilter(actorRole: AuditActorRole.acs, actionType: 'read'), + ); + expect(acsLeitura.items.map((e) => e.id), ['e4']); + final paciente = await ds.fetchAuditLogs(filter: const AuditLogFilter(actorRole: AuditActorRole.patient)); + expect(paciente.items.map((e) => e.id), ['e1']); + expect(paciente.items.single.seal, AuditRowSeal.invalid); + final busca = await ds.fetchAuditLogs(filter: const AuditLogFilter(search: 'coord')); + expect(busca.items.map((e) => e.id), ['e2']); + }); + + test('estado da cadeia é o configurado; sem configuração, verified', () async { + final ds = MockAdminDataSource(); + expect((await ds.verifyAuditChain()).state, AuditIntegrityState.verified); + ds.chainStatus = ChainStatus(state: AuditIntegrityState.unavailable, verifiedAt: DateTime.utc(2026)); + expect((await ds.verifyAuditChain()).state, AuditIntegrityState.unavailable); + expect(ds.verifyChainCalls, 2); + }); + }); + + test('AuditLogFilter: isEmpty, copyWith que limpa campo e igualdade por valor', () { + const vazio = AuditLogFilter(); + expect(vazio.isEmpty, isTrue); + final f = vazio.copyWith(search: 'ab', actorRole: AuditActorRole.acs); + expect(f.isEmpty, isFalse); + expect(f, const AuditLogFilter(search: 'ab', actorRole: AuditActorRole.acs)); + expect(f.copyWith(search: null).search, isNull); + expect(f.copyWith(search: null).actorRole, AuditActorRole.acs); + expect(f.copyWith(search: null, actorRole: null).isEmpty, isTrue); + }); + + test('pedidos do titular: fixtures sintéticas ordenadas por prazo, com um vencido', () async { + final dataSource = MockAdminDataSource(); + + final pedidos = await dataSource.fetchDataRequests(); + + expect(pedidos, isNotEmpty); + for (var i = 1; i < pedidos.length; i++) { + expect(pedidos[i - 1].dueAt.isAfter(pedidos[i].dueAt), isFalse); + } + expect(pedidos.where((p) => p.overdue), isNotEmpty); + }); + + test('pedidos do titular: atender correção muda o status e grava a resposta', () async { + final dataSource = MockAdminDataSource(); + + await dataSource.completeDataRequest('req-a18f', note: 'Resposta fictícia.'); + final pedido = await dataSource.fetchDataRequest('req-a18f'); + + expect(pedido.status, DataRequestStatus.completed); + expect(pedido.resolution, 'Resposta fictícia.'); + expect(pedido.overdue, isFalse, reason: 'decidido não é mais vencido'); + await expectLater( + dataSource.rejectDataRequest('req-a18f', reason: 'tarde demais'), + throwsA(isA()), + ); + }); + + group('gestão de contas (#43)', () { + test('semeia o ACS acs-1 e a conta de equipe staff-1, por chave', () async { + final dataSource = MockAdminDataSource(); + + final acs = await dataSource.fetchAcs(); + final carla = acs.firstWhere((a) => a.id == 'acs-1'); + expect(carla.name, 'Carla Nogueira'); + expect(carla.enrollmentId, 'ACS-001'); + expect(carla.microAreaId, 'ma-12'); + expect(carla.active, isTrue); + expect(carla.mfaActive, isFalse); + + final staff = await dataSource.fetchStaff(); + final coordenadora = staff.firstWhere((s) => s.id == 'staff-1'); + expect(coordenadora.enrollmentId, 'COO-001'); + expect(coordenadora.role, 'coordinator'); + expect(coordenadora.ubsName, isNotNull); + }); + + test('createAcs acrescenta o ACS com a senha inicial determinística, uma vez', () async { + final dataSource = MockAdminDataSource(); + + final criado = await dataSource.createAcs( + name: 'Nova Agente', + enrollmentId: 'ACS-999', + microAreaId: 'ma-07', + ); + + expect(criado.initialPassword, 'SENHA-INICIAL-DE-TESTE'); + expect(criado.acs.name, 'Nova Agente'); + expect(criado.acs.enrollmentId, 'ACS-999'); + expect(criado.acs.microAreaId, 'ma-07'); + expect(criado.acs.microAreaName, 'Microárea 07 — Centro'); + expect(criado.acs.active, isTrue, reason: 'o servidor cadastra já ativo'); + expect(criado.acs.mfaActive, isFalse, reason: 'MFA nasce pendente de ativação'); + + final acs = await dataSource.fetchAcs(); + expect(acs.map((a) => a.id), contains(criado.acs.id)); + }); + + test('setAcsActive desativa e anota a chamada; reativar não entra na lista', () async { + final dataSource = MockAdminDataSource(); + + final desativado = await dataSource.setAcsActive(acsId: 'acs-1', active: false); + expect(desativado.active, isFalse); + expect(dataSource.desativados, ['acs-1']); + + await dataSource.setAcsActive(acsId: 'acs-1', active: true); + expect(dataSource.desativados, ['acs-1'], reason: 'reativar não é uma desativação'); + + final carla = (await dataSource.fetchAcs()).firstWhere((a) => a.id == 'acs-1'); + expect(carla.active, isTrue); + }); + + test('setAcsMicroArea vincula e resolve o nome da microárea na lista da tela', () async { + final dataSource = MockAdminDataSource(); + + final vinculado = await dataSource.setAcsMicroArea(acsId: 'acs-1', microAreaId: 'ma-03'); + + expect(vinculado.microAreaId, 'ma-03'); + expect(vinculado.microAreaName, 'Microárea 03 — Vila Esperança'); + expect(dataSource.vinculados, ['acs-1']); + + final carla = (await dataSource.fetchAcs()).firstWhere((a) => a.id == 'acs-1'); + expect(carla.microAreaId, 'ma-03'); + }); + + test('resetAcsPassword devolve a senha determinística e anota o alvo', () async { + final dataSource = MockAdminDataSource(); + + final senha = await dataSource.resetAcsPassword(acsId: 'acs-1'); + + expect(senha, 'SENHA-INICIAL-DE-TESTE'); + expect(dataSource.senhasRedefinidas, ['acs-1']); + }); + + test('resetAcsMfa limpa o estado de MFA do ACS e anota o alvo', () async { + final dataSource = MockAdminDataSource(); + final antes = (await dataSource.fetchAcs()).firstWhere((a) => a.id == 'acs-2'); + expect(antes.mfaActive, isTrue); + + await dataSource.resetAcsMfa(acsId: 'acs-2'); + + expect(dataSource.mfasRedefinidas, ['acs-2']); + final depois = (await dataSource.fetchAcs()).firstWhere((a) => a.id == 'acs-2'); + expect(depois.mfaActive, isFalse); + }); + + test('resetStaffMfa devolve o código de ativação determinístico e limpa a MFA da conta', () async { + final dataSource = MockAdminDataSource(); + + final ativacao = await dataSource.resetStaffMfa(staffId: 'staff-1'); + + expect(ativacao.code, 'ABCD-EFGH-JKLM-NPQR-STUV'); + expect(ativacao.expiresAt.isAfter(DateTime(2026, 1, 1)), isTrue); + expect(dataSource.mfasRedefinidas, ['staff-1']); + final conta = (await dataSource.fetchStaff()).firstWhere((s) => s.id == 'staff-1'); + expect(conta.mfaActive, isFalse); + }); + }); + + group('escalonamento, pulso e fim de sessão (#44)', () { + final t0 = DateTime(2026, 10, 7, 12); + + // alert-1 é vermelho e pendente, disparado às 8h12 de 11/09 (já vencido + // para qualquer relógio posterior); o relógio injetado controla o prazo. + MockAdminDataSource fonte(DateTime agora) => MockAdminDataSource(now: () => agora); + + test('vermelho pendente vencido: escalona, segue pendente e listado, com escalatedAt', () async { + final ds = fonte(t0); + final antes = await ds.fetchAlerts(); + expect(antes.firstWhere((a) => a.id == 'alert-1').overdue, isTrue); + + final r = await ds.escalateAlert('alert-1', EscalationReasonCode.noAckTimeout); + expect(r.alreadyEscalated, isFalse); + expect(r.escalatedAt, t0); + + final depois = await ds.fetchAlerts(); + expect(depois, hasLength(antes.length), reason: 'escalonar não tira o alerta da lista'); + final a = depois.firstWhere((a) => a.id == 'alert-1'); + expect(a.status, AlertStatus.pending, reason: 'o escalonamento não confirma nem muda o status'); + expect(a.escalatedAt, t0); + }); + + test('segunda chamada devolve alreadyEscalated com o horário original', () async { + var agora = t0; + final ds = MockAdminDataSource(now: () => agora); + final primeira = await ds.escalateAlert('alert-1', EscalationReasonCode.acsUnreachable); + agora = t0.add(const Duration(minutes: 5)); + final segunda = await ds.escalateAlert('alert-1', EscalationReasonCode.patientRecontact); + expect(segunda.alreadyEscalated, isTrue); + expect(segunda.escalatedAt, primeira.escalatedAt); + }); + + test('alerta ainda no prazo: mesma recusa do servidor', () async { + // Um minuto depois do disparo de alert-1, o prazo não venceu. + final ds = fonte(DateTime(2026, 9, 11, 8, 13)); + await expectLater( + ds.escalateAlert('alert-1', EscalationReasonCode.noAckTimeout), + throwsA(isA().having( + (e) => e.message, + 'message', + 'O prazo para escalonar este alerta ainda não venceu.', + )), + ); + }); + + test('recusas: confirmado, não vermelho e inexistente', () async { + final ds = fonte(t0); + Matcher falha(String m) => throwsA(isA().having((e) => e.message, 'message', m)); + await expectLater(ds.escalateAlert('alert-2', EscalationReasonCode.noAckTimeout), falha('Alerta já confirmado.')); + await expectLater( + ds.escalateAlert('alert-3', EscalationReasonCode.noAckTimeout), + falha('Só alertas vermelhos podem ser escalonados.'), + ); + await expectLater( + ds.escalateAlert('nao-existe', EscalationReasonCode.noAckTimeout), + falha('Alerta não encontrado.'), + ); + }); + + test('alertsPulse reflete indicadores, vencidos, marca e relógio', () async { + final ds = fonte(t0); + final p = await ds.alertsPulse(); + expect(p.serverNow, t0); + expect(p.overdueRed, 1); + expect(p.indicators.openRedAlerts, 1); + final antes = p.maxChangedAt; + await ds.escalateAlert('alert-1', EscalationReasonCode.noAckTimeout); + final depois = await ds.alertsPulse(); + expect(depois.maxChangedAt, isNotNull); + expect(antes == null || !depois.maxChangedAt!.isBefore(antes), isTrue); + expect(depois.maxChangedAt, t0, reason: 'o escalonamento é uma mudança observável'); + }); + + test('endMonitoringSession é registrado', () async { + final ds = MockAdminDataSource(); + expect(ds.monitoringSessionEnds, 0); + await ds.endMonitoringSession(); + await ds.endMonitoringSession(); + expect(ds.monitoringSessionEnds, 2); + }); + }); + + group('exportação da trilha (#45, Task 11)', () { + AuditLogEntry linha(int seq, DateTime em) => AuditLogEntry( + id: 'e$seq', + sequence: seq, + userLabel: 'ADM-001 (Administrador)', + actionType: 'read', + resourceType: 'admin_alerts', + timestamp: em, + result: 'success', + seal: AuditRowSeal.verified, + ); + + test('sem resultado configurado, exporta o recorte semeado do período e conta a chamada', () async { + final ds = MockAdminDataSource(auditSeed: [ + linha(1, DateTime.utc(2026, 9, 30)), + linha(2, DateTime.utc(2026, 10, 2)), + linha(3, DateTime.utc(2026, 10, 3)), + linha(4, DateTime.utc(2026, 10, 9)), + ]); + final filtro = AuditLogFilter(from: DateTime.utc(2026, 10, 1), to: DateTime.utc(2026, 10, 5)); + + final r = await ds.exportAuditLogs(filtro); + + expect(ds.exportCalls, 1); + expect(ds.lastExportFilter, filtro); + expect(r.rowCount, 2); + expect(r.firstSequence, 2); + expect(r.lastSequence, 3); + expect(r.csv, startsWith('sequence,timestamp_utc,autor,papel_autor,acao,recurso,rotulo_recurso,resultado,selo\r\n')); + expect(r.contentSha256, hasLength(64)); + expect(r.chainState, AuditIntegrityState.verified); + }); + + test('erro configurado é lançado e a chamada ainda é contada', () async { + final ds = MockAdminDataSource()..exportError = const AuditExportFailure(AuditExportFailureKind.tooManyRows); + final filtro = AuditLogFilter(from: DateTime.utc(2026, 10, 1), to: DateTime.utc(2026, 10, 5)); + await expectLater(ds.exportAuditLogs(filtro), throwsA(isA())); + expect(ds.exportCalls, 1); + }); + + test('sem início e fim é erro de programação: a tela nunca pede assim', () async { + final ds = MockAdminDataSource(); + await expectLater(ds.exportAuditLogs(const AuditLogFilter()), throwsArgumentError); + }); }); } diff --git a/apps/admin/test/notice_form_test.dart b/apps/admin/test/notice_form_test.dart new file mode 100644 index 00000000..89d0b17f --- /dev/null +++ b/apps/admin/test/notice_form_test.dart @@ -0,0 +1,229 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_admin/app/admin_async_states.dart'; +import 'package:sinalacs_admin/app/admin_theme.dart'; +import 'package:sinalacs_admin/app/notices_screen.dart'; +import 'package:sinalacs_admin/core/data/admin_data_source.dart'; +import 'package:sinalacs_admin/core/data/mock_admin_data_source.dart'; + +import 'support/layout_harness.dart'; + +/// Formulário de agendamento de aviso (#46). Dados sintéticos. +final _agora = DateTime(2026, 10, 14, 9); + +class _Agendamento { + _Agendamento(this.microAreaId, this.audience, this.title, this.message, this.scheduledFor); + final String microAreaId; + final NoticeAudience audience; + final String title; + final String message; + final DateTime scheduledFor; +} + +class _FakeAgendamento extends MockAdminDataSource { + _FakeAgendamento() : super(now: () => _agora); + + final List<_Agendamento> agendados = []; + Completer? portao; + Object? erro; + bool semAreas = false; + + @override + Future> fetchMicroAreas() async => semAreas ? const [] : super.fetchMicroAreas(); + + @override + Future scheduleNotice({ + required String microAreaId, + required NoticeAudience audience, + required String title, + required String message, + required DateTime scheduledFor, + }) async { + agendados.add(_Agendamento(microAreaId, audience, title, message, scheduledFor)); + if (portao != null) await portao!.future; + final e = erro; + if (e != null) throw e; + return super.scheduleNotice( + microAreaId: microAreaId, + audience: audience, + title: title, + message: message, + scheduledFor: scheduledFor, + ); + } +} + +Future _abrir( + WidgetTester tester, + AdminDataSource fonte, { + DateTime Function()? relogio, + VoidCallback? aoVencer, +}) async { + redimensionar(tester, const Size(1024, 1000)); + addTearDown(tester.view.reset); + await tester.pumpWidget( + MaterialApp( + theme: buildAdminTheme(), + home: Scaffold( + body: AdminSessionScope( + aoVencer: aoVencer ?? () {}, + child: NoticesScreen(dataSource: fonte, role: 'admin', now: relogio ?? () => _agora), + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('notice_new_button'))); + await tester.pumpAndSettle(); +} + +Future _preencher(WidgetTester tester, {String titulo = ' Vacinação ', String mensagem = ' Traga a caderneta. '}) async { + await tester.enterText(find.byKey(const Key('notice_title')), titulo); + await tester.enterText(find.byKey(const Key('notice_message')), mensagem); + await tester.pumpAndSettle(); +} + +FilledButton _submit(WidgetTester tester) => tester.widget(find.byKey(const Key('notice_submit'))); + +void main() { + testWidgets('enviar fica desabilitado com campos vazios e habilita com título e mensagem; limites 60/240', (tester) async { + await _abrir(tester, _FakeAgendamento()); + expect(_submit(tester).onPressed, isNull); + expect(find.text('0/60'), findsOneWidget); + expect(find.text('0/240'), findsOneWidget); + expect(find.text('O texto do aviso não é gravado na trilha de auditoria.'), findsOneWidget); + await _preencher(tester); + expect(_submit(tester).onPressed, isNotNull); + await tester.enterText(find.byKey(const Key('notice_title')), ' '); + await tester.pumpAndSettle(); + expect(_submit(tester).onPressed, isNull); + await tester.enterText(find.byKey(const Key('notice_title')), 'x' * 61); + await tester.pumpAndSettle(); + expect(find.text('60/60'), findsOneWidget); + }); + + testWidgets('horário a menos de 1 minuto: mostra erro e não chama scheduleNotice', (tester) async { + var agora = _agora; + final fonte = _FakeAgendamento(); + await _abrir(tester, fonte, relogio: () => agora); + await _preencher(tester); + // O padrão (10:00) fica a menos de 1 minuto do relógio. + agora = DateTime(2026, 10, 14, 9, 59, 30); + await tester.tap(find.byKey(const Key('notice_submit'))); + await tester.pumpAndSettle(); + expect(find.text('O horário do aviso deve ser pelo menos 1 minuto à frente.'), findsOneWidget); + expect(fonte.agendados, isEmpty); + expect(find.byKey(const Key('notice_title')), findsOneWidget); + }); + + // O horário padrão é agora + 1 h truncada ao minuto (10:00); o relógio é movido + // depois de abrir o diálogo para a distância ficar exatamente na borda. + testWidgets('borda de 1 minuto: exatamente 60 s à frente é aceito', (tester) async { + var agora = _agora; + final fonte = _FakeAgendamento(); + await _abrir(tester, fonte, relogio: () => agora); + await _preencher(tester); + agora = DateTime(2026, 10, 14, 9, 59); // 10:00 - 60 s + await tester.tap(find.byKey(const Key('notice_submit'))); + await tester.pumpAndSettle(); + expect(fonte.agendados, hasLength(1)); + expect(fonte.agendados.single.scheduledFor, DateTime(2026, 10, 14, 10)); + }); + + testWidgets('borda de 1 minuto: 59 s à frente é recusado sem chamar o data source', (tester) async { + var agora = _agora; + final fonte = _FakeAgendamento(); + await _abrir(tester, fonte, relogio: () => agora); + await _preencher(tester); + agora = DateTime(2026, 10, 14, 9, 59, 1); // 10:00 - 59 s + await tester.tap(find.byKey(const Key('notice_submit'))); + await tester.pumpAndSettle(); + expect(find.text('O horário do aviso deve ser pelo menos 1 minuto à frente.'), findsOneWidget); + expect(fonte.agendados, isEmpty); + }); + + testWidgets('envio válido: uma chamada com os valores escolhidos, fecha e recarrega o histórico', (tester) async { + final fonte = _FakeAgendamento(); + await _abrir(tester, fonte); + expect(find.text('Envio em 14/10/2026 10:00'), findsOneWidget); + await tester.tap(find.text('Só crônicos')); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('notice_area'))); + await tester.pumpAndSettle(); + await tester.tap(find.textContaining('Microárea 07').last); + await tester.pumpAndSettle(); + await _preencher(tester); + // Hora: abre o seletor e confirma com OK; a data fica a do padrão. + await tester.tap(find.byKey(const Key('notice_date'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('notice_submit'))); + await tester.pumpAndSettle(); + + expect(fonte.agendados, hasLength(1)); + final a = fonte.agendados.single; + expect(a.microAreaId, 'ma-07'); + expect(a.audience, NoticeAudience.chronic); + expect(a.title, 'Vacinação'); + expect(a.message, 'Traga a caderneta.'); + expect(a.scheduledFor, DateTime(2026, 10, 14, 10)); + expect(find.byKey(const Key('notice_submit')), findsNothing); + expect(find.text('Aviso agendado.'), findsOneWidget); + expect(find.text('Vacinação'), findsOneWidget); + // (h) a mensagem digitada não aparece no histórico. + expect(find.textContaining('caderneta'), findsNothing); + }); + + testWidgets('duplo toque em Agendar chama o data source uma única vez', (tester) async { + final fonte = _FakeAgendamento()..portao = Completer(); + await _abrir(tester, fonte); + await _preencher(tester); + final botao = find.byKey(const Key('notice_submit')); + await tester.tap(botao); + await tester.tap(botao, warnIfMissed: false); + await tester.pump(); + expect(fonte.agendados, hasLength(1)); + fonte.portao!.complete(); + await tester.pumpAndSettle(); + expect(fonte.agendados, hasLength(1)); + }); + + testWidgets('AdminValidationFailure aparece no diálogo, que continua aberto', (tester) async { + final fonte = _FakeAgendamento()..erro = const AdminValidationFailure('Microárea não encontrada.'); + await _abrir(tester, fonte); + await _preencher(tester); + await tester.tap(find.byKey(const Key('notice_submit'))); + await tester.pumpAndSettle(); + expect(find.text('Microárea não encontrada.'), findsOneWidget); + expect(find.byKey(const Key('notice_submit')), findsOneWidget); + expect(_submit(tester).onPressed, isNotNull); + }); + + testWidgets('erro genérico mostra a mensagem fixa dentro do diálogo', (tester) async { + final fonte = _FakeAgendamento()..erro = StateError('boom'); + await _abrir(tester, fonte); + await _preencher(tester); + await tester.tap(find.byKey(const Key('notice_submit'))); + await tester.pumpAndSettle(); + expect(find.text('Não foi possível agendar o aviso agora.'), findsOneWidget); + }); + + testWidgets('AdminSessionExpired aciona o callback do AdminSessionScope', (tester) async { + var chamadas = 0; + final fonte = _FakeAgendamento()..erro = const AdminSessionExpired(); + await _abrir(tester, fonte, aoVencer: () => chamadas++); + await _preencher(tester); + await tester.tap(find.byKey(const Key('notice_submit'))); + await tester.pumpAndSettle(); + expect(chamadas, 1); + }); + + testWidgets('sem microáreas: mostra a mensagem e desabilita o envio', (tester) async { + await _abrir(tester, _FakeAgendamento()..semAreas = true); + expect(find.text('Nenhuma microárea disponível.'), findsOneWidget); + expect(_submit(tester).onPressed, isNull); + }); +} diff --git a/apps/admin/test/notices_a11y_test.dart b/apps/admin/test/notices_a11y_test.dart new file mode 100644 index 00000000..7e786419 --- /dev/null +++ b/apps/admin/test/notices_a11y_test.dart @@ -0,0 +1,601 @@ +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.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'; + +import 'support/contrast.dart'; +import 'support/fake_admin_auth.dart'; +import 'support/layout_harness.dart'; + +/// Acessibilidade, contraste e layout em celular das telas Avisos/Termos (#46). +/// +/// Dados sintéticos; nenhum texto jurídico real. As quatro frentes seguem os +/// padrões de `touch_targets_test.dart` (alvo E tinta ≥ 48dp), +/// `text_scale_test.dart` (percorrer a tela inteira), `contrast_tokens_test.dart` +/// (4,5:1 medido no widget, contra a superfície onde de fato renderiza) e a +/// regra WCAG 1.4.1 (o selo nunca depende só de cor). +const _texto = '[TEXTO PLACEHOLDER]'; +const _alvo = 48.0; + +/// Os termos usam datas relativas ao relógio real porque o app do teste roda +/// com `DateTime.now`. +DateTime _emDias(int dias) { + final h = DateTime.now().add(Duration(days: dias)); + return DateTime(h.year, h.month, h.day); +} + +class _Dados extends MockAdminDataSource { + _Dados({this.vazio = false, this.falhaNoAgendamento, this.falhaNoRascunho, this.nomeDaArea}); + + /// Nome de microárea forçado (para o caso de nome longo). + final String? nomeDaArea; + + @override + Future> fetchMicroAreas() async { + final areas = await super.fetchMicroAreas(); + final nome = nomeDaArea; + if (nome == null) return areas; + return [ + MicroAreaSummary( + id: areas.first.id, + name: nome, + acsName: areas.first.acsName, + acsEnrollmentId: areas.first.acsEnrollmentId, + acsActive: areas.first.acsActive, + ), + ]; + } + + final bool vazio; + final Object? falhaNoAgendamento; + final Object? falhaNoRascunho; + + @override + Future> fetchNoticeHistory({String? microAreaId, int limit = 50, int offset = 0}) async { + if (vazio) return const []; + NoticeHistoryItem item(NoticeStatus s, {int? rec, int? ace, String? motivo}) => NoticeHistoryItem( + id: 'av-${s.name}', + microAreaId: 'ma-1', + microAreaName: 'Microárea 01', + audience: NoticeAudience.chronic, + title: 'Aviso de vacinação em ${s.name}', + status: s, + scheduledFor: DateTime(2026, 10, 15, 8, 30), + createdByLabel: 'ADM-001', + recipients: rec, + accepted: ace, + failureReason: motivo, + ); + return [ + item(NoticeStatus.pending), + item(NoticeStatus.sending), + item(NoticeStatus.sent, rec: 120, ace: 118), + item(NoticeStatus.unknown), + item(NoticeStatus.failed, motivo: 'Provedor de push indisponível'), + item(NoticeStatus.cancelled), + ]; + } + + @override + Future> fetchTermsVersions() async { + if (vazio) return const []; + TermsVersionSummary termo(String id, int dias, {bool revisada = false, bool publicada = false}) => TermsVersionSummary( + id: id, + version: '2026.$id', + summary: _texto, + effectiveFrom: _emDias(dias), + createdAt: _emDias(-1), + legallyReviewedAt: revisada || publicada ? _emDias(-1) : null, + publishedAt: publicada ? _emDias(-1) : null, + ); + return [ + termo('rascunho', 30), + termo('revisada', 30, revisada: true), + termo('esgotada', 5, revisada: true), + termo('publicada', 40, publicada: true), + ]; + } + + @override + Future scheduleNotice({ + required String microAreaId, + required NoticeAudience audience, + required String title, + required String message, + required DateTime scheduledFor, + }) async { + if (falhaNoAgendamento != null) throw falhaNoAgendamento!; + return super.scheduleNotice( + microAreaId: microAreaId, + audience: audience, + title: title, + message: message, + scheduledFor: scheduledFor, + ); + } + + @override + Future draftTermsVersion({ + required String version, + required String summary, + required DateTime effectiveFrom, + }) async { + if (falhaNoRascunho != null) throw falhaNoRascunho!; + return super.draftTermsVersion(version: version, summary: summary, effectiveFrom: effectiveFrom); + } +} + +Future _abrirAvisos( + WidgetTester tester, { + Size tamanho = const Size(360, 800), + double escala = 1.0, + String papel = 'admin', + AdminDataSource? dados, +}) async { + await abrirBackoffice( + tester, + tamanho: tamanho, + escalaDeFonte: escala, + dataSource: dados ?? _Dados(), + auth: FakeAdminAuth()..role = papel, + ); + await irPara(tester, 'Avisos'); +} + +Future _abrirTermos( + WidgetTester tester, { + Size tamanho = const Size(360, 800), + double escala = 1.0, + AdminDataSource? dados, +}) async { + await _abrirAvisos(tester, tamanho: tamanho, escala: escala, dados: dados); + await tester.tap(find.byKey(const Key('notices_tab_terms'))); + await tester.pumpAndSettle(); +} + +Future _rolarAte(WidgetTester tester, Finder alvo) async { + if (alvo.evaluate().isEmpty) { + // A lista é preguiçosa e pode estar além do alvo: tenta descer e, se não + // houver mais o que descer, sobe. + try { + await tester.scrollUntilVisible(alvo, 200, scrollable: find.byType(Scrollable).last, maxScrolls: 60); + } on StateError { + await tester.scrollUntilVisible(alvo, -200, scrollable: find.byType(Scrollable).last, maxScrolls: 60); + } + } + await tester.ensureVisible(alvo); + await tester.pumpAndSettle(); +} + +Future _tocar(WidgetTester tester, Finder alvo) async { + await _rolarAte(tester, alvo); + await tester.tap(alvo); + await tester.pumpAndSettle(); +} + +/// Altura da TINTA do botão — ver o cabeçalho de `touch_targets_test.dart`. +double _alturaPintada(WidgetTester tester, Finder botao) => + tester.getSize(find.descendant(of: botao, matching: find.byType(Material)).first).height; + +void _conferirAlvo(WidgetTester tester, Finder botao, String rotulo) { + expect(botao, findsOneWidget, reason: rotulo); + final tamanho = tester.getSize(botao); + expect(tamanho.height, greaterThanOrEqualTo(_alvo), reason: '$rotulo (alvo de toque)'); + expect(tamanho.width, greaterThanOrEqualTo(_alvo), reason: '$rotulo (largura do alvo)'); + expect(_alturaPintada(tester, botao), greaterThanOrEqualTo(_alvo), reason: '$rotulo (altura pintada)'); +} + +/// Cor efetiva (já resolvida pelo tema) com que [alvo] pinta o texto/ícone. +Color _corPintada(WidgetTester tester, Finder alvo) { + final rich = find.descendant(of: alvo, matching: find.byType(RichText)).first; + final cor = tester.renderObject(rich).text.style?.color; + expect(cor, isNotNull, reason: 'sem cor resolvida em $alvo'); + return cor!; +} + +/// Cor da superfície que de fato fica atrás de [alvo]: o `Material` com cor +/// mais próximo (Card ou AlertDialog) — medido, não presumido. +Color _superficie(WidgetTester tester, Finder alvo) { + final material = find.ancestor(of: alvo, matching: find.byWidgetPredicate((w) => w is Material && w.color != null)); + expect(material, findsWidgets, reason: 'sem superfície atrás de $alvo'); + return tester.widget(material.first).color!; +} + +void _conferirContraste(WidgetTester tester, Finder alvo, String rotulo, {Color? superficieEsperada}) { + expect(alvo, findsWidgets, reason: rotulo); + final primeiro = alvo.first; + final fundo = _superficie(tester, primeiro); + if (superficieEsperada != null) expect(fundo, superficieEsperada, reason: '$rotulo: superfície inesperada'); + final razao = contrastOn(_corPintada(tester, primeiro), fundo); + expect(razao, greaterThanOrEqualTo(4.5), reason: '$rotulo: ${razao.toStringAsFixed(2)}:1'); +} + +const _avisosPorStatus = { + NoticeStatus.pending: ('Agendado', Icons.schedule), + NoticeStatus.sending: ('Enviando', Icons.send_outlined), + NoticeStatus.sent: ('Enviado', Icons.check_circle_outline), + NoticeStatus.unknown: ('Resultado desconhecido — confira antes de reenviar', Icons.help_outline), + NoticeStatus.failed: ('Falhou', Icons.error_outline), + NoticeStatus.cancelled: ('Cancelado', Icons.block), +}; + +const _termosPorEstado = { + 'rascunho': ('Rascunho', Icons.edit_note), + 'revisada': ('Revisada', Icons.fact_check_outlined), + 'publicada': ('Publicada', Icons.check_circle_outline), +}; + +void main() { + group('alvos de toque >= 48dp (altura pintada e alvo)', () { + testWidgets('sub-abas Avisos e Termos (admin) têm alvo de 48dp', (tester) async { + await _abrirAvisos(tester); + for (final chave in const ['notices_tab_history', 'notices_tab_terms']) { + final aba = find.byKey(Key(chave)); + expect(aba, findsOneWidget, reason: chave); + // A Tab não tem Material interno: o próprio alvo é o que se pinta. + expect(tester.getSize(aba).height, greaterThanOrEqualTo(_alvo), reason: '$chave (alvo de toque)'); + } + }); + + testWidgets('sub-aba Avisos (coordenador) tem alvo de 48dp', (tester) async { + await _abrirAvisos(tester, papel: 'coordinator'); + expect(tester.getSize(find.byKey(const Key('notices_tab_history'))).height, greaterThanOrEqualTo(_alvo)); + }); + + testWidgets('aba Avisos: Novo aviso, Cancelar do cartão e diálogo de cancelamento', (tester) async { + await _abrirAvisos(tester); + + _conferirAlvo(tester, find.byKey(const Key('notice_new_button')), 'Novo aviso'); + final cancelar = find.byKey(const Key('notice_cancel_av-pending')); + await _rolarAte(tester, cancelar); + _conferirAlvo(tester, cancelar, 'Cancelar (cartão pendente)'); + + await tester.tap(cancelar); + await tester.pumpAndSettle(); + _conferirAlvo(tester, find.widgetWithText(TextButton, 'Voltar'), 'Voltar (diálogo de cancelar aviso)'); + _conferirAlvo(tester, find.byKey(const Key('notice_cancel_confirm')), 'Cancelar aviso (confirmar)'); + }); + + testWidgets('formulário de aviso: Agendar (habilitado e desabilitado), Cancelar, data e hora', (tester) async { + await _abrirAvisos(tester); + await _tocar(tester, find.byKey(const Key('notice_new_button'))); + + _conferirAlvo(tester, find.byKey(const Key('notice_form_cancel')), 'Cancelar (formulário de aviso)'); + expect(tester.widget(find.byKey(const Key('notice_submit'))).onPressed, isNull); + _conferirAlvo(tester, find.byKey(const Key('notice_submit')), 'Agendar (desabilitado)'); + _conferirAlvo(tester, find.byKey(const Key('notice_date')), 'Data'); + _conferirAlvo(tester, find.byKey(const Key('notice_time')), 'Hora'); + + await tester.enterText(find.byKey(const Key('notice_title')), 'Título'); + await tester.enterText(find.byKey(const Key('notice_message')), _texto); + await tester.pumpAndSettle(); + expect(tester.widget(find.byKey(const Key('notice_submit'))).onPressed, isNotNull); + _conferirAlvo(tester, find.byKey(const Key('notice_submit')), 'Agendar (habilitado)'); + // O público é um SegmentedButton: cada segmento é alvo de toque. + for (final rotulo in const ['Todos', 'Só crônicos']) { + final segmento = find.ancestor(of: find.text(rotulo), matching: find.byType(TextButton)).first; + _conferirAlvo(tester, segmento, 'segmento "$rotulo"'); + } + }); + + testWidgets('aba Termos: Nova versão, Marcar como revisado, Publicar (ambos os estados)', (tester) async { + await _abrirTermos(tester); + + _conferirAlvo(tester, find.byKey(const Key('terms_new_button')), 'Nova versão'); + final revisar = find.byKey(const Key('terms_review_rascunho')); + await _rolarAte(tester, revisar); + _conferirAlvo(tester, revisar, 'Marcar como revisado'); + + final desabilitado = find.byKey(const Key('terms_publish_rascunho')); + expect(tester.widget(desabilitado).onPressed, isNull); + _conferirAlvo(tester, desabilitado, 'Publicar (desabilitado)'); + + final habilitado = find.byKey(const Key('terms_publish_revisada')); + await _rolarAte(tester, habilitado); + expect(tester.widget(habilitado).onPressed, isNotNull); + _conferirAlvo(tester, habilitado, 'Publicar (habilitado)'); + }); + + testWidgets('aba Termos: diálogos de revisão e de publicação', (tester) async { + await _abrirTermos(tester); + + await _tocar(tester, find.byKey(const Key('terms_review_rascunho'))); + _conferirAlvo(tester, find.widgetWithText(TextButton, 'Voltar'), 'Voltar (diálogo de revisão)'); + _conferirAlvo(tester, find.byKey(const Key('terms_review_confirm')), 'Confirmar revisão'); + await tester.tap(find.widgetWithText(TextButton, 'Voltar')); + await tester.pumpAndSettle(); + + await _tocar(tester, find.byKey(const Key('terms_publish_revisada'))); + _conferirAlvo(tester, find.widgetWithText(TextButton, 'Voltar'), 'Voltar (diálogo de publicação)'); + _conferirAlvo(tester, find.byKey(const Key('terms_publish_confirm')), 'Publicar (confirmar)'); + }); + + testWidgets('formulário de termos: Criar rascunho (ambos os estados), Cancelar e data', (tester) async { + await _abrirTermos(tester); + await _tocar(tester, find.byKey(const Key('terms_new_button'))); + + _conferirAlvo(tester, find.byKey(const Key('terms_form_cancel')), 'Cancelar (formulário de termos)'); + expect(tester.widget(find.byKey(const Key('terms_submit'))).onPressed, isNull); + _conferirAlvo(tester, find.byKey(const Key('terms_submit')), 'Criar rascunho (desabilitado)'); + _conferirAlvo(tester, find.byKey(const Key('terms_date')), 'Vigência'); + + await tester.enterText(find.byKey(const Key('terms_version')), '2026.9'); + await tester.enterText(find.byKey(const Key('terms_summary')), _texto); + await tester.pumpAndSettle(); + expect(tester.widget(find.byKey(const Key('terms_submit'))).onPressed, isNotNull); + _conferirAlvo(tester, find.byKey(const Key('terms_submit')), 'Criar rascunho (habilitado)'); + }); + }); + + group('sem estouro de layout em celular', () { + for (final tamanho in const [Size(360, 640), Size(320, 568)]) { + for (final escala in const [1.0, 2.0]) { + final cenario = '${tamanho.width.toInt()}x${tamanho.height.toInt()}, fonte a ${(escala * 100).toInt()}%'; + + testWidgets('Avisos (admin): histórico com 6 status e vazio, $cenario', (tester) async { + await _abrirAvisos(tester, tamanho: tamanho, escala: escala); + expect(find.byKey(const Key('notice_row_av-unknown')), findsNothing, reason: 'lista preguiçosa: ainda não construído'); + await percorrerTelaInteira(tester, 'Avisos cheio ($cenario)'); + for (final status in NoticeStatus.values) { + await _rolarAte(tester, find.byKey(Key('notice_row_av-${status.name}'))); + esperarCaberNaLargura(tester, find.byKey(Key('notice_row_av-${status.name}')), 'cartão ${status.name} ($cenario)'); + } + }); + + testWidgets('Avisos (admin): vazio, $cenario', (tester) async { + await _abrirAvisos(tester, tamanho: tamanho, escala: escala, dados: _Dados(vazio: true)); + await _rolarAte(tester, find.text('Nenhum aviso agendado ou enviado ainda.')); + expect(find.text('Nenhum aviso agendado ou enviado ainda.'), findsOneWidget); + await percorrerTelaInteira(tester, 'Avisos vazio ($cenario)'); + }); + + testWidgets('Avisos (coordenador), $cenario', (tester) async { + await _abrirAvisos(tester, tamanho: tamanho, escala: escala, papel: 'coordinator'); + expect(find.byKey(const Key('notices_tab_terms')), findsNothing); + await percorrerTelaInteira(tester, 'Avisos coordenador ($cenario)'); + }); + + testWidgets('formulário de aviso (com erro), $cenario', (tester) async { + await _abrirAvisos( + tester, + tamanho: tamanho, + escala: escala, + dados: _Dados(falhaNoAgendamento: const AdminValidationFailure('O horário do aviso deve ser pelo menos 1 minuto à frente.')), + ); + await _tocar(tester, find.byKey(const Key('notice_new_button'))); + final rolagem = find.descendant(of: find.byType(AlertDialog), matching: find.byType(Scrollable)).first; + await percorrerTelaInteira(tester, 'formulário de aviso ($cenario)', lista: rolagem); + + await tester.enterText(find.byKey(const Key('notice_title')), 'Título'); + await tester.enterText(find.byKey(const Key('notice_message')), _texto); + await tester.pumpAndSettle(); + final agendar = find.byKey(const Key('notice_submit')); + await tester.ensureVisible(agendar); + await tester.tap(agendar); + await tester.pumpAndSettle(); + expect(find.textContaining('pelo menos 1 minuto'), findsOneWidget); + esperarSemEstouroDeLayout(tester, 'formulário de aviso com erro ($cenario)'); + await tester.drag(rolagem, const Offset(0, -2000)); + await tester.pumpAndSettle(); + esperarSemEstouroDeLayout(tester, 'formulário de aviso com erro, rodapé ($cenario)'); + }); + + testWidgets('formulário de aviso com nome de microárea LONGO, $cenario', (tester) async { + await _abrirAvisos( + tester, + tamanho: tamanho, + escala: escala, + dados: _Dados(nomeDaArea: 'Microárea 01 - Conjunto Habitacional Vila Nova Esperança, Ruas das Acácias e dos Ipês Amarelos'), + ); + await _tocar(tester, find.byKey(const Key('notice_new_button'))); + final rolagem = find.descendant(of: find.byType(AlertDialog), matching: find.byType(Scrollable)).first; + await percorrerTelaInteira(tester, 'formulário de aviso, nome longo ($cenario)', lista: rolagem); + }); + + testWidgets('Termos (admin): rascunho, revisada, prazo esgotado e publicada, $cenario', (tester) async { + await _abrirTermos(tester, tamanho: tamanho, escala: escala); + await percorrerTelaInteira(tester, 'Termos ($cenario)'); + for (final id in const ['rascunho', 'revisada', 'esgotada', 'publicada']) { + await _rolarAte(tester, find.byKey(Key('terms_row_$id'))); + esperarCaberNaLargura(tester, find.byKey(Key('terms_row_$id')), 'cartão $id ($cenario)'); + } + }); + + testWidgets('Termos (admin): vazio, $cenario', (tester) async { + await _abrirTermos(tester, tamanho: tamanho, escala: escala, dados: _Dados(vazio: true)); + await _rolarAte(tester, find.text('Nenhuma versão de termos cadastrada.')); + expect(find.text('Nenhuma versão de termos cadastrada.'), findsOneWidget); + await percorrerTelaInteira(tester, 'Termos vazio ($cenario)'); + }); + + testWidgets('formulário de termos (com erro), $cenario', (tester) async { + await _abrirTermos( + tester, + tamanho: tamanho, + escala: escala, + dados: _Dados(falhaNoRascunho: const AdminValidationFailure('Já existe uma versão com este número.')), + ); + await _tocar(tester, find.byKey(const Key('terms_new_button'))); + final rolagem = find.descendant(of: find.byType(AlertDialog), matching: find.byType(Scrollable)).first; + await percorrerTelaInteira(tester, 'formulário de termos ($cenario)', lista: rolagem); + + await tester.enterText(find.byKey(const Key('terms_version')), '2026.9'); + await tester.enterText(find.byKey(const Key('terms_summary')), _texto); + await tester.pumpAndSettle(); + final criar = find.byKey(const Key('terms_submit')); + await tester.ensureVisible(criar); + await tester.tap(criar); + await tester.pumpAndSettle(); + expect(find.textContaining('Já existe uma versão'), findsOneWidget); + esperarSemEstouroDeLayout(tester, 'formulário de termos com erro ($cenario)'); + await tester.drag(rolagem, const Offset(0, -2000)); + await tester.pumpAndSettle(); + esperarSemEstouroDeLayout(tester, 'formulário de termos com erro, rodapé ($cenario)'); + }); + } + } + }); + + group('contraste >= 4,5:1 sobre a superfície onde renderiza', () { + testWidgets('selos de status dos avisos (texto e ícone) sobre o Card', (tester) async { + await _abrirAvisos(tester); + for (final entrada in _avisosPorStatus.entries) { + final (rotulo, icone) = entrada.value; + final cartao = find.byKey(Key('notice_row_av-${entrada.key.name}')); + await _rolarAte(tester, cartao); + _conferirContraste(tester, find.descendant(of: cartao, matching: find.text(rotulo)), 'texto do selo ${entrada.key.name}', + superficieEsperada: AdminColors.surfaceRaised); + _conferirContraste(tester, find.descendant(of: cartao, matching: find.byIcon(icone)), 'ícone do selo ${entrada.key.name}', + superficieEsperada: AdminColors.surfaceRaised); + } + }); + + testWidgets('selos de estado dos termos, aviso de revisão e prazo esgotado sobre o Card', (tester) async { + await _abrirTermos(tester); + for (final entrada in _termosPorEstado.entries) { + final (rotulo, icone) = entrada.value; + final cartao = find.byKey(Key('terms_row_${entrada.key}')); + await _rolarAte(tester, cartao); + _conferirContraste(tester, find.descendant(of: cartao, matching: find.text(rotulo)), 'texto do selo ${entrada.key}', + superficieEsperada: AdminColors.surfaceRaised); + _conferirContraste(tester, find.descendant(of: cartao, matching: find.byIcon(icone)), 'ícone do selo ${entrada.key}', + superficieEsperada: AdminColors.surfaceRaised); + } + final rascunho = find.byKey(const Key('terms_row_rascunho')); + await _rolarAte(tester, rascunho); + _conferirContraste(tester, find.descendant(of: rascunho, matching: find.text('Aguardando revisão jurídica')), + 'Aguardando revisão jurídica', superficieEsperada: AdminColors.surfaceRaised); + final esgotada = find.byKey(const Key('terms_row_esgotada')); + await _rolarAte(tester, esgotada); + _conferirContraste(tester, find.descendant(of: esgotada, matching: find.textContaining('Prazo de 15 dias esgotado')), + 'prazo esgotado', superficieEsperada: AdminColors.surfaceRaised); + }); + + testWidgets('erro, ajuda e textos do formulário de aviso sobre o diálogo', (tester) async { + await _abrirAvisos( + tester, + dados: _Dados(falhaNoAgendamento: const AdminValidationFailure('Mensagem de erro sintética.')), + ); + await _tocar(tester, find.byKey(const Key('notice_new_button'))); + await tester.enterText(find.byKey(const Key('notice_title')), 'Título'); + await tester.enterText(find.byKey(const Key('notice_message')), _texto); + await tester.pumpAndSettle(); + await _tocar(tester, find.byKey(const Key('notice_submit'))); + + final dialogo = find.byType(AlertDialog); + _conferirContraste(tester, find.descendant(of: dialogo, matching: find.text('Mensagem de erro sintética.')), 'texto de erro'); + _conferirContraste(tester, find.descendant(of: dialogo, matching: find.byIcon(Icons.error_outline)), 'ícone de erro'); + _conferirContraste( + tester, find.descendant(of: dialogo, matching: find.text('O texto do aviso não é gravado na trilha de auditoria.')), 'texto de ajuda'); + _conferirContraste(tester, find.descendant(of: dialogo, matching: find.textContaining('Envio em ')).last, 'texto de envio'); + }); + + testWidgets('erro, ajuda e textos do formulário de termos sobre o diálogo', (tester) async { + await _abrirTermos( + tester, + dados: _Dados(falhaNoRascunho: const AdminValidationFailure('Mensagem de erro sintética.')), + ); + await _tocar(tester, find.byKey(const Key('terms_new_button'))); + await tester.enterText(find.byKey(const Key('terms_version')), '2026.9'); + await tester.enterText(find.byKey(const Key('terms_summary')), _texto); + await tester.pumpAndSettle(); + await _tocar(tester, find.byKey(const Key('terms_submit'))); + + final dialogo = find.byType(AlertDialog); + _conferirContraste(tester, find.descendant(of: dialogo, matching: find.text('Mensagem de erro sintética.')), 'texto de erro'); + _conferirContraste(tester, find.descendant(of: dialogo, matching: find.byIcon(Icons.error_outline)), 'ícone de erro'); + _conferirContraste(tester, find.descendant(of: dialogo, matching: find.text('A vigência exige 15 dias de antecedência para publicar.')), + 'texto de ajuda da vigência'); + // helperText do campo Resumo: renderizado no diálogo, fora do preenchimento. + _conferirContraste( + tester, find.descendant(of: dialogo, matching: find.text('Resumo público exibido no cartão do paciente.')), 'helperText do Resumo'); + }); + }); + + group('semântica', () { + testWidgets('premissa: as telas novas não têm botão só-ícone (todo botão tem rótulo de texto)', (tester) async { + // Se alguém introduzir um IconButton aqui, este teste força a decisão: + // dar-lhe tooltip e cobri-lo nos testes de alvo e de semântica. + await _abrirTermos(tester); + expect(find.byType(IconButton), findsNothing, reason: 'aba Termos'); + await tester.tap(find.byKey(const Key('notices_tab_history'))); + await tester.pumpAndSettle(); + expect(find.byType(IconButton), findsNothing, reason: 'aba Avisos'); + await _tocar(tester, find.byKey(const Key('notice_new_button'))); + expect(find.byType(IconButton), findsNothing, reason: 'formulário de aviso'); + }); + + testWidgets('selos de aviso têm ícone E texto (não dependem só de cor)', (tester) async { + await _abrirAvisos(tester); + for (final entrada in _avisosPorStatus.entries) { + final (rotulo, icone) = entrada.value; + final cartao = find.byKey(Key('notice_row_av-${entrada.key.name}')); + await _rolarAte(tester, cartao); + expect(find.descendant(of: cartao, matching: find.text(rotulo)), findsOneWidget, reason: 'texto ${entrada.key.name}'); + expect(find.descendant(of: cartao, matching: find.byIcon(icone)), findsOneWidget, reason: 'ícone ${entrada.key.name}'); + } + }); + + testWidgets('selos de termos têm ícone E texto (não dependem só de cor)', (tester) async { + await _abrirTermos(tester); + for (final entrada in _termosPorEstado.entries) { + final (rotulo, icone) = entrada.value; + final cartao = find.byKey(Key('terms_row_${entrada.key}')); + await _rolarAte(tester, cartao); + expect(find.descendant(of: cartao, matching: find.text(rotulo)), findsOneWidget, reason: 'texto ${entrada.key}'); + expect(find.descendant(of: cartao, matching: find.byIcon(icone)), findsOneWidget, reason: 'ícone ${entrada.key}'); + } + }); + + testWidgets('campos dos formulários têm labelText', (tester) async { + await _abrirAvisos(tester); + await _tocar(tester, find.byKey(const Key('notice_new_button'))); + for (final (chave, rotulo) in const [('notice_title', 'Título'), ('notice_message', 'Mensagem')]) { + expect(tester.widget(find.byKey(Key(chave))).decoration?.labelText, rotulo, reason: chave); + } + expect(find.text('Microárea'), findsWidgets); + await tester.tap(find.byKey(const Key('notice_form_cancel'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('notices_tab_terms'))); + await tester.pumpAndSettle(); + await _tocar(tester, find.byKey(const Key('terms_new_button'))); + for (final (chave, rotulo) in const [('terms_version', 'Versão'), ('terms_summary', 'Resumo')]) { + expect(tester.widget(find.byKey(Key(chave))).decoration?.labelText, rotulo, reason: chave); + } + }); + + testWidgets('Publicar desabilitado expõe o motivo ao leitor de tela, sem duplicar o anúncio', (tester) async { + final semantica = tester.ensureSemantics(); + await _abrirTermos(tester); + + for (final (id, motivo) in const [ + ('rascunho', 'Aguardando revisão jurídica'), + ('esgotada', 'Prazo de 15 dias esgotado'), + ]) { + final publicar = find.byKey(Key('terms_publish_$id')); + await _rolarAte(tester, publicar); + final no = tester.getSemantics(publicar); + final dados = no.getSemanticsData(); + expect(dados.label, contains('Publicar'), reason: id); + expect(dados.label, contains(motivo), reason: '$id: o motivo precisa estar no nó do botão'); + expect(dados.flagsCollection.isEnabled, ui.Tristate.isFalse, reason: id); + // Sem duplicar: o texto visível do motivo não vira um segundo nó. + expect( + find.semantics.byLabel(RegExp(motivo)).evaluate().length, + 1, + reason: '$id: motivo anunciado mais de uma vez', + ); + } + + final habilitado = find.byKey(const Key('terms_publish_revisada')); + await _rolarAte(tester, habilitado); + expect(tester.getSemantics(habilitado).getSemanticsData().label, 'Publicar'); + semantica.dispose(); + }); + }); +} diff --git a/apps/admin/test/notices_backend_data_source_test.dart b/apps/admin/test/notices_backend_data_source_test.dart new file mode 100644 index 00000000..94df6f9a --- /dev/null +++ b/apps/admin/test/notices_backend_data_source_test.dart @@ -0,0 +1,181 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_admin/core/data/admin_data_source.dart'; +import 'package:sinalacs_admin/core/data/backend_admin_data_source.dart'; +import 'package:sinalacs_client/sinalacs_client.dart' as api; + +import 'support/fake_admin_auth.dart'; + +/// Avisos e termos (#46) sobre o `EndpointAdmin` gerado, com chamador falso. +/// Dados sintéticos. +BackendAdminDataSource _fonte( + FakeEndpointCaller caller, { + DateTime? expiresAt, + DateTime Function()? now, +}) => BackendAdminDataSource( + api.EndpointAdmin(caller), + accessToken: 'token-de-teste', + expiresAt: expiresAt, + now: now, +); + +api.TermsVersionEntry _termo({DateTime? revisado, DateTime? publicado}) => + api.TermsVersionEntry( + id: 't1', + version: '2026.2', + summary: 'Resumo sintético', + effectiveFrom: DateTime.utc(2026, 11, 1), + createdAt: DateTime.utc(2026, 10, 1), + legallyReviewedAt: revisado, + publishedAt: publicado, + ); + +void main() { + test('histórico: mapeia status e público por nome e repassa o filtro', () async { + final caller = FakeEndpointCaller( + result: api.NoticeHistoryPage(entries: [ + api.NoticeHistoryEntry( + id: 'n1', + microAreaId: 'ma-1', + microAreaName: 'Microárea A', + audience: 'chronic', + title: 'T', + status: api.ScheduledNoticeStatus.sent, + scheduledFor: DateTime.utc(2026, 10, 14, 15), + createdByLabel: 'ADM-001', + sentAt: DateTime.utc(2026, 10, 14, 15, 1), + recipients: 3, + accepted: 2, + ), + ]), + ); + final r = await _fonte(caller).fetchNoticeHistory( + microAreaId: 'ma-1', + limit: 10, + offset: 20, + ); + expect(r.single.status, NoticeStatus.sent); + expect(r.single.audience, NoticeAudience.chronic); + expect(r.single.accepted, 2); + expect(r.single.recipients, 3); + expect(caller.lastArgs, containsPair('accessToken', 'token-de-teste')); + expect(caller.lastArgs, containsPair('microAreaId', 'ma-1')); + expect(caller.lastArgs, containsPair('limit', 10)); + expect(caller.lastArgs, containsPair('offset', 20)); + }); + + test('histórico sem argumentos envia limit 50 e offset 0', () async { + final caller = FakeEndpointCaller(result: api.NoticeHistoryPage(entries: [])); + await _fonte(caller).fetchNoticeHistory(); + expect(caller.lastArgs, containsPair('limit', 50)); + expect(caller.lastArgs, containsPair('offset', 0)); + }); + + test('scheduleNotice envia o público pelo nome e a data em UTC', () async { + final caller = FakeEndpointCaller(result: 'novo-id'); + final local = DateTime(2026, 10, 20, 9, 30); + final id = await _fonte(caller).scheduleNotice( + microAreaId: 'ma-1', + audience: NoticeAudience.everyone, + title: 'Título', + message: 'Texto', + scheduledFor: local, + ); + expect(id, 'novo-id'); + expect(caller.lastArgs, containsPair('audience', 'everyone')); + final enviada = caller.lastArgs!['scheduledFor'] as DateTime; + expect(enviada.isUtc, isTrue); + expect(enviada, local.toUtc()); + }); + + test('draftTermsVersion envia a vigência em UTC e mapeia o termo', () async { + final caller = FakeEndpointCaller(result: _termo()); + final t = await _fonte(caller).draftTermsVersion( + version: '2026.2', + summary: 'Resumo', + effectiveFrom: DateTime(2026, 11, 1, 8), + ); + expect((caller.lastArgs!['effectiveFrom'] as DateTime).isUtc, isTrue); + expect(t.state, TermsVersionState.draft); + }); + + test('termos: mapeia revisão, publicação e o estado', () async { + final rev = DateTime.utc(2026, 10, 5); + final pub = DateTime.utc(2026, 10, 6); + final caller = FakeEndpointCaller( + result: [_termo(), _termo(revisado: rev), _termo(revisado: rev, publicado: pub)], + ); + final r = await _fonte(caller).fetchTermsVersions(); + expect(r.map((t) => t.state), [ + TermsVersionState.draft, + TermsVersionState.reviewed, + TermsVersionState.published, + ]); + expect(r[1].legallyReviewedAt, rev); + expect(r[2].publishedAt, pub); + }); + + test('markTermsReviewed e publishTermsVersion mapeiam o termo', () async { + final rev = DateTime.utc(2026, 10, 5); + final c1 = FakeEndpointCaller(result: _termo(revisado: rev)); + expect((await _fonte(c1).markTermsReviewed('t1')).state, TermsVersionState.reviewed); + expect(c1.lastArgs, containsPair('id', 't1')); + final c2 = FakeEndpointCaller(result: _termo(revisado: rev, publicado: rev)); + expect((await _fonte(c2).publishTermsVersion('t1')).state, TermsVersionState.published); + }); + + group('recusa de validação vira AdminValidationFailure com a mensagem', () { + final erro = api.AdminInvalidRequestException(message: 'A versão ainda não foi revisada juridicamente.'); + final Map Function(BackendAdminDataSource)> escritas = { + 'scheduleNotice': (f) => f.scheduleNotice( + microAreaId: 'm', + audience: NoticeAudience.chronic, + title: 't', + message: 'x', + scheduledFor: DateTime.utc(2026, 12, 1), + ), + 'cancelScheduledNotice': (f) => f.cancelScheduledNotice('n1'), + 'draftTermsVersion': (f) => f.draftTermsVersion( + version: 'v', + summary: 's', + effectiveFrom: DateTime.utc(2026, 12, 1), + ), + 'markTermsReviewed': (f) => f.markTermsReviewed('t1'), + 'publishTermsVersion': (f) => f.publishTermsVersion('t1'), + }; + escritas.forEach((nome, chamar) { + test(nome, () async { + await expectLater( + chamar(_fonte(FakeEndpointCaller(error: erro))), + throwsA(isA().having((e) => e.message, 'message', contains('revisada'))), + ); + }); + }); + }); + + test('recusa por papel vira AdminDataFailure em leitura e escrita', () async { + final erro = api.AlertPermissionException(message: 'x'); + await expectLater( + _fonte(FakeEndpointCaller(error: erro)).fetchTermsVersions(), + throwsA(isA().having((e) => e, 'tipo', isNot(isA()))), + ); + await expectLater( + _fonte(FakeEndpointCaller(error: erro)).fetchNoticeHistory(), + throwsA(isA()), + ); + await expectLater( + _fonte(FakeEndpointCaller(error: erro)).publishTermsVersion('t1'), + throwsA(isA().having((e) => e, 'tipo', isNot(isA()))), + ); + }); + + test('sessão vencida vira AdminSessionExpired em leitura e escrita', () async { + final erro = api.AlertPermissionException(message: 'token inválido ou expirado'); + BackendAdminDataSource vencida() => _fonte( + FakeEndpointCaller(error: erro), + expiresAt: DateTime.utc(2026, 1, 1, 12), + now: () => DateTime.utc(2026, 1, 1, 12, 0, 1), + ); + await expectLater(vencida().fetchNoticeHistory(), throwsA(isA())); + await expectLater(vencida().cancelScheduledNotice('n1'), throwsA(isA())); + }); +} diff --git a/apps/admin/test/notices_data_source_test.dart b/apps/admin/test/notices_data_source_test.dart new file mode 100644 index 00000000..5a9890b1 --- /dev/null +++ b/apps/admin/test/notices_data_source_test.dart @@ -0,0 +1,135 @@ +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'; + +final _agora = DateTime(2026, 10, 14, 12); + +Matcher _recusa(String msg) => + throwsA(isA().having((e) => e.message, 'message', msg)); + +void main() { + late MockAdminDataSource fonte; + setUp(() => fonte = MockAdminDataSource(now: () => _agora)); + + test('agendar a menos de 1 minuto recusa; a mais de 1 minuto entra como pending', () async { + await expectLater( + fonte.scheduleNotice(microAreaId: 'ma-12', audience: NoticeAudience.everyone, + title: 'T', message: 'M', scheduledFor: _agora.add(const Duration(seconds: 30))), + _recusa('O horário do aviso deve ser pelo menos 1 minuto à frente.'), + ); + final id = await fonte.scheduleNotice(microAreaId: 'ma-12', audience: NoticeAudience.chronic, + title: 'T', message: 'M', scheduledFor: _agora.add(const Duration(hours: 2))); + final h = await fonte.fetchNoticeHistory(); + expect(h.single.id, id); + expect(h.single.status, NoticeStatus.pending); + }); + + test('cancelar só vale para pending', () async { + final id = await fonte.scheduleNotice(microAreaId: 'ma-12', audience: NoticeAudience.everyone, + title: 'T', message: 'M', scheduledFor: _agora.add(const Duration(hours: 2))); + await fonte.cancelScheduledNotice(id); + expect((await fonte.fetchNoticeHistory()).single.status, NoticeStatus.cancelled); + await expectLater( + fonte.cancelScheduledNotice(id), _recusa('Só avisos pendentes podem ser cancelados.')); + await expectLater(fonte.cancelScheduledNotice('nada'), _recusa('Aviso não encontrado.')); + }); + + test('termos: publicar sem revisão recusa; com revisão e 15 dias aceita; 14 dias recusa', () async { + final t = await fonte.draftTermsVersion( + version: '2099.1', summary: '[TEXTO PLACEHOLDER]', effectiveFrom: _agora.add(const Duration(days: 15))); + await expectLater(fonte.publishTermsVersion(t.id), _recusa('A versão ainda não foi revisada juridicamente.')); + await fonte.markTermsReviewed(t.id); + expect((await fonte.publishTermsVersion(t.id)).publishedAt, _agora); + + final fonte2 = MockAdminDataSource(now: () => _agora); + final curto = await fonte2.draftTermsVersion( + version: '2099.2', summary: '[TEXTO PLACEHOLDER]', effectiveFrom: _agora.add(const Duration(days: 14))); + await fonte2.markTermsReviewed(curto.id); + await expectLater(fonte2.publishTermsVersion(curto.id), _recusa('A vigência exige 15 dias de antecedência.')); + }); + + test('bordas: 1 minuto exato aceita, 59 s recusa; microárea inexistente e limites de texto', () async { + await fonte.scheduleNotice(microAreaId: 'ma-12', audience: NoticeAudience.everyone, + title: 'T', message: 'M', scheduledFor: _agora.add(const Duration(minutes: 1))); + await expectLater( + fonte.scheduleNotice(microAreaId: 'ma-12', audience: NoticeAudience.everyone, + title: 'T', message: 'M', scheduledFor: _agora.add(const Duration(seconds: 59))), + _recusa('O horário do aviso deve ser pelo menos 1 minuto à frente.'), + ); + await expectLater( + fonte.scheduleNotice(microAreaId: 'nao-existe', audience: NoticeAudience.everyone, + title: 'T', message: 'M', scheduledFor: _agora.add(const Duration(hours: 1))), + throwsA(isA().having((e) => e.message, 'message', 'Microárea não encontrada.')), + ); + await expectLater( + fonte.scheduleNotice(microAreaId: 'ma-12', audience: NoticeAudience.everyone, + title: 'x' * 61, message: 'M', scheduledFor: _agora.add(const Duration(hours: 1))), + throwsA(isA()), + ); + await expectLater( + fonte.scheduleNotice(microAreaId: 'ma-12', audience: NoticeAudience.everyone, + title: 'T', message: ' ', scheduledFor: _agora.add(const Duration(hours: 1))), + throwsA(isA()), + ); + }); + + test('termos: versão única, revisão única, publicação única', () async { + final t = await fonte.draftTermsVersion( + version: '2099.1', summary: 'S', effectiveFrom: _agora.add(const Duration(days: 20))); + await expectLater( + fonte.draftTermsVersion(version: '2099.1', summary: 'S', effectiveFrom: _agora.add(const Duration(days: 20))), + throwsA(isA()), + ); + await fonte.markTermsReviewed(t.id); + await expectLater(fonte.markTermsReviewed(t.id), throwsA(isA())); + await fonte.publishTermsVersion(t.id); + await expectLater(fonte.publishTermsVersion(t.id), throwsA(isA())); + expect((await fonte.fetchTermsVersions()).single.state, TermsVersionState.published); + }); + + Future agenda(String area, Duration em, {String titulo = 'T', String msg = 'M'}) => + fonte.scheduleNotice( + microAreaId: area, audience: NoticeAudience.everyone, title: titulo, message: msg, + scheduledFor: _agora.add(em)); + + test('histórico: ordena por scheduledFor desc, filtra por microárea e pagina', () async { + final a = await agenda('ma-12', const Duration(hours: 1)); + final b = await agenda('ma-07', const Duration(hours: 3)); + final c = await agenda('ma-12', const Duration(hours: 2)); + expect((await fonte.fetchNoticeHistory()).map((x) => x.id), [b, c, a]); + expect((await fonte.fetchNoticeHistory(microAreaId: 'ma-12')).map((x) => x.id), [c, a]); + expect((await fonte.fetchNoticeHistory(limit: 2)).map((x) => x.id), [b, c]); + expect((await fonte.fetchNoticeHistory(limit: 2, offset: 1)).map((x) => x.id), [c, a]); + expect((await fonte.fetchNoticeHistory(offset: 3)), isEmpty); + expect((await fonte.fetchNoticeHistory(offset: 10)), isEmpty); + expect((await fonte.fetchNoticeHistory(microAreaId: 'ma-03')), isEmpty); + }); + + test('limites do aviso: 60/240 aceitam, 61/241 recusam, só espaços recusa, trim aplicado', () async { + await agenda('ma-12', const Duration(hours: 1), titulo: 'x' * 60, msg: 'y' * 240); + await expectLater(agenda('ma-12', const Duration(hours: 1), titulo: 'x' * 61), + _recusa('O título pode ter no máximo 60 caracteres.')); + await expectLater(agenda('ma-12', const Duration(hours: 1), msg: 'y' * 241), + _recusa('A mensagem pode ter no máximo 240 caracteres.')); + await expectLater(agenda('ma-12', const Duration(hours: 1), titulo: ' '), + _recusa('Informe o título e a mensagem do aviso.')); + await expectLater(agenda('ma-12', const Duration(hours: 1), msg: ' '), + _recusa('Informe o título e a mensagem do aviso.')); + final id = await agenda('ma-07', const Duration(hours: 5), titulo: ' ${'x' * 60} '); + final h = await fonte.fetchNoticeHistory(microAreaId: 'ma-07'); + expect(h.single.id, id); + expect(h.single.title, 'x' * 60); + }); + + test('rascunho de termos: resumo 280/281, versão vazia e vigência passada', () async { + final futuro = _agora.add(const Duration(days: 20)); + await fonte.draftTermsVersion(version: 'a', summary: 'x' * 280, effectiveFrom: futuro); + await expectLater(fonte.draftTermsVersion(version: 'b', summary: 'x' * 281, effectiveFrom: futuro), + _recusa('O resumo deve ter de 1 a 280 caracteres.')); + await expectLater(fonte.draftTermsVersion(version: ' ', summary: 'S', effectiveFrom: futuro), + _recusa('Informe o número da versão.')); + await expectLater( + fonte.draftTermsVersion(version: 'c', summary: 'S', effectiveFrom: _agora.subtract(const Duration(days: 1))), + _recusa('A vigência deve ser futura.')); + }); +} diff --git a/apps/admin/test/notices_screen_test.dart b/apps/admin/test/notices_screen_test.dart new file mode 100644 index 00000000..7b3ac1f3 --- /dev/null +++ b/apps/admin/test/notices_screen_test.dart @@ -0,0 +1,283 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_admin/app/admin_async_states.dart'; +import 'package:sinalacs_admin/app/admin_theme.dart'; +import 'package:sinalacs_admin/app/notices_screen.dart'; +import 'package:sinalacs_admin/core/data/admin_data_source.dart'; +import 'package:sinalacs_admin/core/data/mock_admin_data_source.dart'; + +import 'support/failing_admin_data_source.dart'; +import 'support/layout_harness.dart'; + +/// Aba "Avisos" (#46). Dados sintéticos. +final _agora = DateTime(2026, 10, 14, 9); + +/// Devolve itens forjados (ex.: enviado) e conta/segura os cancelamentos. +class _FakeAvisos extends MockAdminDataSource { + _FakeAvisos(this.itens) : super(now: () => _agora); + + final List itens; + final List cancelados = []; + Completer? portao; + Object? erroNoCancelamento; + Object? erroNaCarga; + final List<(String, String)> acessos = []; + final List<(int, int)> leituras = []; + + @override + Future recordAccess({required String actionType, required String resourceType}) async { + acessos.add((actionType, resourceType)); + } + + @override + Future> fetchNoticeHistory({String? microAreaId, int limit = 50, int offset = 0}) async => + _ler(limit, offset); + + Future> _ler(int limit, int offset) async { + leituras.add((limit, offset)); + final erro = erroNaCarga; + if (erro != null) throw erro; + return [for (final i in itens) i]; + } + + @override + Future cancelScheduledNotice(String id) async { + cancelados.add(id); + final erro = erroNoCancelamento; + if (erro != null) throw erro; + if (portao != null) await portao!.future; + final i = itens.indexWhere((a) => a.id == id); + final a = itens[i]; + itens[i] = _item(id, status: NoticeStatus.cancelled, titulo: a.title); + } +} + +NoticeHistoryItem _item( + String id, { + required NoticeStatus status, + String titulo = 'Vacinação na quinta', + int? recipients, + int? accepted, + String? failureReason, +}) => + NoticeHistoryItem( + id: id, + microAreaId: 'ma-1', + microAreaName: 'Microárea 01', + audience: NoticeAudience.chronic, + title: titulo, + status: status, + scheduledFor: DateTime(2026, 10, 15, 8, 30), + createdByLabel: 'ADM-001', + recipients: recipients, + accepted: accepted, + failureReason: failureReason, + ); + +Future _abrir(WidgetTester tester, AdminDataSource fonte, {String role = 'admin', VoidCallback? aoVencer, bool assentar = true}) async { + redimensionar(tester, const Size(1024, 900)); + addTearDown(tester.view.reset); + await tester.pumpWidget( + MaterialApp( + theme: buildAdminTheme(), + home: Scaffold( + body: AdminSessionScope( + aoVencer: aoVencer ?? () {}, + child: NoticesScreen(dataSource: fonte, role: role, now: () => _agora), + ), + ), + ), + ); + // Sessão vencida deixa um spinner girando: pumpAndSettle nunca assentaria. + if (assentar) { + await tester.pumpAndSettle(); + } else { + await tester.pump(); + await tester.pump(); + } +} + +Future _tocar(WidgetTester tester, Finder alvo) async { + await tester.ensureVisible(alvo); + await tester.pumpAndSettle(); + await tester.tap(alvo); + await tester.pumpAndSettle(); +} + +void main() { + test('noticeStatusLabel cobre os 6 status', () { + expect(noticeStatusLabel(NoticeStatus.pending), 'Agendado'); + expect(noticeStatusLabel(NoticeStatus.sending), 'Enviando'); + expect(noticeStatusLabel(NoticeStatus.sent), 'Enviado'); + expect(noticeStatusLabel(NoticeStatus.unknown), 'Resultado desconhecido — confira antes de reenviar'); + expect(noticeStatusLabel(NoticeStatus.failed), 'Falhou'); + expect(noticeStatusLabel(NoticeStatus.cancelled), 'Cancelado'); + }); + + testWidgets('coordenador não vê a sub-aba Termos; admin vê', (tester) async { + await _abrir(tester, MockAdminDataSource(now: () => _agora), role: 'coordinator'); + expect(find.byKey(const Key('notices_tab_history')), findsOneWidget); + expect(find.byKey(const Key('notices_tab_terms')), findsNothing); + await _abrir(tester, MockAdminDataSource(now: () => _agora), role: 'admin'); + expect(find.byKey(const Key('notices_tab_terms')), findsOneWidget); + expect(find.byKey(const Key('notice_new_button')), findsOneWidget); + await tester.tap(find.byKey(const Key('notices_tab_terms'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('terms_new_button')), findsOneWidget); + }); + + testWidgets('histórico: título, microárea, status e contagens; a mensagem nunca aparece', (tester) async { + final fonte = _FakeAvisos([]); + final area = (await fonte.fetchMicroAreas()).first; + await fonte.scheduleNotice( + microAreaId: area.id, + audience: NoticeAudience.everyone, + title: 'Aviso do mock', + message: 'SEGREDO-NAO-EXIBIR', + scheduledFor: _agora.add(const Duration(hours: 2)), + ); + fonte.itens.add(_item('n-sent', status: NoticeStatus.sent, recipients: 3, accepted: 2)); + fonte.itens.add(_item('n-fail', status: NoticeStatus.failed, titulo: 'Outro', failureReason: 'FCM indisponível')); + await _abrir(tester, fonte); + + expect(find.byKey(const Key('notice_row_n-sent')), findsOneWidget); + expect(find.text('Vacinação na quinta'), findsOneWidget); + expect(find.textContaining('Microárea 01'), findsWidgets); + expect(find.text('Enviado'), findsOneWidget); + expect(find.textContaining('2 de 3'), findsOneWidget); + expect(find.textContaining('15/10/2026 08:30'), findsWidgets); + expect(find.textContaining('FCM indisponível'), findsOneWidget); + expect(find.textContaining('ADM-001'), findsWidgets); + expect(find.textContaining('SEGREDO'), findsNothing); + }); + + testWidgets('histórico do mock puro não expõe a mensagem', (tester) async { + final fonte = MockAdminDataSource(now: () => _agora); + final area = (await fonte.fetchMicroAreas()).first; + await fonte.scheduleNotice( + microAreaId: area.id, + audience: NoticeAudience.everyone, + title: 'Aviso do mock', + message: 'SEGREDO-NAO-EXIBIR', + scheduledFor: _agora.add(const Duration(hours: 2)), + ); + await _abrir(tester, fonte); + expect(find.text('Aviso do mock'), findsOneWidget); + expect(find.text('Agendado'), findsOneWidget); + expect(find.textContaining('SEGREDO'), findsNothing); + }); + + testWidgets('pendente tem Cancelar com confirmação e atualiza o selo; enviado não tem', (tester) async { + final fonte = _FakeAvisos([ + _item('n-pend', status: NoticeStatus.pending), + _item('n-sent', status: NoticeStatus.sent, recipients: 3, accepted: 2), + ]); + await _abrir(tester, fonte); + + expect(find.byKey(const Key('notice_cancel_n-sent')), findsNothing); + await _tocar(tester, find.byKey(const Key('notice_cancel_n-pend'))); + // Diálogo de confirmação: nada foi chamado ainda. + expect(fonte.cancelados, isEmpty); + await _tocar(tester, find.byKey(const Key('notice_cancel_confirm'))); + + expect(fonte.cancelados, ['n-pend']); + expect(find.text('Aviso cancelado.'), findsOneWidget); + expect(find.text('Cancelado'), findsOneWidget); + expect(find.byKey(const Key('notice_cancel_n-pend')), findsNothing); + }); + + testWidgets('duplo toque em confirmar chama o cancelamento uma única vez', (tester) async { + final fonte = _FakeAvisos([_item('n-pend', status: NoticeStatus.pending)])..portao = Completer(); + await _abrir(tester, fonte); + + await _tocar(tester, find.byKey(const Key('notice_cancel_n-pend'))); + final confirmar = find.byKey(const Key('notice_cancel_confirm')); + await tester.tap(confirmar); + await tester.pump(); + // O diálogo fecha; um segundo toque no botão da linha é ignorado (desabilitado). + await tester.pump(const Duration(milliseconds: 400)); + final botao = tester.widget(find.byKey(const Key('notice_cancel_n-pend'))); + expect(botao.onPressed, isNull); + expect(fonte.cancelados, ['n-pend']); + + fonte.portao!.complete(); + await tester.pumpAndSettle(); + expect(fonte.cancelados, ['n-pend']); + }); + + testWidgets('AdminValidationFailure no cancelamento mostra a mensagem do servidor', (tester) async { + final fonte = _FakeAvisos([_item('n-pend', status: NoticeStatus.pending)]) + ..erroNoCancelamento = const AdminValidationFailure('Só avisos pendentes podem ser cancelados.'); + await _abrir(tester, fonte); + await _tocar(tester, find.byKey(const Key('notice_cancel_n-pend'))); + await _tocar(tester, find.byKey(const Key('notice_cancel_confirm'))); + expect(find.text('Só avisos pendentes podem ser cancelados.'), findsOneWidget); + }); + + testWidgets('selos de unknown, failed e cancelled com texto exato e ícone', (tester) async { + final fonte = _FakeAvisos([ + _item('n-unk', status: NoticeStatus.unknown), + _item('n-fail', status: NoticeStatus.failed), + _item('n-can', status: NoticeStatus.cancelled), + _item('n-sent', status: NoticeStatus.sent), + ]); + await _abrir(tester, fonte); + expect(find.text('Resultado desconhecido — confira antes de reenviar'), findsOneWidget); + expect(find.text('Falhou'), findsOneWidget); + expect(find.text('Cancelado'), findsOneWidget); + for (final (id, icone) in [ + ('n-unk', Icons.help_outline), + ('n-fail', Icons.error_outline), + ('n-can', Icons.block), + ('n-sent', Icons.check_circle_outline), + ]) { + expect(find.descendant(of: find.byKey(Key('notice_row_$id')), matching: find.byIcon(icone)), findsOneWidget, reason: id); + } + }); + + testWidgets('a carga registra o acesso e lê o histórico com limit 50, offset 0', (tester) async { + final fonte = _FakeAvisos([]); + await _abrir(tester, fonte); + expect(fonte.acessos, [('view', 'community_notices')]); + expect(fonte.leituras, [(50, 0)]); + }); + + testWidgets('AdminSessionExpired na carga chama o callback da sessão', (tester) async { + var chamadas = 0; + final fonte = _FakeAvisos([])..erroNaCarga = const AdminSessionExpired(); + await _abrir(tester, fonte, aoVencer: () => chamadas++, assentar: false); + expect(chamadas, greaterThanOrEqualTo(1)); + }); + + testWidgets('AdminSessionExpired no cancelamento chama o callback da sessão', (tester) async { + var chamadas = 0; + final fonte = _FakeAvisos([_item('n-pend', status: NoticeStatus.pending)]) + ..erroNoCancelamento = const AdminSessionExpired(); + await _abrir(tester, fonte, aoVencer: () => chamadas++); + await _tocar(tester, find.byKey(const Key('notice_cancel_n-pend'))); + await _tocar(tester, find.byKey(const Key('notice_cancel_confirm'))); + expect(chamadas, 1); + }); + + testWidgets('histórico vazio mostra mensagem própria', (tester) async { + await _abrir(tester, MockAdminDataSource(now: () => _agora)); + expect(find.text('Nenhum aviso agendado ou enviado ainda.'), findsOneWidget); + }); + + testWidgets('falha de leitura mostra erro com Tentar novamente', (tester) async { + final fonte = FailingAdminDataSource(inner: MockAdminDataSource(now: () => _agora))..failNextRecordAccess = true; + await _abrir(tester, fonte); + expect(find.text('Não foi possível carregar os avisos.'), findsOneWidget); + await _tocar(tester, find.text('Tentar novamente')); + expect(find.text('Nenhum aviso agendado ou enviado ainda.'), findsOneWidget); + }); + + testWidgets('o destino Avisos aparece na navegação do app completo', (tester) async { + await abrirBackoffice(tester, tamanho: const Size(1024, 900), dataSource: MockAdminDataSource()); + await irPara(tester, 'Avisos'); + expect(find.byKey(const Key('notices_tab_history')), findsOneWidget); + expect(find.byKey(const Key('notice_new_button')), findsOneWidget); + }); +} diff --git a/apps/admin/test/overdue_red_banner_test.dart b/apps/admin/test/overdue_red_banner_test.dart new file mode 100644 index 00000000..c3cbe639 --- /dev/null +++ b/apps/admin/test/overdue_red_banner_test.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_admin/app/admin_theme.dart'; +import 'package:sinalacs_admin/app/app.dart'; +import 'package:sinalacs_admin/app/monitoring_controller.dart'; +import 'package:sinalacs_admin/core/data/admin_data_source.dart'; + +import 'support/scripted_pulse_data_source.dart'; + +/// Contador "N vermelhos vencidos" (#44, F2) e o filtro de status sem +/// "Escalonado" (F1). +/// +/// O contador vem do `overdueRed` do último pulso: é ele que mostra ao +/// coordenador que existe vermelho vencido mesmo quando o filtro da lista o +/// esconde. Não é botão. +void main() { + late DateTime agora; + late ScriptedPulseDataSource fonte; + final faixa = find.byKey(const Key('overdue_red_banner')); + + setUp(() { + agora = DateTime(2026, 10, 11, 9); + fonte = ScriptedPulseDataSource(now: () => agora); + }); + + Future montar(WidgetTester tester, Widget Function(MonitoringController) tela) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(800, 2400); + addTearDown(tester.view.reset); + final monitoramento = MonitoringController(dataSource: fonte, now: () => agora); + await tester.pumpWidget(MaterialApp(theme: buildAdminTheme(), home: Scaffold(body: tela(monitoramento)))); + await tester.pump(); + await tester.pump(); + return monitoramento; + } + + Future ciclo(WidgetTester tester) async { + agora = agora.add(const Duration(seconds: 30)); + await tester.pump(const Duration(seconds: 30)); + await tester.pump(); + await tester.pump(); + } + + Future encerrar(WidgetTester tester, MonitoringController m) async { + await tester.pumpWidget(const SizedBox()); + m.dispose(); + } + + for (final (nome, tela) in <(String, Widget Function(ScriptedPulseDataSource, MonitoringController))>[ + ('Painel', (f, m) => IndicatorsScreen(dataSource: f, monitoring: m)), + ('Alertas', (f, m) => AlertsScreen(dataSource: f, monitoring: m)), + ]) { + testWidgets('$nome: contador aparece com overdueRed=2 e some com 0', (tester) async { + fonte.overdueRed = 2; + final m = await montar(tester, (m) => tela(fonte, m)); + + expect(faixa, findsOneWidget); + expect(find.descendant(of: faixa, matching: find.text('2 vermelhos vencidos')), findsOneWidget); + // Ícone + texto, não só cor (WCAG 1.4.1). + expect(find.descendant(of: faixa, matching: find.byType(Icon)), findsOneWidget); + // Não é botão: nada ali é tocável. + expect(find.descendant(of: faixa, matching: find.byType(InkWell)), findsNothing); + expect(find.descendant(of: faixa, matching: find.byType(ButtonStyleButton)), findsNothing); + + fonte.overdueRed = 0; + await ciclo(tester); + expect(faixa, findsNothing); + expect(find.textContaining('vencido'), findsNothing); + await encerrar(tester, m); + }); + + testWidgets('$nome: singular com 1', (tester) async { + fonte.overdueRed = 1; + final m = await montar(tester, (m) => tela(fonte, m)); + expect(find.text('1 vermelho vencido'), findsOneWidget); + expect(find.text('1 vermelhos vencidos'), findsNothing); + await encerrar(tester, m); + }); + } + + testWidgets('contador usa o token de texto e é anunciado (liveRegion)', (tester) async { + fonte.overdueRed = 3; + final m = await montar(tester, (m) => IndicatorsScreen(dataSource: fonte, monitoring: m)); + + final texto = tester.widget(find.text('3 vermelhos vencidos')); + expect(texto.style?.color, AdminColors.redOnSurface, reason: 'texto vermelho sobre card usa o token *OnSurface, nunca o fill'); + final icone = tester.widget(find.descendant(of: faixa, matching: find.byType(Icon))); + expect(icone.color, AdminColors.redOnSurface); + + final semantica = tester.getSemantics(faixa); + expect(semantica.flagsCollection.isLiveRegion, isTrue); + expect(semantica.label, contains('3 vermelhos vencidos')); + await encerrar(tester, m); + }); + + testWidgets('dropdown de status não oferece "Escalonado"', (tester) async { + fonte.overdueRed = 0; + final m = await montar(tester, (m) => AlertsScreen(dataSource: fonte, monitoring: m)); + + await tester.tap(find.byKey(const Key('alerts_status_filter'))); + await tester.pumpAndSettle(); + + final opcoes = tester + .widgetList>(find.byType(DropdownMenuItem)) + .map((i) => i.value) + .toSet(); + expect(opcoes, {null, AlertStatus.pending, AlertStatus.acknowledged, AlertStatus.resolved}); + expect(opcoes, isNot(contains(AlertStatus.escalated))); + + await tester.tapAt(const Offset(5, 5)); + await tester.pumpAndSettle(); + await encerrar(tester, m); + }); + + testWidgets('linha legada com status escalated ainda mostra "Escalonado"', (tester) async { + fonte.overdueRed = 0; + final m = await montar(tester, (m) => AlertsScreen(dataSource: fonte, monitoring: m)); + + // alert-5 do mock é a linha legada (`AlertStatus.escalated`). + final linha = find.byKey(const Key('alert_alert-5'), skipOffstage: false); + expect(linha, findsOneWidget); + expect(find.descendant(of: linha, matching: find.text('Status: Escalonado'), skipOffstage: false), findsOneWidget); + await encerrar(tester, m); + }); +} diff --git a/apps/admin/test/responsive_layout_test.dart b/apps/admin/test/responsive_layout_test.dart index 2563c543..d69cfcca 100644 --- a/apps/admin/test/responsive_layout_test.dart +++ b/apps/admin/test/responsive_layout_test.dart @@ -4,15 +4,15 @@ 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. +/// Prova que as cinco 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 { + for (final (largura, altura) in const [(320.0, 640.0), (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 cinco telas a ${largura.toInt()}x${altura.toInt()}', (tester) async { await abrirBackoffice(tester, tamanho: Size(largura, altura)); await percorrerBackofficeInteiro(tester, '${largura.toInt()}x${altura.toInt()}'); }); @@ -21,10 +21,9 @@ void main() { 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. + // só rola com `scrollable: true`, que não é o default; com cinco destinos + // rotulados (#42) o conteúdo já passa da altura e depende da rolagem — daí + // também o caso seguinte, com fonte ampliada. await abrirBackoffice(tester, tamanho: const Size(800, 360)); expect(find.byKey(const Key('admin_navigation_rail')), findsOneWidget); @@ -54,4 +53,33 @@ void main() { await tester.pumpAndSettle(); expect(find.byKey(const Key('admin_navigation_bar')), findsOneWidget); }); + + for (final largura in const [320.0, 360.0]) { + for (final escala in const [1.0, 1.3, 2.0]) { + final rotulo = '${largura.toInt()}dp, fonte a ${(escala * 100).toInt()}%'; + testWidgets('os seis destinos cabem na barra inferior em $rotulo, sem rótulo cortado', (tester) async { + await abrirBackoffice(tester, tamanho: Size(largura, 640), escalaDeFonte: escala); + + final barra = find.byKey(const Key('admin_navigation_bar')); + expect(barra, findsOneWidget); + esperarCaberNaLargura(tester, barra, 'barra inferior em $rotulo'); + final caixaDaBarra = tester.getRect(barra); + // Rótulos curtos na barra, com o nome completo no tooltip. + expect(find.byTooltip('Pedidos do titular'), findsOneWidget); + expect(find.byTooltip('Indicadores'), findsOneWidget); + // Todos os rótulos, não só o novo: o Flutter não reporta como estouro + // um rótulo cortado pela borda de baixo da barra. + final rotulos = find.descendant(of: barra, matching: find.byType(Text)); + expect(rotulos, findsWidgets); + for (final elemento in rotulos.evaluate()) { + final caixa = tester.getRect(find.byElementPredicate((e) => e == elemento)); + final texto = (elemento.widget as Text).data; + expect(caixa.left, greaterThanOrEqualTo(caixaDaBarra.left), reason: '$texto em $rotulo'); + expect(caixa.right, lessThanOrEqualTo(caixaDaBarra.right), reason: '$texto em $rotulo'); + expect(caixa.bottom, lessThanOrEqualTo(caixaDaBarra.bottom), reason: '$texto cortado abaixo da barra em $rotulo'); + } + esperarSemEstouroDeLayout(tester, 'barra inferior em $rotulo'); + }); + } + } } diff --git a/apps/admin/test/schedule_notice_tool_test.dart b/apps/admin/test/schedule_notice_tool_test.dart new file mode 100644 index 00000000..b1159584 --- /dev/null +++ b/apps/admin/test/schedule_notice_tool_test.dart @@ -0,0 +1,36 @@ +import 'package:flutter_test/flutter_test.dart'; + +import '../tool/schedule_notice.dart'; + +void main() { + const manifesto = '{"microAreaId":"00000000-0000-4000-8000-000000000003",' + '"staff":{"id":"x","matricula":"ADM-E2E-1","password":"segredo-sintetico",' + '"activationCode":"COD-SINTETICO"}}'; + + test('lê matrícula, senha, código de ativação e microárea do manifesto', () { + final s = lerManifesto(manifesto); + expect(s.matricula, 'ADM-E2E-1'); + expect(s.senha, 'segredo-sintetico'); + expect(s.codigoAtivacao, 'COD-SINTETICO'); + expect(s.microAreaId, '00000000-0000-4000-8000-000000000003'); + }); + + test('manifesto sem staff ou sem microárea: FormatException que não vaza valores', () { + for (final ruim in [ + '{"microAreaId":"m"}', + '{"staff":{"matricula":"a","password":"segredo-sintetico","activationCode":"c"}}', + '{"microAreaId":"m","staff":{"matricula":"a","password":"segredo-sintetico"}}', + ]) { + expect( + () => lerManifesto(ruim), + throwsA(isA().having((e) => e.message, 'message', isNot(contains('segredo')))), + ); + } + }); + + test('passoTotp muda a cada 30 s (o login espera o passo seguinte ao da ativação)', () { + final t0 = DateTime.utc(2026, 10, 14, 12, 0, 0); + expect(passoTotp(t0.add(const Duration(seconds: 29))), passoTotp(t0)); + expect(passoTotp(t0.add(const Duration(seconds: 30))), passoTotp(t0) + 1); + }); +} diff --git a/apps/admin/test/stale_badge_test.dart b/apps/admin/test/stale_badge_test.dart new file mode 100644 index 00000000..531cadcc --- /dev/null +++ b/apps/admin/test/stale_badge_test.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_admin/app/admin_theme.dart'; +import 'package:sinalacs_admin/app/stale_badge.dart'; + +/// Selo de dado desatualizado (#44, Task 11; ADR "Intervalo e selo"). +void main() { + Future montar(WidgetTester tester, {required DateTime ultimoSucesso, required DateTime agora}) => + tester.pumpWidget(MaterialApp( + theme: buildAdminTheme(), + home: Scaffold(body: StaleBadge(lastSuccess: ultimoSucesso, now: agora)), + )); + + testWidgets('selo tem liveRegion e usa o token OnSurface', (tester) async { + final handle = tester.ensureSemantics(); + await montar( + tester, + ultimoSucesso: DateTime(2026, 10, 11, 9, 0, 5), + agora: DateTime(2026, 10, 11, 9, 2, 15), + ); + + const texto = 'Dados de 09:00:05 — sem atualização há 3 min'; + expect(find.text(texto), findsOneWidget); + + // Não depende só de cor: há ícone e texto. + final icone = tester.widget(find.descendant(of: find.byKey(const Key('stale_badge')), matching: find.byType(Icon))); + expect(icone.color, AdminColors.overdueOnSurface); + final rotulo = tester.widget(find.text(texto)); + expect(rotulo.style?.color, AdminColors.overdueOnSurface); + + final semantica = tester.getSemantics(find.byKey(const Key('stale_badge'))); + expect(semantica.getSemanticsData().flagsCollection.isLiveRegion, isTrue); + handle.dispose(); + }); + + testWidgets('minutos arredondados para cima, no mínimo 1', (tester) async { + final base = DateTime(2026, 10, 11, 14, 7, 9); + await montar(tester, ultimoSucesso: base, agora: base.add(const Duration(seconds: 61))); + expect(find.text('Dados de 14:07:09 — sem atualização há 2 min'), findsOneWidget); + + await montar(tester, ultimoSucesso: base, agora: base.add(const Duration(seconds: 120))); + expect(find.text('Dados de 14:07:09 — sem atualização há 2 min'), findsOneWidget); + + await montar(tester, ultimoSucesso: base, agora: base.add(const Duration(seconds: 10))); + expect(find.text('Dados de 14:07:09 — sem atualização há 1 min'), findsOneWidget); + }); +} diff --git a/apps/admin/test/support/failing_admin_data_source.dart b/apps/admin/test/support/failing_admin_data_source.dart index 2f84f919..cd2b6492 100644 --- a/apps/admin/test/support/failing_admin_data_source.dart +++ b/apps/admin/test/support/failing_admin_data_source.dart @@ -11,16 +11,80 @@ class FailingAdminDataSource implements AdminDataSource { final AdminDataSource inner; + // Avisos e termos (#46): repassados ao `inner`. + @override + Future> fetchNoticeHistory({String? microAreaId, int limit = 50, int offset = 0}) => + inner.fetchNoticeHistory(microAreaId: microAreaId, limit: limit, offset: offset); + + @override + Future scheduleNotice({ + required String microAreaId, + required NoticeAudience audience, + required String title, + required String message, + required DateTime scheduledFor, + }) => inner.scheduleNotice( + microAreaId: microAreaId, audience: audience, title: title, message: message, scheduledFor: scheduledFor); + + @override + Future cancelScheduledNotice(String id) => inner.cancelScheduledNotice(id); + + @override + Future> fetchTermsVersions() => inner.fetchTermsVersions(); + + @override + Future draftTermsVersion({ + required String version, + required String summary, + required DateTime effectiveFrom, + }) => inner.draftTermsVersion(version: version, summary: summary, effectiveFrom: effectiveFrom); + + @override + Future markTermsReviewed(String id) => inner.markTermsReviewed(id); + + @override + Future publishTermsVersion(String id) => inner.publishTermsVersion(id); + + // Escalonamento, pulso e fim de sessão (#44): repassados ao `inner`. + @override + Future escalateAlert(String alertId, EscalationReasonCode reason) => + inner.escalateAlert(alertId, reason); + + @override + Future alertsPulse() => inner.alertsPulse(); + + @override + Future endMonitoringSession() => inner.endMonitoringSession(); + bool failNextIndicators = false; bool failNextMicroAreas = false; + bool failNextAcs = false; + bool failNextStaff = false; bool failNextAlerts = false; bool failNextAuditLogs = false; bool failNextRecordAccess = false; int recordAccessCalls = 0; + /// Exceção específica lançada (uma vez) pela próxima leitura de dados, no + /// lugar do `StateError` genérico: prova o tratamento de `AdminDataFailure` e + /// `AdminSessionExpired` nas telas. + Object? nextError; + + /// Falha depois do próximo frame (o pump avança o relógio antes de montar), como uma chamada de rede de + /// verdade: um erro síncrono antes de o `FutureBuilder` assinar o `Future` é + /// reportado como não tratado. + Future _lancaSeProgramado() async { + final erro = nextError; + if (erro == null) return; + nextError = null; + await Future.delayed(const Duration(milliseconds: 150)); + throw erro; + } + @override Future fetchDashboardIndicators() async { + await _lancaSeProgramado(); if (failNextIndicators) { failNextIndicators = false; throw StateError('falha simulada: indicadores'); @@ -30,6 +94,7 @@ class FailingAdminDataSource implements AdminDataSource { @override Future> fetchMicroAreas() async { + await _lancaSeProgramado(); if (failNextMicroAreas) { failNextMicroAreas = false; throw StateError('falha simulada: microáreas'); @@ -38,21 +103,95 @@ class FailingAdminDataSource implements AdminDataSource { } @override - Future> fetchAlerts({String? microAreaName, AlertStatus? status}) async { + Future> fetchAcs() async { + await _lancaSeProgramado(); + if (failNextAcs) { + failNextAcs = false; + throw StateError('falha simulada: ACS'); + } + return inner.fetchAcs(); + } + + @override + Future> fetchStaff() async { + await _lancaSeProgramado(); + if (failNextStaff) { + failNextStaff = false; + throw StateError('falha simulada: equipe do backoffice'); + } + return inner.fetchStaff(); + } + + // As escritas da gestão de contas (#43) passam direto: as telas que este duplo + // exercita medem falha de **leitura**. + @override + Future createAcs({ + required String name, + required String enrollmentId, + required String microAreaId, + }) => inner.createAcs(name: name, enrollmentId: enrollmentId, microAreaId: microAreaId); + + @override + Future setAcsMicroArea({required String acsId, required String microAreaId}) => + inner.setAcsMicroArea(acsId: acsId, microAreaId: microAreaId); + + @override + Future setAcsActive({required String acsId, required bool active}) => + inner.setAcsActive(acsId: acsId, active: active); + + @override + Future resetAcsPassword({required String acsId}) => inner.resetAcsPassword(acsId: acsId); + + @override + Future resetAcsMfa({required String acsId}) => inner.resetAcsMfa(acsId: acsId); + + @override + Future resetStaffMfa({required String staffId}) => inner.resetStaffMfa(staffId: staffId); + + @override + Future> fetchAlerts({String? microAreaId, AlertStatus? status, int limit = 50, int offset = 0}) async { + await _lancaSeProgramado(); if (failNextAlerts) { failNextAlerts = false; throw StateError('falha simulada: alertas'); } - return inner.fetchAlerts(microAreaName: microAreaName, status: status); + return inner.fetchAlerts(microAreaId: microAreaId, status: status, limit: limit, offset: offset); } @override - Future> fetchAuditLogs() async { + Future fetchAuditLogs({ + AuditLogFilter filter = const AuditLogFilter(), + int limit = 50, + int? beforeSequence, + }) async { + await _lancaSeProgramado(); if (failNextAuditLogs) { failNextAuditLogs = false; throw StateError('falha simulada: auditoria'); } - return inner.fetchAuditLogs(); + return inner.fetchAuditLogs(filter: filter, limit: limit, beforeSequence: beforeSequence); + } + + /// Erro lançado (uma vez) pela próxima verificação da cadeia, depois do + /// próximo frame — como [nextError], mas só para esta chamada. + Object? failNextChainStatusWith; + + @override + Future verifyAuditChain() async { + await _lancaSeProgramado(); + final erro = failNextChainStatusWith; + if (erro != null) { + failNextChainStatusWith = null; + await Future.delayed(const Duration(milliseconds: 150)); + throw erro; + } + return inner.verifyAuditChain(); + } + + @override + Future exportAuditLogs(AuditLogFilter filter) async { + await _lancaSeProgramado(); + return inner.exportAuditLogs(filter); } @override @@ -64,4 +203,34 @@ class FailingAdminDataSource implements AdminDataSource { } return inner.recordAccess(actionType: actionType, resourceType: resourceType); } + + @override + Future> fetchDataRequests({DataRequestStatus? status, int limit = 50, int offset = 0}) async { + await _lancaSeProgramado(); + return inner.fetchDataRequests(status: status, limit: limit, offset: offset); + } + + @override + Future fetchDataRequest(String id) async { + await _lancaSeProgramado(); + return inner.fetchDataRequest(id); + } + + @override + Future startDataRequestReview(String id) async { + await _lancaSeProgramado(); + return inner.startDataRequestReview(id); + } + + @override + Future completeDataRequest(String id, {String? note}) async { + await _lancaSeProgramado(); + return inner.completeDataRequest(id, note: note); + } + + @override + Future rejectDataRequest(String id, {required String reason}) async { + await _lancaSeProgramado(); + return inner.rejectDataRequest(id, reason: reason); + } } diff --git a/apps/admin/test/support/fake_admin_auth.dart b/apps/admin/test/support/fake_admin_auth.dart new file mode 100644 index 00000000..d0dedc68 --- /dev/null +++ b/apps/admin/test/support/fake_admin_auth.dart @@ -0,0 +1,112 @@ +import 'package:serverpod_client/serverpod_client.dart'; +import 'package:sinalacs_admin/core/auth/admin_auth_backend.dart'; + +/// Chamador de endpoint falso: a próxima chamada lança [error] ou devolve +/// [result]. Permite exercitar o `EndpointAuth` real sem rede. +class FakeEndpointCaller implements EndpointCaller { + FakeEndpointCaller({this.error, this.result}); + Object? error; + Object? result; + Map? lastArgs; + + @override + Future callServerEndpoint( + String endpoint, + String method, + Map args, { + bool authenticated = true, + }) async { + lastArgs = args; + if (error != null) throw error!; + return result as T; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +/// [AdminAuthBackend] em memória para testes de tela. +class FakeAdminAuth implements AdminAuthBackend { + FakeAdminAuth({this.session}); + + /// Sessão devolvida no sucesso; `null` gera uma de papel `admin` válida por 1 h. + AdminSession? session; + + /// Papel da sessão gerada quando [session] é `null`. + String role = 'admin'; + + /// Lançada por `login` (e só por ele) quando não for `null`. + Object? failWith; + + /// Quando `true`, `login` sem código lança [AdminMfaCodeRequired]. + bool requiresTotp = false; + + /// Último código TOTP recebido por `login`. + String? lastTotpCode; + + /// Última matrícula/senha recebidas por `login`. + String? lastMatricula; + String? lastSenha; + + int loginCalls = 0; + + /// Lançada por `confirmMfaEnrollment` quando não for `null`. + Object? confirmFailWith; + String? lastEnrollmentCode; + + /// Último código de ativação recebido por `begin`/`confirm`, e a falha que o + /// `begin` lança quando não for `null` (código errado/expirado). + String? lastActivationCode; + Object? beginFailWith; + int enrollmentBegins = 0; + + @override + Future login({ + required String matricula, + required String senha, + String? totpCode, + }) async { + loginCalls++; + lastMatricula = matricula; + lastSenha = senha; + lastTotpCode = totpCode; + if (failWith != null) throw failWith!; + if (requiresTotp && (totpCode == null || totpCode.isEmpty)) { + throw const AdminMfaCodeRequired(); + } + return session ?? + AdminSession( + accessToken: 'token-de-teste', + userId: 'ADM-001', + role: role, + expiresAt: DateTime.now().add(const Duration(hours: 1)), + ); + } + + @override + Future<({String secret, String otpauthUri})> beginMfaEnrollment({ + required String matricula, + required String senha, + required String activationCode, + }) async { + enrollmentBegins++; + lastActivationCode = activationCode; + if (beginFailWith != null) throw beginFailWith!; + return ( + secret: 'JBSWY3DPEHPK3PXP', + otpauthUri: 'otpauth://totp/x?secret=JBSWY3DPEHPK3PXP', + ); + } + + @override + Future confirmMfaEnrollment({ + required String matricula, + required String senha, + required String activationCode, + required String code, + }) async { + lastEnrollmentCode = code; + lastActivationCode = activationCode; + if (confirmFailWith != null) throw confirmFailWith!; + } +} diff --git a/apps/admin/test/support/layout_harness.dart b/apps/admin/test/support/layout_harness.dart index 44f80538..0b03d5e6 100644 --- a/apps/admin/test/support/layout_harness.dart +++ b/apps/admin/test/support/layout_harness.dart @@ -1,8 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:sinalacs_admin/app/app.dart'; +import 'package:sinalacs_admin/app/data_requests_screen.dart'; +import 'package:sinalacs_admin/core/auth/admin_auth_backend.dart'; import 'package:sinalacs_admin/core/data/admin_data_source.dart'; +import 'fake_admin_auth.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 @@ -18,9 +22,14 @@ import 'package:sinalacs_admin/core/data/admin_data_source.dart'; /// [percorrerTelaInteira]. /// 3. `takeException()` consome **uma** exceção por chamada. Duas telas /// estourando com uma única checagem no fim reportariam uma só. +/// +/// Há uma quarta, que é o mesmo estrago por outro caminho: um teto de rolagem +/// que devolve em silêncio. A tela cresce, o teto não, e o rodapé deixa de ser +/// checado sem que nada fique vermelho — por isso [percorrerTelaInteira] hoje +/// **falha** ao gastar [tetoDeRolagem] passos. -/// Nomes dos quatro destinos, na ordem de `AdminDestination`. -const destinosDoBackoffice = ['Indicadores', 'Microáreas', 'Alertas', 'Auditoria']; +/// Nomes dos seis destinos, na ordem de `AdminDestination`. +const destinosDoBackoffice = ['Indicadores', 'Microáreas', 'Alertas', 'Auditoria', 'Pedidos do titular', 'Avisos']; /// Abre o backoffice já logado, numa janela de tamanho e escala de fonte fixos. /// @@ -30,17 +39,33 @@ Future abrirBackoffice( required Size tamanho, double escalaDeFonte = 1.0, AdminDataSource? dataSource, + AdminAuthBackend? auth, }) async { redimensionar(tester, tamanho, escalaDeFonte: escalaDeFonte); addTearDown(tester.view.reset); addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); - await tester.pumpWidget(SinalAdminApp(dataSource: dataSource, devLoginEnabled: true)); + await tester.pumpWidget(SinalAdminApp(dataSource: dataSource, auth: auth ?? FakeAdminAuth())); // `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. + await entrarComCredenciais(tester); +} + +/// Preenche matrícula e senha e toca em Entrar (o login real não tem atalho). +/// +/// O `ensureVisible` antes de cada toque vale para o botão e para os campos: +/// com fonte a 200% o formulário passa da dobra. +Future entrarComCredenciais(WidgetTester tester) async { + await tester.pumpAndSettle(); + for (final (chave, texto) in const [('matricula_field', 'ADM-001'), ('senha_field', 'senha-de-teste')]) { + final campo = find.byKey(Key(chave)); + await tester.ensureVisible(campo); + await tester.pumpAndSettle(); + await tester.enterText(campo, texto); + } final entrar = find.byKey(const Key('login_button')); await tester.ensureVisible(entrar); await tester.pumpAndSettle(); @@ -63,29 +88,74 @@ void esperarSemEstouroDeLayout(WidgetTester tester, String contexto) { expect(erro, isNull, reason: 'estouro de layout em $contexto: $erro'); } +/// Número máximo de arrastos de [percorrerTelaInteira] — 30 × 320dp = 9600dp. +/// +/// Medido nesta tarefa, a 200% de fonte em 360x800: Microáreas gasta 17 dos 30 +/// (≈5400dp), Indicadores 4, Auditoria 5, Alertas 9. São ~75% de folga na tela +/// mais longa, bem acima do teto de 8 (≈2400dp) que os testes responsivos +/// herdaram da época em que a tela cabia em dois arrastos — esse parava antes do +/// rodapé de Equipe e nada ficava vermelho. +const tetoDeRolagem = 30; + /// 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 { +/// +/// [lista] escolhe a rolagem da página quando a tela tem outra mais funda +/// (um `TextField` de várias linhas também é `Scrollable`). +/// +/// "Até o fim" e não um número fixo de arrastos: a lista é preguiçosa e o +/// `maxScrollExtent` inicial é só uma estimativa sobre os filhos já construídos, +/// então o número de passos que bastava a uma tela curta fica curto assim que a +/// tela cresce. Na gestão de contas (#43) a tela Microáreas passou a ter ~5150dp +/// de rolagem a 200% de fonte em 360x800 — o teto de 8 (≈2400dp) e um teto de 12 +/// que eu pus na primeira tentativa paravam bem antes do rodapé da seção de +/// Equipe, e a roda parava de ser checada sem que nenhum teste ficasse vermelho. +/// +/// Gastar [tetoDeRolagem] passos **sem** chegar ao fim agora FALHA, em vez de +/// devolver em silêncio: era esse silêncio que transformava "a tela cresceu" em +/// cobertura perdida sem nenhum teste vermelho, que é justamente o que o topo +/// deste arquivo descreve como a pior falha possível aqui. Quem crescer além do +/// teto sobe o teto — a trava existe só contra laço infinito, não para limitar a +/// cobertura. +Future percorrerTelaInteira(WidgetTester tester, String contexto, {Finder? lista}) async { esperarSemEstouroDeLayout(tester, '$contexto (topo)'); - final lista = find.byType(Scrollable).last; - for (var passo = 1; passo <= 8; passo++) { + lista ??= find.byType(Scrollable).last; + var anterior = -1.0; + for (var passo = 1; passo <= tetoDeRolagem; passo++) { + final posicao = tester.state(lista).position; + if (posicao.pixels >= posicao.maxScrollExtent || posicao.pixels == anterior) return; + anterior = posicao.pixels; await tester.drag(lista, const Offset(0, -320)); await tester.pumpAndSettle(); esperarSemEstouroDeLayout(tester, '$contexto (rolagem $passo)'); } + final parada = tester.state(lista).position; + final faltam = (parada.maxScrollExtent - parada.pixels).round(); + fail( + '$contexto: os $tetoDeRolagem passos do harness acabaram antes do fim da ' + 'rolagem (faltavam ${faltam}dp). A tela pode ter crescido além do teto: ' + 'suba `tetoDeRolagem` para que o rodapé volte a ser checado.', + ); } -/// Visita os quatro destinos, percorrendo cada um por inteiro. +/// Visita os cinco 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'); + await percorrerTelaInteira( + tester, + '$contexto / $destino', + lista: destino == 'Auditoria' ? rolagemDaAuditoria() : null, + ); } } +/// Na barra inferior "Pedidos do titular" aparece como "Pedidos", com o nome +/// completo no tooltip: o destino é achado pelo texto ou pelo tooltip. Future irPara(WidgetTester tester, String destino) async { - final alvo = find.text(destino).last; + final porTexto = find.text(destino); + final alvo = porTexto.evaluate().isNotEmpty ? porTexto.last : find.byTooltip(destino).last; await tester.ensureVisible(alvo); await tester.pumpAndSettle(); await tester.tap(alvo); @@ -99,3 +169,30 @@ 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); } + +/// A rolagem da página de Auditoria, e não a de um campo do filtro (todo +/// `TextField` também é `Scrollable`, e `find.byType(Scrollable).last` cairia +/// nele sem rolar nada — cobertura perdida sem teste vermelho). +Finder rolagemDaAuditoria() => + find.descendant(of: find.byType(AuditLogScreen), matching: find.byType(Scrollable)).first; + +/// A rolagem da página de pedidos do titular (fila ou detalhe), e não a do +/// campo de resposta, que também é `Scrollable`. +Finder rolagemDosPedidos() => + find.descendant(of: find.byType(DataRequestsScreen), matching: find.byType(Scrollable)).first; + +/// Rola a página de pedidos até [alvo] ser construído e ficar visível: item de +/// `ListView` abaixo da dobra ainda não existe, então `ensureVisible` sozinho +/// não o acha. +Future rolarAtePedido(WidgetTester tester, Finder alvo) async { + await tester.scrollUntilVisible(alvo, 120, scrollable: rolagemDosPedidos()); + await tester.pumpAndSettle(); +} + +/// Abre o detalhe de um pedido do titular a partir da fila (tela já aberta). +Future abrirPedidoDoTitular(WidgetTester tester, String id) async { + final linha = find.byKey(Key('data_request_$id')); + await rolarAtePedido(tester, linha); + await tester.tap(linha); + await tester.pumpAndSettle(); +} diff --git a/apps/admin/test/support/scripted_pulse_data_source.dart b/apps/admin/test/support/scripted_pulse_data_source.dart new file mode 100644 index 00000000..eb70a4da --- /dev/null +++ b/apps/admin/test/support/scripted_pulse_data_source.dart @@ -0,0 +1,97 @@ +import 'dart:async'; + +import 'package:sinalacs_admin/core/data/admin_data_source.dart'; +import 'package:sinalacs_admin/core/data/mock_admin_data_source.dart'; + +/// Mock com o pulso roteirizado pelo teste (#44, Task 11). +/// +/// Conta pulsos e leituras, deixa o teste mudar `maxChangedAt`, as contagens +/// ou os indicadores do próximo pulso, fazer o pulso falhar e acrescentar +/// alertas à lista — tudo sem rede e com o relógio injetado no mock. +class ScriptedPulseDataSource extends MockAdminDataSource { + ScriptedPulseDataSource({super.now}); + + int pulsos = 0; + int buscasDeAlertas = 0; + int buscasDeIndicadores = 0; + int buscasDeMicroareas = 0; + + /// Quando não nulo, o pulso devolve este futuro (por exemplo, um que nunca + /// completa, para provar o prazo do pulso no app). + Future? pulsoPendurado; + bool _dentroDoPulso = false; + + /// Substituem o campo correspondente do pulso do mock quando não nulos. + DateTime? maxChangedAt; + int? overdueRed; + DashboardIndicators? indicadores; + + /// Lançada por todo pulso enquanto não for nula (falha persistente). + Object? falhaDoPulso; + + /// Lançada por `fetchAlerts` enquanto não for nula. + Object? falhaDasBuscas; + + /// Acrescentados ao fim de `fetchAlerts` (um alerta "novo" visível na tela). + final List extras = []; + + /// Quando não nulo, `endMonitoringSession` devolve este futuro (por exemplo, + /// um que nunca completa, para provar que ninguém dá `await` nele). + Future? fimDaSessao; + + @override + Future alertsPulse() async { + pulsos++; + final falha = falhaDoPulso; + if (falha != null) throw falha; + final pendurado = pulsoPendurado; + if (pendurado != null) return pendurado; + // O pulso do mock monta os indicadores por `fetchDashboardIndicators`: + // essa chamada interna (síncrona, antes do primeiro `await` do mock) não + // conta como leitura da tela. + _dentroDoPulso = true; + final futuro = super.alertsPulse(); + _dentroDoPulso = false; + final base = await futuro; + return AlertsPulse( + indicators: indicadores ?? base.indicators, + overdueRed: overdueRed ?? base.overdueRed, + maxChangedAt: maxChangedAt ?? base.maxChangedAt, + serverNow: base.serverNow, + ); + } + + @override + Future> fetchMicroAreas() { + buscasDeMicroareas++; + return super.fetchMicroAreas(); + } + + @override + Future fetchDashboardIndicators() { + if (!_dentroDoPulso) buscasDeIndicadores++; + return super.fetchDashboardIndicators(); + } + + @override + Future> fetchAlerts({ + String? microAreaId, + AlertStatus? status, + int limit = 50, + int offset = 0, + }) async { + buscasDeAlertas++; + final falha = falhaDasBuscas; + if (falha != null) throw falha; + final lista = await super.fetchAlerts(microAreaId: microAreaId, status: status, limit: limit, offset: offset); + return [...lista, ...extras]; + } + + @override + Future endMonitoringSession() { + final fim = fimDaSessao; + if (fim == null) return super.endMonitoringSession(); + monitoringSessionEnds++; + return fim; + } +} diff --git a/apps/admin/test/terms_form_test.dart b/apps/admin/test/terms_form_test.dart new file mode 100644 index 00000000..3e6c4fd5 --- /dev/null +++ b/apps/admin/test/terms_form_test.dart @@ -0,0 +1,153 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_admin/app/admin_async_states.dart'; +import 'package:sinalacs_admin/app/admin_theme.dart'; +import 'package:sinalacs_admin/app/terms_panel.dart'; +import 'package:sinalacs_admin/core/data/admin_data_source.dart'; +import 'package:sinalacs_admin/core/data/mock_admin_data_source.dart'; + +import 'support/layout_harness.dart'; + +/// Formulário de nova versão de termos (#46). Só texto placeholder. +final _agora = DateTime(2026, 10, 14, 9); +const _texto = '[TEXTO PLACEHOLDER]'; + +class _Chamada { + _Chamada(this.version, this.summary, this.effectiveFrom); + final String version; + final String summary; + final DateTime effectiveFrom; +} + +class _Fake extends MockAdminDataSource { + _Fake() : super(now: () => _agora); + + final List<_Chamada> chamadas = []; + Completer? portao; + Object? erro; + + @override + Future draftTermsVersion({ + required String version, + required String summary, + required DateTime effectiveFrom, + }) async { + chamadas.add(_Chamada(version, summary, effectiveFrom)); + if (portao != null) await portao!.future; + final e = erro; + if (e != null) throw e; + return super.draftTermsVersion(version: version, summary: summary, effectiveFrom: effectiveFrom); + } +} + +Future _abrir(WidgetTester tester, _Fake fonte, {VoidCallback? aoVencer}) async { + redimensionar(tester, const Size(1024, 1000)); + addTearDown(tester.view.reset); + await tester.pumpWidget( + MaterialApp( + theme: buildAdminTheme(), + home: Scaffold( + body: AdminSessionScope( + aoVencer: aoVencer ?? () {}, + child: TermsPanel(dataSource: fonte, now: () => _agora), + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('terms_new_button'))); + await tester.pumpAndSettle(); +} + +Future _preencher(WidgetTester tester) async { + await tester.enterText(find.byKey(const Key('terms_version')), ' 2026.2 '); + await tester.enterText(find.byKey(const Key('terms_summary')), ' $_texto '); + await tester.pumpAndSettle(); +} + +FilledButton _submit(WidgetTester tester) => tester.widget(find.byKey(const Key('terms_submit'))); + +void main() { + testWidgets('versão vazia: submit desabilitado; resumo com limite 280 e ajuda', (tester) async { + await _abrir(tester, _Fake()); + expect(_submit(tester).onPressed, isNull); + expect(find.text('0/280'), findsOneWidget); + expect(find.text('Resumo público exibido no cartão do paciente.'), findsOneWidget); + await tester.enterText(find.byKey(const Key('terms_summary')), _texto); + await tester.pumpAndSettle(); + expect(_submit(tester).onPressed, isNull); + await tester.enterText(find.byKey(const Key('terms_version')), ' '); + await tester.pumpAndSettle(); + expect(_submit(tester).onPressed, isNull); + await tester.enterText(find.byKey(const Key('terms_version')), '2026.2'); + await tester.pumpAndSettle(); + expect(_submit(tester).onPressed, isNotNull); + }); + + testWidgets('data padrão é a próxima meia-noite a partir de now+15 dias', (tester) async { + // 14/10 09:00 + 15 dias = 29/10 09:00 -> próxima meia-noite: 30/10. + await _abrir(tester, _Fake()); + expect(find.text('Vigora em 30/10/2026'), findsOneWidget); + }); + + testWidgets('criar: uma chamada com valores aparados e vigência às 00:00 local; fecha e avisa', (tester) async { + final f = _Fake(); + await _abrir(tester, f); + await _preencher(tester); + await tester.tap(find.byKey(const Key('terms_submit'))); + await tester.pumpAndSettle(); + expect(f.chamadas, hasLength(1)); + expect(f.chamadas.single.version, '2026.2'); + expect(f.chamadas.single.summary, _texto); + expect(f.chamadas.single.effectiveFrom, DateTime(2026, 10, 30)); + expect(find.byKey(const Key('terms_submit')), findsNothing); + expect(find.text('Rascunho criado.'), findsOneWidget); + expect(find.text('Versão 2026.2'), findsOneWidget); + }); + + testWidgets('duplo toque dispara uma única chamada', (tester) async { + final f = _Fake()..portao = Completer(); + await _abrir(tester, f); + await _preencher(tester); + final alvo = find.byKey(const Key('terms_submit')); + await tester.tap(alvo); + await tester.tap(alvo, warnIfMissed: false); + await tester.pump(); + expect(f.chamadas, hasLength(1)); + f.portao!.complete(); + await tester.pumpAndSettle(); + expect(f.chamadas, hasLength(1)); + }); + + testWidgets('erro do servidor fica dentro do diálogo, que continua aberto', (tester) async { + final f = _Fake()..erro = const AdminValidationFailure('Já existe uma versão com este número.'); + await _abrir(tester, f); + await _preencher(tester); + await tester.tap(find.byKey(const Key('terms_submit'))); + await tester.pumpAndSettle(); + expect(find.text('Já existe uma versão com este número.'), findsOneWidget); + expect(find.byKey(const Key('terms_version')), findsOneWidget); + expect(_submit(tester).onPressed, isNotNull); + }); + + testWidgets('sessão vencida aciona o callback capturado', (tester) async { + var venceu = 0; + final f = _Fake()..erro = const AdminSessionExpired(); + await _abrir(tester, f, aoVencer: () => venceu++); + await _preencher(tester); + await tester.tap(find.byKey(const Key('terms_submit'))); + await tester.pumpAndSettle(); + expect(venceu, 1); + }); + + testWidgets('cancelar fecha sem chamar o data source', (tester) async { + final f = _Fake(); + await _abrir(tester, f); + await tester.tap(find.byKey(const Key('terms_form_cancel'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('terms_version')), findsNothing); + expect(f.chamadas, isEmpty); + }); +} diff --git a/apps/admin/test/terms_panel_test.dart b/apps/admin/test/terms_panel_test.dart new file mode 100644 index 00000000..52725455 --- /dev/null +++ b/apps/admin/test/terms_panel_test.dart @@ -0,0 +1,219 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_admin/app/admin_theme.dart'; +import 'package:sinalacs_admin/app/admin_async_states.dart'; +import 'package:sinalacs_admin/app/terms_panel.dart'; +import 'package:sinalacs_admin/core/data/admin_data_source.dart'; +import 'package:sinalacs_admin/core/data/mock_admin_data_source.dart'; + +import 'support/layout_harness.dart'; + +/// Painel de termos (#46). Só texto placeholder: nenhum texto jurídico real. +final _agora = DateTime(2026, 10, 14, 9); +const _texto = '[TEXTO PLACEHOLDER]'; + +class _Fake extends MockAdminDataSource { + _Fake() : super(now: () => _agora); + + int revisoes = 0; + int publicacoes = 0; + Object? erroNaPublicacao; + Object? erroNaCarga; + final List<(String, String)> acessos = []; + + @override + Future recordAccess({required String actionType, required String resourceType}) async { + acessos.add((actionType, resourceType)); + } + + @override + Future> fetchTermsVersions() async { + final e = erroNaCarga; + if (e != null) throw e; + return super.fetchTermsVersions(); + } + + @override + Future markTermsReviewed(String id) { + revisoes++; + return super.markTermsReviewed(id); + } + + @override + Future publishTermsVersion(String id) { + publicacoes++; + final e = erroNaPublicacao; + if (e != null) throw e; + return super.publishTermsVersion(id); + } +} + +Future _rascunho(_Fake f, {String versao = '2026.2', int dias = 30}) => + f.draftTermsVersion(version: versao, summary: _texto, effectiveFrom: DateTime(2026, 10, 14 + dias)); + +Future _abrir(WidgetTester tester, _Fake fonte, {VoidCallback? aoVencer, bool assentar = true}) async { + redimensionar(tester, const Size(1024, 1000)); + addTearDown(tester.view.reset); + await tester.pumpWidget( + MaterialApp( + theme: buildAdminTheme(), + home: Scaffold( + body: AdminSessionScope( + aoVencer: aoVencer ?? () {}, + child: TermsPanel(dataSource: fonte, now: () => _agora), + ), + ), + ), + ); + // Sessão vencida deixa um spinner girando: pumpAndSettle nunca assentaria. + if (assentar) { + await tester.pumpAndSettle(); + } else { + await tester.pump(); + await tester.pump(); + } +} + +FilledButton _publicar(WidgetTester tester, String id) => + tester.widget(find.byKey(Key('terms_publish_$id'))); + +void main() { + testWidgets('lista vazia, texto de ajuda e carga auditada', (tester) async { + final f = _Fake(); + await _abrir(tester, f); + expect(find.text('Nenhuma versão de termos cadastrada.'), findsOneWidget); + expect(find.textContaining('O texto integral dos termos vai no release do app do paciente.'), findsOneWidget); + expect(f.acessos, [('view', 'terms_versions')]); + }); + + testWidgets('erro na carga mostra retry e recarrega', (tester) async { + final f = _Fake()..erroNaCarga = const AdminDataFailure('Falha de teste.'); + await _abrir(tester, f); + expect(find.text('Falha de teste.'), findsOneWidget); + f.erroNaCarga = null; + await _rascunho(f); + await tester.tap(find.text('Tentar novamente')); + await tester.pumpAndSettle(); + expect(find.text('Versão 2026.2'), findsOneWidget); + }); + + testWidgets('sessão vencida na carga aciona o scope', (tester) async { + var venceu = 0; + final f = _Fake()..erroNaCarga = const AdminSessionExpired(); + await _abrir(tester, f, aoVencer: () => venceu++, assentar: false); + expect(venceu, greaterThan(0)); + }); + + testWidgets('rascunho: Publicar desabilitado e explica "Aguardando revisão jurídica"', (tester) async { + final f = _Fake(); + final t = await _rascunho(f); + await _abrir(tester, f); + expect(find.text('Rascunho'), findsOneWidget); + expect(find.byIcon(Icons.edit_note), findsOneWidget); + expect(_publicar(tester, t.id).onPressed, isNull); + expect(find.text('Aguardando revisão jurídica'), findsOneWidget); + expect(find.text('Vigora em 13/11/2026'), findsOneWidget); + }); + + testWidgets('Marcar como revisado pede confirmação, chama uma vez e habilita Publicar', (tester) async { + final f = _Fake(); + final t = await _rascunho(f); + await _abrir(tester, f); + await tester.tap(find.byKey(Key('terms_review_${t.id}'))); + await tester.pumpAndSettle(); + expect(find.text('Confirmo que o jurídico revisou a versão 2026.2.'), findsOneWidget); + expect(f.revisoes, 0); + await tester.tap(find.byKey(const Key('terms_review_confirm'))); + await tester.pumpAndSettle(); + expect(f.revisoes, 1); + expect(find.text('Revisada'), findsOneWidget); + expect(_publicar(tester, t.id).onPressed, isNotNull); + expect(find.byKey(Key('terms_review_${t.id}')), findsNothing); + expect(find.text('Aguardando revisão jurídica'), findsNothing); + }); + + testWidgets('cancelar a confirmação não revisa', (tester) async { + final f = _Fake(); + final t = await _rascunho(f); + await _abrir(tester, f); + await tester.tap(find.byKey(Key('terms_review_${t.id}'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('Voltar')); + await tester.pumpAndSettle(); + expect(f.revisoes, 0); + }); + + testWidgets('publicar cita versão e vigência; depois mostra "Publicada em … · vigora em …" sem botões', (tester) async { + final f = _Fake(); + final t = await _rascunho(f); + await f.markTermsReviewed(t.id); + await _abrir(tester, f); + await tester.tap(find.byKey(Key('terms_publish_${t.id}'))); + await tester.pumpAndSettle(); + expect( + find.text('A versão 2026.2 passará a valer em 13/11/2026. Os pacientes verão o aviso desde hoje.'), + findsOneWidget, + ); + expect(f.publicacoes, 0); + await tester.tap(find.byKey(const Key('terms_publish_confirm'))); + await tester.pumpAndSettle(); + expect(f.publicacoes, 1); + expect(find.text('Publicada em 14/10/2026 · vigora em 13/11/2026'), findsOneWidget); + expect(find.byKey(Key('terms_publish_${t.id}')), findsNothing); + expect(find.byKey(Key('terms_review_${t.id}')), findsNothing); + }); + + testWidgets('revisada a 14 dias: Publicar desabilitado com prazo esgotado; com folga (meia-noite após 15 dias) habilita', (tester) async { + final f = _Fake(); + final curto = await _rascunho(f, versao: '2026.3', dias: 14); + final ok = await _rascunho(f, versao: '2026.4', dias: 16); + await f.markTermsReviewed(curto.id); + await f.markTermsReviewed(ok.id); + await _abrir(tester, f); + expect(_publicar(tester, curto.id).onPressed, isNull); + expect(find.text('Prazo de 15 dias esgotado: crie uma nova versão com vigência posterior.'), findsOneWidget); + expect(_publicar(tester, ok.id).onPressed, isNotNull); + }); + + testWidgets('borda dos 15 dias: vigência em now+15d exatos habilita; 1 s a menos desabilita', (tester) async { + final f = _Fake(); + final exato = await f.draftTermsVersion( + version: '2026.5', summary: _texto, effectiveFrom: _agora.add(const Duration(days: 15))); + final curto = await f.draftTermsVersion( + version: '2026.6', + summary: _texto, + effectiveFrom: _agora.add(const Duration(days: 15)).subtract(const Duration(seconds: 1))); + await f.markTermsReviewed(exato.id); + await f.markTermsReviewed(curto.id); + await _abrir(tester, f); + expect(_publicar(tester, exato.id).onPressed, isNotNull); + expect(_publicar(tester, curto.id).onPressed, isNull); + expect(find.text('Prazo de 15 dias esgotado: crie uma nova versão com vigência posterior.'), findsOneWidget); + }); + + testWidgets('erro do servidor na publicação vira SnackBar', (tester) async { + final f = _Fake()..erroNaPublicacao = const AdminValidationFailure('A vigência exige 15 dias de antecedência.'); + final t = await _rascunho(f); + await f.markTermsReviewed(t.id); + await _abrir(tester, f); + await tester.tap(find.byKey(Key('terms_publish_${t.id}'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('terms_publish_confirm'))); + await tester.pumpAndSettle(); + expect(find.descendant(of: find.byType(SnackBar), matching: find.text('A vigência exige 15 dias de antecedência.')), findsOneWidget); + expect(f.publicacoes, 1); + }); + + testWidgets('sessão vencida ao publicar aciona o scope', (tester) async { + var venceu = 0; + final f = _Fake()..erroNaPublicacao = const AdminSessionExpired(); + final t = await _rascunho(f); + await f.markTermsReviewed(t.id); + await _abrir(tester, f, aoVencer: () => venceu++); + await tester.tap(find.byKey(Key('terms_publish_${t.id}'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('terms_publish_confirm'))); + await tester.pumpAndSettle(); + expect(venceu, greaterThan(0)); + }); +} diff --git a/apps/admin/test/text_scale_test.dart b/apps/admin/test/text_scale_test.dart index 4a84f0f8..a0f42f20 100644 --- a/apps/admin/test/text_scale_test.dart +++ b/apps/admin/test/text_scale_test.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_admin/app/app.dart'; +import 'package:sinalacs_admin/core/auth/admin_auth_backend.dart'; +import 'support/fake_admin_auth.dart'; import 'support/layout_harness.dart'; /// WCAG 1.4.4 exige que o conteúdo sobreviva a 200% de escala de texto. @@ -16,6 +19,15 @@ void main() { }); } + for (final largura in const [360.0, 320.0]) { + testWidgets('detalhe de pedido do titular não estoura com fonte a 200% em ${largura.toInt()}dp', (tester) async { + await abrirBackoffice(tester, tamanho: Size(largura, 800), escalaDeFonte: 2.0); + await irPara(tester, 'Pedidos do titular'); + await abrirPedidoDoTitular(tester, 'req-a18f'); + await percorrerTelaInteira(tester, 'detalhe do pedido (${largura.toInt()}dp, 200%)', lista: rolagemDosPedidos()); + }); + } + 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. @@ -29,4 +41,44 @@ void main() { expect(alturaAmpliada, greaterThan(alturaPadrao)); esperarSemEstouroDeLayout(tester, 'cabeçalho com fonte a 200%'); }); + + // O login ganhou o campo do código e há a tela de ativação do MFA: as duas + // têm de caber a 130%/200% de fonte e em 320dp de largura (WCAG 1.4.4/1.4.10). + for (final largura in const [360.0, 320.0]) { + for (final escala in const [1.3, 2.0]) { + final rotulo = '${largura.toInt()}dp, fonte a ${(escala * 100).toInt()}%'; + + testWidgets('login com campo do código não estoura em $rotulo', (tester) async { + redimensionar(tester, Size(largura, 800), escalaDeFonte: escala); + addTearDown(tester.view.reset); + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + final auth = FakeAdminAuth()..requiresTotp = true; + await tester.pumpWidget(SinalAdminApp(auth: auth)); + await tester.pumpAndSettle(); + await percorrerTelaInteira(tester, 'login ($rotulo)'); + // Primeira tentativa sem código: o campo do código aparece. + await entrarComCredenciais(tester); + expect(find.byKey(const Key('totp_field')), findsOneWidget); + await percorrerTelaInteira(tester, 'login com código ($rotulo)'); + }); + + testWidgets('ativação do MFA não estoura em $rotulo', (tester) async { + redimensionar(tester, Size(largura, 800), escalaDeFonte: escala); + addTearDown(tester.view.reset); + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + final auth = FakeAdminAuth()..failWith = const AdminMfaEnrollmentRequired(); + await tester.pumpWidget(SinalAdminApp(auth: auth)); + await entrarComCredenciais(tester); + expect(find.byKey(const Key('activation_code_field')), findsOneWidget); + await percorrerTelaInteira(tester, 'código de ativação ($rotulo)'); + await tester.enterText(find.byKey(const Key('activation_code_field')), 'ABCD-EFGH'); + await tester.ensureVisible(find.byKey(const Key('activation_continue'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('activation_continue'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('mfa_secret')), findsOneWidget); + await percorrerTelaInteira(tester, 'ativação ($rotulo)'); + }); + } + } } diff --git a/apps/admin/test/touch_targets_test.dart b/apps/admin/test/touch_targets_test.dart index ac87f22c..3e112b3f 100644 --- a/apps/admin/test/touch_targets_test.dart +++ b/apps/admin/test/touch_targets_test.dart @@ -4,6 +4,7 @@ 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/fake_admin_auth.dart'; import 'support/layout_harness.dart'; /// WCAG 2.5.5, na régua de `spec/ux_accessibility_assessment.md`: 48dp. @@ -11,11 +12,60 @@ import 'support/layout_harness.dart'; /// 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. +/// mínimo. O tema do backoffice não define botão (`admin_theme.dart` não tem +/// `outlinedButtonTheme`/`filledButtonTheme`), então a correção é botão a botão, +/// como `login_screen.dart:184` já fazia. +/// +/// A gestão de contas (#43) é o caso novo: a fileira de ações do cartão de ACS +/// e as ações do diálogo destrutivo declaram o próprio `minimumSize` e cada uma +/// tem o seu caso aqui. +/// +/// Medido, e é por isso que cada botão é conferido em **duas** medidas: +/// `getSize` devolve o **alvo** (a caixa que recebe o toque), não a tinta. O +/// `AlertDialog` do Material 3 pinta ações de 40dp e o +/// `MaterialTapTargetSize.padded` do tema infla o alvo para 48 — com o +/// `minimumSize` removido o alvo continuava passando raspando, e só a medida da +/// tinta ([alturaPintada], o `Material` interno) denunciava o botão de 40dp. A +/// régua deste projeto é a altura pintada (é ela que +/// `spec/ux_accessibility_assessment.md` registra como "40dp de altura +/// visual"), então as duas valem: o alvo é o que a WCAG 2.5.5 pede e a tinta é +/// o que se vê e se acerta com o dedo. void main() { const alturaMinima = 48.0; + /// Traz [alvo] para dentro da janela antes de medir ou tocar. + /// + /// Não é zelo: a lista é preguiçosa e medir um widget que não foi construído + /// lança em vez de medir, enquanto tocar num que está fora da viewport acerta + /// o vazio sem lançar nada — o teste seguiria "passando" sem tocar no botão. + Future rolarAte(WidgetTester tester, Finder alvo) async { + if (alvo.evaluate().isEmpty) { + await tester.scrollUntilVisible(alvo, 320, scrollable: find.byType(Scrollable).last); + } + await tester.ensureVisible(alvo); + await tester.pumpAndSettle(); + } + + Future abrirMicroAreas(WidgetTester tester) async { + await abrirBackoffice(tester, tamanho: const Size(360, 800)); + await irPara(tester, 'Microáreas'); + } + + /// Altura da TINTA do botão — o `Material` que o `ButtonStyleButton` monta + /// por dentro do `ConstrainedBox` das `minimumSize`. + /// + /// É a medida que pega a remoção do `minimumSize`: sem ela, o botão do + /// `AlertDialog` volta a pintar 40dp enquanto o alvo segue com 48 — ver o + /// cabeçalho. + double alturaPintada(WidgetTester tester, Finder botao) => + tester.getSize(find.descendant(of: botao, matching: find.byType(Material)).first).height; + + /// Confere as duas medidas da WCAG 2.5.5 de um botão: alvo e tinta. + void conferirAlvo(WidgetTester tester, Finder botao, String rotulo) { + expect(tester.getSize(botao).height, greaterThanOrEqualTo(alturaMinima), reason: '$rotulo (alvo de toque)'); + expect(alturaPintada(tester, botao), greaterThanOrEqualTo(alturaMinima), reason: '$rotulo (altura pintada)'); + } + testWidgets('o botão de tentar novamente tem pelo menos 48dp de altura', (tester) async { final dataSource = FailingAdminDataSource(inner: MockAdminDataSource())..failNextIndicators = true; @@ -30,7 +80,7 @@ void main() { redimensionar(tester, const Size(360, 800)); addTearDown(tester.view.reset); - await tester.pumpWidget(SinalAdminApp(devLoginEnabled: true)); + await tester.pumpWidget(SinalAdminApp(auth: FakeAdminAuth())); await tester.pumpAndSettle(); final botao = tester.getSize(find.byKey(const Key('login_button'))); @@ -43,4 +93,61 @@ void main() { final barra = tester.getSize(find.byKey(const Key('admin_navigation_bar'))); expect(barra.height, greaterThanOrEqualTo(alturaMinima)); }); + + testWidgets('pedidos do titular: linha da fila, filtro, voltar e ações têm pelo menos 48dp', (tester) async { + await abrirBackoffice(tester, tamanho: const Size(360, 800)); + await irPara(tester, 'Pedidos do titular'); + + await rolarAtePedido(tester, find.byKey(const Key('data_request_req-a18f'))); + expect(tester.getSize(find.byKey(const Key('data_request_req-a18f'))).height, greaterThanOrEqualTo(alturaMinima)); + expect(tester.getSize(find.byKey(const Key('data_requests_status_filter'))).height, greaterThanOrEqualTo(alturaMinima)); + + await abrirPedidoDoTitular(tester, 'req-a18f'); + for (final chave in const ['data_request_back', 'start_review_button', 'complete_button', 'reject_button']) { + final alvo = find.byKey(Key(chave)); + await rolarAtePedido(tester, alvo); + final tamanho = tester.getSize(alvo); + expect(tamanho.height, greaterThanOrEqualTo(alturaMinima), reason: chave); + expect(tamanho.width, greaterThanOrEqualTo(alturaMinima), reason: chave); + } + }); + testWidgets('o botão de cadastrar ACS tem pelo menos 48dp de altura', (tester) async { + await abrirMicroAreas(tester); + + final botao = find.byKey(const Key('novo_acs')); + await rolarAte(tester, botao); + conferirAlvo(tester, botao, 'novo_acs'); + }); + + testWidgets('as ações do cartão de ACS têm pelo menos 48dp de altura', (tester) async { + await abrirMicroAreas(tester); + + // As quatro saem do mesmo `OutlinedButton.icon` com `minimumSize`; pinar + // uma só deixaria as outras três regredirem em silêncio. + for (final chave in const [ + 'vincular_acs_acs-1', + 'redefinir_senha_acs-1', + 'redefinir_mfa_acs-1', + 'desativar_acs_acs-1', + ]) { + final botao = find.byKey(Key(chave)); + await rolarAte(tester, botao); + conferirAlvo(tester, botao, chave); + } + }); + + testWidgets('os botões do diálogo de confirmação têm pelo menos 48dp', (tester) async { + await abrirMicroAreas(tester); + + final desativar = find.byKey(const Key('desativar_acs_acs-1')); + await rolarAte(tester, desativar); + await tester.tap(desativar); + await tester.pumpAndSettle(); + + // DENTRO do diálogo e não na tela de fundo: é aqui que o mínimo não vem de + // graça — sem o `minimumSize` de `_ConfirmacaoDestrutiva._estilo` os dois + // pintam 40dp e só o alvo do tema os segura em 48. + conferirAlvo(tester, find.byKey(const Key('confirmar_acao')), 'confirmar_acao'); + conferirAlvo(tester, find.byKey(const Key('cancelar_acao')), 'cancelar_acao'); + }); } diff --git a/apps/admin/tool/schedule_notice.dart b/apps/admin/tool/schedule_notice.dart new file mode 100644 index 00000000..162949e9 --- /dev/null +++ b/apps/admin/tool/schedule_notice.dart @@ -0,0 +1,147 @@ +/// Agenda UM aviso comunitário pelo endpoint REAL `admin.scheduleNotice` (#46), +/// para o `push_e2e.sh` provar agendamento → laço do servidor → Gorush/FCM. +/// +/// dart run tool/schedule_notice.dart [--host https://localhost/] +/// +/// Entra como o staff (admin) das fixtures de e2e: ativa a MFA com o código de +/// ativação do manifesto, espera o passo TOTP seguinte (o servidor recusa o +/// mesmo código duas vezes) e entra com TOTP. Só serve à stack de e2e: as +/// fixtures são novas a cada execução. Nunca imprime senha, código, token nem o +/// texto do aviso; imprime só o id. Recusa ou falha: mensagem e código 2. +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:sinalacs_client/sinalacs_client.dart'; + +import '../integration_test/support/totp.dart'; + +class StaffDoManifesto { + const StaffDoManifesto({ + required this.matricula, + required this.senha, + required this.codigoAtivacao, + required this.microAreaId, + }); + + final String matricula; + final String senha; + final String codigoAtivacao; + final String microAreaId; +} + +/// Lê o manifesto de `seed_e2e_fixtures.dart`. O erro cita só o campo ausente. +StaffDoManifesto lerManifesto(String json) { + final m = jsonDecode(json) as Map; + final staff = m['staff']; + final area = m['microAreaId']; + if (staff is! Map || area is! String) { + throw const FormatException('manifesto sem "staff" ou "microAreaId"'); + } + String campo(String nome) { + final v = staff[nome]; + if (v is! String) throw FormatException('manifesto sem "staff.$nome"'); + return v; + } + + return StaffDoManifesto( + matricula: campo('matricula'), + senha: campo('password'), + codigoAtivacao: campo('activationCode'), + microAreaId: area, + ); +} + +/// Passo TOTP (RFC 6238, 30 s) do instante. +int passoTotp(DateTime instante) => instante.toUtc().millisecondsSinceEpoch ~/ 1000 ~/ 30; + +String _arg(List args, String name, String fallback) { + final index = args.indexOf('--$name'); + return index >= 0 && index + 1 < args.length ? args[index + 1] : fallback; +} + +/// A CA que assina o certificado do Traefik (a mesma do `tool/send_notice.dart` do ACS). +File _devRpcCaFile() { + final repoRoot = Directory.fromUri(Platform.script).parent.parent.parent.parent; + return File('${repoRoot.path}/infra/docker/traefik/runtime/certs/ca.crt'); +} + +Future main(List args) async { + if (args.isEmpty || args.first.startsWith('-')) { + stderr.writeln('uso: dart run tool/schedule_notice.dart [--host https://localhost/]'); + exitCode = 2; + return; + } + final host = _arg(args, 'host', 'https://localhost/'); + if (!host.startsWith('https://')) { + stderr.writeln('erro: o RPC é HTTPS (RNF04); recusando $host'); + exitCode = 2; + return; + } + final StaffDoManifesto staff; + try { + staff = lerManifesto(await File(args.first).readAsString()); + } on Object catch (e) { + stderr.writeln('erro: manifesto ilegível (${e is FormatException ? e.message : e.runtimeType})'); + exitCode = 2; + return; + } + final ca = _devRpcCaFile(); + if (!await ca.exists()) { + stderr.writeln('erro: a CA do RPC não existe em ${ca.path}. Suba a stack.'); + exitCode = 2; + return; + } + + final client = Client( + host, + securityContext: SecurityContext()..setTrustedCertificatesBytes(await ca.readAsBytes()), + )..connectivityMonitor = null; + try { + final inscricao = await client.auth.beginStaffTotpEnrollment( + matricula: staff.matricula, + password: staff.senha, + activationCode: staff.codigoAtivacao, + ); + final segredo = base32Decode(inscricao.secretBase32); + final ativacao = DateTime.now(); + await client.auth.confirmStaffTotpEnrollment( + matricula: staff.matricula, + password: staff.senha, + activationCode: staff.codigoAtivacao, + code: totpCode(segredo, ativacao), + ); + // Anti-replay: o código do login precisa ser de um passo POSTERIOR ao da ativação. + while (passoTotp(DateTime.now()) <= passoTotp(ativacao)) { + await Future.delayed(const Duration(seconds: 1)); + } + final sessao = await client.auth.loginStaff( + matricula: staff.matricula, + password: staff.senha, + totpCode: totpCode(segredo, DateTime.now()), + ); + final id = await client.admin.scheduleNotice( + accessToken: sessao.accessToken, + microAreaId: staff.microAreaId, + audience: 'everyone', + title: 'SinalACS agendado e2e', + message: 'Aviso agendado do Gorush', + // O servidor exige ao menos 1 min (`noticeMinimumLeadTime`). + scheduledFor: DateTime.now().toUtc().add(const Duration(seconds: 70)), + ); + stdout.writeln('Aviso agendado de e2e criado (id=$id).'); + } on AdminInvalidRequestException catch (e) { + stderr.writeln('recusado: ${e.message}'); + exitCode = 2; + } on AlertPermissionException { + stderr.writeln('recusado: sem permissão para agendar'); + exitCode = 2; + } on Object catch (e) { + // Só o tipo: a mensagem de uma exceção de auth pode ecoar a entrada. + stderr.writeln('falhou: ${e.runtimeType}'); + exitCode = 2; + } finally { + client.close(); + } +} diff --git a/apps/patient/.flutter-plugins b/apps/patient/.flutter-plugins deleted file mode 100644 index 53146151..00000000 --- a/apps/patient/.flutter-plugins +++ /dev/null @@ -1,13 +0,0 @@ -# This is a generated file; do not edit or check into version control. -connectivity_plus=/home/codespace/.pub-cache/hosted/pub.dev/connectivity_plus-7.3.1/ -flutter_local_notifications=/home/codespace/.pub-cache/hosted/pub.dev/flutter_local_notifications-17.2.4/ -flutter_local_notifications_linux=/home/codespace/.pub-cache/hosted/pub.dev/flutter_local_notifications_linux-4.0.1/ -geolocator=/home/codespace/.pub-cache/hosted/pub.dev/geolocator-12.0.0/ -geolocator_android=/home/codespace/.pub-cache/hosted/pub.dev/geolocator_android-4.6.2/ -geolocator_apple=/home/codespace/.pub-cache/hosted/pub.dev/geolocator_apple-2.3.14/ -geolocator_web=/home/codespace/.pub-cache/hosted/pub.dev/geolocator_web-4.1.4/ -geolocator_windows=/home/codespace/.pub-cache/hosted/pub.dev/geolocator_windows-0.2.5/ -sqflite=/home/codespace/.pub-cache/hosted/pub.dev/sqflite-2.4.1/ -sqflite_android=/home/codespace/.pub-cache/hosted/pub.dev/sqflite_android-2.4.0/ -sqflite_darwin=/home/codespace/.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.1+1/ -sqflite_sqlcipher=/home/codespace/.pub-cache/hosted/pub.dev/sqflite_sqlcipher-3.2.0/ diff --git a/apps/patient/.flutter-plugins-dependencies b/apps/patient/.flutter-plugins-dependencies deleted file mode 100644 index fd6a93f8..00000000 --- a/apps/patient/.flutter-plugins-dependencies +++ /dev/null @@ -1 +0,0 @@ -{"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/.gitignore b/apps/patient/.gitignore index 3820a95c..ba6d2380 100644 --- a/apps/patient/.gitignore +++ b/apps/patient/.gitignore @@ -27,6 +27,7 @@ migrate_working_dir/ **/doc/api/ **/ios/Flutter/.last_build_id .dart_tool/ +.flutter-plugins .flutter-plugins-dependencies .pub-cache/ .pub/ diff --git a/apps/patient/README.md b/apps/patient/README.md index f56822d8..e4a5921e 100644 --- a/apps/patient/README.md +++ b/apps/patient/README.md @@ -1,5 +1,5 @@ # SinalACS — App Paciente -Aplicativo Flutter do paciente: autenticação simples, alerta de urgência, triagem estruturada com classificação de risco determinística e acompanhamento de status da solicitação. +Aplicativo Flutter do paciente: login sem senha (CPF + data de nascimento + OTP) e cadastro por convite em QR Code, alerta de urgência, triagem estruturada com classificação de risco determinística, acompanhamento da solicitação, lembretes, avisos por push e "Meus Dados" (direitos do titular, LGPD). Ver o [README.md](../../README.md) na raiz do repositório para pré-requisitos, comandos de execução/teste e o estado atual do projeto, e [PROGRESS.md](../../PROGRESS.md) para o status detalhado dos milestones. Documentação visual das telas em [docs/telas-paciente.md](../../docs/telas-paciente.md). diff --git a/apps/patient/android/app/build.gradle.kts b/apps/patient/android/app/build.gradle.kts index 36e0f79c..3923a78e 100644 --- a/apps/patient/android/app/build.gradle.kts +++ b/apps/patient/android/app/build.gradle.kts @@ -4,6 +4,13 @@ plugins { id("dev.flutter.flutter-gradle-plugin") } +// `google-services.json` não é versionado (a CI e outros devs não o têm): sem ele o +// plugin derrubaria o build inteiro. Só aplica quando o arquivo existe; sem ele o +// app compila e o lado nativo responde "firebase_unavailable" (sem push, RF14). +if (file("google-services.json").exists()) { + apply(plugin = "com.google.gms.google-services") +} + android { namespace = "br.com.prismrr.sinalacs.patient" compileSdk = 36 @@ -36,6 +43,7 @@ android { dependencies { coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5") + implementation("com.google.firebase:firebase-messaging:25.1.3") } kotlin { diff --git a/apps/patient/android/app/src/main/AndroidManifest.xml b/apps/patient/android/app/src/main/AndroidManifest.xml index ebccdd39..71e8fbb7 100644 --- a/apps/patient/android/app/src/main/AndroidManifest.xml +++ b/apps/patient/android/app/src/main/AndroidManifest.xml @@ -10,6 +10,16 @@ sem isso, os alarmes agendados via `flutter_local_notifications` somem em cada reboot (não sobrevivem sozinhos). --> + + + + + + + + if (call.method != "getToken") { + result.notImplemented() + return@setMethodCallHandler + } + // Sem google-services.json (CI, outro dev) o FirebaseApp não existe. + if (FirebaseApp.getApps(this).isEmpty()) { + result.error("firebase_unavailable", "FirebaseApp não inicializado", null) + return@setMethodCallHandler + } + FirebaseMessaging.getInstance().token.addOnCompleteListener { task -> + val token = if (task.isSuccessful) task.result else null + if (!token.isNullOrBlank()) { + result.success(mapOf("token" to token, "platform" to "android")) + } else { + result.error("token_unavailable", task.exception?.message, null) + } + } + } + } +} diff --git a/apps/patient/android/settings.gradle.kts b/apps/patient/android/settings.gradle.kts index c21f0c5b..196d24ae 100644 --- a/apps/patient/android/settings.gradle.kts +++ b/apps/patient/android/settings.gradle.kts @@ -21,6 +21,7 @@ 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 + id("com.google.gms.google-services") version "4.5.0" apply false } include(":app") diff --git a/apps/patient/integration_test/backend_connection_test.dart b/apps/patient/integration_test/backend_connection_test.dart index a863089f..d2d692d6 100644 --- a/apps/patient/integration_test/backend_connection_test.dart +++ b/apps/patient/integration_test/backend_connection_test.dart @@ -38,7 +38,8 @@ import 'package:sinalacs_patient/core/network/backend_config.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'; +import 'support/e2e_login.dart'; + /// Bytes da CA de desenvolvimento do RPC, lidos do bundle do app. /// @@ -61,7 +62,10 @@ void main() { late BackendClient backend; - setUp(() async { + // Uma sessão para o arquivo inteiro: o servidor impõe 60 s entre dois pedidos de + // OTP do mesmo paciente, então logar de novo a cada teste só funciona com o + // login de desenvolvimento. O teste de sessão confere o que esta obteve. + setUpAll(() async { final caBytes = await _devRpcCaBytes(); if (caBytes == null) { fail( @@ -71,8 +75,9 @@ void main() { ); } backend = BackendClient(trustedCaBytes: caBytes); + await loginPatient(backend); }); - tearDown(() => backend.close()); + tearDownAll(() => backend.close()); test('a stack responde à sonda de saúde', () async { final health = await backend.health(); @@ -83,18 +88,16 @@ void main() { }); test('o login devolve uma sessão com a microárea do seed', () async { - final session = await backend.developmentLogin(role: 'patient'); + final session = backend.session!; 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.microAreaId, expectedMicroArea()); expect(session.isExpired(), isFalse); }); test('a triagem é classificada pelo motor do servidor', () async { - await backend.developmentLogin(role: 'patient'); - final red = await backend.evaluateTriage( chestPain: true, difficultyBreathing: false, @@ -126,8 +129,6 @@ void main() { }); test('a mesma resposta produz sempre o mesmo risco', () async { - await backend.developmentLogin(role: 'patient'); - // Determinismo é invariante (INV-02): a classificação não pode variar entre // chamadas idênticas. final results = []; @@ -146,7 +147,6 @@ void main() { }); test('o alerta vermelho é criado e o reenvio não duplica', () async { - await backend.developmentLogin(role: 'patient'); final key = newIdempotencyKey(); final hash = locationHashFrom(-23.55052, -46.633308); @@ -161,7 +161,6 @@ void main() { }); test('a chave de idempotência reusada com outra localização é recusada', () async { - await backend.developmentLogin(role: 'patient'); final key = newIdempotencyKey(); await backend.createRedAlert( diff --git a/apps/patient/integration_test/full_journey_test.dart b/apps/patient/integration_test/full_journey_test.dart new file mode 100644 index 00000000..3703e13e --- /dev/null +++ b/apps/patient/integration_test/full_journey_test.dart @@ -0,0 +1,169 @@ +/// Jornada completa do paciente, pela TELA, contra a stack de e2e (banco de +/// teste, sem login de desenvolvimento): OTP real, termos, triagem, alerta, +/// status e "Meus dados" (o território do ACS é `tool/territory_check.dart`, no host). Só roda com as fixtures +/// (`--dart-define=E2E_FIXTURES=...`, ver `scripts/qa/patient_full_e2e.sh`); sem +/// elas o grupo é pulado, então `e2e.sh --full` não quebra. +/// +/// O servidor impõe 60 s entre dois pedidos de código do MESMO paciente, por +/// isso cada teste usa o seu: `main` (código errado e certo), `chronic` (a +/// jornada inteira). O GPS é um leitor fixo: o diálogo de permissão do sistema +/// não é alcançável por `flutter test`, e o alerta tem de sair mesmo assim. +/// +/// PRIVACIDADE: só dados sintéticos das fixtures; CPF, código e token nunca são +/// impressos. +library; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show rootBundle; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:sinalacs_patient/app/app.dart'; +import 'package:sinalacs_patient/core/network/backend_client.dart'; +import 'package:sinalacs_patient/core/network/backend_config.dart'; +import 'package:sinalacs_patient/core/privacy/location_hash.dart'; + +import '../test/support/e2e_config.dart'; +import '../test/support/e2e_login_core.dart'; +import 'support/e2e_login.dart'; + +const _relay = String.fromEnvironment('OTP_RELAY', defaultValue: 'http://localhost:8765/code'); + +class _FixedLocation implements LocationReader { + @override + Future read({Duration timeout = const Duration(seconds: 8)}) async => + const LocationAvailable('e2ehash00001', '-2356:-4664'); +} + +/// Bate frames até [finder] aparecer: com rede de verdade o `pumpAndSettle` +/// pode terminar antes da resposta chegar (não há frame agendado enquanto se +/// espera o socket). +Future pumpUntil(WidgetTester tester, Finder finder, {Duration timeout = const Duration(seconds: 30)}) async { + final limit = DateTime.now().add(timeout); + while (DateTime.now().isBefore(limit)) { + await tester.pump(const Duration(milliseconds: 250)); + if (finder.evaluate().isNotEmpty) return; + } + throw TestFailure('não apareceu em ${timeout.inSeconds}s: $finder'); +} + +Future tapKey(WidgetTester tester, String key) async { + final finder = find.byKey(Key(key)); + await tester.ensureVisible(finder); + await tester.pump(const Duration(milliseconds: 200)); + await tester.tap(finder); + await tester.pump(const Duration(milliseconds: 300)); +} + +String _ddmmyyyy(DateTime d) => + '${d.day.toString().padLeft(2, '0')}/${d.month.toString().padLeft(2, '0')}/${d.year}'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + final config = e2eConfig(); + + Future backendReal() async { + final ca = (await rootBundle.load(BackendConfig.rpcCaAsset)).buffer.asUint8List(); + return BackendClient(trustedCaBytes: ca); + } + + /// Preenche CPF e data, pede o código e devolve o instante do pedido. + Future pedirCodigo(WidgetTester tester, E2ePatientFixture p) async { + await tester.enterText(find.byKey(const Key('cpf_field')), p.cpf); + await tester.enterText(find.byKey(const Key('birth_date_field')), _ddmmyyyy(p.birthDate)); + final pedidoEm = await relayNow(_relay); + await tapKey(tester, 'enter_button'); + await pumpUntil(tester, find.byKey(const Key('otp_code_field'))); + return pedidoEm; + } + + Future digitarCodigo(WidgetTester tester, String code) async { + await tester.enterText(find.byKey(const Key('otp_code_field')), code); + await tapKey(tester, 'verify_code_button'); + } + + /// Passa pelo convite de termos, se o paciente ainda não os aceitou. + Future aceitarTermosSePedido(WidgetTester tester) async { + final termos = find.byKey(const Key('terms_gate_accept_button')); + final home = find.text('Urgência'); + final limit = DateTime.now().add(const Duration(seconds: 30)); + while (termos.evaluate().isEmpty && home.evaluate().isEmpty) { + if (DateTime.now().isAfter(limit)) throw TestFailure('nem termos nem home após o login'); + await tester.pump(const Duration(milliseconds: 250)); + } + if (termos.evaluate().isNotEmpty) { + await tapKey(tester, 'terms_gate_checkbox'); + await tapKey(tester, 'terms_gate_accept_button'); + await pumpUntil(tester, home); + } + } + + group( + 'jornada do paciente contra o banco de teste', + skip: config == null ? 'defina E2E_FIXTURES (scripts/qa/patient_full_e2e.sh)' : false, + () { + testWidgets('código errado não entra; o certo, pedido uma única vez, entra', (tester) async { + final backend = await backendReal(); + addTearDown(backend.close); + await tester.pumpWidget(SinalAcsApp(backend: backend, locationReader: _FixedLocation())); + final p = config!.patient('main'); + + final pedidoEm = await pedirCodigo(tester, p); + await digitarCodigo(tester, '000000'); + await pumpUntil(tester, find.byKey(const Key('login_error'))); + expect(find.byKey(const Key('panic_button')), findsNothing, reason: 'código errado não abre a sessão'); + + // O código verdadeiro é o do PRIMEIRO pedido: errar não gasta outro SMS. + await digitarCodigo(tester, await codeFromRelay(_relay, pedidoEm)); + await aceitarTermosSePedido(tester); + expect(find.text('Urgência'), findsOneWidget); + // E de fato foi UM pedido: errar o código não pode ter gasto outro SMS. + expect(await relayCount(_relay, pedidoEm), 1); + }); + + testWidgets('triagem vermelha, alerta, status e Meus dados do próprio paciente', (tester) async { + final backend = await backendReal(); + addTearDown(backend.close); + await tester.pumpWidget(SinalAcsApp(backend: backend, locationReader: _FixedLocation())); + final p = config!.patient('chronic'); + + final pedidoEm = await pedirCodigo(tester, p); + await digitarCodigo(tester, await codeFromRelay(_relay, pedidoEm)); + await aceitarTermosSePedido(tester); + + // Triagem: dor no peito, "não" ao resto; quem classifica é o servidor. + for (final symptom in TriageSymptom.values) { + final key = symptom == TriageSymptom.chestPain ? symptom.key : '${symptom.key}_no'; + await tester.tap(find.byKey(Key(key))); + await tester.pump(const Duration(milliseconds: 200)); + await tester.tap(find.byKey(const Key('submit_triage'))); + await tester.pump(const Duration(milliseconds: 400)); + } + await pumpUntil(tester, find.text('Risco: Vermelho')); + + // Alerta de urgência: sai mesmo sem o GPS do sistema. + await tester.tap(find.text('Urgência')); + await tester.pump(const Duration(milliseconds: 400)); + await tapKey(tester, 'panic_button'); + await tester.tap(find.text('Confirmar alerta')); + await pumpUntil(tester, find.textContaining('Alerta recebido pela equipe')); + + // Status real, não o vazio. + await tester.tap(find.text('Status')); + await pumpUntil(tester, find.text('Enviado — aguardando confirmação da equipe')); + expect(find.byKey(const Key('status_empty')), findsNothing); + + // Meus dados: o cadastro da fixture, a condição crônica decifrada, e só. + tester.view.physicalSize = const Size(800, 2400); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.tap(find.text('Mais')); + await tester.pump(const Duration(milliseconds: 400)); + await tester.tap(find.text('Meus dados')); + await pumpUntil(tester, find.text(p.name)); + expect(find.textContaining('ipertens'), findsWidgets); + expect(find.text(config.patient('main').name), findsNothing); + expect(find.text(config.patient('outsider').name), findsNothing); + }); + }, + ); +} diff --git a/apps/patient/integration_test/push_native_token_test.dart b/apps/patient/integration_test/push_native_token_test.dart new file mode 100644 index 00000000..d93cae1b --- /dev/null +++ b/apps/patient/integration_test/push_native_token_test.dart @@ -0,0 +1,30 @@ +/// Prova, no aparelho, que o canal nativo devolve um token FCM REAL. Precisa de +/// Play Services, internet e do `google-services.json` no build. Nunca imprime o +/// token: só confere forma e tamanho. +library; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:sinalacs_patient/core/push/native_push_token_source.dart'; + +/// Só roda com `--dart-define=PUSH_E2E=1` (o `scripts/qa/push_e2e.sh` passa). Precisa do +/// `google-services.json` (ignorado pelo git) e de um Google Play Services: sem ele a +/// bateria `e2e.sh --emulator --full` ficaria vermelha para quem não tem as credenciais. +// `String`, e não `bool.fromEnvironment`: este só aceita o texto `true`, e o script passa `=1`. +const _pushE2e = String.fromEnvironment('PUSH_E2E') == '1'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + test('o canal nativo devolve um token FCM real do aparelho', skip: !_pushE2e ? 'defina --dart-define=PUSH_E2E=1' : false, () async { + final device = await const NativePushTokenSource() + .currentDevice() + .timeout(const Duration(seconds: 60)); + + expect(device, isNotNull, + reason: 'sem token: o lado nativo falta, ou o build não tem o google-services.json'); + expect(device!.platform, 'android'); + expect(device.token.length, greaterThan(100)); + expect(device.token.contains(':'), isTrue, reason: 'token FCM tem a forma :'); + }); +} diff --git a/apps/patient/integration_test/push_register_test.dart b/apps/patient/integration_test/push_register_test.dart new file mode 100644 index 00000000..1ba3a209 --- /dev/null +++ b/apps/patient/integration_test/push_register_test.dart @@ -0,0 +1,77 @@ +/// Registra no backend REAL o token FCM REAL do aparelho, com consentimento (RF14). +/// Pré-requisitos: a stack de pé com `ENABLE_DEV_LOGIN=true`, Play Services, o +/// `google-services.json` no build e `adb reverse tcp:8443 tcp:443` (ver +/// `scripts/qa/push_e2e.sh`). Nunca imprime o token. +library; + +import 'package:flutter/services.dart' show rootBundle; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:sinalacs_client/sinalacs_client.dart' show ConsentPurpose; +import 'package:sinalacs_patient/core/network/backend_client.dart'; +import 'package:sinalacs_patient/core/network/backend_config.dart'; +import 'package:sinalacs_patient/core/push/native_push_token_source.dart'; + +import 'support/e2e_login.dart'; + +Future?> _devRpcCaBytes() async { + try { + final data = await rootBundle.load(BackendConfig.rpcCaAsset); + return data.buffer.asUint8List(); + } catch (_) { + return null; + } +} + +/// Quanto segurar o app instalado depois do registro (`--dart-define=PUSH_HOLD_SECONDS=N`). +/// +/// O `flutter test integration_test` DESINSTALA o app ao terminar, e o token FCM +/// morre junto (o FCM passa a responder `NotRegistered`). Para provar a ENTREGA o +/// app precisa continuar instalado enquanto `tool/send_notice.dart` envia, então +/// `scripts/qa/push_e2e.sh` usa este intervalo. O padrão, 0, não espera nada. +const _holdSeconds = int.fromEnvironment('PUSH_HOLD_SECONDS'); + +/// Só roda com `--dart-define=PUSH_E2E=1` (o `scripts/qa/push_e2e.sh` passa). Precisa do +/// `google-services.json` (ignorado pelo git) e de um Google Play Services: sem ele a +/// bateria `e2e.sh --emulator --full` ficaria vermelha para quem não tem as credenciais. +// `String`, e não `bool.fromEnvironment`: este só aceita o texto `true`, e o script passa `=1`. +const _pushE2e = String.fromEnvironment('PUSH_E2E') == '1'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + test('registra no backend o token FCM real do aparelho, com consentimento', skip: !_pushE2e ? 'defina --dart-define=PUSH_E2E=1' : false, timeout: const Timeout(Duration(minutes: 6)), () async { + final caBytes = await _devRpcCaBytes(); + if (caBytes == null) { + fail('A CA do RPC não está no bundle (${BackendConfig.rpcCaAsset}). ' + 'Rode ./scripts/dev/sync_dev_ca.sh com a stack de pé.'); + } + final backend = BackendClient(trustedCaBytes: caBytes); + addTearDown(backend.close); + await loginPatient(backend, role: 'push'); + + final device = await const NativePushTokenSource() + .currentDevice() + .timeout(const Duration(seconds: 60)); + expect(device, isNotNull, reason: 'sem token FCM real: rode a Task 2 antes'); + + // Desfaz qualquer consentimento de uma rodada anterior: o teste parte de "sem". + await backend.updateConsent(purpose: ConsentPurpose.segmentedPush, granted: false); + + // Sem consentimento, o servidor recusa e nada é gravado. + await expectLater( + backend.registerPushToken(token: device!.token, platform: device.platform), + throwsA(isA()), + ); + + await backend.updateConsent(purpose: ConsentPurpose.segmentedPush, granted: true); + await backend.registerPushToken(token: device.token, platform: device.platform); // não lança + + if (_holdSeconds > 0) { + // Sinal para o script: o registro terminou e o app fica instalado daqui em diante. + // ignore: avoid_print + print('PUSH_E2E_REGISTERED'); + await Future.delayed(const Duration(seconds: _holdSeconds)); + } + }); +} diff --git a/apps/patient/integration_test/smoke_test.dart b/apps/patient/integration_test/smoke_test.dart index edee8c85..02ee9681 100644 --- a/apps/patient/integration_test/smoke_test.dart +++ b/apps/patient/integration_test/smoke_test.dart @@ -14,7 +14,10 @@ /// /// Mesmos pré-requisitos e `--dart-define` de `backend_connection_test.dart`. /// -/// PRIVACIDADE: só os UUIDs sintéticos do seed; o token nunca é impresso. +/// Login: com `--dart-define=E2E_FIXTURES=...` entra pelo OTP real de uma fixture +/// (ver `scripts/qa/patient_full_e2e.sh`); sem ele, pelo login de desenvolvimento. +/// +/// PRIVACIDADE: só dados sintéticos; o token nunca é impresso. library; import 'package:flutter/services.dart' show rootBundle; @@ -26,7 +29,7 @@ import 'package:sinalacs_patient/core/network/backend_config.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'; +import 'support/e2e_login.dart'; /// Mesma leitura de `backend_connection_test.dart`: sem a CA no bundle o /// handshake falha, e o sintoma culparia a rede. @@ -58,9 +61,9 @@ void main() { expect(health.dbConnected, isTrue, reason: 'sem banco, createRedAlert falha por chave estrangeira'); - final session = await backend.developmentLogin(role: 'patient'); + final session = await loginPatient(backend); expect(session.role, 'patient'); - expect(session.microAreaId, seedMicroAreaId); + expect(session.microAreaId, expectedMicroArea()); final risk = await backend.evaluateTriage( chestPain: true, diff --git a/apps/patient/integration_test/support/e2e_login.dart b/apps/patient/integration_test/support/e2e_login.dart new file mode 100644 index 00000000..1a6fc858 --- /dev/null +++ b/apps/patient/integration_test/support/e2e_login.dart @@ -0,0 +1,39 @@ +import 'dart:convert'; + +import 'package:sinalacs_patient/core/network/auth_session.dart'; +import 'package:sinalacs_patient/core/network/backend_client.dart'; + +import '../../test/support/e2e_config.dart'; +import '../../test/support/e2e_login_core.dart'; + +/// Microárea do seed de DESENVOLVIMENTO. Só vale no fallback (sem manifesto), +/// que é o que o `android-e2e` da CI usa; com o manifesto a microárea da +/// fixture é a referência. +const developmentMicroAreaId = '00000000-0000-4000-8000-000000000003'; + +const _fixtures = String.fromEnvironment('E2E_FIXTURES'); +const _otpRelay = String.fromEnvironment( + 'OTP_RELAY', + defaultValue: 'http://localhost:8765/code', +); + +/// O manifesto de fixtures, ou `null` quando o teste roda contra a stack de +/// desenvolvimento (sem `--dart-define=E2E_FIXTURES=...`). +E2eConfig? e2eConfig() => _fixtures.isEmpty + ? null + : E2eConfig.fromMap((jsonDecode(_fixtures) as Map).cast()); + +/// Microárea que a sessão do paciente [role] deve trazer. +String expectedMicroArea({String role = 'api'}) { + final config = e2eConfig(); + return config == null + ? developmentMicroAreaId + : config.patient(role).microAreaId; +} + +/// Entra como o paciente [role] da fixture (OTP real) ou, sem manifesto, pelo +/// login de desenvolvimento. Ver [loginPatientWith]. +Future loginPatient( + BackendClient backend, { + String role = 'api', +}) => loginPatientWith(backend, e2eConfig(), relayUrl: _otpRelay, role: role); diff --git a/apps/patient/lib/app/app.dart b/apps/patient/lib/app/app.dart index 1984fdbc..86e2b7c0 100644 --- a/apps/patient/lib/app/app.dart +++ b/apps/patient/lib/app/app.dart @@ -2,22 +2,41 @@ import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart' show ProviderScope; import 'package:flutter/services.dart' show Clipboard, ClipboardData, TextEditingValue, TextInputFormatter, TextSelection; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:sinalacs_client/sinalacs_client.dart' - show AlertStatus, AlertStatusResult, PatientDataOverview, RiskLevel; + show + AlertStatus, + AlertStatusResult, + ConsentPurpose, + DataSubjectRequestStatus, + DataSubjectRequestType, + PatientDataOverview, + PatientDataSubjectRequestRecord, + TermsChangeNotice, + RiskLevel; +import 'package:sinalacs_patient/app/legal_screens.dart'; import 'package:sinalacs_patient/app/patient_theme.dart'; +import 'package:sinalacs_patient/app/qr_scanner.dart'; +import 'package:sinalacs_patient/core/consent/consent_decisions.dart'; +import 'package:sinalacs_patient/core/legal/legal_documents.dart'; +import 'package:sinalacs_patient/core/legal/terms_change_notice_card.dart'; import 'package:sinalacs_patient/core/consent/consent_preferences.dart'; import 'package:sinalacs_patient/core/consent/sqflite_consent_preferences.dart'; import 'package:sinalacs_patient/core/network/backend_client.dart'; +import 'package:sinalacs_patient/core/push/push_token_provider.dart'; +import 'package:sinalacs_patient/core/push/push_token_source.dart'; import 'package:sinalacs_patient/core/network/backend_scope.dart'; import 'package:sinalacs_patient/core/network/idempotency.dart'; +import 'package:sinalacs_patient/core/onboarding/enrollment_qr.dart'; import 'package:sinalacs_patient/core/privacy/location_hash.dart'; import 'package:sinalacs_patient/core/reminders/reminder.dart'; import 'package:sinalacs_patient/core/reminders/reminder_scheduler.dart'; import 'package:sinalacs_patient/core/reminders/reminder_store.dart'; import 'package:sinalacs_patient/core/reminders/sqflite_reminder_store.dart'; +import 'package:sinalacs_patient/core/services/theme_controller.dart'; class SinalAcsApp extends StatefulWidget { const SinalAcsApp({ @@ -27,6 +46,10 @@ class SinalAcsApp extends StatefulWidget { this.reminderStore, this.reminderScheduler, this.consentPreferences, + this.qrScanner, + this.pushTokens, + this.upcomingDocuments, + this.themeController, }); /// Backend do app. **Obrigatório, e construído em `main.dart`.** @@ -58,6 +81,20 @@ class SinalAcsApp extends StatefulWidget { /// real. final ConsentPreferences? consentPreferences; + /// Injetável para teste. Em execução normal é [scanQrWithCamera], que abre + /// a câmera do aparelho. + final QrScanner? qrScanner; + + /// Injetável para teste. Em execução normal é [NoPushTokenSource]: sem + /// projeto Firebase (RF14, decisão §3.2) o aparelho não tem token, e o + /// registro fica inerte até a implementação do FCM entrar aqui. + final PushTokenSource? pushTokens; + + /// Injetável para teste. Em execução normal é [upcomingLegalDocuments]: o texto + /// da versão dos termos já anunciada (aviso de 15 dias) e ainda não vigente. + final UpcomingLegalDocuments? upcomingDocuments; + final ThemeController? themeController; + @override State createState() => _SinalAcsAppState(); } @@ -70,10 +107,37 @@ class _SinalAcsAppState extends State { widget.reminderScheduler ?? LocalNotificationsReminderScheduler(FlutterLocalNotificationsPlugin()); late final ConsentPreferences _consentPreferences = widget.consentPreferences ?? SqfliteConsentPreferences(); + late final QrScanner _qrScanner = widget.qrScanner ?? scanQrWithCamera; + // O parâmetro (teste) tem precedência; senão, o provider Riverpod, se houver um + // `ProviderScope` acima; senão, sem push. Lido em `didChangeDependencies`. + PushTokenSource _pushTokens = const NoPushTokenSource(); - // Sem `dispose`: este widget não cria mais cliente nenhum (o `main` é quem - // constrói e injeta), então não há o que fechar — fechar um backend injetado - // seria fechar o de quem injetou. + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _pushTokens = widget.pushTokens ?? _providerSource(); + } + + PushTokenSource _providerSource() { + try { + return ProviderScope.containerOf(context, listen: false).read(pushTokenSourceProvider); + } catch (_) { + return const NoPushTokenSource(); + } + } + late final ThemeController _themeController = widget.themeController ?? ThemeController(); + + @override + void initState() { + super.initState(); + _themeController.restore(); + } + + @override + void dispose() { + if (widget.themeController == null) _themeController.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { @@ -85,11 +149,25 @@ class _SinalAcsAppState extends State { store: _reminderStore, scheduler: _reminderScheduler, consentPreferences: _consentPreferences, - child: MaterialApp( - title: 'SinalACS Paciente', - debugShowCheckedModeBanner: false, - theme: buildPatientTheme(), - home: const PatientLoginScreen(), + child: PushTokenScope( + source: _pushTokens, + child: UpcomingDocumentsScope( + documents: widget.upcomingDocuments ?? upcomingLegalDocuments, + child: QrScannerScope( + scanner: _qrScanner, + child: ValueListenableBuilder( + valueListenable: _themeController, + builder: (context, mode, _) => MaterialApp( + title: 'SinalACS Paciente', + debugShowCheckedModeBanner: false, + theme: buildPatientLightTheme(), + darkTheme: buildPatientDarkTheme(), + themeMode: mode, + home: PatientLoginScreen(themeController: _themeController), + ), + ), + ), + ), ), ), ), @@ -97,6 +175,21 @@ class _SinalAcsAppState extends State { } } +/// Disponibiliza o texto dos termos já anunciados (aviso de 15 dias), ou `null`. +/// Mesmo padrão de `QrScannerScope`; sem escopo, `maybeOf` devolve `null` e o +/// cartão oferece só o texto vigente. +class UpcomingDocumentsScope extends InheritedWidget { + const UpcomingDocumentsScope({required this.documents, required super.child, super.key}); + + final UpcomingLegalDocuments? documents; + + static UpcomingLegalDocuments? maybeOf(BuildContext context) => + context.dependOnInheritedWidgetOfExactType()?.documents; + + @override + bool updateShouldNotify(UpcomingDocumentsScope oldWidget) => documents != oldWidget.documents; +} + /// Disponibiliza o [LocationReader] para a árvore de widgets. /// /// Mesmo padrão de `BackendScope`: a tela não constrói o próprio leitor de @@ -154,7 +247,9 @@ class RemindersScope extends InheritedWidget { } class PatientLoginScreen extends StatefulWidget { - const PatientLoginScreen({super.key}); + const PatientLoginScreen({required this.themeController, super.key}); + + final ThemeController themeController; @override State createState() => _PatientLoginScreenState(); @@ -217,6 +312,9 @@ class _CpfInputFormatter extends TextInputFormatter { } } +/// Quanto o login espera pela checagem do aceite do termo antes de entrar sem ela. +const _termsCheckTimeout = Duration(seconds: 3); + class _PatientLoginScreenState extends State { final _cpf = TextEditingController(); final _nascimento = TextEditingController(); @@ -341,6 +439,23 @@ class _PatientLoginScreenState extends State { } } + /// Quem entrou por OTP sem onboarding, ou com aceite de versão anterior, + /// recebe o convite ao aceite (LGPD-RF18). Falhou a leitura → entra direto: + /// o alerta de emergência não espera por um aceite. + Future _needsTerms() async { + try { + // Teto curto: a espera padrão do cliente é de 20 s, e esta checagem não + // pode ficar entre um login já verificado e o alerta de urgência. + final accepted = + await BackendScope.of(context).hasAcceptedCurrentTerms().timeout(_termsCheckTimeout); + return !accepted; + } on BackendFailure { + return false; + } on TimeoutException { + return false; + } + } + /// Verifica o código e só navega em caso de sucesso. /// /// Antes a tela navegava incondicionalmente, ignorando o que era digitado — @@ -363,12 +478,23 @@ class _PatientLoginScreenState extends State { try { await BackendScope.of(context).verifyOtp(cpf: cpf, code: _codigo.text.trim()); if (!mounted) return; + unawaited(registerPushDevice(BackendScope.of(context), PushTokenScope.maybeOf(context))); + final needsTerms = await _needsTerms(); + if (!mounted) return; + MaterialPageRoute home() => MaterialPageRoute( + builder: (_) => PatientHomeShell( + initialDestination: PatientDestination.triage, + themeController: widget.themeController, + ), + ); Navigator.of(context).pushReplacement( - MaterialPageRoute( - builder: (_) => const PatientHomeShell( - initialDestination: PatientDestination.triage, - ), - ), + needsTerms + ? MaterialPageRoute( + builder: (routeContext) => TermsAcceptanceScreen( + onContinue: () => Navigator.of(routeContext).pushReplacement(home()), + ), + ) + : home(), ); } on BackendFailure catch (failure) { if (!mounted) return; @@ -426,7 +552,7 @@ class _PatientLoginScreenState extends State { // telefone cadastrado, e afirmar isso seria inventar. : 'Digite o código de 6 dígitos enviado pela sua unidade de saúde.', textAlign: TextAlign.center, - style: const TextStyle(color: Colors.white70), + style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), ), const SizedBox(height: 24), if (_step == _LoginStep.credenciais) ..._camposDeCredenciais() else ..._camposDoCodigo(), @@ -442,8 +568,8 @@ class _PatientLoginScreenState extends State { key: const Key('login_error'), _error!, textAlign: TextAlign.center, - style: const TextStyle( - color: PatientColors.dangerOnSurface, + style: TextStyle( + color: context.patientRisk.dangerOnSurface, fontWeight: FontWeight.bold, ), ), @@ -456,12 +582,22 @@ class _PatientLoginScreenState extends State { child: OutlinedButton.icon( key: const Key('start_onboarding_button'), onPressed: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const OnboardingScreen()), + MaterialPageRoute( + builder: (_) => OnboardingScreen(themeController: widget.themeController), + ), ), icon: const Icon(Icons.qr_code_scanner_outlined), label: const Text('Escanear QR Code do ACS'), ), ), + const SizedBox(height: 4), + TextButton( + key: const Key('login_legal_link'), + onPressed: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const LegalDocumentsScreen()), + ), + child: const Text('Política de Privacidade e Termo de Uso'), + ), ], ], ), @@ -530,7 +666,7 @@ class _PatientLoginScreenState extends State { key: const Key('otp_cooldown_message'), 'Você pode pedir um novo código em ${restante.inSeconds}s.', textAlign: TextAlign.center, - style: const TextStyle(color: Colors.white70), + style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), ), ), ], @@ -585,11 +721,13 @@ class _PatientLoginScreenState extends State { /// grava os 3 consentimentos por finalidade (LGPD-RF02) antes de ativar a /// sessão do paciente (RF02). /// -/// O campo de texto recebe o valor do token do QR Code — a leitura por -/// câmera é apenas um jeito alternativo de preencher o mesmo campo, não uma -/// dependência nova desta tela (fora de escopo aqui). +/// O campo de texto recebe o token do convite. A leitura do QR Code pela +/// câmera ("Ler QR Code com a câmera") preenche o mesmo campo — digitar +/// continua possível para quem não tem câmera ou negou a permissão. class OnboardingScreen extends StatefulWidget { - const OnboardingScreen({super.key}); + const OnboardingScreen({required this.themeController, super.key}); + + final ThemeController themeController; @override State createState() => _OnboardingScreenState(); @@ -603,8 +741,13 @@ class _OnboardingScreenState extends State { bool _healthDataConsent = false; bool _remindersConsent = false; bool _pushConsent = false; + // Aceite do Termo de Uso e da Política de Privacidade (LGPD-RF18): também + // desmarcado por padrão e obrigatório, gravado pelo servidor com a versão + // vigente dos documentos. + bool _termsAccepted = false; bool _busy = false; + bool _scanning = false; String? _error; @override @@ -613,6 +756,39 @@ class _OnboardingScreenState extends State { super.dispose(); } + Future _scan() async { + if (_scanning) return; + setState(() => _scanning = true); + try { + await _readQr(); + } finally { + if (mounted) setState(() => _scanning = false); + } + } + + Future _readQr() async { + final scanner = QrScannerScope.of(context); + final String? raw; + try { + raw = await scanner(context); + } catch (_) { + if (!mounted) return; + setState(() => _error = 'Não foi possível usar a câmera. Digite o código do convite.'); + return; + } + if (!mounted || raw == null) return; + final token = parseEnrollmentQr(raw); + setState(() { + if (token == null) { + _error = 'Este QR Code não é um convite do SinalACS. Peça ao agente de ' + 'saúde para mostrar o convite de novo.'; + } else { + _tokenController.text = token; + _error = null; + } + }); + } + Future _complete() async { final token = _tokenController.text.trim(); if (token.isEmpty) return; @@ -626,6 +802,12 @@ class _OnboardingScreenState extends State { }); return; } + if (!_termsAccepted) { + setState(() { + _error = 'É preciso aceitar o Termo de Uso e a Política de Privacidade.'; + }); + return; + } setState(() { _busy = true; @@ -638,6 +820,7 @@ class _OnboardingScreenState extends State { healthDataConsent: _healthDataConsent, remindersConsent: _remindersConsent, pushConsent: _pushConsent, + termsAccepted: _termsAccepted, ); if (!mounted) return; // Espelha localmente a resposta já enviada ao backend — é o único @@ -655,12 +838,19 @@ class _OnboardingScreenState extends State { // Intencionalmente silencioso — ver comentário acima. } if (!mounted) return; + // O cadastro acabou de gravar a decisão: sem consentimento, nem chega ao + // provedor; com ele, dispensa a consulta ao servidor. + if (_pushConsent) { + unawaited(registerPushDevice(BackendScope.of(context), PushTokenScope.maybeOf(context), + consentKnownGranted: true)); + } // Mesmo caminho que `_PatientLoginScreenState._enter()` já usa para // entrar na navegação principal — a sessão já está em `BackendScope`, // não há estado novo para duplicar aqui. Navigator.of(context).pushReplacement( MaterialPageRoute( - builder: (_) => const PatientHomeShell( + builder: (_) => PatientHomeShell( + themeController: widget.themeController, initialDestination: PatientDestination.triage, ), ), @@ -695,18 +885,28 @@ class _OnboardingScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const Text( - 'Cole ou digite o código do convite recebido do agente ' - 'comunitário de saúde. A leitura do QR Code preenche o ' - 'mesmo campo.', + Text( + 'Leia o QR Code do convite mostrado pelo agente ' + 'comunitário de saúde, ou digite o código.', textAlign: TextAlign.center, - style: TextStyle(color: Colors.white70), + style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + key: const Key('scan_qr_button'), + onPressed: _busy || _scanning ? null : _scan, + style: OutlinedButton.styleFrom(minimumSize: const Size(48, 52)), + icon: const Icon(Icons.qr_code_scanner_outlined), + label: const Text('Ler QR Code com a câmera'), + ), ), const SizedBox(height: 20), TextField( key: const Key('onboarding_token_field'), controller: _tokenController, - onChanged: (_) => setState(() {}), + onChanged: (_) => setState(() => _error = null), decoration: const InputDecoration(labelText: 'Código do convite'), ), const SizedBox(height: 20), @@ -737,6 +937,40 @@ class _OnboardingScreenState extends State { controlAffinity: ListTileControlAffinity.leading, title: const Text('Recebimento de avisos segmentados por push'), ), + const SizedBox(height: 12), + const Align( + alignment: Alignment.centerLeft, + child: Text('Termo de Uso e Privacidade', style: TextStyle(fontWeight: FontWeight.bold)), + ), + Wrap( + spacing: 8, + children: [ + TextButton( + key: const Key('onboarding_open_terms'), + onPressed: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const LegalDocumentScreen(document: termsOfUse)), + ), + child: const Text('Ler o Termo de Uso'), + ), + TextButton( + key: const Key('onboarding_open_privacy'), + onPressed: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const LegalDocumentScreen(document: privacyPolicy)), + ), + child: const Text('Ler a Política de Privacidade'), + ), + ], + ), + CheckboxListTile( + key: const Key('onboarding_terms_accept'), + value: _termsAccepted, + onChanged: (value) => setState(() => _termsAccepted = value ?? false), + controlAffinity: ListTileControlAffinity.leading, + title: const Text( + 'Li e aceito o Termo de Uso e a Política de Privacidade ' + '(versão $legalDocumentsVersion) (obrigatório)', + ), + ), const SizedBox(height: 20), // `MergeSemantics`, mesma correção do botão de EMERGÊNCIA e dos // logins do paciente/ACS: um `Semantics(button: true)` em volta de @@ -777,8 +1011,8 @@ class _OnboardingScreenState extends State { key: const Key('onboarding_error'), _error!, textAlign: TextAlign.center, - style: const TextStyle( - color: PatientColors.dangerOnSurface, + style: TextStyle( + color: context.patientRisk.dangerOnSurface, fontWeight: FontWeight.bold, ), ), @@ -797,12 +1031,17 @@ class _OnboardingScreenState extends State { } } -enum PatientDestination { emergency, triage, profile, status, reminders, myData } +enum PatientDestination { emergency, triage, profile, status, reminders, myData, settings } class PatientHomeShell extends StatefulWidget { - const PatientHomeShell({super.key, this.initialDestination = PatientDestination.emergency}); + const PatientHomeShell({ + required this.themeController, + super.key, + this.initialDestination = PatientDestination.emergency, + }); final PatientDestination initialDestination; + final ThemeController themeController; @override State createState() => _PatientHomeShellState(); @@ -820,12 +1059,42 @@ class _PatientHomeShellState extends State { /// devolve "sua sessão expirou". bool _sessionExpired = false; + /// Aviso de mudança dos termos (LGPD-RF18), buscado uma vez ao abrir a home. + /// Nunca bloqueia: falha, lentidão ou ausência de `BackendScope` = sem cartão. + TermsChangeNotice? _notice; + bool _noticeDismissed = false; + @override void initState() { super.initState(); _destination = widget.initialDestination; } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_noticeRequested) { + _noticeRequested = true; + unawaited(_loadNotice()); + } + } + + bool _noticeRequested = false; + + Future _loadNotice() async { + // `getInheritedWidgetOfExactType` e não `BackendScope.of`: um shell montado + // sem `BackendScope` (o teste de Lembretes) não pode quebrar por causa de um + // aviso acessório. + final backend = context.getInheritedWidgetOfExactType()?.backend; + if (backend == null) return; + try { + final notice = await backend.termsChangeNotice().timeout(_termsCheckTimeout); + if (mounted) setState(() => _notice = notice); + } catch (_) { + // Sem aviso: a home segue como estava. + } + } + void _select(PatientDestination destination) => setState(() => _destination = destination); /// Reage a uma falha relatada por uma das telas do shell. @@ -845,7 +1114,7 @@ class _PatientHomeShellState extends State { void _reenter() { Navigator.of(context).pushReplacement( - MaterialPageRoute(builder: (_) => const PatientLoginScreen()), + MaterialPageRoute(builder: (_) => PatientLoginScreen(themeController: widget.themeController)), ); } @@ -861,6 +1130,7 @@ class _PatientHomeShellState extends State { PatientDestination.myData => const MyDataScreen(), PatientDestination.status => StatusScreen(onFailure: _reportFailure), PatientDestination.reminders => const RemindersScreen(), + PatientDestination.settings => ThemeSettingsScreen(controller: widget.themeController), }; final title = switch (_destination) { PatientDestination.emergency => 'Alerta de urgência', @@ -869,6 +1139,7 @@ class _PatientHomeShellState extends State { PatientDestination.myData => 'Meus dados', PatientDestination.status => 'Acompanhamento', PatientDestination.reminders => 'Lembretes', + PatientDestination.settings => 'Preferências', }; return Scaffold( @@ -877,6 +1148,33 @@ class _PatientHomeShellState extends State { child: Column( children: [ if (_sessionExpired) _SessionExpiredBanner(onReenter: _reenter), + // Nunca na aba de urgência: o cartão desce o botão de pânico (e um aviso que + // chega de forma assíncrona o moveria sob o dedo). Volta nas outras abas. + if (_notice != null && !_noticeDismissed && _destination != PatientDestination.emergency) + Builder(builder: (context) { + final notice = _notice!; + final docs = UpcomingDocumentsScope.maybeOf(context); + // O texto novo só é oferecido se o app carrega EXATAMENTE a versão que + // o servidor anunciou; senão, o cartão fica só com o texto vigente. + final novo = docs != null && docs.version == notice.version ? docs : null; + return TermsChangeNoticeCard( + notice: notice, + onDismiss: () => setState(() => _noticeDismissed = true), + onRead: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const LegalDocumentsScreen()), + ), + onReadNew: novo == null + ? null + : () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => LegalDocumentsScreen( + upcoming: novo, + effectiveLabel: TermsChangeNoticeCard.formatDate(notice.effectiveFrom), + ), + ), + ), + ); + }), Expanded(child: content), ], ), @@ -933,11 +1231,11 @@ class _SessionExpiredBanner extends StatelessWidget { // de responder ao servidor nem que existe um botão para voltar. Semantics( liveRegion: true, - child: const Text( - key: Key('session_expired_notice'), + child: Text( + key: const Key('session_expired_notice'), 'Sua sessão expirou. Entre novamente com o código de acesso.', textAlign: TextAlign.center, - style: TextStyle(color: Colors.white70), + style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), ), ), const SizedBox(height: 12), @@ -1102,7 +1400,7 @@ class _EmergencyScreenState extends State { // 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)), + child: Text(_state, style: TextStyle(color: context.patientRisk.accentOnSurface, fontWeight: FontWeight.bold)), ), ]))), ], @@ -1209,7 +1507,7 @@ class _TriageScreenState extends State { children: [ Text( 'Passo ${_step + 1} de ${_symptoms.length}', - style: const TextStyle(color: PatientColors.accent, fontWeight: FontWeight.bold), + style: TextStyle(color: context.patientRisk.accentOnSurface, fontWeight: FontWeight.bold), ), const SizedBox(height: 8), LinearProgressIndicator(value: (_step + 1) / _symptoms.length), @@ -1256,7 +1554,7 @@ class _TriageScreenState extends State { key: const Key('triage_error'), _error!, textAlign: TextAlign.center, - style: const TextStyle(color: PatientColors.dangerOnSurface, fontWeight: FontWeight.bold), + style: TextStyle(color: context.patientRisk.dangerOnSurface, fontWeight: FontWeight.bold), ), ), ), @@ -1278,24 +1576,25 @@ class _TriageResult extends StatelessWidget { @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). + // este tom), por isso a variante `OnSurface` do tema ativo entra direto + // na tupla — o fill puro (`PatientColors.danger`/`accent`) falha como + // texto em ambos os temas (ver `PatientRiskColors` em `patient_theme.dart`). + final riskColors = context.patientRisk; final (label, color, guidance) = switch (risk) { RiskLevel.red => ( 'Risco: Vermelho', - PatientColors.dangerOnSurface, + riskColors.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), + riskColors.yellowOnSurface, 'Sua solicitação foi priorizada. O agente de saúde entrará em contato.', ), RiskLevel.green => ( 'Risco: Verde', - PatientColors.accentOnSurface, + riskColors.accentOnSurface, 'Sem sinais de urgência. Sua solicitação entrou na fila de rotina.', ), }; @@ -1318,10 +1617,10 @@ class _TriageResult extends StatelessWidget { const SizedBox(height: 12), Text(guidance, textAlign: TextAlign.center), const SizedBox(height: 8), - const Text( + Text( 'Classificação feita pelo protocolo da equipe de saúde.', textAlign: TextAlign.center, - style: TextStyle(fontSize: 12, color: Colors.white54), + style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant), ), ], ), @@ -1443,7 +1742,7 @@ class _ClinicalProfileScreenState extends State { child: Text( _error!, key: const Key('clinical_profile_error'), - style: const TextStyle(color: PatientColors.dangerOnSurface, fontWeight: FontWeight.bold), + style: TextStyle(color: context.patientRisk.dangerOnSurface, fontWeight: FontWeight.bold), ), ), ), @@ -1505,7 +1804,11 @@ class _MyDataScreenState extends State { PatientDataOverview? _data; String? _error; String? _confirmation; + + /// Texto de uma correção ainda não aceita pelo servidor. + String? _pendingCorrection; bool _requestedLoad = false; + bool _busy = false; @override void didChangeDependencies() { @@ -1519,19 +1822,221 @@ class _MyDataScreenState extends State { } Future _load() async { + final backend = BackendScope.of(context); + final reminders = RemindersScope.of(context); try { - final data = await BackendScope.of(context).myData(); + final data = await backend.myData(); if (!mounted) return; setState(() { _data = data; _error = null; }); + await _alignLocalRemindersMirror(reminders, data); } on BackendFailure catch (failure) { if (!mounted) return; setState(() => _error = failure.message); } } + /// O servidor é a fonte da decisão de lembretes (`consent_logs`); o store + /// local é só o espelho que `RemindersScreen` consulta (ver + /// `ConsentPreferences`). Alinhar aqui cobre a revogação feita nesta tela e + /// também o aparelho que entrou pelo login OTP (RF01) sem passar pelo + /// onboarding, que nunca teve espelho. Sem decisão de lembretes no servidor, + /// não mexe em nada. + Future _alignLocalRemindersMirror(RemindersScope scope, PatientDataOverview data) async { + final granted = currentConsentDecisions(data.consents)[ConsentPurpose.localReminders]; + if (granted == null) return; + await _applyLocalRemindersDecision(scope, granted); + } + + Future _applyLocalRemindersDecision(RemindersScope scope, bool granted) async { + try { + if (await scope.consentPreferences.localRemindersGranted() != granted) { + await scope.consentPreferences.saveLocalRemindersConsent(granted); + } + if (!granted) await deactivateAllReminders(scope); + } catch (_) { + if (!mounted) return; + setState(() => _error = + 'Sua escolha foi registrada, mas os lembretes deste aparelho não puderam ser atualizados.'); + } + } + + Future _confirmRevocation(ConsentPurpose purpose) async { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Revogar consentimento?'), + content: Text( + '${consentPurposeLabel(purpose)}: seus dados deixam de ser usados para esta ' + 'finalidade a partir de agora. Você pode conceder de novo quando quiser.' + '${purpose == ConsentPurpose.localReminders ? ' Os lembretes agendados neste aparelho serão desativados.' : ''}', + ), + actions: [ + TextButton( + key: const Key('consent_revoke_cancel'), + onPressed: () => Navigator.pop(dialogContext, false), + child: const Text('Cancelar'), + ), + FilledButton( + key: const Key('consent_revoke_confirm'), + onPressed: () => Navigator.pop(dialogContext, true), + child: const Text('Revogar'), + ), + ], + ), + ); + return confirmed == true; + } + + /// LGPD-RF05: revogar pede confirmação explícita; conceder, não. O + /// interruptor segue a decisão devolvida pelo servidor no recarregamento, + /// então uma falha o deixa exatamente como estava. + Future _changeConsent(ConsentPurpose purpose, bool granted) async { + if (_busy) return; + if (!granted && !await _confirmRevocation(purpose)) return; + if (!mounted) return; + final backend = BackendScope.of(context); + final reminders = RemindersScope.of(context); + setState(() { + _busy = true; + _error = null; + _confirmation = null; + }); + try { + final record = await backend.updateConsent(purpose: purpose, granted: granted); + if (purpose == ConsentPurpose.segmentedPush && granted && mounted) { + unawaited(registerPushDevice(backend, PushTokenScope.maybeOf(context), consentKnownGranted: true)); + } + // A decisão já está gravada no servidor: aplicar no aparelho aqui, sem + // depender do recarregamento abaixo — se ele falhar, um lembrete + // agendado continuaria disparando depois de uma revogação confirmada. + if (purpose == ConsentPurpose.localReminders) { + await _applyLocalRemindersDecision(reminders, granted); + } + if (!mounted) return; + setState(() => _confirmation = '${consentPurposeLabel(purpose)}: consentimento ' + '${granted ? 'concedido' : 'revogado'} em ${_formatDate(record.timestamp.toLocal())}.'); + await _load(); + } on BackendFailure catch (failure) { + if (!mounted) return; + setState(() => _error = failure.message); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + /// Envia um pedido e recarrega. Mesmo formato de [_changeConsent]: `_busy` + /// desabilita os controles enquanto a chamada está em voo, e é isso que + /// impede o segundo toque de virar segundo pedido. + /// `true` quando o servidor aceitou o pedido. + Future _submitRequest( + Future Function(PatientBackend backend) call, + String done, + ) async { + final backend = BackendScope.of(context); + setState(() { + _busy = true; + _error = null; + _confirmation = null; + }); + try { + final record = await call(backend); + if (!mounted) return true; + setState(() => _confirmation = '$done. Resposta até ${_formatDate(record.dueAt.toLocal())}.'); + await _load(); + return true; + } on BackendFailure catch (failure) { + if (!mounted) return false; + setState(() => _error = failure.message); + return false; + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _requestDeletion() async { + if (_busy) return; + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Solicitar exclusão dos seus dados?'), + content: const Text( + 'A equipe da UBS analisa o pedido em até 15 dias e a resposta aparece aqui. ' + 'Registros de saúde (alertas, triagens e visitas) podem ser mantidos pelo prazo ' + 'legal de 5 anos e anonimizados depois, em vez de apagados. Enquanto o pedido ' + 'estiver em análise, o app continua funcionando — inclusive o botão de emergência.', + ), + actions: [ + TextButton( + key: const Key('deletion_request_cancel'), + onPressed: () => Navigator.pop(dialogContext, false), + child: const Text('Cancelar'), + ), + FilledButton( + key: const Key('deletion_request_confirm'), + onPressed: () => Navigator.pop(dialogContext, true), + child: const Text('Solicitar exclusão'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + await _submitRequest((backend) => backend.requestDataDeletion(), 'Pedido de exclusão registrado'); + } + + Future _requestCorrection() async { + if (_busy) return; + // Sem `barrierDismissible`: tocar fora fecharia o diálogo com `null` e + // apagaria o rascunho. Só "Cancelar" descarta de propósito. + final details = await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => _CorrectionRequestDialog(initialText: _pendingCorrection), + ); + if (details == null || !mounted) { + // Cancelou de propósito: o rascunho não fica para trás. + _pendingCorrection = null; + return; + } + // Guardado até o servidor aceitar: se o envio falhar, o texto volta quando + // a pessoa reabrir o diálogo, em vez de ser digitado de novo. + _pendingCorrection = details; + final ok = await _submitRequest( + (backend) => backend.requestDataCorrection(details), + 'Pedido de correção registrado', + ); + if (ok) _pendingCorrection = null; + } + + String _requestTypeLabel(DataSubjectRequestType type) => switch (type) { + DataSubjectRequestType.deletion => 'Exclusão dos dados', + DataSubjectRequestType.correction => 'Correção de dados', + }; + + String _requestStatusLabel(DataSubjectRequestStatus status) => switch (status) { + DataSubjectRequestStatus.open => 'Em análise', + DataSubjectRequestStatus.inReview => 'Em análise', + DataSubjectRequestStatus.completed => 'Atendido', + DataSubjectRequestStatus.rejected => 'Recusado', + }; + + /// dd/mm/aaaa. Não converte fuso: quem passa um instante (consentimento, + /// pedido) chama `.toLocal()` antes; a data de nascimento é meia-noite UTC e + /// passa como está, senão cairia no dia anterior no Brasil. + String _formatDate(DateTime date) => '${date.day.toString().padLeft(2, '0')}/' + '${date.month.toString().padLeft(2, '0')}/${date.year}'; + + String _purposeDescription(ConsentPurpose purpose) => switch (purpose) { + ConsentPurpose.localReminders => + 'Notificações de remédios e cuidados que você agenda em "Lembretes".', + ConsentPurpose.segmentedPush => + 'Avisos da UBS para a sua microárea. Ainda não são enviados nesta versão.', + ConsentPurpose.healthDataProcessing => 'Obrigatório para usar o app.', + ConsentPurpose.termsOfUse => 'Aceito no cadastro.', + }; + /// JSON da "exportação" pedida por spec/lgpd_design.md — não há /// infraestrutura de e-mail/arquivo nesta etapa, então a saída é a área de /// transferência: a pessoa cola onde quiser salvar ou compartilhar. @@ -1558,6 +2063,16 @@ class _MyDataScreenState extends State { 'data': event.recordedAt.toIso8601String(), }, ], + 'pedidos': [ + for (final request in data.requests) + { + 'tipo': request.type.name, + 'situacao': request.status.name, + if (request.details != null) 'detalhes': request.details, + 'data': request.createdAt.toIso8601String(), + 'prazo': request.dueAt.toIso8601String(), + }, + ], }; Future _export() async { @@ -1584,6 +2099,14 @@ class _MyDataScreenState extends State { @override Widget build(BuildContext context) { final data = _data; + final decisions = data == null + ? const {} + : currentConsentDecisions(data.consents); + final openDeletion = data?.requests.any((r) => + r.type == DataSubjectRequestType.deletion && + (r.status == DataSubjectRequestStatus.open || + r.status == DataSubjectRequestStatus.inReview)) ?? + false; return ListView( padding: const EdgeInsets.all(20), children: [ @@ -1591,7 +2114,8 @@ class _MyDataScreenState extends State { const SizedBox(height: 8), const Text( 'Confirmação de que seus dados pessoais estão sendo tratados pelo ' - 'SinalACS, o que está cadastrado, e uma cópia para guardar.', + 'SinalACS, o que está cadastrado, suas escolhas de consentimento, ' + 'pedidos de correção ou exclusão, e uma cópia para guardar.', ), const SizedBox(height: 16), if (_error != null) @@ -1604,7 +2128,7 @@ class _MyDataScreenState extends State { child: Text( _error!, key: const Key('my_data_error'), - style: const TextStyle(color: PatientColors.dangerOnSurface, fontWeight: FontWeight.bold), + style: TextStyle(color: context.patientRisk.dangerOnSurface, fontWeight: FontWeight.bold), ), ), ), @@ -1613,7 +2137,7 @@ class _MyDataScreenState extends State { padding: const EdgeInsets.only(bottom: 16), child: Semantics( liveRegion: true, - child: Text(_confirmation!, key: const Key('my_data_export_confirmation')), + child: Text(_confirmation!, key: const Key('my_data_confirmation')), ), ), if (data == null && _error == null) @@ -1629,8 +2153,7 @@ class _MyDataScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(data.name, style: const TextStyle(fontWeight: FontWeight.bold)), - Text('Data de nascimento: ${data.birthDate.day.toString().padLeft(2, '0')}/' - '${data.birthDate.month.toString().padLeft(2, '0')}/${data.birthDate.year}'), + Text('Data de nascimento: ${_formatDate(data.birthDate)}'), Text('Contato de emergência: ${data.emergencyContact}'), Text('Condição crônica: ${data.isChronic ? 'Sim' : 'Não'}'), if (data.chronicConditions.isNotEmpty) @@ -1640,28 +2163,47 @@ class _MyDataScreenState extends State { ), ), const SizedBox(height: 16), - const Text('Consentimentos', style: TextStyle(fontWeight: FontWeight.bold)), + const Text('Suas escolhas de consentimento', style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + Text( + decisions[ConsentPurpose.healthDataProcessing] == true + ? '${consentPurposeLabel(ConsentPurpose.healthDataProcessing)}: concedido — ' + 'obrigatório para usar o app. Para retirá-lo, solicite a exclusão dos seus dados abaixo.' + : '${consentPurposeLabel(ConsentPurpose.healthDataProcessing)}: sem registro de consentimento.', + key: const Key('consent_health_data_notice'), + ), + for (final purpose in const [ConsentPurpose.localReminders, ConsentPurpose.segmentedPush]) + SwitchListTile( + key: Key('consent_switch_${purpose.name}'), + contentPadding: EdgeInsets.zero, + title: Text(consentPurposeLabel(purpose)), + subtitle: Text(_purposeDescription(purpose)), + value: decisions[purpose] ?? false, + onChanged: _busy ? null : (value) => _changeConsent(purpose, value), + ), + const SizedBox(height: 16), + const Text('Histórico de consentimentos', style: TextStyle(fontWeight: FontWeight.bold)), if (data.consents.isEmpty) - const Padding( - padding: EdgeInsets.only(top: 8), - child: Text('Nenhum consentimento registrado.', style: TextStyle(color: Colors.white54)), + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text('Nenhum consentimento registrado.', style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant)), ) else ...data.consents.map( (consent) => ListTile( dense: true, contentPadding: EdgeInsets.zero, - title: Text(consent.purpose), + title: Text(consentRecordLabel(consent.purpose)), subtitle: Text('${consent.action == 'granted' ? 'Concedido' : 'Recusado'} · ' - 'v${consent.version} · ${consent.timestamp.toIso8601String().split('T').first}'), + 'v${consent.version} · ${_formatDate(consent.timestamp.toLocal())}'), ), ), const SizedBox(height: 16), const Text('Histórico de classificação de risco', style: TextStyle(fontWeight: FontWeight.bold)), if (data.riskHistory.isEmpty) - const Padding( - padding: EdgeInsets.only(top: 8), - child: Text('Nenhum evento registrado.', style: TextStyle(color: Colors.white54)), + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text('Nenhum evento registrado.', style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant)), ) else ...data.riskHistory.map( @@ -1673,6 +2215,42 @@ class _MyDataScreenState extends State { ), ), const SizedBox(height: 16), + const Text('Pedidos sobre seus dados', style: TextStyle(fontWeight: FontWeight.bold)), + if (data.requests.isEmpty) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text('Nenhum pedido feito.', style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant)), + ) + else + ...data.requests.map( + (request) => ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + title: Text('${_requestTypeLabel(request.type)} · ${_requestStatusLabel(request.status)}'), + subtitle: Text([ + 'Pedido em ${_formatDate(request.createdAt.toLocal())} · ' + 'resposta até ${_formatDate(request.dueAt.toLocal())}', + if (request.details != null) '"${request.details}"', + if (request.resolution != null && request.resolution!.isNotEmpty) + 'Resposta: ${request.resolution}', + ].join('\n')), + ), + ), + const SizedBox(height: 8), + OutlinedButton.icon( + key: const Key('request_deletion_button'), + onPressed: _busy || openDeletion ? null : _requestDeletion, + icon: const Icon(Icons.delete_outline), + label: Text(openDeletion ? 'Exclusão já solicitada — em análise' : 'Solicitar exclusão dos dados'), + ), + const SizedBox(height: 8), + OutlinedButton.icon( + key: const Key('request_correction_button'), + onPressed: _busy ? null : _requestCorrection, + icon: const Icon(Icons.edit_note_outlined), + label: const Text('Solicitar correção'), + ), + const SizedBox(height: 16), FilledButton.icon( key: const Key('export_my_data_button'), onPressed: _export, @@ -1685,6 +2263,61 @@ class _MyDataScreenState extends State { } } +/// Pede o texto de uma correção (LGPD-RF08). Devolve o texto já sem espaços +/// nas pontas, ou `null` se a pessoa cancelar. O limite de 500 é o mesmo que o +/// servidor impõe (`correctionDetailsMaxLength`); o servidor revalida, porque +/// o app não é a única origem possível da chamada. +class _CorrectionRequestDialog extends StatefulWidget { + const _CorrectionRequestDialog({this.initialText}); + + final String? initialText; + + @override + State<_CorrectionRequestDialog> createState() => _CorrectionRequestDialogState(); +} + +class _CorrectionRequestDialogState extends State<_CorrectionRequestDialog> { + late final _controller = TextEditingController(text: widget.initialText); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final details = _controller.text.trim(); + return AlertDialog( + title: const Text('Solicitar correção'), + content: TextField( + key: const Key('correction_details_field'), + controller: _controller, + maxLength: 500, + maxLines: 4, + onChanged: (_) => setState(() {}), + decoration: const InputDecoration( + labelText: 'O que precisa ser corrigido?', + helperText: 'Condições crônicas você mesmo atualiza em "Perfil clínico".', + helperMaxLines: 2, + ), + ), + actions: [ + TextButton( + key: const Key('correction_request_cancel'), + onPressed: () => Navigator.pop(context), + child: const Text('Cancelar'), + ), + FilledButton( + key: const Key('correction_request_submit'), + onPressed: details.isEmpty ? null : () => Navigator.pop(context, details), + child: const Text('Enviar pedido'), + ), + ], + ); + } +} + /// Acompanhamento da solicitação do paciente (RF05, decisão §5) — consome /// `alerts.statusFor`, escopado ao próprio paciente pelo token. /// @@ -1818,7 +2451,7 @@ class _StatusScreenBodyState extends State<_StatusScreenBody> with WidgetsBindin child: Text( key: const Key('status_error'), _error!, - style: const TextStyle(color: PatientColors.dangerOnSurface, fontWeight: FontWeight.bold), + style: TextStyle(color: context.patientRisk.dangerOnSurface, fontWeight: FontWeight.bold), ), ), ), @@ -1835,9 +2468,9 @@ class _StatusScreenBodyState extends State<_StatusScreenBody> with WidgetsBindin style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), const SizedBox(height: 8), - const Text( + Text( 'Um alerta de urgência ou uma triagem concluída aparece aqui assim que a equipe recebe.', - style: TextStyle(color: Colors.white70), + style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), ), ], ), @@ -1859,7 +2492,7 @@ class _StatusScreenBodyState extends State<_StatusScreenBody> with WidgetsBindin padding: const EdgeInsets.only(top: 8), child: Text( 'Verificado às ${_formatTime(_checkedAt!)}', - style: const TextStyle(fontSize: 12, color: Colors.white54), + style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant), ), ), ], @@ -1877,10 +2510,10 @@ class _StatusCard extends StatelessWidget { // Mesma disciplina de cor de `_TriageResult`: `OnSurface` porque o tom // aparece como texto sobre `Card`, não como preenchimento. final (riskLabel, riskColor) = switch (status.riskLevel) { - RiskLevel.red => ('Vermelho', PatientColors.dangerOnSurface), + RiskLevel.red => ('Vermelho', context.patientRisk.dangerOnSurface), RiskLevel.yellow => ('Amarelo', const Color(0xFFE0A800)), - RiskLevel.green => ('Verde', PatientColors.accentOnSurface), - null => ('Não classificado', Colors.white70), + RiskLevel.green => ('Verde', context.patientRisk.accentOnSurface), + null => ('Não classificado', Theme.of(context).colorScheme.onSurfaceVariant), }; final statusLabel = switch (status.status) { AlertStatus.pending => 'Enviado — aguardando confirmação da equipe', @@ -1929,6 +2562,22 @@ String _formatTime(DateTime value) { return '${local.hour.toString().padLeft(2, '0')}:${local.minute.toString().padLeft(2, '0')}'; } +/// Cancela no sistema operacional e desativa no store todo lembrete ativo. +/// +/// É o que acontece quando o consentimento de lembretes não está (ou deixou de +/// estar) concedido: `RemindersScreen` chama ao abrir, e "Meus dados" chama ao +/// saber de uma revogação — sem isso, um lembrete agendado antes continuaria +/// disparando até a pessoa abrir "Lembretes" (LGPD-RF05). Devolve se algum +/// lembrete foi alterado. +Future deactivateAllReminders(RemindersScope scope) async { + final stillActive = (await scope.store.list()).where((r) => r.active).toList(); + for (final r in stillActive) { + await scope.scheduler.cancel(r.id); + await scope.store.save(r.copyWith(active: false)); + } + return stillActive.isNotEmpty; +} + /// Lembretes locais de saúde (medicamento, pesagem, etc.), RF06 §3.1. /// /// Carrega do [ReminderStore] injetado via [RemindersScope]; abre vazia @@ -1974,12 +2623,7 @@ class _RemindersScreenState extends State { // segurança): nenhum lembrete pode continuar agendado no sistema // operacional depois disso — sem isso, um lembrete criado antes da // recusa continuaria disparando mesmo depois dela (LGPD-RF05). - final stillActive = reminders.where((r) => r.active).toList(); - for (final r in stillActive) { - await scope.scheduler.cancel(r.id); - await scope.store.save(r.copyWith(active: false)); - } - if (stillActive.isNotEmpty) { + if (await deactivateAllReminders(scope)) { reminders = await scope.store.list(); } } @@ -2006,8 +2650,8 @@ class _RemindersScreenState extends State { String _consentDeniedMessage(bool? granted) { if (granted == false) { - return 'Você recusou o consentimento para lembretes locais no cadastro — ' - 'não é possível agendar notificações.'; + return 'Você recusou ou revogou o consentimento para lembretes locais — ' + 'reative em "Meus dados" para agendar notificações.'; } return 'Não encontramos seu consentimento para lembretes locais neste ' 'aparelho — conclua o cadastro para ativar notificações.'; @@ -2119,12 +2763,12 @@ class _RemindersScreenState extends State { key: const Key('reminders_consent_denied_banner'), padding: const EdgeInsets.all(12), decoration: BoxDecoration( - border: Border.all(color: Colors.white24), + border: Border.all(color: Theme.of(context).colorScheme.outlineVariant), borderRadius: BorderRadius.circular(8), ), child: Text( _consentDeniedMessage(_remindersConsentGranted), - style: const TextStyle(color: Colors.white70), + style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), ), ), ), @@ -2140,7 +2784,7 @@ class _RemindersScreenState extends State { child: Text( _error!, key: const Key('reminders_error'), - style: const TextStyle(color: PatientColors.dangerOnSurface, fontWeight: FontWeight.bold), + style: TextStyle(color: context.patientRisk.dangerOnSurface, fontWeight: FontWeight.bold), ), ), ), @@ -2156,14 +2800,14 @@ class _RemindersScreenState extends State { child: Center(child: CircularProgressIndicator()), ) else if (reminders != null && reminders.isEmpty) - const Padding( - key: Key('reminders_empty_state'), - padding: EdgeInsets.only(top: 40), + Padding( + key: const Key('reminders_empty_state'), + padding: const EdgeInsets.only(top: 40), child: Center( child: Text( 'Você ainda não tem lembretes. Toque em "+" para criar o primeiro.', textAlign: TextAlign.center, - style: TextStyle(color: Colors.white54), + style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), ), ), ) @@ -2280,12 +2924,41 @@ class _ReminderFormDialogState extends State<_ReminderFormDialog> { } } +class ThemeSettingsScreen extends StatelessWidget { + const ThemeSettingsScreen({required this.controller, super.key}); + + final ThemeController controller; + + @override + Widget build(BuildContext context) => ValueListenableBuilder( + valueListenable: controller, + builder: (context, mode, _) => ListView( + padding: const EdgeInsets.all(20), + children: [ + const Text('Aparência', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + const SizedBox(height: 4), + const Text('Escolha como o app deve se apresentar neste aparelho.'), + const SizedBox(height: 16), + RadioGroup( + groupValue: mode, + onChanged: (value) { if (value != null) controller.setThemeMode(value); }, + child: const Column(children: [ + Card(child: RadioListTile(key: Key('theme_light'), value: ThemeMode.light, title: Text('Claro'))), + Card(child: RadioListTile(key: Key('theme_dark'), value: ThemeMode.dark, title: Text('Escuro'))), + Card(child: RadioListTile(key: Key('theme_system'), value: ThemeMode.system, title: Text('Automático (segue o sistema)'))), + ]), + ), + ], + ), + ); +} + class _PatientHeader extends StatelessWidget implements PreferredSizeWidget { const _PatientHeader({required this.eyebrow, required this.title}); final String eyebrow; final String title; @override Size get preferredSize => const Size.fromHeight(72); - @override Widget build(BuildContext context) => AppBar(title: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Text(eyebrow.toUpperCase(), style: const TextStyle(fontSize: 10, color: PatientColors.accent, fontWeight: FontWeight.bold)), Text(title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold))]), actions: const [Padding(padding: EdgeInsets.only(right: 16), child: CircleAvatar(child: Icon(Icons.person_outline)))]) ; + @override Widget build(BuildContext context) => AppBar(title: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Text(eyebrow.toUpperCase(), style: TextStyle(fontSize: 10, color: context.patientRisk.accentOnSurface, fontWeight: FontWeight.bold)), Text(title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold))]), actions: const [Padding(padding: EdgeInsets.only(right: 16), child: CircleAvatar(child: Icon(Icons.person_outline)))]) ; } void _showMoreDestinations(BuildContext context, ValueChanged select) { - showModalBottomSheet(context: context, builder: (sheetContext) => SafeArea(child: Column(mainAxisSize: MainAxisSize.min, children: [ListTile(leading: const Icon(Icons.person_outline), title: const Text('Perfil clínico'), onTap: () { Navigator.pop(sheetContext); select(PatientDestination.profile); }), ListTile(leading: const Icon(Icons.alarm_outlined), title: const Text('Lembretes'), onTap: () { Navigator.pop(sheetContext); select(PatientDestination.reminders); }), ListTile(leading: const Icon(Icons.privacy_tip_outlined), title: const Text('Meus dados'), onTap: () { Navigator.pop(sheetContext); select(PatientDestination.myData); })]))); -} \ No newline at end of file + showModalBottomSheet(context: context, builder: (sheetContext) => SafeArea(child: Column(mainAxisSize: MainAxisSize.min, children: [ListTile(leading: const Icon(Icons.person_outline), title: const Text('Perfil clínico'), onTap: () { Navigator.pop(sheetContext); select(PatientDestination.profile); }), ListTile(leading: const Icon(Icons.alarm_outlined), title: const Text('Lembretes'), onTap: () { Navigator.pop(sheetContext); select(PatientDestination.reminders); }), ListTile(leading: const Icon(Icons.privacy_tip_outlined), title: const Text('Meus dados'), onTap: () { Navigator.pop(sheetContext); select(PatientDestination.myData); }), ListTile(key: const Key('more_legal'), leading: const Icon(Icons.gavel_outlined), title: const Text('Privacidade e termos'), onTap: () { Navigator.pop(sheetContext); Navigator.of(context).push(MaterialPageRoute(builder: (_) => const LegalDocumentsScreen())); }), ListTile(leading: const Icon(Icons.tune_outlined), title: const Text('Preferências'), onTap: () { Navigator.pop(sheetContext); select(PatientDestination.settings); })]))); +} diff --git a/apps/patient/lib/app/legal_screens.dart b/apps/patient/lib/app/legal_screens.dart new file mode 100644 index 00000000..f1efa572 --- /dev/null +++ b/apps/patient/lib/app/legal_screens.dart @@ -0,0 +1,278 @@ +import 'package:flutter/material.dart'; +import 'package:sinalacs_patient/app/patient_theme.dart'; +import 'package:sinalacs_patient/core/legal/legal_documents.dart'; +import 'package:sinalacs_patient/core/network/backend_client.dart'; +import 'package:sinalacs_patient/core/network/backend_scope.dart'; + +/// Índice "Privacidade e termos": abre a partir do login (antes do cadastro, +/// para ler antes de aceitar) e do menu "Mais" — Mais → Privacidade e termos → +/// documento, três toques (painel de privacidade em até 3 cliques, +/// LGPD-RF03). +class LegalDocumentsScreen extends StatelessWidget { + const LegalDocumentsScreen({super.key, this.upcoming, this.effectiveLabel}); + + /// Quando presente, lista o texto que ainda **não vale** (aviso de 15 dias). + final UpcomingLegalDocuments? upcoming; + + /// Data em que o texto novo passa a valer (`dd/mm/aaaa`), para o subtítulo. + final String? effectiveLabel; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(upcoming == null ? 'Privacidade e termos' : 'Termos que passam a valer'), + ), + body: SafeArea( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + for (final (key, document) in [ + ('legal_open_privacy', upcoming?.privacy ?? privacyPolicy), + ('legal_open_terms', upcoming?.terms ?? termsOfUse), + ]) + Card( + child: ListTile( + key: Key(key), + leading: Icon( + document.id == 'privacy' + ? Icons.privacy_tip_outlined + : Icons.description_outlined, + ), + title: Text(document.title), + subtitle: Text( + upcoming == null + ? 'Versão ${document.version} · vigente desde ${document.effectiveDate}' + : 'Versão ${document.version} · passa a valer em ${effectiveLabel ?? document.effectiveDate}', + ), + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => LegalDocumentScreen( + document: document, + effectiveLabel: upcoming == null ? null : (effectiveLabel ?? document.effectiveDate), + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +/// Um documento legal: primeiro o resumo visual (passos numerados, na ordem +/// em que o dado circula — LGPD-RF10), depois a versão completa em seções +/// expansíveis e, por fim, o histórico de versões (LGPD-RF18/RF19). +class LegalDocumentScreen extends StatelessWidget { + const LegalDocumentScreen({super.key, required this.document, this.effectiveLabel}); + + final LegalDocument document; + + /// Presente só para o texto que ainda **não vale** (aviso de 15 dias): a data em + /// que passa a valer (`dd/mm/aaaa`). Sem ela, o documento é o vigente. + final String? effectiveLabel; + + /// A versão do documento é a que "vigora" — exceto o texto futuro, que só "passa + /// a valer". + String _historyVerb(LegalVersion entry) { + if (entry.version != document.version) return 'de'; + return effectiveLabel == null ? 'vigente desde' : 'passa a valer em'; + } + + String _historyDate(LegalVersion entry) => + entry.version == document.version && effectiveLabel != null ? effectiveLabel! : entry.date; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(document.title)), + body: SafeArea( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text( + effectiveLabel == null + ? 'Versão ${document.version} · vigente desde ${document.effectiveDate}' + : 'Versão ${document.version} · passa a valer em $effectiveLabel', + key: const Key('legal_version'), + style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + const SizedBox(height: 16), + const Text( + 'Resumo', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Column( + key: const Key('legal_summary'), + children: [ + for (final (index, step) in document.summary.indexed) + Card( + child: ListTile( + leading: CircleAvatar( + // `accentDark`, não `accent`: o dígito é texto normal + // e branco sobre `accent` fica abaixo de 4.5:1. + backgroundColor: PatientColors.accentDark, + foregroundColor: Colors.white, + child: Text('${index + 1}'), + ), + title: Text( + step.title, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Text(step.text), + ), + ), + ], + ), + const SizedBox(height: 16), + const Text( + 'Texto completo', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + for (final (index, section) in document.sections.indexed) + ExpansionTile( + key: Key('legal_section_$index'), + tilePadding: EdgeInsets.zero, + childrenPadding: const EdgeInsets.only(bottom: 12), + expandedAlignment: Alignment.centerLeft, + title: Text(section.title), + children: [Text(section.body)], + ), + const SizedBox(height: 16), + const Text( + 'Histórico de versões', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + Column( + key: const Key('legal_history'), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final entry in document.history) + ListTile( + contentPadding: EdgeInsets.zero, + title: Text( + '${entry.version} · ${_historyVerb(entry)} ${_historyDate(entry)}', + ), + subtitle: Text(entry.changes), + ), + ], + ), + ], + ), + ), + ); + } +} + +/// Convite para aceitar o Termo de Uso e a Política de Privacidade vigentes, +/// mostrado depois do login por OTP a quem ainda não aceitou a versão atual +/// (LGPD-RF18). +/// +/// **Não é um portão.** "Agora não" segue para a tela inicial e o aviso volta +/// no próximo login: um paciente em emergência precisa chegar ao alerta de +/// urgência sem ler nada antes (invariante "red alerts never dropped"). +class TermsAcceptanceScreen extends StatefulWidget { + const TermsAcceptanceScreen({required this.onContinue, super.key}); + + /// Chamado depois do aceite gravado, ou ao escolher "Agora não". + final VoidCallback onContinue; + + @override + State createState() => _TermsAcceptanceScreenState(); +} + +class _TermsAcceptanceScreenState extends State { + bool _checked = false; + bool _busy = false; + bool _left = false; + String? _error; + + /// Sai da tela uma vez só: um segundo toque em "Agora não" durante a + /// transição não pode empilhar a tela inicial de novo. + void _continue() { + if (_left) return; + _left = true; + widget.onContinue(); + } + + Future _accept() async { + setState(() { + _busy = true; + _error = null; + }); + try { + await BackendScope.of(context).acceptTermsOfUse(); + if (!mounted) return; + _continue(); + } on BackendFailure catch (failure) { + if (!mounted) return; + setState(() { + _busy = false; + _error = failure.message; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Termo de Uso e Privacidade')), + body: SafeArea( + child: ListView( + padding: const EdgeInsets.all(24), + children: [ + const Text( + 'Para usar o app com tudo em dia, leia e aceite o Termo de Uso e a ' + 'Política de Privacidade (versão $legalDocumentsVersion). Você pode ' + 'aceitar depois: o alerta de urgência continua disponível.', + ), + const SizedBox(height: 16), + OutlinedButton.icon( + key: const Key('terms_gate_read_button'), + onPressed: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const LegalDocumentsScreen()), + ), + style: OutlinedButton.styleFrom(minimumSize: const Size(48, 52)), + icon: const Icon(Icons.description_outlined), + label: const Text('Ler o Termo e a Política'), + ), + CheckboxListTile( + key: const Key('terms_gate_checkbox'), + value: _checked, + onChanged: _busy ? null : (value) => setState(() => _checked = value ?? false), + controlAffinity: ListTileControlAffinity.leading, + title: const Text('Li e aceito o Termo de Uso e a Política de Privacidade.'), + ), + if (_error != null) + Semantics( + liveRegion: true, + child: Text( + _error!, + key: const Key('terms_gate_error'), + style: TextStyle(color: context.patientRisk.dangerOnSurface), + ), + ), + const SizedBox(height: 16), + FilledButton( + key: const Key('terms_gate_accept_button'), + onPressed: _checked && !_busy ? _accept : null, + style: FilledButton.styleFrom(minimumSize: const Size(48, 52)), + child: const Text('Aceitar e continuar'), + ), + const SizedBox(height: 8), + TextButton( + key: const Key('terms_gate_later_button'), + onPressed: _busy ? null : _continue, + style: TextButton.styleFrom(minimumSize: const Size(48, 52)), + child: const Text('Agora não'), + ), + ], + ), + ), + ); + } +} diff --git a/apps/patient/lib/app/patient_theme.dart b/apps/patient/lib/app/patient_theme.dart index ede01306..d9e8f199 100644 --- a/apps/patient/lib/app/patient_theme.dart +++ b/apps/patient/lib/app/patient_theme.dart @@ -1,63 +1,182 @@ import 'package:flutter/material.dart'; +/// Cores clínicas e de marca, compartilhadas pelos dois temas. +/// +/// `accent`/`danger` só podem significar `RiskLevel` (ver CLAUDE.md); o +/// mesmo tom vale em claro e escuro — é a superfície ao redor que muda. Para +/// usar qualquer uma destas como TEXTO/ÍCONE em vez de preenchimento, use +/// [PatientRiskColors] (via `context.patientRisk`), nunca o valor daqui +/// direto. abstract final class PatientColors { + static const accent = Color(0xFF0D9488); + static const accentDark = Color(0xFF0F766E); + static const danger = Color(0xFFDC2626); +} + +abstract final class PatientDarkColors { static const background = Color(0xFF030712); static const surface = Color(0xFF0F172A); static const surfaceRaised = Color(0xFF1E293B); static const border = Color(0xFF334155); - static const accent = Color(0xFF0D9488); - static const accentDark = Color(0xFF0F766E); - static const danger = Color(0xFFDC2626); +} + +abstract final class PatientLightColors { + static const background = Color(0xFFF9FAFB); + static const surface = Color(0xFFFFFFFF); + static const surfaceRaised = Color(0xFFFFFFFF); + static const border = Color(0xFFD1D5DB); +} + +/// Variantes de `PatientColors`/`RiskLevel.yellow` para uso como +/// TEXTO/ÍCONE sobre superfície (`surfaceRaised`/`background`), nunca como +/// preenchimento. +/// +/// Ao contrário de [PatientColors], a cor certa depende do tema ativo — um +/// [ThemeExtension] em vez de `static const`. No escuro, `accent` como +/// texto direto cai para 3.91:1 (abaixo de 4.5:1, WCAG 1.4.3); no claro, +/// `accent` cai para 3.74:1 e `danger` cai abaixo de 4.5:1 sobre o fundo da +/// tela. `yellowOnSurface` é o tom usado para `RiskLevel.yellow` em +/// `_TriageResult`, que nunca teve token de preenchimento equivalente. Ver +/// `test/contrast_tokens_test.dart` e `test/contrast_tokens_light_test.dart`. +@immutable +class PatientRiskColors extends ThemeExtension { + const PatientRiskColors({ + required this.dangerOnSurface, + required this.accentOnSurface, + required this.yellowOnSurface, + }); + + final Color dangerOnSurface; + final Color accentOnSurface; + final Color yellowOnSurface; - /// 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); + static const dark = PatientRiskColors( + dangerOnSurface: Color(0xFFF87171), + accentOnSurface: Color(0xFF2DD4BF), + yellowOnSurface: Color(0xFFE0A800), + ); + + static const light = PatientRiskColors( + dangerOnSurface: Color(0xFFB91C1C), + accentOnSurface: PatientColors.accentDark, + yellowOnSurface: Color(0xFFB45309), + ); + + @override + PatientRiskColors copyWith({ + Color? dangerOnSurface, + Color? accentOnSurface, + Color? yellowOnSurface, + }) => + PatientRiskColors( + dangerOnSurface: dangerOnSurface ?? this.dangerOnSurface, + accentOnSurface: accentOnSurface ?? this.accentOnSurface, + yellowOnSurface: yellowOnSurface ?? this.yellowOnSurface, + ); + + @override + PatientRiskColors lerp(ThemeExtension? other, double t) { + if (other is! PatientRiskColors) return this; + return PatientRiskColors( + dangerOnSurface: Color.lerp(dangerOnSurface, other.dangerOnSurface, t)!, + accentOnSurface: Color.lerp(accentOnSurface, other.accentOnSurface, t)!, + yellowOnSurface: Color.lerp(yellowOnSurface, other.yellowOnSurface, t)!, + ); + } +} + +/// Atalho para ler [PatientRiskColors] do tema ativo. +/// +/// Cai para [PatientRiskColors.dark] quando a extensão não está registrada +/// (um `MaterialApp` de teste com `ThemeData()` padrão, por exemplo) — +/// nunca derruba a árvore de widgets por causa de uma variante de cor de +/// texto. +extension PatientThemeContext on BuildContext { + PatientRiskColors get patientRisk => Theme.of(this).extension() ?? PatientRiskColors.dark; } -ThemeData buildPatientTheme() { +ThemeData buildPatientDarkTheme() { final colorScheme = ColorScheme.fromSeed( seedColor: PatientColors.accent, brightness: Brightness.dark, - surface: PatientColors.surface, + surface: PatientDarkColors.surface, ); return ThemeData( useMaterial3: true, brightness: Brightness.dark, colorScheme: colorScheme, - scaffoldBackgroundColor: PatientColors.background, + scaffoldBackgroundColor: PatientDarkColors.background, appBarTheme: const AppBarTheme( - backgroundColor: PatientColors.surface, + backgroundColor: PatientDarkColors.surface, foregroundColor: Colors.white, elevation: 0, centerTitle: false, ), cardTheme: CardThemeData( - color: PatientColors.surfaceRaised, + color: PatientDarkColors.surfaceRaised, elevation: 0, margin: EdgeInsets.zero, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), - side: const BorderSide(color: PatientColors.border), + side: const BorderSide(color: PatientDarkColors.border), ), ), inputDecorationTheme: InputDecorationTheme( filled: true, - fillColor: PatientColors.surface, + fillColor: PatientDarkColors.surface, border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide(color: PatientColors.border), + borderSide: const BorderSide(color: PatientDarkColors.border), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide(color: PatientColors.border), + borderSide: const BorderSide(color: PatientDarkColors.border), ), ), + extensions: const [PatientRiskColors.dark], ); -} \ No newline at end of file +} + +ThemeData buildPatientLightTheme() { + final colorScheme = ColorScheme.fromSeed( + seedColor: PatientColors.accent, + brightness: Brightness.light, + surface: PatientLightColors.surface, + ); + + return ThemeData( + useMaterial3: true, + brightness: Brightness.light, + colorScheme: colorScheme, + scaffoldBackgroundColor: PatientLightColors.background, + appBarTheme: const AppBarTheme( + backgroundColor: PatientLightColors.surface, + foregroundColor: Color(0xFF111827), + elevation: 0, + centerTitle: false, + ), + cardTheme: CardThemeData( + color: PatientLightColors.surfaceRaised, + elevation: 0, + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: PatientLightColors.border), + ), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: PatientLightColors.background, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: PatientLightColors.border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: PatientLightColors.border), + ), + ), + extensions: const [PatientRiskColors.light], + ); +} diff --git a/apps/patient/lib/app/qr_scanner.dart b/apps/patient/lib/app/qr_scanner.dart new file mode 100644 index 00000000..d2816da0 --- /dev/null +++ b/apps/patient/lib/app/qr_scanner.dart @@ -0,0 +1,130 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; + +/// Lê um QR Code e devolve o texto dele, ou `null` se a pessoa voltar sem ler. +/// Pode lançar quando a câmera não está disponível. +typedef QrScanner = Future Function(BuildContext context); + +/// Disponibiliza o [QrScanner] para a árvore de widgets. +/// +/// Mesmo padrão de `BackendScope`/`LocationScope`: a tela de onboarding não +/// abre a câmera diretamente, o que permite trocar o leitor por um duplo em +/// teste hermético — a câmera real é canal de plataforma e não existe no +/// `flutter test`. +class QrScannerScope extends InheritedWidget { + const QrScannerScope({required this.scanner, required super.child, super.key}); + + final QrScanner scanner; + + static QrScanner of(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType(); + assert(scope != null, 'Nenhum QrScannerScope acima deste widget.'); + return scope!.scanner; + } + + @override + bool updateShouldNotify(QrScannerScope oldWidget) => scanner != oldWidget.scanner; +} + +/// Leitor real: abre a câmera numa tela própria e devolve o primeiro QR lido. +/// A imagem não é guardada nem enviada — só o texto do QR volta. +Future scanQrWithCamera(BuildContext context) => + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const _CameraScanPage()), + ); + +/// Decide se uma leitura da câmera fecha a tela de leitura. +/// +/// Separado de `_CameraScanPage` porque a câmera real não existe no +/// `flutter test`: esta é a parte da página que dá para provar sem aparelho. +class QrDetectionGate { + // A câmera entrega vários quadros por segundo: sem esta trava, o mesmo QR + // tentaria fechar a tela várias vezes. + bool _done = false; + + /// O texto do QR que deve fechar a tela, ou `null` para ignorar a leitura. + /// + /// [routeIsCurrent] falso quer dizer que a pessoa já voltou: a rota segue + /// montada durante a animação de saída e a câmera segue lendo, mas um `pop` + /// nesse momento fecharia a tela de baixo (o onboarding), não esta. + String? accept(BarcodeCapture capture, {required bool routeIsCurrent}) { + if (_done || !routeIsCurrent) return null; + for (final barcode in capture.barcodes) { + final value = barcode.rawValue; + if (value != null && value.isNotEmpty) { + _done = true; + return value; + } + } + return null; + } +} + +class _CameraScanPage extends StatefulWidget { + const _CameraScanPage(); + + @override + State<_CameraScanPage> createState() => _CameraScanPageState(); +} + +class _CameraScanPageState extends State<_CameraScanPage> { + final _controller = MobileScannerController(formats: const [BarcodeFormat.qrCode]); + + final _gate = QrDetectionGate(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _onDetect(BarcodeCapture capture) { + // Um quadro já enfileirado pode chegar depois do `dispose`. + if (!mounted) return; + final value = _gate.accept( + capture, + routeIsCurrent: ModalRoute.of(context)?.isCurrent ?? false, + ); + if (value == null) return; + unawaited(_controller.stop()); + Navigator.of(context).pop(value); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Ler QR Code do convite')), + body: Stack( + children: [ + MobileScanner( + controller: _controller, + onDetect: _onDetect, + errorBuilder: (context, error) => const Center( + child: Padding( + padding: EdgeInsets.all(24), + child: Text( + 'Não foi possível usar a câmera. Volte e digite o código do convite.', + key: Key('camera_error'), + textAlign: TextAlign.center, + ), + ), + ), + ), + const Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: EdgeInsets.all(24), + child: Text( + 'Aponte a câmera para o QR Code mostrado pelo agente de saúde.', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold), + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/patient/lib/core/consent/consent_decisions.dart b/apps/patient/lib/core/consent/consent_decisions.dart new file mode 100644 index 00000000..718e91aa --- /dev/null +++ b/apps/patient/lib/core/consent/consent_decisions.dart @@ -0,0 +1,39 @@ +import 'package:sinalacs_client/sinalacs_client.dart'; + +/// Decisão vigente de cada finalidade, a partir do histórico de +/// `consent_logs` que `patients.myData` devolve. +/// +/// O histórico é append-only (LGPD-RF04): revogar grava uma linha nova, nunca +/// apaga a anterior, então vale o registro mais recente de cada finalidade. +/// Empate de horário resolve pela ordem da lista, que o servidor entrega da +/// mais antiga para a mais nova. Finalidade que o app não conhece é ignorada, e +/// finalidade sem registro fica fora do mapa — quem lê distingue "nunca +/// decidiu" de "recusou", mesmo cuidado de `ConsentPreferences`. +Map currentConsentDecisions(List history) { + final known = ConsentPurpose.values.asNameMap(); + final latest = {}; + for (final record in history) { + final purpose = known[record.purpose]; + if (purpose == null) continue; + final previous = latest[purpose]; + if (previous == null || !record.timestamp.isBefore(previous.timestamp)) { + latest[purpose] = record; + } + } + return {for (final entry in latest.entries) entry.key: entry.value.action == 'granted'}; +} + +/// Nome de cada finalidade para a pessoa ler. +String consentPurposeLabel(ConsentPurpose purpose) => switch (purpose) { + ConsentPurpose.healthDataProcessing => 'Tratamento de dados de saúde', + ConsentPurpose.localReminders => 'Lembretes neste aparelho', + ConsentPurpose.segmentedPush => 'Avisos da equipe de saúde', + ConsentPurpose.termsOfUse => 'Termo de Uso e Política de Privacidade', + }; + +/// Rótulo de um registro do histórico, que carrega a finalidade como texto. +/// Uma finalidade desconhecida aparece crua, em vez de sumir do histórico. +String consentRecordLabel(String purpose) { + final known = ConsentPurpose.values.asNameMap()[purpose]; + return known == null ? purpose : consentPurposeLabel(known); +} diff --git a/apps/patient/lib/core/legal/legal_documents.dart b/apps/patient/lib/core/legal/legal_documents.dart new file mode 100644 index 00000000..0ee90f27 --- /dev/null +++ b/apps/patient/lib/core/legal/legal_documents.dart @@ -0,0 +1,324 @@ +/// Termo de Uso e Política de Privacidade do app do paciente (LGPD-RF18, +/// LGPD-RF19 e LGPD-RF10 de `spec/lgpd_design.md`). +/// +/// Conteúdo constante, no próprio app, de propósito: o documento precisa +/// abrir antes do cadastro (a pessoa lê antes de aceitar) e sem rede. A versão +/// é a mesma que o backend grava em `consent_logs.version` +/// (`consentPolicyVersion`), e `test/legal_documents_test.dart` falha se as +/// duas divergirem. +/// +/// Trocar o texto exige versão nova: acrescente uma [LegalVersion] ao +/// histórico, mude [legalDocumentsVersion] **e** `consentPolicyVersion` no +/// backend. O texto de 2026.1 ainda precisa de revisão jurídica e dos dados +/// reais do controlador antes de produção (ver PROGRESS.md). +library; + +const String legalDocumentsVersion = '2026.1'; + +/// Um passo do resumo visual (versão resumida do documento). +class LegalSummaryStep { + const LegalSummaryStep({required this.title, required this.text}); + + final String title; + final String text; +} + +/// Uma seção da versão completa. +class LegalSection { + const LegalSection({required this.title, required this.body}); + + final String title; + final String body; +} + +/// Uma entrada do histórico de versões (controle de versões consultável, +/// LGPD-RF18). +class LegalVersion { + const LegalVersion({ + required this.version, + required this.date, + required this.changes, + }); + + final String version; + + /// Data de vigência, já no formato de exibição (`dd/mm/aaaa`). + final String date; + final String changes; +} + +class LegalDocument { + const LegalDocument({ + required this.id, + required this.title, + required this.version, + required this.effectiveDate, + required this.summary, + required this.sections, + required this.history, + }); + + final String id; + final String title; + final String version; + final String effectiveDate; + final List summary; + final List sections; + final List history; +} + +/// Texto da versão que **ainda não vale** e já foi anunciada (LGPD-RF18, 15 dias). +/// +/// O app embarca o texto ANTES de o servidor publicar o aviso: publique uma versão +/// do app com este valor, só depois publique a versão (agenda em `terms_versions`, +/// publicada pelo admin no backoffice; `upcomingTermsChange` é só o espelho), e na +/// vigência mude `legalDocumentsVersion` e `consentPolicyVersion`, mova o texto +/// para [privacyPolicy]/[termsOfUse] e volte as duas constantes para `null`. +/// `test/upcoming_legal_documents_test.dart` falha se as duas pontas divergirem. +class UpcomingLegalDocuments { + const UpcomingLegalDocuments({ + required this.version, + required this.privacy, + required this.terms, + }); + + final String version; + final LegalDocument privacy; + final LegalDocument terms; +} + +/// Nada anunciado hoje. +const UpcomingLegalDocuments? upcomingLegalDocuments = null; + +const _history2026_1 = [ + LegalVersion( + version: '2026.1', + date: '29/09/2026', + changes: 'Primeira versão publicada no aplicativo.', + ), +]; + +/// Política de Privacidade. O [LegalDocument.summary] é o resumo visual do +/// fluxo de dados pedido pelo LGPD-RF10: de onde o dado sai, por onde passa e +/// quem o vê, na ordem em que acontece. +const privacyPolicy = LegalDocument( + id: 'privacy', + title: 'Política de Privacidade', + version: legalDocumentsVersion, + effectiveDate: '29/09/2026', + summary: [ + LegalSummaryStep( + title: 'Você informa', + text: + 'Seu cadastro vem da sua UBS. No app, você responde a triagem, ' + 'pode enviar um alerta de urgência e escolhe seus consentimentos.', + ), + LegalSummaryStep( + title: 'O app protege', + text: + 'Tudo viaja criptografado. Suas condições de saúde, respostas de ' + 'triagem e anotações de visita são guardadas cifradas no servidor.', + ), + LegalSummaryStep( + title: 'A equipe da sua área vê', + text: + 'Só o agente comunitário de saúde da sua microárea e a equipe da ' + 'sua UBS, para organizar as visitas pela prioridade.', + ), + LegalSummaryStep( + title: 'Em emergência', + text: + 'Se o alerta for grave, o agente aciona o SAMU com o necessário ' + 'para o atendimento.', + ), + LegalSummaryStep( + title: 'Você decide', + text: + 'Em "Meus dados" você vê o que está guardado, copia seus dados, ' + 'muda consentimentos e pede correção ou exclusão.', + ), + ], + sections: [ + LegalSection( + title: 'Quem cuida dos seus dados', + body: + 'O responsável pelos seus dados (controlador) é a Secretaria ' + 'Municipal de Saúde do seu município, a quem pertence a UBS que ' + 'acompanha você. O SinalACS é a ferramenta que a equipe de saúde usa ' + 'para organizar as visitas.', + ), + LegalSection( + title: 'Dados que coletamos', + body: + 'Cadastro: nome, data de nascimento, contato de emergência e se ' + 'você tem condição crônica. O CPF é guardado só de forma embaralhada ' + '(hash), usada para conferir o seu acesso.\n' + 'Saúde: condições crônicas e respostas da triagem, guardadas ' + 'cifradas, e a classificação de risco que resulta delas.\n' + 'Alerta de urgência: o horário e, se você permitir o GPS, uma área ' + 'aproximada de onde você está — nunca o endereço exato.\n' + 'Consentimentos: cada escolha que você faz, com data e versão deste ' + 'texto.\n' + 'Lembretes: ficam só no seu aparelho e não vão para o servidor.', + ), + LegalSection( + title: 'Para que usamos', + body: + 'Para classificar o risco da sua triagem de forma igual para ' + 'todos, avisar o agente de saúde quando você pede ajuda, organizar a ' + 'fila de visitas da sua microárea e mostrar a você o andamento do seu ' + 'pedido. Não usamos seus dados para propaganda nem os vendemos.', + ), + LegalSection( + title: 'Bases legais', + body: + 'Dados de saúde são tratados com o seu consentimento específico e ' + 'para a tutela da saúde (Lei 13.709/2018, art. 11). Os registros que ' + 'a lei manda guardar, como os de visita, seguem a obrigação legal ' + '(art. 7º, II). Lembretes e avisos só acontecem se você consentir, e ' + 'você pode mudar de ideia quando quiser.', + ), + LegalSection( + title: 'Com quem compartilhamos', + body: + 'Com o agente comunitário de saúde da sua microárea e com a equipe ' + 'da sua UBS. Em emergência, com o SAMU, só o necessário para o ' + 'atendimento. Nenhum outro agente de saúde, de outra área, vê seus ' + 'dados.', + ), + LegalSection( + title: 'Por quanto tempo guardamos', + body: + 'Alertas: 2 anos. Visitas: 5 anos, como pede a legislação de ' + 'saúde. Registros de acesso: 1 ano. Registros de consentimento: ' + 'enquanto existir o cadastro, para provar as suas escolhas. Depois ' + 'disso, os dados são apagados ou anonimizados.', + ), + LegalSection( + title: 'Seus direitos', + body: + 'Você pode confirmar que tratamos seus dados, ver e copiar tudo o ' + 'que está guardado, pedir correção, pedir exclusão, retirar os ' + 'consentimentos que não são obrigatórios e saber com quem os dados ' + 'foram compartilhados. Faça isso em "Meus dados". Os pedidos são ' + 'respondidos em até 15 dias.', + ), + LegalSection( + title: 'Como protegemos', + body: + 'Conexão criptografada entre o app e o servidor, dados de saúde ' + 'cifrados no banco, acesso limitado à equipe da sua microárea e ' + 'registro de cada acesso aos seus dados.', + ), + LegalSection( + title: 'Encarregado de dados', + body: + 'O encarregado pelo tratamento de dados (DPO) é o da Secretaria ' + 'Municipal de Saúde do seu município. Você pode chegar até ele pela ' + 'sua UBS.', + ), + LegalSection( + title: 'Dúvidas sobre seus dados', + body: + 'Fale com o seu agente comunitário de saúde ou com a sua UBS: eles ' + 'encaminham a sua pergunta ao encarregado. Pedidos de correção e de ' + 'exclusão podem ser feitos direto em "Meus dados".', + ), + LegalSection( + title: 'Mudanças nesta política', + body: + 'Quando este texto mudar, o app mostra a nova versão com pelo ' + 'menos 15 dias de antecedência e explica o que mudou. As versões ' + 'anteriores ficam no histórico abaixo.', + ), + ], + history: _history2026_1, +); + +/// Termo de Uso, em linguagem simples (acessível para pessoas idosas e com +/// pouca leitura, LGPD-RF18). +const termsOfUse = LegalDocument( + id: 'terms', + title: 'Termo de Uso', + version: legalDocumentsVersion, + effectiveDate: '29/09/2026', + summary: [ + LegalSummaryStep( + title: 'Para quem é', + text: + 'Para pacientes acompanhados por um agente comunitário de saúde, ' + 'com cadastro na UBS.', + ), + LegalSummaryStep( + title: 'Em risco de vida, ligue 192', + text: + 'O botão de urgência avisa o seu agente de saúde. Se a vida estiver ' + 'em risco, ligue também para o SAMU.', + ), + LegalSummaryStep( + title: 'Use com verdade', + text: + 'Responda a triagem com sinceridade e use o alerta só quando ' + 'precisar de ajuda.', + ), + LegalSummaryStep( + title: 'O app ajuda, não substitui', + text: + 'A triagem organiza a prioridade das visitas. Ela não é consulta ' + 'nem diagnóstico.', + ), + ], + sections: [ + LegalSection( + title: 'Regras de uso', + body: + 'O app é para você acompanhar sua saúde com a equipe da sua UBS: ' + 'responder a triagem, pedir ajuda em urgência, ver o andamento do seu ' + 'pedido e cuidar dos seus dados. O acesso é pessoal: entre só com o ' + 'seu CPF ou com o convite que o seu agente de saúde mostrou a você.', + ), + LegalSection( + title: 'Responsabilidades', + body: + 'A equipe de saúde organiza as visitas pela classificação de risco. ' + 'Você se compromete a informar dados verdadeiros e a manter seu ' + 'celular protegido. A classificação da triagem não é diagnóstico e ' + 'não substitui uma consulta.', + ), + LegalSection( + title: 'O que não é permitido', + body: + 'Enviar alertas falsos, usar o acesso de outra pessoa, tentar ver ' + 'dados de outras pessoas ou atrapalhar o funcionamento do app.', + ), + LegalSection( + title: 'Propriedade intelectual', + body: + 'O app, sua marca e seu código pertencem aos seus desenvolvedores e ' + 'ao poder público que o adotou. O uso é gratuito e não transfere ' + 'nenhum desses direitos.', + ), + LegalSection( + title: 'Limites de responsabilidade', + body: + 'O app depende de internet e do seu aparelho para funcionar. Ele ' + 'não garante tempo de atendimento. Em risco de vida, ligue 192 (SAMU) ' + 'sem esperar resposta pelo app.', + ), + LegalSection( + title: 'Lei aplicável', + body: + 'Vale a lei brasileira, incluindo a Lei Geral de Proteção de Dados ' + '(Lei 13.709/2018). Questões judiciais são tratadas no foro do seu ' + 'município.', + ), + LegalSection( + title: 'Alterações neste termo', + body: + 'Quando este termo mudar, o app mostra a nova versão com pelo menos ' + '15 dias de antecedência e pede o seu aceite de novo. As versões ' + 'anteriores ficam no histórico abaixo.', + ), + ], + history: _history2026_1, +); diff --git a/apps/patient/lib/core/legal/terms_change_notice_card.dart b/apps/patient/lib/core/legal/terms_change_notice_card.dart new file mode 100644 index 00000000..b305e6ae --- /dev/null +++ b/apps/patient/lib/core/legal/terms_change_notice_card.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:sinalacs_client/sinalacs_client.dart' show TermsChangeNotice; +import 'package:sinalacs_patient/app/patient_theme.dart'; + +/// Aviso, dentro do app, de que os termos vão mudar (LGPD-RF18: 15 dias de +/// antecedência). Dispensável e nunca bloqueia nada: o alerta de urgência não +/// espera por leitura de termos. +class TermsChangeNoticeCard extends StatelessWidget { + const TermsChangeNoticeCard({ + super.key, + required this.notice, + required this.onRead, + required this.onDismiss, + this.onReadNew, + }); + + final TermsChangeNotice notice; + final VoidCallback onRead; + final VoidCallback onDismiss; + + /// Só passado quando o app carrega o texto da versão anunciada; sem ele o cartão + /// oferece apenas o texto vigente. + final VoidCallback? onReadNew; + + /// `dd/mm/aaaa` no fuso local. + static String formatDate(DateTime value) { + final local = value.toLocal(); + return '${local.day.toString().padLeft(2, '0')}/' + '${local.month.toString().padLeft(2, '0')}/${local.year}'; + } + + @override + Widget build(BuildContext context) { + return Card( + key: const Key('terms_change_notice_card'), + margin: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 8, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.info_outline, color: context.patientRisk.accentOnSurface), + const SizedBox(width: 8), + Expanded( + child: Semantics( + header: true, + child: const Text( + 'Os termos vão mudar', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + ), + IconButton( + key: const Key('terms_change_notice_dismiss'), + tooltip: 'Dispensar aviso', + icon: const Icon(Icons.close), + onPressed: onDismiss, + ), + ], + ), + Text( + 'A versão ${notice.version} passa a valer em ${formatDate(notice.effectiveFrom)}. ' + '${notice.summary}', + ), + if (onReadNew != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: FilledButton.tonal( + key: const Key('terms_change_notice_read_new'), + onPressed: onReadNew, + child: const Text('Ler o texto novo'), + ), + ), + Align( + alignment: Alignment.centerLeft, + child: TextButton( + key: const Key('terms_change_notice_read'), + onPressed: onRead, + child: const Text('Ler os termos atuais'), + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/patient/lib/core/network/backend_client.dart b/apps/patient/lib/core/network/backend_client.dart index 6678bee9..4553b658 100644 --- a/apps/patient/lib/core/network/backend_client.dart +++ b/apps/patient/lib/core/network/backend_client.dart @@ -100,13 +100,15 @@ abstract class PatientBackend { String? locationCell, }); - /// Conclui o onboarding a partir de um convite do ACS, gravando os 3 - /// consentimentos por finalidade (LGPD-RF02) e ativando a sessão. + /// Conclui o onboarding a partir de um convite do ACS, gravando os 4 + /// registros de consentimento, incluindo o aceite do Termo de Uso + /// (LGPD-RF02/RF18), e ativando a sessão. Future completeEnrollment({ required String token, required bool healthDataConsent, required bool remindersConsent, required bool pushConsent, + required bool termsAccepted, }); /// Condições crônicas do próprio paciente autenticado (tela "Perfil @@ -122,6 +124,46 @@ abstract class PatientBackend { /// acesso aos dados pessoais do próprio paciente autenticado. Future myData(); + /// Concede ou revoga uma finalidade opcional de consentimento (LGPD-RF05). + /// O servidor grava uma linha nova em `consent_logs`; `healthDataProcessing` + /// é recusado com [BackendFailure] não recuperável. + Future updateConsent({ + required ConsentPurpose purpose, + required bool granted, + }); + + /// Aceita o Termo de Uso e a Política de Privacidade vigentes (LGPD-RF18), + /// para quem entrou por OTP sem passar pelo onboarding. O servidor grava uma + /// linha `termsOfUse` `granted` em `consent_logs` só se a versão vigente ainda + /// não foi aceita; repetir devolve a existente. + Future acceptTermsOfUse(); + + /// Registra o token de push do aparelho (RF14). Falha se não houver + /// consentimento `segmentedPush` vigente; quem chama ignora a falha. + Future registerPushToken({required String token, required String platform}); + + /// Aviso de mudança dos termos ativo agora (LGPD-RF18, 15 dias de antecedência), + /// ou `null`. O app ignora falha e lentidão: sem aviso, sem cartão. + Future termsChangeNotice(); + + /// Se o paciente já aceitou o Termo de Uso e a Política de Privacidade da + /// versão vigente (LGPD-RF18). O login por OTP consulta isto para decidir se + /// mostra o convite ao aceite. + Future hasAcceptedCurrentTerms(); + + /// Se a decisão mais recente do titular para [purpose] é `granted`. Um `bool` + /// vindo do servidor, sem o painel "Meus Dados" e sem auditoria de leitura: o + /// app pergunta isto a cada login antes de falar com o provedor de push. + Future hasGrantedConsent(ConsentPurpose purpose); + + /// Pede a exclusão dos próprios dados (LGPD-RF08). Pedir de novo com um + /// pedido aberto devolve o mesmo. + Future requestDataDeletion(); + + /// Pede a correção de um dado (LGPD-RF08). O servidor faz o `trim` e recusa + /// texto vazio ou acima de 500 caracteres. + Future requestDataCorrection(String details); + void close(); } @@ -198,6 +240,7 @@ class MisconfiguredBackend implements PatientBackend { required bool healthDataConsent, required bool remindersConsent, required bool pushConsent, + required bool termsAccepted, }) async => _recusar(); @@ -210,6 +253,36 @@ class MisconfiguredBackend implements PatientBackend { @override Future myData() async => _recusar(); + @override + Future updateConsent({ + required ConsentPurpose purpose, + required bool granted, + }) async => + _recusar(); + + @override + Future acceptTermsOfUse() async => _recusar(); + + @override + Future hasAcceptedCurrentTerms() async => _recusar(); + + @override + Future hasGrantedConsent(ConsentPurpose purpose) async => _recusar(); + + @override + Future termsChangeNotice() async => _recusar(); + + @override + Future registerPushToken({required String token, required String platform}) async => + _recusar(); + + @override + Future requestDataDeletion() async => _recusar(); + + @override + Future requestDataCorrection(String details) async => + _recusar(); + /// Fechar **não** é uma chamada ao backend: não há o que fechar, e um `close` /// que lançasse derrubaria o `finally` de quem só queria encerrar. @override @@ -428,6 +501,7 @@ class BackendClient implements PatientBackend { required bool healthDataConsent, required bool remindersConsent, required bool pushConsent, + required bool termsAccepted, }) async { final result = await _guard( () => _client.onboarding.completeEnrollment( @@ -435,6 +509,7 @@ class BackendClient implements PatientBackend { healthDataConsent: healthDataConsent, remindersConsent: remindersConsent, pushConsent: pushConsent, + termsAccepted: termsAccepted, ), ); final session = AuthSession.tryParse(result.accessToken, result.tokenType); @@ -473,6 +548,64 @@ class BackendClient implements PatientBackend { return _guard(() => _client.patients.myData(accessToken: token)); } + @override + Future updateConsent({ + required ConsentPurpose purpose, + required bool granted, + }) async { + final token = await _requireToken(); + return _guard( + () => _client.patients.updateConsent(accessToken: token, purpose: purpose, granted: granted), + ); + } + + @override + Future acceptTermsOfUse() async { + final token = await _requireToken(); + return _guard(() => _client.patients.acceptTermsOfUse(accessToken: token)); + } + + @override + Future registerPushToken({required String token, required String platform}) async { + final accessToken = await _requireToken(); + return _guard( + () => _client.devices + .registerPushToken(accessToken: accessToken, token: token, platform: platform), + ); + } + + @override + Future termsChangeNotice() async { + final token = await _requireToken(); + return _guard(() => _client.patients.termsChangeNotice(accessToken: token)); + } + + @override + Future hasAcceptedCurrentTerms() async { + final token = await _requireToken(); + return _guard(() => _client.patients.hasAcceptedCurrentTerms(accessToken: token)); + } + + @override + Future hasGrantedConsent(ConsentPurpose purpose) async { + final token = await _requireToken(); + return _guard(() => _client.patients.hasGrantedConsent(accessToken: token, purpose: purpose)); + } + + @override + Future requestDataDeletion() async { + final token = await _requireToken(); + return _guard(() => _client.patients.requestDataDeletion(accessToken: token)); + } + + @override + Future requestDataCorrection(String details) async { + final token = await _requireToken(); + return _guard( + () => _client.patients.requestDataCorrection(accessToken: token, details: details), + ); + } + @override void close() => _client.close(); @@ -503,6 +636,11 @@ class BackendClient implements PatientBackend { // propósito: ela já é única para todas as causas, para não dizer se // aquele CPF está cadastrado. throw BackendFailure(error.message, isRecoverable: false); + } on DataRightsException catch (error) { + // Recusa de negócio de um direito do titular (consentimento obrigatório, + // texto de correção inválido). A mensagem é do servidor, pronta para a + // pessoa ler; tentar de novo sem mudar nada dá a mesma recusa. + 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. diff --git a/apps/patient/lib/core/onboarding/enrollment_qr.dart b/apps/patient/lib/core/onboarding/enrollment_qr.dart new file mode 100644 index 00000000..00d09c89 --- /dev/null +++ b/apps/patient/lib/core/onboarding/enrollment_qr.dart @@ -0,0 +1,15 @@ +/// Formato do convite que `OnboardingService._newToken` gera no servidor: +/// 32 bytes aleatórios em base64url, sem `=` de padding — 43 caracteres. +final _enrollmentToken = RegExp(r'^[A-Za-z0-9_-]{43}$'); + +/// O token contido num QR Code lido pela câmera, ou `null` se o QR não for um +/// convite do SinalACS (link, Pix, QR de outro app). +/// +/// Só a leitura da câmera passa por aqui: o campo digitado à mão continua +/// indo ao servidor como está, e é o servidor quem diz se o convite vale. +/// Aqui o objetivo é outro — não sobrescrever o campo com o conteúdo de um QR +/// qualquer que a câmera tenha pegado. +String? parseEnrollmentQr(String raw) { + final value = raw.trim(); + return _enrollmentToken.hasMatch(value) ? value : null; +} diff --git a/apps/patient/lib/core/push/native_push_token_source.dart b/apps/patient/lib/core/push/native_push_token_source.dart new file mode 100644 index 00000000..a64304ba --- /dev/null +++ b/apps/patient/lib/core/push/native_push_token_source.dart @@ -0,0 +1,31 @@ +import 'package:flutter/services.dart'; +import 'package:sinalacs_patient/core/push/push_token_source.dart'; + +/// Token de push do aparelho, pedido ao lado nativo por um `MethodChannel` +/// (FCM no Android, APNs no iOS — RF14, decisão §3.2). +/// +/// O contrato do canal `sinalacs/push_token`: o método `getToken` devolve +/// `{'token': String, 'platform': 'android' | 'ios'}`. O lado nativo (Kotlin e +/// Swift) ainda não existe — depende das credenciais FCM/APNs que a organização +/// vai fornecer —, então hoje o canal não responde e [currentDevice] devolve +/// `null`: o app degrada para "sem push" sem erro para a pessoa. +class NativePushTokenSource implements PushTokenSource { + const NativePushTokenSource(); + + static const MethodChannel _channel = MethodChannel('sinalacs/push_token'); + + @override + Future currentDevice() async { + try { + final answer = await _channel.invokeMapMethod('getToken'); + final token = answer?['token']; + final platform = answer?['platform']; + if (token is! String || token.trim().isEmpty) return null; + if (platform != 'android' && platform != 'ios') return null; + return PushDevice(token: token.trim(), platform: platform as String); + } catch (_) { + // Canal ausente, permissão negada ou resposta ilegível: sem token. + return null; + } + } +} diff --git a/apps/patient/lib/core/push/push_token_provider.dart b/apps/patient/lib/core/push/push_token_provider.dart new file mode 100644 index 00000000..6ace929a --- /dev/null +++ b/apps/patient/lib/core/push/push_token_provider.dart @@ -0,0 +1,10 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:sinalacs_patient/core/push/native_push_token_source.dart'; +import 'package:sinalacs_patient/core/push/push_token_source.dart'; + +/// De onde o app tira o token de push. Único uso de Riverpod no app do +/// paciente (decisão de 2026-09-29, RF14): o resto da injeção segue por +/// `InheritedWidget`. Sobrescrevível em teste com `overrideWithValue`. +final pushTokenSourceProvider = Provider( + (ref) => const NativePushTokenSource(), +); diff --git a/apps/patient/lib/core/push/push_token_source.dart b/apps/patient/lib/core/push/push_token_source.dart new file mode 100644 index 00000000..44b6d719 --- /dev/null +++ b/apps/patient/lib/core/push/push_token_source.dart @@ -0,0 +1,93 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:sinalacs_client/sinalacs_client.dart' show ConsentPurpose; +import 'package:sinalacs_patient/core/network/backend_client.dart'; + +/// Aparelho apto a receber push: o token do provedor e a plataforma. +class PushDevice { + const PushDevice({required this.token, required this.platform}); + + final String token; + + /// `android` ou `ios`, o mesmo vocabulário que o servidor valida. + final String platform; +} + +/// De onde o app tira o token de push. A implementação real, +/// `NativePushTokenSource` (canal `sinalacs/push_token`), lê o token do FCM no +/// Android e, no futuro, do APNs no iOS, e o backend o entrega depois ao Gorush +/// (decisão §3.2 do documento de decisões de produto). O lado nativo existe só +/// no Android, e só com `google-services.json` no build; onde o canal não +/// responde, o registro fica inerte. +abstract interface class PushTokenSource { + /// `null` quando o aparelho não tem token (sem provedor, sem permissão). + Future currentDevice(); +} + +class NoPushTokenSource implements PushTokenSource { + const NoPushTokenSource(); + + @override + Future currentDevice() async => null; +} + +const _consentLookupTimeout = Duration(seconds: 3); + +/// Registra o aparelho para avisos (RF14). Silencioso de propósito: o paciente +/// não pediu isto na tela, e uma recusa (consentimento desligado), uma falha de +/// rede ou um provedor sem token nunca podem atrasar a home nem o botão de +/// urgência. Tenta de novo no próximo login ou ao conceder o consentimento. +/// +/// Com [consentKnownGranted] falso, pergunta ao servidor (`hasGrantedConsent`, +/// teto de 3 s) antes de falar com o provedor; quem acabou de gravar a concessão +/// passa `true` e dispensa a ida. +Future registerPushDevice( + PatientBackend backend, + PushTokenSource source, { + bool consentKnownGranted = false, +}) async { + // Sem fonte de token não há o que perguntar ao provedor: poupa a ida ao + // servidor. + if (source is NoPushTokenSource) return; + try { + // LGPD: o aparelho só fala com o provedor (Google/Apple) depois de o + // servidor confirmar o consentimento vigente de avisos. Na dúvida (falha, + // teto estourado, nunca decidiu) não pergunta: fecha, não abre. + if (!consentKnownGranted) { + final granted = await backend + .hasGrantedConsent(ConsentPurpose.segmentedPush) + .timeout(_consentLookupTimeout); + if (!granted) return; + } + final device = await source.currentDevice(); + if (device == null) return; + await backend.registerPushToken(token: device.token, platform: device.platform); + } catch (_) { + // Intencionalmente silencioso — ver acima. + } +} + +/// Disponibiliza o [PushTokenSource] para a árvore de widgets, no mesmo padrão +/// de `QrScannerScope`: a tela não fala com o provedor, o que permite um duplo +/// em teste hermético. +class PushTokenScope extends InheritedWidget { + const PushTokenScope({required this.source, required super.child, super.key}); + + final PushTokenSource source; + + static PushTokenSource of(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType(); + assert(scope != null, 'Nenhum PushTokenScope acima deste widget.'); + return scope!.source; + } + + /// Como [of], mas sem escopo cai em [NoPushTokenSource]: o registro de push é + /// acessório, e uma tela montada fora do `SinalAcsApp` não pode quebrar o login. + static PushTokenSource maybeOf(BuildContext context) => + context.dependOnInheritedWidgetOfExactType()?.source ?? + const NoPushTokenSource(); + + @override + bool updateShouldNotify(PushTokenScope oldWidget) => source != oldWidget.source; +} diff --git a/apps/patient/lib/core/services/theme_controller.dart b/apps/patient/lib/core/services/theme_controller.dart new file mode 100644 index 00000000..585f3e67 --- /dev/null +++ b/apps/patient/lib/core/services/theme_controller.dart @@ -0,0 +1,51 @@ +import 'dart:developer' as developer; + +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Preferência de tema (claro/escuro/automático) do Paciente, persistida +/// localmente. +/// +/// `ValueNotifier` simples em vez de framework de estado externo — o app já +/// não usa Provider/Riverpod/Bloc (ver AGENTS.md), e o `MaterialApp` é o +/// único ouvinte. Falha ao ler/gravar a preferência NUNCA pode travar a +/// tela: é uma preferência de UI, não um dado que o app precisa para +/// funcionar, então qualquer erro aqui apenas mantém `ThemeMode.system`. +class ThemeController extends ValueNotifier { + ThemeController([super.value = ThemeMode.system]); + + static const _prefsKey = 'theme_mode'; + + Future restore() async { + try { + final prefs = await SharedPreferences.getInstance(); + final saved = prefs.getString(_prefsKey); + value = ThemeMode.values.firstWhere( + (mode) => mode.name == saved, + orElse: () => ThemeMode.system, + ); + } catch (error, stackTrace) { + developer.log( + 'não foi possível restaurar a preferência de tema', + name: 'sinalacs.patient.theme_controller', + error: error, + stackTrace: stackTrace, + ); + } + } + + Future setThemeMode(ThemeMode mode) async { + value = mode; + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_prefsKey, mode.name); + } catch (error, stackTrace) { + developer.log( + 'não foi possível salvar a preferência de tema', + name: 'sinalacs.patient.theme_controller', + error: error, + stackTrace: stackTrace, + ); + } + } +} diff --git a/apps/patient/lib/main.dart b/apps/patient/lib/main.dart index 451d2d02..7704107a 100644 --- a/apps/patient/lib/main.dart +++ b/apps/patient/lib/main.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart' show ProviderScope; import 'package:flutter/services.dart' show rootBundle; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:sinalacs_patient/app/app.dart'; @@ -65,7 +66,7 @@ Future main({String? defaultHost}) async { // O cliente é construído aqui, e não dentro de `SinalAcsApp`, porque só aqui // a CA já foi lida: `BackendConfig` não importa `rootBundle` de propósito — // `tool/live_check.dart` o importa fora do Flutter, onde `dart:ui` não existe. - runApp(SinalAcsApp(backend: backend)); + runApp(ProviderScope(child: SinalAcsApp(backend: backend))); } /// Os bytes da CA de desenvolvimento do RPC, ou `null` quando ela não pode ser diff --git a/apps/patient/pubspec.lock b/apps/patient/pubspec.lock index 38f9553e..350f6236 100644 --- a/apps/patient/pubspec.lock +++ b/apps/patient/pubspec.lock @@ -13,10 +13,10 @@ packages: dependency: transitive description: name: async - sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 url: "https://pub.dev" source: hosted - version: "2.11.0" + version: "2.13.1" boolean_selector: dependency: transitive description: @@ -155,6 +155,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.2.0" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "2b7f9d2a3c730ac1a98221b420cef966b990fca7ab546ede324642ca57a533e3" + url: "https://pub.dev" + source: hosted + version: "3.4.3" flutter_test: dependency: "direct dev" description: flutter @@ -271,6 +279,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + listen: + dependency: transitive + description: + name: listen + sha256: "47501a08016a43fcad79252439d723f50f14f88fa7bfd8a177e0a417e5c9e1f2" + url: "https://pub.dev" + source: hosted + version: "1.0.1" matcher: dependency: transitive description: @@ -295,6 +311,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.18.0" + mobile_scanner: + dependency: "direct main" + description: + name: mobile_scanner + sha256: "5a51ea79f0cf8293f616a5d08baae185b59b5e6bb24df01f2a63037ade0bade0" + url: "https://pub.dev" + source: hosted + version: "7.4.2" nm: dependency: transitive description: @@ -311,6 +335,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" petitparser: dependency: transitive description: @@ -343,6 +391,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.6" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "484dfc873ea4c4f4240e5635444fa066f87b07862b7279ebd004a2b71fba4b7a" + url: "https://pub.dev" + source: hosted + version: "3.4.3" serverpod_auth_core_client: dependency: transitive description: @@ -375,6 +431,62 @@ packages: url: "https://pub.dev" source: hosted version: "3.4.13" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "1e12aafe408aa50da80edfd679a2a6bf63ba7ab37c7fa98286da459a757b3399" + url: "https://pub.dev" + source: hosted + version: "2.4.28" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea" + url: "https://pub.dev" + source: hosted + version: "2.5.7" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" sinalacs_client: dependency: "direct main" description: @@ -467,6 +579,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.12.1" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" stream_channel: dependency: transitive description: @@ -613,4 +733,4 @@ packages: version: "3.1.4" sdks: dart: ">=3.12.0 <4.0.0" - flutter: ">=3.24.0" + flutter: ">=3.44.0" diff --git a/apps/patient/pubspec.yaml b/apps/patient/pubspec.yaml index 4640b90a..9c15c1cf 100644 --- a/apps/patient/pubspec.yaml +++ b/apps/patient/pubspec.yaml @@ -25,11 +25,16 @@ dependencies: connectivity_plus: ^7.3.1 geolocator: ^12.0.0 flutter_local_notifications: ^17.0.0 + # Preferência de tema (claro/escuro/automático) — dado de UI não sensível, + # não precisa do Keystore/Keychain do flutter_secure_storage. + shared_preferences: ^2.3.2 # Usado diretamente por `core/reminders/reminder_scheduler.dart` para # construir o `tz.TZDateTime` que `zonedSchedule` exige. Já vinha como # transitiva de `flutter_local_notifications`; declarada aqui porque o # código do app importa `package:timezone/timezone.dart` diretamente. timezone: ^0.9.4 + mobile_scanner: ^7.0.0 + flutter_riverpod: ^3.4.3 dev_dependencies: flutter_test: diff --git a/apps/patient/test/android_push_manifest_test.dart b/apps/patient/test/android_push_manifest_test.dart new file mode 100644 index 00000000..53151b50 --- /dev/null +++ b/apps/patient/test/android_push_manifest_test.dart @@ -0,0 +1,25 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +/// O FCM, por padrão, gera o token e fala com os servidores do Google **em toda +/// abertura do app** (auto-init): antes do login e sem nenhum consentimento +/// `segmentedPush` (LGPD: recusa por omissão). O token só pode ser pedido quando o +/// código o pedir (`MainActivity`, canal `sinalacs/push_token`), então o auto-init +/// precisa estar desligado no manifesto. Este teste lê o texto-fonte, o mesmo idioma +/// de `legal_documents_test.dart`: o manifesto não existe no `flutter test`. +void main() { + final manifest = File('android/app/src/main/AndroidManifest.xml').readAsStringSync(); + + test('o auto-init do FCM está desligado no manifesto', () { + final meta = RegExp( + r'', + ); + expect(meta.hasMatch(manifest), isTrue, + reason: 'sem isto o aparelho contata o Google em toda abertura, sem consentimento'); + }); + + test('o manifesto continua pedindo POST_NOTIFICATIONS', () { + expect(manifest.contains('android.permission.POST_NOTIFICATIONS'), isTrue); + }); +} diff --git a/apps/patient/test/backend_client_data_rights_test.dart b/apps/patient/test/backend_client_data_rights_test.dart new file mode 100644 index 00000000..e5e06ca4 --- /dev/null +++ b/apps/patient/test/backend_client_data_rights_test.dart @@ -0,0 +1,63 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_client/sinalacs_client.dart'; +import 'package:sinalacs_patient/core/network/backend_client.dart'; + +import 'support/fake_rpc_server.dart'; + +/// A tradução `DataRightsException → BackendFailure(mensagem do servidor, não +/// recuperável)` pelo [BackendClient] real. A suíte de widgets usa o +/// `FakePatientBackend`, que não tem `_guard`, e apagar a cláusula de lá não +/// deixaria nada vermelho sem este arquivo. +void main() { + late FakeRpcServer server; + late BackendClient backend; + + setUp(() async { + server = await FakeRpcServer.start(); + backend = BackendClient(host: server.host); + await backend.verifyOtp(cpf: '123.456.789-09', code: '123456'); + }); + + tearDown(() async { + backend.close(); + await server.stop(); + }); + + Matcher recusaDoServidor(String mensagem) => throwsA( + isA() + .having((falha) => falha.message, 'mensagem', mensagem) + .having((falha) => falha.isRecoverable, 'isRecoverable', isFalse), + ); + + test('a recusa de updateConsent chega com a mensagem do servidor', () async { + server.rejectDataRightsWith = 'O consentimento para dados de saúde é obrigatório.'; + + await expectLater( + backend.updateConsent(purpose: ConsentPurpose.healthDataProcessing, granted: false), + recusaDoServidor('O consentimento para dados de saúde é obrigatório.'), + ); + + final pedido = server.requests.last; + expect(pedido.endpoint, 'patients'); + expect(pedido.method, 'updateConsent'); + expect(pedido.args['purpose'], 'healthDataProcessing'); + expect(pedido.args['granted'], false); + }); + + test('a recusa de requestDataCorrection chega com a mensagem do servidor', () async { + server.rejectDataRightsWith = 'Descreva o que precisa ser corrigido.'; + + await expectLater( + backend.requestDataCorrection(' '), + recusaDoServidor('Descreva o que precisa ser corrigido.'), + ); + expect(server.requests.last.args['details'], ' '); + }); + + test('a recusa de requestDataDeletion chega com a mensagem do servidor', () async { + server.rejectDataRightsWith = 'Recusado.'; + + await expectLater(backend.requestDataDeletion(), recusaDoServidor('Recusado.')); + expect(server.requests.last.method, 'requestDataDeletion'); + }); +} diff --git a/apps/patient/test/consent_decisions_test.dart b/apps/patient/test/consent_decisions_test.dart new file mode 100644 index 00000000..a047fd99 --- /dev/null +++ b/apps/patient/test/consent_decisions_test.dart @@ -0,0 +1,59 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_client/sinalacs_client.dart'; +import 'package:sinalacs_patient/core/consent/consent_decisions.dart'; + +PatientConsentRecord _record(String purpose, String action, DateTime at) => + PatientConsentRecord(purpose: purpose, action: action, version: '2026.1', timestamp: at); + +void main() { + test('histórico vazio não tem decisão nenhuma — "nunca decidiu" não vira recusa', () { + expect(currentConsentDecisions(const []), isEmpty); + }); + + test('vale o registro mais recente, qualquer que seja a ordem da lista', () { + final decisions = currentConsentDecisions([ + _record('localReminders', 'denied', DateTime.utc(2026, 2, 1)), + _record('localReminders', 'granted', DateTime.utc(2026, 1, 1)), + ]); + + expect(decisions, {ConsentPurpose.localReminders: false}); + }); + + test('empate de horário: vale o último da lista', () { + final at = DateTime.utc(2026, 1, 1); + final decisions = currentConsentDecisions([ + _record('segmentedPush', 'granted', at), + _record('segmentedPush', 'denied', at), + ]); + + expect(decisions[ConsentPurpose.segmentedPush], isFalse); + }); + + test('as três finalidades do onboarding saem independentes', () { + final at = DateTime.utc(2026, 1, 1); + final decisions = currentConsentDecisions([ + _record('healthDataProcessing', 'granted', at), + _record('localReminders', 'granted', at), + _record('segmentedPush', 'denied', at), + ]); + + expect(decisions, { + ConsentPurpose.healthDataProcessing: true, + ConsentPurpose.localReminders: true, + ConsentPurpose.segmentedPush: false, + }); + }); + + test('finalidade que o app não conhece é ignorada', () { + final decisions = currentConsentDecisions([ + _record('finalidadeFutura', 'granted', DateTime.utc(2026, 1, 1)), + ]); + + expect(decisions, isEmpty); + }); + + test('rótulo de registro cai no nome cru só para finalidade desconhecida', () { + expect(consentRecordLabel('localReminders'), 'Lembretes neste aparelho'); + expect(consentRecordLabel('finalidadeFutura'), 'finalidadeFutura'); + }); +} diff --git a/apps/patient/test/contrast_tokens_light_test.dart b/apps/patient/test/contrast_tokens_light_test.dart new file mode 100644 index 00000000..35b488ab --- /dev/null +++ b/apps/patient/test/contrast_tokens_light_test.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_patient/app/patient_theme.dart'; + +import 'support/contrast.dart'; + +/// Equivalente de `contrast_tokens_test.dart` para o tema claro (issue #9). +/// +/// `accent` (teal-600) é desenhado para funcionar como texto sobre fundo +/// ESCURO (ver `PatientRiskColors.dark`); sobre fundo claro cai para +/// 3.74:1 — por isso `PatientRiskColors.light` usa um tom mais escuro +/// (`PatientColors.accentDark`, teal-700) só para leitura como texto, sem +/// alterar o preenchimento (`PatientColors.accent`), que continua o mesmo +/// em ambos os temas. +void main() { + const normalText = 4.5; + const largeTextOrUi = 3.0; + const risk = PatientRiskColors.light; + // Cartão de aviso de mudança dos termos: cores geradas pelo tema a partir do seed. + final scheme = buildPatientLightTheme().colorScheme; + + final cases = <(String, Color, Color, double)>[ + ('texto principal sobre scaffold', const Color(0xFF111827), PatientLightColors.background, normalText), + ('texto principal sobre card', const Color(0xFF111827), PatientLightColors.surfaceRaised, normalText), + ('dangerOnSurface sobre card', risk.dangerOnSurface, PatientLightColors.surfaceRaised, normalText), + ('dangerOnSurface sobre scaffold', risk.dangerOnSurface, PatientLightColors.background, normalText), + ('accentOnSurface sobre card', risk.accentOnSurface, PatientLightColors.surfaceRaised, normalText), + ('accentOnSurface sobre scaffold', risk.accentOnSurface, PatientLightColors.background, normalText), + ('yellowOnSurface sobre card', risk.yellowOnSurface, PatientLightColors.surfaceRaised, normalText), + ('yellowOnSurface sobre scaffold', risk.yellowOnSurface, PatientLightColors.background, normalText), + ('texto do cartão de aviso (onSurface) sobre card', scheme.onSurface, PatientLightColors.surfaceRaised, normalText), + ('TextButton do cartão de aviso (primary) sobre card', scheme.primary, PatientLightColors.surfaceRaised, normalText), + ('ícone de fechar do cartão de aviso (onSurfaceVariant) sobre card', scheme.onSurfaceVariant, PatientLightColors.surfaceRaised, largeTextOrUi), + ('branco sobre accentDark (passos do resumo legal)', Colors.white, PatientColors.accentDark, 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', + ); + }); + } + + // Documenta por que o tema claro precisa de um tom próprio de accent: o + // fill (teal-600) falha como texto sobre card claro. `danger` (vermelho), + // ao contrário de `accent`, passa por pouco mesmo sem token dedicado + // (4.83:1 sobre card) — `dangerOnSurface` é usado mesmo assim, pela mesma + // margem de segurança já adotada no tema escuro. + test('accent (fill, teal-600) usado como texto sobre card claro fica abaixo de 4.5:1', () { + expect(contrastOn(PatientColors.accent, PatientLightColors.surfaceRaised), lessThan(normalText)); + }); +} diff --git a/apps/patient/test/contrast_tokens_test.dart b/apps/patient/test/contrast_tokens_test.dart index a3f4d226..c5351cf9 100644 --- a/apps/patient/test/contrast_tokens_test.dart +++ b/apps/patient/test/contrast_tokens_test.dart @@ -6,22 +6,32 @@ 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. +/// existir no lugar da medição manual no WebAIM, e +/// `contrast_tokens_light_test.dart` para o equivalente do tema claro. void main() { const normalText = 4.5; const largeTextOrUi = 3.0; + const risk = PatientRiskColors.dark; - const cases = <(String, Color, Color, double)>[ - ('branco sobre scaffold', Colors.white, PatientColors.background, normalText), - ('branco sobre card', Colors.white, PatientColors.surfaceRaised, normalText), + // Pares que o cartão de aviso de mudança dos termos usa e que o tema gera a + // partir do seed (não são tokens fixos), medidos contra o `Card` em que renderizam. + final scheme = buildPatientDarkTheme().colorScheme; + final cases = <(String, Color, Color, double)>[ + ('branco sobre scaffold', Colors.white, PatientDarkColors.background, normalText), + ('branco sobre card', Colors.white, PatientDarkColors.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), + ('white54 sobre card (rodapé da triagem)', Colors.white54, PatientDarkColors.surfaceRaised, normalText), + ('yellowOnSurface sobre card (risco amarelo)', risk.yellowOnSurface, PatientDarkColors.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), + ('dangerOnSurface sobre card', risk.dangerOnSurface, PatientDarkColors.surfaceRaised, normalText), + ('dangerOnSurface sobre scaffold', risk.dangerOnSurface, PatientDarkColors.background, normalText), + ('accentOnSurface sobre card', risk.accentOnSurface, PatientDarkColors.surfaceRaised, normalText), + // Círculo numerado do resumo da Política/Termo (`legal_screens.dart`). + ('branco sobre accentDark (passos do resumo legal)', Colors.white, PatientColors.accentDark, normalText), + ('texto do cartão de aviso (onSurface) sobre card', scheme.onSurface, PatientDarkColors.surfaceRaised, normalText), + ('TextButton do cartão de aviso (primary) sobre card', scheme.primary, PatientDarkColors.surfaceRaised, normalText), + ('ícone de fechar do cartão de aviso (onSurfaceVariant) sobre card', scheme.onSurfaceVariant, PatientDarkColors.surfaceRaised, largeTextOrUi), // Preenchimento de botão: continua correto sem token novo. ('branco sobre botão de pânico (danger fill)', Colors.white, PatientColors.danger, largeTextOrUi), ]; @@ -42,14 +52,14 @@ void main() { // 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)); + expect(contrastOn(PatientColors.danger, PatientDarkColors.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)); + expect(contrastOn(PatientColors.danger, PatientDarkColors.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)); + expect(contrastOn(PatientColors.accent, PatientDarkColors.surfaceRaised), lessThan(normalText)); }); } diff --git a/apps/patient/test/e2e_login_test.dart b/apps/patient/test/e2e_login_test.dart new file mode 100644 index 00000000..bdbd3cf8 --- /dev/null +++ b/apps/patient/test/e2e_login_test.dart @@ -0,0 +1,128 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'support/e2e_config.dart'; +import 'support/e2e_login_core.dart'; +import 'support/fake_patient_backend.dart'; + +Map _manifesto() => { + 'microAreaId': 'm', + 'acs': {'id': 'c', 'matricula': 'E2E-1', 'password': 'senha-secreta-xyz'}, + 'patients': [ + {'role': 'main', 'cpf': '52998224725', 'birthDate': '1990-01-01', 'id': 'a', 'name': 'Nome Um', 'chronic': false, 'microAreaId': 'm'}, + {'role': 'outsider', 'cpf': '11144477735', 'birthDate': '1970-02-02', 'id': 'b', 'name': 'Nome Dois', 'chronic': false, 'microAreaId': 'n'}, + ], + }; + +/// Relé de mentira: responde 404 nas `naoRespondidas` primeiras chamadas e +/// depois devolve [codigo]. Guarda o `since` recebido. +Future<(HttpServer, List)> _rele({required String codigo, int naoRespondidas = 0}) async { + const relogioDoHost = 1700000000000; // diferente do relógio deste processo, de propósito + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final desdes = []; + var chamadas = 0; + server.listen((request) async { + if (request.uri.path == '/count') { + desdes.add('count:${request.uri.queryParameters['since']}'); + request.response.write('2'); + await request.response.close(); + return; + } + if (request.uri.path == '/now') { + request.response.write('$relogioDoHost'); + await request.response.close(); + return; + } + desdes.add(request.uri.queryParameters['since'] ?? ''); + if (chamadas++ < naoRespondidas) { + request.response.statusCode = 404; + } else { + request.response.write(codigo); + } + await request.response.close(); + }); + addTearDown(() => server.close(force: true)); + return (server, desdes); +} + +void main() { + test('sem manifesto, não há configuração e vale o fallback de desenvolvimento', () async { + expect(E2eConfig.fromMap(const {}), isNull); + + final backend = FakePatientBackend(); + await loginPatientWith(backend, null, relayUrl: 'http://127.0.0.1:1/code'); + + expect(backend.developmentLoginCount, 1); + expect(backend.otpRequests, isEmpty); + }); + + test('o manifesto escolhe o paciente pelo papel', () { + final config = E2eConfig.fromMap(_manifesto())!; + + expect(config.patient('outsider').birthDate, DateTime(1970, 2, 2)); + expect(config.microAreaId, 'm'); + expect(() => config.patient('inexistente'), throwsStateError); + }); + + test('o manifesto do aparelho não traz a senha do ACS e mesmo assim é lido', () { + final semAcs = _manifesto()..remove('acs'); + + final config = E2eConfig.fromMap(semAcs)!; + + expect(config.patient('main').birthDate, DateTime(1990, 1, 1)); + expect(config.acsPassword, isEmpty); + }); + + test('toString nunca mostra CPF nem senha do ACS', () { + final config = E2eConfig.fromMap(_manifesto())!; + final texto = '$config ${config.patient('main')}'; + + expect(texto.contains('52998224725'), isFalse); + expect(texto.contains(config.acsPassword), isFalse); + }); + + test('com manifesto: pede o OTP da fixture, lê o código no relé e verifica com ele', () async { + final (server, desdes) = await _rele(codigo: '123456', naoRespondidas: 2); + final backend = FakePatientBackend(); + + final session = await loginPatientWith( + backend, + E2eConfig.fromMap(_manifesto()), + relayUrl: 'http://127.0.0.1:${server.port}/code', + role: 'outsider', + retryDelay: Duration.zero, + ); + + expect(session.role, 'patient'); + expect(backend.developmentLoginCount, 0, reason: 'com manifesto nunca usa o login de desenvolvimento'); + expect(backend.otpRequests.single.cpf, '11144477735'); + expect(backend.otpRequests.single.birthDate, DateTime(1970, 2, 2)); + expect(backend.otpVerifications.single.code, '123456'); + // O corte vem do relógio do HOST (o do relé), nunca do aparelho: um emulador + // adiantado ou atrasado não pode fazer o relé negar o código do pedido. + expect(desdes, hasLength(3)); + expect(desdes.every((d) => d == '1700000000000'), isTrue); + }); + + test('relayCount devolve quantos códigos o relé viu depois do corte', () async { + final (server, desdes) = await _rele(codigo: '123456'); + + final n = await relayCount('http://127.0.0.1:${server.port}/code', 42); + + expect(n, 2); + expect(desdes, ['count:42']); + }); + + test('relé mudo: falha com a dica de subir o relé, sem chamar o verifyOtp', () async { + final (server, _) = await _rele(codigo: 'x', naoRespondidas: 1000); + final backend = FakePatientBackend(); + + await expectLater( + loginPatientWith(backend, E2eConfig.fromMap(_manifesto()), + relayUrl: 'http://127.0.0.1:${server.port}/code', retryDelay: Duration.zero, attempts: 3), + throwsA(isA().having((e) => e.message, 'message', contains('otp_relay.py'))), + ); + expect(backend.otpVerifications, isEmpty); + }); +} diff --git a/apps/patient/test/enrollment_qr_test.dart b/apps/patient/test/enrollment_qr_test.dart new file mode 100644 index 00000000..f91023c1 --- /dev/null +++ b/apps/patient/test/enrollment_qr_test.dart @@ -0,0 +1,26 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_patient/core/onboarding/enrollment_qr.dart'; + +/// Token sintético no formato real: 43 caracteres base64url. +const conviteSintetico = 'AbCdEfGhIjKlMnOpQrStUvWxYz0123456789-_AbCde'; + +void main() { + test('aceita um convite no formato do servidor', () { + expect(conviteSintetico.length, 43); + expect(parseEnrollmentQr(conviteSintetico), conviteSintetico); + }); + + test('tira espaços e quebras de linha nas pontas', () { + expect(parseEnrollmentQr(' $conviteSintetico\n'), conviteSintetico); + }); + + test('recusa QR que não é convite', () { + expect(parseEnrollmentQr('https://exemplo.invalid/pagina'), isNull); + expect(parseEnrollmentQr('00020126580014br.gov.bcb.pix'), isNull); + expect(parseEnrollmentQr(''), isNull); + expect(parseEnrollmentQr(conviteSintetico.substring(1)), isNull, reason: '42 caracteres'); + expect(parseEnrollmentQr('${conviteSintetico}A'), isNull, reason: '44 caracteres'); + expect(parseEnrollmentQr(conviteSintetico.replaceFirst('A', '+')), isNull, + reason: 'base64 padrão, não url-safe'); + }); +} diff --git a/apps/patient/test/legal_documents_test.dart b/apps/patient/test/legal_documents_test.dart new file mode 100644 index 00000000..54d64f43 --- /dev/null +++ b/apps/patient/test/legal_documents_test.dart @@ -0,0 +1,98 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_patient/core/legal/legal_documents.dart'; + +/// Conteúdo mínimo que `spec/lgpd_design.md` exige de cada documento +/// (LGPD-RF18, linha 249; LGPD-RF19, linha 259). O teste procura o tópico no +/// título das seções, sem acento e em minúsculas, para não quebrar por uma +/// troca de redação que mantém o tópico. +String _norm(String text) => text + .toLowerCase() + .replaceAll(RegExp('[áàâã]'), 'a') + .replaceAll(RegExp('[éê]'), 'e') + .replaceAll('í', 'i') + .replaceAll(RegExp('[óôõ]'), 'o') + .replaceAll('ú', 'u') + .replaceAll('ç', 'c'); + +void expectTopics(LegalDocument document, List topics) { + final titles = document.sections.map((s) => _norm(s.title)).join(' | '); + for (final topic in topics) { + expect( + titles, + contains(topic), + reason: '${document.title} sem seção sobre "$topic"', + ); + } +} + +void main() { + test('política de privacidade cobre o conteúdo mínimo do LGPD-RF19', () { + expectTopics(privacyPolicy, [ + 'quem cuida dos seus dados', + 'dados que coletamos', + 'para que usamos', + 'bases legais', + 'com quem compartilhamos', + 'por quanto tempo', + 'seus direitos', + 'como protegemos', + 'encarregado', + 'duvidas', + ]); + }); + + test('termo de uso cobre o conteúdo mínimo do LGPD-RF18', () { + expectTopics(termsOfUse, [ + 'regras de uso', + 'responsabilidades', + 'o que nao e permitido', + 'propriedade intelectual', + 'limites de responsabilidade', + 'lei aplicavel', + 'alteracoes', + ]); + }); + + test( + 'os dois documentos têm resumo, versão vigente e histórico que a inclui', + () { + for (final document in [privacyPolicy, termsOfUse]) { + expect(document.summary, isNotEmpty, reason: document.title); + expect(document.version, legalDocumentsVersion, reason: document.title); + expect( + document.history.map((v) => v.version), + contains(document.version), + reason: '${document.title}: versão vigente fora do histórico', + ); + for (final section in document.sections) { + expect( + section.body.trim(), + isNotEmpty, + reason: '${document.title} › ${section.title}', + ); + } + } + }, + ); + + test('versão dos documentos é a mesma que o backend carimba em consent_logs', () { + // Guarda de deriva: o aceite gravado no cadastro leva `consentPolicyVersion` + // do backend; se o app mostrar outra versão, "Meus dados" passa a exibir um + // aceite de um texto que a pessoa nunca viu. Monorepo: o CI do app faz + // checkout do repositório inteiro, então o arquivo do servidor existe. + final source = File( + '../../backend/sinalacs_server/lib/src/application/onboarding/onboarding_service.dart', + ).readAsStringSync(); + final match = RegExp( + r"consentPolicyVersion = '([^']+)'", + ).firstMatch(source); + expect( + match, + isNotNull, + reason: 'consentPolicyVersion não encontrado no backend', + ); + expect(legalDocumentsVersion, match!.group(1)); + }); +} diff --git a/apps/patient/test/legal_screens_test.dart b/apps/patient/test/legal_screens_test.dart new file mode 100644 index 00000000..f161f9dd --- /dev/null +++ b/apps/patient/test/legal_screens_test.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_patient/app/app.dart'; +import 'package:sinalacs_patient/app/legal_screens.dart'; +import 'package:sinalacs_patient/core/legal/legal_documents.dart'; + +import 'support/fake_patient_backend.dart'; +import 'support/contrast.dart'; +import 'support/semantics_scan.dart'; + +Future tapKey(WidgetTester tester, String key) async { + final finder = find.byKey(Key(key)); + await tester.ensureVisible(finder); + await tester.pumpAndSettle(); + await tester.tap(finder); + await tester.pumpAndSettle(); +} + +void main() { + testWidgets('antes de entrar, a tela de login abre a política e o termo', ( + tester, + ) async { + // Ler antes de aceitar: os documentos têm de abrir sem sessão. + await tester.pumpWidget(SinalAcsApp(backend: FakePatientBackend())); + await tapKey(tester, 'login_legal_link'); + + expect(find.byType(LegalDocumentsScreen), findsOneWidget); + await tapKey(tester, 'legal_open_privacy'); + expect(find.text('Política de Privacidade'), findsWidgets); + expect( + find.textContaining('Versão $legalDocumentsVersion'), + findsOneWidget, + ); + + await tester.pageBack(); + await tester.pumpAndSettle(); + await tapKey(tester, 'legal_open_terms'); + expect(find.text('Termo de Uso'), findsWidgets); + }); + + testWidgets( + 'documento mostra resumo, texto completo expansível e histórico de versões', + (tester) async { + tester.view.physicalSize = const Size(800, 2400); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + const MaterialApp(home: LegalDocumentScreen(document: privacyPolicy)), + ); + + expect(find.byKey(const Key('legal_summary')), findsOneWidget); + for (final step in privacyPolicy.summary) { + expect(find.text(step.title), findsOneWidget); + } + // Versão completa recolhida por padrão: o corpo só aparece ao expandir. + final first = privacyPolicy.sections.first; + expect(find.text(first.body), findsNothing); + await tapKey(tester, 'legal_section_0'); + expect(find.text(first.body), findsOneWidget); + + await tester.ensureVisible(find.byKey(const Key('legal_history'))); + expect( + find.descendant( + of: find.byKey(const Key('legal_history')), + matching: find.textContaining('2026.1 · vigente desde 29/09/2026'), + ), + findsOneWidget, + ); + }, + ); + + testWidgets( + 'índice dos documentos não tem botão inerte para leitor de tela', + (tester) async { + final handle = tester.ensureSemantics(); + await tester.pumpWidget(const MaterialApp(home: LegalDocumentsScreen())); + expectNenhumBotaoInerte(tester); + handle.dispose(); + }, + ); + + testWidgets('números dos passos do resumo têm contraste de texto normal', ( + tester, + ) async { + // Dígito de 16sp sobre círculo preenchido: é texto normal (WCAG 1.4.3, + // 4.5:1), não "texto grande" — o fill `accent` puro fica em ~3.7:1. + await tester.pumpWidget( + const MaterialApp(home: LegalDocumentScreen(document: privacyPolicy)), + ); + final avatars = tester.widgetList( + find.descendant( + of: find.byKey(const Key('legal_summary')), + matching: find.byType(CircleAvatar), + ), + ); + expect(avatars, isNotEmpty); + for (final avatar in avatars) { + final ratio = contrastOn(avatar.foregroundColor!, avatar.backgroundColor!); + expect(ratio, greaterThanOrEqualTo(4.5), reason: '$ratio:1'); + } + }); +} diff --git a/apps/patient/test/main_boot_ca_test.dart b/apps/patient/test/main_boot_ca_test.dart index bf0b7331..4e320f4e 100644 --- a/apps/patient/test/main_boot_ca_test.dart +++ b/apps/patient/test/main_boot_ca_test.dart @@ -210,6 +210,7 @@ void main() { ); await tester.pump(); await tocar(tester, 'onboarding_consent_health'); + await tocar(tester, 'onboarding_terms_accept'); await tocar(tester, 'complete_enrollment_button'); final erro = tester.widget(find.byKey(const Key('onboarding_error'))).data!; diff --git a/apps/patient/test/no_fixed_white_text_test.dart b/apps/patient/test/no_fixed_white_text_test.dart new file mode 100644 index 00000000..7dfc6019 --- /dev/null +++ b/apps/patient/test/no_fixed_white_text_test.dart @@ -0,0 +1,22 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +/// Guarda contra texto/borda em branco translúcido fixo (`Colors.white24/54/70`): +/// foi escrito para o tema escuro e some sobre o fundo claro (medido no emulador, +/// 2026-10-01). Use `Theme.of(context).colorScheme.onSurfaceVariant` (texto) ou +/// `outlineVariant` (borda), que seguem o tema ativo. +void main() { + test('lib/ não usa Colors.white24/54/70 fixos', () { + final padrao = RegExp(r'Colors\.white(24|54|70)\b'); + final achados = []; + for (final arquivo in Directory('lib').listSync(recursive: true).whereType()) { + if (!arquivo.path.endsWith('.dart')) continue; + final linhas = arquivo.readAsLinesSync(); + for (var i = 0; i < linhas.length; i++) { + if (padrao.hasMatch(linhas[i])) achados.add('${arquivo.path}:${i + 1}'); + } + } + expect(achados, isEmpty, reason: 'cores brancas fixas somem no tema claro'); + }); +} diff --git a/apps/patient/test/onboarding_flow_test.dart b/apps/patient/test/onboarding_flow_test.dart index fa8c5e55..8b91837e 100644 --- a/apps/patient/test/onboarding_flow_test.dart +++ b/apps/patient/test/onboarding_flow_test.dart @@ -1,8 +1,12 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:sinalacs_patient/app/app.dart'; +import 'package:sinalacs_patient/app/legal_screens.dart'; import 'package:sinalacs_patient/core/consent/consent_preferences.dart'; import 'package:sinalacs_patient/core/network/backend_client.dart'; +import 'package:sinalacs_patient/core/push/push_token_source.dart'; import 'support/fake_patient_backend.dart'; import 'support/semantics_scan.dart'; @@ -37,6 +41,28 @@ Future tapKey(WidgetTester tester, String key) async { await tester.pumpAndSettle(); } +class _FonteDeToken implements PushTokenSource { + int calls = 0; + + @override + Future currentDevice() async { + calls++; + return const PushDevice(token: 'tok-onb', platform: 'android'); + } +} + +Future concluirCadastro(WidgetTester tester, {required bool avisos}) async { + await openOnboarding(tester); + await tester.enterText(find.byKey(const Key('onboarding_token_field')), 'convite-123'); + await tapKey(tester, 'onboarding_consent_health'); + await tapKey(tester, 'onboarding_terms_accept'); + if (avisos) await tapKey(tester, 'onboarding_consent_push'); + await tapKey(tester, 'complete_enrollment_button'); +} + +/// Token sintético no formato real (43 caracteres base64url). +const _conviteSintetico = 'AbCdEfGhIjKlMnOpQrStUvWxYz0123456789-_AbCde'; + void main() { testWidgets('deve abrir com campo de token e os 3 consentimentos desmarcados', (tester) async { await tester.pumpWidget(SinalAcsApp(backend: FakePatientBackend())); @@ -59,6 +85,11 @@ void main() { expect(healthConsent.value, isFalse); expect(remindersConsent.value, isFalse); expect(pushConsent.value, isFalse); + await tester.ensureVisible(find.byKey(const Key('onboarding_terms_accept'))); + final termsAccept = tester.widget( + find.byKey(const Key('onboarding_terms_accept')), + ); + expect(termsAccept.value, isFalse); }); testWidgets('botão de concluir cadastro fica desabilitado enquanto o token estiver vazio', (tester) async { @@ -99,6 +130,7 @@ void main() { await tester.enterText(find.byKey(const Key('onboarding_token_field')), 'convite-123'); await tapKey(tester, 'onboarding_consent_health'); + await tapKey(tester, 'onboarding_terms_accept'); await tapKey(tester, 'onboarding_consent_reminders'); await tapKey(tester, 'complete_enrollment_button'); @@ -108,10 +140,35 @@ void main() { 'healthDataConsent': true, 'remindersConsent': true, 'pushConsent': false, + 'termsAccepted': true, }); expect(find.text('Triagem rápida'), findsOneWidget); }); + testWidgets('cadastro com "Avisos da equipe" marcado registra o token sem consultar o servidor', + (tester) async { + final backend = FakePatientBackend(); + final fonte = _FonteDeToken(); + await tester.pumpWidget(SinalAcsApp(backend: backend, pushTokens: fonte)); + await concluirCadastro(tester, avisos: true); + + expect(backend.pushRegistrations, [('tok-onb', 'android')]); + expect(backend.hasGrantedConsentCalls, 0); + expect(backend.myDataCallCount, 0); + }); + + testWidgets('cadastro sem "Avisos da equipe": o aparelho nem é consultado', (tester) async { + final backend = FakePatientBackend(); + final fonte = _FonteDeToken(); + await tester.pumpWidget(SinalAcsApp(backend: backend, pushTokens: fonte)); + await concluirCadastro(tester, avisos: false); + + expect(fonte.calls, 0); + expect(backend.pushRegistrations, isEmpty); + expect(backend.hasGrantedConsentCalls, 0); + expect(find.text('Triagem rápida'), findsOneWidget); + }); + testWidgets('falha do backend mostra a mensagem e mantém a tela de onboarding', (tester) async { final backend = FakePatientBackend( enrollmentFailure: const BackendFailure('Convite inválido, expirado ou já utilizado.'), @@ -121,6 +178,7 @@ void main() { await tester.enterText(find.byKey(const Key('onboarding_token_field')), 'convite-123'); await tapKey(tester, 'onboarding_consent_health'); + await tapKey(tester, 'onboarding_terms_accept'); await tapKey(tester, 'complete_enrollment_button'); expect(find.text('Convite inválido, expirado ou já utilizado.'), findsOneWidget); @@ -136,6 +194,7 @@ void main() { await tester.enterText(find.byKey(const Key('onboarding_token_field')), 'convite-123'); await tapKey(tester, 'onboarding_consent_health'); + await tapKey(tester, 'onboarding_terms_accept'); await tapKey(tester, 'onboarding_consent_reminders'); await tapKey(tester, 'complete_enrollment_button'); @@ -150,6 +209,7 @@ void main() { await tester.enterText(find.byKey(const Key('onboarding_token_field')), 'convite-123'); await tapKey(tester, 'onboarding_consent_health'); + await tapKey(tester, 'onboarding_terms_accept'); // onboarding_consent_reminders permanece desmarcado. await tapKey(tester, 'complete_enrollment_button'); @@ -175,4 +235,156 @@ void main() { expect(data.label, contains('Concluir cadastro')); handle.dispose(); }); + + testWidgets('sem aceitar o termo, concluir mostra o motivo e não chama o backend', (tester) async { + final backend = FakePatientBackend(); + await tester.pumpWidget(SinalAcsApp(backend: backend)); + await openOnboarding(tester); + + await tester.enterText(find.byKey(const Key('onboarding_token_field')), 'convite-123'); + await tapKey(tester, 'onboarding_consent_health'); + await tapKey(tester, 'complete_enrollment_button'); + + expect(find.text('É preciso aceitar o Termo de Uso e a Política de Privacidade.'), findsOneWidget); + expect(backend.enrollmentCalls, isEmpty); + }); + + testWidgets('o onboarding abre o termo e a política antes do aceite', (tester) async { + await tester.pumpWidget(SinalAcsApp(backend: FakePatientBackend())); + await openOnboarding(tester); + + await tapKey(tester, 'onboarding_open_terms'); + expect(find.byType(LegalDocumentScreen), findsOneWidget); + expect(find.text('Termo de Uso'), findsWidgets); + await tester.pageBack(); + await tester.pumpAndSettle(); + + await tapKey(tester, 'onboarding_open_privacy'); + expect(find.text('Política de Privacidade'), findsWidgets); + }); + + testWidgets('ler o QR do convite preenche o campo e libera concluir', (tester) async { + final backend = FakePatientBackend(); + await tester.pumpWidget(SinalAcsApp( + backend: backend, + qrScanner: (_) async => _conviteSintetico, + )); + await openOnboarding(tester); + + await tapKey(tester, 'scan_qr_button'); + final field = tester.widget(find.byKey(const Key('onboarding_token_field'))); + expect(field.controller!.text, _conviteSintetico); + + await tapKey(tester, 'onboarding_consent_health'); + await tapKey(tester, 'onboarding_terms_accept'); + await tapKey(tester, 'complete_enrollment_button'); + expect(backend.enrollmentCalls.single['token'], _conviteSintetico); + }); + + testWidgets('cancelar a leitura não muda o que já foi digitado', (tester) async { + await tester.pumpWidget(SinalAcsApp( + backend: FakePatientBackend(), + qrScanner: (_) async => null, + )); + await openOnboarding(tester); + await tester.enterText(find.byKey(const Key('onboarding_token_field')), 'convite-123'); + + await tapKey(tester, 'scan_qr_button'); + + final field = tester.widget(find.byKey(const Key('onboarding_token_field'))); + expect(field.controller!.text, 'convite-123'); + expect(find.byKey(const Key('onboarding_error')), findsNothing); + }); + + testWidgets('QR que não é convite mostra aviso e não preenche o campo', (tester) async { + await tester.pumpWidget(SinalAcsApp( + backend: FakePatientBackend(), + qrScanner: (_) async => 'https://exemplo.invalid/pagina', + )); + await openOnboarding(tester); + await tester.enterText(find.byKey(const Key('onboarding_token_field')), 'convite-123'); + + await tapKey(tester, 'scan_qr_button'); + + expect(find.textContaining('não é um convite do SinalACS'), findsOneWidget); + final field = tester.widget(find.byKey(const Key('onboarding_token_field'))); + expect(field.controller!.text, 'convite-123'); + }); + + testWidgets('falha da câmera mostra aviso e o campo manual continua utilizável', (tester) async { + final backend = FakePatientBackend(); + await tester.pumpWidget(SinalAcsApp( + backend: backend, + qrScanner: (_) async => throw StateError('câmera indisponível'), + )); + await openOnboarding(tester); + + await tapKey(tester, 'scan_qr_button'); + expect(find.textContaining('Não foi possível usar a câmera'), findsOneWidget); + + await tester.enterText(find.byKey(const Key('onboarding_token_field')), 'convite-123'); + await tapKey(tester, 'onboarding_consent_health'); + await tapKey(tester, 'onboarding_terms_accept'); + await tapKey(tester, 'complete_enrollment_button'); + expect(backend.enrollmentCalls, hasLength(1)); + }); + + testWidgets('toque duplo em "Ler QR Code" abre uma leitura só', (tester) async { + final gate = Completer(); + var calls = 0; + await tester.pumpWidget(SinalAcsApp( + backend: FakePatientBackend(), + qrScanner: (_) { + calls++; + return gate.future; + }, + )); + await openOnboarding(tester); + + await tester.tap(find.byKey(const Key('scan_qr_button'))); + await tester.pump(); + await tester.tap(find.byKey(const Key('scan_qr_button')), warnIfMissed: false); + await tester.pump(); + expect(calls, 1); + + gate.complete(null); + await tester.pumpAndSettle(); + await tapKey(tester, 'scan_qr_button'); + expect(calls, 2, reason: 'depois de voltar, dá para ler de novo'); + }); + + testWidgets('o botão de ler QR volta a funcionar depois que o leitor lança', (tester) async { + var calls = 0; + await tester.pumpWidget(SinalAcsApp( + backend: FakePatientBackend(), + qrScanner: (_) async { + calls++; + if (calls == 1) throw StateError('câmera indisponível'); + return null; + }, + )); + await openOnboarding(tester); + + await tapKey(tester, 'scan_qr_button'); + expect(find.textContaining('Não foi possível usar a câmera'), findsOneWidget); + expect(tester.widget(find.byKey(const Key('scan_qr_button'))).onPressed, isNotNull); + + await tapKey(tester, 'scan_qr_button'); + expect(calls, 2); + }); + + testWidgets('o aviso do QR some quando a pessoa volta a digitar', (tester) async { + await tester.pumpWidget(SinalAcsApp( + backend: FakePatientBackend(), + qrScanner: (_) async => 'https://exemplo.invalid/pagina', + )); + await openOnboarding(tester); + await tapKey(tester, 'scan_qr_button'); + expect(find.textContaining('não é um convite do SinalACS'), findsOneWidget); + + await tester.enterText(find.byKey(const Key('onboarding_token_field')), 'convite-123'); + await tester.pump(); + + expect(find.textContaining('não é um convite do SinalACS'), findsNothing); + }); } diff --git a/apps/patient/test/patient_app_mvp_test.dart b/apps/patient/test/patient_app_mvp_test.dart index 1369e77d..10cab8a2 100644 --- a/apps/patient/test/patient_app_mvp_test.dart +++ b/apps/patient/test/patient_app_mvp_test.dart @@ -1,4 +1,5 @@ +import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; @@ -9,15 +10,22 @@ import 'package:sinalacs_client/sinalacs_client.dart' show AlertStatus, AlertStatusResult, + ConsentPurpose, + DataSubjectRequestStatus, + DataSubjectRequestType, PatientConsentRecord, PatientDataOverview, + PatientDataSubjectRequestRecord, PatientRiskEvent, RiskLevel; import 'package:sinalacs_patient/app/app.dart'; +import 'package:sinalacs_patient/app/legal_screens.dart'; import 'package:sinalacs_patient/core/consent/consent_preferences.dart'; import 'package:sinalacs_patient/core/network/backend_client.dart'; +import 'package:sinalacs_patient/core/push/push_token_source.dart'; import 'package:sinalacs_patient/core/network/backend_scope.dart'; import 'package:sinalacs_patient/core/privacy/location_hash.dart'; +import 'package:sinalacs_patient/core/services/theme_controller.dart'; import 'package:sinalacs_patient/core/reminders/reminder.dart'; import 'package:sinalacs_patient/core/reminders/reminder_scheduler.dart'; import 'package:sinalacs_patient/core/reminders/reminder_store.dart'; @@ -27,6 +35,15 @@ import 'support/semantics_scan.dart'; /// Duplo de [ReminderStore] em memória — evita SQLite real no teste de /// widget, mesmo padrão de `_FixedLocationReader`/`FakePatientBackend`. +class _FixedPushSource implements PushTokenSource { + const _FixedPushSource(this.device); + + final PushDevice device; + + @override + Future currentDevice() async => device; +} + class _InMemoryReminderStore implements ReminderStore { final _items = {}; int _nextId = 1; @@ -179,7 +196,15 @@ Future openClinicalProfile(WidgetTester tester) async { } /// Abre "Meus dados" pelo menu "Mais". +/// +/// A tela é um `ListView` preguiçoso e, com os interruptores de consentimento, +/// passou da altura da superfície padrão de teste (800x600): o histórico e o +/// segundo interruptor ficavam fora da área construída, e nenhum `find` os via. +/// Uma superfície alta mantém a tela inteira construída. Future openMyData(WidgetTester tester) async { + tester.view.physicalSize = const Size(800, 2400); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); await tester.tap(find.text('Mais')); await tester.pumpAndSettle(); await tester.tap(find.text('Meus dados')); @@ -515,7 +540,10 @@ void main() { store: store, scheduler: scheduler, consentPreferences: consentPreferences ?? _FixedConsentPreferences(), - child: const PatientHomeShell(initialDestination: PatientDestination.reminders), + child: PatientHomeShell( + themeController: ThemeController(), + initialDestination: PatientDestination.reminders, + ), ), ); } @@ -834,6 +862,7 @@ void main() { PatientDataOverview overview({ List consents = const [], List riskHistory = const [], + List requests = const [], }) => PatientDataOverview( name: 'Fulano de Tal', @@ -843,8 +872,515 @@ void main() { chronicConditions: const ['hipertensão'], consents: consents, riskHistory: riskHistory, + requests: requests, + ); + + PatientConsentRecord consent(ConsentPurpose purpose, String action, DateTime at) => + PatientConsentRecord(purpose: purpose.name, action: action, version: '2026.1', timestamp: at); + + List onboardingConsents() { + final at = DateTime.utc(2026, 1, 1); + return [ + consent(ConsentPurpose.healthDataProcessing, 'granted', at), + consent(ConsentPurpose.localReminders, 'granted', at), + consent(ConsentPurpose.segmentedPush, 'denied', at), + ]; + } + + /// "Meus dados" com os duplos de lembretes: a tela agora lê e alinha o + /// espelho local, e sem eles o `SinalAcsApp` montaria o SQLite real. + Future pumpMyData( + WidgetTester tester, + FakePatientBackend backend, { + ReminderStore? store, + ReminderScheduler? scheduler, + ConsentPreferences? consentPreferences, + }) async { + await tester.pumpWidget(SinalAcsApp( + backend: backend, + reminderStore: store ?? _InMemoryReminderStore(), + reminderScheduler: scheduler ?? _RecordingReminderScheduler(), + consentPreferences: consentPreferences ?? _FixedConsentPreferences(), + )); + await login(tester); + await openMyData(tester); + } + + Future tapSwitch(WidgetTester tester, ConsentPurpose purpose) async { + final finder = find.byKey(Key('consent_switch_${purpose.name}')); + await tester.ensureVisible(finder); + await tester.pumpAndSettle(); + await tester.tap(finder); + await tester.pumpAndSettle(); + } + + bool switchValue(WidgetTester tester, ConsentPurpose purpose) => + tester.widget(find.byKey(Key('consent_switch_${purpose.name}'))).value; + + testWidgets('os interruptores mostram a decisão mais recente de cada finalidade', (tester) async { + final backend = FakePatientBackend() + ..myDataResult = overview(consents: [ + ...onboardingConsents(), + consent(ConsentPurpose.localReminders, 'denied', DateTime.utc(2026, 2, 1)), + ]); + await pumpMyData(tester, backend, consentPreferences: _FixedConsentPreferences(granted: false)); + + expect(switchValue(tester, ConsentPurpose.localReminders), isFalse); + expect(switchValue(tester, ConsentPurpose.segmentedPush), isFalse); + expect(find.byKey(const Key('consent_switch_healthDataProcessing')), findsNothing); + expect( + tester.widget(find.byKey(const Key('consent_health_data_notice'))).data, + contains('solicite a exclusão'), + ); + }); + + testWidgets('revogar pede confirmação; cancelar não chama o servidor', (tester) async { + final backend = FakePatientBackend()..myDataResult = overview(consents: onboardingConsents()); + await pumpMyData(tester, backend); + + await tapSwitch(tester, ConsentPurpose.localReminders); + expect(find.text('Revogar consentimento?'), findsOneWidget); + await tester.tap(find.byKey(const Key('consent_revoke_cancel'))); + await tester.pumpAndSettle(); + + expect(backend.updateConsentCalls, isEmpty); + expect(switchValue(tester, ConsentPurpose.localReminders), isTrue); + }); + + testWidgets( + 'confirmar a revogação de lembretes registra no servidor, atualiza o espelho e ' + 'cancela na hora o lembrete já agendado', (tester) async { + final store = _InMemoryReminderStore(); + final seeded = await store.save( + const Reminder(id: 0, label: 'Losartana 50 mg', hour: 8, minute: 0, active: true), + ); + final scheduler = _RecordingReminderScheduler(); + final prefs = _FixedConsentPreferences(granted: true); + final backend = FakePatientBackend()..myDataResult = overview(consents: onboardingConsents()); + await pumpMyData(tester, backend, store: store, scheduler: scheduler, consentPreferences: prefs); + + await tapSwitch(tester, ConsentPurpose.localReminders); + await tester.tap(find.byKey(const Key('consent_revoke_confirm'))); + await tester.pumpAndSettle(); + + expect(backend.updateConsentCalls.single, (purpose: ConsentPurpose.localReminders, granted: false)); + expect(prefs.granted, isFalse); + expect(scheduler.cancelled, [seeded.id]); + expect((await store.list()).single.active, isFalse); + expect(switchValue(tester, ConsentPurpose.localReminders), isFalse); + expect( + tester.widget(find.byKey(const Key('my_data_confirmation'))).data, + contains('revogado'), + ); + }); + + testWidgets( + 'revogação confirmada pelo servidor cancela os lembretes mesmo se o ' + 'recarregamento de "Meus dados" falhar', (tester) async { + final store = _InMemoryReminderStore(); + final seeded = await store.save( + const Reminder(id: 0, label: 'Losartana 50 mg', hour: 8, minute: 0, active: true), + ); + final scheduler = _RecordingReminderScheduler(); + final prefs = _FixedConsentPreferences(granted: true); + final backend = FakePatientBackend()..myDataResult = overview(consents: onboardingConsents()); + await pumpMyData(tester, backend, store: store, scheduler: scheduler, consentPreferences: prefs); + + // A carga inicial já passou; só o recarregamento depois da revogação falha. + backend.myDataFailure = const BackendFailure('Sem conexão com o servidor.'); + await tapSwitch(tester, ConsentPurpose.localReminders); + await tester.tap(find.byKey(const Key('consent_revoke_confirm'))); + await tester.pumpAndSettle(); + + expect(backend.updateConsentCalls.single, (purpose: ConsentPurpose.localReminders, granted: false)); + expect(prefs.granted, isFalse); + expect(scheduler.cancelled, [seeded.id]); + expect((await store.list()).single.active, isFalse); + }); + + testWidgets('conceder não pede confirmação e não mexe nos lembretes', (tester) async { + final scheduler = _RecordingReminderScheduler(); + final backend = FakePatientBackend()..myDataResult = overview(consents: onboardingConsents()); + await pumpMyData(tester, backend, scheduler: scheduler); + + await tapSwitch(tester, ConsentPurpose.segmentedPush); + + expect(find.text('Revogar consentimento?'), findsNothing); + expect(backend.updateConsentCalls.single, (purpose: ConsentPurpose.segmentedPush, granted: true)); + expect(scheduler.cancelled, isEmpty); + expect(switchValue(tester, ConsentPurpose.segmentedPush), isTrue); + }); + + testWidgets('falha do servidor mostra o erro e o interruptor fica como estava', (tester) async { + final handle = tester.ensureSemantics(); + final backend = FakePatientBackend() + ..myDataResult = overview(consents: onboardingConsents()) + ..updateConsentFailure = const BackendFailure('Sem conexão com o servidor.'); + await pumpMyData(tester, backend); + + await tapSwitch(tester, ConsentPurpose.segmentedPush); + + expect(find.text('Sem conexão com o servidor.'), findsOneWidget); + expect(tester.getSemantics(find.byKey(const Key('my_data_error'))).flagsCollection.isLiveRegion, isTrue); + expect(switchValue(tester, ConsentPurpose.segmentedPush), isFalse); + handle.dispose(); + }); + + testWidgets('aparelho sem espelho local (login OTP) recebe a concessão do servidor ao abrir', (tester) async { + final prefs = _FixedConsentPreferences(granted: null); + final backend = FakePatientBackend()..myDataResult = overview(consents: onboardingConsents()); + await pumpMyData(tester, backend, consentPreferences: prefs); + + expect(prefs.granted, isTrue); + }); + + testWidgets('recusa vinda do servidor cancela lembretes ativos de um aparelho sem espelho', (tester) async { + final store = _InMemoryReminderStore(); + final seeded = await store.save( + const Reminder(id: 0, label: 'Metformina 850 mg', hour: 7, minute: 0, active: true), + ); + final scheduler = _RecordingReminderScheduler(); + final prefs = _FixedConsentPreferences(granted: null); + final backend = FakePatientBackend() + ..myDataResult = overview(consents: [ + ...onboardingConsents(), + consent(ConsentPurpose.localReminders, 'denied', DateTime.utc(2026, 2, 1)), + ]); + await pumpMyData(tester, backend, store: store, scheduler: scheduler, consentPreferences: prefs); + + expect(prefs.granted, isFalse); + expect(scheduler.cancelled, [seeded.id]); + }); + + testWidgets('conceder "Avisos da equipe" registra o token; revogar não registra', (tester) async { + final backend = FakePatientBackend()..myDataResult = overview(consents: onboardingConsents()); + await tester.pumpWidget(SinalAcsApp( + backend: backend, + reminderStore: _InMemoryReminderStore(), + reminderScheduler: _RecordingReminderScheduler(), + consentPreferences: _FixedConsentPreferences(), + pushTokens: const _FixedPushSource(PushDevice(token: 'tok-9', platform: 'ios')), + )); + await login(tester); + await openMyData(tester); + backend.pushRegistrations.clear(); // o login também registra + final consultasAntes = backend.hasGrantedConsentCalls; + + await tapSwitch(tester, ConsentPurpose.segmentedPush); + expect(backend.updateConsentCalls.last.granted, isTrue); + expect(backend.pushRegistrations, [('tok-9', 'ios')]); + expect(backend.hasGrantedConsentCalls, consultasAntes, + reason: 'quem acabou de gravar a concessão não precisa perguntar de novo'); + + await tapSwitch(tester, ConsentPurpose.segmentedPush); + await tester.tap(find.byKey(const Key('consent_revoke_confirm'))); + await tester.pumpAndSettle(); + expect(backend.updateConsentCalls.last.granted, isFalse); + expect(backend.pushRegistrations, hasLength(1)); + }); + + testWidgets('os interruptores não criam nó de botão inerte', (tester) async { + final handle = tester.ensureSemantics(); + final backend = FakePatientBackend()..myDataResult = overview(consents: onboardingConsents()); + await pumpMyData(tester, backend); + await tester.ensureVisible(find.byKey(const Key('consent_switch_segmentedPush'))); + await tester.pumpAndSettle(); + + expectNenhumBotaoInerte(tester); + handle.dispose(); + }); + + Future tapByKey(WidgetTester tester, String key) async { + final finder = find.byKey(Key(key)); + await tester.ensureVisible(finder); + await tester.pumpAndSettle(); + await tester.tap(finder); + await tester.pumpAndSettle(); + } + + OutlinedButton outlined(WidgetTester tester, String key) => + tester.widget(find.byKey(Key(key))); + + PatientDataSubjectRequestRecord openRequest(DataSubjectRequestType type, {String? details}) => + PatientDataSubjectRequestRecord( + type: type, + status: DataSubjectRequestStatus.open, + details: details, + // Meio-dia UTC: `toLocal()` mantém o dia em qualquer fuso entre + // UTC-11 e UTC+11 — meia-noite viraria 15/09 no Brasil. + createdAt: DateTime.utc(2026, 9, 1, 12), + dueAt: DateTime.utc(2026, 9, 16, 12), ); + testWidgets('sem pedidos, mostra o estado vazio e os dois botões habilitados', (tester) async { + final backend = FakePatientBackend()..myDataResult = overview(); + await pumpMyData(tester, backend); + + expect(find.text('Nenhum pedido feito.'), findsOneWidget); + expect(outlined(tester, 'request_deletion_button').onPressed, isNotNull); + expect(outlined(tester, 'request_correction_button').onPressed, isNotNull); + }); + + testWidgets('pedir exclusão explica prazo e retenção, registra e mostra o pedido em análise', (tester) async { + final backend = FakePatientBackend()..myDataResult = overview(); + await pumpMyData(tester, backend); + + await tapByKey(tester, 'request_deletion_button'); + // Escopado ao diálogo: o cartão de cadastro atrás dele também diz + // "Contato de emergência". + Finder inDialog(String text) => + find.descendant(of: find.byType(AlertDialog), matching: find.textContaining(text)); + expect(inDialog('15 dias'), findsOneWidget); + expect(inDialog('emergência'), findsOneWidget); + await tester.tap(find.byKey(const Key('deletion_request_confirm'))); + await tester.pumpAndSettle(); + + expect(backend.requestDataDeletionCount, 1); + expect(find.textContaining('Exclusão dos dados · Em análise'), findsOneWidget); + expect(outlined(tester, 'request_deletion_button').onPressed, isNull); + expect(find.text('Exclusão já solicitada — em análise'), findsOneWidget); + expect( + tester.widget(find.byKey(const Key('my_data_confirmation'))).data, + startsWith('Pedido de exclusão registrado'), + ); + }); + + testWidgets('cancelar o diálogo de exclusão não chama o servidor', (tester) async { + final backend = FakePatientBackend()..myDataResult = overview(); + await pumpMyData(tester, backend); + + await tapByKey(tester, 'request_deletion_button'); + await tester.tap(find.byKey(const Key('deletion_request_cancel'))); + await tester.pumpAndSettle(); + + expect(backend.requestDataDeletionCount, 0); + }); + + testWidgets('com o pedido em voo os botões ficam desabilitados — um pedido só', (tester) async { + final gate = Completer(); + final backend = FakePatientBackend() + ..myDataResult = overview() + ..dataRequestGate = gate; + await pumpMyData(tester, backend); + + await tapByKey(tester, 'request_deletion_button'); + await tester.tap(find.byKey(const Key('deletion_request_confirm'))); + await tester.pumpAndSettle(); + + expect(outlined(tester, 'request_deletion_button').onPressed, isNull); + expect(outlined(tester, 'request_correction_button').onPressed, isNull); + + gate.complete(); + await tester.pumpAndSettle(); + expect(backend.requestDataDeletionCount, 1); + }); + + testWidgets('pedido aberto vindo do servidor já desabilita o botão de exclusão', (tester) async { + final backend = FakePatientBackend() + ..myDataResult = overview(requests: [openRequest(DataSubjectRequestType.deletion)]); + await pumpMyData(tester, backend); + + expect(outlined(tester, 'request_deletion_button').onPressed, isNull); + expect(find.textContaining('resposta até 16/09/2026'), findsOneWidget); + }); + + PatientDataSubjectRequestRecord decided( + DataSubjectRequestType type, + DataSubjectRequestStatus status, { + String? resolution, + }) => + PatientDataSubjectRequestRecord( + type: type, + status: status, + createdAt: DateTime.utc(2026, 9, 1, 12), + dueAt: DateTime.utc(2026, 9, 16, 12), + resolution: resolution, + ); + + testWidgets('titular: pedido em análise mostra "Em análise" e não duplica a exclusão', (tester) async { + final backend = FakePatientBackend() + ..myDataResult = overview( + requests: [decided(DataSubjectRequestType.deletion, DataSubjectRequestStatus.inReview)], + ); + await pumpMyData(tester, backend); + + expect(find.textContaining('Exclusão dos dados · Em análise'), findsOneWidget); + expect(outlined(tester, 'request_deletion_button').onPressed, isNull); + expect(find.text('Exclusão já solicitada — em análise'), findsOneWidget); + }); + + testWidgets('titular: pedido atendido mostra a nota da resposta', (tester) async { + final backend = FakePatientBackend() + ..myDataResult = overview( + requests: [ + decided( + DataSubjectRequestType.correction, + DataSubjectRequestStatus.completed, + resolution: 'Telefone corrigido conforme solicitado.', + ), + ], + ); + await pumpMyData(tester, backend); + + expect(find.textContaining('Correção de dados · Atendido'), findsOneWidget); + expect(find.textContaining('Telefone corrigido conforme solicitado.'), findsOneWidget); + }); + + testWidgets('titular: pedido recusado mostra o motivo', (tester) async { + final backend = FakePatientBackend() + ..myDataResult = overview( + requests: [ + decided( + DataSubjectRequestType.correction, + DataSubjectRequestStatus.rejected, + resolution: 'Dado já está correto no cadastro.', + ), + ], + ); + await pumpMyData(tester, backend); + + expect(find.textContaining('Correção de dados · Recusado'), findsOneWidget); + expect(find.textContaining('Dado já está correto no cadastro.'), findsOneWidget); + }); + + testWidgets('titular: exclusão já atendida libera novo pedido e sem nota não mostra linha de resposta', (tester) async { + final backend = FakePatientBackend() + ..myDataResult = overview( + requests: [decided(DataSubjectRequestType.deletion, DataSubjectRequestStatus.rejected)], + ); + await pumpMyData(tester, backend); + + expect(outlined(tester, 'request_deletion_button').onPressed, isNotNull); + expect(find.textContaining('Resposta:'), findsNothing); + }); + + testWidgets('correção: só envia com texto de verdade, e sem os espaços das pontas', (tester) async { + final backend = FakePatientBackend()..myDataResult = overview(); + await pumpMyData(tester, backend); + + await tapByKey(tester, 'request_correction_button'); + FilledButton submit() => + tester.widget(find.byKey(const Key('correction_request_submit'))); + expect(submit().onPressed, isNull); + + await tester.enterText(find.byKey(const Key('correction_details_field')), ' '); + await tester.pump(); + expect(submit().onPressed, isNull); + + await tester.enterText(find.byKey(const Key('correction_details_field')), ' Meu contato mudou. '); + await tester.pump(); + await tester.tap(find.byKey(const Key('correction_request_submit'))); + await tester.pumpAndSettle(); + + expect(backend.correctionRequests, ['Meu contato mudou.']); + expect(find.textContaining('Correção de dados · Em análise'), findsOneWidget); + expect(find.textContaining('"Meu contato mudou."'), findsOneWidget); + }); + + testWidgets('com uma correção aberta, pedir outra continua possível', (tester) async { + final backend = FakePatientBackend() + ..myDataResult = overview( + requests: [openRequest(DataSubjectRequestType.correction, details: 'Contato errado.')], + ); + await pumpMyData(tester, backend); + + expect(outlined(tester, 'request_correction_button').onPressed, isNotNull); + }); + + testWidgets('falha ao pedir exclusão mostra o erro e reabilita o botão', (tester) async { + final backend = FakePatientBackend() + ..myDataResult = overview() + ..dataRequestFailure = const BackendFailure('Sem conexão com o servidor.'); + await pumpMyData(tester, backend); + + await tapByKey(tester, 'request_deletion_button'); + await tester.tap(find.byKey(const Key('deletion_request_confirm'))); + await tester.pumpAndSettle(); + + expect(find.text('Sem conexão com o servidor.'), findsOneWidget); + expect(outlined(tester, 'request_deletion_button').onPressed, isNotNull); + }); + + testWidgets('correção que falhou volta com o texto ao reabrir; sai do rascunho depois de enviada', (tester) async { + final backend = FakePatientBackend() + ..myDataResult = overview() + ..dataRequestFailure = const BackendFailure('Sem conexão com o servidor.'); + await pumpMyData(tester, backend); + + await tapByKey(tester, 'request_correction_button'); + await tester.enterText(find.byKey(const Key('correction_details_field')), 'Meu contato mudou.'); + await tester.pump(); + await tester.tap(find.byKey(const Key('correction_request_submit'))); + await tester.pumpAndSettle(); + // O aviso fica no topo da lista, que a rolagem até o botão deixou para trás. + await tester.drag(find.byType(Scrollable).first, const Offset(0, 3000)); + await tester.pumpAndSettle(); + expect(find.text('Sem conexão com o servidor.'), findsOneWidget); + + backend.dataRequestFailure = null; + await tapByKey(tester, 'request_correction_button'); + expect( + tester.widget(find.byKey(const Key('correction_details_field'))).controller!.text, + 'Meu contato mudou.', + ); + await tester.tap(find.byKey(const Key('correction_request_submit'))); + await tester.pumpAndSettle(); + // A tentativa que falhou também chegou ao fake, com o mesmo texto. + expect(backend.correctionRequests, ['Meu contato mudou.', 'Meu contato mudou.']); + + await tapByKey(tester, 'request_correction_button'); + expect( + tester.widget(find.byKey(const Key('correction_details_field'))).controller!.text, + isEmpty, + ); + }); + + testWidgets('tocar fora do diálogo de correção não descarta o rascunho', (tester) async { + final backend = FakePatientBackend()..myDataResult = overview(); + await pumpMyData(tester, backend); + + await tapByKey(tester, 'request_correction_button'); + await tester.enterText(find.byKey(const Key('correction_details_field')), 'Meu contato mudou.'); + await tester.pump(); + + await tester.tapAt(const Offset(4, 4)); // fora do diálogo + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('correction_details_field')), findsOneWidget); + expect( + tester.widget(find.byKey(const Key('correction_details_field'))).controller!.text, + 'Meu contato mudou.', + ); + }); + + testWidgets('Cancelar descarta o rascunho: reabrir vem vazio', (tester) async { + final backend = FakePatientBackend()..myDataResult = overview(); + await pumpMyData(tester, backend); + + await tapByKey(tester, 'request_correction_button'); + await tester.enterText(find.byKey(const Key('correction_details_field')), 'Meu contato mudou.'); + await tester.pump(); + await tester.tap(find.byKey(const Key('correction_request_cancel'))); + await tester.pumpAndSettle(); + + await tapByKey(tester, 'request_correction_button'); + expect( + tester.widget(find.byKey(const Key('correction_details_field'))).controller!.text, + isEmpty, + ); + }); + + testWidgets('os botões de pedido não criam nó de botão inerte', (tester) async { + final handle = tester.ensureSemantics(); + final backend = FakePatientBackend()..myDataResult = overview(); + await pumpMyData(tester, backend); + await tester.ensureVisible(find.byKey(const Key('request_correction_button'))); + await tester.pumpAndSettle(); + + expectNenhumBotaoInerte(tester); + handle.dispose(); + }); + testWidgets('mostra o cadastro e as condições crônicas devolvidas pelo servidor', (tester) async { final backend = FakePatientBackend()..myDataResult = overview(); await tester.pumpWidget(SinalAcsApp(backend: backend)); @@ -881,7 +1417,7 @@ void main() { await login(tester); await openMyData(tester); - expect(find.text('healthDataProcessing'), findsOneWidget); + expect(find.text('Tratamento de dados de saúde'), findsOneWidget); expect(find.textContaining('Concedido'), findsOneWidget); expect(find.textContaining('Alerta de urgência'), findsOneWidget); expect(find.textContaining('Vermelho'), findsOneWidget); @@ -926,6 +1462,7 @@ void main() { timestamp: DateTime.utc(2026, 1, 1), ), ], + requests: [openRequest(DataSubjectRequestType.deletion)], ); await tester.pumpWidget(SinalAcsApp(backend: backend)); await login(tester); @@ -950,9 +1487,15 @@ void main() { 'versao': '2026.1', 'data': DateTime.utc(2026, 1, 1).toIso8601String(), }); + expect((decoded['pedidos'] as List).single, { + 'tipo': 'deletion', + 'situacao': 'open', + 'data': DateTime.utc(2026, 9, 1, 12).toIso8601String(), + 'prazo': DateTime.utc(2026, 9, 16, 12).toIso8601String(), + }); - expect(find.byKey(const Key('my_data_export_confirmation')), findsOneWidget); - final semantics = tester.getSemantics(find.byKey(const Key('my_data_export_confirmation'))); + expect(find.byKey(const Key('my_data_confirmation')), findsOneWidget); + final semantics = tester.getSemantics(find.byKey(const Key('my_data_confirmation'))); expect(semantics.flagsCollection.isLiveRegion, isTrue); }); @@ -1094,4 +1637,15 @@ void main() { expect(backend.statusForCallCount, 3); }); }); + + testWidgets('menu Mais abre Privacidade e termos', (tester) async { + await tester.pumpWidget(SinalAcsApp(backend: FakePatientBackend())); + await login(tester); + await tester.tap(find.text('Mais')); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('more_legal'))); + await tester.pumpAndSettle(); + expect(find.byType(LegalDocumentsScreen), findsOneWidget); + }); } diff --git a/apps/patient/test/push_registration_test.dart b/apps/patient/test/push_registration_test.dart new file mode 100644 index 00000000..c9dcb51a --- /dev/null +++ b/apps/patient/test/push_registration_test.dart @@ -0,0 +1,181 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_patient/app/app.dart'; +import 'package:sinalacs_patient/core/network/backend_client.dart'; +import 'package:sinalacs_patient/core/push/push_token_source.dart'; + +import 'support/fake_patient_backend.dart'; + +class _FakeSource implements PushTokenSource { + const _FakeSource(this.device); + + final PushDevice? device; + + @override + Future currentDevice() async => device; +} + +const _aparelho = PushDevice(token: 'tok-1', platform: 'android'); + +Future login(WidgetTester tester) async { + await tester.enterText(find.byKey(const Key('cpf_field')), '123.456.789-09'); + await tester.enterText(find.byKey(const Key('birth_date_field')), '01/01/1990'); + await tester.tap(find.byKey(const Key('enter_button'))); + await tester.pumpAndSettle(); + + await tester.enterText(find.byKey(const Key('otp_code_field')), '123456'); + await tester.tap(find.byKey(const Key('verify_code_button'))); + await tester.pumpAndSettle(); +} + +void main() { + testWidgets('depois do login registra o token do aparelho', (tester) async { + final backend = FakePatientBackend()..grantPushConsent(); + await tester.pumpWidget(SinalAcsApp(backend: backend, pushTokens: const _FakeSource(_aparelho))); + await login(tester); + + expect(backend.pushRegistrations, [('tok-1', 'android')]); + }); + + testWidgets('sem token (sem Firebase) não chama o servidor', (tester) async { + final backend = FakePatientBackend(); + await tester.pumpWidget(SinalAcsApp(backend: backend)); + await login(tester); + + expect(backend.pushRegistrations, isEmpty); + expect(find.byType(PatientHomeShell), findsOneWidget); + }); + + testWidgets('recusa do servidor não afeta a home nem mostra erro', (tester) async { + final backend = FakePatientBackend() + ..grantPushConsent() + ..pushRegistrationFailure = const BackendFailure('sem consentimento'); + await tester.pumpWidget(SinalAcsApp(backend: backend, pushTokens: const _FakeSource(_aparelho))); + await login(tester); + + expect(backend.pushRegistrations, hasLength(1)); + expect(find.byType(PatientHomeShell), findsOneWidget); + expect(find.textContaining('sem consentimento'), findsNothing); + }); + + testWidgets('fonte que lança não afeta a home', (tester) async { + await tester.pumpWidget(SinalAcsApp( + backend: FakePatientBackend(), + pushTokens: _ThrowingSource(), + )); + await login(tester); + + expect(find.byType(PatientHomeShell), findsOneWidget); + }); + + testWidgets('fonte de token que nunca completa não atrasa a home', (tester) async { + await tester.pumpWidget(SinalAcsApp( + backend: FakePatientBackend(), + pushTokens: _NeverSource(), + )); + await login(tester); + expect(find.byType(PatientHomeShell), findsOneWidget); + }); + + testWidgets('sem consentimento de avisos, nem pergunta o token ao provedor', (tester) async { + final backend = FakePatientBackend(); + final source = _CountingSource(); + await tester.pumpWidget(SinalAcsApp(backend: backend, pushTokens: source)); + await login(tester); + + expect(source.calls, 0); + expect(backend.pushRegistrations, isEmpty); + expect(find.byType(PatientHomeShell), findsOneWidget); + }); + + testWidgets('concedido e depois revogado: vale o mais recente e o provedor não é consultado', + (tester) async { + final backend = FakePatientBackend() + ..addConsentRecord('segmentedPush', 'granted', DateTime.utc(2026, 9, 1)) + ..addConsentRecord('segmentedPush', 'denied', DateTime.utc(2026, 9, 2)); + final source = _CountingSource(); + await tester.pumpWidget(SinalAcsApp(backend: backend, pushTokens: source)); + await login(tester); + + expect(source.calls, 0); + }); + + testWidgets('consulta de consentimento falhando no login fecha: nada é pedido nem registrado', (tester) async { + final backend = FakePatientBackend() + ..grantPushConsent() + ..hasGrantedConsentFailure = const BackendFailure('falha'); + final source = _CountingSource(); + await tester.pumpWidget(SinalAcsApp(backend: backend, pushTokens: source)); + await login(tester); + + expect(source.calls, 0); + expect(find.byType(PatientHomeShell), findsOneWidget); + }); + + testWidgets('consulta que nunca responde não atrasa a home; a resposta tardia é descartada', + (tester) async { + final backend = FakePatientBackend()..grantPushConsent(); + final gate = Completer(); + backend.hasGrantedConsentGate = gate; + final source = _CountingSource(); + await tester.pumpWidget(SinalAcsApp(backend: backend, pushTokens: source)); + await login(tester); + + expect(find.byType(PatientHomeShell), findsOneWidget); + await tester.pump(const Duration(seconds: 4)); // estoura o teto de 3 s + gate.complete(); // a resposta chega tarde, dizendo "concedido" + await tester.pumpAndSettle(); + expect(source.calls, 0); + expect(backend.pushRegistrations, isEmpty); + }); + + testWidgets('o login não lê o painel "Meus dados" só para decidir o push', (tester) async { + final backend = FakePatientBackend()..grantPushConsent(); + await tester.pumpWidget(SinalAcsApp(backend: backend, pushTokens: _CountingSource())); + await login(tester); + + expect(backend.hasGrantedConsentCalls, 1); + expect(backend.myDataCallCount, 0, reason: 'myData grava auditoria de leitura e devolve o dossiê'); + }); + + testWidgets('com consentimento vigente, o login registra o token', (tester) async { + final backend = FakePatientBackend()..grantPushConsent(); + final source = _CountingSource(); + await tester.pumpWidget(SinalAcsApp(backend: backend, pushTokens: source)); + await login(tester); + + expect(source.calls, 1); + expect(backend.pushRegistrations, [('tok-1', 'android')]); + }); + + testWidgets('sem PushTokenScope, maybeOf devolve a fonte inerte', (tester) async { + PushTokenSource? achada; + await tester.pumpWidget(Builder(builder: (context) { + achada = PushTokenScope.maybeOf(context); + return const SizedBox(); + })); + expect(achada, isA()); + }); +} + +class _CountingSource implements PushTokenSource { + int calls = 0; + + @override + Future currentDevice() async { + calls++; + return _aparelho; + } +} + +class _ThrowingSource implements PushTokenSource { + @override + Future currentDevice() async => throw StateError('sem provedor'); +} + +class _NeverSource implements PushTokenSource { + @override + Future currentDevice() => Completer().future; +} diff --git a/apps/patient/test/push_riverpod_test.dart b/apps/patient/test/push_riverpod_test.dart new file mode 100644 index 00000000..fdfcdecf --- /dev/null +++ b/apps/patient/test/push_riverpod_test.dart @@ -0,0 +1,119 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_patient/app/app.dart'; +import 'package:sinalacs_patient/core/push/native_push_token_source.dart'; +import 'package:sinalacs_patient/core/push/push_token_provider.dart'; +import 'package:sinalacs_patient/core/push/push_token_source.dart'; + +import 'support/fake_patient_backend.dart'; + +class _Fonte implements PushTokenSource { + const _Fonte(this.device); + + final PushDevice? device; + + @override + Future currentDevice() async => device; +} + +Future login(WidgetTester tester) async { + await tester.enterText(find.byKey(const Key('cpf_field')), '123.456.789-09'); + await tester.enterText(find.byKey(const Key('birth_date_field')), '01/01/1990'); + await tester.tap(find.byKey(const Key('enter_button'))); + await tester.pumpAndSettle(); + + await tester.enterText(find.byKey(const Key('otp_code_field')), '123456'); + await tester.tap(find.byKey(const Key('verify_code_button'))); + await tester.pumpAndSettle(); +} + +const _canal = MethodChannel('sinalacs/push_token'); + +void _mockCanal(Future Function(MethodCall call)? handler) { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_canal, handler); +} + +void main() { + testWidgets('o provider entrega o token da fonte e o registro chega ao backend', (tester) async { + final backend = FakePatientBackend()..grantPushConsent(); + await tester.pumpWidget(ProviderScope( + overrides: [ + pushTokenSourceProvider + .overrideWithValue(const _Fonte(PushDevice(token: 'tok-9', platform: 'android'))), + ], + child: SinalAcsApp(backend: backend), + )); + await login(tester); + + expect(backend.pushRegistrations, [('tok-9', 'android')]); + }); + + testWidgets('sem ProviderScope acima, o app ainda sobe e não registra', (tester) async { + final backend = FakePatientBackend(); + await tester.pumpWidget(SinalAcsApp(backend: backend)); + await login(tester); + + expect(find.byType(PatientHomeShell), findsOneWidget); + expect(backend.pushRegistrations, isEmpty); + }); + + testWidgets('a fonte injetada por parâmetro tem precedência sobre o provider', (tester) async { + final backend = FakePatientBackend()..grantPushConsent(); + await tester.pumpWidget(ProviderScope( + overrides: [ + pushTokenSourceProvider + .overrideWithValue(const _Fonte(PushDevice(token: 'do-provider', platform: 'ios'))), + ], + child: SinalAcsApp( + backend: backend, + pushTokens: const _Fonte(PushDevice(token: 'do-parametro', platform: 'android')), + ), + )); + await login(tester); + + expect(backend.pushRegistrations, [('do-parametro', 'android')]); + }); + + test('NativePushTokenSource devolve null quando o canal não existe', () async { + TestWidgetsFlutterBinding.ensureInitialized(); + _mockCanal(null); + expect(await const NativePushTokenSource().currentDevice(), isNull); + }); + + test('NativePushTokenSource lê o token e a plataforma do canal', () async { + TestWidgetsFlutterBinding.ensureInitialized(); + _mockCanal((call) async { + expect(call.method, 'getToken'); + return {'token': 'abc', 'platform': 'ios'}; + }); + addTearDown(() => _mockCanal(null)); + + final device = await const NativePushTokenSource().currentDevice(); + expect((device!.token, device.platform), ('abc', 'ios')); + }); + + test('resposta malformada ou plataforma desconhecida vira null', () async { + TestWidgetsFlutterBinding.ensureInitialized(); + addTearDown(() => _mockCanal(null)); + for (final resposta in [ + {'token': '', 'platform': 'android'}, + {'token': 'abc', 'platform': 'web'}, + {'platform': 'android'}, + 'lixo', + null, + ]) { + _mockCanal((call) async => resposta); + expect(await const NativePushTokenSource().currentDevice(), isNull, reason: '$resposta'); + } + }); + + test('erro de plataforma no canal vira null, não exceção', () async { + TestWidgetsFlutterBinding.ensureInitialized(); + _mockCanal((call) async => throw PlatformException(code: 'sem_permissao')); + addTearDown(() => _mockCanal(null)); + expect(await const NativePushTokenSource().currentDevice(), isNull); + }); +} diff --git a/apps/patient/test/qr_scanner_test.dart b/apps/patient/test/qr_scanner_test.dart new file mode 100644 index 00000000..206012cd --- /dev/null +++ b/apps/patient/test/qr_scanner_test.dart @@ -0,0 +1,27 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; +import 'package:sinalacs_patient/app/qr_scanner.dart'; + +BarcodeCapture captura(String? valor) => + BarcodeCapture(barcodes: [Barcode(rawValue: valor)]); + +void main() { + test('devolve o primeiro QR com texto', () { + final gate = QrDetectionGate(); + expect(gate.accept(captura(''), routeIsCurrent: true), isNull); + expect(gate.accept(captura('convite'), routeIsCurrent: true), 'convite'); + }); + + test('a câmera entrega vários quadros: só o primeiro fecha a tela', () { + final gate = QrDetectionGate(); + expect(gate.accept(captura('convite'), routeIsCurrent: true), 'convite'); + expect(gate.accept(captura('convite'), routeIsCurrent: true), isNull); + }); + + test('QR lido depois de a pessoa voltar é ignorado', () { + // Durante a animação de saída a rota ainda está montada e a câmera ainda + // entrega quadros; um pop aqui fecharia a tela de onboarding por baixo. + final gate = QrDetectionGate(); + expect(gate.accept(captura('convite'), routeIsCurrent: false), isNull); + }); +} diff --git a/apps/patient/test/support/e2e_config.dart b/apps/patient/test/support/e2e_config.dart new file mode 100644 index 00000000..fd5426c8 --- /dev/null +++ b/apps/patient/test/support/e2e_config.dart @@ -0,0 +1,69 @@ +/// Manifesto das fixtures de e2e (gerado por `bin/seed_e2e_fixtures.dart`). +/// Chega aos testes de dispositivo como UM define JSON +/// (`--dart-define=E2E_FIXTURES="$(cat .e2e/fixtures.json)"`) e às ferramentas do +/// host pelo arquivo `.e2e/fixtures.json`. Não importar em código de produção. +class E2ePatientFixture { + const E2ePatientFixture({ + required this.role, + required this.name, + required this.cpf, + required this.birthDate, + required this.microAreaId, + }); + + final String role; + final String name; + + /// Só dígitos; nunca em log nem em mensagem de erro. + final String cpf; + final DateTime birthDate; + final String microAreaId; + + @override + String toString() => 'E2ePatientFixture($role)'; // sem CPF +} + +class E2eConfig { + const E2eConfig({ + required this.patients, + required this.microAreaId, + required this.acsMatricula, + required this.acsPassword, + }); + + final List patients; + final String microAreaId; + final String acsMatricula; + final String acsPassword; + + E2ePatientFixture patient(String role) => patients.firstWhere( + (p) => p.role == role, + orElse: () => throw StateError('sem paciente "$role" no manifesto'), + ); + + static E2eConfig? fromMap(Map map) { + final list = map['patients']; + if (list is! List) return null; + // O manifesto que vai ao aparelho (`--dart-define`) NÃO traz o bloco `acs`: a + // senha nunca vai para o argv do `flutter test` nem para o APK. + final acs = (map['acs'] as Map?)?.cast() ?? const {}; + return E2eConfig( + patients: [ + for (final raw in list.cast()) + E2ePatientFixture( + role: raw['role'] as String, + name: raw['name'] as String, + cpf: raw['cpf'] as String, + birthDate: DateTime.parse(raw['birthDate'] as String), + microAreaId: raw['microAreaId'] as String, + ), + ], + microAreaId: map['microAreaId']! as String, + acsMatricula: (acs['matricula'] as String?) ?? '', + acsPassword: (acs['password'] as String?) ?? '', + ); + } + + @override + String toString() => 'E2eConfig(${patients.length} pacientes)'; +} diff --git a/apps/patient/test/support/e2e_login_core.dart b/apps/patient/test/support/e2e_login_core.dart new file mode 100644 index 00000000..2b9abedd --- /dev/null +++ b/apps/patient/test/support/e2e_login_core.dart @@ -0,0 +1,92 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:sinalacs_patient/core/network/auth_session.dart'; +import 'package:sinalacs_patient/core/network/backend_client.dart'; + +import 'e2e_config.dart'; + +/// Entra como o paciente [role] da fixture, pelo OTP real: pede o código, lê no +/// relé (`scripts/qa/otp_relay.py`) o que o gateway `log` escreveu e verifica. +/// Sem manifesto ([config] nulo) cai no login de desenvolvimento, como antes — +/// é o que mantém o `android-e2e` da CI funcionando. +/// +/// O servidor impõe 60 s entre dois pedidos de código do MESMO paciente: quem +/// precisa de várias sessões no mesmo paciente compartilha a sessão. +Future loginPatientWith( + PatientBackend backend, + E2eConfig? config, { + required String relayUrl, + String role = 'main', + Duration retryDelay = const Duration(milliseconds: 500), + int attempts = 20, +}) async { + if (config == null) return backend.developmentLogin(role: 'patient'); + final fixture = config.patient(role); + final pedidoEm = await relayNow(relayUrl); + await backend.requestOtp(cpf: fixture.cpf, birthDate: fixture.birthDate); + final code = await codeFromRelay(relayUrl, pedidoEm, retryDelay: retryDelay, attempts: attempts); + return backend.verifyOtp(cpf: fixture.cpf, code: code); +} + +/// O instante (epoch, ms) no relógio do HOST, que é quem lê o log do servidor. +/// +/// O corte do código nunca usa o relógio do aparelho: um emulador adiantado +/// (snapshot restaurado, host suspenso) faria o relé negar o código do pedido +/// recém-feito, e atrasado devolveria um código velho. +Future relayNow(String relayUrl) async { + final client = HttpClient(); + try { + final request = await client.getUrl(Uri.parse(relayUrl).replace(path: '/now', query: '')); + final response = await request.close(); + final body = await utf8.decodeStream(response); + if (response.statusCode != 200) throw StateError('o relé respondeu ${response.statusCode} a /now'); + return int.parse(body.trim()); + } on SocketException { + throw StateError('o relé não está no ar: rode scripts/qa/otp_relay.py e, no emulador, adb reverse tcp:8765 tcp:8765'); + } finally { + client.close(force: true); + } +} + +/// Quantos códigos o gateway emitiu depois de [sinceMs] (relógio do HOST). +/// Serve para provar que um erro de digitação não gastou outro pedido de SMS. +Future relayCount(String relayUrl, int sinceMs) async { + final client = HttpClient(); + try { + final request = await client.getUrl(Uri.parse(relayUrl).replace(path: '/count', query: 'since=$sinceMs')); + final response = await request.close(); + final body = await utf8.decodeStream(response); + if (response.statusCode != 200) throw StateError('o relé respondeu ${response.statusCode} a /count'); + return int.parse(body.trim()); + } finally { + client.close(force: true); + } +} + +/// O código mais recente escrito DEPOIS de [sinceMs] (epoch, ms). +Future codeFromRelay( + String relayUrl, + int sinceMs, { + Duration retryDelay = const Duration(milliseconds: 500), + int attempts = 20, +}) async { + final client = HttpClient(); + try { + for (var i = 0; i < attempts; i++) { + try { + final request = await client.getUrl(Uri.parse('$relayUrl?since=$sinceMs')); + final response = await request.close(); + final body = await utf8.decodeStream(response); + if (response.statusCode == 200) return body.trim(); + } on SocketException { + // relé ainda não subiu: tenta de novo + } + await Future.delayed(retryDelay); + } + throw StateError('o relé não devolveu o código do OTP: rode scripts/qa/otp_relay.py ' + 'e, no emulador, adb reverse tcp:8765 tcp:8765'); + } finally { + client.close(force: true); + } +} diff --git a/apps/patient/test/support/fake_patient_backend.dart b/apps/patient/test/support/fake_patient_backend.dart index 5c1bd491..d5df71ec 100644 --- a/apps/patient/test/support/fake_patient_backend.dart +++ b/apps/patient/test/support/fake_patient_backend.dart @@ -1,4 +1,8 @@ +import 'dart:async'; + import 'package:sinalacs_client/sinalacs_client.dart'; +import 'package:sinalacs_patient/core/consent/consent_decisions.dart'; +import 'package:sinalacs_patient/core/legal/legal_documents.dart'; import 'package:sinalacs_patient/core/network/auth_session.dart'; import 'package:sinalacs_patient/core/network/backend_client.dart'; @@ -49,10 +53,124 @@ class FakePatientBackend implements PatientBackend { chronicConditions: const [], consents: const [], riskHistory: const [], + requests: const [], ); BackendFailure? myDataFailure; int myDataCallCount = 0; + + /// Acrescenta ao histórico uma decisão sobre [purpose] (`granted` ou + /// `revoked`), como o servidor faria ao gravar em `consent_logs`. + void addConsentRecord(String purpose, String action, DateTime timestamp) { + final record = PatientConsentRecord( + purpose: purpose, + action: action, + version: legalDocumentsVersion, + timestamp: timestamp, + ); + myDataResult = myDataResult.copyWith(consents: [...myDataResult.consents, record]); + } + + /// Atalho: o paciente já concedeu "Avisos da equipe". + void grantPushConsent() => addConsentRecord('segmentedPush', 'granted', DateTime.utc(2026, 9, 1)); + + /// Chamadas a [updateConsent], na ordem. + final List<({ConsentPurpose purpose, bool granted})> updateConsentCalls = + <({ConsentPurpose purpose, bool granted})>[]; + BackendFailure? updateConsentFailure; + + /// Chamadas a [registerPushToken], na ordem. + final List<(String, String)> pushRegistrations = <(String, String)>[]; + BackendFailure? pushRegistrationFailure; + + @override + Future registerPushToken({required String token, required String platform}) async { + pushRegistrations.add((token, platform)); + final failure = pushRegistrationFailure; + if (failure != null) throw failure; + } + + /// O aviso de mudança dos termos que o servidor devolveria, ou `null`. + TermsChangeNotice? termsNotice; + int termsNoticeCalls = 0; + BackendFailure? termsNoticeFailure; + + /// Quando definido, [termsChangeNotice] só responde depois que ele completa — + /// simula um backend lento ou pendurado. + Completer? termsNoticeGate; + + @override + Future termsChangeNotice() async { + termsNoticeCalls++; + await termsNoticeGate?.future; + final failure = termsNoticeFailure; + if (failure != null) throw failure; + return termsNotice; + } + + /// O que o servidor responderia a [hasAcceptedCurrentTerms]. + bool termsAccepted = true; + int termsStatusCalls = 0; + BackendFailure? termsStatusFailure; + + /// Quando definido, [hasAcceptedCurrentTerms] só responde depois que ele + /// completa — simula um backend lento ou pendurado. + Completer? termsStatusGate; + + @override + Future hasAcceptedCurrentTerms() async { + termsStatusCalls++; + await termsStatusGate?.future; + final failure = termsStatusFailure; + if (failure != null) throw failure; + return termsAccepted; + } + + /// Como no servidor: a decisão mais recente do histórico para [purpose]. + int hasGrantedConsentCalls = 0; + BackendFailure? hasGrantedConsentFailure; + + /// Definido, segura [hasGrantedConsent] até ser completado — backend lento. + Completer? hasGrantedConsentGate; + + @override + Future hasGrantedConsent(ConsentPurpose purpose) async { + hasGrantedConsentCalls++; + await hasGrantedConsentGate?.future; + final failure = hasGrantedConsentFailure; + if (failure != null) throw failure; + return currentConsentDecisions(myDataResult.consents)[purpose] == true; + } + + /// Chamadas a [acceptTermsOfUse]. + int acceptTermsCalls = 0; + BackendFailure? acceptTermsFailure; + + /// Como no servidor: uma linha `termsOfUse` `granted` a mais no histórico. + @override + Future acceptTermsOfUse() async { + acceptTermsCalls++; + final failure = acceptTermsFailure; + if (failure != null) throw failure; + final record = PatientConsentRecord( + purpose: 'termsOfUse', + action: 'granted', + version: legalDocumentsVersion, + timestamp: DateTime.now().toUtc(), + ); + termsAccepted = true; + myDataResult = myDataResult.copyWith(consents: [...myDataResult.consents, record]); + return record; + } + + int requestDataDeletionCount = 0; + final List correctionRequests = []; + BackendFailure? dataRequestFailure; + + /// Definido, segura [requestDataDeletion] até ser completado — é como o teste + /// observa a tela com o pedido ainda em voo. + Completer? dataRequestGate; + /// Código que o "servidor" aceita em [verifyOtp]. /// /// O backend real nunca devolve o código ao app — ele sai por SMS, e o app @@ -235,12 +353,14 @@ class FakePatientBackend implements PatientBackend { required bool healthDataConsent, required bool remindersConsent, required bool pushConsent, + required bool termsAccepted, }) async { enrollmentCalls.add({ 'token': token, 'healthDataConsent': healthDataConsent, 'remindersConsent': remindersConsent, 'pushConsent': pushConsent, + 'termsAccepted': termsAccepted, }); final failure = enrollmentFailure; if (failure != null) throw failure; @@ -281,6 +401,63 @@ class FakePatientBackend implements PatientBackend { return myDataResult; } + /// Como no servidor: uma linha nova no histórico, que o próximo [myData] + /// devolve. + @override + Future updateConsent({ + required ConsentPurpose purpose, + required bool granted, + }) async { + updateConsentCalls.add((purpose: purpose, granted: granted)); + final failure = updateConsentFailure; + if (failure != null) throw failure; + final record = PatientConsentRecord( + purpose: purpose.name, + action: granted ? 'granted' : 'denied', + version: legalDocumentsVersion, + timestamp: DateTime.now().toUtc(), + ); + myDataResult = myDataResult.copyWith(consents: [...myDataResult.consents, record]); + return record; + } + + /// Idempotente enquanto houver exclusão aberta, como o servidor. + @override + Future requestDataDeletion() async { + requestDataDeletionCount++; + await dataRequestGate?.future; + final failure = dataRequestFailure; + if (failure != null) throw failure; + for (final request in myDataResult.requests) { + if (request.type == DataSubjectRequestType.deletion && + request.status == DataSubjectRequestStatus.open) { + return request; + } + } + return _appendRequest(DataSubjectRequestType.deletion, null); + } + + @override + Future requestDataCorrection(String details) async { + correctionRequests.add(details); + final failure = dataRequestFailure; + if (failure != null) throw failure; + return _appendRequest(DataSubjectRequestType.correction, details); + } + + PatientDataSubjectRequestRecord _appendRequest(DataSubjectRequestType type, String? details) { + final now = DateTime.now().toUtc(); + final record = PatientDataSubjectRequestRecord( + type: type, + status: DataSubjectRequestStatus.open, + details: details, + createdAt: now, + dueAt: now.add(const Duration(days: 15)), + ); + myDataResult = myDataResult.copyWith(requests: [...myDataResult.requests, record]); + return record; + } + @override void close() => closed = true; } diff --git a/apps/patient/test/support/fake_rpc_server.dart b/apps/patient/test/support/fake_rpc_server.dart index 7cfb313f..1566c980 100644 --- a/apps/patient/test/support/fake_rpc_server.dart +++ b/apps/patient/test/support/fake_rpc_server.dart @@ -60,6 +60,10 @@ class FakeRpcServer { /// Idem para `verifyOtp`. String? rejectVerifyWith; + /// Definida, faz `updateConsent`/`requestDataDeletion`/`requestDataCorrection` + /// recusarem com esta mensagem, no formato de `DataRightsException`. + String? rejectDataRightsWith; + /// Endereço para passar a `BackendClient(host: ...)`. Porta efêmera do SO: /// dois testes em paralelo não brigam por porta. String get host => 'http://127.0.0.1:${_server.port}/'; @@ -101,6 +105,19 @@ class FakeRpcServer { return; } + final recusaDeDireitos = switch (method) { + 'updateConsent' || 'requestDataDeletion' || 'requestDataCorrection' => + rejectDataRightsWith, + _ => null, + }; + if (recusaDeDireitos != null) { + await _respond(request, HttpStatus.badRequest, { + 'className': 'DataRightsException', + 'data': {'__className__': 'DataRightsException', 'message': recusaDeDireitos}, + }); + return; + } + final payload = switch (method) { // `requestOtp` devolve `void`. Aqui o corpo é `{}`, e o servidor real // devolve `null` — a diferença é inócua e conhecida: o cliente gerado diff --git a/apps/patient/test/terms_change_notice_test.dart b/apps/patient/test/terms_change_notice_test.dart new file mode 100644 index 00000000..666e54b7 --- /dev/null +++ b/apps/patient/test/terms_change_notice_test.dart @@ -0,0 +1,242 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_client/sinalacs_client.dart' show TermsChangeNotice; +import 'package:sinalacs_patient/core/services/theme_controller.dart'; +import 'package:sinalacs_patient/app/app.dart'; +import 'package:sinalacs_patient/core/legal/legal_documents.dart'; +import 'package:sinalacs_patient/core/network/backend_client.dart'; + +import 'support/fake_patient_backend.dart'; +import 'support/semantics_scan.dart'; + +final _aviso = TermsChangeNotice( + version: '2026.2', + effectiveFrom: DateTime.utc(2026, 10, 20, 12), + summary: 'Novo canal de dúvidas.', +); + +UpcomingLegalDocuments _textoNovoDe(String versao) => UpcomingLegalDocuments( + version: versao, + privacy: LegalDocument( + id: 'privacy', + title: 'Política de Privacidade', + version: versao, + effectiveDate: '20/10/2026', + summary: privacyPolicy.summary, + sections: privacyPolicy.sections, + history: [ + LegalVersion(version: versao, date: '20/10/2026', changes: 'Texto novo.'), + ...privacyPolicy.history, + ], + ), + terms: LegalDocument( + id: 'terms', + title: 'Termo de Uso', + version: versao, + effectiveDate: '20/10/2026', + summary: termsOfUse.summary, + sections: termsOfUse.sections, + history: [ + LegalVersion(version: versao, date: '20/10/2026', changes: 'Texto novo.'), + ...termsOfUse.history, + ], + ), + ); + +final _textoNovo = _textoNovoDe('2026.2'); + +Future login(WidgetTester tester) async { + await tester.enterText(find.byKey(const Key('cpf_field')), '123.456.789-09'); + await tester.enterText(find.byKey(const Key('birth_date_field')), '01/01/1990'); + await tester.tap(find.byKey(const Key('enter_button'))); + await tester.pumpAndSettle(); + + await tester.enterText(find.byKey(const Key('otp_code_field')), '123456'); + await tester.tap(find.byKey(const Key('verify_code_button'))); + await tester.pumpAndSettle(); +} + +void main() { + testWidgets('com aviso ativo, a home mostra o cartão com a data e o resumo', (tester) async { + final backend = FakePatientBackend()..termsNotice = _aviso; + await tester.pumpWidget(SinalAcsApp(backend: backend)); + await login(tester); + + expect(find.byKey(const Key('terms_change_notice_card')), findsOneWidget); + expect(find.textContaining('20/10/2026'), findsOneWidget); + expect(find.textContaining('2026.2'), findsOneWidget); + expect(find.textContaining('Novo canal de dúvidas.'), findsOneWidget); + }); + + testWidgets('sem aviso, não há cartão', (tester) async { + await tester.pumpWidget(SinalAcsApp(backend: FakePatientBackend())); + await login(tester); + expect(find.byKey(const Key('terms_change_notice_card')), findsNothing); + }); + + testWidgets('dispensar some com o cartão', (tester) async { + final backend = FakePatientBackend()..termsNotice = _aviso; + await tester.pumpWidget(SinalAcsApp(backend: backend)); + await login(tester); + await tester.tap(find.byKey(const Key('terms_change_notice_dismiss'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('terms_change_notice_card')), findsNothing); + }); + + testWidgets('"Ler os termos atuais" abre os documentos', (tester) async { + final backend = FakePatientBackend()..termsNotice = _aviso; + await tester.pumpWidget(SinalAcsApp(backend: backend)); + await login(tester); + await tester.tap(find.byKey(const Key('terms_change_notice_read'))); + await tester.pumpAndSettle(); + expect(find.text('Privacidade e termos'), findsWidgets); + }); + + testWidgets('falha ao buscar o aviso não afeta a home nem mostra erro', (tester) async { + final backend = FakePatientBackend() + ..termsNotice = _aviso + ..termsNoticeFailure = const BackendFailure('sem rede'); + await tester.pumpWidget(SinalAcsApp(backend: backend)); + await login(tester); + expect(find.byType(PatientHomeShell), findsOneWidget); + expect(find.byKey(const Key('terms_change_notice_card')), findsNothing); + expect(find.textContaining('sem rede'), findsNothing); + }); + + testWidgets('servidor que nunca responde não atrasa a home', (tester) async { + final backend = FakePatientBackend() + ..termsNotice = _aviso + ..termsNoticeGate = Completer(); + await tester.pumpWidget(SinalAcsApp(backend: backend)); + await login(tester); + expect(find.byType(PatientHomeShell), findsOneWidget); + expect(find.byKey(const Key('terms_change_notice_card')), findsNothing); + + // Passado o teto, a busca abandonada não derruba nada. + await tester.pump(const Duration(seconds: 4)); + expect(find.byType(PatientHomeShell), findsOneWidget); + }); + + testWidgets('o shell montado sem BackendScope não quebra (teste de Lembretes)', (tester) async { + final themeController = ThemeController(); + addTearDown(themeController.dispose); + await tester.pumpWidget(MaterialApp( + home: PatientHomeShell( + initialDestination: PatientDestination.reminders, + themeController: themeController, + ), + )); + expect(tester.takeException(), isNull); + }); + + testWidgets('o cartão não cria nó de botão inerte', (tester) async { + final handle = tester.ensureSemantics(); + final backend = FakePatientBackend()..termsNotice = _aviso; + await tester.pumpWidget(SinalAcsApp(backend: backend)); + await login(tester); + expectNenhumBotaoInerte(tester); + handle.dispose(); + }); + + testWidgets('na aba de urgência o cartão não aparece e o botão de pânico fica na tela', (tester) async { + // Tela pequena e fonte grande: o pior caso para o botão descer. + tester.view.physicalSize = const Size(360, 640); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final backend = FakePatientBackend()..termsNotice = _aviso; + await tester.pumpWidget(SinalAcsApp(backend: backend)); + await login(tester); + expect(find.byKey(const Key('terms_change_notice_card')), findsOneWidget); + + await tester.tap(find.text('Urgência')); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('terms_change_notice_card')), findsNothing); + final botao = find.byKey(const Key('panic_button')); + expect(botao, findsOneWidget); + final area = tester.getRect(botao); + expect(area.top, greaterThanOrEqualTo(0)); + expect(area.bottom, lessThanOrEqualTo(640)); + }); + + testWidgets('o cartão volta nas outras abas depois de passar pela urgência', (tester) async { + final backend = FakePatientBackend()..termsNotice = _aviso; + await tester.pumpWidget(SinalAcsApp(backend: backend)); + await login(tester); + await tester.tap(find.text('Urgência')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Status')); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('terms_change_notice_card')), findsOneWidget); + }); + + testWidgets('com o texto novo embarcado na mesma versão do aviso, oferece "Ler o texto novo"', (tester) async { + final backend = FakePatientBackend()..termsNotice = _aviso; // versão 2026.2 + await tester.pumpWidget(SinalAcsApp(backend: backend, upcomingDocuments: _textoNovo)); + await login(tester); + + await tester.tap(find.byKey(const Key('terms_change_notice_read_new'))); + await tester.pumpAndSettle(); + + expect(find.text('Termos que passam a valer'), findsOneWidget); + expect(find.textContaining('Versão 2026.2'), findsWidgets); + }); + + testWidgets('o detalhe do texto novo diz que ele AINDA NÃO vale, com a data', (tester) async { + final backend = FakePatientBackend()..termsNotice = _aviso; // vigência 20/10/2026 + await tester.pumpWidget(SinalAcsApp(backend: backend, upcomingDocuments: _textoNovo)); + await login(tester); + await tester.tap(find.byKey(const Key('terms_change_notice_read_new'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('legal_open_terms'))); + await tester.pumpAndSettle(); + + final versao = tester.widget(find.byKey(const Key('legal_version'))).data!; + expect(versao, contains('passa a valer em 20/10/2026')); + expect(versao, isNot(contains('vigente desde'))); + // O histórico também não pode chamar a versão futura de vigente. + await tester.scrollUntilVisible(find.byKey(const Key('legal_history')), 300); + expect(find.textContaining('2026.2 · passa a valer em 20/10/2026'), findsOneWidget); + expect(find.textContaining('2026.2 · vigente desde'), findsNothing); + }); + + testWidgets('o detalhe do texto vigente continua dizendo "vigente desde"', (tester) async { + final backend = FakePatientBackend()..termsNotice = _aviso; + await tester.pumpWidget(SinalAcsApp(backend: backend)); + await login(tester); + await tester.tap(find.byKey(const Key('terms_change_notice_read'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('legal_open_terms'))); + await tester.pumpAndSettle(); + expect(tester.widget(find.byKey(const Key('legal_version'))).data!, contains('vigente desde')); + }); + + testWidgets('aviso de versão que o app não conhece: só o texto vigente, sem erro', (tester) async { + final backend = FakePatientBackend()..termsNotice = _aviso; + await tester.pumpWidget(SinalAcsApp(backend: backend)); // sem texto novo embarcado + await login(tester); + expect(find.byKey(const Key('terms_change_notice_read_new')), findsNothing); + expect(find.byKey(const Key('terms_change_notice_read')), findsOneWidget); + }); + + testWidgets('texto novo de OUTRA versão que a do aviso não é oferecido', (tester) async { + final backend = FakePatientBackend()..termsNotice = _aviso; // 2026.2 + await tester.pumpWidget(SinalAcsApp(backend: backend, upcomingDocuments: _textoNovoDe('2026.3'))); + await login(tester); + expect(find.byKey(const Key('terms_change_notice_read_new')), findsNothing); + }); + + testWidgets('o título do cartão é um cabeçalho semântico', (tester) async { + final handle = tester.ensureSemantics(); + final backend = FakePatientBackend()..termsNotice = _aviso; + await tester.pumpWidget(SinalAcsApp(backend: backend)); + await login(tester); + final data = tester.getSemantics(find.text('Os termos vão mudar')).getSemanticsData(); + expect(data.flagsCollection.isHeader, isTrue); + expect(data.label, contains('Os termos vão mudar')); + handle.dispose(); + }); +} diff --git a/apps/patient/test/terms_gate_flow_test.dart b/apps/patient/test/terms_gate_flow_test.dart new file mode 100644 index 00000000..2974ba85 --- /dev/null +++ b/apps/patient/test/terms_gate_flow_test.dart @@ -0,0 +1,181 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_patient/app/app.dart'; +import 'package:sinalacs_patient/core/legal/legal_documents.dart'; +import 'package:sinalacs_patient/core/network/backend_client.dart'; + +import 'support/fake_patient_backend.dart'; + +Future login( + WidgetTester tester, { + String cpf = '123.456.789-09', + String nascimento = '01/01/1990', + String codigo = '123456', +}) async { + await tester.enterText(find.byKey(const Key('cpf_field')), cpf); + await tester.enterText(find.byKey(const Key('birth_date_field')), nascimento); + await tester.tap(find.byKey(const Key('enter_button'))); + await tester.pumpAndSettle(); + + await tester.enterText(find.byKey(const Key('otp_code_field')), codigo); + await tester.tap(find.byKey(const Key('verify_code_button'))); + await tester.pumpAndSettle(); +} + + +Future tapKey(WidgetTester tester, String key) async { + final finder = find.byKey(Key(key)); + await tester.ensureVisible(finder); + await tester.pumpAndSettle(); + await tester.tap(finder); + await tester.pumpAndSettle(); +} + +/// Paciente que ainda não aceitou nada: é o de quem entrou por OTP sem onboarding. +FakePatientBackend semAceite() => FakePatientBackend()..termsAccepted = false; + +void main() { + testWidgets('sem aceite registrado, o login leva à tela de aceite', (tester) async { + final backend = semAceite(); + await tester.pumpWidget(SinalAcsApp(backend: backend)); + await login(tester); + + expect(find.byKey(const Key('terms_gate_accept_button')), findsOneWidget); + expect(find.text('Registrar alerta de urgência'), findsNothing); + }); + + testWidgets('aceitar exige marcar a caixa, grava uma vez e segue para a tela inicial', (tester) async { + final backend = semAceite(); + await tester.pumpWidget(SinalAcsApp(backend: backend)); + await login(tester); + + final antes = tester.widget(find.byKey(const Key('terms_gate_accept_button'))); + expect(antes.onPressed, isNull); + + await tapKey(tester, 'terms_gate_checkbox'); + await tapKey(tester, 'terms_gate_accept_button'); + + expect(backend.acceptTermsCalls, 1); + expect(find.byKey(const Key('terms_gate_accept_button')), findsNothing); + }); + + testWidgets('"Agora não" entra sem gravar nada', (tester) async { + final backend = semAceite(); + await tester.pumpWidget(SinalAcsApp(backend: backend)); + await login(tester); + + await tapKey(tester, 'terms_gate_later_button'); + + expect(backend.acceptTermsCalls, 0); + expect(find.byKey(const Key('terms_gate_later_button')), findsNothing); + }); + + testWidgets('já aceitou a versão vigente: nenhuma tela extra, uma leitura', (tester) async { + final backend = FakePatientBackend(); + await tester.pumpWidget(SinalAcsApp(backend: backend)); + await login(tester); + + expect(find.byKey(const Key('terms_gate_accept_button')), findsNothing); + expect(backend.termsStatusCalls, 1); + expect(backend.myDataCallCount, 0, reason: 'o login não lê mais o painel inteiro'); + }); + + testWidgets('falha ao ler os consentimentos não impede a entrada', (tester) async { + // Emergência: o alerta de urgência não pode ficar atrás de um aceite. + final backend = semAceite()..termsStatusFailure = const BackendFailure('sem rede'); + await tester.pumpWidget(SinalAcsApp(backend: backend)); + await login(tester); + + expect(find.byKey(const Key('terms_gate_accept_button')), findsNothing); + expect(find.byType(NavigationBar), findsOneWidget); + }); + + testWidgets('falha ao aceitar mostra o erro, mantém "Agora não" e permite tentar de novo', (tester) async { + final backend = semAceite()..acceptTermsFailure = const BackendFailure('Sem conexão.'); + await tester.pumpWidget(SinalAcsApp(backend: backend)); + await login(tester); + + await tapKey(tester, 'terms_gate_checkbox'); + await tapKey(tester, 'terms_gate_accept_button'); + + expect(find.byKey(const Key('terms_gate_error')), findsOneWidget); + expect(find.byKey(const Key('terms_gate_later_button')), findsOneWidget); + + backend.acceptTermsFailure = null; + await tapKey(tester, 'terms_gate_accept_button'); + expect(backend.acceptTermsCalls, 2); + expect(find.byKey(const Key('terms_gate_accept_button')), findsNothing); + }); + + testWidgets('"Ler o Termo e a Política" abre os documentos', (tester) async { + await tester.pumpWidget(SinalAcsApp(backend: semAceite())); + await login(tester); + + await tapKey(tester, 'terms_gate_read_button'); + + expect(find.textContaining('Versão $legalDocumentsVersion'), findsWidgets); + }); + + testWidgets('leitura de consentimentos pendurada não segura o paciente no login', (tester) async { + // `verifyOtp` já deu sessão: a checagem do aceite não pode ficar entre o + // paciente e o alerta de urgência (a espera padrão do cliente é de 20 s). + final backend = semAceite()..termsStatusGate = Completer(); + await tester.pumpWidget(SinalAcsApp(backend: backend)); + await login(tester); + await tester.pump(const Duration(seconds: 4)); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('terms_gate_accept_button')), findsNothing); + expect(find.byType(NavigationBar), findsOneWidget); + }); + + testWidgets('a tela de aceite não diz que é obrigatório e lembra do alerta', (tester) async { + await tester.pumpWidget(SinalAcsApp(backend: semAceite())); + await login(tester); + + expect(find.textContaining('para continuar'), findsNothing); + expect(find.textContaining('alerta de urgência continua disponível'), findsOneWidget); + }); + + testWidgets('"Agora não" tocado duas vezes entra uma vez só', (tester) async { + await tester.pumpWidget(SinalAcsApp(backend: semAceite())); + await login(tester); + + final later = find.byKey(const Key('terms_gate_later_button')); + await tester.tap(later); + await tester.tap(later, warnIfMissed: false); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.byType(NavigationBar), findsOneWidget); + }); + + testWidgets('"Agora não" leva à tela inicial e nada é gravado', (tester) async { + final backend = semAceite(); + await tester.pumpWidget(SinalAcsApp(backend: backend)); + await login(tester); + + await tapKey(tester, 'terms_gate_later_button'); + + expect(find.byType(NavigationBar), findsOneWidget); + expect(backend.acceptTermsCalls, 0); + }); + + testWidgets('quem tocou "Agora não" vê o aviso de novo no login seguinte', (tester) async { + final backend = semAceite(); + await tester.pumpWidget(SinalAcsApp(backend: backend)); + await login(tester); + await tapKey(tester, 'terms_gate_later_button'); + expect(find.byType(NavigationBar), findsOneWidget); + + // Novo início do app sobre o mesmo backend: a árvore é refeita do zero. + await tester.pumpWidget(const SizedBox()); + await tester.pumpWidget(SinalAcsApp(backend: backend)); + await login(tester); + + expect(find.byKey(const Key('terms_gate_accept_button')), findsOneWidget); + expect(backend.acceptTermsCalls, 0); + }); +} diff --git a/apps/patient/test/upcoming_legal_documents_test.dart b/apps/patient/test/upcoming_legal_documents_test.dart new file mode 100644 index 00000000..9efcc6b1 --- /dev/null +++ b/apps/patient/test/upcoming_legal_documents_test.dart @@ -0,0 +1,82 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_patient/core/legal/legal_documents.dart'; + +/// Versão anunciada no backend: `null` só quando a declaração é `= null`; a versão +/// quando há uma agenda com `version:` literal; qualquer outra forma **lança** — +/// "não entendi a agenda" nunca pode passar por "sem agenda". +String? backendUpcomingVersion(String source) { + final semComentarios = source.replaceAll(RegExp(r'//[^\n]*'), ''); + final declaracao = RegExp( + r'final\s+TermsChangeSchedule\?\s+upcomingTermsChange\s*=\s*', + ).firstMatch(semComentarios); + if (declaracao == null) { + throw StateError('declaração de upcomingTermsChange não encontrada'); + } + final resto = semComentarios.substring(declaracao.end); + if (RegExp(r'^null\s*;').hasMatch(resto)) return null; + final versao = RegExp(r'^TermsChangeSchedule\([^]*?version:\s*([\x27"])([^\x27"]+)\1').firstMatch(resto); + if (versao == null) { + throw StateError('não consegui ler a versão da agenda (use um literal em version:)'); + } + return versao.group(2); +} + +/// Passa pelo tipo declarado: o analisador enxerga a constante como `null` fixo. +UpcomingLegalDocuments? _atual() => upcomingLegalDocuments; + +void main() { + const decl = 'final TermsChangeSchedule? upcomingTermsChange'; + + test('o leitor devolve null só para "= null" e a versão para uma agenda literal', () { + expect(backendUpcomingVersion('$decl = null;'), isNull); + expect(backendUpcomingVersion('$decl =\n null;'), isNull); + expect( + backendUpcomingVersion("$decl = TermsChangeSchedule(version: '2026.2', publishedAt: x);"), + '2026.2', + ); + }); + + test('o leitor aceita aspas duplas, ponto e vírgula no resumo e comentário enganoso', () { + expect(backendUpcomingVersion('$decl = TermsChangeSchedule(version: "2026.3", x: 1);'), '2026.3'); + expect( + backendUpcomingVersion("$decl = TermsChangeSchedule(summary: 'a; b', version: '2026.4');"), + '2026.4', + ); + expect( + backendUpcomingVersion("// upcomingTermsChange = null;\n$decl = TermsChangeSchedule(version: '2026.5');"), + '2026.5', + ); + }); + + test('o leitor NÃO trata "não entendi" como "sem agenda": lança', () { + expect( + () => backendUpcomingVersion('$decl = TermsChangeSchedule(version: proximaVersao);'), + throwsStateError, + ); + expect(() => backendUpcomingVersion('nada aqui'), throwsStateError); + expect(() => backendUpcomingVersion('$decl = criaAgenda();'), throwsStateError); + }); + + test('agenda do backend e texto do app andam juntos', () { + final source = File( + '../../backend/sinalacs_server/lib/src/application/patients/terms_change_schedule.dart', + ).readAsStringSync(); + expect( + upcomingLegalDocuments?.version, + backendUpcomingVersion(source), + reason: 'o aviso anunciado no servidor precisa ter o texto novo embarcado no app ' + '(e vice-versa): publique o app antes de publicar o aviso', + ); + }); + + test('o texto novo, quando existe, não repete a versão vigente e é coerente', () { + final novo = _atual(); + if (novo != null) { + expect(novo.version, isNot(legalDocumentsVersion)); + expect(novo.privacy.version, novo.version); + expect(novo.terms.version, novo.version); + } + }); +} diff --git a/apps/patient/tool/live_check.dart b/apps/patient/tool/live_check.dart index 69d090fc..8e46a88c 100644 --- a/apps/patient/tool/live_check.dart +++ b/apps/patient/tool/live_check.dart @@ -28,6 +28,8 @@ import 'dart:io'; import 'package:sinalacs_patient/core/network/backend_client.dart'; +import 'support/host_login.dart'; + /// Caminho da CA de desenvolvimento do RPC (a que assina o certificado do /// Traefik em 443), dentro do repositório. /// @@ -107,7 +109,7 @@ Future main(List args) async { stdout.writeln(' health ............. ${health.status} ' '(db=${health.dbConnected} mqtt=${health.mqttConnected})'); - final session = await backend.developmentLogin(role: 'patient'); + final session = await loginPatientOnHost(backend); // O token nunca é impresso inteiro. stdout.writeln(' login .............. papel=${session.role} ' 'microárea=${session.microAreaId} expira=${session.expiresAt.toIso8601String()}'); diff --git a/apps/patient/tool/push_consent.dart b/apps/patient/tool/push_consent.dart new file mode 100644 index 00000000..6dbfc5c5 --- /dev/null +++ b/apps/patient/tool/push_consent.dart @@ -0,0 +1,52 @@ +/// Concede ou revoga `segmentedPush` como o paciente de desenvolvimento, pelo RPC real. +/// +/// dart run tool/push_consent.dart --grant | --revoke [--host https://localhost/] +/// +/// Serve ao `scripts/qa/push_e2e.sh` (caso "revogar zera os destinatários"). Precisa +/// da stack de pé com `ENABLE_DEV_LOGIN=true`. Saída: `consent=granted|revoked`; uma +/// falha imprime a mensagem e sai com 2. +library; + +import 'dart:io'; + +import 'package:sinalacs_client/sinalacs_client.dart' show ConsentPurpose; +import 'package:sinalacs_patient/core/network/backend_client.dart'; + +import 'support/host_login.dart'; + +Future main(List args) async { + final grant = args.contains('--grant'); + if (grant == args.contains('--revoke')) { + stderr.writeln('uso: dart run tool/push_consent.dart --grant | --revoke [--host https://localhost/]'); + exitCode = 2; + return; + } + final index = args.indexOf('--host'); + final String host; + try { + host = requireSecureHost(index >= 0 && index + 1 < args.length ? args[index + 1] : 'https://localhost/'); + } on BackendFailure catch (failure) { + stderr.writeln('erro: ${failure.message}'); + exitCode = 2; + return; + } + // apps/patient/tool/push_consent.dart → raiz do repositório. + final repoRoot = Directory.fromUri(Platform.script).parent.parent.parent.parent; + final caFile = File('${repoRoot.path}/infra/docker/traefik/runtime/certs/ca.crt'); + if (!await caFile.exists()) { + stderr.writeln('erro: a CA do RPC não existe em ${caFile.path}. Suba a stack.'); + exitCode = 2; + return; + } + final backend = BackendClient(host: host, trustedCaBytes: await caFile.readAsBytes()); + try { + await loginPatientOnHost(backend, role: 'push'); + await backend.updateConsent(purpose: ConsentPurpose.segmentedPush, granted: grant); + stdout.writeln('consent=${grant ? 'granted' : 'revoked'}'); + } on BackendFailure catch (failure) { + stderr.writeln('falhou: ${failure.message}'); + exitCode = 2; + } finally { + backend.close(); + } +} diff --git a/apps/patient/tool/support/host_login.dart b/apps/patient/tool/support/host_login.dart new file mode 100644 index 00000000..0534b40b --- /dev/null +++ b/apps/patient/tool/support/host_login.dart @@ -0,0 +1,28 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:sinalacs_patient/core/network/auth_session.dart'; +import 'package:sinalacs_patient/core/network/backend_client.dart'; + +import '../../test/support/e2e_config.dart'; +import '../../test/support/e2e_login_core.dart'; + +/// Login do paciente para as ferramentas de linha de comando (rodam no host). +/// +/// O manifesto de fixtures só vale quando pedido: `E2E_FIXTURES_FILE=` +/// (o `scripts/qa/patient_full_e2e.sh` define). Um arquivo esquecido em +/// `.e2e/` não muda o caminho sozinho — ele descreveria pacientes de uma stack +/// que pode nem estar de pé, e a ferramenta tentaria um OTP que ninguém vai +/// ouvir. Sem a variável, usa o login de desenvolvimento, como antes. +Future loginPatientOnHost(PatientBackend backend, {String role = 'main'}) { + final path = Platform.environment['E2E_FIXTURES_FILE']; + final config = path == null || path.isEmpty + ? null + : E2eConfig.fromMap((jsonDecode(File(path).readAsStringSync()) as Map).cast()); + return loginPatientWith( + backend, + config, + relayUrl: Platform.environment['OTP_RELAY'] ?? 'http://127.0.0.1:8765/code', + role: role, + ); +} diff --git a/apps/patient/tool/territory_check.dart b/apps/patient/tool/territory_check.dart new file mode 100644 index 00000000..4f4e00a4 --- /dev/null +++ b/apps/patient/tool/territory_check.dart @@ -0,0 +1,55 @@ +/// Prova de território com o banco de e2e: o ACS da microárea lista os pacientes +/// dela (`main`, `chronic`) e NÃO o da outra (`outsider`). +/// +/// E2E_FIXTURES_FILE=.e2e/fixtures.json ACS_MATRICULA=… ACS_PASSWORD=… \ +/// dart run tool/territory_check.dart [--host https://localhost/] +/// +/// Roda no host, e não dentro do aparelho, para que a senha do ACS chegue por +/// ambiente e nunca por `--dart-define` (que iria para o argv do `flutter test` e +/// seria compilado no APK). Saída: `territorio=ok` e código 0; qualquer +/// divergência imprime o que faltou (nomes sintéticos, nunca CPF) e sai com 2. +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:sinalacs_client/sinalacs_client.dart'; + +import '../test/support/e2e_config.dart'; + +Future main(List args) async { + final path = Platform.environment['E2E_FIXTURES_FILE']; + final matricula = Platform.environment['ACS_MATRICULA']; + final password = Platform.environment['ACS_PASSWORD']; + if (path == null || matricula == null || password == null) { + stderr.writeln('erro: defina E2E_FIXTURES_FILE, ACS_MATRICULA e ACS_PASSWORD.'); + exitCode = 2; + return; + } + final config = E2eConfig.fromMap((jsonDecode(File(path).readAsStringSync()) as Map).cast())!; + final index = args.indexOf('--host'); + final host = index >= 0 && index + 1 < args.length ? args[index + 1] : 'https://localhost/'; + // apps/patient/tool/territory_check.dart → raiz do repositório. + final repoRoot = Directory.fromUri(Platform.script).parent.parent.parent.parent; + final ca = File('${repoRoot.path}/infra/docker/traefik/runtime/certs/ca.crt').readAsBytesSync(); + + final client = Client(host, securityContext: SecurityContext()..setTrustedCertificatesBytes(ca)) + ..connectivityMonitor = null; + try { + final session = await client.auth.loginInstitutional(matricula: matricula, password: password); + final names = (await client.patients.listMicroArea(accessToken: session.accessToken)).map((p) => p.name).toSet(); + final falhas = [ + for (final role in ['main', 'chronic']) + if (!names.contains(config.patient(role).name)) 'faltou na lista: ${config.patient(role).name}', + if (names.contains(config.patient('outsider').name)) 'vazou de outra microárea: ${config.patient('outsider').name}', + ]; + if (falhas.isNotEmpty) { + stderr.writeln(falhas.join('\n')); + exitCode = 2; + return; + } + stdout.writeln('territorio=ok'); + } finally { + client.close(); + } +} diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 16fee680..d0ef076e 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -36,10 +36,44 @@ 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). On the API server specifically, `config/development.yaml` keeps `port: 8080` — the process still listens on cleartext 8080 **inside** the container — while `publicHost`/`publicPort`/`publicScheme` are `localhost`/`443`/`https`. Those three describe the **declared** public address (the encrypted URL Traefik terminates), not the listener and not the address actually in use: no client here reads them, since both apps build the client from `--dart-define`, and the route that would serve them (`AppConfigRoute` in `lib/server.dart`) lives on the webServer (8082), which the compose neither publishes nor routes. And note **which** config file actually serves: the image runs in production mode — the Dockerfile sets `ENV runmode=production` and the entrypoint is `./server --mode=$runmode` — so the running backend reads `config/production.yaml`, not the `development.yaml` quoted above; there, the `apiServer` block keeps Serverpod's placeholder `publicHost: api.examplepod.com` with `publicPort: 443`, `publicScheme: https` and `port: 8080`. The paragraph above is therefore true about the file, but the process in the container does **not** declare `localhost:443` — it declares a hostname nobody resolves. Only Traefik publishes a port for the RPC — `443`, per RNF04/L-08; cleartext 8080 used to be published on the host and no longer is. -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`, `ENABLE_DEV_LOGIN` (default `false` — gates `auth.developmentLogin`, which fails as if the route did not exist when off), `CPF_HASH_PEPPER` (RF01 — the key of the HMAC over the CPF and over the OTP code; the server and the seed writers must share it) and `SMS_GATEWAY` (the OTP delivery channel: it defaults to `log` in `development`, is **required** elsewhere, and `log` — the only gateway implemented today — is refused outside `development` by name, since it sends no SMS at all; the development gateway writes the code to the server log, and nothing else of the signature). +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`/`MQTT_CLIENT_CERT_PATH`/`MQTT_CLIENT_KEY_PATH` (certificado de cliente do mTLS do broker; só um dos dois definido é erro de boot, e fora de `development` com `MQTT_USE_TLS=true` os dois são obrigatórios), plus `JWT_SECRET`, `AUDIT_CHAIN_SECRET`, `APP_ENV`, `ENABLE_DEV_LOGIN` (default `false` — gates `auth.developmentLogin`, which fails as if the route did not exist when off), `CPF_HASH_PEPPER` (RF01 — the key of the HMAC over the CPF and over the OTP code; the server and the seed writers must share it) and `SMS_GATEWAY` (the OTP delivery channel: it defaults to `log` in `development`, is **required** elsewhere, and `log` — the only gateway implemented today — is refused outside `development` by name, since it sends no SMS at all; the development gateway writes the code to the server log, and nothing else of the signature). Outside `development`, boot fails fast when `JWT_SECRET`, `AUDIT_CHAIN_SECRET` or `CPF_HASH_PEPPER` is absent, empty, blank, or equal to its own development fallback (`AppConfig.developmentJwtSecret` / `AppConfig.developmentAuditChainSecret` / `AppConfig.developmentCpfHashPepper`) — those fallbacks live in versioned code, so they're public, and `JWT_SECRET` signs tokens that carry role and micro-area, `AUDIT_CHAIN_SECRET` keys the `audit_logs` hash chain while `CPF_HASH_PEPPER` keys the HMAC over the CPF and over the OTP code. They are deliberately independent secrets — rotating one must not affect the others. 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`. +`REQUIRE_ACS_MFA` (RF07, MFA por TOTP) — ausente, vale `true` em todo ambiente que não seja `development` e `false` em `development` (`AppConfig.resolveRequireAcsMfa`); presente, só a string exata `true` liga. `docker-compose.yml` passa `${REQUIRE_ACS_MFA:-false}` (default só da stack local) e `docker-compose.e2e.yml` fixa `"false"` para a jornada do emulador. Ligada, o ACS sem MFA ativa recebe `MfaEnrollmentRequiredException` no `auth.loginInstitutional` e precisa ativá-la por `auth.beginTotpEnrollment` (matrícula + senha → segredo base32 + URI `otpauth://`, gravado como **pendente**) e `auth.confirmTotpEnrollment` (matrícula + senha + primeiro código); os dois são públicos por desenho (não há token antes da MFA) e contam tentativa errada no mesmo bloqueio do login. Com a MFA ativa, a senha certa sem código recebe `MfaRequiredException` (não conta tentativa); código errado conta; o passo de 30 s aceito vai para `user_credentials.totpLastStep` e o `UPDATE` só deixa o passo avançar, o que barra o replay. TOTP é RFC 6238 (SHA-1, 6 dígitos, 30 s, janela ±1, comparação sem saída antecipada) em `application/auth/totp.dart`. O segredo é cifrado por `HealthCipherTotpVault` com a **mesma** chave dos dados clínicos (`HEALTH_DATA_ENCRYPTION_KEY`): rotacionar ou perder essa chave invalida também as MFAs. **Redefinição (desde a #43):** a coordenação redefine a MFA pela tela do backoffice — `admin.resetAcsMfa` zera as quatro colunas (`totpSecretEncrypted`, `totpKeyVersion`, `totpEnabledAt`, `totpLastStep`) e o ACS ativa de novo na entrada seguinte; ninguém zera coluna à mão desde então. Para o staff é `admin.resetStaffMfa`; a CLI `bin/issue_staff_activation_code.dart` continua sendo o caminho do operador para uma conta que ainda não nasceu (ver "Gestão de contas do backoffice (#43)"). + +**Staff (backoffice, issue #39).** `auth.loginStaff` (matrícula + senha + `totpCode`), `auth.beginStaffTotpEnrollment` e `auth.confirmStaffTotpEnrollment` são o login institucional do `coordinator`/`admin`: o mesmo `InstitutionalAuthService` do ACS, instanciado com a audiência `staff` (`CredentialAudience`, `staffAuthServiceFor`) sobre `OrmAcsCredentialStore(staff: true)`, que procura a matrícula em `staff_accounts` (`id` = UUID do `users`, `enrollmentId` único, `active`) em vez de `acs`; as duas tabelas têm matrículas independentes e uma matrícula de ACS não existe para o login do staff. A MFA é **sempre obrigatória** para o staff, em todo ambiente (não há `REQUIRE_*_MFA` para ele); conta sem TOTP ativado recebe `MfaEnrollmentRequiredException` e ativa pela tela. O JWT sai com o papel do usuário e **sem microárea**; um papel que não seja `coordinator`/`admin` é recusado depois de a senha conferir e a recusa é auditada. `Authorization.staffRoles` é a regra de papel para os futuros endpoints de staff (os de dados são a issue #40; hoje nenhum os usa). **Sem refresh token para o staff** (JWT de 15 min, depois novo login com código). Staff e ACS seguem a **mesma política** de bloqueio (5 falhas, 15 min dobrando a cada rodada seguinte, até o teto de 24 h; `lockDurationFor`), mas o contador (`failedAttempts`/`lockStreak` em `user_credentials`) é **por conta**: falhas de uma conta não afetam outra (provado em `staff_login_test.dart`); dentro da conta, senha e TOTP compartilham o mesmo contador. As linhas de `audit_logs` distinguem a audiência: o login do staff grava `resourceType: 'staff_session'`; o do ACS, `'session'` (linhas gravadas antes dessa distinção mantêm `'session'`, sem backfill: o login do staff ainda não havia sido lançado; o `actionType` é `login` nos dois, e a ativação da MFA segue a mesma regra, porque todo evento do `InstitutionalAuthService` passa por `_recordAudit`). **Ativação da MFA do staff com código de uso único (issue #48).** `beginStaffTotpEnrollment` e `confirmStaffTotpEnrollment` exigem, além de matrícula e senha, `activationCode`: 130 bits aleatórios, guardados só como SHA-256 em `staff_accounts` (`activationCodeHash`, `activationCodeExpiresAt`, `activationCodeIssuedBy`, `activationCodeIssuedAt`; migração aditiva), validade de 24 h, comparação em tempo constante (`StaffActivationCode`). Código ausente, errado, expirado ou já usado recebe a **mesma** recusa (`Código de ativação inválido ou expirado.`), conta como tentativa falha da conta e grava `denied_staff_activation_code`. A checagem vem **depois** de "a MFA já está ativa", para não revelar que um código existe. `begin` repetido com o mesmo código funciona até a confirmação; só o `confirm` bem-sucedido apaga hash e validade (o último `IssuedBy/At` fica). Emitir de novo invalida o anterior. A ativação do ACS não mudou. **Emissão:** `dart run bin/issue_staff_activation_code.dart MATRICULA --issued-by QUEM [--ttl-hours 24]`, ferramenta de operador com as variáveis `SERVERPOD_DATABASE_*`; mostra o código uma única vez e sai com 2, sem imprimir nada, para matrícula inexistente ou conta inativa. Quem emitiu fica em `activationCodeIssuedBy/At`, **não** em `audit_logs` (a CLI não carrega a cadeia de auditoria do servidor). **A emissão pela interface deixou de ser pendência na #43:** `admin.resetStaffMfa` (só o administrador, nunca a própria conta) zera a MFA e emite o código novo numa transação só, e **essa** emissão entra na trilha (`admin_staff`/`mfa_reset`, com o id do alvo) — é a primeira vez que uma emissão de código de ativação aparece em `audit_logs`. A CLI permanece para o operador e para uma conta que ainda não nasceu; a emissão por ela continua fora da trilha encadeada. O seed de desenvolvimento cria `ADM-001` (`staff_accounts` + `users` com papel `admin`); a senha vem de `DEV_ADMIN_PASSWORD` (obrigatória no `.env`, gerada por `bootstrap_env.sh`; o serviço `acs-credential-seed` do Compose grava o hash Argon2id junto com o do ACS), sem TOTP e **sem código de ativação**: antes do primeiro login, emita um com a CLI acima (nada no seed o cria, de propósito). O seed de e2e (`bin/seed_e2e_fixtures.dart`) cria um admin sintético por execução (bloco `staff` do manifesto, com `activationCode`; matrícula `E2E-ADM-*`; o seed grava só o hash), que o relé `scripts/qa/otp_relay.py` entrega em `/admin` uma única vez; desde a #43 cria também um **coordenador** sintético (`E2E-COORD-*`, bloco `coordinator`, com `staff_accounts."ubsId"` preenchido), entregue em `/coordenador`. + +**Leitura do backoffice (`AdminEndpoint`, issue #40).** `admin.indicators`, `admin.microAreas`, `admin.alerts` e `admin.auditLogs`, todos `(Session, {required String accessToken, …})` e somente leitura. O endpoint só verifica o token e delega a `AdminReadService` (`application/admin/`), sobre a interface `AdminReadStore` (`OrmAdminReadStore`, SQL direto com `unsafeQuery` e parâmetros nomeados). Regras, todas cobertas por `test/unit/admin_read_service_test.dart` (10), `test/integration/admin_read_store_test.dart` (14, Postgres real) e `test/integration/admin_endpoint_test.dart` (10, tokens reais): +- **Papel:** só `coordinator` e `admin` (`Authorization.staffRoles`, sem exigir microárea); `acs` e `patient` recebem `AlertPermissionException` mesmo com token válido, e a recusa é auditada (`result: denied`). +- **Escopo (PRD §4.2.2):** o administrador vê o sistema; o coordenador vê só a UBS de `staff_accounts.ubsId` (coluna nova, nula para admin). A #43 entregou a operação escopada por ela, mas **não** a tela para atribuí-la: criar coordenação e escolher a UBS seguem por SQL, e o seed de desenvolvimento não tem coordenador. Coordenador **sem** UBS é recusado em todos os métodos. O filtro de microárea de outra UBS devolve vazio, não fura o escopo. `alerts.microAreaId` é anulável: o administrador conta o alerta sem microárea, o coordenador não o enxerga. +- **Auditoria escopada (issue #43, deferimento da #40).** ~~Só o administrador lê a auditoria~~ — o coordenador lê as linhas cujo **autor** tem microárea na UBS dele (a junção `users` × `micro_areas` que a #40 adiou), e o administrador lê todas. O escopo filtra o **autor** da linha, não o recurso auditado: por isso o coordenador também enxerga as linhas **pseudonimizadas de paciente** da UBS dele (`triage_session`, login OTP), e as linhas de staff (autor sem microárea) ficam invisíveis para ele. +- **Auditoria antes do dado, fail-closed:** cada leitura grava uma linha (`actionType: read`, `resourceType: admin_indicators | admin_micro_areas | admin_alerts | admin_audit_logs`, `result: success`) com `record`, não `recordSafely`: se a linha não grava, a exceção sobe e o store não é chamado. Parâmetro inválido não gera linha de sucesso. +- **TMRAV:** média de `acknowledgedAt − triggeredAt` dos alertas **vermelhos** reconhecidos com `triggeredAt` nos últimos 30 dias, em segundos inteiros; `null` sem amostra (a tela mostra "—", nunca "0s"). O PRD §1.3 não fixa a janela: os 30 dias são decisão desta issue. +- **Paginação:** `limit` de 1 a 100 (padrão 50) e `offset` não negativo; fora disso, `AdminInvalidRequestException` (não é erro de permissão). Alertas por `offset` (`nextOffset` nulo no fim; desde a #44, **vencidos primeiro** — `overdue` desc pelo fragmento de `alert_overdue_sql.dart` —, depois `triggeredAt` desc e `id` desc); auditoria por keyset (`beforeSequence`, `nextBeforeSequence`), porque o log só cresce e `offset` repetiria linhas. +- **Minimização de PII:** `AdminLabels` — o paciente é `#` + 4 hex finais do UUID (`#A18F`), nunca nome, CPF ou contato; o usuário da auditoria é `MATRÍCULA (Papel)`. `AdminAuditEntry` não tem `ipHash`, `previousHash` nem `entryHash`. O nome do ACS aparece na listagem de microáreas (é profissional, não titular). +- O seed de e2e semeia os 3 alertas do painel só com `E2E_SEED_ADMIN_ALERTS=1` (ligado por `admin_login_e2e.sh`), porque o seed é compartilhado e eles pertencem ao paciente `main`. O seed de desenvolvimento **não** tem coordenador (exigiria nova senha em `.env`): coordenador só existe nos testes — o de e2e é o `E2E-COORD-*`, com `ubsId` preenchido, desde a #43. + +**Gestão de contas do backoffice (`AdminEndpoint`, issue #43).** Os oito métodos de escrita e de listagem de contas no mesmo endpoint: `admin.acs` e `admin.staff` (listagem), `admin.createAcs`, `admin.setAcsMicroArea`, `admin.setAcsActive`, `admin.resetAcsPassword`, `admin.resetAcsMfa` e `admin.resetStaffMfa` — todos `(Session, {required String accessToken, …})`, delegando ao `AdminAccountService` (`application/admin/admin_account_service.dart`) sobre a interface `AdminAccountStore` (`OrmAdminAccountStore`). Papel e escopo vêm do `AdminScopeResolver` (`application/admin/admin_scope.dart`), que desde a #43 é a **regra única** do backoffice — o `AdminReadService` também o usa, no lugar da cópia que tinha: só `coordinator`/`admin`; o coordenador opera apenas a UBS de `staff_accounts.ubsId`, fail-closed sem UBS; o administrador, o sistema. Regras, cobertas por `test/unit/admin_scope_test.dart` (8), `test/unit/admin_account_service_test.dart` (43), `test/integration/admin_account_store_test.dart` (28, Postgres real) e `test/integration/admin_endpoint_accounts_test.dart` (18, tokens reais), mais o e2e `scripts/qa/admin_acs_gestao_e2e.sh`: +- **Auditoria fail-closed, sempre com `AuditTrail.record`** (nunca `recordSafely`): recusa grava `denied` **antes** de lançar; leitura audita `read`/`success` antes de consultar o store; escrita bem-sucedida audita **depois do commit**, com `actionType: write`, `resourceType: admin_acs|admin_staff`, `resourceId` do alvo e `result` específico (`created`, `micro_area_changed`, `activated`, `deactivated`, `password_reset`, `mfa_reset`). É o que faz a emissão do código de ativação deixar de ser a única sem trilha. +- **Recusas uniformes:** "não existe" e "não é da sua UBS" devolvem a **mesma** mensagem (`ACS não encontrado.` / `Microárea não encontrada.` / `Conta de equipe não encontrada.`), para o coordenador não descobrir, por tentativa, o que existe fora da UBS dele. Erro de permissão continua `AlertPermissionException` com `acesso restrito ao backoffice`; as validações de entrada devolvem `AdminInvalidRequestException` com texto sobre a própria entrada do operador. +- **`createAcs`:** a UBS **nunca** vem do pedido — sai da microárea escolhida (é o único dado que o escopo do coordenador pode comparar). `users` (papel `acs`), `acs` e `user_credentials` numa única transação; a corrida que passa pela pré-checagem de matrícula é fechada pelo índice único do banco, e o `insertAcs` devolvendo `null` cai na mesma mensagem, nunca em erro de servidor. A **senha inicial é sorteada pelo servidor** (`AcsInitialPassword`: 16 caracteres de um alfabeto de 31 símbolos, sem os pares ambíguos `0`/`O` e `1`/`I`/`L`, em grupos de quatro, ~79 bits — o doc de classe fala em 30; o alfabeto tem 31), mostrada uma única vez e nunca guardada — só o hash Argon2id vai para `user_credentials`. O `cpfHash` do ACS criado é um placeholder aleatório (`acs-sem-cpf-`) e o `birthDate` um sentinela (1900-01-01 UTC): nunca um CPF/HMAC real, pela armadilha de `spec/lgpd_data_audit.md` L254 (o login passwordless emite papel de *paciente*). +- **`setAcsMicroArea`:** grava `users."microAreaId"` e `acs."ubsId"` numa transação, com o predicado do escopo **dentro** do `WHERE` (a checagem e a escrita não são duas decisões sobre estados diferentes); devolve a linha relida do banco. O JWT em curso do ACS continua com o território antigo até o refresh — a microárea do token novo é relida do banco a cada `refreshSession`. +- **`setAcsActive`:** desativar revoga, **na mesma transação**, todas as famílias de refresh token e todos os tokens de envio diferido da conta: não existe instante observável em que o ACS esteja inativo e ainda com sessão viva. Reativar não ressuscita token nenhum — o acesso novo se obtém com login (senha + TOTP). Idempotente (desativar um já inativo conclui e audita de novo: o que a trilha registra é o pedido do operador). +- **`resetAcsPassword`:** senha nova sorteada pelo servidor (mesmo gerador), mostrada uma única vez. A escrita é o `saveCredential` que já existia, e é ele que **zera o bloqueio** (`failedAttempts`/`lockedUntil`/`lockStreak`): a senha nova não tem relação com as tentativas da antiga. As sessões em curso **não** são revogadas (decisão do plano): cortar o acesso na hora é a desativação. +- **`resetAcsMfa`:** apaga o segredo TOTP e o estado da ativação, para o ACS ativar de novo na entrada seguinte — é o caminho que a recusa "Peça a redefinição à coordenação" não tinha. A senha e o bloqueio **não** são tocados (redefinir a MFA não é desbloquear a conta). Idempotente. +- **`resetStaffMfa`:** só o administrador, e **nunca a própria conta** (a recusa compara ids **canônicos**, em minúsculas, porque o `UuidValue` e o `::uuid` aceitam a mesma conta grafada em maiúsculas). Zera as quatro colunas `totp*` **e** emite o código de ativação novo numa transação só, devolvendo-o uma única vez (`activationCodeIssuedBy` = o ator; `activationCodeHash` = SHA-256; validade de 24 h). Conta inexistente e conta inativa recebem a mesma mensagem. +- **Sem migração:** só modelos de API (`AdminAcs`, `AdminStaff`, `AdminAcsCreationResult`, `AdminPasswordResetResult`, `AdminStaffMfaResetResult`). **Aberto, nomeado:** criar coordenação e atribuir `ubsId` pela tela não existe; a troca de senha pelo próprio ACS não existe; os resets validam o alvo por leitura e escrevem por PK (TOCTOU residual); `enrollmentIdTaken` é global, não por UBS (oráculo de existência de matrícula entre UBS). + +**Escalonamento de alertas e monitoração do backoffice (`AdminEndpoint`, issue #44).** Migração com `alerts.escalatedAt/escalatedBy/escalationReason` (motivo fechado `EscalationReason`: `noAckTimeout`, `acsUnreachable`, `patientRecontact`). Escalonar **nunca** muda o `status`: o alerta segue `pending` (decisão D3) e só o ACK o resolve. +- **Prazo:** `RED_ALERT_ESCALATION_SECONDS` (`AppConfig.redAlertEscalationSeconds`), padrão 300, inteiro de 30 a 3600; fora da faixa falha no boot. A stack de e2e usa 30. "Vencido" é `now − triggeredAt > prazo`, **estritamente**, em um só SQL compartilhado (`infrastructure/database/alert_overdue_sql.dart`) usado por `AdminAlert.overdue`, `AdminAlertsPulse.overdueRed` e pelo escalonamento; o teste `acordo: …` de `admin_read_store_test.dart` pina os três usos. +- **`admin.escalateAlert(accessToken, alertId, reason)`** → `AdminEscalationResult{alertId, escalatedAt, alreadyEscalated}` (sem identidade). Papel/escopo do backoffice (`coordinator`/`admin`; coordenador só a UBS da microárea do alerta; alerta sem microárea só o administrador). Precedência fixa dos resultados, decidida no store sob trava de linha: `notFound` (inexistente ou fora do escopo, mesma resposta) > `notRed` > `notPending` > `alreadyEscalated` > `notDue`; as recusas viram `AdminInvalidRequestException` com `denied_not_found|not_red|not_pending|not_due|invalid_id` (auditadas com `record` pelo serviço). `UPDATE` + auditoria `write`/`admin_alert` + reforço no outbox numa **única** transação (fail-closed): `escalated:` e, no replay idempotente (original preservado, nada reescrito), `already_escalated:`. **Duas grafias do mesmo motivo, de propósito:** a coluna `alerts.escalationReason` guarda o nome do enum em camelCase (`noAckTimeout`) e o `result` da auditoria guarda snake_case (`escalated:no_ack_timeout`); uma consulta que cruze as duas não pode compará-las por igualdade. O reforço é **uma** reentrega ao tópico da microárea com `escalated: true` no envelope, sem identidade; alerta sem microárea escalona **sem reforço** e o resultado não avisa. Serviço: `AlertEscalationService`; store: `OrmAlertEscalationStore`. +- **Lista do backoffice:** `admin.alerts` devolve os **vencidos primeiro** (mesmo fragmento SQL, sem cópia da regra), para nenhum vermelho vencido ficar além da primeira página de 50 sem botão "Escalar" em tela nenhuma; o app mostra ainda "N vermelhos vencidos" a partir do `overdueRed` do pulso. Paginação real da lista segue aberta (`PROGRESS.md`). +- **Monitoração (`AdminMonitoringService`, D5):** `admin.alertsPulse` devolve `AdminAlertsPulse{indicators, overdueRed, maxChangedAt, serverNow}` e **não grava `read`**; a sessão de monitoramento fica num mapa em memória do processo (`userId → último pulso`, instância única), janela de 90 s. Sem sessão viva o pulso grava `monitoring_session_start` com `record` antes de ler (fail-closed); `admin.endMonitoringSession` grava `monitoring_session_end` com `recordSafely` (best-effort, idempotente). Dois primeiros pulsos concorrentes podem gravar dois START. `maxChangedAt` não se move quando um alerta sai do escopo; o app compensa comparando as contagens. Detalhe e alternativa descartada (MQTT): `docs/superpowers/specs/2026-10-11-adr-tempo-real-alertas-admin.md`. +- **ACK e expurgo:** `AlertStore.acknowledge` agora audita na **mesma** transação (fail-closed, também no ACK por MQTT). O `result` distingue a origem: `acknowledged` no ACK do RPC (`alerts.acknowledge`, token verificado) e `acknowledged_mqtt` no ACK que chega pelo broker (`lib/server.dart`, `deviceId` sentinela `RedAlertService.mqttAckDeviceId`), porque esse caminho não tem token e `acsId`/`microAreaId` vêm do payload — a trilha não o atribui como um ACK autenticado (autenticar o ACK por MQTT é aberto no `PROGRESS.md`). O expurgo LGPD (`ExpurgeService`) **pula** alerta vermelho `pending` mesmo vencido o prazo de retenção, conta em `ExpurgeCounts.skippedPendingRed` e loga sem identidade; o encerramento administrativo forçado é pendência (`PROGRESS.md`). O inventário `test/unit/red_alert_discard_paths_test.dart` varre `lib/` e falha se surgir outro escritor de `alerts` fora da lista; `test/integration/red_alert_discard_paths_db_test.dart` prova no banco. + +**Atendimento dos pedidos do titular (`AdminEndpoint`, issue #42).** `dataSubjectRequests` (lista paginada, vencidos primeiro), `dataSubjectRequest`, `startDataSubjectReview`, `completeDataSubjectRequest` e `rejectDataSubjectRequest`, todos com `accessToken`. O endpoint delega a `DataSubjectCaseService` (`application/admin/`, regras puras sobre `DataSubjectCaseStore`) e o store ORM (`OrmDataSubjectCaseStore`) decide em uma transação: lock por pedido, releitura do status, `UPDATE`, anonimização quando é exclusão e a linha de auditoria (`OrmAuditTrail.recordInTransaction`, `resourceType: data_subject_request`, `result: in_review | completed | rejected`). Papel e escopo como em `AdminReadService` (coordenador só da própria UBS; recusa fail-closed e mensagem genérica "Pedido não encontrado ou já decidido. Atualize a lista." para corrida e fora de escopo). A nota (3 a 500 caracteres) vai cifrada em `resolutionEncrypted` com a chave de `details` e nunca para log ou payload de push. Exclusão = anonimização: ver `spec/lgpd_design.md` §exclusão e `PROGRESS.md` ("Atendimento dos pedidos do titular"). Conta anonimizada (`cpfHash LIKE 'removed:%'`) tem a sessão recusada nos caminhos de paciente, exceto o alerta vermelho. O seed de e2e semeia os pedidos só com `E2E_SEED_DATA_REQUESTS=1` (ligado por `admin_titular_e2e.sh`), cifrando o texto com `encryptJson` como o código de produção. + 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. The seed runs in **four** steps. `development.sql` is plain SQL over `psql`, so it cannot produce the AES-256-GCM value that `patients.chronicConditionsEncrypted` now holds (Track E, RNF03/INV-04): it inserts the rows with an empty ciphertext, and the `health-data-seed` compose service then runs `bin/seed_health_data.dart`, which reuses `HealthDataCipher` to fill those columns. That service reuses the server image (`sinalacs/server:local`) precisely so both processes derive the key from the same `HEALTH_DATA_ENCRYPTION_KEY`. Pasting a fixed ciphertext into the `.sql` was the alternative, and was rejected because it duplicates the encryption logic outside Dart and breaks silently on any key or algorithm change. The third step is `acs-credential-seed` (RF07): `bin/seed_acs_credentials.dart` derives the Argon2id hash and writes the `user_credentials` row of the seeded ACS, for exactly the same reason — Postgres cannot derive Argon2id either — and a literal hash in the `.sql` was rejected by the same argument. It reuses the server image too, refuses to run unless `APP_ENV=development`, and takes the password from `DEV_ACS_PASSWORD`, which `bootstrap_env.sh` generates per machine and which is never versioned. The fourth step is `cpf-hash-seed` (RF01): `bin/seed_cpf_hashes.dart` computes the HMAC-SHA-256 of each seeded patient's CPF with `CPF_HASH_PEPPER` and writes `users.cpfHash` — the same argument once more, and this time the consequence is a login, not a field: the passwordless login looks the CPF up *by hash*, and `development.sql` **still writes a literal there** (`'development-patient'`, and one per seeded patient — lines ~19-30) that matches nothing, so without this step no seeded patient can log in. Who replaces those literals is the `UPDATE` of this step, on the patient rows only; the `.sql` keeps writing them, which is why anyone checking the file finds them and has to keep reading to find the fix. It reuses the server image as well, and the pepper **must** be the same value the server process gets, or no seeded CPF is ever found. The seeded ACS is deliberately left out of it: `verifyOtp` always issues `role: UserRole.patient`, so a CPF on the ACS row would let the passwordless login mint a *patient* token carrying the ACS's id and micro-area. @@ -55,7 +89,7 @@ The three clinical columns are encrypted at the ORM boundary only: `OrmPatientDi `backend/` is a Dart workspace with `sinalacs_server` (the server) and `sinalacs_client` (the generated typed client). Serverpod was the original stack decision recorded in `spec/stack.md`/`spec/PRD_system.md`; it was not implemented at first — the server was a hand-rolled `dart:io` HttpServer — and was adopted later, replacing it. The Dockerfile (`backend/sinalacs_server/Dockerfile`) is a multi-stage build: `dart compile exe` (AOT) on `dart:3.12.2` (bumped from `3.8.0` because the `postgres` package, a transitive/direct dependency, requires `sdk: '^3.9.0'`), copied into `alpine` with a non-root user, `curl` for the healthcheck, and a `HEALTHCHECK` that does `POST /health/check`. Layering under `backend/sinalacs_server/lib/src/`: -- `models/` — the schema as `.spy.yaml` model files: 16 tables, 6 enums (`RiskLevel`, `AlertStatus`, `SyncStatus`, `UserRole`, `ConsentPurpose`, `ArrivalMethod`), typed exceptions, and endpoint result types. `serverpod generate` turns these into Dart classes shared by server and client; `serverpod create-migration` turns them into SQL under `migrations/`. +- `models/` — the schema as `.spy.yaml` model files: 21 tables, 9 enums (`RiskLevel`, `AlertStatus`, `SyncStatus`, `UserRole`, `ConsentPurpose`, `ArrivalMethod`, `DataSubjectRequestType`, `DataSubjectRequestStatus`, `VisitAuthorship`), typed exceptions, and endpoint result types. `serverpod generate` turns these into Dart classes shared by server and client; `serverpod create-migration` turns them into SQL under `migrations/`. - `application/` — use-case services, unchanged by the migration: `alerts/red_alert_service.dart` (red alert creation/ack, idempotency, micro-area/role enforcement), `triage/triage_engine.dart` (deterministic symptom → `RiskLevel`, mirrors Manchester Protocol logic), `sync/sync_fsm.dart` (`idle → localWrite → queued → syncing → {synced|conflict|error}`), `auth/development_auth_service.dart` (dev-only HMAC tokens, **not** real institutional auth). - `infrastructure/` — `database/orm_alert_store.dart` (implements `AlertStore` over the Serverpod ORM), `database/seeds/development.sql`, `mqtt/mqtt_alert_dispatcher.dart` (implements `AlertPublisher`, publishes/subscribes per micro-area topic, handles ACK payloads). - `endpoints/` — the RPC surface. `runtime/alert_runtime.dart` holds process-scoped state (MQTT dispatcher, config), because endpoints are constructed per request. @@ -65,7 +99,9 @@ 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), `auth.loginInstitutional` (o login real do ACS, RF07: matrícula + senha verificadas com Argon2id contra `user_credentials`, sem guardar a senha em nenhuma forma — só o hash com seu salt e os parâmetros de custo; bloqueia a conta por 15 minutos após 5 tentativas falhas, que é o rate limiting do achado F6 de `spec/security_assessment.md`, e audita cada desfecho em `audit_logs`; matrícula inexistente e senha errada devolvem a mesma mensagem), `auth.requestOtp`/`auth.verifyOtp` (o login passwordless do paciente, RF01: CPF validado por dígito verificador e hasheado no servidor em `users.cpfHash` com `CPF_HASH_PEPPER`, mais a data de nascimento; código de 6 dígitos, com TTL de 5 minutos, teto de 5 verificações e 60 s entre pedidos, guardado em `otp_challenges` **como HMAC** — o código em claro existe só entre a geração e o envio — e uma linha de auditoria por desfecho. `requestOtp` responde a mesma coisa exista ou não o CPF — mesmo payload, mesmo status, sem linha de desafio e sem SMS na recusa —, mas **não** equaliza o *tempo* de resposta: as lacunas do RF01 estão no `PROGRESS.md`. `verifyOtp` emite a sessão do paciente com `patientSessionLifetime` (**1 hora**, LGPD-RT06) e não com os 15 minutos padrão, que o ACS mantém porque a renovação dele é silenciosa — um código OTP não pode ser reapresentado; o caminho de onboarding ainda emite os 15 minutos, defeito do RF02 registrado com dono no `PROGRESS.md`), `onboarding.generateEnrollmentToken`/`onboarding.completeEnrollment` (o convite de uso único que o ACS gera e a conclusão dele pelo paciente; `completeEnrollment` também emite sessão, e é o caminho do defeito do RF02 acima), `alerts.createRedAlert` (idempotency key is a method parameter, not a header; throws `AlertDispatchUnavailableException` if the MQTT dispatcher isn't connected), `alerts.acknowledge`, `triage.evaluate` (exige `accessToken`; classifica pelo `TriageEngine` determinístico, grava a sessão em `triage_sessions` com o `patientId` vindo do token — nunca de parâmetro, INV-05 — e escreve uma linha `write`/`triage_session` em `audit_logs`; só o papel `patient` é aceito, um ACS recebe `AlertPermissionException`), `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. Toda decisão de papel e de presença de território no token passa por uma regra +**MQTT do backend e outbox (#56).** `MqttAlertDispatcher` roda cada `client.connect` numa zona própria por cliente, derivada de `Zone.root`: o erro assíncrono do `Socket.done` do TLS (que antes ia sem dono para a zona do `connect` e matava o processo, saída 255) vira queda de conexão tratada, com reconexão e backoff. Política: `SocketException`, `TlsException`, `TimeoutException`, `ConnectionException`, `NoConnectionException` e `StateError('socket is null')` são **transitórios** (descarta o cliente; `publish` lança `MqttUnavailableException`); qualquer outro é **fatal** (registra só o tipo e chama `onFatal`, padrão `exit(70)`). Injetáveis nos testes: `initialBackoff`, `maxBackoff`, `publishAckTimeout`, `onFatal`, `log`. `AlertPublisher.publish` é `Future` e só completa no PUBACK do broker (timeout 10 s; PINGRESP também 10 s); o outbox só grava `publishedAt` depois disso. `bin/main.dart` tem um `runZonedGuarded` global (`runtime/uncaught_error_policy.dart`) como defesa em profundidade, e o `serverpod` do Compose tem `restart: unless-stopped`. Logs só com tipo e `errno` (VAZ-01). Testes: `dart test -t resiliencia` (broker falso em TCP real). QA manual e opcional, fora da CI, no Docker/TLS: `./scripts/qa/mqtt_resiliencia_e2e.sh` (base, B reinício do broker, C partição; o A, `docker pause` do backend, só com `--com-pausa-do-backend`). Evidências, decisões e riscos residuais: `docs/superpowers/plans/2026-10-12-issue-56-evidencias.md`. + +**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), `auth.loginInstitutional` (o login real do ACS, RF07: matrícula + senha verificadas com Argon2id contra `user_credentials`, sem guardar a senha em nenhuma forma — só o hash com seu salt e os parâmetros de custo; bloqueia a conta após 5 tentativas falhas — 15 minutos na primeira rodada e o dobro a cada rodada seguinte sem um login válido no meio, até 24 h (`lockStreak`, `InstitutionalAuthService.lockDurationFor`) —, que é o rate limiting do achado F6 de `spec/security_assessment.md`, e audita cada desfecho em `audit_logs`; matrícula inexistente e senha errada devolvem a mesma mensagem), `auth.refreshSession`/`auth.logout` (o refresh token do ACS, LGPD-RT06; `loginInstitutional` só emite `refreshToken` quando o cliente envia um `deviceId` não-branco — sem ele o resultado traz `refreshToken` nulo e o JWT de 15 minutos segue valendo sozinho. O token guarda-se em `acs_refresh_tokens` **só como hash SHA-256**, com família, aparelho, `rotatedAt`/`revokedAt` — o `deviceId` é o identificador da instalação (UUID aleatório), não uma atestação de hardware: protege contra token vazado sem ele, não contra comprometimento do aparelho; constantes em `RefreshTokenService`: janela ociosa de 2 h (`idleWindow`), teto absoluto de 8 h contado do login por senha + TOTP (`absoluteWindow`; a rotação não o estende) e tolerância de reuso de 30 s (`reuseGrace`, para a resposta perdida em rede: reapresentar o pai dentro dela devolve um filho e audita `refresh_granted_grace`). Reuso fora da tolerância, aparelho diferente, conta inativa ou sem microárea **revogam a família inteira**, e toda recusa devolve a mesma `SessionExpiredException` com a mensagem única `Sessão expirada. Entre novamente.` (sem dizer o motivo). A microárea do JWT novo é relida do banco a cada refresh, nunca copiada do token antigo. A rotação é um INSERT condicional atômico e a revogação da família roda em duas passagens, para que dois refreshes concorrentes não gerem dois filhos vivos. Cada desfecho vira uma linha `session_refresh` em `audit_logs` (`refresh_granted`, `refresh_granted_grace`, `denied_*`). Os dois métodos são públicos por desenho — o token de acesso já venceu quando o primeiro é chamado — e estão na allowlist fina de `endpoint_auth_posture_test.dart`. `logout` revoga a família, audita `logout` e ignora em silêncio um token que não conhece. O login só poda os tokens expirados do próprio usuário: não há limpeza periódica da tabela), `auth.requestOtp`/`auth.verifyOtp` (o login passwordless do paciente, RF01: CPF validado por dígito verificador e hasheado no servidor em `users.cpfHash` com `CPF_HASH_PEPPER`, mais a data de nascimento; código de 6 dígitos, com TTL de 5 minutos, teto de 5 verificações e 60 s entre pedidos, guardado em `otp_challenges` **como HMAC** — o código em claro existe só entre a geração e o envio — e uma linha de auditoria por desfecho. `requestOtp` responde a mesma coisa exista ou não o CPF — mesmo payload, mesmo status, sem linha de desafio e sem SMS na recusa —, mas **não** equaliza o *tempo* de resposta: as lacunas do RF01 estão no `PROGRESS.md`. `verifyOtp` emite a sessão do paciente com `patientSessionLifetime` (**1 hora**, LGPD-RT06) e não com os 15 minutos padrão, que o ACS mantém porque a renovação dele é feita pelo refresh token (abaixo) — o paciente não tem refresh token e um código OTP não pode ser reapresentado; o caminho de onboarding emite o mesmo TTL — o defeito dos 15 minutos do RF02 foi corrigido, ver `PROGRESS.md`), `onboarding.generateEnrollmentToken`/`onboarding.completeEnrollment` (o convite de uso único que o ACS gera e a conclusão dele pelo paciente; `completeEnrollment` também emite sessão, com `patientSessionLifetime`), `patients.acceptTermsOfUse` (aceite do Termo de Uso e da Política de Privacidade vigentes por quem entrou por OTP sem onboarding, LGPD-RF18; única via de escrita de `termsOfUse` fora do cadastro; idempotente — com o aceite vigente já gravado devolve a linha existente) e `patients.hasAcceptedCurrentTerms` (o `bool` que o login consulta, sem ler o painel nem auditar leitura), `patients.hasGrantedConsent` (o mesmo desenho para uma finalidade: `true` se a decisão mais recente é `granted`; o app a consulta a cada login antes de pedir o token de push ao FCM); a criação do pedido de exclusão (`patients.requestDataDeletion`) é serializada por `pg_advisory_xact_lock` por titular (o aceite do termo também, e o registro de token de push por token: `lockPerSubject` em `infrastructure/database/subject_lock.dart`, forma de duas chaves `(namespace, hashtext)`, que não colide com a chave única da cadeia de auditoria), `notices.sendSegmented` (RF14: só ACS, a microárea vem do token; a consulta SQL em `OrmNoticeRecipientStore` usa o consentimento `segmentedPush` **mais recente** de cada titular, não a existência do token; entrega a lista ao Gorush por `GorushClient` — `dart:io`, tempo limite de 5 s no total e de 2 s só na **conexão** (estourar na conexão é "inacessível, tente de novo", porque nada saiu; estourar depois de o pedido sair é "resultado desconhecido", e corpo 2xx ilegível idem), um único cliente por processo encerrado em `overrideConfig`, `GORUSH_URL` vazio desliga o envio e a chamada falha com `NoticeDeliveryException`; tokens que o provedor declara inválidos são apagados; audita `community_notice` com a microárea e nunca o texto), `patients.termsChangeNotice` (aviso de mudança dos termos com 15 dias de antecedência, LGPD-RF18: só paciente e sem auditoria; desde a #46 lê as versões publicadas de `terms_versions` (`TermsVersionStore.currentPublished`), não faz mais "sem I/O"; a constante `upcomingTermsChange` em `terms_change_schedule.dart` é só um espelho `null` mantido por dois testes, não é mais a agenda, e `TermsChangeSchedule` se recusa (com `ArgumentError`, que vale em release, não `assert`) a existir com menos de 15 dias entre as datas de publicação e de vigência declaradas — não prova que o aviso chegou a alguém, um `publishedAt` retroativo passa — para anunciar uma mudança: (1) escreva o texto novo em `upcomingLegalDocuments` no app e publique essa versão do app, (2) só então troque `upcomingTermsChange` aqui — um teste do app (`upcoming_legal_documents_test.dart`) falha se as duas pontas divergirem —, (3) na vigência mude `consentPolicyVersion` e `legalDocumentsVersion`, mova o texto para `privacyPolicy`/`termsOfUse` e volte as duas constantes para `null`; um app antigo, sem o texto embarcado, vê só "Ler os termos atuais" durante os 15 dias), `devices.registerPushToken` (RF14: só paciente, só com o consentimento `segmentedPush` vigente, uma linha por token em `push_tokens`; revogar o consentimento apaga os tokens do titular na MESMA transação do `denied`, sob o lock por titular; no máximo 10 tokens por titular, o mais antigo sai; só a troca de dono do token é auditada, com uma linha para o novo dono e outra para o anterior; a poda apaga por id **e** titular e poupa o token recém-gravado; o consentimento mais recente desempata por `id`), `alerts.createRedAlert` (idempotency key is a method parameter, not a header; throws `AlertDispatchUnavailableException` if the MQTT dispatcher isn't connected), `alerts.acknowledge`, `triage.evaluate` (exige `accessToken`; classifica pelo `TriageEngine` determinístico, grava a sessão em `triage_sessions` com o `patientId` vindo do token — nunca de parâmetro, INV-05 — e escreve uma linha `write`/`triage_session` em `audit_logs`; só o papel `patient` é aceito, um ACS recebe `AlertPermissionException`), `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; a visit with no author — `acsId` null, `authorship = legacyUnclaimed` — is also `rejected` there, never claimed), `visits.syncLegacy` (D4 of the 2026-10-03 plan: uploads the device's pre-owner legacy visits with **unknown authorship** — the ACS session is only the transport, territory-checked like `sync`, idempotent by `localId` from the same `originDeviceId`, refuses an inactive ACS, never writes the transporter as `acsId`, at most `VisitSyncService.maxLegacyBatch` (200) visits per call, one `visit_legacy_sync` audit row per batch plus one `visit_legacy_sync_item` row per visit actually created or changed (only the visit id, no clinical content); a re-send of an authored `localId` at the same version is a no-op `synced`, at another version `rejected`), `visits.syncDeferred`/`visits.revokeUploadToken` (D7 of the 2026-10-03 plan: the **deferred upload token** of `UploadTokenService` (`application/auth/upload_token_service.dart`, store `OrmUploadTokenStore`) lets the device upload the pending visits of an ACS who already logged out, **with that ACS's authorship** and no session. `auth.loginInstitutional` issues it as `uploadToken` next to the refresh token, under the same rule — only with a non-blank `deviceId`, otherwise `null`; patient logins and `developmentLogin` never return it. 256 random bits, stored in `acs_upload_tokens` **only as SHA-256** (helpers shared with the refresh token in `opaque_token.dart`), bound to user + device, fixed 7-day life (`UploadTokenService.lifetime`), no rotation, independent of the refresh family (logout does not touch it); a new login on the same (user, device) revokes the previous one, inside a transaction serialized by `lockPerSubject` (namespace 5). `resolve` returns the **token owner** as ACS with the micro-area **re-read from the database** on every call, and every refusal — unknown, expired, revoked, other device, inactive account, no micro-area — is the same `SessionExpiredException` with `UploadTokenService.deniedMessage` (`Envio não autorizado. Entre novamente.`); other device and inactive/no micro-area also revoke the token. `syncDeferred` checks the batch cap `VisitSyncService.maxLegacyBatch` (200) before anything else (`AlertValidationException`), resolves the token outside the batch transaction and runs the **same** `VisitSyncService.sync` as `visits.sync` — so author = owner, territory = owner's current one, another ACS's `localId` `rejected`. Audit rows in `audit_logs`, never with the token: `upload_token` / `upload_token_issued`, `upload_token_denied_{revoked,device,expired,inactive}` (unknown token → no row, no subject), `upload_token_revoked`, and one `visit_deferred` / `visit_deferred_sync` per committed batch. Both methods are public by design for `authenticate(...)` and sit in the allowlist of `endpoint_auth_posture_test.dart`. Still open, by design or deferred (`PROGRESS.md`, section "Fila de visitas por dono e corrida do login"): `visits.sync`/`pull` accept an inactive ACS until the JWT expires (`isActiveAcs` is only checked by `syncLegacy`; `syncDeferred` re-checks it through the token); no database CHECK ties a null `acsId` to `authorship = legacyUnclaimed`; the existing `visits.sync` has no batch cap; a `localId` existence oracle remains, identical to `visits.sync`'s; `uploadToken`/`refreshToken` are plain RPC arguments, so any future argument logging must scrub them, see VAZ-03 in `spec/lgpd_data_audit.md`), 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). `bin/seed_e2e_fixtures.dart` seeds the e2e stack (`docker-compose.e2e.yml`, see `apps/CLAUDE.md`): synthetic UUIDs/CPFs generated per run by `lib/src/infrastructure/testing/e2e_fixtures.dart`, written **only** to the database `sinalacs_e2e` (it refuses any other name and any `APP_ENV` but `development`); `development.sql` and its `*-seed` services stay for the plain development stack. 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. Toda decisão de papel e de presença de território no token passa por uma regra única, `Authorization.require` (`lib/src/application/auth/authorization.dart`): o chamador decide o que lançar (`StateError` nos serviços territoriais, `TriageAuthorizationException` na triagem), e é isso que preserva a tradução @@ -74,3 +110,19 @@ microárea **do paciente** e a do ACS **não** passa por ela e continua inline e cada caso de uso (`onboarding_service.dart:112-113`, `visit_sync_service.dart:252-262`), porque a guarda nunca vê o paciente. Os endpoints com postura integralmente autenticada estendem `AuthenticatedEndpoint` (`lib/src/endpoints/authenticated_endpoint.dart`), que centraliza a verificação do token; `test/unit/endpoint_auth_posture_test.dart` lê o texto-fonte de `lib/src/endpoints/` e falha se um endpoint novo não estender a base nem constar da allowlist justificada. `requireLogin` continua `false` em todos: o `AuthenticationHandler` do Serverpod não está conectado. + +**Avisos agendados e versões dos termos (`AdminEndpoint`, issue #46).** Sete métodos novos, todos `(Session, {required String accessToken, …})`: `admin.scheduleNotice`, `admin.cancelScheduledNotice`, `admin.noticeHistory` (avisos; coordenador e admin, o coordenador só na microárea da própria UBS) e `admin.termsVersions`, `admin.draftTermsVersion`, `admin.markTermsReviewed`, `admin.publishTermsVersion` (termos; só admin). Duas tabelas novas: `scheduled_notices` (ciclo `pending → sending → sent | failed | unknown | cancelled`, `ScheduledNoticeStatus`) e `terms_versions` (rascunho → revisado juridicamente → publicado). +- **Avisos:** `NoticeSchedulingService` (`application/notices/`) sobre `ScheduledNoticeStore` (`OrmScheduledNoticeStore`). A reserva `pending → sending` é um `UPDATE` atômico, então um aviso nunca sai duas vezes. O disparo reaproveita `NoticeService.sendToMicroArea`, a mesma auditoria do envio imediato. O histórico não devolve o texto do aviso e a trilha não o grava. Negações gravam `denied_role`/`denied_territory` antes de lançar. +- **Laço:** `NoticeDispatchLoop` (`runtime/notice_dispatch_loop.dart`), iniciado em `lib/server.dart`: `Timer.periodic` de 30 s, sem sobrepor ciclos, nunca morre por erro; na partida roda `recoverStuckSending()`, que leva a `unknown` o aviso preso em `sending` há mais de 10 min (por `scheduledFor`, sem coluna `claimedAt`; instância única). +- **Disparo reconfere o autor.** `dispatchDue` lê a autoridade ATUAL de quem agendou (`ScheduledNoticeStore.authorityOf`: `users.role` ⨝ `staff_accounts.active/ubsId`) antes de enviar. Sem conta de staff, desativado, papel fora de `coordinator`/`admin` ou coordenador cuja UBS já não é a da microárea → o aviso não sai, vai a `failed` com `noticeAuthorRevokedReason` e grava `community_notice`/`denied_author_revoked` (autor, id do aviso; `recordSafely`). Sem migração: reaproveita `failed`. O e2e (`scripts/qa/e2e_stack.sh schedule-notice`) agenda pelo endpoint real com `apps/admin/tool/schedule_notice.dart` (login de staff com MFA/TOTP + `admin.scheduleNotice`). +- **`NoticesEndpoint.sendSegmented` segue ACS-imediato:** só papel `acs`, microárea vinda do token. Agendar é outro caminho, do backoffice. +- **Auditoria fail-closed (convenção dos dois serviços):** `NoticeSchedulingService` e `TermsPublicationService` usam `AuditTrail.record`, nunca `recordSafely`, como o backoffice da #40/#43: a negação grava `denied_*` antes de lançar (se a gravação falha, a exceção sobe e a operação continua negada), a leitura audita antes de consultar o store e a escrita bem-sucedida audita depois do commit. `recordSafely` fica só no caminho de disparo e de partida (`dispatchDue`, os resultados de envio, o `failed` e `unknown_after_restart` da varredura), porque um envio já feito não se desfaz e a partida do servidor não pode ser impedida pela trilha. Identificadores do cliente que não são UUID viram `AdminInvalidRequestException('Identificador inválido.')` depois da autorização de papel e antes do store (`isUuidFormat`). +- **Termos:** `TermsPublicationService` (`application/patients/`) exige revisão jurídica registrada e vigência com pelo menos 15 dias de antecedência (`termsChangeNoticePeriod`); recusas gravam `denied_not_reviewed`/`denied_notice_period`/`denied_role`. A agenda que o app do paciente lê em `patients.termsChangeNotice` agora vem das linhas publicadas de `terms_versions` (`TermsVersionStore.currentPublished`), **não mais da constante** `upcomingTermsChange` (mantida `null` como espelho; dois testes ainda a leem: `test/unit/terms_change_schedule_test.dart` e `apps/patient/test/upcoming_legal_documents_test.dart`). + +**Trilha de auditoria do backoffice (issue #45).** Contratos (`AdminEndpoint`, sempre papel → escopo → validação → auditoria → dado): `admin.auditLogs(accessToken, AdminAuditFilter? filter, limit = 50, beforeSequence)` → `AdminAuditPage{items, nextBeforeSequence?}` (`AdminAuditEntry` ganhou `integrity: AuditRowSeal`); `admin.verifyAuditChain(accessToken)` → `AdminAuditChainStatus{state, checkedRows?, headSequence?, firstBrokenSequence?, brokenInScope?, reasonCode?, verifiedAt}`; `admin.exportAuditLogs(accessToken, filter)` → `AdminAuditExport{fileName, csv, rowCount, firstSequence?, lastSequence?, contentSha256, chainState, generatedAt}`. Nenhuma coluna da cadeia mudou; o cliente gerado só ganhou adições. Decisões como implementadas: +- **D1/D2 filtro** (`AdminAuditFilter`, validado em `AdminReadService.validarFiltroDeAuditoria`): matrícula (igualdade sem caixa), `actorRole`, `actionType` (`read|write|login|export`), `resourceType` e `result` com regex `^[a-z0-9_:]{1,64}$` — **com `:`** porque os resultados reais da #44 são `escalated:no_ack_timeout` —, `from`/`to` UTC (`to` exclusivo, `from < to`, janela ≤ 366 dias), `search` 2–64 caracteres. `search` casa **só** início da matrícula, `resourceType` e `result` (`ILIKE` com `%`/`_`/`\` escapados por `_escaparLike`, parâmetros nomeados, nunca interpolação); nunca `ipHash`, hash, UUID ou nome. Só `from` ou só `to` na **consulta** é janela sem limite (aceito); na **exportação** os dois são obrigatórios. Sem filtro por `resourceId` exato (D10, aprovado): o UUID de recurso pode ser id de paciente. O paciente só é filtrável por `actorRole = patient`. +- **D3 estados e selo por linha.** `AuditIntegrityState {verified, broken, unavailable, inconclusive}` e `AuditRowSeal {verified, invalid, unverifiable}`; o selo vem de `AuditRowSealer` (mesma `AuditChain` que grava) e **olha só para trás**: a linha 51 continua `verified` com a 50 adulterada (provado no e2e) — a quebra a jusante só aparece no painel global. HMAC que não bate é `invalid` mesmo sem vizinho; vizinho anterior ausente é `unverifiable`. Só `verified` é verde; propriedade negativa parametrizada sobre todos os `AuditChainReason` garante que nenhum motivo vira `verified`. `AuditChainReason {hashMismatch, linkBroken, sequenceGap, tailTruncated, timeBudget, databaseError, secretUnavailable, checkpointInvalid}`; o texto livre do verificador não vai ao cliente. +- **D9 verificação global** (`AuditChainStatusService`): `resolve` (recusa = `denied` auditado) e `record(read/admin_audit_chain)` **antes** de verificar, também com cache. Single-flight por processo, orçamento 10 s (`inconclusive/timeBudget`), timeout por lote 10 s, cache de 60 s no `AlertRuntime` (inclusive de falhas), `Exception` **ou** `Error` ⇒ `unavailable/databaseError`, nunca 500 nem `verified`. O admin vê o status completo; o coordenador vê o estado sempre, mas `firstBrokenSequence`/`reasonCode` da quebra só se a linha pertence à UBS dele (`auditRowInUbs`), senão `brokenInScope=false`, e nunca `checkedRows`/`headSequence` (`checkedRows`/`brokenInScope` são anuláveis; nulo = "não informado", nunca false). O coordenador recebe `reasonCode` só em `unavailable` ou `checkpointInvalid`; em `inconclusive` por `timeBudget` recebe `state=inconclusive` sem `reasonCode` (o motivo revelaria o tamanho da cadeia global); o admin recebe tudo. +- **D4 checkpoint de cauda** (`audit_chain_checkpoints`, `OrmAuditCheckpointStore`): cada verificação `verified` grava `(headSequence, headEntryHash)` com `signature = HMAC(secret, "checkpoint" U+001F sequence U+001F entryHash)` (`AuditChain.computeCheckpointSignature`); só avança (`pg_advisory_xact_lock` + `NOT EXISTS`/`ON CONFLICT`). A verificação seguinte falha com `broken/tailTruncated` se `entryAt(sequence)` do checkpoint válido mais novo sumiu ou tem outro `entryHash`; assinatura inválida ⇒ `inconclusive/checkpointInvalid` (ignorado, não apagado). Só os 100 checkpoints mais recentes são conferidos; a tabela cresce até 1 linha/min/processo e não tem poda. **Limites:** quem apaga a cauda **e** os checkpoints não é detectado (reduz, não elimina); restaurar backup inteiro (cadeia + checkpoints) passa como íntegro; restauração **parcial** de `audit_logs` dá `broken` permanente e não há operação auditada de re-baseline; falha ao gravar o checkpoint só vai ao log (tipo do erro) e não muda o `verified`; a CLI `bin/audit_chain_check.dart` **não** lê nem grava checkpoints (não vê truncamento de cauda); `tailTruncated` não expõe `sequence` ao coordenador (a linha não existe, `brokenInScope=false`). Rotacionar `AUDIT_CHAIN_SECRET` invalida toda a história (parece adulteração em massa): não há `keyVersion` na linha. +- **D5–D8 exportação** (`AuditExportService`, só `admin`; coordenador/ACS/paciente ⇒ `AlertPermissionException` + linha `export/admin_audit_export/denied`, sem leitura): ordem `requireAdmin` → período obrigatório (`from` **e** `to`) + validação (sem auditar) → `headSequence` → contagem com teto `maxRows = 5000` (acima: `record(export/refused_limit)` + `AdminInvalidRequestException`, que também serve ao filtro inválido — exceção própria do teto é pendência) → leitura `sequence <= head` (a exportação não exporta a si mesma) → `AuditCsv.build` (nove colunas por igualdade: `sequence, timestamp_utc, autor, papel_autor, acao, recurso, rotulo_recurso, resultado, selo`; sem `userId`/`resourceId`/IP/hash/segredo; células que parecem fórmula são neutralizadas) + SHA-256 → `chainState` → **uma transação** com `audit_exports{requestedBy, requestedAt, filtersJson, format, rowCount, firstSequence, lastSequence, contentSha256, chainState}` + evento `export/admin_audit_logs/success` com `resourceId = audit_exports.id` (dentro do HMAC) → só então devolve o CSV. Se a transação falha, nada sai e grava-se `failed` fora dela; resposta perdida depois do commit deixa evento sem entrega (aceito: erra para auditar a mais). `chainState` vem de **verificação fresca** (`currentState(notBefore: início da exportação)`, não do cache de 60 s), mas atesta a cadeia no instante da verificação, não no commit; cada exportação paga uma verificação completa (N simultâneas = N verificações; só admin). Falha em qualquer etapa ⇒ `AuditExportFailedException`, exceção Dart **genérica** (500 sem mensagem, não vaza texto do banco); o app mapeia para texto fixo. `filtersJson` guarda só os parâmetros do filtro, nunca linhas; `search`/matrícula digitados são dado pessoal do staff e não vão a log. `audit_exports` fica **fora** da cadeia (só o `resourceId` do evento a liga): editá-la direto não é detectado. Critérios de código: `grep -rn "auditChainSecret" lib/src/models lib/src/endpoints` e `grep -rn "entryHash\|ipHash" lib/src/application/admin/audit_export_service.dart` devem ser vazios. +- **Escrita e e2e.** O seed opt-in `E2E_SEED_AUDIT_TRAIL=1` (`bin/seed_e2e_fixtures.dart`) encadeia 60 linhas direto com `AuditChain` + `RotatingIpHasher` (não `OrmAuditTrail`, que carimba `DateTime.now()` e exige `Session`); `scripts/qa/admin_auditoria_e2e.sh` adultera/restaura só em `sinalacs_e2e` e confere o CLI (`exit 1` adulterada, `exit 0` restaurada). Cadeia detecta, não impede: sem trigger/`REVOKE` append-only, quem escreve no Postgres **e** tem o segredo reescreve tudo. Verificação é O(n) (~22 µs/linha medidos; 10 s cobrem ~440 mil linhas); acima disso é preciso verificação incremental por checkpoint. Abrir a Auditoria grava 2 linhas (`admin_audit_logs` + `admin_audit_chain`). Rode os testes de integração com `dart test -j 1`. diff --git a/backend/DEPLOY.md b/backend/DEPLOY.md index 3ec42baa..1539955c 100644 --- a/backend/DEPLOY.md +++ b/backend/DEPLOY.md @@ -88,6 +88,8 @@ 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) | +> Com `APP_ENV=production` e `MQTT_USE_TLS=true` o servidor agora exige `MQTT_CLIENT_CERT_PATH` e `MQTT_CLIENT_KEY_PATH` (mTLS). Com `ENABLE_DEV_LOGIN=true` a MFA do ACS é contornada neste caminho de demonstração (decisão preexistente). + > **TLS (RNF04).** O container **não** atende RNF04 sozinho: o Serverpod continua > escutando em **texto claro** na porta que o Render injeta em `PORT`, e quem > termina TLS é o proxy do Render, na borda — por isso a URL do serviço é @@ -170,6 +172,7 @@ que roda contra o harness local. ## Limitações conhecidas deste piloto +- A política de reinício do processo (o `restart: unless-stopped` do `docker-compose.yml` vale só para o Compose local) é responsabilidade do hospedeiro. - Render free hiberna após inatividade — o primeiro request após acordar tem latência alta (cold start); considere um ping externo periódico em `/health/check` durante janelas de demonstração. diff --git a/backend/sinalacs_client/lib/src/protocol/acs_refresh_token.dart b/backend/sinalacs_client/lib/src/protocol/acs_refresh_token.dart new file mode 100644 index 00000000..9cf2c6d6 --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/acs_refresh_token.dart @@ -0,0 +1,202 @@ +/* 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; + +/// Refresh token do ACS (LGPD-RT06). Uma linha por token emitido; os tokens de +/// um mesmo login formam uma *família* (`familyId`) e a rotação encadeia +/// dentro dela. +/// +/// **Nunca** o token: só o SHA-256 dele em hex. O token é 256 bits aleatórios, +/// então um hash simples basta (não há senha humana para adivinhar). +abstract class AcsRefreshToken implements _i1.SerializableModel { + AcsRefreshToken._({ + this.id, + required this.userId, + required this.familyId, + required this.tokenHash, + required this.deviceId, + required this.issuedAt, + required this.idleExpiresAt, + required this.absoluteExpiresAt, + this.rotatedAt, + this.revokedAt, + }); + + factory AcsRefreshToken({ + _i1.UuidValue? id, + required _i1.UuidValue userId, + required _i1.UuidValue familyId, + required String tokenHash, + required String deviceId, + required DateTime issuedAt, + required DateTime idleExpiresAt, + required DateTime absoluteExpiresAt, + DateTime? rotatedAt, + DateTime? revokedAt, + }) = _AcsRefreshTokenImpl; + + factory AcsRefreshToken.fromJson(Map jsonSerialization) { + return AcsRefreshToken( + id: jsonSerialization['id'] == null + ? null + : _i1.UuidValueJsonExtension.fromJson(jsonSerialization['id']), + userId: _i1.UuidValueJsonExtension.fromJson(jsonSerialization['userId']), + familyId: _i1.UuidValueJsonExtension.fromJson( + jsonSerialization['familyId'], + ), + tokenHash: jsonSerialization['tokenHash'] as String, + deviceId: jsonSerialization['deviceId'] as String, + issuedAt: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['issuedAt'], + ), + idleExpiresAt: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['idleExpiresAt'], + ), + absoluteExpiresAt: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['absoluteExpiresAt'], + ), + rotatedAt: jsonSerialization['rotatedAt'] == null + ? null + : _i1.DateTimeJsonExtension.fromJson(jsonSerialization['rotatedAt']), + revokedAt: jsonSerialization['revokedAt'] == null + ? null + : _i1.DateTimeJsonExtension.fromJson(jsonSerialization['revokedAt']), + ); + } + + /// The database id, set if the object has been inserted into the + /// database or if it has been fetched from the database. Otherwise, + /// the id will be null. + _i1.UuidValue? id; + + _i1.UuidValue userId; + + /// Todos os tokens nascidos do mesmo login por senha+TOTP. + _i1.UuidValue familyId; + + String tokenHash; + + /// Aparelho que recebeu o token. Token apresentado por outro aparelho + /// revoga a família. + String deviceId; + + DateTime issuedAt; + + /// Janela ociosa: renovada a cada rotação, nunca além de `absoluteExpiresAt`. + DateTime idleExpiresAt; + + /// Teto do turno, fixado no login por senha+TOTP e herdado pelos filhos. + DateTime absoluteExpiresAt; + + /// `null` = ainda não usado. Preenchido na rotação. + DateTime? rotatedAt; + + /// `null` = vigente. Preenchido na revogação da família ou no logout. + DateTime? revokedAt; + + /// Returns a shallow copy of this [AcsRefreshToken] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + AcsRefreshToken copyWith({ + _i1.UuidValue? id, + _i1.UuidValue? userId, + _i1.UuidValue? familyId, + String? tokenHash, + String? deviceId, + DateTime? issuedAt, + DateTime? idleExpiresAt, + DateTime? absoluteExpiresAt, + DateTime? rotatedAt, + DateTime? revokedAt, + }); + @override + Map toJson() { + return { + '__className__': 'AcsRefreshToken', + if (id != null) 'id': id?.toJson(), + 'userId': userId.toJson(), + 'familyId': familyId.toJson(), + 'tokenHash': tokenHash, + 'deviceId': deviceId, + 'issuedAt': issuedAt.toJson(), + 'idleExpiresAt': idleExpiresAt.toJson(), + 'absoluteExpiresAt': absoluteExpiresAt.toJson(), + if (rotatedAt != null) 'rotatedAt': rotatedAt?.toJson(), + if (revokedAt != null) 'revokedAt': revokedAt?.toJson(), + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _AcsRefreshTokenImpl extends AcsRefreshToken { + _AcsRefreshTokenImpl({ + _i1.UuidValue? id, + required _i1.UuidValue userId, + required _i1.UuidValue familyId, + required String tokenHash, + required String deviceId, + required DateTime issuedAt, + required DateTime idleExpiresAt, + required DateTime absoluteExpiresAt, + DateTime? rotatedAt, + DateTime? revokedAt, + }) : super._( + id: id, + userId: userId, + familyId: familyId, + tokenHash: tokenHash, + deviceId: deviceId, + issuedAt: issuedAt, + idleExpiresAt: idleExpiresAt, + absoluteExpiresAt: absoluteExpiresAt, + rotatedAt: rotatedAt, + revokedAt: revokedAt, + ); + + /// Returns a shallow copy of this [AcsRefreshToken] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + AcsRefreshToken copyWith({ + Object? id = _Undefined, + _i1.UuidValue? userId, + _i1.UuidValue? familyId, + String? tokenHash, + String? deviceId, + DateTime? issuedAt, + DateTime? idleExpiresAt, + DateTime? absoluteExpiresAt, + Object? rotatedAt = _Undefined, + Object? revokedAt = _Undefined, + }) { + return AcsRefreshToken( + id: id is _i1.UuidValue? ? id : this.id, + userId: userId ?? this.userId, + familyId: familyId ?? this.familyId, + tokenHash: tokenHash ?? this.tokenHash, + deviceId: deviceId ?? this.deviceId, + issuedAt: issuedAt ?? this.issuedAt, + idleExpiresAt: idleExpiresAt ?? this.idleExpiresAt, + absoluteExpiresAt: absoluteExpiresAt ?? this.absoluteExpiresAt, + rotatedAt: rotatedAt is DateTime? ? rotatedAt : this.rotatedAt, + revokedAt: revokedAt is DateTime? ? revokedAt : this.revokedAt, + ); + } +} diff --git a/backend/sinalacs_client/lib/src/protocol/acs_upload_token.dart b/backend/sinalacs_client/lib/src/protocol/acs_upload_token.dart new file mode 100644 index 00000000..c714ffab --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/acs_upload_token.dart @@ -0,0 +1,163 @@ +/* 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; + +/// Token de envio diferido do ACS (D7 do plano 2026-10-03). Deixa o aparelho +/// subir as visitas pendentes de um ACS que já saiu — com a autoria DELE — +/// sem sessão e sem nenhum outro poder: o único uso é `visits.syncDeferred` +/// (e `visits.revokeUploadToken`). Um vigente por (usuário, aparelho): a +/// emissão revoga o anterior. Validade de 7 dias, sem rotação. +/// +/// **Nunca** o token: só o SHA-256 dele em hex. O token é 256 bits aleatórios, +/// então um hash simples basta (não há senha humana para adivinhar). +abstract class AcsUploadToken implements _i1.SerializableModel { + AcsUploadToken._({ + this.id, + required this.userId, + required this.tokenHash, + required this.deviceId, + required this.issuedAt, + required this.expiresAt, + this.revokedAt, + }); + + factory AcsUploadToken({ + _i1.UuidValue? id, + required _i1.UuidValue userId, + required String tokenHash, + required String deviceId, + required DateTime issuedAt, + required DateTime expiresAt, + DateTime? revokedAt, + }) = _AcsUploadTokenImpl; + + factory AcsUploadToken.fromJson(Map jsonSerialization) { + return AcsUploadToken( + id: jsonSerialization['id'] == null + ? null + : _i1.UuidValueJsonExtension.fromJson(jsonSerialization['id']), + userId: _i1.UuidValueJsonExtension.fromJson(jsonSerialization['userId']), + tokenHash: jsonSerialization['tokenHash'] as String, + deviceId: jsonSerialization['deviceId'] as String, + issuedAt: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['issuedAt'], + ), + expiresAt: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['expiresAt'], + ), + revokedAt: jsonSerialization['revokedAt'] == null + ? null + : _i1.DateTimeJsonExtension.fromJson(jsonSerialization['revokedAt']), + ); + } + + /// The database id, set if the object has been inserted into the + /// database or if it has been fetched from the database. Otherwise, + /// the id will be null. + _i1.UuidValue? id; + + _i1.UuidValue userId; + + String tokenHash; + + /// Instalação que recebeu o token. Token apresentado por outro aparelho é + /// recusado e revogado. + String deviceId; + + DateTime issuedAt; + + /// Teto fixo (`issuedAt` + 7 dias); o uso não o estende. + DateTime expiresAt; + + /// `null` = vigente. Preenchido na revogação (pedido do app, novo login no + /// mesmo aparelho, aparelho divergente ou conta inativa/sem microárea). + DateTime? revokedAt; + + /// Returns a shallow copy of this [AcsUploadToken] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + AcsUploadToken copyWith({ + _i1.UuidValue? id, + _i1.UuidValue? userId, + String? tokenHash, + String? deviceId, + DateTime? issuedAt, + DateTime? expiresAt, + DateTime? revokedAt, + }); + @override + Map toJson() { + return { + '__className__': 'AcsUploadToken', + if (id != null) 'id': id?.toJson(), + 'userId': userId.toJson(), + 'tokenHash': tokenHash, + 'deviceId': deviceId, + 'issuedAt': issuedAt.toJson(), + 'expiresAt': expiresAt.toJson(), + if (revokedAt != null) 'revokedAt': revokedAt?.toJson(), + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _AcsUploadTokenImpl extends AcsUploadToken { + _AcsUploadTokenImpl({ + _i1.UuidValue? id, + required _i1.UuidValue userId, + required String tokenHash, + required String deviceId, + required DateTime issuedAt, + required DateTime expiresAt, + DateTime? revokedAt, + }) : super._( + id: id, + userId: userId, + tokenHash: tokenHash, + deviceId: deviceId, + issuedAt: issuedAt, + expiresAt: expiresAt, + revokedAt: revokedAt, + ); + + /// Returns a shallow copy of this [AcsUploadToken] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + AcsUploadToken copyWith({ + Object? id = _Undefined, + _i1.UuidValue? userId, + String? tokenHash, + String? deviceId, + DateTime? issuedAt, + DateTime? expiresAt, + Object? revokedAt = _Undefined, + }) { + return AcsUploadToken( + id: id is _i1.UuidValue? ? id : this.id, + userId: userId ?? this.userId, + tokenHash: tokenHash ?? this.tokenHash, + deviceId: deviceId ?? this.deviceId, + issuedAt: issuedAt ?? this.issuedAt, + expiresAt: expiresAt ?? this.expiresAt, + revokedAt: revokedAt is DateTime? ? revokedAt : this.revokedAt, + ); + } +} diff --git a/backend/sinalacs_client/lib/src/protocol/alert.dart b/backend/sinalacs_client/lib/src/protocol/alert.dart index 1690019e..88fa218c 100644 --- a/backend/sinalacs_client/lib/src/protocol/alert.dart +++ b/backend/sinalacs_client/lib/src/protocol/alert.dart @@ -14,6 +14,7 @@ import 'package:serverpod_client/serverpod_client.dart' as _i1; import 'enums/risk_level.dart' as _i2; import 'enums/alert_status.dart' as _i3; +import 'enums/escalation_reason.dart' as _i4; /// Alerta de urgência disparado pelo paciente. /// @@ -38,6 +39,9 @@ abstract class Alert implements _i1.SerializableModel { required this.deviceId, required this.retryCount, required this.version, + this.escalatedAt, + this.escalatedBy, + this.escalationReason, }); factory Alert({ @@ -57,6 +61,9 @@ abstract class Alert implements _i1.SerializableModel { required String deviceId, required int retryCount, required int version, + DateTime? escalatedAt, + _i1.UuidValue? escalatedBy, + _i4.EscalationReason? escalationReason, }) = _AlertImpl; factory Alert.fromJson(Map jsonSerialization) { @@ -101,6 +108,21 @@ abstract class Alert implements _i1.SerializableModel { deviceId: jsonSerialization['deviceId'] as String, retryCount: jsonSerialization['retryCount'] as int, version: jsonSerialization['version'] as int, + escalatedAt: jsonSerialization['escalatedAt'] == null + ? null + : _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['escalatedAt'], + ), + escalatedBy: jsonSerialization['escalatedBy'] == null + ? null + : _i1.UuidValueJsonExtension.fromJson( + jsonSerialization['escalatedBy'], + ), + escalationReason: jsonSerialization['escalationReason'] == null + ? null + : _i4.EscalationReason.fromJson( + (jsonSerialization['escalationReason'] as String), + ), ); } @@ -141,6 +163,13 @@ abstract class Alert implements _i1.SerializableModel { int version; + /// Escalonamento pelo backoffice (#44). Anuláveis: alerta não escalonado. + DateTime? escalatedAt; + + _i1.UuidValue? escalatedBy; + + _i4.EscalationReason? escalationReason; + /// Returns a shallow copy of this [Alert] /// with some or all fields replaced by the given arguments. @_i1.useResult @@ -161,6 +190,9 @@ abstract class Alert implements _i1.SerializableModel { String? deviceId, int? retryCount, int? version, + DateTime? escalatedAt, + _i1.UuidValue? escalatedBy, + _i4.EscalationReason? escalationReason, }); @override Map toJson() { @@ -182,6 +214,10 @@ abstract class Alert implements _i1.SerializableModel { 'deviceId': deviceId, 'retryCount': retryCount, 'version': version, + if (escalatedAt != null) 'escalatedAt': escalatedAt?.toJson(), + if (escalatedBy != null) 'escalatedBy': escalatedBy?.toJson(), + if (escalationReason != null) + 'escalationReason': escalationReason?.toJson(), }; } @@ -211,6 +247,9 @@ class _AlertImpl extends Alert { required String deviceId, required int retryCount, required int version, + DateTime? escalatedAt, + _i1.UuidValue? escalatedBy, + _i4.EscalationReason? escalationReason, }) : super._( id: id, patientId: patientId, @@ -228,6 +267,9 @@ class _AlertImpl extends Alert { deviceId: deviceId, retryCount: retryCount, version: version, + escalatedAt: escalatedAt, + escalatedBy: escalatedBy, + escalationReason: escalationReason, ); /// Returns a shallow copy of this [Alert] @@ -251,6 +293,9 @@ class _AlertImpl extends Alert { String? deviceId, int? retryCount, int? version, + Object? escalatedAt = _Undefined, + Object? escalatedBy = _Undefined, + Object? escalationReason = _Undefined, }) { return Alert( id: id is _i1.UuidValue? ? id : this.id, @@ -273,6 +318,13 @@ class _AlertImpl extends Alert { deviceId: deviceId ?? this.deviceId, retryCount: retryCount ?? this.retryCount, version: version ?? this.version, + escalatedAt: escalatedAt is DateTime? ? escalatedAt : this.escalatedAt, + escalatedBy: escalatedBy is _i1.UuidValue? + ? escalatedBy + : this.escalatedBy, + escalationReason: escalationReason is _i4.EscalationReason? + ? escalationReason + : this.escalationReason, ); } } diff --git a/backend/sinalacs_client/lib/src/protocol/api/admin_acs.dart b/backend/sinalacs_client/lib/src/protocol/api/admin_acs.dart new file mode 100644 index 00000000..af12a90f --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/api/admin_acs.dart @@ -0,0 +1,171 @@ +/* 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; + +/// ACS no backoffice (#43): identificação profissional, território e estado de +/// acesso. Nunca inclui CPF, senha ou contato. +/// +/// `mfaActive` é `user_credentials.totpEnabledAt IS NOT NULL` — MFA **ativa**, +/// não apenas iniciada: um enrollment pendente (`totpEnabledAt` nulo) não vale +/// como proteção e a tela precisa poder dizer isso. +abstract class AdminAcs implements _i1.SerializableModel { + AdminAcs._({ + required this.id, + required this.name, + required this.enrollmentId, + required this.ubsId, + required this.ubsName, + this.microAreaId, + this.microAreaName, + required this.active, + required this.mfaActive, + }); + + factory AdminAcs({ + required String id, + required String name, + required String enrollmentId, + required String ubsId, + required String ubsName, + String? microAreaId, + String? microAreaName, + required bool active, + required bool mfaActive, + }) = _AdminAcsImpl; + + factory AdminAcs.fromJson(Map jsonSerialization) { + return AdminAcs( + id: jsonSerialization['id'] as String, + name: jsonSerialization['name'] as String, + enrollmentId: jsonSerialization['enrollmentId'] as String, + ubsId: jsonSerialization['ubsId'] as String, + ubsName: jsonSerialization['ubsName'] as String, + microAreaId: jsonSerialization['microAreaId'] as String?, + microAreaName: jsonSerialization['microAreaName'] as String?, + active: _i1.BoolJsonExtension.fromJson(jsonSerialization['active']), + mfaActive: _i1.BoolJsonExtension.fromJson(jsonSerialization['mfaActive']), + ); + } + + String id; + + String name; + + String enrollmentId; + + String ubsId; + + String ubsName; + + /// Nulos enquanto o ACS não tem território (`users.microAreaId`). A listagem + /// inclui quem não tem microárea, para poder vinculá-lo. + String? microAreaId; + + String? microAreaName; + + bool active; + + bool mfaActive; + + /// Returns a shallow copy of this [AdminAcs] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + AdminAcs copyWith({ + String? id, + String? name, + String? enrollmentId, + String? ubsId, + String? ubsName, + String? microAreaId, + String? microAreaName, + bool? active, + bool? mfaActive, + }); + @override + Map toJson() { + return { + '__className__': 'AdminAcs', + 'id': id, + 'name': name, + 'enrollmentId': enrollmentId, + 'ubsId': ubsId, + 'ubsName': ubsName, + if (microAreaId != null) 'microAreaId': microAreaId, + if (microAreaName != null) 'microAreaName': microAreaName, + 'active': active, + 'mfaActive': mfaActive, + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _AdminAcsImpl extends AdminAcs { + _AdminAcsImpl({ + required String id, + required String name, + required String enrollmentId, + required String ubsId, + required String ubsName, + String? microAreaId, + String? microAreaName, + required bool active, + required bool mfaActive, + }) : super._( + id: id, + name: name, + enrollmentId: enrollmentId, + ubsId: ubsId, + ubsName: ubsName, + microAreaId: microAreaId, + microAreaName: microAreaName, + active: active, + mfaActive: mfaActive, + ); + + /// Returns a shallow copy of this [AdminAcs] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + AdminAcs copyWith({ + String? id, + String? name, + String? enrollmentId, + String? ubsId, + String? ubsName, + Object? microAreaId = _Undefined, + Object? microAreaName = _Undefined, + bool? active, + bool? mfaActive, + }) { + return AdminAcs( + id: id ?? this.id, + name: name ?? this.name, + enrollmentId: enrollmentId ?? this.enrollmentId, + ubsId: ubsId ?? this.ubsId, + ubsName: ubsName ?? this.ubsName, + microAreaId: microAreaId is String? ? microAreaId : this.microAreaId, + microAreaName: microAreaName is String? + ? microAreaName + : this.microAreaName, + active: active ?? this.active, + mfaActive: mfaActive ?? this.mfaActive, + ); + } +} diff --git a/backend/sinalacs_client/lib/src/protocol/api/admin_acs_creation_result.dart b/backend/sinalacs_client/lib/src/protocol/api/admin_acs_creation_result.dart new file mode 100644 index 00000000..2ead119a --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/api/admin_acs_creation_result.dart @@ -0,0 +1,93 @@ +/* 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 '../api/admin_acs.dart' as _i2; +import 'package:sinalacs_client/src/protocol/protocol.dart' as _i3; + +/// ACS recém-cadastrado e a senha inicial gerada (issue #43). +/// +/// A senha existe **só** aqui e na resposta de `admin.createAcs`: não há coluna, +/// log nem linha de auditoria que a guarde — o servidor persiste apenas o hash +/// Argon2id em `user_credentials`. A trilha registra `created` com o id do ACS e +/// nunca a credencial. Quem cadastra mostra uma vez; perdida a senha, o caminho +/// é redefinir, não recuperar (operação seguinte da mesma issue). +abstract class AdminAcsCreationResult implements _i1.SerializableModel { + AdminAcsCreationResult._({ + required this.acs, + required this.initialPassword, + }); + + factory AdminAcsCreationResult({ + required _i2.AdminAcs acs, + required String initialPassword, + }) = _AdminAcsCreationResultImpl; + + factory AdminAcsCreationResult.fromJson( + Map jsonSerialization, + ) { + return AdminAcsCreationResult( + acs: _i3.Protocol().deserialize<_i2.AdminAcs>(jsonSerialization['acs']), + initialPassword: jsonSerialization['initialPassword'] as String, + ); + } + + _i2.AdminAcs acs; + + String initialPassword; + + /// Returns a shallow copy of this [AdminAcsCreationResult] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + AdminAcsCreationResult copyWith({ + _i2.AdminAcs? acs, + String? initialPassword, + }); + @override + Map toJson() { + return { + '__className__': 'AdminAcsCreationResult', + 'acs': acs.toJson(), + 'initialPassword': initialPassword, + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _AdminAcsCreationResultImpl extends AdminAcsCreationResult { + _AdminAcsCreationResultImpl({ + required _i2.AdminAcs acs, + required String initialPassword, + }) : super._( + acs: acs, + initialPassword: initialPassword, + ); + + /// Returns a shallow copy of this [AdminAcsCreationResult] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + AdminAcsCreationResult copyWith({ + _i2.AdminAcs? acs, + String? initialPassword, + }) { + return AdminAcsCreationResult( + acs: acs ?? this.acs.copyWith(), + initialPassword: initialPassword ?? this.initialPassword, + ); + } +} diff --git a/backend/sinalacs_client/lib/src/protocol/api/admin_alert.dart b/backend/sinalacs_client/lib/src/protocol/api/admin_alert.dart new file mode 100644 index 00000000..c4e63ae3 --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/api/admin_alert.dart @@ -0,0 +1,163 @@ +/* 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 '../enums/alert_status.dart' as _i3; + +/// Alerta para o backoffice. `patientLabel` é `#` + 4 hex do UUID: o backoffice +/// identifica, não qualifica (spec/lgpd_design.md). Nunca nome, CPF ou contato. +abstract class AdminAlert implements _i1.SerializableModel { + AdminAlert._({ + required this.id, + required this.patientLabel, + required this.microAreaName, + required this.riskLevel, + required this.status, + required this.triggeredAt, + this.escalatedAt, + required this.overdue, + }); + + factory AdminAlert({ + required String id, + required String patientLabel, + required String microAreaName, + required _i2.RiskLevel riskLevel, + required _i3.AlertStatus status, + required DateTime triggeredAt, + DateTime? escalatedAt, + required bool overdue, + }) = _AdminAlertImpl; + + factory AdminAlert.fromJson(Map jsonSerialization) { + return AdminAlert( + id: jsonSerialization['id'] as String, + patientLabel: jsonSerialization['patientLabel'] as String, + microAreaName: jsonSerialization['microAreaName'] as String, + riskLevel: _i2.RiskLevel.fromJson( + (jsonSerialization['riskLevel'] as String), + ), + status: _i3.AlertStatus.fromJson((jsonSerialization['status'] as String)), + triggeredAt: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['triggeredAt'], + ), + escalatedAt: jsonSerialization['escalatedAt'] == null + ? null + : _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['escalatedAt'], + ), + overdue: _i1.BoolJsonExtension.fromJson(jsonSerialization['overdue']), + ); + } + + String id; + + String patientLabel; + + String microAreaName; + + _i2.RiskLevel riskLevel; + + _i3.AlertStatus status; + + DateTime triggeredAt; + + DateTime? escalatedAt; + + /// Sem reconhecimento além do prazo configurado; calculado na leitura. + bool overdue; + + /// Returns a shallow copy of this [AdminAlert] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + AdminAlert copyWith({ + String? id, + String? patientLabel, + String? microAreaName, + _i2.RiskLevel? riskLevel, + _i3.AlertStatus? status, + DateTime? triggeredAt, + DateTime? escalatedAt, + bool? overdue, + }); + @override + Map toJson() { + return { + '__className__': 'AdminAlert', + 'id': id, + 'patientLabel': patientLabel, + 'microAreaName': microAreaName, + 'riskLevel': riskLevel.toJson(), + 'status': status.toJson(), + 'triggeredAt': triggeredAt.toJson(), + if (escalatedAt != null) 'escalatedAt': escalatedAt?.toJson(), + 'overdue': overdue, + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _AdminAlertImpl extends AdminAlert { + _AdminAlertImpl({ + required String id, + required String patientLabel, + required String microAreaName, + required _i2.RiskLevel riskLevel, + required _i3.AlertStatus status, + required DateTime triggeredAt, + DateTime? escalatedAt, + required bool overdue, + }) : super._( + id: id, + patientLabel: patientLabel, + microAreaName: microAreaName, + riskLevel: riskLevel, + status: status, + triggeredAt: triggeredAt, + escalatedAt: escalatedAt, + overdue: overdue, + ); + + /// Returns a shallow copy of this [AdminAlert] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + AdminAlert copyWith({ + String? id, + String? patientLabel, + String? microAreaName, + _i2.RiskLevel? riskLevel, + _i3.AlertStatus? status, + DateTime? triggeredAt, + Object? escalatedAt = _Undefined, + bool? overdue, + }) { + return AdminAlert( + id: id ?? this.id, + patientLabel: patientLabel ?? this.patientLabel, + microAreaName: microAreaName ?? this.microAreaName, + riskLevel: riskLevel ?? this.riskLevel, + status: status ?? this.status, + triggeredAt: triggeredAt ?? this.triggeredAt, + escalatedAt: escalatedAt is DateTime? ? escalatedAt : this.escalatedAt, + overdue: overdue ?? this.overdue, + ); + } +} diff --git a/backend/sinalacs_client/lib/src/protocol/api/admin_alert_page.dart b/backend/sinalacs_client/lib/src/protocol/api/admin_alert_page.dart new file mode 100644 index 00000000..db1fc49a --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/api/admin_alert_page.dart @@ -0,0 +1,89 @@ +/* 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 '../api/admin_alert.dart' as _i2; +import 'package:sinalacs_client/src/protocol/protocol.dart' as _i3; + +/// Página de alertas. `nextOffset` nulo = não há mais páginas. +abstract class AdminAlertPage implements _i1.SerializableModel { + AdminAlertPage._({ + required this.items, + this.nextOffset, + }); + + factory AdminAlertPage({ + required List<_i2.AdminAlert> items, + int? nextOffset, + }) = _AdminAlertPageImpl; + + factory AdminAlertPage.fromJson(Map jsonSerialization) { + return AdminAlertPage( + items: _i3.Protocol().deserialize>( + jsonSerialization['items'], + ), + nextOffset: jsonSerialization['nextOffset'] as int?, + ); + } + + List<_i2.AdminAlert> items; + + int? nextOffset; + + /// Returns a shallow copy of this [AdminAlertPage] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + AdminAlertPage copyWith({ + List<_i2.AdminAlert>? items, + int? nextOffset, + }); + @override + Map toJson() { + return { + '__className__': 'AdminAlertPage', + 'items': items.toJson(valueToJson: (v) => v.toJson()), + if (nextOffset != null) 'nextOffset': nextOffset, + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _AdminAlertPageImpl extends AdminAlertPage { + _AdminAlertPageImpl({ + required List<_i2.AdminAlert> items, + int? nextOffset, + }) : super._( + items: items, + nextOffset: nextOffset, + ); + + /// Returns a shallow copy of this [AdminAlertPage] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + AdminAlertPage copyWith({ + List<_i2.AdminAlert>? items, + Object? nextOffset = _Undefined, + }) { + return AdminAlertPage( + items: items ?? this.items.map((e0) => e0.copyWith()).toList(), + nextOffset: nextOffset is int? ? nextOffset : this.nextOffset, + ); + } +} diff --git a/backend/sinalacs_client/lib/src/protocol/api/admin_alerts_pulse.dart b/backend/sinalacs_client/lib/src/protocol/api/admin_alerts_pulse.dart new file mode 100644 index 00000000..8882618c --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/api/admin_alerts_pulse.dart @@ -0,0 +1,125 @@ +/* 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 '../api/admin_indicators.dart' as _i2; +import 'package:sinalacs_client/src/protocol/protocol.dart' as _i3; + +/// Pulso da monitoração do backoffice (issue #44, D5): o que o painel consulta +/// a cada 30 s. Sem identidade nenhuma — nem o rótulo do paciente: só contagens +/// e instantes. O dashboard se atualiza por aqui (`indicators` são os mesmos de +/// `admin.indicators`, no mesmo escopo); a lista só é relida por `admin.alerts` +/// quando `maxChangedAt` muda. `overdueRed` segue a regra de `AdminAlert.overdue`. +/// `maxChangedAt` é nulo quando o escopo não tem alerta; `serverNow` é o +/// relógio do servidor, nunca o do aparelho. +abstract class AdminAlertsPulse implements _i1.SerializableModel { + AdminAlertsPulse._({ + required this.indicators, + required this.overdueRed, + this.maxChangedAt, + required this.serverNow, + }); + + factory AdminAlertsPulse({ + required _i2.AdminIndicators indicators, + required int overdueRed, + DateTime? maxChangedAt, + required DateTime serverNow, + }) = _AdminAlertsPulseImpl; + + factory AdminAlertsPulse.fromJson(Map jsonSerialization) { + return AdminAlertsPulse( + indicators: _i3.Protocol().deserialize<_i2.AdminIndicators>( + jsonSerialization['indicators'], + ), + overdueRed: jsonSerialization['overdueRed'] as int, + maxChangedAt: jsonSerialization['maxChangedAt'] == null + ? null + : _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['maxChangedAt'], + ), + serverNow: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['serverNow'], + ), + ); + } + + _i2.AdminIndicators indicators; + + int overdueRed; + + DateTime? maxChangedAt; + + DateTime serverNow; + + /// Returns a shallow copy of this [AdminAlertsPulse] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + AdminAlertsPulse copyWith({ + _i2.AdminIndicators? indicators, + int? overdueRed, + DateTime? maxChangedAt, + DateTime? serverNow, + }); + @override + Map toJson() { + return { + '__className__': 'AdminAlertsPulse', + 'indicators': indicators.toJson(), + 'overdueRed': overdueRed, + if (maxChangedAt != null) 'maxChangedAt': maxChangedAt?.toJson(), + 'serverNow': serverNow.toJson(), + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _AdminAlertsPulseImpl extends AdminAlertsPulse { + _AdminAlertsPulseImpl({ + required _i2.AdminIndicators indicators, + required int overdueRed, + DateTime? maxChangedAt, + required DateTime serverNow, + }) : super._( + indicators: indicators, + overdueRed: overdueRed, + maxChangedAt: maxChangedAt, + serverNow: serverNow, + ); + + /// Returns a shallow copy of this [AdminAlertsPulse] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + AdminAlertsPulse copyWith({ + _i2.AdminIndicators? indicators, + int? overdueRed, + Object? maxChangedAt = _Undefined, + DateTime? serverNow, + }) { + return AdminAlertsPulse( + indicators: indicators ?? this.indicators.copyWith(), + overdueRed: overdueRed ?? this.overdueRed, + maxChangedAt: maxChangedAt is DateTime? + ? maxChangedAt + : this.maxChangedAt, + serverNow: serverNow ?? this.serverNow, + ); + } +} diff --git a/backend/sinalacs_client/lib/src/protocol/api/admin_audit_chain_status.dart b/backend/sinalacs_client/lib/src/protocol/api/admin_audit_chain_status.dart new file mode 100644 index 00000000..7d071570 --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/api/admin_audit_chain_status.dart @@ -0,0 +1,175 @@ +/* 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/audit_integrity_state.dart' as _i2; +import '../enums/audit_chain_reason.dart' as _i3; + +/// Resultado de `admin.verifyAuditChain` (#45, D3/D9). Nunca carrega hash, +/// segredo nem conteúdo de linha. +/// - `state`: só `verified` é verde; qualquer outro, nunca. +/// - `checkedRows`: linhas conferidas antes da quebra (ou o total). Só para o +/// administrador; nulo para o coordenador (não infere volume de outra UBS). +/// - `headSequence`: última `sequence` da cadeia íntegra. Só administrador e +/// só com `verified`. +/// - `firstBrokenSequence`: onde a cadeia quebrou. Para o coordenador, só se +/// a linha quebrada for de um autor da UBS dele; senão nulo. +/// - `brokenInScope`: com `broken`, se a quebra é visível no escopo de quem +/// chama (sempre `true` para o administrador); nulo nos outros estados ou se +/// o pertencimento não pôde ser apurado. +/// - `reasonCode`: motivo fechado; o texto livre do verificador não sai do +/// servidor. Para o coordenador, quebra fora do escopo vem sem motivo. +/// - `verifiedAt`: quando a verificação servida terminou (cache de 60 s). +abstract class AdminAuditChainStatus implements _i1.SerializableModel { + AdminAuditChainStatus._({ + required this.state, + this.checkedRows, + this.headSequence, + this.firstBrokenSequence, + this.brokenInScope, + this.reasonCode, + required this.verifiedAt, + }); + + factory AdminAuditChainStatus({ + required _i2.AuditIntegrityState state, + int? checkedRows, + int? headSequence, + int? firstBrokenSequence, + bool? brokenInScope, + _i3.AuditChainReason? reasonCode, + required DateTime verifiedAt, + }) = _AdminAuditChainStatusImpl; + + factory AdminAuditChainStatus.fromJson( + Map jsonSerialization, + ) { + return AdminAuditChainStatus( + state: _i2.AuditIntegrityState.fromJson( + (jsonSerialization['state'] as String), + ), + checkedRows: jsonSerialization['checkedRows'] as int?, + headSequence: jsonSerialization['headSequence'] as int?, + firstBrokenSequence: jsonSerialization['firstBrokenSequence'] as int?, + brokenInScope: jsonSerialization['brokenInScope'] == null + ? null + : _i1.BoolJsonExtension.fromJson(jsonSerialization['brokenInScope']), + reasonCode: jsonSerialization['reasonCode'] == null + ? null + : _i3.AuditChainReason.fromJson( + (jsonSerialization['reasonCode'] as String), + ), + verifiedAt: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['verifiedAt'], + ), + ); + } + + _i2.AuditIntegrityState state; + + int? checkedRows; + + int? headSequence; + + int? firstBrokenSequence; + + bool? brokenInScope; + + _i3.AuditChainReason? reasonCode; + + DateTime verifiedAt; + + /// Returns a shallow copy of this [AdminAuditChainStatus] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + AdminAuditChainStatus copyWith({ + _i2.AuditIntegrityState? state, + int? checkedRows, + int? headSequence, + int? firstBrokenSequence, + bool? brokenInScope, + _i3.AuditChainReason? reasonCode, + DateTime? verifiedAt, + }); + @override + Map toJson() { + return { + '__className__': 'AdminAuditChainStatus', + 'state': state.toJson(), + if (checkedRows != null) 'checkedRows': checkedRows, + if (headSequence != null) 'headSequence': headSequence, + if (firstBrokenSequence != null) + 'firstBrokenSequence': firstBrokenSequence, + if (brokenInScope != null) 'brokenInScope': brokenInScope, + if (reasonCode != null) 'reasonCode': reasonCode?.toJson(), + 'verifiedAt': verifiedAt.toJson(), + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _AdminAuditChainStatusImpl extends AdminAuditChainStatus { + _AdminAuditChainStatusImpl({ + required _i2.AuditIntegrityState state, + int? checkedRows, + int? headSequence, + int? firstBrokenSequence, + bool? brokenInScope, + _i3.AuditChainReason? reasonCode, + required DateTime verifiedAt, + }) : super._( + state: state, + checkedRows: checkedRows, + headSequence: headSequence, + firstBrokenSequence: firstBrokenSequence, + brokenInScope: brokenInScope, + reasonCode: reasonCode, + verifiedAt: verifiedAt, + ); + + /// Returns a shallow copy of this [AdminAuditChainStatus] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + AdminAuditChainStatus copyWith({ + _i2.AuditIntegrityState? state, + Object? checkedRows = _Undefined, + Object? headSequence = _Undefined, + Object? firstBrokenSequence = _Undefined, + Object? brokenInScope = _Undefined, + Object? reasonCode = _Undefined, + DateTime? verifiedAt, + }) { + return AdminAuditChainStatus( + state: state ?? this.state, + checkedRows: checkedRows is int? ? checkedRows : this.checkedRows, + headSequence: headSequence is int? ? headSequence : this.headSequence, + firstBrokenSequence: firstBrokenSequence is int? + ? firstBrokenSequence + : this.firstBrokenSequence, + brokenInScope: brokenInScope is bool? + ? brokenInScope + : this.brokenInScope, + reasonCode: reasonCode is _i3.AuditChainReason? + ? reasonCode + : this.reasonCode, + verifiedAt: verifiedAt ?? this.verifiedAt, + ); + } +} diff --git a/backend/sinalacs_client/lib/src/protocol/api/admin_audit_entry.dart b/backend/sinalacs_client/lib/src/protocol/api/admin_audit_entry.dart new file mode 100644 index 00000000..936ad2cc --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/api/admin_audit_entry.dart @@ -0,0 +1,159 @@ +/* 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/audit_row_seal.dart' as _i2; + +/// Linha de auditoria para o backoffice. Sem `ipHash`, `previousHash` nem +/// `entryHash`: a cadeia de hash é do servidor, não da tela. +abstract class AdminAuditEntry implements _i1.SerializableModel { + AdminAuditEntry._({ + required this.id, + required this.sequence, + required this.userLabel, + required this.actionType, + required this.resourceType, + required this.timestamp, + required this.result, + _i2.AuditRowSeal? integrity, + }) : integrity = integrity ?? _i2.AuditRowSeal.unverifiable; + + factory AdminAuditEntry({ + required String id, + required int sequence, + required String userLabel, + required String actionType, + required String resourceType, + required DateTime timestamp, + required String result, + _i2.AuditRowSeal? integrity, + }) = _AdminAuditEntryImpl; + + factory AdminAuditEntry.fromJson(Map jsonSerialization) { + return AdminAuditEntry( + id: jsonSerialization['id'] as String, + sequence: jsonSerialization['sequence'] as int, + userLabel: jsonSerialization['userLabel'] as String, + actionType: jsonSerialization['actionType'] as String, + resourceType: jsonSerialization['resourceType'] as String, + timestamp: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['timestamp'], + ), + result: jsonSerialization['result'] as String, + integrity: jsonSerialization['integrity'] == null + ? null + : _i2.AuditRowSeal.fromJson( + (jsonSerialization['integrity'] as String), + ), + ); + } + + String id; + + int sequence; + + String userLabel; + + String actionType; + + String resourceType; + + DateTime timestamp; + + String result; + + /// Selo da linha (#45, D3), calculado no servidor. O padrão é + /// `unverifiable` — nunca `verified` por omissão. + _i2.AuditRowSeal integrity; + + /// Returns a shallow copy of this [AdminAuditEntry] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + AdminAuditEntry copyWith({ + String? id, + int? sequence, + String? userLabel, + String? actionType, + String? resourceType, + DateTime? timestamp, + String? result, + _i2.AuditRowSeal? integrity, + }); + @override + Map toJson() { + return { + '__className__': 'AdminAuditEntry', + 'id': id, + 'sequence': sequence, + 'userLabel': userLabel, + 'actionType': actionType, + 'resourceType': resourceType, + 'timestamp': timestamp.toJson(), + 'result': result, + 'integrity': integrity.toJson(), + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _AdminAuditEntryImpl extends AdminAuditEntry { + _AdminAuditEntryImpl({ + required String id, + required int sequence, + required String userLabel, + required String actionType, + required String resourceType, + required DateTime timestamp, + required String result, + _i2.AuditRowSeal? integrity, + }) : super._( + id: id, + sequence: sequence, + userLabel: userLabel, + actionType: actionType, + resourceType: resourceType, + timestamp: timestamp, + result: result, + integrity: integrity, + ); + + /// Returns a shallow copy of this [AdminAuditEntry] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + AdminAuditEntry copyWith({ + String? id, + int? sequence, + String? userLabel, + String? actionType, + String? resourceType, + DateTime? timestamp, + String? result, + _i2.AuditRowSeal? integrity, + }) { + return AdminAuditEntry( + id: id ?? this.id, + sequence: sequence ?? this.sequence, + userLabel: userLabel ?? this.userLabel, + actionType: actionType ?? this.actionType, + resourceType: resourceType ?? this.resourceType, + timestamp: timestamp ?? this.timestamp, + result: result ?? this.result, + integrity: integrity ?? this.integrity, + ); + } +} diff --git a/backend/sinalacs_client/lib/src/protocol/api/admin_audit_export.dart b/backend/sinalacs_client/lib/src/protocol/api/admin_audit_export.dart new file mode 100644 index 00000000..6d283a5f --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/api/admin_audit_export.dart @@ -0,0 +1,169 @@ +/* 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/audit_integrity_state.dart' as _i2; + +/// Resultado de `admin.exportAuditLogs` (#45, D5–D8). Só o administrador +/// exporta. O arquivo é o CSV de `AuditCsv` (colunas fixas de D6: nunca +/// `userId`, `resourceId`, `ipHash`, `previousHash`, `entryHash` nem segredo). +/// - `fileName`: `auditoria__.csv` em UTC compacto; sem dado pessoal. +/// - `csv`: o conteúdo (UTF-8, RFC 4180, CRLF), gerado em memória no servidor. +/// - `rowCount`, `firstSequence`, `lastSequence`: o recorte exportado (as duas +/// sequences são nulas com recorte vazio). O próprio evento `export` fica +/// sempre FORA do arquivo (`sequence` maior que `lastSequence`). +/// - `contentSha256`: SHA-256 (hex minúsculo) dos bytes UTF-8 de `csv`, o mesmo +/// gravado em `audit_exports`. +/// - `chainState`: estado da verificação global da cadeia no momento da +/// exportação (cache de 60 s); qualquer coisa diferente de `verified` é +/// registrada e devolvida como está. +/// - `generatedAt`: instante da exportação (UTC), o `requestedAt` gravado. +abstract class AdminAuditExport implements _i1.SerializableModel { + AdminAuditExport._({ + required this.fileName, + required this.csv, + required this.rowCount, + this.firstSequence, + this.lastSequence, + required this.contentSha256, + required this.chainState, + required this.generatedAt, + }); + + factory AdminAuditExport({ + required String fileName, + required String csv, + required int rowCount, + int? firstSequence, + int? lastSequence, + required String contentSha256, + required _i2.AuditIntegrityState chainState, + required DateTime generatedAt, + }) = _AdminAuditExportImpl; + + factory AdminAuditExport.fromJson(Map jsonSerialization) { + return AdminAuditExport( + fileName: jsonSerialization['fileName'] as String, + csv: jsonSerialization['csv'] as String, + rowCount: jsonSerialization['rowCount'] as int, + firstSequence: jsonSerialization['firstSequence'] as int?, + lastSequence: jsonSerialization['lastSequence'] as int?, + contentSha256: jsonSerialization['contentSha256'] as String, + chainState: _i2.AuditIntegrityState.fromJson( + (jsonSerialization['chainState'] as String), + ), + generatedAt: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['generatedAt'], + ), + ); + } + + String fileName; + + String csv; + + int rowCount; + + int? firstSequence; + + int? lastSequence; + + String contentSha256; + + _i2.AuditIntegrityState chainState; + + DateTime generatedAt; + + /// Returns a shallow copy of this [AdminAuditExport] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + AdminAuditExport copyWith({ + String? fileName, + String? csv, + int? rowCount, + int? firstSequence, + int? lastSequence, + String? contentSha256, + _i2.AuditIntegrityState? chainState, + DateTime? generatedAt, + }); + @override + Map toJson() { + return { + '__className__': 'AdminAuditExport', + 'fileName': fileName, + 'csv': csv, + 'rowCount': rowCount, + if (firstSequence != null) 'firstSequence': firstSequence, + if (lastSequence != null) 'lastSequence': lastSequence, + 'contentSha256': contentSha256, + 'chainState': chainState.toJson(), + 'generatedAt': generatedAt.toJson(), + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _AdminAuditExportImpl extends AdminAuditExport { + _AdminAuditExportImpl({ + required String fileName, + required String csv, + required int rowCount, + int? firstSequence, + int? lastSequence, + required String contentSha256, + required _i2.AuditIntegrityState chainState, + required DateTime generatedAt, + }) : super._( + fileName: fileName, + csv: csv, + rowCount: rowCount, + firstSequence: firstSequence, + lastSequence: lastSequence, + contentSha256: contentSha256, + chainState: chainState, + generatedAt: generatedAt, + ); + + /// Returns a shallow copy of this [AdminAuditExport] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + AdminAuditExport copyWith({ + String? fileName, + String? csv, + int? rowCount, + Object? firstSequence = _Undefined, + Object? lastSequence = _Undefined, + String? contentSha256, + _i2.AuditIntegrityState? chainState, + DateTime? generatedAt, + }) { + return AdminAuditExport( + fileName: fileName ?? this.fileName, + csv: csv ?? this.csv, + rowCount: rowCount ?? this.rowCount, + firstSequence: firstSequence is int? ? firstSequence : this.firstSequence, + lastSequence: lastSequence is int? ? lastSequence : this.lastSequence, + contentSha256: contentSha256 ?? this.contentSha256, + chainState: chainState ?? this.chainState, + generatedAt: generatedAt ?? this.generatedAt, + ); + } +} diff --git a/backend/sinalacs_client/lib/src/protocol/api/admin_audit_filter.dart b/backend/sinalacs_client/lib/src/protocol/api/admin_audit_filter.dart new file mode 100644 index 00000000..8a11f386 --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/api/admin_audit_filter.dart @@ -0,0 +1,175 @@ +/* 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/user_role.dart' as _i2; + +/// Filtro da trilha de auditoria do backoffice (#45, D1/D2/D10). Todos os +/// campos são opcionais e combinados por AND; nulo = sem restrição. Nunca +/// alarga o escopo: o coordenador continua vendo só os autores da UBS dele. +/// +/// Validação no `AdminReadService` (antes de auditar a leitura): +/// - `actorEnrollmentId`: matrícula do autor (ACS ou staff), igualdade sem +/// caixa, 1 a 32 caracteres. Paciente não é filtrável por identificador. +/// - `actionType`: `read`, `write`, `login` ou `export`. +/// - `resourceType`, `result`: `^[a-z0-9_:]{1,64}$`, igualdade (o `:` e os +/// dígitos cobrem resultados como `escalated:no_ack_timeout`, da #44). +/// - `from`/`to`: UTC; `from` inclusivo, `to` exclusivo, `from < to` e janela +/// de no máximo 366 dias quando os dois vêm. +/// - `search`: 2 a 64 caracteres; casa só o início da matrícula do autor, +/// `resourceType` e `result` (sem caixa, curingas escapados). Nunca hash, +/// IP, UUID ou nome. +/// Não há filtro por `resourceId` (D10: pode ser id de paciente). +abstract class AdminAuditFilter implements _i1.SerializableModel { + AdminAuditFilter._({ + this.actorEnrollmentId, + this.actorRole, + this.actionType, + this.resourceType, + this.result, + this.from, + this.to, + this.search, + }); + + factory AdminAuditFilter({ + String? actorEnrollmentId, + _i2.UserRole? actorRole, + String? actionType, + String? resourceType, + String? result, + DateTime? from, + DateTime? to, + String? search, + }) = _AdminAuditFilterImpl; + + factory AdminAuditFilter.fromJson(Map jsonSerialization) { + return AdminAuditFilter( + actorEnrollmentId: jsonSerialization['actorEnrollmentId'] as String?, + actorRole: jsonSerialization['actorRole'] == null + ? null + : _i2.UserRole.fromJson((jsonSerialization['actorRole'] as String)), + actionType: jsonSerialization['actionType'] as String?, + resourceType: jsonSerialization['resourceType'] as String?, + result: jsonSerialization['result'] as String?, + from: jsonSerialization['from'] == null + ? null + : _i1.DateTimeJsonExtension.fromJson(jsonSerialization['from']), + to: jsonSerialization['to'] == null + ? null + : _i1.DateTimeJsonExtension.fromJson(jsonSerialization['to']), + search: jsonSerialization['search'] as String?, + ); + } + + String? actorEnrollmentId; + + _i2.UserRole? actorRole; + + String? actionType; + + String? resourceType; + + String? result; + + DateTime? from; + + DateTime? to; + + String? search; + + /// Returns a shallow copy of this [AdminAuditFilter] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + AdminAuditFilter copyWith({ + String? actorEnrollmentId, + _i2.UserRole? actorRole, + String? actionType, + String? resourceType, + String? result, + DateTime? from, + DateTime? to, + String? search, + }); + @override + Map toJson() { + return { + '__className__': 'AdminAuditFilter', + if (actorEnrollmentId != null) 'actorEnrollmentId': actorEnrollmentId, + if (actorRole != null) 'actorRole': actorRole?.toJson(), + if (actionType != null) 'actionType': actionType, + if (resourceType != null) 'resourceType': resourceType, + if (result != null) 'result': result, + if (from != null) 'from': from?.toJson(), + if (to != null) 'to': to?.toJson(), + if (search != null) 'search': search, + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _AdminAuditFilterImpl extends AdminAuditFilter { + _AdminAuditFilterImpl({ + String? actorEnrollmentId, + _i2.UserRole? actorRole, + String? actionType, + String? resourceType, + String? result, + DateTime? from, + DateTime? to, + String? search, + }) : super._( + actorEnrollmentId: actorEnrollmentId, + actorRole: actorRole, + actionType: actionType, + resourceType: resourceType, + result: result, + from: from, + to: to, + search: search, + ); + + /// Returns a shallow copy of this [AdminAuditFilter] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + AdminAuditFilter copyWith({ + Object? actorEnrollmentId = _Undefined, + Object? actorRole = _Undefined, + Object? actionType = _Undefined, + Object? resourceType = _Undefined, + Object? result = _Undefined, + Object? from = _Undefined, + Object? to = _Undefined, + Object? search = _Undefined, + }) { + return AdminAuditFilter( + actorEnrollmentId: actorEnrollmentId is String? + ? actorEnrollmentId + : this.actorEnrollmentId, + actorRole: actorRole is _i2.UserRole? ? actorRole : this.actorRole, + actionType: actionType is String? ? actionType : this.actionType, + resourceType: resourceType is String? ? resourceType : this.resourceType, + result: result is String? ? result : this.result, + from: from is DateTime? ? from : this.from, + to: to is DateTime? ? to : this.to, + search: search is String? ? search : this.search, + ); + } +} diff --git a/backend/sinalacs_client/lib/src/protocol/api/admin_audit_page.dart b/backend/sinalacs_client/lib/src/protocol/api/admin_audit_page.dart new file mode 100644 index 00000000..746b4fc4 --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/api/admin_audit_page.dart @@ -0,0 +1,92 @@ +/* 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 '../api/admin_audit_entry.dart' as _i2; +import 'package:sinalacs_client/src/protocol/protocol.dart' as _i3; + +/// Página de auditoria por keyset: `nextBeforeSequence` é o `sequence` da última +/// linha devolvida (o log só cresce, então `offset` repetiria linhas). +abstract class AdminAuditPage implements _i1.SerializableModel { + AdminAuditPage._({ + required this.items, + this.nextBeforeSequence, + }); + + factory AdminAuditPage({ + required List<_i2.AdminAuditEntry> items, + int? nextBeforeSequence, + }) = _AdminAuditPageImpl; + + factory AdminAuditPage.fromJson(Map jsonSerialization) { + return AdminAuditPage( + items: _i3.Protocol().deserialize>( + jsonSerialization['items'], + ), + nextBeforeSequence: jsonSerialization['nextBeforeSequence'] as int?, + ); + } + + List<_i2.AdminAuditEntry> items; + + int? nextBeforeSequence; + + /// Returns a shallow copy of this [AdminAuditPage] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + AdminAuditPage copyWith({ + List<_i2.AdminAuditEntry>? items, + int? nextBeforeSequence, + }); + @override + Map toJson() { + return { + '__className__': 'AdminAuditPage', + 'items': items.toJson(valueToJson: (v) => v.toJson()), + if (nextBeforeSequence != null) 'nextBeforeSequence': nextBeforeSequence, + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _AdminAuditPageImpl extends AdminAuditPage { + _AdminAuditPageImpl({ + required List<_i2.AdminAuditEntry> items, + int? nextBeforeSequence, + }) : super._( + items: items, + nextBeforeSequence: nextBeforeSequence, + ); + + /// Returns a shallow copy of this [AdminAuditPage] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + AdminAuditPage copyWith({ + List<_i2.AdminAuditEntry>? items, + Object? nextBeforeSequence = _Undefined, + }) { + return AdminAuditPage( + items: items ?? this.items.map((e0) => e0.copyWith()).toList(), + nextBeforeSequence: nextBeforeSequence is int? + ? nextBeforeSequence + : this.nextBeforeSequence, + ); + } +} diff --git a/backend/sinalacs_client/lib/src/protocol/api/admin_data_subject_request.dart b/backend/sinalacs_client/lib/src/protocol/api/admin_data_subject_request.dart new file mode 100644 index 00000000..9ba3d659 --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/api/admin_data_subject_request.dart @@ -0,0 +1,150 @@ +/* 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/data_subject_request_type.dart' as _i2; +import '../enums/data_subject_request_status.dart' as _i3; + +/// Linha da lista de pedidos do titular no backoffice. Sem nome nem CPF: +/// `patientLabel` é um rótulo curto (`#A18F`) derivado do id do titular. +abstract class AdminDataSubjectRequest implements _i1.SerializableModel { + AdminDataSubjectRequest._({ + required this.id, + required this.type, + required this.status, + required this.createdAt, + required this.dueAt, + required this.overdue, + required this.patientLabel, + }); + + factory AdminDataSubjectRequest({ + required String id, + required _i2.DataSubjectRequestType type, + required _i3.DataSubjectRequestStatus status, + required DateTime createdAt, + required DateTime dueAt, + required bool overdue, + required String patientLabel, + }) = _AdminDataSubjectRequestImpl; + + factory AdminDataSubjectRequest.fromJson( + Map jsonSerialization, + ) { + return AdminDataSubjectRequest( + id: jsonSerialization['id'] as String, + type: _i2.DataSubjectRequestType.fromJson( + (jsonSerialization['type'] as String), + ), + status: _i3.DataSubjectRequestStatus.fromJson( + (jsonSerialization['status'] as String), + ), + createdAt: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['createdAt'], + ), + dueAt: _i1.DateTimeJsonExtension.fromJson(jsonSerialization['dueAt']), + overdue: _i1.BoolJsonExtension.fromJson(jsonSerialization['overdue']), + patientLabel: jsonSerialization['patientLabel'] as String, + ); + } + + String id; + + _i2.DataSubjectRequestType type; + + _i3.DataSubjectRequestStatus status; + + DateTime createdAt; + + DateTime dueAt; + + /// Vencido: status `open`/`inReview` e agora > `dueAt`. + bool overdue; + + String patientLabel; + + /// Returns a shallow copy of this [AdminDataSubjectRequest] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + AdminDataSubjectRequest copyWith({ + String? id, + _i2.DataSubjectRequestType? type, + _i3.DataSubjectRequestStatus? status, + DateTime? createdAt, + DateTime? dueAt, + bool? overdue, + String? patientLabel, + }); + @override + Map toJson() { + return { + '__className__': 'AdminDataSubjectRequest', + 'id': id, + 'type': type.toJson(), + 'status': status.toJson(), + 'createdAt': createdAt.toJson(), + 'dueAt': dueAt.toJson(), + 'overdue': overdue, + 'patientLabel': patientLabel, + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _AdminDataSubjectRequestImpl extends AdminDataSubjectRequest { + _AdminDataSubjectRequestImpl({ + required String id, + required _i2.DataSubjectRequestType type, + required _i3.DataSubjectRequestStatus status, + required DateTime createdAt, + required DateTime dueAt, + required bool overdue, + required String patientLabel, + }) : super._( + id: id, + type: type, + status: status, + createdAt: createdAt, + dueAt: dueAt, + overdue: overdue, + patientLabel: patientLabel, + ); + + /// Returns a shallow copy of this [AdminDataSubjectRequest] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + AdminDataSubjectRequest copyWith({ + String? id, + _i2.DataSubjectRequestType? type, + _i3.DataSubjectRequestStatus? status, + DateTime? createdAt, + DateTime? dueAt, + bool? overdue, + String? patientLabel, + }) { + return AdminDataSubjectRequest( + id: id ?? this.id, + type: type ?? this.type, + status: status ?? this.status, + createdAt: createdAt ?? this.createdAt, + dueAt: dueAt ?? this.dueAt, + overdue: overdue ?? this.overdue, + patientLabel: patientLabel ?? this.patientLabel, + ); + } +} diff --git a/backend/sinalacs_client/lib/src/protocol/api/admin_data_subject_request_detail.dart b/backend/sinalacs_client/lib/src/protocol/api/admin_data_subject_request_detail.dart new file mode 100644 index 00000000..582e3b3c --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/api/admin_data_subject_request_detail.dart @@ -0,0 +1,186 @@ +/* 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/data_subject_request_type.dart' as _i2; +import '../enums/data_subject_request_status.dart' as _i3; + +/// Detalhe de um pedido do titular. `details` e `resolution` são decifrados +/// só aqui (nunca na lista) e a leitura é auditada antes do dado. +abstract class AdminDataSubjectRequestDetail implements _i1.SerializableModel { + AdminDataSubjectRequestDetail._({ + required this.id, + required this.type, + required this.status, + required this.createdAt, + required this.dueAt, + required this.overdue, + required this.patientLabel, + this.details, + this.resolution, + this.decidedAt, + }); + + factory AdminDataSubjectRequestDetail({ + required String id, + required _i2.DataSubjectRequestType type, + required _i3.DataSubjectRequestStatus status, + required DateTime createdAt, + required DateTime dueAt, + required bool overdue, + required String patientLabel, + String? details, + String? resolution, + DateTime? decidedAt, + }) = _AdminDataSubjectRequestDetailImpl; + + factory AdminDataSubjectRequestDetail.fromJson( + Map jsonSerialization, + ) { + return AdminDataSubjectRequestDetail( + id: jsonSerialization['id'] as String, + type: _i2.DataSubjectRequestType.fromJson( + (jsonSerialization['type'] as String), + ), + status: _i3.DataSubjectRequestStatus.fromJson( + (jsonSerialization['status'] as String), + ), + createdAt: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['createdAt'], + ), + dueAt: _i1.DateTimeJsonExtension.fromJson(jsonSerialization['dueAt']), + overdue: _i1.BoolJsonExtension.fromJson(jsonSerialization['overdue']), + patientLabel: jsonSerialization['patientLabel'] as String, + details: jsonSerialization['details'] as String?, + resolution: jsonSerialization['resolution'] as String?, + decidedAt: jsonSerialization['decidedAt'] == null + ? null + : _i1.DateTimeJsonExtension.fromJson(jsonSerialization['decidedAt']), + ); + } + + String id; + + _i2.DataSubjectRequestType type; + + _i3.DataSubjectRequestStatus status; + + DateTime createdAt; + + DateTime dueAt; + + bool overdue; + + String patientLabel; + + String? details; + + String? resolution; + + DateTime? decidedAt; + + /// Returns a shallow copy of this [AdminDataSubjectRequestDetail] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + AdminDataSubjectRequestDetail copyWith({ + String? id, + _i2.DataSubjectRequestType? type, + _i3.DataSubjectRequestStatus? status, + DateTime? createdAt, + DateTime? dueAt, + bool? overdue, + String? patientLabel, + String? details, + String? resolution, + DateTime? decidedAt, + }); + @override + Map toJson() { + return { + '__className__': 'AdminDataSubjectRequestDetail', + 'id': id, + 'type': type.toJson(), + 'status': status.toJson(), + 'createdAt': createdAt.toJson(), + 'dueAt': dueAt.toJson(), + 'overdue': overdue, + 'patientLabel': patientLabel, + if (details != null) 'details': details, + if (resolution != null) 'resolution': resolution, + if (decidedAt != null) 'decidedAt': decidedAt?.toJson(), + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _AdminDataSubjectRequestDetailImpl extends AdminDataSubjectRequestDetail { + _AdminDataSubjectRequestDetailImpl({ + required String id, + required _i2.DataSubjectRequestType type, + required _i3.DataSubjectRequestStatus status, + required DateTime createdAt, + required DateTime dueAt, + required bool overdue, + required String patientLabel, + String? details, + String? resolution, + DateTime? decidedAt, + }) : super._( + id: id, + type: type, + status: status, + createdAt: createdAt, + dueAt: dueAt, + overdue: overdue, + patientLabel: patientLabel, + details: details, + resolution: resolution, + decidedAt: decidedAt, + ); + + /// Returns a shallow copy of this [AdminDataSubjectRequestDetail] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + AdminDataSubjectRequestDetail copyWith({ + String? id, + _i2.DataSubjectRequestType? type, + _i3.DataSubjectRequestStatus? status, + DateTime? createdAt, + DateTime? dueAt, + bool? overdue, + String? patientLabel, + Object? details = _Undefined, + Object? resolution = _Undefined, + Object? decidedAt = _Undefined, + }) { + return AdminDataSubjectRequestDetail( + id: id ?? this.id, + type: type ?? this.type, + status: status ?? this.status, + createdAt: createdAt ?? this.createdAt, + dueAt: dueAt ?? this.dueAt, + overdue: overdue ?? this.overdue, + patientLabel: patientLabel ?? this.patientLabel, + details: details is String? ? details : this.details, + resolution: resolution is String? ? resolution : this.resolution, + decidedAt: decidedAt is DateTime? ? decidedAt : this.decidedAt, + ); + } +} diff --git a/backend/sinalacs_client/lib/src/protocol/api/admin_data_subject_request_page.dart b/backend/sinalacs_client/lib/src/protocol/api/admin_data_subject_request_page.dart new file mode 100644 index 00000000..84e22c7c --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/api/admin_data_subject_request_page.dart @@ -0,0 +1,91 @@ +/* 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 '../api/admin_data_subject_request.dart' as _i2; +import 'package:sinalacs_client/src/protocol/protocol.dart' as _i3; + +/// Página de pedidos do titular. `nextOffset` nulo = não há mais páginas. +abstract class AdminDataSubjectRequestPage implements _i1.SerializableModel { + AdminDataSubjectRequestPage._({ + required this.items, + this.nextOffset, + }); + + factory AdminDataSubjectRequestPage({ + required List<_i2.AdminDataSubjectRequest> items, + int? nextOffset, + }) = _AdminDataSubjectRequestPageImpl; + + factory AdminDataSubjectRequestPage.fromJson( + Map jsonSerialization, + ) { + return AdminDataSubjectRequestPage( + items: _i3.Protocol().deserialize>( + jsonSerialization['items'], + ), + nextOffset: jsonSerialization['nextOffset'] as int?, + ); + } + + List<_i2.AdminDataSubjectRequest> items; + + int? nextOffset; + + /// Returns a shallow copy of this [AdminDataSubjectRequestPage] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + AdminDataSubjectRequestPage copyWith({ + List<_i2.AdminDataSubjectRequest>? items, + int? nextOffset, + }); + @override + Map toJson() { + return { + '__className__': 'AdminDataSubjectRequestPage', + 'items': items.toJson(valueToJson: (v) => v.toJson()), + if (nextOffset != null) 'nextOffset': nextOffset, + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _AdminDataSubjectRequestPageImpl extends AdminDataSubjectRequestPage { + _AdminDataSubjectRequestPageImpl({ + required List<_i2.AdminDataSubjectRequest> items, + int? nextOffset, + }) : super._( + items: items, + nextOffset: nextOffset, + ); + + /// Returns a shallow copy of this [AdminDataSubjectRequestPage] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + AdminDataSubjectRequestPage copyWith({ + List<_i2.AdminDataSubjectRequest>? items, + Object? nextOffset = _Undefined, + }) { + return AdminDataSubjectRequestPage( + items: items ?? this.items.map((e0) => e0.copyWith()).toList(), + nextOffset: nextOffset is int? ? nextOffset : this.nextOffset, + ); + } +} diff --git a/backend/sinalacs_client/lib/src/protocol/api/admin_escalation_result.dart b/backend/sinalacs_client/lib/src/protocol/api/admin_escalation_result.dart new file mode 100644 index 00000000..3d8c00d2 --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/api/admin_escalation_result.dart @@ -0,0 +1,101 @@ +/* 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; + +/// Resultado de escalonar um alerta pelo backoffice. `alreadyEscalated` torna +/// a operação idempotente: repetir o pedido não duplica o escalonamento. +abstract class AdminEscalationResult implements _i1.SerializableModel { + AdminEscalationResult._({ + required this.alertId, + required this.escalatedAt, + required this.alreadyEscalated, + }); + + factory AdminEscalationResult({ + required String alertId, + required DateTime escalatedAt, + required bool alreadyEscalated, + }) = _AdminEscalationResultImpl; + + factory AdminEscalationResult.fromJson( + Map jsonSerialization, + ) { + return AdminEscalationResult( + alertId: jsonSerialization['alertId'] as String, + escalatedAt: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['escalatedAt'], + ), + alreadyEscalated: _i1.BoolJsonExtension.fromJson( + jsonSerialization['alreadyEscalated'], + ), + ); + } + + String alertId; + + DateTime escalatedAt; + + bool alreadyEscalated; + + /// Returns a shallow copy of this [AdminEscalationResult] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + AdminEscalationResult copyWith({ + String? alertId, + DateTime? escalatedAt, + bool? alreadyEscalated, + }); + @override + Map toJson() { + return { + '__className__': 'AdminEscalationResult', + 'alertId': alertId, + 'escalatedAt': escalatedAt.toJson(), + 'alreadyEscalated': alreadyEscalated, + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _AdminEscalationResultImpl extends AdminEscalationResult { + _AdminEscalationResultImpl({ + required String alertId, + required DateTime escalatedAt, + required bool alreadyEscalated, + }) : super._( + alertId: alertId, + escalatedAt: escalatedAt, + alreadyEscalated: alreadyEscalated, + ); + + /// Returns a shallow copy of this [AdminEscalationResult] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + AdminEscalationResult copyWith({ + String? alertId, + DateTime? escalatedAt, + bool? alreadyEscalated, + }) { + return AdminEscalationResult( + alertId: alertId ?? this.alertId, + escalatedAt: escalatedAt ?? this.escalatedAt, + alreadyEscalated: alreadyEscalated ?? this.alreadyEscalated, + ); + } +} diff --git a/backend/sinalacs_client/lib/src/protocol/api/admin_indicators.dart b/backend/sinalacs_client/lib/src/protocol/api/admin_indicators.dart new file mode 100644 index 00000000..83eb2701 --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/api/admin_indicators.dart @@ -0,0 +1,133 @@ +/* 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; + +/// Indicadores do painel do backoffice (issue #40). Contagens por risco e o +/// TMRAV, no escopo do chamador (sistema inteiro para o administrador, a UBS +/// para o coordenador). `tmravSeconds` é nulo quando não há alerta vermelho +/// reconhecido na janela: sem amostra não há média, e zero seria mentira. +abstract class AdminIndicators implements _i1.SerializableModel { + AdminIndicators._({ + required this.red, + required this.yellow, + required this.green, + required this.openRedAlerts, + required this.acknowledgedRedAlerts, + this.tmravSeconds, + }); + + factory AdminIndicators({ + required int red, + required int yellow, + required int green, + required int openRedAlerts, + required int acknowledgedRedAlerts, + int? tmravSeconds, + }) = _AdminIndicatorsImpl; + + factory AdminIndicators.fromJson(Map jsonSerialization) { + return AdminIndicators( + red: jsonSerialization['red'] as int, + yellow: jsonSerialization['yellow'] as int, + green: jsonSerialization['green'] as int, + openRedAlerts: jsonSerialization['openRedAlerts'] as int, + acknowledgedRedAlerts: jsonSerialization['acknowledgedRedAlerts'] as int, + tmravSeconds: jsonSerialization['tmravSeconds'] as int?, + ); + } + + int red; + + int yellow; + + int green; + + int openRedAlerts; + + int acknowledgedRedAlerts; + + int? tmravSeconds; + + /// Returns a shallow copy of this [AdminIndicators] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + AdminIndicators copyWith({ + int? red, + int? yellow, + int? green, + int? openRedAlerts, + int? acknowledgedRedAlerts, + int? tmravSeconds, + }); + @override + Map toJson() { + return { + '__className__': 'AdminIndicators', + 'red': red, + 'yellow': yellow, + 'green': green, + 'openRedAlerts': openRedAlerts, + 'acknowledgedRedAlerts': acknowledgedRedAlerts, + if (tmravSeconds != null) 'tmravSeconds': tmravSeconds, + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _AdminIndicatorsImpl extends AdminIndicators { + _AdminIndicatorsImpl({ + required int red, + required int yellow, + required int green, + required int openRedAlerts, + required int acknowledgedRedAlerts, + int? tmravSeconds, + }) : super._( + red: red, + yellow: yellow, + green: green, + openRedAlerts: openRedAlerts, + acknowledgedRedAlerts: acknowledgedRedAlerts, + tmravSeconds: tmravSeconds, + ); + + /// Returns a shallow copy of this [AdminIndicators] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + AdminIndicators copyWith({ + int? red, + int? yellow, + int? green, + int? openRedAlerts, + int? acknowledgedRedAlerts, + Object? tmravSeconds = _Undefined, + }) { + return AdminIndicators( + red: red ?? this.red, + yellow: yellow ?? this.yellow, + green: green ?? this.green, + openRedAlerts: openRedAlerts ?? this.openRedAlerts, + acknowledgedRedAlerts: + acknowledgedRedAlerts ?? this.acknowledgedRedAlerts, + tmravSeconds: tmravSeconds is int? ? tmravSeconds : this.tmravSeconds, + ); + } +} diff --git a/backend/sinalacs_client/lib/src/protocol/api/admin_micro_area.dart b/backend/sinalacs_client/lib/src/protocol/api/admin_micro_area.dart new file mode 100644 index 00000000..ab76ff42 --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/api/admin_micro_area.dart @@ -0,0 +1,116 @@ +/* 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; + +/// Microárea e o ACS vinculado, para a listagem somente leitura do backoffice. +abstract class AdminMicroArea implements _i1.SerializableModel { + AdminMicroArea._({ + required this.id, + required this.name, + required this.acsName, + required this.acsEnrollmentId, + required this.acsActive, + }); + + factory AdminMicroArea({ + required String id, + required String name, + required String acsName, + required String acsEnrollmentId, + required bool acsActive, + }) = _AdminMicroAreaImpl; + + factory AdminMicroArea.fromJson(Map jsonSerialization) { + return AdminMicroArea( + id: jsonSerialization['id'] as String, + name: jsonSerialization['name'] as String, + acsName: jsonSerialization['acsName'] as String, + acsEnrollmentId: jsonSerialization['acsEnrollmentId'] as String, + acsActive: _i1.BoolJsonExtension.fromJson(jsonSerialization['acsActive']), + ); + } + + String id; + + String name; + + String acsName; + + String acsEnrollmentId; + + bool acsActive; + + /// Returns a shallow copy of this [AdminMicroArea] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + AdminMicroArea copyWith({ + String? id, + String? name, + String? acsName, + String? acsEnrollmentId, + bool? acsActive, + }); + @override + Map toJson() { + return { + '__className__': 'AdminMicroArea', + 'id': id, + 'name': name, + 'acsName': acsName, + 'acsEnrollmentId': acsEnrollmentId, + 'acsActive': acsActive, + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _AdminMicroAreaImpl extends AdminMicroArea { + _AdminMicroAreaImpl({ + required String id, + required String name, + required String acsName, + required String acsEnrollmentId, + required bool acsActive, + }) : super._( + id: id, + name: name, + acsName: acsName, + acsEnrollmentId: acsEnrollmentId, + acsActive: acsActive, + ); + + /// Returns a shallow copy of this [AdminMicroArea] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + AdminMicroArea copyWith({ + String? id, + String? name, + String? acsName, + String? acsEnrollmentId, + bool? acsActive, + }) { + return AdminMicroArea( + id: id ?? this.id, + name: name ?? this.name, + acsName: acsName ?? this.acsName, + acsEnrollmentId: acsEnrollmentId ?? this.acsEnrollmentId, + acsActive: acsActive ?? this.acsActive, + ); + } +} diff --git a/backend/sinalacs_client/lib/src/protocol/api/admin_password_reset_result.dart b/backend/sinalacs_client/lib/src/protocol/api/admin_password_reset_result.dart new file mode 100644 index 00000000..c51230de --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/api/admin_password_reset_result.dart @@ -0,0 +1,71 @@ +/* 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; + +/// Senha nova do ACS redefinida pelo backoffice (issue #43). +/// +/// Existe **só** nesta resposta: não há coluna, log nem linha de auditoria que +/// a guarde — o servidor persiste apenas o hash Argon2id em `user_credentials`. +/// A senha é sorteada pelo servidor (o operador não a escolhe), pelo mesmo +/// motivo da senha inicial: uma senha escolhida por quem tem pressa vira a +/// credencial do RF07. Quem redefine mostra uma vez; perdida de novo, o caminho +/// é redefinir outra vez, nunca recuperar. +abstract class AdminPasswordResetResult implements _i1.SerializableModel { + AdminPasswordResetResult._({required this.newPassword}); + + factory AdminPasswordResetResult({required String newPassword}) = + _AdminPasswordResetResultImpl; + + factory AdminPasswordResetResult.fromJson( + Map jsonSerialization, + ) { + return AdminPasswordResetResult( + newPassword: jsonSerialization['newPassword'] as String, + ); + } + + String newPassword; + + /// Returns a shallow copy of this [AdminPasswordResetResult] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + AdminPasswordResetResult copyWith({String? newPassword}); + @override + Map toJson() { + return { + '__className__': 'AdminPasswordResetResult', + 'newPassword': newPassword, + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _AdminPasswordResetResultImpl extends AdminPasswordResetResult { + _AdminPasswordResetResultImpl({required String newPassword}) + : super._(newPassword: newPassword); + + /// Returns a shallow copy of this [AdminPasswordResetResult] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + AdminPasswordResetResult copyWith({String? newPassword}) { + return AdminPasswordResetResult( + newPassword: newPassword ?? this.newPassword, + ); + } +} diff --git a/backend/sinalacs_client/lib/src/protocol/api/admin_staff.dart b/backend/sinalacs_client/lib/src/protocol/api/admin_staff.dart new file mode 100644 index 00000000..fcec5c79 --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/api/admin_staff.dart @@ -0,0 +1,146 @@ +/* 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/user_role.dart' as _i2; + +/// Conta de equipe do backoffice (#43): identificação profissional, papel, +/// UBS e estado de acesso. Nunca inclui CPF, senha ou contato. +/// +/// `ubsName` é nulo para o administrador (vê o sistema inteiro) e para o +/// coordenador ainda sem UBS — que é recusado em tudo até ter uma. +/// `mfaActive` tem a mesma leitura de `AdminAcs`: `totpEnabledAt` preenchido. +abstract class AdminStaff implements _i1.SerializableModel { + AdminStaff._({ + required this.id, + required this.name, + required this.enrollmentId, + required this.role, + this.ubsName, + required this.active, + required this.mfaActive, + }); + + factory AdminStaff({ + required String id, + required String name, + required String enrollmentId, + required _i2.UserRole role, + String? ubsName, + required bool active, + required bool mfaActive, + }) = _AdminStaffImpl; + + factory AdminStaff.fromJson(Map jsonSerialization) { + return AdminStaff( + id: jsonSerialization['id'] as String, + name: jsonSerialization['name'] as String, + enrollmentId: jsonSerialization['enrollmentId'] as String, + role: _i2.UserRole.fromJson((jsonSerialization['role'] as String)), + ubsName: jsonSerialization['ubsName'] as String?, + active: _i1.BoolJsonExtension.fromJson(jsonSerialization['active']), + mfaActive: _i1.BoolJsonExtension.fromJson(jsonSerialization['mfaActive']), + ); + } + + String id; + + String name; + + String enrollmentId; + + _i2.UserRole role; + + String? ubsName; + + bool active; + + bool mfaActive; + + /// Returns a shallow copy of this [AdminStaff] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + AdminStaff copyWith({ + String? id, + String? name, + String? enrollmentId, + _i2.UserRole? role, + String? ubsName, + bool? active, + bool? mfaActive, + }); + @override + Map toJson() { + return { + '__className__': 'AdminStaff', + 'id': id, + 'name': name, + 'enrollmentId': enrollmentId, + 'role': role.toJson(), + if (ubsName != null) 'ubsName': ubsName, + 'active': active, + 'mfaActive': mfaActive, + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _AdminStaffImpl extends AdminStaff { + _AdminStaffImpl({ + required String id, + required String name, + required String enrollmentId, + required _i2.UserRole role, + String? ubsName, + required bool active, + required bool mfaActive, + }) : super._( + id: id, + name: name, + enrollmentId: enrollmentId, + role: role, + ubsName: ubsName, + active: active, + mfaActive: mfaActive, + ); + + /// Returns a shallow copy of this [AdminStaff] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + AdminStaff copyWith({ + String? id, + String? name, + String? enrollmentId, + _i2.UserRole? role, + Object? ubsName = _Undefined, + bool? active, + bool? mfaActive, + }) { + return AdminStaff( + id: id ?? this.id, + name: name ?? this.name, + enrollmentId: enrollmentId ?? this.enrollmentId, + role: role ?? this.role, + ubsName: ubsName is String? ? ubsName : this.ubsName, + active: active ?? this.active, + mfaActive: mfaActive ?? this.mfaActive, + ); + } +} diff --git a/backend/sinalacs_client/lib/src/protocol/api/admin_staff_mfa_reset_result.dart b/backend/sinalacs_client/lib/src/protocol/api/admin_staff_mfa_reset_result.dart new file mode 100644 index 00000000..ec7351ba --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/api/admin_staff_mfa_reset_result.dart @@ -0,0 +1,100 @@ +/* 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; + +/// Redefinição da MFA de uma conta de equipe pelo backoffice (issue #43), com +/// o código de ativação novo emitido **pela tela** — o deferimento da #48. +/// +/// Fecha a pendência registrada no `PROGRESS.md` (L665): até aqui só a CLI de +/// operador `bin/issue_staff_activation_code.dart` emitia, e a emissão não +/// entrava em `audit_logs`. Agora a operação faz as duas coisas numa transação +/// só — zera as quatro colunas `totp*` da conta e grava +/// `activationCodeHash/ExpiresAt/IssuedBy/IssuedAt` — e a linha +/// `admin_staff`/`mfa_reset` com o id do alvo fica na trilha. +/// +/// O código existe **só** nesta resposta: o servidor guarda apenas o SHA-256 +/// (`StaffActivationCode.hash`), como no `#48`. Quem redefine mostra uma vez; +/// perdido de novo, o caminho é redefinir outra vez, nunca recuperar. +abstract class AdminStaffMfaResetResult implements _i1.SerializableModel { + AdminStaffMfaResetResult._({ + required this.activationCode, + required this.activationCodeExpiresAt, + }); + + factory AdminStaffMfaResetResult({ + required String activationCode, + required DateTime activationCodeExpiresAt, + }) = _AdminStaffMfaResetResultImpl; + + factory AdminStaffMfaResetResult.fromJson( + Map jsonSerialization, + ) { + return AdminStaffMfaResetResult( + activationCode: jsonSerialization['activationCode'] as String, + activationCodeExpiresAt: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['activationCodeExpiresAt'], + ), + ); + } + + String activationCode; + + DateTime activationCodeExpiresAt; + + /// Returns a shallow copy of this [AdminStaffMfaResetResult] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + AdminStaffMfaResetResult copyWith({ + String? activationCode, + DateTime? activationCodeExpiresAt, + }); + @override + Map toJson() { + return { + '__className__': 'AdminStaffMfaResetResult', + 'activationCode': activationCode, + 'activationCodeExpiresAt': activationCodeExpiresAt.toJson(), + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _AdminStaffMfaResetResultImpl extends AdminStaffMfaResetResult { + _AdminStaffMfaResetResultImpl({ + required String activationCode, + required DateTime activationCodeExpiresAt, + }) : super._( + activationCode: activationCode, + activationCodeExpiresAt: activationCodeExpiresAt, + ); + + /// Returns a shallow copy of this [AdminStaffMfaResetResult] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + AdminStaffMfaResetResult copyWith({ + String? activationCode, + DateTime? activationCodeExpiresAt, + }) { + return AdminStaffMfaResetResult( + activationCode: activationCode ?? this.activationCode, + activationCodeExpiresAt: + activationCodeExpiresAt ?? this.activationCodeExpiresAt, + ); + } +} diff --git a/backend/sinalacs_client/lib/src/protocol/api/development_login_result.dart b/backend/sinalacs_client/lib/src/protocol/api/development_login_result.dart index 5d52ea99..9b69faf2 100644 --- a/backend/sinalacs_client/lib/src/protocol/api/development_login_result.dart +++ b/backend/sinalacs_client/lib/src/protocol/api/development_login_result.dart @@ -18,11 +18,15 @@ abstract class DevelopmentLoginResult implements _i1.SerializableModel { DevelopmentLoginResult._({ required this.accessToken, required this.tokenType, + this.refreshToken, + this.uploadToken, }); factory DevelopmentLoginResult({ required String accessToken, required String tokenType, + String? refreshToken, + String? uploadToken, }) = _DevelopmentLoginResultImpl; factory DevelopmentLoginResult.fromJson( @@ -31,6 +35,8 @@ abstract class DevelopmentLoginResult implements _i1.SerializableModel { return DevelopmentLoginResult( accessToken: jsonSerialization['accessToken'] as String, tokenType: jsonSerialization['tokenType'] as String, + refreshToken: jsonSerialization['refreshToken'] as String?, + uploadToken: jsonSerialization['uploadToken'] as String?, ); } @@ -38,12 +44,24 @@ abstract class DevelopmentLoginResult implements _i1.SerializableModel { String tokenType; + /// Só no `loginInstitutional` e no `refreshSession`. `null` nos demais + /// emissores (paciente, `developmentLogin`). + String? refreshToken; + + /// Token de envio diferido (D7): só no `loginInstitutional` com `deviceId` + /// real. Serve apenas a `visits.syncDeferred`/`visits.revokeUploadToken`. + /// `null` em todos os outros emissores (paciente, `developmentLogin`, + /// `refreshSession`). + String? uploadToken; + /// Returns a shallow copy of this [DevelopmentLoginResult] /// with some or all fields replaced by the given arguments. @_i1.useResult DevelopmentLoginResult copyWith({ String? accessToken, String? tokenType, + String? refreshToken, + String? uploadToken, }); @override Map toJson() { @@ -51,6 +69,8 @@ abstract class DevelopmentLoginResult implements _i1.SerializableModel { '__className__': 'DevelopmentLoginResult', 'accessToken': accessToken, 'tokenType': tokenType, + if (refreshToken != null) 'refreshToken': refreshToken, + if (uploadToken != null) 'uploadToken': uploadToken, }; } @@ -60,13 +80,19 @@ abstract class DevelopmentLoginResult implements _i1.SerializableModel { } } +class _Undefined {} + class _DevelopmentLoginResultImpl extends DevelopmentLoginResult { _DevelopmentLoginResultImpl({ required String accessToken, required String tokenType, + String? refreshToken, + String? uploadToken, }) : super._( accessToken: accessToken, tokenType: tokenType, + refreshToken: refreshToken, + uploadToken: uploadToken, ); /// Returns a shallow copy of this [DevelopmentLoginResult] @@ -76,10 +102,14 @@ class _DevelopmentLoginResultImpl extends DevelopmentLoginResult { DevelopmentLoginResult copyWith({ String? accessToken, String? tokenType, + Object? refreshToken = _Undefined, + Object? uploadToken = _Undefined, }) { return DevelopmentLoginResult( accessToken: accessToken ?? this.accessToken, tokenType: tokenType ?? this.tokenType, + refreshToken: refreshToken is String? ? refreshToken : this.refreshToken, + uploadToken: uploadToken is String? ? uploadToken : this.uploadToken, ); } } diff --git a/backend/sinalacs_client/lib/src/protocol/api/notice_history_entry.dart b/backend/sinalacs_client/lib/src/protocol/api/notice_history_entry.dart new file mode 100644 index 00000000..538e3cdb --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/api/notice_history_entry.dart @@ -0,0 +1,209 @@ +/* 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/scheduled_notice_status.dart' as _i2; + +/// Linha do histórico de avisos agendados (#46). Deliberadamente SEM o texto +/// da mensagem: o backoffice lista título, alvo e resultado. +/// +/// `createdByLabel` é a matrícula de quem agendou ("—" se a conta não tem). +/// `recipients`/`accepted` só existem depois do envio; `failureReason` só em +/// `failed`/`unknown`. +abstract class NoticeHistoryEntry implements _i1.SerializableModel { + NoticeHistoryEntry._({ + required this.id, + required this.microAreaId, + required this.microAreaName, + required this.audience, + required this.title, + required this.status, + required this.scheduledFor, + this.sentAt, + this.recipients, + this.accepted, + this.failureReason, + required this.createdByLabel, + }); + + factory NoticeHistoryEntry({ + required String id, + required String microAreaId, + required String microAreaName, + required String audience, + required String title, + required _i2.ScheduledNoticeStatus status, + required DateTime scheduledFor, + DateTime? sentAt, + int? recipients, + int? accepted, + String? failureReason, + required String createdByLabel, + }) = _NoticeHistoryEntryImpl; + + factory NoticeHistoryEntry.fromJson(Map jsonSerialization) { + return NoticeHistoryEntry( + id: jsonSerialization['id'] as String, + microAreaId: jsonSerialization['microAreaId'] as String, + microAreaName: jsonSerialization['microAreaName'] as String, + audience: jsonSerialization['audience'] as String, + title: jsonSerialization['title'] as String, + status: _i2.ScheduledNoticeStatus.fromJson( + (jsonSerialization['status'] as String), + ), + scheduledFor: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['scheduledFor'], + ), + sentAt: jsonSerialization['sentAt'] == null + ? null + : _i1.DateTimeJsonExtension.fromJson(jsonSerialization['sentAt']), + recipients: jsonSerialization['recipients'] as int?, + accepted: jsonSerialization['accepted'] as int?, + failureReason: jsonSerialization['failureReason'] as String?, + createdByLabel: jsonSerialization['createdByLabel'] as String, + ); + } + + String id; + + String microAreaId; + + String microAreaName; + + String audience; + + String title; + + _i2.ScheduledNoticeStatus status; + + DateTime scheduledFor; + + DateTime? sentAt; + + int? recipients; + + int? accepted; + + String? failureReason; + + String createdByLabel; + + /// Returns a shallow copy of this [NoticeHistoryEntry] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + NoticeHistoryEntry copyWith({ + String? id, + String? microAreaId, + String? microAreaName, + String? audience, + String? title, + _i2.ScheduledNoticeStatus? status, + DateTime? scheduledFor, + DateTime? sentAt, + int? recipients, + int? accepted, + String? failureReason, + String? createdByLabel, + }); + @override + Map toJson() { + return { + '__className__': 'NoticeHistoryEntry', + 'id': id, + 'microAreaId': microAreaId, + 'microAreaName': microAreaName, + 'audience': audience, + 'title': title, + 'status': status.toJson(), + 'scheduledFor': scheduledFor.toJson(), + if (sentAt != null) 'sentAt': sentAt?.toJson(), + if (recipients != null) 'recipients': recipients, + if (accepted != null) 'accepted': accepted, + if (failureReason != null) 'failureReason': failureReason, + 'createdByLabel': createdByLabel, + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _NoticeHistoryEntryImpl extends NoticeHistoryEntry { + _NoticeHistoryEntryImpl({ + required String id, + required String microAreaId, + required String microAreaName, + required String audience, + required String title, + required _i2.ScheduledNoticeStatus status, + required DateTime scheduledFor, + DateTime? sentAt, + int? recipients, + int? accepted, + String? failureReason, + required String createdByLabel, + }) : super._( + id: id, + microAreaId: microAreaId, + microAreaName: microAreaName, + audience: audience, + title: title, + status: status, + scheduledFor: scheduledFor, + sentAt: sentAt, + recipients: recipients, + accepted: accepted, + failureReason: failureReason, + createdByLabel: createdByLabel, + ); + + /// Returns a shallow copy of this [NoticeHistoryEntry] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + NoticeHistoryEntry copyWith({ + String? id, + String? microAreaId, + String? microAreaName, + String? audience, + String? title, + _i2.ScheduledNoticeStatus? status, + DateTime? scheduledFor, + Object? sentAt = _Undefined, + Object? recipients = _Undefined, + Object? accepted = _Undefined, + Object? failureReason = _Undefined, + String? createdByLabel, + }) { + return NoticeHistoryEntry( + id: id ?? this.id, + microAreaId: microAreaId ?? this.microAreaId, + microAreaName: microAreaName ?? this.microAreaName, + audience: audience ?? this.audience, + title: title ?? this.title, + status: status ?? this.status, + scheduledFor: scheduledFor ?? this.scheduledFor, + sentAt: sentAt is DateTime? ? sentAt : this.sentAt, + recipients: recipients is int? ? recipients : this.recipients, + accepted: accepted is int? ? accepted : this.accepted, + failureReason: failureReason is String? + ? failureReason + : this.failureReason, + createdByLabel: createdByLabel ?? this.createdByLabel, + ); + } +} diff --git a/backend/sinalacs_client/lib/src/protocol/api/notice_history_page.dart b/backend/sinalacs_client/lib/src/protocol/api/notice_history_page.dart new file mode 100644 index 00000000..5f89c379 --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/api/notice_history_page.dart @@ -0,0 +1,66 @@ +/* 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 '../api/notice_history_entry.dart' as _i2; +import 'package:sinalacs_client/src/protocol/protocol.dart' as _i3; + +/// Página do histórico de avisos agendados (#46), mais recentes primeiro. +abstract class NoticeHistoryPage implements _i1.SerializableModel { + NoticeHistoryPage._({required this.entries}); + + factory NoticeHistoryPage({required List<_i2.NoticeHistoryEntry> entries}) = + _NoticeHistoryPageImpl; + + factory NoticeHistoryPage.fromJson(Map jsonSerialization) { + return NoticeHistoryPage( + entries: _i3.Protocol().deserialize>( + jsonSerialization['entries'], + ), + ); + } + + List<_i2.NoticeHistoryEntry> entries; + + /// Returns a shallow copy of this [NoticeHistoryPage] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + NoticeHistoryPage copyWith({List<_i2.NoticeHistoryEntry>? entries}); + @override + Map toJson() { + return { + '__className__': 'NoticeHistoryPage', + 'entries': entries.toJson(valueToJson: (v) => v.toJson()), + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _NoticeHistoryPageImpl extends NoticeHistoryPage { + _NoticeHistoryPageImpl({required List<_i2.NoticeHistoryEntry> entries}) + : super._(entries: entries); + + /// Returns a shallow copy of this [NoticeHistoryPage] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + NoticeHistoryPage copyWith({List<_i2.NoticeHistoryEntry>? entries}) { + return NoticeHistoryPage( + entries: entries ?? this.entries.map((e0) => e0.copyWith()).toList(), + ); + } +} diff --git a/backend/sinalacs_client/lib/src/protocol/api/notice_send_result.dart b/backend/sinalacs_client/lib/src/protocol/api/notice_send_result.dart new file mode 100644 index 00000000..c0b98444 --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/api/notice_send_result.dart @@ -0,0 +1,85 @@ +/* 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; + +/// Resultado de `notices.sendSegmented` (RF14): quantos pacientes da microárea +/// consentiram em receber o aviso e quantas notificações o relé aceitou. +/// Só contagens — nunca a lista de destinatários. +abstract class NoticeSendResult implements _i1.SerializableModel { + NoticeSendResult._({ + required this.recipients, + required this.accepted, + }); + + factory NoticeSendResult({ + required int recipients, + required int accepted, + }) = _NoticeSendResultImpl; + + factory NoticeSendResult.fromJson(Map jsonSerialization) { + return NoticeSendResult( + recipients: jsonSerialization['recipients'] as int, + accepted: jsonSerialization['accepted'] as int, + ); + } + + int recipients; + + int accepted; + + /// Returns a shallow copy of this [NoticeSendResult] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + NoticeSendResult copyWith({ + int? recipients, + int? accepted, + }); + @override + Map toJson() { + return { + '__className__': 'NoticeSendResult', + 'recipients': recipients, + 'accepted': accepted, + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _NoticeSendResultImpl extends NoticeSendResult { + _NoticeSendResultImpl({ + required int recipients, + required int accepted, + }) : super._( + recipients: recipients, + accepted: accepted, + ); + + /// Returns a shallow copy of this [NoticeSendResult] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + NoticeSendResult copyWith({ + int? recipients, + int? accepted, + }) { + return NoticeSendResult( + recipients: recipients ?? this.recipients, + accepted: accepted ?? this.accepted, + ); + } +} diff --git a/backend/sinalacs_client/lib/src/protocol/api/patient_data_overview.dart b/backend/sinalacs_client/lib/src/protocol/api/patient_data_overview.dart index a1b57a7f..4294cb9f 100644 --- a/backend/sinalacs_client/lib/src/protocol/api/patient_data_overview.dart +++ b/backend/sinalacs_client/lib/src/protocol/api/patient_data_overview.dart @@ -14,7 +14,8 @@ import 'package:serverpod_client/serverpod_client.dart' as _i1; import '../api/patient_consent_record.dart' as _i2; import '../api/patient_risk_event.dart' as _i3; -import 'package:sinalacs_client/src/protocol/protocol.dart' as _i4; +import '../api/patient_data_subject_request_record.dart' as _i4; +import 'package:sinalacs_client/src/protocol/protocol.dart' as _i5; /// Painel "Meus Dados" do próprio paciente autenticado — confirmação de /// existência de tratamento e acesso aos dados pessoais, critério de aceite @@ -31,6 +32,7 @@ abstract class PatientDataOverview implements _i1.SerializableModel { required this.chronicConditions, required this.consents, required this.riskHistory, + required this.requests, }); factory PatientDataOverview({ @@ -41,6 +43,7 @@ abstract class PatientDataOverview implements _i1.SerializableModel { required List chronicConditions, required List<_i2.PatientConsentRecord> consents, required List<_i3.PatientRiskEvent> riskHistory, + required List<_i4.PatientDataSubjectRequestRecord> requests, }) = _PatientDataOverviewImpl; factory PatientDataOverview.fromJson(Map jsonSerialization) { @@ -51,15 +54,19 @@ abstract class PatientDataOverview implements _i1.SerializableModel { ), emergencyContact: jsonSerialization['emergencyContact'] as String, isChronic: _i1.BoolJsonExtension.fromJson(jsonSerialization['isChronic']), - chronicConditions: _i4.Protocol().deserialize>( + chronicConditions: _i5.Protocol().deserialize>( jsonSerialization['chronicConditions'], ), - consents: _i4.Protocol().deserialize>( + consents: _i5.Protocol().deserialize>( jsonSerialization['consents'], ), - riskHistory: _i4.Protocol().deserialize>( + riskHistory: _i5.Protocol().deserialize>( jsonSerialization['riskHistory'], ), + requests: _i5.Protocol() + .deserialize>( + jsonSerialization['requests'], + ), ); } @@ -77,6 +84,8 @@ abstract class PatientDataOverview implements _i1.SerializableModel { List<_i3.PatientRiskEvent> riskHistory; + List<_i4.PatientDataSubjectRequestRecord> requests; + /// Returns a shallow copy of this [PatientDataOverview] /// with some or all fields replaced by the given arguments. @_i1.useResult @@ -88,6 +97,7 @@ abstract class PatientDataOverview implements _i1.SerializableModel { List? chronicConditions, List<_i2.PatientConsentRecord>? consents, List<_i3.PatientRiskEvent>? riskHistory, + List<_i4.PatientDataSubjectRequestRecord>? requests, }); @override Map toJson() { @@ -100,6 +110,7 @@ abstract class PatientDataOverview implements _i1.SerializableModel { 'chronicConditions': chronicConditions.toJson(), 'consents': consents.toJson(valueToJson: (v) => v.toJson()), 'riskHistory': riskHistory.toJson(valueToJson: (v) => v.toJson()), + 'requests': requests.toJson(valueToJson: (v) => v.toJson()), }; } @@ -118,6 +129,7 @@ class _PatientDataOverviewImpl extends PatientDataOverview { required List chronicConditions, required List<_i2.PatientConsentRecord> consents, required List<_i3.PatientRiskEvent> riskHistory, + required List<_i4.PatientDataSubjectRequestRecord> requests, }) : super._( name: name, birthDate: birthDate, @@ -126,6 +138,7 @@ class _PatientDataOverviewImpl extends PatientDataOverview { chronicConditions: chronicConditions, consents: consents, riskHistory: riskHistory, + requests: requests, ); /// Returns a shallow copy of this [PatientDataOverview] @@ -140,6 +153,7 @@ class _PatientDataOverviewImpl extends PatientDataOverview { List? chronicConditions, List<_i2.PatientConsentRecord>? consents, List<_i3.PatientRiskEvent>? riskHistory, + List<_i4.PatientDataSubjectRequestRecord>? requests, }) { return PatientDataOverview( name: name ?? this.name, @@ -151,6 +165,7 @@ class _PatientDataOverviewImpl extends PatientDataOverview { consents: consents ?? this.consents.map((e0) => e0.copyWith()).toList(), riskHistory: riskHistory ?? this.riskHistory.map((e0) => e0.copyWith()).toList(), + requests: requests ?? this.requests.map((e0) => e0.copyWith()).toList(), ); } } diff --git a/backend/sinalacs_client/lib/src/protocol/api/patient_data_subject_request_record.dart b/backend/sinalacs_client/lib/src/protocol/api/patient_data_subject_request_record.dart new file mode 100644 index 00000000..a7807e29 --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/api/patient_data_subject_request_record.dart @@ -0,0 +1,143 @@ +/* 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/data_subject_request_type.dart' as _i2; +import '../enums/data_subject_request_status.dart' as _i3; + +/// Um pedido do próprio titular, para o painel "Meus Dados". `details` já +/// decifrado — é texto que o próprio titular escreveu (direito de acesso). +abstract class PatientDataSubjectRequestRecord + implements _i1.SerializableModel { + PatientDataSubjectRequestRecord._({ + required this.type, + required this.status, + this.details, + required this.createdAt, + required this.dueAt, + this.resolution, + }); + + factory PatientDataSubjectRequestRecord({ + required _i2.DataSubjectRequestType type, + required _i3.DataSubjectRequestStatus status, + String? details, + required DateTime createdAt, + required DateTime dueAt, + String? resolution, + }) = _PatientDataSubjectRequestRecordImpl; + + factory PatientDataSubjectRequestRecord.fromJson( + Map jsonSerialization, + ) { + return PatientDataSubjectRequestRecord( + type: _i2.DataSubjectRequestType.fromJson( + (jsonSerialization['type'] as String), + ), + status: _i3.DataSubjectRequestStatus.fromJson( + (jsonSerialization['status'] as String), + ), + details: jsonSerialization['details'] as String?, + createdAt: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['createdAt'], + ), + dueAt: _i1.DateTimeJsonExtension.fromJson(jsonSerialization['dueAt']), + resolution: jsonSerialization['resolution'] as String?, + ); + } + + _i2.DataSubjectRequestType type; + + _i3.DataSubjectRequestStatus status; + + String? details; + + DateTime createdAt; + + DateTime dueAt; + + /// Nota de resposta do backoffice, já decifrada; nula até a decisão. + String? resolution; + + /// Returns a shallow copy of this [PatientDataSubjectRequestRecord] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + PatientDataSubjectRequestRecord copyWith({ + _i2.DataSubjectRequestType? type, + _i3.DataSubjectRequestStatus? status, + String? details, + DateTime? createdAt, + DateTime? dueAt, + String? resolution, + }); + @override + Map toJson() { + return { + '__className__': 'PatientDataSubjectRequestRecord', + 'type': type.toJson(), + 'status': status.toJson(), + if (details != null) 'details': details, + 'createdAt': createdAt.toJson(), + 'dueAt': dueAt.toJson(), + if (resolution != null) 'resolution': resolution, + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _PatientDataSubjectRequestRecordImpl + extends PatientDataSubjectRequestRecord { + _PatientDataSubjectRequestRecordImpl({ + required _i2.DataSubjectRequestType type, + required _i3.DataSubjectRequestStatus status, + String? details, + required DateTime createdAt, + required DateTime dueAt, + String? resolution, + }) : super._( + type: type, + status: status, + details: details, + createdAt: createdAt, + dueAt: dueAt, + resolution: resolution, + ); + + /// Returns a shallow copy of this [PatientDataSubjectRequestRecord] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + PatientDataSubjectRequestRecord copyWith({ + _i2.DataSubjectRequestType? type, + _i3.DataSubjectRequestStatus? status, + Object? details = _Undefined, + DateTime? createdAt, + DateTime? dueAt, + Object? resolution = _Undefined, + }) { + return PatientDataSubjectRequestRecord( + type: type ?? this.type, + status: status ?? this.status, + details: details is String? ? details : this.details, + createdAt: createdAt ?? this.createdAt, + dueAt: dueAt ?? this.dueAt, + resolution: resolution is String? ? resolution : this.resolution, + ); + } +} diff --git a/backend/sinalacs_client/lib/src/protocol/api/terms_change_notice.dart b/backend/sinalacs_client/lib/src/protocol/api/terms_change_notice.dart new file mode 100644 index 00000000..bdecad67 --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/api/terms_change_notice.dart @@ -0,0 +1,98 @@ +/* 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; + +/// Aviso de mudança dos termos (LGPD-RF18): a versão que passa a valer, a data +/// de vigência e um resumo do que muda. Publicado com pelo menos 15 dias de +/// antecedência (`TermsChangeSchedule`). Só texto público; nada do titular. +abstract class TermsChangeNotice implements _i1.SerializableModel { + TermsChangeNotice._({ + required this.version, + required this.effectiveFrom, + required this.summary, + }); + + factory TermsChangeNotice({ + required String version, + required DateTime effectiveFrom, + required String summary, + }) = _TermsChangeNoticeImpl; + + factory TermsChangeNotice.fromJson(Map jsonSerialization) { + return TermsChangeNotice( + version: jsonSerialization['version'] as String, + effectiveFrom: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['effectiveFrom'], + ), + summary: jsonSerialization['summary'] as String, + ); + } + + String version; + + DateTime effectiveFrom; + + String summary; + + /// Returns a shallow copy of this [TermsChangeNotice] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + TermsChangeNotice copyWith({ + String? version, + DateTime? effectiveFrom, + String? summary, + }); + @override + Map toJson() { + return { + '__className__': 'TermsChangeNotice', + 'version': version, + 'effectiveFrom': effectiveFrom.toJson(), + 'summary': summary, + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _TermsChangeNoticeImpl extends TermsChangeNotice { + _TermsChangeNoticeImpl({ + required String version, + required DateTime effectiveFrom, + required String summary, + }) : super._( + version: version, + effectiveFrom: effectiveFrom, + summary: summary, + ); + + /// Returns a shallow copy of this [TermsChangeNotice] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + TermsChangeNotice copyWith({ + String? version, + DateTime? effectiveFrom, + String? summary, + }) { + return TermsChangeNotice( + version: version ?? this.version, + effectiveFrom: effectiveFrom ?? this.effectiveFrom, + summary: summary ?? this.summary, + ); + } +} diff --git a/backend/sinalacs_client/lib/src/protocol/api/terms_version_entry.dart b/backend/sinalacs_client/lib/src/protocol/api/terms_version_entry.dart new file mode 100644 index 00000000..34a5ac88 --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/api/terms_version_entry.dart @@ -0,0 +1,156 @@ +/* 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; + +/// Versão dos termos de uso no backoffice (#46, LGPD-RF18). Sem os campos de +/// quem revisou/publicou: o admin vê quando, não a identidade. +abstract class TermsVersionEntry implements _i1.SerializableModel { + TermsVersionEntry._({ + required this.id, + required this.version, + required this.summary, + required this.effectiveFrom, + required this.createdAt, + this.legallyReviewedAt, + this.publishedAt, + }); + + factory TermsVersionEntry({ + required String id, + required String version, + required String summary, + required DateTime effectiveFrom, + required DateTime createdAt, + DateTime? legallyReviewedAt, + DateTime? publishedAt, + }) = _TermsVersionEntryImpl; + + factory TermsVersionEntry.fromJson(Map jsonSerialization) { + return TermsVersionEntry( + id: jsonSerialization['id'] as String, + version: jsonSerialization['version'] as String, + summary: jsonSerialization['summary'] as String, + effectiveFrom: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['effectiveFrom'], + ), + createdAt: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['createdAt'], + ), + legallyReviewedAt: jsonSerialization['legallyReviewedAt'] == null + ? null + : _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['legallyReviewedAt'], + ), + publishedAt: jsonSerialization['publishedAt'] == null + ? null + : _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['publishedAt'], + ), + ); + } + + String id; + + String version; + + String summary; + + DateTime effectiveFrom; + + DateTime createdAt; + + DateTime? legallyReviewedAt; + + DateTime? publishedAt; + + /// Returns a shallow copy of this [TermsVersionEntry] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + TermsVersionEntry copyWith({ + String? id, + String? version, + String? summary, + DateTime? effectiveFrom, + DateTime? createdAt, + DateTime? legallyReviewedAt, + DateTime? publishedAt, + }); + @override + Map toJson() { + return { + '__className__': 'TermsVersionEntry', + 'id': id, + 'version': version, + 'summary': summary, + 'effectiveFrom': effectiveFrom.toJson(), + 'createdAt': createdAt.toJson(), + if (legallyReviewedAt != null) + 'legallyReviewedAt': legallyReviewedAt?.toJson(), + if (publishedAt != null) 'publishedAt': publishedAt?.toJson(), + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _TermsVersionEntryImpl extends TermsVersionEntry { + _TermsVersionEntryImpl({ + required String id, + required String version, + required String summary, + required DateTime effectiveFrom, + required DateTime createdAt, + DateTime? legallyReviewedAt, + DateTime? publishedAt, + }) : super._( + id: id, + version: version, + summary: summary, + effectiveFrom: effectiveFrom, + createdAt: createdAt, + legallyReviewedAt: legallyReviewedAt, + publishedAt: publishedAt, + ); + + /// Returns a shallow copy of this [TermsVersionEntry] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + TermsVersionEntry copyWith({ + String? id, + String? version, + String? summary, + DateTime? effectiveFrom, + DateTime? createdAt, + Object? legallyReviewedAt = _Undefined, + Object? publishedAt = _Undefined, + }) { + return TermsVersionEntry( + id: id ?? this.id, + version: version ?? this.version, + summary: summary ?? this.summary, + effectiveFrom: effectiveFrom ?? this.effectiveFrom, + createdAt: createdAt ?? this.createdAt, + legallyReviewedAt: legallyReviewedAt is DateTime? + ? legallyReviewedAt + : this.legallyReviewedAt, + publishedAt: publishedAt is DateTime? ? publishedAt : this.publishedAt, + ); + } +} diff --git a/backend/sinalacs_client/lib/src/protocol/api/totp_enrollment_start.dart b/backend/sinalacs_client/lib/src/protocol/api/totp_enrollment_start.dart new file mode 100644 index 00000000..7d78a446 --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/api/totp_enrollment_start.dart @@ -0,0 +1,84 @@ +/* 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; + +/// Início da ativação da MFA: o segredo para o autenticador (base32) e a URI +/// `otpauth://` que vira o QR. Volta só nesta resposta; nunca é gravado em claro. +abstract class TotpEnrollmentStart implements _i1.SerializableModel { + TotpEnrollmentStart._({ + required this.secretBase32, + required this.otpauthUri, + }); + + factory TotpEnrollmentStart({ + required String secretBase32, + required String otpauthUri, + }) = _TotpEnrollmentStartImpl; + + factory TotpEnrollmentStart.fromJson(Map jsonSerialization) { + return TotpEnrollmentStart( + secretBase32: jsonSerialization['secretBase32'] as String, + otpauthUri: jsonSerialization['otpauthUri'] as String, + ); + } + + String secretBase32; + + String otpauthUri; + + /// Returns a shallow copy of this [TotpEnrollmentStart] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + TotpEnrollmentStart copyWith({ + String? secretBase32, + String? otpauthUri, + }); + @override + Map toJson() { + return { + '__className__': 'TotpEnrollmentStart', + 'secretBase32': secretBase32, + 'otpauthUri': otpauthUri, + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _TotpEnrollmentStartImpl extends TotpEnrollmentStart { + _TotpEnrollmentStartImpl({ + required String secretBase32, + required String otpauthUri, + }) : super._( + secretBase32: secretBase32, + otpauthUri: otpauthUri, + ); + + /// Returns a shallow copy of this [TotpEnrollmentStart] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + TotpEnrollmentStart copyWith({ + String? secretBase32, + String? otpauthUri, + }) { + return TotpEnrollmentStart( + secretBase32: secretBase32 ?? this.secretBase32, + otpauthUri: otpauthUri ?? this.otpauthUri, + ); + } +} diff --git a/backend/sinalacs_client/lib/src/protocol/api/ubs_contact.dart b/backend/sinalacs_client/lib/src/protocol/api/ubs_contact.dart new file mode 100644 index 00000000..3402ad83 --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/api/ubs_contact.dart @@ -0,0 +1,86 @@ +/* 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; + +/// Contato da UBS do ACS autenticado (RF13). Só o que o botão "Ligar para a UBS" +/// precisa: o nome para a mensagem e o telefone para o discador. +abstract class UbsContact implements _i1.SerializableModel { + UbsContact._({ + required this.name, + this.phone, + }); + + factory UbsContact({ + required String name, + String? phone, + }) = _UbsContactImpl; + + factory UbsContact.fromJson(Map jsonSerialization) { + return UbsContact( + name: jsonSerialization['name'] as String, + phone: jsonSerialization['phone'] as String?, + ); + } + + String name; + + String? phone; + + /// Returns a shallow copy of this [UbsContact] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + UbsContact copyWith({ + String? name, + String? phone, + }); + @override + Map toJson() { + return { + '__className__': 'UbsContact', + 'name': name, + if (phone != null) 'phone': phone, + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _UbsContactImpl extends UbsContact { + _UbsContactImpl({ + required String name, + String? phone, + }) : super._( + name: name, + phone: phone, + ); + + /// Returns a shallow copy of this [UbsContact] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + UbsContact copyWith({ + String? name, + Object? phone = _Undefined, + }) { + return UbsContact( + name: name ?? this.name, + phone: phone is String? ? phone : this.phone, + ); + } +} diff --git a/backend/sinalacs_client/lib/src/protocol/audit_chain_checkpoint.dart b/backend/sinalacs_client/lib/src/protocol/audit_chain_checkpoint.dart new file mode 100644 index 00000000..4318fcc1 --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/audit_chain_checkpoint.dart @@ -0,0 +1,128 @@ +/* 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; + +/// Ponto de verificação da cadeia de `audit_logs` (#45): `entryHash` na +/// posição `sequence`, assinado, para detectar truncamento do final da cadeia. +abstract class AuditChainCheckpoint implements _i1.SerializableModel { + AuditChainCheckpoint._({ + this.id, + required this.sequence, + required this.entryHash, + required this.signature, + required this.createdAt, + }); + + factory AuditChainCheckpoint({ + _i1.UuidValue? id, + required int sequence, + required String entryHash, + required String signature, + required DateTime createdAt, + }) = _AuditChainCheckpointImpl; + + factory AuditChainCheckpoint.fromJson( + Map jsonSerialization, + ) { + return AuditChainCheckpoint( + id: jsonSerialization['id'] == null + ? null + : _i1.UuidValueJsonExtension.fromJson(jsonSerialization['id']), + sequence: jsonSerialization['sequence'] as int, + entryHash: jsonSerialization['entryHash'] as String, + signature: jsonSerialization['signature'] as String, + createdAt: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['createdAt'], + ), + ); + } + + /// The database id, set if the object has been inserted into the + /// database or if it has been fetched from the database. Otherwise, + /// the id will be null. + _i1.UuidValue? id; + + int sequence; + + String entryHash; + + String signature; + + DateTime createdAt; + + /// Returns a shallow copy of this [AuditChainCheckpoint] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + AuditChainCheckpoint copyWith({ + _i1.UuidValue? id, + int? sequence, + String? entryHash, + String? signature, + DateTime? createdAt, + }); + @override + Map toJson() { + return { + '__className__': 'AuditChainCheckpoint', + if (id != null) 'id': id?.toJson(), + 'sequence': sequence, + 'entryHash': entryHash, + 'signature': signature, + 'createdAt': createdAt.toJson(), + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _AuditChainCheckpointImpl extends AuditChainCheckpoint { + _AuditChainCheckpointImpl({ + _i1.UuidValue? id, + required int sequence, + required String entryHash, + required String signature, + required DateTime createdAt, + }) : super._( + id: id, + sequence: sequence, + entryHash: entryHash, + signature: signature, + createdAt: createdAt, + ); + + /// Returns a shallow copy of this [AuditChainCheckpoint] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + AuditChainCheckpoint copyWith({ + Object? id = _Undefined, + int? sequence, + String? entryHash, + String? signature, + DateTime? createdAt, + }) { + return AuditChainCheckpoint( + id: id is _i1.UuidValue? ? id : this.id, + sequence: sequence ?? this.sequence, + entryHash: entryHash ?? this.entryHash, + signature: signature ?? this.signature, + createdAt: createdAt ?? this.createdAt, + ); + } +} diff --git a/backend/sinalacs_client/lib/src/protocol/audit_export.dart b/backend/sinalacs_client/lib/src/protocol/audit_export.dart new file mode 100644 index 00000000..f1def385 --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/audit_export.dart @@ -0,0 +1,192 @@ +/* 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; + +/// Registro de cada exportação da trilha de auditoria (#45). Append-only: +/// guarda quem exportou, os filtros, o intervalo de `sequence` coberto e o +/// SHA-256 do conteúdo, para que a própria exportação seja auditável. +abstract class AuditExport implements _i1.SerializableModel { + AuditExport._({ + this.id, + required this.requestedBy, + required this.requestedAt, + required this.filtersJson, + required this.format, + required this.rowCount, + this.firstSequence, + this.lastSequence, + required this.contentSha256, + required this.chainState, + }); + + factory AuditExport({ + _i1.UuidValue? id, + required _i1.UuidValue requestedBy, + required DateTime requestedAt, + required String filtersJson, + required String format, + required int rowCount, + int? firstSequence, + int? lastSequence, + required String contentSha256, + required String chainState, + }) = _AuditExportImpl; + + factory AuditExport.fromJson(Map jsonSerialization) { + return AuditExport( + id: jsonSerialization['id'] == null + ? null + : _i1.UuidValueJsonExtension.fromJson(jsonSerialization['id']), + requestedBy: _i1.UuidValueJsonExtension.fromJson( + jsonSerialization['requestedBy'], + ), + requestedAt: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['requestedAt'], + ), + filtersJson: jsonSerialization['filtersJson'] as String, + format: jsonSerialization['format'] as String, + rowCount: jsonSerialization['rowCount'] as int, + firstSequence: jsonSerialization['firstSequence'] as int?, + lastSequence: jsonSerialization['lastSequence'] as int?, + contentSha256: jsonSerialization['contentSha256'] as String, + chainState: jsonSerialization['chainState'] as String, + ); + } + + /// The database id, set if the object has been inserted into the + /// database or if it has been fetched from the database. Otherwise, + /// the id will be null. + _i1.UuidValue? id; + + _i1.UuidValue requestedBy; + + DateTime requestedAt; + + /// Só os parâmetros do filtro (`AdminAuditFilter.toJson`, chaves + /// `from`/`to` e as opcionais), nunca linhas exportadas. LGPD: guarda o + /// `search` e o `actorEnrollmentId` digitados pelo administrador — texto + /// livre de até 64 caracteres que casa só com matrícula do autor, + /// `resourceType` e `result` (nunca nome, CPF, UUID ou hash), mas que o + /// operador pode preencher com qualquer coisa: tratar como dado pessoal + /// do staff (identificação profissional) e não ecoar em log. + String filtersJson; + + String format; + + int rowCount; + + int? firstSequence; + + int? lastSequence; + + String contentSha256; + + /// Resultado da verificação da cadeia no momento da exportação. + String chainState; + + /// Returns a shallow copy of this [AuditExport] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + AuditExport copyWith({ + _i1.UuidValue? id, + _i1.UuidValue? requestedBy, + DateTime? requestedAt, + String? filtersJson, + String? format, + int? rowCount, + int? firstSequence, + int? lastSequence, + String? contentSha256, + String? chainState, + }); + @override + Map toJson() { + return { + '__className__': 'AuditExport', + if (id != null) 'id': id?.toJson(), + 'requestedBy': requestedBy.toJson(), + 'requestedAt': requestedAt.toJson(), + 'filtersJson': filtersJson, + 'format': format, + 'rowCount': rowCount, + if (firstSequence != null) 'firstSequence': firstSequence, + if (lastSequence != null) 'lastSequence': lastSequence, + 'contentSha256': contentSha256, + 'chainState': chainState, + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _AuditExportImpl extends AuditExport { + _AuditExportImpl({ + _i1.UuidValue? id, + required _i1.UuidValue requestedBy, + required DateTime requestedAt, + required String filtersJson, + required String format, + required int rowCount, + int? firstSequence, + int? lastSequence, + required String contentSha256, + required String chainState, + }) : super._( + id: id, + requestedBy: requestedBy, + requestedAt: requestedAt, + filtersJson: filtersJson, + format: format, + rowCount: rowCount, + firstSequence: firstSequence, + lastSequence: lastSequence, + contentSha256: contentSha256, + chainState: chainState, + ); + + /// Returns a shallow copy of this [AuditExport] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + AuditExport copyWith({ + Object? id = _Undefined, + _i1.UuidValue? requestedBy, + DateTime? requestedAt, + String? filtersJson, + String? format, + int? rowCount, + Object? firstSequence = _Undefined, + Object? lastSequence = _Undefined, + String? contentSha256, + String? chainState, + }) { + return AuditExport( + id: id is _i1.UuidValue? ? id : this.id, + requestedBy: requestedBy ?? this.requestedBy, + requestedAt: requestedAt ?? this.requestedAt, + filtersJson: filtersJson ?? this.filtersJson, + format: format ?? this.format, + rowCount: rowCount ?? this.rowCount, + firstSequence: firstSequence is int? ? firstSequence : this.firstSequence, + lastSequence: lastSequence is int? ? lastSequence : this.lastSequence, + contentSha256: contentSha256 ?? this.contentSha256, + chainState: chainState ?? this.chainState, + ); + } +} diff --git a/backend/sinalacs_client/lib/src/protocol/client.dart b/backend/sinalacs_client/lib/src/protocol/client.dart index bba152a6..07625ae1 100644 --- a/backend/sinalacs_client/lib/src/protocol/client.dart +++ b/backend/sinalacs_client/lib/src/protocol/client.dart @@ -13,25 +13,536 @@ import 'package:serverpod_client/serverpod_client.dart' as _i1; import 'dart:async' as _i2; -import 'package:sinalacs_client/src/protocol/api/red_alert_result.dart' as _i3; -import 'package:sinalacs_client/src/protocol/api/alert_ack_result.dart' as _i4; +import 'package:sinalacs_client/src/protocol/api/admin_indicators.dart' as _i3; +import 'package:sinalacs_client/src/protocol/api/admin_micro_area.dart' as _i4; +import 'package:sinalacs_client/src/protocol/api/admin_alert_page.dart' as _i5; +import 'package:sinalacs_client/src/protocol/enums/alert_status.dart' as _i6; +import 'package:sinalacs_client/src/protocol/api/admin_alerts_pulse.dart' + as _i7; +import 'package:sinalacs_client/src/protocol/api/admin_audit_page.dart' as _i8; +import 'package:sinalacs_client/src/protocol/api/admin_audit_filter.dart' + as _i9; +import 'package:sinalacs_client/src/protocol/api/admin_audit_chain_status.dart' + as _i10; +import 'package:sinalacs_client/src/protocol/api/admin_audit_export.dart' + as _i11; +import 'package:sinalacs_client/src/protocol/api/admin_data_subject_request_page.dart' + as _i12; +import 'package:sinalacs_client/src/protocol/enums/data_subject_request_status.dart' + as _i13; +import 'package:sinalacs_client/src/protocol/api/admin_data_subject_request_detail.dart' + as _i14; +import 'package:sinalacs_client/src/protocol/api/admin_acs.dart' as _i15; +import 'package:sinalacs_client/src/protocol/api/admin_staff.dart' as _i16; +import 'package:sinalacs_client/src/protocol/api/admin_acs_creation_result.dart' + as _i17; +import 'package:sinalacs_client/src/protocol/api/admin_password_reset_result.dart' + as _i18; +import 'package:sinalacs_client/src/protocol/api/admin_staff_mfa_reset_result.dart' + as _i19; +import 'package:sinalacs_client/src/protocol/api/admin_escalation_result.dart' + as _i20; +import 'package:sinalacs_client/src/protocol/enums/escalation_reason.dart' + as _i21; +import 'package:sinalacs_client/src/protocol/api/notice_history_page.dart' + as _i22; +import 'package:sinalacs_client/src/protocol/api/terms_version_entry.dart' + as _i23; +import 'package:sinalacs_client/src/protocol/api/red_alert_result.dart' as _i24; +import 'package:sinalacs_client/src/protocol/api/alert_ack_result.dart' as _i25; import 'package:sinalacs_client/src/protocol/api/alert_status_result.dart' - as _i5; + as _i26; import 'package:sinalacs_client/src/protocol/api/development_login_result.dart' - as _i6; -import 'package:sinalacs_client/src/protocol/api/service_health.dart' as _i7; + as _i27; +import 'package:sinalacs_client/src/protocol/api/totp_enrollment_start.dart' + as _i28; +import 'package:sinalacs_client/src/protocol/api/service_health.dart' as _i29; +import 'package:sinalacs_client/src/protocol/api/notice_send_result.dart' + as _i30; import 'package:sinalacs_client/src/protocol/api/enrollment_token_result.dart' - as _i8; -import 'package:sinalacs_client/src/protocol/api/enrollment_result.dart' as _i9; + as _i31; +import 'package:sinalacs_client/src/protocol/api/enrollment_result.dart' + as _i32; import 'package:sinalacs_client/src/protocol/api/micro_area_patient.dart' - as _i10; + as _i33; import 'package:sinalacs_client/src/protocol/api/patient_data_overview.dart' - as _i11; -import 'package:sinalacs_client/src/protocol/api/triage_result.dart' as _i12; + as _i34; +import 'package:sinalacs_client/src/protocol/api/patient_consent_record.dart' + as _i35; +import 'package:sinalacs_client/src/protocol/enums/consent_purpose.dart' + as _i36; +import 'package:sinalacs_client/src/protocol/api/terms_change_notice.dart' + as _i37; +import 'package:sinalacs_client/src/protocol/api/patient_data_subject_request_record.dart' + as _i38; +import 'package:sinalacs_client/src/protocol/api/triage_result.dart' as _i39; +import 'package:sinalacs_client/src/protocol/api/ubs_contact.dart' as _i40; import 'package:sinalacs_client/src/protocol/api/visit_sync_result.dart' - as _i13; -import 'package:sinalacs_client/src/protocol/api/visit_sync_entry.dart' as _i14; -import 'protocol.dart' as _i15; + as _i41; +import 'package:sinalacs_client/src/protocol/api/visit_sync_entry.dart' as _i42; +import 'protocol.dart' as _i43; + +/// Backoffice, só para `coordinator` e `admin`: leitura (issue #40), gestão de +/// contas (issue #43), atendimento de pedidos do titular (issue #42) e +/// escalonamento de alerta vermelho (issue #44, `AlertEscalationService`). +/// +/// A leitura — indicadores, microáreas, alertas e auditoria — é do +/// `AdminReadService`; a listagem de ACS e de equipe, o cadastro, o vínculo de +/// microárea, a (des)ativação, as redefinições de senha/MFA do ACS e a +/// redefinição da MFA do staff (com a emissão do código de ativação pela tela, +/// o deferimento da #48) são do `AdminAccountService`; os pedidos do titular +/// são do `DataSubjectCaseService` — sempre na mesma dupla serviço/store. +/// +/// O papel, o escopo (sistema para o administrador, UBS para o coordenador), a +/// paginação e a auditoria de cada operação são dos serviços; o endpoint só +/// verifica o token e delega. Nenhum método devolve nome, CPF ou contato de +/// paciente: o paciente é um rótulo (`#A18F`), e o que sai dos cadastros é a +/// identificação **profissional** (nome, matrícula, UBS, microárea). +/// {@category Endpoint} +class EndpointAdmin extends EndpointAuthenticated { + EndpointAdmin(_i1.EndpointCaller caller) : super(caller); + + @override + String get name => 'admin'; + + _i2.Future<_i3.AdminIndicators> indicators({required String accessToken}) => + caller.callServerEndpoint<_i3.AdminIndicators>( + 'admin', + 'indicators', + {'accessToken': accessToken}, + ); + + _i2.Future> microAreas({ + required String accessToken, + }) => caller.callServerEndpoint>( + 'admin', + 'microAreas', + {'accessToken': accessToken}, + ); + + _i2.Future<_i5.AdminAlertPage> alerts({ + required String accessToken, + String? microAreaId, + _i6.AlertStatus? status, + required int limit, + required int offset, + }) => caller.callServerEndpoint<_i5.AdminAlertPage>( + 'admin', + 'alerts', + { + 'accessToken': accessToken, + 'microAreaId': microAreaId, + 'status': status, + 'limit': limit, + 'offset': offset, + }, + ); + + /// Pulso da monitoração (#44, D5): indicadores, `overdueRed`, `maxChangedAt` + /// e o relógio do servidor, sem identidade. Não grava linha `read` por + /// chamada: a leitura contínua é uma sessão (`monitoring_session_start` + /// fail-closed quando não há pulso nos 90 s anteriores). Ver + /// `AdminMonitoringService`. + _i2.Future<_i7.AdminAlertsPulse> alertsPulse({required String accessToken}) => + caller.callServerEndpoint<_i7.AdminAlertsPulse>( + 'admin', + 'alertsPulse', + {'accessToken': accessToken}, + ); + + /// Fecha a sessão de monitoramento do chamador (`monitoring_session_end`, + /// best-effort). Idempotente: sem sessão aberta, não grava nada. + _i2.Future endMonitoringSession({required String accessToken}) => + caller.callServerEndpoint( + 'admin', + 'endMonitoringSession', + {'accessToken': accessToken}, + ); + + /// Trilha de auditoria no escopo de quem chama, por keyset. [filter] (#45) + /// é opcional e só restringe; inválido vira [AdminInvalidRequestException]. + _i2.Future<_i8.AdminAuditPage> auditLogs({ + required String accessToken, + _i9.AdminAuditFilter? filter, + required int limit, + int? beforeSequence, + }) => caller.callServerEndpoint<_i8.AdminAuditPage>( + 'admin', + 'auditLogs', + { + 'accessToken': accessToken, + 'filter': filter, + 'limit': limit, + 'beforeSequence': beforeSequence, + }, + ); + + /// Verificação global da cadeia de `audit_logs` (#45, D3/D9). Só `verified` + /// é íntegra; o coordenador não recebe `sequence` nem motivo de quebra fora + /// da UBS dele. Auditada antes de verificar; single-flight e cache de 60 s + /// por processo. Ver `AuditChainStatusService`. + _i2.Future<_i10.AdminAuditChainStatus> verifyAuditChain({ + required String accessToken, + }) => caller.callServerEndpoint<_i10.AdminAuditChainStatus>( + 'admin', + 'verifyAuditChain', + {'accessToken': accessToken}, + ); + + /// Exportação da trilha de auditoria em CSV (#45, D5–D8). **Só o + /// administrador**; o filtro exige `from` e `to` (janela ≤ 366 dias); teto + /// de `AuditExportService.maxRows` linhas (acima: `refused_limit` e + /// `AdminInvalidRequestException`). O recorte é fixado antes do evento + /// `export`, que entra na mesma transação do registro em `audit_exports` — + /// o arquivo nunca contém a própria exportação. Ver `AuditExportService`. + _i2.Future<_i11.AdminAuditExport> exportAuditLogs({ + required String accessToken, + required _i9.AdminAuditFilter filter, + }) => caller.callServerEndpoint<_i11.AdminAuditExport>( + 'admin', + 'exportAuditLogs', + { + 'accessToken': accessToken, + 'filter': filter, + }, + ); + + /// Pedidos do titular no escopo de quem chama, prazo mais próximo primeiro. + /// Nunca traz o texto do pedido nem a nota. + _i2.Future<_i12.AdminDataSubjectRequestPage> dataSubjectRequests({ + required String accessToken, + _i13.DataSubjectRequestStatus? status, + required int limit, + required int offset, + }) => caller.callServerEndpoint<_i12.AdminDataSubjectRequestPage>( + 'admin', + 'dataSubjectRequests', + { + 'accessToken': accessToken, + 'status': status, + 'limit': limit, + 'offset': offset, + }, + ); + + /// Detalhe de um pedido, com o texto decifrado. A leitura é auditada antes. + /// Inexistente e fora do escopo dão a mesma recusa. + _i2.Future<_i14.AdminDataSubjectRequestDetail> dataSubjectRequest({ + required String accessToken, + required String id, + }) => caller.callServerEndpoint<_i14.AdminDataSubjectRequestDetail>( + 'admin', + 'dataSubjectRequest', + { + 'accessToken': accessToken, + 'id': id, + }, + ); + + /// `open → inReview`. + _i2.Future startDataSubjectReview({ + required String accessToken, + required String id, + }) => caller.callServerEndpoint( + 'admin', + 'startDataSubjectReview', + { + 'accessToken': accessToken, + 'id': id, + }, + ); + + /// Atende o pedido. Correção exige [note]; exclusão anonimiza o titular na + /// mesma transação da mudança de status e da auditoria. + _i2.Future completeDataSubjectRequest({ + required String accessToken, + required String id, + String? note, + }) => caller.callServerEndpoint( + 'admin', + 'completeDataSubjectRequest', + { + 'accessToken': accessToken, + 'id': id, + 'note': note, + }, + ); + + /// Recusa o pedido com um motivo de 3 a 500 caracteres, que o titular vê. + _i2.Future rejectDataSubjectRequest({ + required String accessToken, + required String id, + required String reason, + }) => caller.callServerEndpoint( + 'admin', + 'rejectDataSubjectRequest', + { + 'accessToken': accessToken, + 'id': id, + 'reason': reason, + }, + ); + + /// ACS visíveis para o chamador: o administrador vê o sistema, o coordenador + /// só os da própria UBS. + _i2.Future> acs({required String accessToken}) => + caller.callServerEndpoint>( + 'admin', + 'acs', + {'accessToken': accessToken}, + ); + + /// Contas de equipe do backoffice — só o administrador. + _i2.Future> staff({required String accessToken}) => + caller.callServerEndpoint>( + 'admin', + 'staff', + {'accessToken': accessToken}, + ); + + /// Cadastra um ACS e devolve a senha inicial gerada, que **só** existe nesta + /// resposta: o servidor guarda apenas o hash (ver `AcsInitialPassword`). + /// A UBS vem da microárea, nunca do pedido. + _i2.Future<_i17.AdminAcsCreationResult> createAcs({ + required String accessToken, + required String name, + required String enrollmentId, + required String microAreaId, + }) => caller.callServerEndpoint<_i17.AdminAcsCreationResult>( + 'admin', + 'createAcs', + { + 'accessToken': accessToken, + 'name': name, + 'enrollmentId': enrollmentId, + 'microAreaId': microAreaId, + }, + ); + + /// Move um ACS existente para outra microárea — da própria UBS, para o + /// coordenador; de qualquer UBS, para o administrador. A UBS do vínculo sai + /// da microárea-alvo, nunca do pedido, e devolve a linha já no território + /// novo. O ACS renovará a sessão (refresh token) já no território novo; o JWT + /// em curso continua com o antigo até o refresh. + _i2.Future<_i15.AdminAcs> setAcsMicroArea({ + required String accessToken, + required String acsId, + required String microAreaId, + }) => caller.callServerEndpoint<_i15.AdminAcs>( + 'admin', + 'setAcsMicroArea', + { + 'accessToken': accessToken, + 'acsId': acsId, + 'microAreaId': microAreaId, + }, + ); + + /// Liga/desliga o acesso de um ACS — da própria UBS, para o coordenador; de + /// qualquer UBS, para o administrador. + /// + /// Desativar revoga, na mesma transação da flag, todas as sessões (refresh + /// token) e todos os tokens de envio diferido da conta: o aparelho perde o + /// acesso na hora, inclusive o envio offline das visitas pendentes, e o login + /// por senha passa a recusar com `'Este acesso está inativo.'`. Reativar + /// devolve o acesso pelo login — os tokens revogados não voltam. + _i2.Future<_i15.AdminAcs> setAcsActive({ + required String accessToken, + required String acsId, + required bool active, + }) => caller.callServerEndpoint<_i15.AdminAcs>( + 'admin', + 'setAcsActive', + { + 'accessToken': accessToken, + 'acsId': acsId, + 'active': active, + }, + ); + + /// Redefine a senha do ACS — da própria UBS, para o coordenador; de qualquer + /// UBS, para o administrador — e devolve a nova, que **só** existe nesta + /// resposta. + /// + /// A senha é sorteada pelo servidor (nunca escolhida pelo operador) e o + /// bloqueio da conta é zerado: a credencial nova não herda as tentativas da + /// antiga. As sessões em curso **não** são revogadas; cortá-las na hora é a + /// desativação. + _i2.Future<_i18.AdminPasswordResetResult> resetAcsPassword({ + required String accessToken, + required String acsId, + }) => caller.callServerEndpoint<_i18.AdminPasswordResetResult>( + 'admin', + 'resetAcsPassword', + { + 'accessToken': accessToken, + 'acsId': acsId, + }, + ); + + /// Redefine a MFA do ACS: apaga o segredo TOTP gravado, para ele ativar de + /// novo na próxima entrada (é o caminho que a recusa "Peça a redefinição à + /// coordenação" não tinha). A senha e o bloqueio não são tocados. + _i2.Future resetAcsMfa({ + required String accessToken, + required String acsId, + }) => caller.callServerEndpoint( + 'admin', + 'resetAcsMfa', + { + 'accessToken': accessToken, + 'acsId': acsId, + }, + ); + + /// Redefine a MFA de uma conta de equipe — só o administrador, nunca a + /// própria conta — e devolve o código de ativação novo, que **só** existe + /// nesta resposta (é o deferimento da #48: a CLI de operador era o único + /// emissor, e a emissão não entrava na trilha). + /// + /// As duas escritas — as quatro colunas `totp*` zeradas e o + /// `activationCodeHash`/validade/emissor gravados — são uma transação só, e a + /// linha `admin_staff`/`mfa_reset` entra em `audit_logs` com o id do alvo. + /// Quem redefine mostra o código uma vez; perdido de novo, o caminho é + /// redefinir outra vez, nunca recuperar. + _i2.Future<_i19.AdminStaffMfaResetResult> resetStaffMfa({ + required String accessToken, + required String staffId, + }) => caller.callServerEndpoint<_i19.AdminStaffMfaResetResult>( + 'admin', + 'resetStaffMfa', + { + 'accessToken': accessToken, + 'staffId': staffId, + }, + ); + + /// Escalona um alerta vermelho pendente cujo prazo sem confirmação venceu + /// (#44). Grava data, autor e motivo fechado, reenfileira **uma** entrega de + /// reforço para o tópico da microárea e audita — numa transação só; o alerta + /// segue pendente. Repetir é idempotente (`alreadyEscalated`, carimbo + /// original). A resposta não traz identidade: só id, carimbo e o indicador. + _i2.Future<_i20.AdminEscalationResult> escalateAlert({ + required String accessToken, + required String alertId, + required _i21.EscalationReason reason, + }) => caller.callServerEndpoint<_i20.AdminEscalationResult>( + 'admin', + 'escalateAlert', + { + 'accessToken': accessToken, + 'alertId': alertId, + 'reason': reason, + }, + ); + + /// Agenda um aviso comunitário a uma microárea (#46) e devolve o id. Só + /// `coordinator`/`admin`; o coordenador só na própria UBS. A negação já vem + /// auditada do serviço; o texto nunca entra na trilha. + _i2.Future scheduleNotice({ + required String accessToken, + required String microAreaId, + required String audience, + required String title, + required String message, + required DateTime scheduledFor, + }) => caller.callServerEndpoint( + 'admin', + 'scheduleNotice', + { + 'accessToken': accessToken, + 'microAreaId': microAreaId, + 'audience': audience, + 'title': title, + 'message': message, + 'scheduledFor': scheduledFor, + }, + ); + + /// Cancela um aviso ainda `pending` (#46). Aviso já reservado/enviado: + /// [AdminInvalidRequestException]. + _i2.Future cancelScheduledNotice({ + required String accessToken, + required String id, + }) => caller.callServerEndpoint( + 'admin', + 'cancelScheduledNotice', + { + 'accessToken': accessToken, + 'id': id, + }, + ); + + /// Histórico de avisos, mais recentes primeiro, **sem o texto** (#46). + /// Coordenador: só a própria UBS. + _i2.Future<_i22.NoticeHistoryPage> noticeHistory({ + required String accessToken, + String? microAreaId, + required int limit, + required int offset, + }) => caller.callServerEndpoint<_i22.NoticeHistoryPage>( + 'admin', + 'noticeHistory', + { + 'accessToken': accessToken, + 'microAreaId': microAreaId, + 'limit': limit, + 'offset': offset, + }, + ); + + /// Versões dos termos, mais recentes primeiro. + _i2.Future> termsVersions({ + required String accessToken, + }) => caller.callServerEndpoint>( + 'admin', + 'termsVersions', + {'accessToken': accessToken}, + ); + + /// Rascunha uma versão dos termos. + _i2.Future<_i23.TermsVersionEntry> draftTermsVersion({ + required String accessToken, + required String version, + required String summary, + required DateTime effectiveFrom, + }) => caller.callServerEndpoint<_i23.TermsVersionEntry>( + 'admin', + 'draftTermsVersion', + { + 'accessToken': accessToken, + 'version': version, + 'summary': summary, + 'effectiveFrom': effectiveFrom, + }, + ); + + /// Registra a revisão jurídica de uma versão. + _i2.Future<_i23.TermsVersionEntry> markTermsReviewed({ + required String accessToken, + required String id, + }) => caller.callServerEndpoint<_i23.TermsVersionEntry>( + 'admin', + 'markTermsReviewed', + { + 'accessToken': accessToken, + 'id': id, + }, + ); + + /// Publica uma versão revisada, com vigência a pelo menos 15 dias. + _i2.Future<_i23.TermsVersionEntry> publishTermsVersion({ + required String accessToken, + required String id, + }) => caller.callServerEndpoint<_i23.TermsVersionEntry>( + 'admin', + 'publishTermsVersion', + { + 'accessToken': accessToken, + 'id': id, + }, + ); +} /// Ciclo do alerta vermelho. /// @@ -54,12 +565,12 @@ class EndpointAlerts extends EndpointAuthenticated { @override String get name => 'alerts'; - _i2.Future<_i3.RedAlertResult> createRedAlert({ + _i2.Future<_i24.RedAlertResult> createRedAlert({ required String accessToken, required String idempotencyKey, required String locationHash, String? locationCell, - }) => caller.callServerEndpoint<_i3.RedAlertResult>( + }) => caller.callServerEndpoint<_i24.RedAlertResult>( 'alerts', 'createRedAlert', { @@ -70,10 +581,10 @@ class EndpointAlerts extends EndpointAuthenticated { }, ); - _i2.Future<_i4.AlertAckResult> acknowledge({ + _i2.Future<_i25.AlertAckResult> acknowledge({ required String accessToken, required String alertId, - }) => caller.callServerEndpoint<_i4.AlertAckResult>( + }) => caller.callServerEndpoint<_i25.AlertAckResult>( 'alerts', 'acknowledge', { @@ -84,8 +595,8 @@ class EndpointAlerts extends EndpointAuthenticated { /// Status do alerta mais recente do PRÓPRIO paciente (RF05, decisão §5). /// `patientId` nunca é parâmetro — vem do token (INV-05). - _i2.Future<_i5.AlertStatusResult> statusFor({required String accessToken}) => - caller.callServerEndpoint<_i5.AlertStatusResult>( + _i2.Future<_i26.AlertStatusResult> statusFor({required String accessToken}) => + caller.callServerEndpoint<_i26.AlertStatusResult>( 'alerts', 'statusFor', {'accessToken': accessToken}, @@ -107,9 +618,9 @@ class EndpointAuth extends _i1.EndpointRef { @override String get name => 'auth'; - _i2.Future<_i6.DevelopmentLoginResult> developmentLogin({ + _i2.Future<_i27.DevelopmentLoginResult> developmentLogin({ required String role, - }) => caller.callServerEndpoint<_i6.DevelopmentLoginResult>( + }) => caller.callServerEndpoint<_i27.DevelopmentLoginResult>( 'auth', 'developmentLogin', {'role': role}, @@ -122,17 +633,135 @@ class EndpointAuth extends _i1.EndpointRef { /// `AuthenticationFailedException`, com a mensagem que o serviço escolheu: /// mensagem idêntica para matrícula inexistente e senha errada (ver /// `InstitutionalAuthService`). - _i2.Future<_i6.DevelopmentLoginResult> loginInstitutional({ + /// + /// MFA: com a verificação em duas etapas ativa, a senha certa sem + /// [totpCode] recebe `MfaRequiredException` (o app pede o código e repete a + /// chamada); com `REQUIRE_ACS_MFA` ligado e a MFA não ativada, recebe + /// `MfaEnrollmentRequiredException` (o app chama `beginTotpEnrollment`). + _i2.Future<_i27.DevelopmentLoginResult> loginInstitutional({ required String matricula, required String password, String? deviceId, - }) => caller.callServerEndpoint<_i6.DevelopmentLoginResult>( + String? totpCode, + }) => caller.callServerEndpoint<_i27.DevelopmentLoginResult>( 'auth', 'loginInstitutional', { 'matricula': matricula, 'password': password, 'deviceId': deviceId, + 'totpCode': totpCode, + }, + ); + + /// Login do backoffice (coordenador/administrador): matrícula + senha + TOTP. + /// + /// A MFA é obrigatória em todo ambiente (não há `REQUIRE_*_MFA` para o + /// staff). Sem refresh token e sem token de envio diferido: a sessão dura os + /// 15 minutos do JWT, e o `deviceId` do token é o sentinela de "sem + /// aparelho". O token sai com o papel gravado em `users` e **sem** microárea: + /// staff não é territorializado, e os caminhos do ACS o recusam por papel. + /// As recusas são as de [loginInstitutional]: mensagem única para matrícula + /// inexistente e senha errada, `MfaRequiredException` sem o código, e o mesmo + /// bloqueio por tentativas. + _i2.Future<_i27.DevelopmentLoginResult> loginStaff({ + required String matricula, + required String password, + String? totpCode, + }) => caller.callServerEndpoint<_i27.DevelopmentLoginResult>( + 'auth', + 'loginStaff', + { + 'matricula': matricula, + 'password': password, + 'totpCode': totpCode, + }, + ); + + /// Começa a ativação da MFA do staff. Sem token: o staff prova matrícula, + /// senha **e o código de ativação de uso único** (#48), emitido fora de banda + /// por `bin/issue_staff_activation_code.dart`; a MFA é obrigatória, então não + /// há login possível antes dela. Código errado, expirado ou já usado recebe a + /// mesma recusa e conta como tentativa falha da conta. + _i2.Future<_i28.TotpEnrollmentStart> beginStaffTotpEnrollment({ + required String matricula, + required String password, + required String activationCode, + }) => caller.callServerEndpoint<_i28.TotpEnrollmentStart>( + 'auth', + 'beginStaffTotpEnrollment', + { + 'matricula': matricula, + 'password': password, + 'activationCode': activationCode, + }, + ); + + /// Confirma a ativação da MFA do staff com o primeiro código do autenticador. + _i2.Future confirmStaffTotpEnrollment({ + required String matricula, + required String password, + required String activationCode, + required String code, + }) => caller.callServerEndpoint( + 'auth', + 'confirmStaffTotpEnrollment', + { + 'matricula': matricula, + 'password': password, + 'activationCode': activationCode, + 'code': code, + }, + ); + + /// Renova a sessão do ACS sem pedir senha nem TOTP (LGPD-RT06). Público por + /// desenho: quem chama já perdeu o JWT de 15 min — o refresh token, opaco e de + /// uso único, é a credencial. Toda recusa é a mesma `SessionExpiredException`. + _i2.Future<_i27.DevelopmentLoginResult> refreshSession({ + required String refreshToken, + required String deviceId, + }) => caller.callServerEndpoint<_i27.DevelopmentLoginResult>( + 'auth', + 'refreshSession', + { + 'refreshToken': refreshToken, + 'deviceId': deviceId, + }, + ); + + /// Encerra o turno: revoga a família inteira. Idempotente. + _i2.Future logout({required String refreshToken}) => + caller.callServerEndpoint( + 'auth', + 'logout', + {'refreshToken': refreshToken}, + ); + + /// Começa a ativação da MFA do ACS (RF07). Sem token: o ACS prova matrícula e senha. + _i2.Future<_i28.TotpEnrollmentStart> beginTotpEnrollment({ + required String matricula, + required String password, + }) => caller.callServerEndpoint<_i28.TotpEnrollmentStart>( + 'auth', + 'beginTotpEnrollment', + { + 'matricula': matricula, + 'password': password, + }, + ); + + /// Confirma a ativação com o primeiro código do autenticador. + _i2.Future confirmTotpEnrollment({ + required String matricula, + required String password, + required String code, + }) => caller.callServerEndpoint( + 'auth', + 'confirmTotpEnrollment', + { + 'matricula': matricula, + 'password': password, + 'code': code, }, ); @@ -177,11 +806,11 @@ class EndpointAuth extends _i1.EndpointRef { /// /// Toda recusa chega ao app como `OtpRequestException`, com a mensagem que o /// serviço escolheu: uma só, para não dizer se aquele CPF existe. - _i2.Future<_i6.DevelopmentLoginResult> verifyOtp({ + _i2.Future<_i27.DevelopmentLoginResult> verifyOtp({ required String cpf, required String code, String? deviceId, - }) => caller.callServerEndpoint<_i6.DevelopmentLoginResult>( + }) => caller.callServerEndpoint<_i27.DevelopmentLoginResult>( 'auth', 'verifyOtp', { @@ -207,6 +836,31 @@ abstract class EndpointAuthenticated extends _i1.EndpointRef { EndpointAuthenticated(_i1.EndpointCaller caller) : super(caller); } +/// Aparelhos do paciente para avisos segmentados (RF14, decisão §3.2). +/// {@category Endpoint} +class EndpointDevices extends EndpointAuthenticated { + EndpointDevices(_i1.EndpointCaller caller) : super(caller); + + @override + String get name => 'devices'; + + /// Registra o token de push do aparelho do paciente autenticado. Só grava com + /// o consentimento `segmentedPush` vigente; sem ele, [DataRightsException]. + _i2.Future registerPushToken({ + required String accessToken, + required String token, + required String platform, + }) => caller.callServerEndpoint( + 'devices', + 'registerPushToken', + { + 'accessToken': accessToken, + 'token': token, + 'platform': platform, + }, + ); +} + /// Sonda de saúde. /// /// Preserva a forma do antigo `GET /health` — `{status, mqtt_connected, @@ -223,14 +877,43 @@ class EndpointHealth extends _i1.EndpointRef { @override String get name => 'health'; - _i2.Future<_i7.ServiceHealth> check() => - caller.callServerEndpoint<_i7.ServiceHealth>( + _i2.Future<_i29.ServiceHealth> check() => + caller.callServerEndpoint<_i29.ServiceHealth>( 'health', 'check', {}, ); } +/// Avisos comunitários do ACS (RF14, decisão §3.2). +/// {@category Endpoint} +class EndpointNotices extends EndpointAuthenticated { + EndpointNotices(_i1.EndpointCaller caller) : super(caller); + + @override + String get name => 'notices'; + + /// Envia um aviso segmentado aos pacientes da microárea do ACS que + /// consentiram com `segmentedPush`. A microárea vem do token, nunca de + /// parâmetro. Auditoria: uma linha `community_notice` com quem enviou e a + /// microárea; o texto do aviso nunca entra na trilha. + _i2.Future<_i30.NoticeSendResult> sendSegmented({ + required String accessToken, + required String title, + required String message, + required String audience, + }) => caller.callServerEndpoint<_i30.NoticeSendResult>( + 'notices', + 'sendSegmented', + { + 'accessToken': accessToken, + 'title': title, + 'message': message, + 'audience': audience, + }, + ); +} + /// Onboarding do paciente por convite do ACS (RF02) e captura de /// consentimento por finalidade (LGPD-RF02). Ver decisão §2 de /// docs/superpowers/specs/2026-09-16-decisoes-produto-pos-validacao.md. @@ -248,10 +931,10 @@ class EndpointOnboarding extends _i1.EndpointRef { String get name => 'onboarding'; /// Chamado pelo app do ACS. Exige sessão de ACS. - _i2.Future<_i8.EnrollmentTokenResult> generateEnrollmentToken({ + _i2.Future<_i31.EnrollmentTokenResult> generateEnrollmentToken({ required String accessToken, required String patientId, - }) => caller.callServerEndpoint<_i8.EnrollmentTokenResult>( + }) => caller.callServerEndpoint<_i31.EnrollmentTokenResult>( 'onboarding', 'generateEnrollmentToken', { @@ -262,12 +945,13 @@ class EndpointOnboarding extends _i1.EndpointRef { /// Chamado pelo app do paciente. Não exige sessão prévia — é a própria /// conclusão do onboarding que emite a primeira sessão. - _i2.Future<_i9.EnrollmentResult> completeEnrollment({ + _i2.Future<_i32.EnrollmentResult> completeEnrollment({ required String token, required bool healthDataConsent, required bool remindersConsent, required bool pushConsent, - }) => caller.callServerEndpoint<_i9.EnrollmentResult>( + required bool termsAccepted, + }) => caller.callServerEndpoint<_i32.EnrollmentResult>( 'onboarding', 'completeEnrollment', { @@ -275,6 +959,7 @@ class EndpointOnboarding extends _i1.EndpointRef { 'healthDataConsent': healthDataConsent, 'remindersConsent': remindersConsent, 'pushConsent': pushConsent, + 'termsAccepted': termsAccepted, }, ); } @@ -285,6 +970,9 @@ class EndpointOnboarding extends _i1.EndpointRef { /// (`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. +/// +/// Serve também o próprio paciente: "Perfil clínico", "Meus Dados" e os +/// direitos do titular (LGPD-RF05/RF08) — sempre escopados pelo id do token. /// {@category Endpoint} class EndpointPatients extends EndpointAuthenticated { EndpointPatients(_i1.EndpointCaller caller) : super(caller); @@ -292,9 +980,9 @@ class EndpointPatients extends EndpointAuthenticated { @override String get name => 'patients'; - _i2.Future> listMicroArea({ + _i2.Future> listMicroArea({ required String accessToken, - }) => caller.callServerEndpoint>( + }) => caller.callServerEndpoint>( 'patients', 'listMicroArea', {'accessToken': accessToken}, @@ -327,12 +1015,106 @@ class EndpointPatients extends EndpointAuthenticated { /// Painel "Meus Dados" do próprio paciente autenticado (LGPD, /// spec/lgpd_design.md linhas 417/581-595): confirmação de existência de /// tratamento e acesso aos dados pessoais. - _i2.Future<_i11.PatientDataOverview> myData({required String accessToken}) => - caller.callServerEndpoint<_i11.PatientDataOverview>( + _i2.Future<_i34.PatientDataOverview> myData({required String accessToken}) => + caller.callServerEndpoint<_i34.PatientDataOverview>( 'patients', 'myData', {'accessToken': accessToken}, ); + + /// Concede ou revoga, pelo próprio titular, uma finalidade opcional de + /// consentimento (LGPD-RF05): uma linha nova em `consent_logs`, nunca a + /// edição da anterior. `healthDataProcessing` volta como + /// [DataRightsException] — retirá-lo passa pelo pedido de exclusão. + _i2.Future<_i35.PatientConsentRecord> updateConsent({ + required String accessToken, + required _i36.ConsentPurpose purpose, + required bool granted, + }) => caller.callServerEndpoint<_i35.PatientConsentRecord>( + 'patients', + 'updateConsent', + { + 'accessToken': accessToken, + 'purpose': purpose, + 'granted': granted, + }, + ); + + /// Aceite do Termo de Uso e da Política de Privacidade vigentes (LGPD-RF18) + /// por quem entrou por OTP sem passar pelo onboarding, ou aceitou uma versão + /// anterior. Só paciente; grava uma linha assinada em `consent_logs` só se a + /// versão vigente ainda não foi aceita — repetir devolve a existente. + _i2.Future<_i35.PatientConsentRecord> acceptTermsOfUse({ + required String accessToken, + }) => caller.callServerEndpoint<_i35.PatientConsentRecord>( + 'patients', + 'acceptTermsOfUse', + {'accessToken': accessToken}, + ); + + /// Se o paciente já aceitou o Termo de Uso e a Política de Privacidade da + /// versão vigente (LGPD-RF18). O app consulta depois do login por OTP para + /// decidir se mostra o convite ao aceite — um `bool`, sem ler o painel + /// "Meus Dados". Não grava auditoria: devolve ao próprio titular um fato + /// sobre o consentimento dele. + _i2.Future hasAcceptedCurrentTerms({required String accessToken}) => + caller.callServerEndpoint( + 'patients', + 'hasAcceptedCurrentTerms', + {'accessToken': accessToken}, + ); + + /// Se a decisão mais recente do titular para [purpose] é `granted` — um + /// `bool`, sem ler o painel "Meus Dados" e sem linha de auditoria de leitura. + /// O app pergunta isto a cada login antes de pedir o token ao provedor de + /// push (RF14). + _i2.Future hasGrantedConsent({ + required String accessToken, + required _i36.ConsentPurpose purpose, + }) => caller.callServerEndpoint( + 'patients', + 'hasGrantedConsent', + { + 'accessToken': accessToken, + 'purpose': purpose, + }, + ); + + /// Aviso de mudança dos termos ativo agora (LGPD-RF18, 15 dias de antecedência), + /// ou `null`. Só paciente. Lê a agenda publicada em `terms_versions` e não grava + /// linha de auditoria: nada do titular é lido nem gravado. + _i2.Future<_i37.TermsChangeNotice?> termsChangeNotice({ + required String accessToken, + }) => caller.callServerEndpoint<_i37.TermsChangeNotice?>( + 'patients', + 'termsChangeNotice', + {'accessToken': accessToken}, + ); + + /// Pedido de exclusão/anonimização dos próprios dados (LGPD-RF08). + /// Idempotente enquanto houver um pedido de exclusão em aberto. + _i2.Future<_i38.PatientDataSubjectRequestRecord> requestDataDeletion({ + required String accessToken, + }) => caller.callServerEndpoint<_i38.PatientDataSubjectRequestRecord>( + 'patients', + 'requestDataDeletion', + {'accessToken': accessToken}, + ); + + /// Pedido de correção de um dado (LGPD-RF08). [details] é texto livre do + /// titular, gravado cifrado; vazio ou acima de 500 caracteres volta como + /// [DataRightsException]. + _i2.Future<_i38.PatientDataSubjectRequestRecord> requestDataCorrection({ + required String accessToken, + required String details, + }) => caller.callServerEndpoint<_i38.PatientDataSubjectRequestRecord>( + 'patients', + 'requestDataCorrection', + { + 'accessToken': accessToken, + 'details': details, + }, + ); } /// Motor de triagem determinístico, inspirado no Protocolo de Manchester. @@ -352,7 +1134,7 @@ class EndpointTriage extends EndpointAuthenticated { @override String get name => 'triage'; - _i2.Future<_i12.TriageResult> evaluate({ + _i2.Future<_i39.TriageResult> evaluate({ required String accessToken, required bool chestPain, required bool difficultyBreathing, @@ -360,7 +1142,7 @@ class EndpointTriage extends EndpointAuthenticated { required bool persistentVomiting, required bool bleeding, required bool severeWeakness, - }) => caller.callServerEndpoint<_i12.TriageResult>( + }) => caller.callServerEndpoint<_i39.TriageResult>( 'triage', 'evaluate', { @@ -375,6 +1157,22 @@ class EndpointTriage extends EndpointAuthenticated { ); } +/// Contato da UBS do ACS (RF13). +/// {@category Endpoint} +class EndpointUbs extends EndpointAuthenticated { + EndpointUbs(_i1.EndpointCaller caller) : super(caller); + + @override + String get name => 'ubs'; + + _i2.Future<_i40.UbsContact> myContact({required String accessToken}) => + caller.callServerEndpoint<_i40.UbsContact>( + 'ubs', + 'myContact', + {'accessToken': accessToken}, + ); +} + /// Sincronização das visitas domiciliares registradas offline. /// /// É a contraparte da fila offline do app do ACS: o dispositivo grava a visita @@ -391,10 +1189,10 @@ class EndpointVisits extends EndpointAuthenticated { @override String get name => 'visits'; - _i2.Future> sync({ + _i2.Future> sync({ required String accessToken, - required List<_i14.VisitSyncEntry> visits, - }) => caller.callServerEndpoint>( + required List<_i42.VisitSyncEntry> visits, + }) => caller.callServerEndpoint>( 'visits', 'sync', { @@ -403,13 +1201,70 @@ class EndpointVisits extends EndpointAuthenticated { }, ); + /// Envio das visitas LEGADAS do aparelho (autoria desconhecida, D4 do plano + /// 2026-10-03): gravadas antes de existir dono por visita no banco local. + /// + /// A sessão do ACS é só o transporte — ver `VisitSyncService.syncLegacy`. + /// Mesma transação por lote e mesma tradução de erros de [sync]. + _i2.Future> syncLegacy({ + required String accessToken, + required String deviceId, + required List<_i42.VisitSyncEntry> visits, + }) => caller.callServerEndpoint>( + 'visits', + 'syncLegacy', + { + 'accessToken': accessToken, + 'deviceId': deviceId, + 'visits': visits, + }, + ); + + /// Envio DIFERIDO (D7 do plano 2026-10-03): sobe as visitas pendentes de um + /// ACS que pode já ter saído, autenticado pelo token de envio que o + /// `auth.loginInstitutional` emitiu para ele neste aparelho — não pelo JWT. + /// + /// Público por desenho para o `authenticate(...)`: o token opaco É a + /// credencial, e o único poder dele é este envio. O usuário resolvido é o + /// DONO do token, com a microárea relida do banco agora, e o lote passa + /// pelo MESMO `VisitSyncService.sync` do envio comum: autoria do dono, + /// território atual do dono, `localId` de outro agente recusado. Toda recusa + /// do token é a mesma `SessionExpiredException`, sem motivo e sem o token. + /// + /// No máximo `VisitSyncService.maxLegacyBatch` visitas por chamada, checado + /// antes de qualquer acesso ao banco. Um evento `visit_deferred_sync` por + /// lote gravado vai para `audit_logs`, com o dono e nada clínico. + _i2.Future> syncDeferred({ + required String uploadToken, + required String deviceId, + required List<_i42.VisitSyncEntry> visits, + }) => caller.callServerEndpoint>( + 'visits', + 'syncDeferred', + { + 'uploadToken': uploadToken, + 'deviceId': deviceId, + 'visits': visits, + }, + ); + + /// Revoga o token de envio diferido (o app chama quando a fila do dono + /// zera). Público pelo mesmo motivo de [syncDeferred]; idempotente, e um + /// token desconhecido é ignorado em silêncio. + _i2.Future revokeUploadToken({required String uploadToken}) => + caller.callServerEndpoint( + 'visits', + 'revokeUploadToken', + {'uploadToken': uploadToken}, + ); + /// Sincronização central→dispositivo: visitas da microárea do ACS /// autenticado alteradas após `since`, para reconciliar um device que /// ficou offline ou foi reinstalado. - _i2.Future> pull({ + _i2.Future> pull({ required String accessToken, required DateTime since, - }) => caller.callServerEndpoint>( + }) => caller.callServerEndpoint>( 'visits', 'pull', { @@ -439,7 +1294,7 @@ class Client extends _i1.ServerpodClientShared { bool? disconnectStreamsOnLostInternetConnection, }) : super( host, - _i15.Protocol(), + _i43.Protocol(), securityContext: securityContext, streamingConnectionTimeout: streamingConnectionTimeout, connectionTimeout: connectionTimeout, @@ -448,37 +1303,53 @@ class Client extends _i1.ServerpodClientShared { disconnectStreamsOnLostInternetConnection: disconnectStreamsOnLostInternetConnection, ) { + admin = EndpointAdmin(this); alerts = EndpointAlerts(this); auth = EndpointAuth(this); + devices = EndpointDevices(this); health = EndpointHealth(this); + notices = EndpointNotices(this); onboarding = EndpointOnboarding(this); patients = EndpointPatients(this); triage = EndpointTriage(this); + ubs = EndpointUbs(this); visits = EndpointVisits(this); } + late final EndpointAdmin admin; + late final EndpointAlerts alerts; late final EndpointAuth auth; + late final EndpointDevices devices; + late final EndpointHealth health; + late final EndpointNotices notices; + late final EndpointOnboarding onboarding; late final EndpointPatients patients; late final EndpointTriage triage; + late final EndpointUbs ubs; + late final EndpointVisits visits; @override Map get endpointRefLookup => { + 'admin': admin, 'alerts': alerts, 'auth': auth, + 'devices': devices, 'health': health, + 'notices': notices, 'onboarding': onboarding, 'patients': patients, 'triage': triage, + 'ubs': ubs, 'visits': visits, }; diff --git a/backend/sinalacs_client/lib/src/protocol/data_subject_request.dart b/backend/sinalacs_client/lib/src/protocol/data_subject_request.dart new file mode 100644 index 00000000..c285b36c --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/data_subject_request.dart @@ -0,0 +1,227 @@ +/* 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/data_subject_request_type.dart' as _i2; +import 'enums/data_subject_request_status.dart' as _i3; + +/// Pedido do titular sobre os próprios dados (LGPD-RF08): exclusão ou +/// correção, com prazo de resposta de 15 dias (spec/lgpd_design.md 596-597). +/// +/// `details` é texto livre do titular e pode citar condição de saúde — por +/// isso é cifrado na aplicação (AES-256-GCM), pelo mesmo motivo e com o +/// mesmo `HealthDataCipher` de `visits.notes` (RNF03/INV-04). Num pedido de +/// exclusão guarda o JSON `null` cifrado. +abstract class DataSubjectRequest implements _i1.SerializableModel { + DataSubjectRequest._({ + this.id, + required this.userId, + required this.requestType, + required this.detailsEncrypted, + required this.detailsKeyVersion, + required this.status, + required this.createdAt, + required this.dueAt, + this.decidedAt, + this.decidedBy, + this.resolutionEncrypted, + this.resolutionKeyVersion, + }); + + factory DataSubjectRequest({ + _i1.UuidValue? id, + required _i1.UuidValue userId, + required _i2.DataSubjectRequestType requestType, + required String detailsEncrypted, + required int detailsKeyVersion, + required _i3.DataSubjectRequestStatus status, + required DateTime createdAt, + required DateTime dueAt, + DateTime? decidedAt, + _i1.UuidValue? decidedBy, + String? resolutionEncrypted, + int? resolutionKeyVersion, + }) = _DataSubjectRequestImpl; + + factory DataSubjectRequest.fromJson(Map jsonSerialization) { + return DataSubjectRequest( + id: jsonSerialization['id'] == null + ? null + : _i1.UuidValueJsonExtension.fromJson(jsonSerialization['id']), + userId: _i1.UuidValueJsonExtension.fromJson(jsonSerialization['userId']), + requestType: _i2.DataSubjectRequestType.fromJson( + (jsonSerialization['requestType'] as String), + ), + detailsEncrypted: jsonSerialization['detailsEncrypted'] as String, + detailsKeyVersion: jsonSerialization['detailsKeyVersion'] as int, + status: _i3.DataSubjectRequestStatus.fromJson( + (jsonSerialization['status'] as String), + ), + createdAt: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['createdAt'], + ), + dueAt: _i1.DateTimeJsonExtension.fromJson(jsonSerialization['dueAt']), + decidedAt: jsonSerialization['decidedAt'] == null + ? null + : _i1.DateTimeJsonExtension.fromJson(jsonSerialization['decidedAt']), + decidedBy: jsonSerialization['decidedBy'] == null + ? null + : _i1.UuidValueJsonExtension.fromJson(jsonSerialization['decidedBy']), + resolutionEncrypted: jsonSerialization['resolutionEncrypted'] as String?, + resolutionKeyVersion: jsonSerialization['resolutionKeyVersion'] as int?, + ); + } + + /// The database id, set if the object has been inserted into the + /// database or if it has been fetched from the database. Otherwise, + /// the id will be null. + _i1.UuidValue? id; + + _i1.UuidValue userId; + + _i2.DataSubjectRequestType requestType; + + String detailsEncrypted; + + int detailsKeyVersion; + + _i3.DataSubjectRequestStatus status; + + DateTime createdAt; + + /// Prazo de resposta: `createdAt` + 15 dias. + DateTime dueAt; + + /// Decisão do backoffice (#42); nulos enquanto o pedido está `open`. + DateTime? decidedAt; + + _i1.UuidValue? decidedBy; + + /// Nota de resposta/motivo da recusa: texto livre, cifrado como `details`. + String? resolutionEncrypted; + + int? resolutionKeyVersion; + + /// Returns a shallow copy of this [DataSubjectRequest] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + DataSubjectRequest copyWith({ + _i1.UuidValue? id, + _i1.UuidValue? userId, + _i2.DataSubjectRequestType? requestType, + String? detailsEncrypted, + int? detailsKeyVersion, + _i3.DataSubjectRequestStatus? status, + DateTime? createdAt, + DateTime? dueAt, + DateTime? decidedAt, + _i1.UuidValue? decidedBy, + String? resolutionEncrypted, + int? resolutionKeyVersion, + }); + @override + Map toJson() { + return { + '__className__': 'DataSubjectRequest', + if (id != null) 'id': id?.toJson(), + 'userId': userId.toJson(), + 'requestType': requestType.toJson(), + 'detailsEncrypted': detailsEncrypted, + 'detailsKeyVersion': detailsKeyVersion, + 'status': status.toJson(), + 'createdAt': createdAt.toJson(), + 'dueAt': dueAt.toJson(), + if (decidedAt != null) 'decidedAt': decidedAt?.toJson(), + if (decidedBy != null) 'decidedBy': decidedBy?.toJson(), + if (resolutionEncrypted != null) + 'resolutionEncrypted': resolutionEncrypted, + if (resolutionKeyVersion != null) + 'resolutionKeyVersion': resolutionKeyVersion, + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _DataSubjectRequestImpl extends DataSubjectRequest { + _DataSubjectRequestImpl({ + _i1.UuidValue? id, + required _i1.UuidValue userId, + required _i2.DataSubjectRequestType requestType, + required String detailsEncrypted, + required int detailsKeyVersion, + required _i3.DataSubjectRequestStatus status, + required DateTime createdAt, + required DateTime dueAt, + DateTime? decidedAt, + _i1.UuidValue? decidedBy, + String? resolutionEncrypted, + int? resolutionKeyVersion, + }) : super._( + id: id, + userId: userId, + requestType: requestType, + detailsEncrypted: detailsEncrypted, + detailsKeyVersion: detailsKeyVersion, + status: status, + createdAt: createdAt, + dueAt: dueAt, + decidedAt: decidedAt, + decidedBy: decidedBy, + resolutionEncrypted: resolutionEncrypted, + resolutionKeyVersion: resolutionKeyVersion, + ); + + /// Returns a shallow copy of this [DataSubjectRequest] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + DataSubjectRequest copyWith({ + Object? id = _Undefined, + _i1.UuidValue? userId, + _i2.DataSubjectRequestType? requestType, + String? detailsEncrypted, + int? detailsKeyVersion, + _i3.DataSubjectRequestStatus? status, + DateTime? createdAt, + DateTime? dueAt, + Object? decidedAt = _Undefined, + Object? decidedBy = _Undefined, + Object? resolutionEncrypted = _Undefined, + Object? resolutionKeyVersion = _Undefined, + }) { + return DataSubjectRequest( + id: id is _i1.UuidValue? ? id : this.id, + userId: userId ?? this.userId, + requestType: requestType ?? this.requestType, + detailsEncrypted: detailsEncrypted ?? this.detailsEncrypted, + detailsKeyVersion: detailsKeyVersion ?? this.detailsKeyVersion, + status: status ?? this.status, + createdAt: createdAt ?? this.createdAt, + dueAt: dueAt ?? this.dueAt, + decidedAt: decidedAt is DateTime? ? decidedAt : this.decidedAt, + decidedBy: decidedBy is _i1.UuidValue? ? decidedBy : this.decidedBy, + resolutionEncrypted: resolutionEncrypted is String? + ? resolutionEncrypted + : this.resolutionEncrypted, + resolutionKeyVersion: resolutionKeyVersion is int? + ? resolutionKeyVersion + : this.resolutionKeyVersion, + ); + } +} diff --git a/backend/sinalacs_client/lib/src/protocol/enums/audit_chain_reason.dart b/backend/sinalacs_client/lib/src/protocol/enums/audit_chain_reason.dart new file mode 100644 index 00000000..dcfe2fe1 --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/enums/audit_chain_reason.dart @@ -0,0 +1,68 @@ +/* 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; + +/// Motivo fechado de uma verificação da cadeia de `audit_logs` que não deu +/// `ok` (#45, D9). Substitui, para o cliente, o texto livre `reason` do +/// verificador: nunca vai hash, segredo nem conteúdo de linha. +/// `hashMismatch` = HMAC da linha não confere; `linkBroken` = `previousHash` +/// não aponta para a anterior; `sequenceGap` = `sequence` descontínua; +/// `tailTruncated` = cadeia termina antes do último checkpoint válido, ou o +/// `entryHash` naquela `sequence` diverge do checkpoint (Task 7); +/// `checkpointInvalid` = cadeia íntegra, mas há checkpoint com assinatura +/// inválida — ignorado e sinalizado, nunca aceito (Task 7; estado inconclusive); +/// `timeBudget` = orçamento de tempo esgotado antes do fim (resultado parcial, +/// nunca íntegro); `databaseError` = falha ao ler; `secretUnavailable` = +/// segredo da cadeia ausente. +enum AuditChainReason implements _i1.SerializableModel { + hashMismatch, + linkBroken, + sequenceGap, + tailTruncated, + checkpointInvalid, + timeBudget, + databaseError, + secretUnavailable; + + static AuditChainReason fromJson(String name) { + switch (name) { + case 'hashMismatch': + return AuditChainReason.hashMismatch; + case 'linkBroken': + return AuditChainReason.linkBroken; + case 'sequenceGap': + return AuditChainReason.sequenceGap; + case 'tailTruncated': + return AuditChainReason.tailTruncated; + case 'checkpointInvalid': + return AuditChainReason.checkpointInvalid; + case 'timeBudget': + return AuditChainReason.timeBudget; + case 'databaseError': + return AuditChainReason.databaseError; + case 'secretUnavailable': + return AuditChainReason.secretUnavailable; + default: + throw ArgumentError( + 'Value "$name" cannot be converted to "AuditChainReason"', + ); + } + } + + @override + String toJson() => name; + + @override + String toString() => name; +} diff --git a/backend/sinalacs_client/lib/src/protocol/enums/audit_integrity_state.dart b/backend/sinalacs_client/lib/src/protocol/enums/audit_integrity_state.dart new file mode 100644 index 00000000..ab360a04 --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/enums/audit_integrity_state.dart @@ -0,0 +1,51 @@ +/* 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; + +/// Estado global da cadeia de `audit_logs` (#45, D3), calculado no servidor +/// por `admin.verifyAuditChain`. **Só `verified` é verde.** `broken` = a +/// verificação achou uma quebra (pode ser adulteração ou segredo trocado: o +/// painel diz "falha", nunca "adulteração" por conta própria); +/// `unavailable` = não deu para verificar (banco, tempo de leitura ou segredo +/// ausente); `inconclusive` = nada a atestar (cadeia vazia) ou verificação +/// parcial (orçamento de tempo esgotado). +enum AuditIntegrityState implements _i1.SerializableModel { + verified, + broken, + unavailable, + inconclusive; + + static AuditIntegrityState fromJson(String name) { + switch (name) { + case 'verified': + return AuditIntegrityState.verified; + case 'broken': + return AuditIntegrityState.broken; + case 'unavailable': + return AuditIntegrityState.unavailable; + case 'inconclusive': + return AuditIntegrityState.inconclusive; + default: + throw ArgumentError( + 'Value "$name" cannot be converted to "AuditIntegrityState"', + ); + } + } + + @override + String toJson() => name; + + @override + String toString() => name; +} diff --git a/backend/sinalacs_client/lib/src/protocol/enums/audit_row_seal.dart b/backend/sinalacs_client/lib/src/protocol/enums/audit_row_seal.dart new file mode 100644 index 00000000..c9f641fe --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/enums/audit_row_seal.dart @@ -0,0 +1,46 @@ +/* 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; + +/// Selo de integridade de UMA linha de `audit_logs` (#45, D3), calculado no +/// servidor com a mesma `AuditChain` que grava a trilha. Só `verified` é verde: +/// `invalid` = o HMAC da linha ou o elo com a `sequence - 1` não confere; +/// `unverifiable` = a linha `sequence - 1` não existe no banco (o elo não pôde +/// ser conferido). Nunca carrega hash nem segredo. +enum AuditRowSeal implements _i1.SerializableModel { + verified, + invalid, + unverifiable; + + static AuditRowSeal fromJson(String name) { + switch (name) { + case 'verified': + return AuditRowSeal.verified; + case 'invalid': + return AuditRowSeal.invalid; + case 'unverifiable': + return AuditRowSeal.unverifiable; + default: + throw ArgumentError( + 'Value "$name" cannot be converted to "AuditRowSeal"', + ); + } + } + + @override + String toJson() => name; + + @override + String toString() => name; +} diff --git a/backend/sinalacs_client/lib/src/protocol/enums/consent_purpose.dart b/backend/sinalacs_client/lib/src/protocol/enums/consent_purpose.dart index 23069b53..3d07d420 100644 --- a/backend/sinalacs_client/lib/src/protocol/enums/consent_purpose.dart +++ b/backend/sinalacs_client/lib/src/protocol/enums/consent_purpose.dart @@ -13,14 +13,18 @@ import 'package:serverpod_client/serverpod_client.dart' as _i1; -/// As três finalidades de consentimento do onboarding (decisão §2.2 de +/// As finalidades de consentimento do onboarding (decisão §2.2 de /// docs/superpowers/specs/2026-09-16-decisoes-produto-pos-validacao.md). -/// `healthDataProcessing` é obrigatória para usar o app; as outras duas -/// podem ser recusadas sem impedir o restante do fluxo. +/// `healthDataProcessing` e `termsOfUse` são obrigatórias para usar o app; +/// as outras duas podem ser recusadas sem impedir o restante do fluxo. +/// `termsOfUse` registra o aceite do Termo de Uso e da Política de +/// Privacidade na versão `consentPolicyVersion` (LGPD-RF18/RF19) — não é +/// consentimento revogável pelo painel, é a condição de uso do app. enum ConsentPurpose implements _i1.SerializableModel { healthDataProcessing, localReminders, - segmentedPush; + segmentedPush, + termsOfUse; static ConsentPurpose fromJson(String name) { switch (name) { @@ -30,6 +34,8 @@ enum ConsentPurpose implements _i1.SerializableModel { return ConsentPurpose.localReminders; case 'segmentedPush': return ConsentPurpose.segmentedPush; + case 'termsOfUse': + return ConsentPurpose.termsOfUse; default: throw ArgumentError( 'Value "$name" cannot be converted to "ConsentPurpose"', diff --git a/backend/sinalacs_client/lib/src/protocol/enums/data_subject_request_status.dart b/backend/sinalacs_client/lib/src/protocol/enums/data_subject_request_status.dart new file mode 100644 index 00000000..de837f1a --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/enums/data_subject_request_status.dart @@ -0,0 +1,47 @@ +/* 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; + +/// Situação de um pedido do titular. `open` é escrito pelo titular; `inReview`, +/// `completed` e `rejected` pelo backoffice (coordenador/admin, #42). +/// `completed` e `rejected` são finais. +enum DataSubjectRequestStatus implements _i1.SerializableModel { + open, + inReview, + completed, + rejected; + + static DataSubjectRequestStatus fromJson(String name) { + switch (name) { + case 'open': + return DataSubjectRequestStatus.open; + case 'inReview': + return DataSubjectRequestStatus.inReview; + case 'completed': + return DataSubjectRequestStatus.completed; + case 'rejected': + return DataSubjectRequestStatus.rejected; + default: + throw ArgumentError( + 'Value "$name" cannot be converted to "DataSubjectRequestStatus"', + ); + } + } + + @override + String toJson() => name; + + @override + String toString() => name; +} diff --git a/backend/sinalacs_client/lib/src/protocol/enums/data_subject_request_type.dart b/backend/sinalacs_client/lib/src/protocol/enums/data_subject_request_type.dart new file mode 100644 index 00000000..7ebeab6f --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/enums/data_subject_request_type.dart @@ -0,0 +1,40 @@ +/* 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; + +/// Tipo de pedido do titular sobre os próprios dados (LGPD-RF08, Art. 18): +/// exclusão/anonimização ou correção. Ver spec/lgpd_design.md linhas 583-597. +enum DataSubjectRequestType implements _i1.SerializableModel { + deletion, + correction; + + static DataSubjectRequestType fromJson(String name) { + switch (name) { + case 'deletion': + return DataSubjectRequestType.deletion; + case 'correction': + return DataSubjectRequestType.correction; + default: + throw ArgumentError( + 'Value "$name" cannot be converted to "DataSubjectRequestType"', + ); + } + } + + @override + String toJson() => name; + + @override + String toString() => name; +} diff --git a/backend/sinalacs_client/lib/src/protocol/enums/escalation_reason.dart b/backend/sinalacs_client/lib/src/protocol/enums/escalation_reason.dart new file mode 100644 index 00000000..d552f1e9 --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/enums/escalation_reason.dart @@ -0,0 +1,43 @@ +/* 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; + +/// Motivo do escalonamento de um alerta (#44). Conjunto fechado, sem texto +/// livre: texto livre num alerta de saúde é dado pessoal sensível (LGPD). +enum EscalationReason implements _i1.SerializableModel { + noAckTimeout, + acsUnreachable, + patientRecontact; + + static EscalationReason fromJson(String name) { + switch (name) { + case 'noAckTimeout': + return EscalationReason.noAckTimeout; + case 'acsUnreachable': + return EscalationReason.acsUnreachable; + case 'patientRecontact': + return EscalationReason.patientRecontact; + default: + throw ArgumentError( + 'Value "$name" cannot be converted to "EscalationReason"', + ); + } + } + + @override + String toJson() => name; + + @override + String toString() => name; +} diff --git a/backend/sinalacs_client/lib/src/protocol/enums/scheduled_notice_status.dart b/backend/sinalacs_client/lib/src/protocol/enums/scheduled_notice_status.dart new file mode 100644 index 00000000..e6facb06 --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/enums/scheduled_notice_status.dart @@ -0,0 +1,51 @@ +/* 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; + +/// Ciclo de um aviso agendado. `sending` é a reserva atômica anti-duplicidade. +enum ScheduledNoticeStatus implements _i1.SerializableModel { + pending, + sending, + sent, + unknown, + failed, + cancelled; + + static ScheduledNoticeStatus fromJson(String name) { + switch (name) { + case 'pending': + return ScheduledNoticeStatus.pending; + case 'sending': + return ScheduledNoticeStatus.sending; + case 'sent': + return ScheduledNoticeStatus.sent; + case 'unknown': + return ScheduledNoticeStatus.unknown; + case 'failed': + return ScheduledNoticeStatus.failed; + case 'cancelled': + return ScheduledNoticeStatus.cancelled; + default: + throw ArgumentError( + 'Value "$name" cannot be converted to "ScheduledNoticeStatus"', + ); + } + } + + @override + String toJson() => name; + + @override + String toString() => name; +} diff --git a/backend/sinalacs_client/lib/src/protocol/enums/visit_authorship.dart b/backend/sinalacs_client/lib/src/protocol/enums/visit_authorship.dart new file mode 100644 index 00000000..602396ce --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/enums/visit_authorship.dart @@ -0,0 +1,44 @@ +/* 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; + +/// Quem é o autor de uma visita (D4 do plano 2026-10-03). +/// +/// `acs`: o ACS da sessão que a enviou por `visits.sync` — `visits.acsId` é +/// ele. `legacyUnclaimed`: visita gravada no aparelho antes de existir dono por +/// visita (migração v7 do banco local do app); a autoria é DESCONHECIDA e +/// `visits.acsId` fica nulo. Só `visits.syncLegacy` grava este valor. +enum VisitAuthorship implements _i1.SerializableModel { + acs, + legacyUnclaimed; + + static VisitAuthorship fromJson(String name) { + switch (name) { + case 'acs': + return VisitAuthorship.acs; + case 'legacyUnclaimed': + return VisitAuthorship.legacyUnclaimed; + default: + throw ArgumentError( + 'Value "$name" cannot be converted to "VisitAuthorship"', + ); + } + } + + @override + String toJson() => name; + + @override + String toString() => name; +} diff --git a/backend/sinalacs_client/lib/src/protocol/exceptions/admin_invalid_request_exception.dart b/backend/sinalacs_client/lib/src/protocol/exceptions/admin_invalid_request_exception.dart new file mode 100644 index 00000000..8d8a2634 --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/exceptions/admin_invalid_request_exception.dart @@ -0,0 +1,64 @@ +/* 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; + +/// Parâmetro do backoffice fora do intervalo (por exemplo, `limit` de paginação). +/// Não é erro de permissão. +abstract class AdminInvalidRequestException + implements _i1.SerializableException, _i1.SerializableModel { + AdminInvalidRequestException._({required this.message}); + + factory AdminInvalidRequestException({required String message}) = + _AdminInvalidRequestExceptionImpl; + + factory AdminInvalidRequestException.fromJson( + Map jsonSerialization, + ) { + return AdminInvalidRequestException( + message: jsonSerialization['message'] as String, + ); + } + + String message; + + /// Returns a shallow copy of this [AdminInvalidRequestException] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + AdminInvalidRequestException copyWith({String? message}); + @override + Map toJson() { + return { + '__className__': 'AdminInvalidRequestException', + 'message': message, + }; + } + + @override + String toString() { + return 'AdminInvalidRequestException(message: $message)'; + } +} + +class _AdminInvalidRequestExceptionImpl extends AdminInvalidRequestException { + _AdminInvalidRequestExceptionImpl({required String message}) + : super._(message: message); + + /// Returns a shallow copy of this [AdminInvalidRequestException] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + AdminInvalidRequestException copyWith({String? message}) { + return AdminInvalidRequestException(message: message ?? this.message); + } +} diff --git a/backend/sinalacs_client/lib/src/protocol/exceptions/data_rights_exception.dart b/backend/sinalacs_client/lib/src/protocol/exceptions/data_rights_exception.dart new file mode 100644 index 00000000..c349a711 --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/exceptions/data_rights_exception.dart @@ -0,0 +1,61 @@ +/* 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; + +/// Recusa de negócio de uma operação do titular sobre os próprios dados +/// (consentimento obrigatório, texto de correção vazio ou longo demais). +/// Mensagem pronta para a pessoa ler; nunca cita dado do paciente. +abstract class DataRightsException + implements _i1.SerializableException, _i1.SerializableModel { + DataRightsException._({required this.message}); + + factory DataRightsException({required String message}) = + _DataRightsExceptionImpl; + + factory DataRightsException.fromJson(Map jsonSerialization) { + return DataRightsException(message: jsonSerialization['message'] as String); + } + + String message; + + /// Returns a shallow copy of this [DataRightsException] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + DataRightsException copyWith({String? message}); + @override + Map toJson() { + return { + '__className__': 'DataRightsException', + 'message': message, + }; + } + + @override + String toString() { + return 'DataRightsException(message: $message)'; + } +} + +class _DataRightsExceptionImpl extends DataRightsException { + _DataRightsExceptionImpl({required String message}) + : super._(message: message); + + /// Returns a shallow copy of this [DataRightsException] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + DataRightsException copyWith({String? message}) { + return DataRightsException(message: message ?? this.message); + } +} diff --git a/backend/sinalacs_client/lib/src/protocol/exceptions/mfa_enrollment_required_exception.dart b/backend/sinalacs_client/lib/src/protocol/exceptions/mfa_enrollment_required_exception.dart new file mode 100644 index 00000000..5eef78af --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/exceptions/mfa_enrollment_required_exception.dart @@ -0,0 +1,65 @@ +/* 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; + +/// O servidor exige MFA e este ACS ainda não a ativou: a pessoa precisa +/// ativar (auth.beginTotpEnrollment) antes de entrar. +abstract class MfaEnrollmentRequiredException + implements _i1.SerializableException, _i1.SerializableModel { + MfaEnrollmentRequiredException._({required this.message}); + + factory MfaEnrollmentRequiredException({required String message}) = + _MfaEnrollmentRequiredExceptionImpl; + + factory MfaEnrollmentRequiredException.fromJson( + Map jsonSerialization, + ) { + return MfaEnrollmentRequiredException( + message: jsonSerialization['message'] as String, + ); + } + + String message; + + /// Returns a shallow copy of this [MfaEnrollmentRequiredException] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + MfaEnrollmentRequiredException copyWith({String? message}); + @override + Map toJson() { + return { + '__className__': 'MfaEnrollmentRequiredException', + 'message': message, + }; + } + + @override + String toString() { + return 'MfaEnrollmentRequiredException(message: $message)'; + } +} + +class _MfaEnrollmentRequiredExceptionImpl + extends MfaEnrollmentRequiredException { + _MfaEnrollmentRequiredExceptionImpl({required String message}) + : super._(message: message); + + /// Returns a shallow copy of this [MfaEnrollmentRequiredException] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + MfaEnrollmentRequiredException copyWith({String? message}) { + return MfaEnrollmentRequiredException(message: message ?? this.message); + } +} diff --git a/backend/sinalacs_client/lib/src/protocol/exceptions/mfa_required_exception.dart b/backend/sinalacs_client/lib/src/protocol/exceptions/mfa_required_exception.dart new file mode 100644 index 00000000..16da530f --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/exceptions/mfa_required_exception.dart @@ -0,0 +1,64 @@ +/* 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; + +/// O ACS tem MFA ativa, a senha conferiu e faltou o código do autenticador. +/// Só é lançada DEPOIS de a senha estar certa: não revela matrícula nem senha. +abstract class MfaRequiredException + implements _i1.SerializableException, _i1.SerializableModel { + MfaRequiredException._({required this.message}); + + factory MfaRequiredException({required String message}) = + _MfaRequiredExceptionImpl; + + factory MfaRequiredException.fromJson( + Map jsonSerialization, + ) { + return MfaRequiredException( + message: jsonSerialization['message'] as String, + ); + } + + String message; + + /// Returns a shallow copy of this [MfaRequiredException] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + MfaRequiredException copyWith({String? message}); + @override + Map toJson() { + return { + '__className__': 'MfaRequiredException', + 'message': message, + }; + } + + @override + String toString() { + return 'MfaRequiredException(message: $message)'; + } +} + +class _MfaRequiredExceptionImpl extends MfaRequiredException { + _MfaRequiredExceptionImpl({required String message}) + : super._(message: message); + + /// Returns a shallow copy of this [MfaRequiredException] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + MfaRequiredException copyWith({String? message}) { + return MfaRequiredException(message: message ?? this.message); + } +} diff --git a/backend/sinalacs_client/lib/src/protocol/exceptions/notice_delivery_exception.dart b/backend/sinalacs_client/lib/src/protocol/exceptions/notice_delivery_exception.dart new file mode 100644 index 00000000..51b62c96 --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/exceptions/notice_delivery_exception.dart @@ -0,0 +1,65 @@ +/* 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; + +/// O aviso comunitário não pôde ser entregue agora: o relé de push está fora do +/// ar, lento, ou o envio não está configurado neste ambiente. Mensagem pronta +/// para o ACS ler; nunca cita token, destinatário nem o texto enviado. +abstract class NoticeDeliveryException + implements _i1.SerializableException, _i1.SerializableModel { + NoticeDeliveryException._({required this.message}); + + factory NoticeDeliveryException({required String message}) = + _NoticeDeliveryExceptionImpl; + + factory NoticeDeliveryException.fromJson( + Map jsonSerialization, + ) { + return NoticeDeliveryException( + message: jsonSerialization['message'] as String, + ); + } + + String message; + + /// Returns a shallow copy of this [NoticeDeliveryException] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + NoticeDeliveryException copyWith({String? message}); + @override + Map toJson() { + return { + '__className__': 'NoticeDeliveryException', + 'message': message, + }; + } + + @override + String toString() { + return 'NoticeDeliveryException(message: $message)'; + } +} + +class _NoticeDeliveryExceptionImpl extends NoticeDeliveryException { + _NoticeDeliveryExceptionImpl({required String message}) + : super._(message: message); + + /// Returns a shallow copy of this [NoticeDeliveryException] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + NoticeDeliveryException copyWith({String? message}) { + return NoticeDeliveryException(message: message ?? this.message); + } +} diff --git a/backend/sinalacs_client/lib/src/protocol/exceptions/session_expired_exception.dart b/backend/sinalacs_client/lib/src/protocol/exceptions/session_expired_exception.dart new file mode 100644 index 00000000..9b28f714 --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/exceptions/session_expired_exception.dart @@ -0,0 +1,65 @@ +/* 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; + +/// O refresh token não vale mais (expirado, revogado, reusado, de outro +/// aparelho ou de conta desativada). Mensagem única de propósito: o motivo +/// real fica só em audit_logs. +abstract class SessionExpiredException + implements _i1.SerializableException, _i1.SerializableModel { + SessionExpiredException._({required this.message}); + + factory SessionExpiredException({required String message}) = + _SessionExpiredExceptionImpl; + + factory SessionExpiredException.fromJson( + Map jsonSerialization, + ) { + return SessionExpiredException( + message: jsonSerialization['message'] as String, + ); + } + + String message; + + /// Returns a shallow copy of this [SessionExpiredException] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + SessionExpiredException copyWith({String? message}); + @override + Map toJson() { + return { + '__className__': 'SessionExpiredException', + 'message': message, + }; + } + + @override + String toString() { + return 'SessionExpiredException(message: $message)'; + } +} + +class _SessionExpiredExceptionImpl extends SessionExpiredException { + _SessionExpiredExceptionImpl({required String message}) + : super._(message: message); + + /// Returns a shallow copy of this [SessionExpiredException] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + SessionExpiredException copyWith({String? message}) { + return SessionExpiredException(message: message ?? this.message); + } +} diff --git a/backend/sinalacs_client/lib/src/protocol/patient.dart b/backend/sinalacs_client/lib/src/protocol/patient.dart index cd901efa..656c341c 100644 --- a/backend/sinalacs_client/lib/src/protocol/patient.dart +++ b/backend/sinalacs_client/lib/src/protocol/patient.dart @@ -24,18 +24,22 @@ import 'package:serverpod_client/serverpod_client.dart' as _i1; abstract class Patient implements _i1.SerializableModel { Patient._({ this.id, - required this.emergencyContact, + String? emergencyContactEncrypted, + int? emergencyContactKeyVersion, required this.isChronic, String? chronicConditionsEncrypted, int? chronicConditionsKeyVersion, this.lastLocationHash, this.lastTriageAt, - }) : chronicConditionsEncrypted = chronicConditionsEncrypted ?? '', + }) : emergencyContactEncrypted = emergencyContactEncrypted ?? '', + emergencyContactKeyVersion = emergencyContactKeyVersion ?? 1, + chronicConditionsEncrypted = chronicConditionsEncrypted ?? '', chronicConditionsKeyVersion = chronicConditionsKeyVersion ?? 1; factory Patient({ _i1.UuidValue? id, - required String emergencyContact, + String? emergencyContactEncrypted, + int? emergencyContactKeyVersion, required bool isChronic, String? chronicConditionsEncrypted, int? chronicConditionsKeyVersion, @@ -48,7 +52,10 @@ abstract class Patient implements _i1.SerializableModel { id: jsonSerialization['id'] == null ? null : _i1.UuidValueJsonExtension.fromJson(jsonSerialization['id']), - emergencyContact: jsonSerialization['emergencyContact'] as String, + emergencyContactEncrypted: + jsonSerialization['emergencyContactEncrypted'] as String?, + emergencyContactKeyVersion: + jsonSerialization['emergencyContactKeyVersion'] as int?, isChronic: _i1.BoolJsonExtension.fromJson(jsonSerialization['isChronic']), chronicConditionsEncrypted: jsonSerialization['chronicConditionsEncrypted'] as String?, @@ -68,7 +75,17 @@ abstract class Patient implements _i1.SerializableModel { /// the id will be null. _i1.UuidValue? id; - String emergencyContact; + /// Contato de emergência do paciente (PII de terceiro: nome/telefone), + /// cifrado em AES-256-GCM (RNF03, INV-04) — mesmo padrão de + /// chronicConditions. Antes era `emergencyContact` em texto claro; agora o + /// par `emergencyContactEncrypted`/`emergencyContactKeyVersion` guarda o + /// ciphertext, cifrado e decifrado na borda do repositório ORM + /// (OrmPatientDirectoryStore), nunca em application/. + String emergencyContactEncrypted; + + /// Versão da chave que cifrou o campo acima, para permitir rotação futura + /// sem reescrever todas as linhas de uma vez. + int emergencyContactKeyVersion; bool isChronic; @@ -96,7 +113,8 @@ abstract class Patient implements _i1.SerializableModel { @_i1.useResult Patient copyWith({ _i1.UuidValue? id, - String? emergencyContact, + String? emergencyContactEncrypted, + int? emergencyContactKeyVersion, bool? isChronic, String? chronicConditionsEncrypted, int? chronicConditionsKeyVersion, @@ -108,7 +126,8 @@ abstract class Patient implements _i1.SerializableModel { return { '__className__': 'Patient', if (id != null) 'id': id?.toJson(), - 'emergencyContact': emergencyContact, + 'emergencyContactEncrypted': emergencyContactEncrypted, + 'emergencyContactKeyVersion': emergencyContactKeyVersion, 'isChronic': isChronic, 'chronicConditionsEncrypted': chronicConditionsEncrypted, 'chronicConditionsKeyVersion': chronicConditionsKeyVersion, @@ -128,7 +147,8 @@ class _Undefined {} class _PatientImpl extends Patient { _PatientImpl({ _i1.UuidValue? id, - required String emergencyContact, + String? emergencyContactEncrypted, + int? emergencyContactKeyVersion, required bool isChronic, String? chronicConditionsEncrypted, int? chronicConditionsKeyVersion, @@ -136,7 +156,8 @@ class _PatientImpl extends Patient { DateTime? lastTriageAt, }) : super._( id: id, - emergencyContact: emergencyContact, + emergencyContactEncrypted: emergencyContactEncrypted, + emergencyContactKeyVersion: emergencyContactKeyVersion, isChronic: isChronic, chronicConditionsEncrypted: chronicConditionsEncrypted, chronicConditionsKeyVersion: chronicConditionsKeyVersion, @@ -150,7 +171,8 @@ class _PatientImpl extends Patient { @override Patient copyWith({ Object? id = _Undefined, - String? emergencyContact, + String? emergencyContactEncrypted, + int? emergencyContactKeyVersion, bool? isChronic, String? chronicConditionsEncrypted, int? chronicConditionsKeyVersion, @@ -159,7 +181,10 @@ class _PatientImpl extends Patient { }) { return Patient( id: id is _i1.UuidValue? ? id : this.id, - emergencyContact: emergencyContact ?? this.emergencyContact, + emergencyContactEncrypted: + emergencyContactEncrypted ?? this.emergencyContactEncrypted, + emergencyContactKeyVersion: + emergencyContactKeyVersion ?? this.emergencyContactKeyVersion, isChronic: isChronic ?? this.isChronic, chronicConditionsEncrypted: chronicConditionsEncrypted ?? this.chronicConditionsEncrypted, diff --git a/backend/sinalacs_client/lib/src/protocol/protocol.dart b/backend/sinalacs_client/lib/src/protocol/protocol.dart index 2fed766f..f3511641 100644 --- a/backend/sinalacs_client/lib/src/protocol/protocol.dart +++ b/backend/sinalacs_client/lib/src/protocol/protocol.dart @@ -13,92 +13,198 @@ import 'package:serverpod_client/serverpod_client.dart' as _i1; import 'acs.dart' as _i2; -import 'alert.dart' as _i3; -import 'alert_delivery_record.dart' as _i4; -import 'alert_idempotency_key.dart' as _i5; -import 'alert_outbox_entry.dart' as _i6; -import 'api/alert_ack_result.dart' as _i7; -import 'api/alert_status_result.dart' as _i8; -import 'api/development_login_result.dart' as _i9; -import 'api/enrollment_result.dart' as _i10; -import 'api/enrollment_token_result.dart' as _i11; -import 'api/micro_area_patient.dart' as _i12; -import 'api/patient_consent_record.dart' as _i13; -import 'api/patient_data_overview.dart' as _i14; -import 'api/patient_risk_event.dart' as _i15; -import 'api/red_alert_result.dart' as _i16; -import 'api/service_health.dart' as _i17; -import 'api/triage_result.dart' as _i18; -import 'api/visit_sync_entry.dart' as _i19; -import 'api/visit_sync_result.dart' as _i20; -import 'audit_log.dart' as _i21; -import 'consent_log.dart' as _i22; -import 'enrollment_token.dart' as _i23; -import 'enums/alert_status.dart' as _i24; -import 'enums/arrival_method.dart' as _i25; -import 'enums/consent_purpose.dart' as _i26; -import 'enums/risk_level.dart' as _i27; -import 'enums/sync_status.dart' as _i28; -import 'enums/user_role.dart' as _i29; -import 'exceptions/alert_dispatch_unavailable_exception.dart' as _i30; -import 'exceptions/alert_permission_exception.dart' as _i31; -import 'exceptions/alert_validation_exception.dart' as _i32; -import 'exceptions/authentication_failed_exception.dart' as _i33; -import 'exceptions/endpoint_disabled_exception.dart' as _i34; -import 'exceptions/enrollment_exception.dart' as _i35; -import 'exceptions/otp_request_exception.dart' as _i36; -import 'micro_area.dart' as _i37; -import 'otp_challenge.dart' as _i38; -import 'patient.dart' as _i39; -import 'triage_answer.dart' as _i40; -import 'triage_session.dart' as _i41; -import 'ubs.dart' as _i42; -import 'user.dart' as _i43; -import 'user_credential.dart' as _i44; -import 'visit.dart' as _i45; +import 'acs_refresh_token.dart' as _i3; +import 'acs_upload_token.dart' as _i4; +import 'alert.dart' as _i5; +import 'alert_delivery_record.dart' as _i6; +import 'alert_idempotency_key.dart' as _i7; +import 'alert_outbox_entry.dart' as _i8; +import 'api/admin_acs.dart' as _i9; +import 'api/admin_acs_creation_result.dart' as _i10; +import 'api/admin_alert.dart' as _i11; +import 'api/admin_alert_page.dart' as _i12; +import 'api/admin_alerts_pulse.dart' as _i13; +import 'api/admin_audit_chain_status.dart' as _i14; +import 'api/admin_audit_entry.dart' as _i15; +import 'api/admin_audit_export.dart' as _i16; +import 'api/admin_audit_filter.dart' as _i17; +import 'api/admin_audit_page.dart' as _i18; +import 'api/admin_data_subject_request.dart' as _i19; +import 'api/admin_data_subject_request_detail.dart' as _i20; +import 'api/admin_data_subject_request_page.dart' as _i21; +import 'api/admin_escalation_result.dart' as _i22; +import 'api/admin_indicators.dart' as _i23; +import 'api/admin_micro_area.dart' as _i24; +import 'api/admin_password_reset_result.dart' as _i25; +import 'api/admin_staff.dart' as _i26; +import 'api/admin_staff_mfa_reset_result.dart' as _i27; +import 'api/alert_ack_result.dart' as _i28; +import 'api/alert_status_result.dart' as _i29; +import 'api/development_login_result.dart' as _i30; +import 'api/enrollment_result.dart' as _i31; +import 'api/enrollment_token_result.dart' as _i32; +import 'api/micro_area_patient.dart' as _i33; +import 'api/notice_history_entry.dart' as _i34; +import 'api/notice_history_page.dart' as _i35; +import 'api/notice_send_result.dart' as _i36; +import 'api/patient_consent_record.dart' as _i37; +import 'api/patient_data_overview.dart' as _i38; +import 'api/patient_data_subject_request_record.dart' as _i39; +import 'api/patient_risk_event.dart' as _i40; +import 'api/red_alert_result.dart' as _i41; +import 'api/service_health.dart' as _i42; +import 'api/terms_change_notice.dart' as _i43; +import 'api/terms_version_entry.dart' as _i44; +import 'api/totp_enrollment_start.dart' as _i45; +import 'api/triage_result.dart' as _i46; +import 'api/ubs_contact.dart' as _i47; +import 'api/visit_sync_entry.dart' as _i48; +import 'api/visit_sync_result.dart' as _i49; +import 'audit_chain_checkpoint.dart' as _i50; +import 'audit_export.dart' as _i51; +import 'audit_log.dart' as _i52; +import 'consent_log.dart' as _i53; +import 'data_subject_request.dart' as _i54; +import 'enrollment_token.dart' as _i55; +import 'enums/alert_status.dart' as _i56; +import 'enums/arrival_method.dart' as _i57; +import 'enums/audit_chain_reason.dart' as _i58; +import 'enums/audit_integrity_state.dart' as _i59; +import 'enums/audit_row_seal.dart' as _i60; +import 'enums/consent_purpose.dart' as _i61; +import 'enums/data_subject_request_status.dart' as _i62; +import 'enums/data_subject_request_type.dart' as _i63; +import 'enums/escalation_reason.dart' as _i64; +import 'enums/risk_level.dart' as _i65; +import 'enums/scheduled_notice_status.dart' as _i66; +import 'enums/sync_status.dart' as _i67; +import 'enums/user_role.dart' as _i68; +import 'enums/visit_authorship.dart' as _i69; +import 'exceptions/admin_invalid_request_exception.dart' as _i70; +import 'exceptions/alert_dispatch_unavailable_exception.dart' as _i71; +import 'exceptions/alert_permission_exception.dart' as _i72; +import 'exceptions/alert_validation_exception.dart' as _i73; +import 'exceptions/authentication_failed_exception.dart' as _i74; +import 'exceptions/data_rights_exception.dart' as _i75; +import 'exceptions/endpoint_disabled_exception.dart' as _i76; +import 'exceptions/enrollment_exception.dart' as _i77; +import 'exceptions/mfa_enrollment_required_exception.dart' as _i78; +import 'exceptions/mfa_required_exception.dart' as _i79; +import 'exceptions/notice_delivery_exception.dart' as _i80; +import 'exceptions/otp_request_exception.dart' as _i81; +import 'exceptions/session_expired_exception.dart' as _i82; +import 'micro_area.dart' as _i83; +import 'otp_challenge.dart' as _i84; +import 'patient.dart' as _i85; +import 'push_token.dart' as _i86; +import 'scheduled_notice.dart' as _i87; +import 'staff_account.dart' as _i88; +import 'terms_version.dart' as _i89; +import 'triage_answer.dart' as _i90; +import 'triage_session.dart' as _i91; +import 'ubs.dart' as _i92; +import 'user.dart' as _i93; +import 'user_credential.dart' as _i94; +import 'visit.dart' as _i95; +import 'package:sinalacs_client/src/protocol/api/admin_micro_area.dart' as _i96; +import 'package:sinalacs_client/src/protocol/api/admin_acs.dart' as _i97; +import 'package:sinalacs_client/src/protocol/api/admin_staff.dart' as _i98; +import 'package:sinalacs_client/src/protocol/api/terms_version_entry.dart' + as _i99; import 'package:sinalacs_client/src/protocol/api/micro_area_patient.dart' - as _i46; + as _i100; import 'package:sinalacs_client/src/protocol/api/visit_sync_result.dart' - as _i47; -import 'package:sinalacs_client/src/protocol/api/visit_sync_entry.dart' as _i48; + as _i101; +import 'package:sinalacs_client/src/protocol/api/visit_sync_entry.dart' + as _i102; export 'acs.dart'; +export 'acs_refresh_token.dart'; +export 'acs_upload_token.dart'; export 'alert.dart'; export 'alert_delivery_record.dart'; export 'alert_idempotency_key.dart'; export 'alert_outbox_entry.dart'; +export 'api/admin_acs.dart'; +export 'api/admin_acs_creation_result.dart'; +export 'api/admin_alert.dart'; +export 'api/admin_alert_page.dart'; +export 'api/admin_alerts_pulse.dart'; +export 'api/admin_audit_chain_status.dart'; +export 'api/admin_audit_entry.dart'; +export 'api/admin_audit_export.dart'; +export 'api/admin_audit_filter.dart'; +export 'api/admin_audit_page.dart'; +export 'api/admin_data_subject_request.dart'; +export 'api/admin_data_subject_request_detail.dart'; +export 'api/admin_data_subject_request_page.dart'; +export 'api/admin_escalation_result.dart'; +export 'api/admin_indicators.dart'; +export 'api/admin_micro_area.dart'; +export 'api/admin_password_reset_result.dart'; +export 'api/admin_staff.dart'; +export 'api/admin_staff_mfa_reset_result.dart'; export 'api/alert_ack_result.dart'; export 'api/alert_status_result.dart'; export 'api/development_login_result.dart'; export 'api/enrollment_result.dart'; export 'api/enrollment_token_result.dart'; export 'api/micro_area_patient.dart'; +export 'api/notice_history_entry.dart'; +export 'api/notice_history_page.dart'; +export 'api/notice_send_result.dart'; export 'api/patient_consent_record.dart'; export 'api/patient_data_overview.dart'; +export 'api/patient_data_subject_request_record.dart'; export 'api/patient_risk_event.dart'; export 'api/red_alert_result.dart'; export 'api/service_health.dart'; +export 'api/terms_change_notice.dart'; +export 'api/terms_version_entry.dart'; +export 'api/totp_enrollment_start.dart'; export 'api/triage_result.dart'; +export 'api/ubs_contact.dart'; export 'api/visit_sync_entry.dart'; export 'api/visit_sync_result.dart'; +export 'audit_chain_checkpoint.dart'; +export 'audit_export.dart'; export 'audit_log.dart'; export 'consent_log.dart'; +export 'data_subject_request.dart'; export 'enrollment_token.dart'; export 'enums/alert_status.dart'; export 'enums/arrival_method.dart'; +export 'enums/audit_chain_reason.dart'; +export 'enums/audit_integrity_state.dart'; +export 'enums/audit_row_seal.dart'; export 'enums/consent_purpose.dart'; +export 'enums/data_subject_request_status.dart'; +export 'enums/data_subject_request_type.dart'; +export 'enums/escalation_reason.dart'; export 'enums/risk_level.dart'; +export 'enums/scheduled_notice_status.dart'; export 'enums/sync_status.dart'; export 'enums/user_role.dart'; +export 'enums/visit_authorship.dart'; +export 'exceptions/admin_invalid_request_exception.dart'; export 'exceptions/alert_dispatch_unavailable_exception.dart'; export 'exceptions/alert_permission_exception.dart'; export 'exceptions/alert_validation_exception.dart'; export 'exceptions/authentication_failed_exception.dart'; +export 'exceptions/data_rights_exception.dart'; export 'exceptions/endpoint_disabled_exception.dart'; export 'exceptions/enrollment_exception.dart'; +export 'exceptions/mfa_enrollment_required_exception.dart'; +export 'exceptions/mfa_required_exception.dart'; +export 'exceptions/notice_delivery_exception.dart'; export 'exceptions/otp_request_exception.dart'; +export 'exceptions/session_expired_exception.dart'; export 'micro_area.dart'; export 'otp_challenge.dart'; export 'patient.dart'; +export 'push_token.dart'; +export 'scheduled_notice.dart'; +export 'staff_account.dart'; +export 'terms_version.dart'; export 'triage_answer.dart'; export 'triage_session.dart'; export 'ubs.dart'; @@ -144,302 +250,669 @@ class Protocol extends _i1.SerializationManager { if (t == _i2.Acs) { return _i2.Acs.fromJson(data) as T; } - if (t == _i3.Alert) { - return _i3.Alert.fromJson(data) as T; + if (t == _i3.AcsRefreshToken) { + return _i3.AcsRefreshToken.fromJson(data) as T; } - if (t == _i4.AlertDeliveryRecord) { - return _i4.AlertDeliveryRecord.fromJson(data) as T; + if (t == _i4.AcsUploadToken) { + return _i4.AcsUploadToken.fromJson(data) as T; } - if (t == _i5.AlertIdempotencyKey) { - return _i5.AlertIdempotencyKey.fromJson(data) as T; + if (t == _i5.Alert) { + return _i5.Alert.fromJson(data) as T; } - if (t == _i6.AlertOutboxEntry) { - return _i6.AlertOutboxEntry.fromJson(data) as T; + if (t == _i6.AlertDeliveryRecord) { + return _i6.AlertDeliveryRecord.fromJson(data) as T; } - if (t == _i7.AlertAckResult) { - return _i7.AlertAckResult.fromJson(data) as T; + if (t == _i7.AlertIdempotencyKey) { + return _i7.AlertIdempotencyKey.fromJson(data) as T; } - if (t == _i8.AlertStatusResult) { - return _i8.AlertStatusResult.fromJson(data) as T; + if (t == _i8.AlertOutboxEntry) { + return _i8.AlertOutboxEntry.fromJson(data) as T; } - if (t == _i9.DevelopmentLoginResult) { - return _i9.DevelopmentLoginResult.fromJson(data) as T; + if (t == _i9.AdminAcs) { + return _i9.AdminAcs.fromJson(data) as T; } - if (t == _i10.EnrollmentResult) { - return _i10.EnrollmentResult.fromJson(data) as T; + if (t == _i10.AdminAcsCreationResult) { + return _i10.AdminAcsCreationResult.fromJson(data) as T; } - if (t == _i11.EnrollmentTokenResult) { - return _i11.EnrollmentTokenResult.fromJson(data) as T; + if (t == _i11.AdminAlert) { + return _i11.AdminAlert.fromJson(data) as T; } - if (t == _i12.MicroAreaPatient) { - return _i12.MicroAreaPatient.fromJson(data) as T; + if (t == _i12.AdminAlertPage) { + return _i12.AdminAlertPage.fromJson(data) as T; } - if (t == _i13.PatientConsentRecord) { - return _i13.PatientConsentRecord.fromJson(data) as T; + if (t == _i13.AdminAlertsPulse) { + return _i13.AdminAlertsPulse.fromJson(data) as T; } - if (t == _i14.PatientDataOverview) { - return _i14.PatientDataOverview.fromJson(data) as T; + if (t == _i14.AdminAuditChainStatus) { + return _i14.AdminAuditChainStatus.fromJson(data) as T; } - if (t == _i15.PatientRiskEvent) { - return _i15.PatientRiskEvent.fromJson(data) as T; + if (t == _i15.AdminAuditEntry) { + return _i15.AdminAuditEntry.fromJson(data) as T; } - if (t == _i16.RedAlertResult) { - return _i16.RedAlertResult.fromJson(data) as T; + if (t == _i16.AdminAuditExport) { + return _i16.AdminAuditExport.fromJson(data) as T; } - if (t == _i17.ServiceHealth) { - return _i17.ServiceHealth.fromJson(data) as T; + if (t == _i17.AdminAuditFilter) { + return _i17.AdminAuditFilter.fromJson(data) as T; } - if (t == _i18.TriageResult) { - return _i18.TriageResult.fromJson(data) as T; + if (t == _i18.AdminAuditPage) { + return _i18.AdminAuditPage.fromJson(data) as T; } - if (t == _i19.VisitSyncEntry) { - return _i19.VisitSyncEntry.fromJson(data) as T; + if (t == _i19.AdminDataSubjectRequest) { + return _i19.AdminDataSubjectRequest.fromJson(data) as T; } - if (t == _i20.VisitSyncResult) { - return _i20.VisitSyncResult.fromJson(data) as T; + if (t == _i20.AdminDataSubjectRequestDetail) { + return _i20.AdminDataSubjectRequestDetail.fromJson(data) as T; } - if (t == _i21.AuditLog) { - return _i21.AuditLog.fromJson(data) as T; + if (t == _i21.AdminDataSubjectRequestPage) { + return _i21.AdminDataSubjectRequestPage.fromJson(data) as T; } - if (t == _i22.ConsentLog) { - return _i22.ConsentLog.fromJson(data) as T; + if (t == _i22.AdminEscalationResult) { + return _i22.AdminEscalationResult.fromJson(data) as T; } - if (t == _i23.EnrollmentToken) { - return _i23.EnrollmentToken.fromJson(data) as T; + if (t == _i23.AdminIndicators) { + return _i23.AdminIndicators.fromJson(data) as T; } - if (t == _i24.AlertStatus) { - return _i24.AlertStatus.fromJson(data) as T; + if (t == _i24.AdminMicroArea) { + return _i24.AdminMicroArea.fromJson(data) as T; } - if (t == _i25.ArrivalMethod) { - return _i25.ArrivalMethod.fromJson(data) as T; + if (t == _i25.AdminPasswordResetResult) { + return _i25.AdminPasswordResetResult.fromJson(data) as T; } - if (t == _i26.ConsentPurpose) { - return _i26.ConsentPurpose.fromJson(data) as T; + if (t == _i26.AdminStaff) { + return _i26.AdminStaff.fromJson(data) as T; } - if (t == _i27.RiskLevel) { - return _i27.RiskLevel.fromJson(data) as T; + if (t == _i27.AdminStaffMfaResetResult) { + return _i27.AdminStaffMfaResetResult.fromJson(data) as T; } - if (t == _i28.SyncStatus) { - return _i28.SyncStatus.fromJson(data) as T; + if (t == _i28.AlertAckResult) { + return _i28.AlertAckResult.fromJson(data) as T; } - if (t == _i29.UserRole) { - return _i29.UserRole.fromJson(data) as T; + if (t == _i29.AlertStatusResult) { + return _i29.AlertStatusResult.fromJson(data) as T; } - if (t == _i30.AlertDispatchUnavailableException) { - return _i30.AlertDispatchUnavailableException.fromJson(data) as T; + if (t == _i30.DevelopmentLoginResult) { + return _i30.DevelopmentLoginResult.fromJson(data) as T; } - if (t == _i31.AlertPermissionException) { - return _i31.AlertPermissionException.fromJson(data) as T; + if (t == _i31.EnrollmentResult) { + return _i31.EnrollmentResult.fromJson(data) as T; } - if (t == _i32.AlertValidationException) { - return _i32.AlertValidationException.fromJson(data) as T; + if (t == _i32.EnrollmentTokenResult) { + return _i32.EnrollmentTokenResult.fromJson(data) as T; } - if (t == _i33.AuthenticationFailedException) { - return _i33.AuthenticationFailedException.fromJson(data) as T; + if (t == _i33.MicroAreaPatient) { + return _i33.MicroAreaPatient.fromJson(data) as T; } - if (t == _i34.EndpointDisabledException) { - return _i34.EndpointDisabledException.fromJson(data) as T; + if (t == _i34.NoticeHistoryEntry) { + return _i34.NoticeHistoryEntry.fromJson(data) as T; } - if (t == _i35.EnrollmentException) { - return _i35.EnrollmentException.fromJson(data) as T; + if (t == _i35.NoticeHistoryPage) { + return _i35.NoticeHistoryPage.fromJson(data) as T; } - if (t == _i36.OtpRequestException) { - return _i36.OtpRequestException.fromJson(data) as T; + if (t == _i36.NoticeSendResult) { + return _i36.NoticeSendResult.fromJson(data) as T; } - if (t == _i37.MicroArea) { - return _i37.MicroArea.fromJson(data) as T; + if (t == _i37.PatientConsentRecord) { + return _i37.PatientConsentRecord.fromJson(data) as T; } - if (t == _i38.OtpChallenge) { - return _i38.OtpChallenge.fromJson(data) as T; + if (t == _i38.PatientDataOverview) { + return _i38.PatientDataOverview.fromJson(data) as T; } - if (t == _i39.Patient) { - return _i39.Patient.fromJson(data) as T; + if (t == _i39.PatientDataSubjectRequestRecord) { + return _i39.PatientDataSubjectRequestRecord.fromJson(data) as T; } - if (t == _i40.TriageAnswer) { - return _i40.TriageAnswer.fromJson(data) as T; + if (t == _i40.PatientRiskEvent) { + return _i40.PatientRiskEvent.fromJson(data) as T; } - if (t == _i41.TriageSession) { - return _i41.TriageSession.fromJson(data) as T; + if (t == _i41.RedAlertResult) { + return _i41.RedAlertResult.fromJson(data) as T; } - if (t == _i42.Ubs) { - return _i42.Ubs.fromJson(data) as T; + if (t == _i42.ServiceHealth) { + return _i42.ServiceHealth.fromJson(data) as T; } - if (t == _i43.User) { - return _i43.User.fromJson(data) as T; + if (t == _i43.TermsChangeNotice) { + return _i43.TermsChangeNotice.fromJson(data) as T; } - if (t == _i44.UserCredential) { - return _i44.UserCredential.fromJson(data) as T; + if (t == _i44.TermsVersionEntry) { + return _i44.TermsVersionEntry.fromJson(data) as T; } - if (t == _i45.Visit) { - return _i45.Visit.fromJson(data) as T; + if (t == _i45.TotpEnrollmentStart) { + return _i45.TotpEnrollmentStart.fromJson(data) as T; + } + if (t == _i46.TriageResult) { + return _i46.TriageResult.fromJson(data) as T; + } + if (t == _i47.UbsContact) { + return _i47.UbsContact.fromJson(data) as T; + } + if (t == _i48.VisitSyncEntry) { + return _i48.VisitSyncEntry.fromJson(data) as T; + } + if (t == _i49.VisitSyncResult) { + return _i49.VisitSyncResult.fromJson(data) as T; + } + if (t == _i50.AuditChainCheckpoint) { + return _i50.AuditChainCheckpoint.fromJson(data) as T; + } + if (t == _i51.AuditExport) { + return _i51.AuditExport.fromJson(data) as T; + } + if (t == _i52.AuditLog) { + return _i52.AuditLog.fromJson(data) as T; + } + if (t == _i53.ConsentLog) { + return _i53.ConsentLog.fromJson(data) as T; + } + if (t == _i54.DataSubjectRequest) { + return _i54.DataSubjectRequest.fromJson(data) as T; + } + if (t == _i55.EnrollmentToken) { + return _i55.EnrollmentToken.fromJson(data) as T; + } + if (t == _i56.AlertStatus) { + return _i56.AlertStatus.fromJson(data) as T; + } + if (t == _i57.ArrivalMethod) { + return _i57.ArrivalMethod.fromJson(data) as T; + } + if (t == _i58.AuditChainReason) { + return _i58.AuditChainReason.fromJson(data) as T; + } + if (t == _i59.AuditIntegrityState) { + return _i59.AuditIntegrityState.fromJson(data) as T; + } + if (t == _i60.AuditRowSeal) { + return _i60.AuditRowSeal.fromJson(data) as T; + } + if (t == _i61.ConsentPurpose) { + return _i61.ConsentPurpose.fromJson(data) as T; + } + if (t == _i62.DataSubjectRequestStatus) { + return _i62.DataSubjectRequestStatus.fromJson(data) as T; + } + if (t == _i63.DataSubjectRequestType) { + return _i63.DataSubjectRequestType.fromJson(data) as T; + } + if (t == _i64.EscalationReason) { + return _i64.EscalationReason.fromJson(data) as T; + } + if (t == _i65.RiskLevel) { + return _i65.RiskLevel.fromJson(data) as T; + } + if (t == _i66.ScheduledNoticeStatus) { + return _i66.ScheduledNoticeStatus.fromJson(data) as T; + } + if (t == _i67.SyncStatus) { + return _i67.SyncStatus.fromJson(data) as T; + } + if (t == _i68.UserRole) { + return _i68.UserRole.fromJson(data) as T; + } + if (t == _i69.VisitAuthorship) { + return _i69.VisitAuthorship.fromJson(data) as T; + } + if (t == _i70.AdminInvalidRequestException) { + return _i70.AdminInvalidRequestException.fromJson(data) as T; + } + if (t == _i71.AlertDispatchUnavailableException) { + return _i71.AlertDispatchUnavailableException.fromJson(data) as T; + } + if (t == _i72.AlertPermissionException) { + return _i72.AlertPermissionException.fromJson(data) as T; + } + if (t == _i73.AlertValidationException) { + return _i73.AlertValidationException.fromJson(data) as T; + } + if (t == _i74.AuthenticationFailedException) { + return _i74.AuthenticationFailedException.fromJson(data) as T; + } + if (t == _i75.DataRightsException) { + return _i75.DataRightsException.fromJson(data) as T; + } + if (t == _i76.EndpointDisabledException) { + return _i76.EndpointDisabledException.fromJson(data) as T; + } + if (t == _i77.EnrollmentException) { + return _i77.EnrollmentException.fromJson(data) as T; + } + if (t == _i78.MfaEnrollmentRequiredException) { + return _i78.MfaEnrollmentRequiredException.fromJson(data) as T; + } + if (t == _i79.MfaRequiredException) { + return _i79.MfaRequiredException.fromJson(data) as T; + } + if (t == _i80.NoticeDeliveryException) { + return _i80.NoticeDeliveryException.fromJson(data) as T; + } + if (t == _i81.OtpRequestException) { + return _i81.OtpRequestException.fromJson(data) as T; + } + if (t == _i82.SessionExpiredException) { + return _i82.SessionExpiredException.fromJson(data) as T; + } + if (t == _i83.MicroArea) { + return _i83.MicroArea.fromJson(data) as T; + } + if (t == _i84.OtpChallenge) { + return _i84.OtpChallenge.fromJson(data) as T; + } + if (t == _i85.Patient) { + return _i85.Patient.fromJson(data) as T; + } + if (t == _i86.PushToken) { + return _i86.PushToken.fromJson(data) as T; + } + if (t == _i87.ScheduledNotice) { + return _i87.ScheduledNotice.fromJson(data) as T; + } + if (t == _i88.StaffAccount) { + return _i88.StaffAccount.fromJson(data) as T; + } + if (t == _i89.TermsVersion) { + return _i89.TermsVersion.fromJson(data) as T; + } + if (t == _i90.TriageAnswer) { + return _i90.TriageAnswer.fromJson(data) as T; + } + if (t == _i91.TriageSession) { + return _i91.TriageSession.fromJson(data) as T; + } + if (t == _i92.Ubs) { + return _i92.Ubs.fromJson(data) as T; + } + if (t == _i93.User) { + return _i93.User.fromJson(data) as T; + } + if (t == _i94.UserCredential) { + return _i94.UserCredential.fromJson(data) as T; + } + if (t == _i95.Visit) { + return _i95.Visit.fromJson(data) as T; } if (t == _i1.getType<_i2.Acs?>()) { return (data != null ? _i2.Acs.fromJson(data) : null) as T; } - if (t == _i1.getType<_i3.Alert?>()) { - return (data != null ? _i3.Alert.fromJson(data) : null) as T; + if (t == _i1.getType<_i3.AcsRefreshToken?>()) { + return (data != null ? _i3.AcsRefreshToken.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i4.AcsUploadToken?>()) { + return (data != null ? _i4.AcsUploadToken.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i5.Alert?>()) { + return (data != null ? _i5.Alert.fromJson(data) : null) as T; } - if (t == _i1.getType<_i4.AlertDeliveryRecord?>()) { - return (data != null ? _i4.AlertDeliveryRecord.fromJson(data) : null) + if (t == _i1.getType<_i6.AlertDeliveryRecord?>()) { + return (data != null ? _i6.AlertDeliveryRecord.fromJson(data) : null) as T; } - if (t == _i1.getType<_i5.AlertIdempotencyKey?>()) { - return (data != null ? _i5.AlertIdempotencyKey.fromJson(data) : null) + if (t == _i1.getType<_i7.AlertIdempotencyKey?>()) { + return (data != null ? _i7.AlertIdempotencyKey.fromJson(data) : null) + as T; + } + if (t == _i1.getType<_i8.AlertOutboxEntry?>()) { + return (data != null ? _i8.AlertOutboxEntry.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i9.AdminAcs?>()) { + return (data != null ? _i9.AdminAcs.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i10.AdminAcsCreationResult?>()) { + return (data != null ? _i10.AdminAcsCreationResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i6.AlertOutboxEntry?>()) { - return (data != null ? _i6.AlertOutboxEntry.fromJson(data) : null) as T; + if (t == _i1.getType<_i11.AdminAlert?>()) { + return (data != null ? _i11.AdminAlert.fromJson(data) : null) as T; } - if (t == _i1.getType<_i7.AlertAckResult?>()) { - return (data != null ? _i7.AlertAckResult.fromJson(data) : null) as T; + if (t == _i1.getType<_i12.AdminAlertPage?>()) { + return (data != null ? _i12.AdminAlertPage.fromJson(data) : null) as T; } - if (t == _i1.getType<_i8.AlertStatusResult?>()) { - return (data != null ? _i8.AlertStatusResult.fromJson(data) : null) as T; + if (t == _i1.getType<_i13.AdminAlertsPulse?>()) { + return (data != null ? _i13.AdminAlertsPulse.fromJson(data) : null) as T; } - if (t == _i1.getType<_i9.DevelopmentLoginResult?>()) { - return (data != null ? _i9.DevelopmentLoginResult.fromJson(data) : null) + if (t == _i1.getType<_i14.AdminAuditChainStatus?>()) { + return (data != null ? _i14.AdminAuditChainStatus.fromJson(data) : null) as T; } - if (t == _i1.getType<_i10.EnrollmentResult?>()) { - return (data != null ? _i10.EnrollmentResult.fromJson(data) : null) as T; + if (t == _i1.getType<_i15.AdminAuditEntry?>()) { + return (data != null ? _i15.AdminAuditEntry.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i16.AdminAuditExport?>()) { + return (data != null ? _i16.AdminAuditExport.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i17.AdminAuditFilter?>()) { + return (data != null ? _i17.AdminAuditFilter.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i18.AdminAuditPage?>()) { + return (data != null ? _i18.AdminAuditPage.fromJson(data) : null) as T; } - if (t == _i1.getType<_i11.EnrollmentTokenResult?>()) { - return (data != null ? _i11.EnrollmentTokenResult.fromJson(data) : null) + if (t == _i1.getType<_i19.AdminDataSubjectRequest?>()) { + return (data != null ? _i19.AdminDataSubjectRequest.fromJson(data) : null) as T; } - if (t == _i1.getType<_i12.MicroAreaPatient?>()) { - return (data != null ? _i12.MicroAreaPatient.fromJson(data) : null) as T; + if (t == _i1.getType<_i20.AdminDataSubjectRequestDetail?>()) { + return (data != null + ? _i20.AdminDataSubjectRequestDetail.fromJson(data) + : null) + as T; } - if (t == _i1.getType<_i13.PatientConsentRecord?>()) { - return (data != null ? _i13.PatientConsentRecord.fromJson(data) : null) + if (t == _i1.getType<_i21.AdminDataSubjectRequestPage?>()) { + return (data != null + ? _i21.AdminDataSubjectRequestPage.fromJson(data) + : null) as T; } - if (t == _i1.getType<_i14.PatientDataOverview?>()) { - return (data != null ? _i14.PatientDataOverview.fromJson(data) : null) + if (t == _i1.getType<_i22.AdminEscalationResult?>()) { + return (data != null ? _i22.AdminEscalationResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i15.PatientRiskEvent?>()) { - return (data != null ? _i15.PatientRiskEvent.fromJson(data) : null) as T; + if (t == _i1.getType<_i23.AdminIndicators?>()) { + return (data != null ? _i23.AdminIndicators.fromJson(data) : null) as T; } - if (t == _i1.getType<_i16.RedAlertResult?>()) { - return (data != null ? _i16.RedAlertResult.fromJson(data) : null) as T; + if (t == _i1.getType<_i24.AdminMicroArea?>()) { + return (data != null ? _i24.AdminMicroArea.fromJson(data) : null) as T; } - if (t == _i1.getType<_i17.ServiceHealth?>()) { - return (data != null ? _i17.ServiceHealth.fromJson(data) : null) as T; + if (t == _i1.getType<_i25.AdminPasswordResetResult?>()) { + return (data != null + ? _i25.AdminPasswordResetResult.fromJson(data) + : null) + as T; + } + if (t == _i1.getType<_i26.AdminStaff?>()) { + return (data != null ? _i26.AdminStaff.fromJson(data) : null) as T; } - if (t == _i1.getType<_i18.TriageResult?>()) { - return (data != null ? _i18.TriageResult.fromJson(data) : null) as T; + if (t == _i1.getType<_i27.AdminStaffMfaResetResult?>()) { + return (data != null + ? _i27.AdminStaffMfaResetResult.fromJson(data) + : null) + as T; } - if (t == _i1.getType<_i19.VisitSyncEntry?>()) { - return (data != null ? _i19.VisitSyncEntry.fromJson(data) : null) as T; + if (t == _i1.getType<_i28.AlertAckResult?>()) { + return (data != null ? _i28.AlertAckResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i20.VisitSyncResult?>()) { - return (data != null ? _i20.VisitSyncResult.fromJson(data) : null) as T; + if (t == _i1.getType<_i29.AlertStatusResult?>()) { + return (data != null ? _i29.AlertStatusResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i21.AuditLog?>()) { - return (data != null ? _i21.AuditLog.fromJson(data) : null) as T; + if (t == _i1.getType<_i30.DevelopmentLoginResult?>()) { + return (data != null ? _i30.DevelopmentLoginResult.fromJson(data) : null) + as T; } - if (t == _i1.getType<_i22.ConsentLog?>()) { - return (data != null ? _i22.ConsentLog.fromJson(data) : null) as T; + if (t == _i1.getType<_i31.EnrollmentResult?>()) { + return (data != null ? _i31.EnrollmentResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i23.EnrollmentToken?>()) { - return (data != null ? _i23.EnrollmentToken.fromJson(data) : null) as T; + if (t == _i1.getType<_i32.EnrollmentTokenResult?>()) { + return (data != null ? _i32.EnrollmentTokenResult.fromJson(data) : null) + as T; } - if (t == _i1.getType<_i24.AlertStatus?>()) { - return (data != null ? _i24.AlertStatus.fromJson(data) : null) as T; + if (t == _i1.getType<_i33.MicroAreaPatient?>()) { + return (data != null ? _i33.MicroAreaPatient.fromJson(data) : null) as T; } - if (t == _i1.getType<_i25.ArrivalMethod?>()) { - return (data != null ? _i25.ArrivalMethod.fromJson(data) : null) as T; + if (t == _i1.getType<_i34.NoticeHistoryEntry?>()) { + return (data != null ? _i34.NoticeHistoryEntry.fromJson(data) : null) + as T; } - if (t == _i1.getType<_i26.ConsentPurpose?>()) { - return (data != null ? _i26.ConsentPurpose.fromJson(data) : null) as T; + if (t == _i1.getType<_i35.NoticeHistoryPage?>()) { + return (data != null ? _i35.NoticeHistoryPage.fromJson(data) : null) as T; } - if (t == _i1.getType<_i27.RiskLevel?>()) { - return (data != null ? _i27.RiskLevel.fromJson(data) : null) as T; + if (t == _i1.getType<_i36.NoticeSendResult?>()) { + return (data != null ? _i36.NoticeSendResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i28.SyncStatus?>()) { - return (data != null ? _i28.SyncStatus.fromJson(data) : null) as T; + if (t == _i1.getType<_i37.PatientConsentRecord?>()) { + return (data != null ? _i37.PatientConsentRecord.fromJson(data) : null) + as T; } - if (t == _i1.getType<_i29.UserRole?>()) { - return (data != null ? _i29.UserRole.fromJson(data) : null) as T; + if (t == _i1.getType<_i38.PatientDataOverview?>()) { + return (data != null ? _i38.PatientDataOverview.fromJson(data) : null) + as T; } - if (t == _i1.getType<_i30.AlertDispatchUnavailableException?>()) { + if (t == _i1.getType<_i39.PatientDataSubjectRequestRecord?>()) { return (data != null - ? _i30.AlertDispatchUnavailableException.fromJson(data) + ? _i39.PatientDataSubjectRequestRecord.fromJson(data) : null) as T; } - if (t == _i1.getType<_i31.AlertPermissionException?>()) { + if (t == _i1.getType<_i40.PatientRiskEvent?>()) { + return (data != null ? _i40.PatientRiskEvent.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i41.RedAlertResult?>()) { + return (data != null ? _i41.RedAlertResult.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i42.ServiceHealth?>()) { + return (data != null ? _i42.ServiceHealth.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i43.TermsChangeNotice?>()) { + return (data != null ? _i43.TermsChangeNotice.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i44.TermsVersionEntry?>()) { + return (data != null ? _i44.TermsVersionEntry.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i45.TotpEnrollmentStart?>()) { + return (data != null ? _i45.TotpEnrollmentStart.fromJson(data) : null) + as T; + } + if (t == _i1.getType<_i46.TriageResult?>()) { + return (data != null ? _i46.TriageResult.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i47.UbsContact?>()) { + return (data != null ? _i47.UbsContact.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i48.VisitSyncEntry?>()) { + return (data != null ? _i48.VisitSyncEntry.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i49.VisitSyncResult?>()) { + return (data != null ? _i49.VisitSyncResult.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i50.AuditChainCheckpoint?>()) { + return (data != null ? _i50.AuditChainCheckpoint.fromJson(data) : null) + as T; + } + if (t == _i1.getType<_i51.AuditExport?>()) { + return (data != null ? _i51.AuditExport.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i52.AuditLog?>()) { + return (data != null ? _i52.AuditLog.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i53.ConsentLog?>()) { + return (data != null ? _i53.ConsentLog.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i54.DataSubjectRequest?>()) { + return (data != null ? _i54.DataSubjectRequest.fromJson(data) : null) + as T; + } + if (t == _i1.getType<_i55.EnrollmentToken?>()) { + return (data != null ? _i55.EnrollmentToken.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i56.AlertStatus?>()) { + return (data != null ? _i56.AlertStatus.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i57.ArrivalMethod?>()) { + return (data != null ? _i57.ArrivalMethod.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i58.AuditChainReason?>()) { + return (data != null ? _i58.AuditChainReason.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i59.AuditIntegrityState?>()) { + return (data != null ? _i59.AuditIntegrityState.fromJson(data) : null) + as T; + } + if (t == _i1.getType<_i60.AuditRowSeal?>()) { + return (data != null ? _i60.AuditRowSeal.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i61.ConsentPurpose?>()) { + return (data != null ? _i61.ConsentPurpose.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i62.DataSubjectRequestStatus?>()) { return (data != null - ? _i31.AlertPermissionException.fromJson(data) + ? _i62.DataSubjectRequestStatus.fromJson(data) : null) as T; } - if (t == _i1.getType<_i32.AlertValidationException?>()) { + if (t == _i1.getType<_i63.DataSubjectRequestType?>()) { + return (data != null ? _i63.DataSubjectRequestType.fromJson(data) : null) + as T; + } + if (t == _i1.getType<_i64.EscalationReason?>()) { + return (data != null ? _i64.EscalationReason.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i65.RiskLevel?>()) { + return (data != null ? _i65.RiskLevel.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i66.ScheduledNoticeStatus?>()) { + return (data != null ? _i66.ScheduledNoticeStatus.fromJson(data) : null) + as T; + } + if (t == _i1.getType<_i67.SyncStatus?>()) { + return (data != null ? _i67.SyncStatus.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i68.UserRole?>()) { + return (data != null ? _i68.UserRole.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i69.VisitAuthorship?>()) { + return (data != null ? _i69.VisitAuthorship.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i70.AdminInvalidRequestException?>()) { + return (data != null + ? _i70.AdminInvalidRequestException.fromJson(data) + : null) + as T; + } + if (t == _i1.getType<_i71.AlertDispatchUnavailableException?>()) { + return (data != null + ? _i71.AlertDispatchUnavailableException.fromJson(data) + : null) + as T; + } + if (t == _i1.getType<_i72.AlertPermissionException?>()) { + return (data != null + ? _i72.AlertPermissionException.fromJson(data) + : null) + as T; + } + if (t == _i1.getType<_i73.AlertValidationException?>()) { + return (data != null + ? _i73.AlertValidationException.fromJson(data) + : null) + as T; + } + if (t == _i1.getType<_i74.AuthenticationFailedException?>()) { return (data != null - ? _i32.AlertValidationException.fromJson(data) + ? _i74.AuthenticationFailedException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i33.AuthenticationFailedException?>()) { + if (t == _i1.getType<_i75.DataRightsException?>()) { + return (data != null ? _i75.DataRightsException.fromJson(data) : null) + as T; + } + if (t == _i1.getType<_i76.EndpointDisabledException?>()) { return (data != null - ? _i33.AuthenticationFailedException.fromJson(data) + ? _i76.EndpointDisabledException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i34.EndpointDisabledException?>()) { + if (t == _i1.getType<_i77.EnrollmentException?>()) { + return (data != null ? _i77.EnrollmentException.fromJson(data) : null) + as T; + } + if (t == _i1.getType<_i78.MfaEnrollmentRequiredException?>()) { return (data != null - ? _i34.EndpointDisabledException.fromJson(data) + ? _i78.MfaEnrollmentRequiredException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i35.EnrollmentException?>()) { - return (data != null ? _i35.EnrollmentException.fromJson(data) : null) + if (t == _i1.getType<_i79.MfaRequiredException?>()) { + return (data != null ? _i79.MfaRequiredException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i36.OtpRequestException?>()) { - return (data != null ? _i36.OtpRequestException.fromJson(data) : null) + if (t == _i1.getType<_i80.NoticeDeliveryException?>()) { + return (data != null ? _i80.NoticeDeliveryException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i37.MicroArea?>()) { - return (data != null ? _i37.MicroArea.fromJson(data) : null) as T; + if (t == _i1.getType<_i81.OtpRequestException?>()) { + return (data != null ? _i81.OtpRequestException.fromJson(data) : null) + as T; + } + if (t == _i1.getType<_i82.SessionExpiredException?>()) { + return (data != null ? _i82.SessionExpiredException.fromJson(data) : null) + as T; + } + if (t == _i1.getType<_i83.MicroArea?>()) { + return (data != null ? _i83.MicroArea.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i84.OtpChallenge?>()) { + return (data != null ? _i84.OtpChallenge.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i85.Patient?>()) { + return (data != null ? _i85.Patient.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i86.PushToken?>()) { + return (data != null ? _i86.PushToken.fromJson(data) : null) as T; } - if (t == _i1.getType<_i38.OtpChallenge?>()) { - return (data != null ? _i38.OtpChallenge.fromJson(data) : null) as T; + if (t == _i1.getType<_i87.ScheduledNotice?>()) { + return (data != null ? _i87.ScheduledNotice.fromJson(data) : null) as T; } - if (t == _i1.getType<_i39.Patient?>()) { - return (data != null ? _i39.Patient.fromJson(data) : null) as T; + if (t == _i1.getType<_i88.StaffAccount?>()) { + return (data != null ? _i88.StaffAccount.fromJson(data) : null) as T; } - if (t == _i1.getType<_i40.TriageAnswer?>()) { - return (data != null ? _i40.TriageAnswer.fromJson(data) : null) as T; + if (t == _i1.getType<_i89.TermsVersion?>()) { + return (data != null ? _i89.TermsVersion.fromJson(data) : null) as T; } - if (t == _i1.getType<_i41.TriageSession?>()) { - return (data != null ? _i41.TriageSession.fromJson(data) : null) as T; + if (t == _i1.getType<_i90.TriageAnswer?>()) { + return (data != null ? _i90.TriageAnswer.fromJson(data) : null) as T; } - if (t == _i1.getType<_i42.Ubs?>()) { - return (data != null ? _i42.Ubs.fromJson(data) : null) as T; + if (t == _i1.getType<_i91.TriageSession?>()) { + return (data != null ? _i91.TriageSession.fromJson(data) : null) as T; } - if (t == _i1.getType<_i43.User?>()) { - return (data != null ? _i43.User.fromJson(data) : null) as T; + if (t == _i1.getType<_i92.Ubs?>()) { + return (data != null ? _i92.Ubs.fromJson(data) : null) as T; } - if (t == _i1.getType<_i44.UserCredential?>()) { - return (data != null ? _i44.UserCredential.fromJson(data) : null) as T; + if (t == _i1.getType<_i93.User?>()) { + return (data != null ? _i93.User.fromJson(data) : null) as T; } - if (t == _i1.getType<_i45.Visit?>()) { - return (data != null ? _i45.Visit.fromJson(data) : null) as T; + if (t == _i1.getType<_i94.UserCredential?>()) { + return (data != null ? _i94.UserCredential.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i95.Visit?>()) { + return (data != null ? _i95.Visit.fromJson(data) : null) as T; + } + if (t == List<_i11.AdminAlert>) { + return (data as List).map((e) => deserialize<_i11.AdminAlert>(e)).toList() + as T; + } + if (t == List<_i15.AdminAuditEntry>) { + return (data as List) + .map((e) => deserialize<_i15.AdminAuditEntry>(e)) + .toList() + as T; + } + if (t == List<_i19.AdminDataSubjectRequest>) { + return (data as List) + .map((e) => deserialize<_i19.AdminDataSubjectRequest>(e)) + .toList() + as T; } if (t == List) { return (data as List).map((e) => deserialize(e)).toList() as T; } - if (t == List<_i13.PatientConsentRecord>) { + if (t == List<_i34.NoticeHistoryEntry>) { return (data as List) - .map((e) => deserialize<_i13.PatientConsentRecord>(e)) + .map((e) => deserialize<_i34.NoticeHistoryEntry>(e)) .toList() as T; } - if (t == List<_i15.PatientRiskEvent>) { + if (t == List<_i37.PatientConsentRecord>) { return (data as List) - .map((e) => deserialize<_i15.PatientRiskEvent>(e)) + .map((e) => deserialize<_i37.PatientConsentRecord>(e)) + .toList() + as T; + } + if (t == List<_i40.PatientRiskEvent>) { + return (data as List) + .map((e) => deserialize<_i40.PatientRiskEvent>(e)) + .toList() + as T; + } + if (t == List<_i39.PatientDataSubjectRequestRecord>) { + return (data as List) + .map((e) => deserialize<_i39.PatientDataSubjectRequestRecord>(e)) .toList() as T; } @@ -449,24 +922,44 @@ class Protocol extends _i1.SerializationManager { ) as T; } - if (t == List<_i46.MicroAreaPatient>) { + if (t == List<_i96.AdminMicroArea>) { + return (data as List) + .map((e) => deserialize<_i96.AdminMicroArea>(e)) + .toList() + as T; + } + if (t == List<_i97.AdminAcs>) { + return (data as List).map((e) => deserialize<_i97.AdminAcs>(e)).toList() + as T; + } + if (t == List<_i98.AdminStaff>) { + return (data as List).map((e) => deserialize<_i98.AdminStaff>(e)).toList() + as T; + } + if (t == List<_i99.TermsVersionEntry>) { return (data as List) - .map((e) => deserialize<_i46.MicroAreaPatient>(e)) + .map((e) => deserialize<_i99.TermsVersionEntry>(e)) + .toList() + as T; + } + if (t == List<_i100.MicroAreaPatient>) { + return (data as List) + .map((e) => deserialize<_i100.MicroAreaPatient>(e)) .toList() as T; } if (t == List) { return (data as List).map((e) => deserialize(e)).toList() as T; } - if (t == List<_i47.VisitSyncResult>) { + if (t == List<_i101.VisitSyncResult>) { return (data as List) - .map((e) => deserialize<_i47.VisitSyncResult>(e)) + .map((e) => deserialize<_i101.VisitSyncResult>(e)) .toList() as T; } - if (t == List<_i48.VisitSyncEntry>) { + if (t == List<_i102.VisitSyncEntry>) { return (data as List) - .map((e) => deserialize<_i48.VisitSyncEntry>(e)) + .map((e) => deserialize<_i102.VisitSyncEntry>(e)) .toList() as T; } @@ -476,50 +969,100 @@ class Protocol extends _i1.SerializationManager { static String? getClassNameForType(Type type) { return switch (type) { _i2.Acs => 'Acs', - _i3.Alert => 'Alert', - _i4.AlertDeliveryRecord => 'AlertDeliveryRecord', - _i5.AlertIdempotencyKey => 'AlertIdempotencyKey', - _i6.AlertOutboxEntry => 'AlertOutboxEntry', - _i7.AlertAckResult => 'AlertAckResult', - _i8.AlertStatusResult => 'AlertStatusResult', - _i9.DevelopmentLoginResult => 'DevelopmentLoginResult', - _i10.EnrollmentResult => 'EnrollmentResult', - _i11.EnrollmentTokenResult => 'EnrollmentTokenResult', - _i12.MicroAreaPatient => 'MicroAreaPatient', - _i13.PatientConsentRecord => 'PatientConsentRecord', - _i14.PatientDataOverview => 'PatientDataOverview', - _i15.PatientRiskEvent => 'PatientRiskEvent', - _i16.RedAlertResult => 'RedAlertResult', - _i17.ServiceHealth => 'ServiceHealth', - _i18.TriageResult => 'TriageResult', - _i19.VisitSyncEntry => 'VisitSyncEntry', - _i20.VisitSyncResult => 'VisitSyncResult', - _i21.AuditLog => 'AuditLog', - _i22.ConsentLog => 'ConsentLog', - _i23.EnrollmentToken => 'EnrollmentToken', - _i24.AlertStatus => 'AlertStatus', - _i25.ArrivalMethod => 'ArrivalMethod', - _i26.ConsentPurpose => 'ConsentPurpose', - _i27.RiskLevel => 'RiskLevel', - _i28.SyncStatus => 'SyncStatus', - _i29.UserRole => 'UserRole', - _i30.AlertDispatchUnavailableException => + _i3.AcsRefreshToken => 'AcsRefreshToken', + _i4.AcsUploadToken => 'AcsUploadToken', + _i5.Alert => 'Alert', + _i6.AlertDeliveryRecord => 'AlertDeliveryRecord', + _i7.AlertIdempotencyKey => 'AlertIdempotencyKey', + _i8.AlertOutboxEntry => 'AlertOutboxEntry', + _i9.AdminAcs => 'AdminAcs', + _i10.AdminAcsCreationResult => 'AdminAcsCreationResult', + _i11.AdminAlert => 'AdminAlert', + _i12.AdminAlertPage => 'AdminAlertPage', + _i13.AdminAlertsPulse => 'AdminAlertsPulse', + _i14.AdminAuditChainStatus => 'AdminAuditChainStatus', + _i15.AdminAuditEntry => 'AdminAuditEntry', + _i16.AdminAuditExport => 'AdminAuditExport', + _i17.AdminAuditFilter => 'AdminAuditFilter', + _i18.AdminAuditPage => 'AdminAuditPage', + _i19.AdminDataSubjectRequest => 'AdminDataSubjectRequest', + _i20.AdminDataSubjectRequestDetail => 'AdminDataSubjectRequestDetail', + _i21.AdminDataSubjectRequestPage => 'AdminDataSubjectRequestPage', + _i22.AdminEscalationResult => 'AdminEscalationResult', + _i23.AdminIndicators => 'AdminIndicators', + _i24.AdminMicroArea => 'AdminMicroArea', + _i25.AdminPasswordResetResult => 'AdminPasswordResetResult', + _i26.AdminStaff => 'AdminStaff', + _i27.AdminStaffMfaResetResult => 'AdminStaffMfaResetResult', + _i28.AlertAckResult => 'AlertAckResult', + _i29.AlertStatusResult => 'AlertStatusResult', + _i30.DevelopmentLoginResult => 'DevelopmentLoginResult', + _i31.EnrollmentResult => 'EnrollmentResult', + _i32.EnrollmentTokenResult => 'EnrollmentTokenResult', + _i33.MicroAreaPatient => 'MicroAreaPatient', + _i34.NoticeHistoryEntry => 'NoticeHistoryEntry', + _i35.NoticeHistoryPage => 'NoticeHistoryPage', + _i36.NoticeSendResult => 'NoticeSendResult', + _i37.PatientConsentRecord => 'PatientConsentRecord', + _i38.PatientDataOverview => 'PatientDataOverview', + _i39.PatientDataSubjectRequestRecord => 'PatientDataSubjectRequestRecord', + _i40.PatientRiskEvent => 'PatientRiskEvent', + _i41.RedAlertResult => 'RedAlertResult', + _i42.ServiceHealth => 'ServiceHealth', + _i43.TermsChangeNotice => 'TermsChangeNotice', + _i44.TermsVersionEntry => 'TermsVersionEntry', + _i45.TotpEnrollmentStart => 'TotpEnrollmentStart', + _i46.TriageResult => 'TriageResult', + _i47.UbsContact => 'UbsContact', + _i48.VisitSyncEntry => 'VisitSyncEntry', + _i49.VisitSyncResult => 'VisitSyncResult', + _i50.AuditChainCheckpoint => 'AuditChainCheckpoint', + _i51.AuditExport => 'AuditExport', + _i52.AuditLog => 'AuditLog', + _i53.ConsentLog => 'ConsentLog', + _i54.DataSubjectRequest => 'DataSubjectRequest', + _i55.EnrollmentToken => 'EnrollmentToken', + _i56.AlertStatus => 'AlertStatus', + _i57.ArrivalMethod => 'ArrivalMethod', + _i58.AuditChainReason => 'AuditChainReason', + _i59.AuditIntegrityState => 'AuditIntegrityState', + _i60.AuditRowSeal => 'AuditRowSeal', + _i61.ConsentPurpose => 'ConsentPurpose', + _i62.DataSubjectRequestStatus => 'DataSubjectRequestStatus', + _i63.DataSubjectRequestType => 'DataSubjectRequestType', + _i64.EscalationReason => 'EscalationReason', + _i65.RiskLevel => 'RiskLevel', + _i66.ScheduledNoticeStatus => 'ScheduledNoticeStatus', + _i67.SyncStatus => 'SyncStatus', + _i68.UserRole => 'UserRole', + _i69.VisitAuthorship => 'VisitAuthorship', + _i70.AdminInvalidRequestException => 'AdminInvalidRequestException', + _i71.AlertDispatchUnavailableException => 'AlertDispatchUnavailableException', - _i31.AlertPermissionException => 'AlertPermissionException', - _i32.AlertValidationException => 'AlertValidationException', - _i33.AuthenticationFailedException => 'AuthenticationFailedException', - _i34.EndpointDisabledException => 'EndpointDisabledException', - _i35.EnrollmentException => 'EnrollmentException', - _i36.OtpRequestException => 'OtpRequestException', - _i37.MicroArea => 'MicroArea', - _i38.OtpChallenge => 'OtpChallenge', - _i39.Patient => 'Patient', - _i40.TriageAnswer => 'TriageAnswer', - _i41.TriageSession => 'TriageSession', - _i42.Ubs => 'Ubs', - _i43.User => 'User', - _i44.UserCredential => 'UserCredential', - _i45.Visit => 'Visit', + _i72.AlertPermissionException => 'AlertPermissionException', + _i73.AlertValidationException => 'AlertValidationException', + _i74.AuthenticationFailedException => 'AuthenticationFailedException', + _i75.DataRightsException => 'DataRightsException', + _i76.EndpointDisabledException => 'EndpointDisabledException', + _i77.EnrollmentException => 'EnrollmentException', + _i78.MfaEnrollmentRequiredException => 'MfaEnrollmentRequiredException', + _i79.MfaRequiredException => 'MfaRequiredException', + _i80.NoticeDeliveryException => 'NoticeDeliveryException', + _i81.OtpRequestException => 'OtpRequestException', + _i82.SessionExpiredException => 'SessionExpiredException', + _i83.MicroArea => 'MicroArea', + _i84.OtpChallenge => 'OtpChallenge', + _i85.Patient => 'Patient', + _i86.PushToken => 'PushToken', + _i87.ScheduledNotice => 'ScheduledNotice', + _i88.StaffAccount => 'StaffAccount', + _i89.TermsVersion => 'TermsVersion', + _i90.TriageAnswer => 'TriageAnswer', + _i91.TriageSession => 'TriageSession', + _i92.Ubs => 'Ubs', + _i93.User => 'User', + _i94.UserCredential => 'UserCredential', + _i95.Visit => 'Visit', _ => null, }; } @@ -536,91 +1079,191 @@ class Protocol extends _i1.SerializationManager { switch (data) { case _i2.Acs(): return 'Acs'; - case _i3.Alert(): + case _i3.AcsRefreshToken(): + return 'AcsRefreshToken'; + case _i4.AcsUploadToken(): + return 'AcsUploadToken'; + case _i5.Alert(): return 'Alert'; - case _i4.AlertDeliveryRecord(): + case _i6.AlertDeliveryRecord(): return 'AlertDeliveryRecord'; - case _i5.AlertIdempotencyKey(): + case _i7.AlertIdempotencyKey(): return 'AlertIdempotencyKey'; - case _i6.AlertOutboxEntry(): + case _i8.AlertOutboxEntry(): return 'AlertOutboxEntry'; - case _i7.AlertAckResult(): + case _i9.AdminAcs(): + return 'AdminAcs'; + case _i10.AdminAcsCreationResult(): + return 'AdminAcsCreationResult'; + case _i11.AdminAlert(): + return 'AdminAlert'; + case _i12.AdminAlertPage(): + return 'AdminAlertPage'; + case _i13.AdminAlertsPulse(): + return 'AdminAlertsPulse'; + case _i14.AdminAuditChainStatus(): + return 'AdminAuditChainStatus'; + case _i15.AdminAuditEntry(): + return 'AdminAuditEntry'; + case _i16.AdminAuditExport(): + return 'AdminAuditExport'; + case _i17.AdminAuditFilter(): + return 'AdminAuditFilter'; + case _i18.AdminAuditPage(): + return 'AdminAuditPage'; + case _i19.AdminDataSubjectRequest(): + return 'AdminDataSubjectRequest'; + case _i20.AdminDataSubjectRequestDetail(): + return 'AdminDataSubjectRequestDetail'; + case _i21.AdminDataSubjectRequestPage(): + return 'AdminDataSubjectRequestPage'; + case _i22.AdminEscalationResult(): + return 'AdminEscalationResult'; + case _i23.AdminIndicators(): + return 'AdminIndicators'; + case _i24.AdminMicroArea(): + return 'AdminMicroArea'; + case _i25.AdminPasswordResetResult(): + return 'AdminPasswordResetResult'; + case _i26.AdminStaff(): + return 'AdminStaff'; + case _i27.AdminStaffMfaResetResult(): + return 'AdminStaffMfaResetResult'; + case _i28.AlertAckResult(): return 'AlertAckResult'; - case _i8.AlertStatusResult(): + case _i29.AlertStatusResult(): return 'AlertStatusResult'; - case _i9.DevelopmentLoginResult(): + case _i30.DevelopmentLoginResult(): return 'DevelopmentLoginResult'; - case _i10.EnrollmentResult(): + case _i31.EnrollmentResult(): return 'EnrollmentResult'; - case _i11.EnrollmentTokenResult(): + case _i32.EnrollmentTokenResult(): return 'EnrollmentTokenResult'; - case _i12.MicroAreaPatient(): + case _i33.MicroAreaPatient(): return 'MicroAreaPatient'; - case _i13.PatientConsentRecord(): + case _i34.NoticeHistoryEntry(): + return 'NoticeHistoryEntry'; + case _i35.NoticeHistoryPage(): + return 'NoticeHistoryPage'; + case _i36.NoticeSendResult(): + return 'NoticeSendResult'; + case _i37.PatientConsentRecord(): return 'PatientConsentRecord'; - case _i14.PatientDataOverview(): + case _i38.PatientDataOverview(): return 'PatientDataOverview'; - case _i15.PatientRiskEvent(): + case _i39.PatientDataSubjectRequestRecord(): + return 'PatientDataSubjectRequestRecord'; + case _i40.PatientRiskEvent(): return 'PatientRiskEvent'; - case _i16.RedAlertResult(): + case _i41.RedAlertResult(): return 'RedAlertResult'; - case _i17.ServiceHealth(): + case _i42.ServiceHealth(): return 'ServiceHealth'; - case _i18.TriageResult(): + case _i43.TermsChangeNotice(): + return 'TermsChangeNotice'; + case _i44.TermsVersionEntry(): + return 'TermsVersionEntry'; + case _i45.TotpEnrollmentStart(): + return 'TotpEnrollmentStart'; + case _i46.TriageResult(): return 'TriageResult'; - case _i19.VisitSyncEntry(): + case _i47.UbsContact(): + return 'UbsContact'; + case _i48.VisitSyncEntry(): return 'VisitSyncEntry'; - case _i20.VisitSyncResult(): + case _i49.VisitSyncResult(): return 'VisitSyncResult'; - case _i21.AuditLog(): + case _i50.AuditChainCheckpoint(): + return 'AuditChainCheckpoint'; + case _i51.AuditExport(): + return 'AuditExport'; + case _i52.AuditLog(): return 'AuditLog'; - case _i22.ConsentLog(): + case _i53.ConsentLog(): return 'ConsentLog'; - case _i23.EnrollmentToken(): + case _i54.DataSubjectRequest(): + return 'DataSubjectRequest'; + case _i55.EnrollmentToken(): return 'EnrollmentToken'; - case _i24.AlertStatus(): + case _i56.AlertStatus(): return 'AlertStatus'; - case _i25.ArrivalMethod(): + case _i57.ArrivalMethod(): return 'ArrivalMethod'; - case _i26.ConsentPurpose(): + case _i58.AuditChainReason(): + return 'AuditChainReason'; + case _i59.AuditIntegrityState(): + return 'AuditIntegrityState'; + case _i60.AuditRowSeal(): + return 'AuditRowSeal'; + case _i61.ConsentPurpose(): return 'ConsentPurpose'; - case _i27.RiskLevel(): + case _i62.DataSubjectRequestStatus(): + return 'DataSubjectRequestStatus'; + case _i63.DataSubjectRequestType(): + return 'DataSubjectRequestType'; + case _i64.EscalationReason(): + return 'EscalationReason'; + case _i65.RiskLevel(): return 'RiskLevel'; - case _i28.SyncStatus(): + case _i66.ScheduledNoticeStatus(): + return 'ScheduledNoticeStatus'; + case _i67.SyncStatus(): return 'SyncStatus'; - case _i29.UserRole(): + case _i68.UserRole(): return 'UserRole'; - case _i30.AlertDispatchUnavailableException(): + case _i69.VisitAuthorship(): + return 'VisitAuthorship'; + case _i70.AdminInvalidRequestException(): + return 'AdminInvalidRequestException'; + case _i71.AlertDispatchUnavailableException(): return 'AlertDispatchUnavailableException'; - case _i31.AlertPermissionException(): + case _i72.AlertPermissionException(): return 'AlertPermissionException'; - case _i32.AlertValidationException(): + case _i73.AlertValidationException(): return 'AlertValidationException'; - case _i33.AuthenticationFailedException(): + case _i74.AuthenticationFailedException(): return 'AuthenticationFailedException'; - case _i34.EndpointDisabledException(): + case _i75.DataRightsException(): + return 'DataRightsException'; + case _i76.EndpointDisabledException(): return 'EndpointDisabledException'; - case _i35.EnrollmentException(): + case _i77.EnrollmentException(): return 'EnrollmentException'; - case _i36.OtpRequestException(): + case _i78.MfaEnrollmentRequiredException(): + return 'MfaEnrollmentRequiredException'; + case _i79.MfaRequiredException(): + return 'MfaRequiredException'; + case _i80.NoticeDeliveryException(): + return 'NoticeDeliveryException'; + case _i81.OtpRequestException(): return 'OtpRequestException'; - case _i37.MicroArea(): + case _i82.SessionExpiredException(): + return 'SessionExpiredException'; + case _i83.MicroArea(): return 'MicroArea'; - case _i38.OtpChallenge(): + case _i84.OtpChallenge(): return 'OtpChallenge'; - case _i39.Patient(): + case _i85.Patient(): return 'Patient'; - case _i40.TriageAnswer(): + case _i86.PushToken(): + return 'PushToken'; + case _i87.ScheduledNotice(): + return 'ScheduledNotice'; + case _i88.StaffAccount(): + return 'StaffAccount'; + case _i89.TermsVersion(): + return 'TermsVersion'; + case _i90.TriageAnswer(): return 'TriageAnswer'; - case _i41.TriageSession(): + case _i91.TriageSession(): return 'TriageSession'; - case _i42.Ubs(): + case _i92.Ubs(): return 'Ubs'; - case _i43.User(): + case _i93.User(): return 'User'; - case _i44.UserCredential(): + case _i94.UserCredential(): return 'UserCredential'; - case _i45.Visit(): + case _i95.Visit(): return 'Visit'; } return null; @@ -635,134 +1278,284 @@ class Protocol extends _i1.SerializationManager { if (dataClassName == 'Acs') { return deserialize<_i2.Acs>(data['data']); } + if (dataClassName == 'AcsRefreshToken') { + return deserialize<_i3.AcsRefreshToken>(data['data']); + } + if (dataClassName == 'AcsUploadToken') { + return deserialize<_i4.AcsUploadToken>(data['data']); + } if (dataClassName == 'Alert') { - return deserialize<_i3.Alert>(data['data']); + return deserialize<_i5.Alert>(data['data']); } if (dataClassName == 'AlertDeliveryRecord') { - return deserialize<_i4.AlertDeliveryRecord>(data['data']); + return deserialize<_i6.AlertDeliveryRecord>(data['data']); } if (dataClassName == 'AlertIdempotencyKey') { - return deserialize<_i5.AlertIdempotencyKey>(data['data']); + return deserialize<_i7.AlertIdempotencyKey>(data['data']); } if (dataClassName == 'AlertOutboxEntry') { - return deserialize<_i6.AlertOutboxEntry>(data['data']); + return deserialize<_i8.AlertOutboxEntry>(data['data']); + } + if (dataClassName == 'AdminAcs') { + return deserialize<_i9.AdminAcs>(data['data']); + } + if (dataClassName == 'AdminAcsCreationResult') { + return deserialize<_i10.AdminAcsCreationResult>(data['data']); + } + if (dataClassName == 'AdminAlert') { + return deserialize<_i11.AdminAlert>(data['data']); + } + if (dataClassName == 'AdminAlertPage') { + return deserialize<_i12.AdminAlertPage>(data['data']); + } + if (dataClassName == 'AdminAlertsPulse') { + return deserialize<_i13.AdminAlertsPulse>(data['data']); + } + if (dataClassName == 'AdminAuditChainStatus') { + return deserialize<_i14.AdminAuditChainStatus>(data['data']); + } + if (dataClassName == 'AdminAuditEntry') { + return deserialize<_i15.AdminAuditEntry>(data['data']); + } + if (dataClassName == 'AdminAuditExport') { + return deserialize<_i16.AdminAuditExport>(data['data']); + } + if (dataClassName == 'AdminAuditFilter') { + return deserialize<_i17.AdminAuditFilter>(data['data']); + } + if (dataClassName == 'AdminAuditPage') { + return deserialize<_i18.AdminAuditPage>(data['data']); + } + if (dataClassName == 'AdminDataSubjectRequest') { + return deserialize<_i19.AdminDataSubjectRequest>(data['data']); + } + if (dataClassName == 'AdminDataSubjectRequestDetail') { + return deserialize<_i20.AdminDataSubjectRequestDetail>(data['data']); + } + if (dataClassName == 'AdminDataSubjectRequestPage') { + return deserialize<_i21.AdminDataSubjectRequestPage>(data['data']); + } + if (dataClassName == 'AdminEscalationResult') { + return deserialize<_i22.AdminEscalationResult>(data['data']); + } + if (dataClassName == 'AdminIndicators') { + return deserialize<_i23.AdminIndicators>(data['data']); + } + if (dataClassName == 'AdminMicroArea') { + return deserialize<_i24.AdminMicroArea>(data['data']); + } + if (dataClassName == 'AdminPasswordResetResult') { + return deserialize<_i25.AdminPasswordResetResult>(data['data']); + } + if (dataClassName == 'AdminStaff') { + return deserialize<_i26.AdminStaff>(data['data']); + } + if (dataClassName == 'AdminStaffMfaResetResult') { + return deserialize<_i27.AdminStaffMfaResetResult>(data['data']); } if (dataClassName == 'AlertAckResult') { - return deserialize<_i7.AlertAckResult>(data['data']); + return deserialize<_i28.AlertAckResult>(data['data']); } if (dataClassName == 'AlertStatusResult') { - return deserialize<_i8.AlertStatusResult>(data['data']); + return deserialize<_i29.AlertStatusResult>(data['data']); } if (dataClassName == 'DevelopmentLoginResult') { - return deserialize<_i9.DevelopmentLoginResult>(data['data']); + return deserialize<_i30.DevelopmentLoginResult>(data['data']); } if (dataClassName == 'EnrollmentResult') { - return deserialize<_i10.EnrollmentResult>(data['data']); + return deserialize<_i31.EnrollmentResult>(data['data']); } if (dataClassName == 'EnrollmentTokenResult') { - return deserialize<_i11.EnrollmentTokenResult>(data['data']); + return deserialize<_i32.EnrollmentTokenResult>(data['data']); } if (dataClassName == 'MicroAreaPatient') { - return deserialize<_i12.MicroAreaPatient>(data['data']); + return deserialize<_i33.MicroAreaPatient>(data['data']); + } + if (dataClassName == 'NoticeHistoryEntry') { + return deserialize<_i34.NoticeHistoryEntry>(data['data']); + } + if (dataClassName == 'NoticeHistoryPage') { + return deserialize<_i35.NoticeHistoryPage>(data['data']); + } + if (dataClassName == 'NoticeSendResult') { + return deserialize<_i36.NoticeSendResult>(data['data']); } if (dataClassName == 'PatientConsentRecord') { - return deserialize<_i13.PatientConsentRecord>(data['data']); + return deserialize<_i37.PatientConsentRecord>(data['data']); } if (dataClassName == 'PatientDataOverview') { - return deserialize<_i14.PatientDataOverview>(data['data']); + return deserialize<_i38.PatientDataOverview>(data['data']); + } + if (dataClassName == 'PatientDataSubjectRequestRecord') { + return deserialize<_i39.PatientDataSubjectRequestRecord>(data['data']); } if (dataClassName == 'PatientRiskEvent') { - return deserialize<_i15.PatientRiskEvent>(data['data']); + return deserialize<_i40.PatientRiskEvent>(data['data']); } if (dataClassName == 'RedAlertResult') { - return deserialize<_i16.RedAlertResult>(data['data']); + return deserialize<_i41.RedAlertResult>(data['data']); } if (dataClassName == 'ServiceHealth') { - return deserialize<_i17.ServiceHealth>(data['data']); + return deserialize<_i42.ServiceHealth>(data['data']); + } + if (dataClassName == 'TermsChangeNotice') { + return deserialize<_i43.TermsChangeNotice>(data['data']); + } + if (dataClassName == 'TermsVersionEntry') { + return deserialize<_i44.TermsVersionEntry>(data['data']); + } + if (dataClassName == 'TotpEnrollmentStart') { + return deserialize<_i45.TotpEnrollmentStart>(data['data']); } if (dataClassName == 'TriageResult') { - return deserialize<_i18.TriageResult>(data['data']); + return deserialize<_i46.TriageResult>(data['data']); + } + if (dataClassName == 'UbsContact') { + return deserialize<_i47.UbsContact>(data['data']); } if (dataClassName == 'VisitSyncEntry') { - return deserialize<_i19.VisitSyncEntry>(data['data']); + return deserialize<_i48.VisitSyncEntry>(data['data']); } if (dataClassName == 'VisitSyncResult') { - return deserialize<_i20.VisitSyncResult>(data['data']); + return deserialize<_i49.VisitSyncResult>(data['data']); + } + if (dataClassName == 'AuditChainCheckpoint') { + return deserialize<_i50.AuditChainCheckpoint>(data['data']); + } + if (dataClassName == 'AuditExport') { + return deserialize<_i51.AuditExport>(data['data']); } if (dataClassName == 'AuditLog') { - return deserialize<_i21.AuditLog>(data['data']); + return deserialize<_i52.AuditLog>(data['data']); } if (dataClassName == 'ConsentLog') { - return deserialize<_i22.ConsentLog>(data['data']); + return deserialize<_i53.ConsentLog>(data['data']); + } + if (dataClassName == 'DataSubjectRequest') { + return deserialize<_i54.DataSubjectRequest>(data['data']); } if (dataClassName == 'EnrollmentToken') { - return deserialize<_i23.EnrollmentToken>(data['data']); + return deserialize<_i55.EnrollmentToken>(data['data']); } if (dataClassName == 'AlertStatus') { - return deserialize<_i24.AlertStatus>(data['data']); + return deserialize<_i56.AlertStatus>(data['data']); } if (dataClassName == 'ArrivalMethod') { - return deserialize<_i25.ArrivalMethod>(data['data']); + return deserialize<_i57.ArrivalMethod>(data['data']); + } + if (dataClassName == 'AuditChainReason') { + return deserialize<_i58.AuditChainReason>(data['data']); + } + if (dataClassName == 'AuditIntegrityState') { + return deserialize<_i59.AuditIntegrityState>(data['data']); + } + if (dataClassName == 'AuditRowSeal') { + return deserialize<_i60.AuditRowSeal>(data['data']); } if (dataClassName == 'ConsentPurpose') { - return deserialize<_i26.ConsentPurpose>(data['data']); + return deserialize<_i61.ConsentPurpose>(data['data']); + } + if (dataClassName == 'DataSubjectRequestStatus') { + return deserialize<_i62.DataSubjectRequestStatus>(data['data']); + } + if (dataClassName == 'DataSubjectRequestType') { + return deserialize<_i63.DataSubjectRequestType>(data['data']); + } + if (dataClassName == 'EscalationReason') { + return deserialize<_i64.EscalationReason>(data['data']); } if (dataClassName == 'RiskLevel') { - return deserialize<_i27.RiskLevel>(data['data']); + return deserialize<_i65.RiskLevel>(data['data']); + } + if (dataClassName == 'ScheduledNoticeStatus') { + return deserialize<_i66.ScheduledNoticeStatus>(data['data']); } if (dataClassName == 'SyncStatus') { - return deserialize<_i28.SyncStatus>(data['data']); + return deserialize<_i67.SyncStatus>(data['data']); } if (dataClassName == 'UserRole') { - return deserialize<_i29.UserRole>(data['data']); + return deserialize<_i68.UserRole>(data['data']); + } + if (dataClassName == 'VisitAuthorship') { + return deserialize<_i69.VisitAuthorship>(data['data']); + } + if (dataClassName == 'AdminInvalidRequestException') { + return deserialize<_i70.AdminInvalidRequestException>(data['data']); } if (dataClassName == 'AlertDispatchUnavailableException') { - return deserialize<_i30.AlertDispatchUnavailableException>(data['data']); + return deserialize<_i71.AlertDispatchUnavailableException>(data['data']); } if (dataClassName == 'AlertPermissionException') { - return deserialize<_i31.AlertPermissionException>(data['data']); + return deserialize<_i72.AlertPermissionException>(data['data']); } if (dataClassName == 'AlertValidationException') { - return deserialize<_i32.AlertValidationException>(data['data']); + return deserialize<_i73.AlertValidationException>(data['data']); } if (dataClassName == 'AuthenticationFailedException') { - return deserialize<_i33.AuthenticationFailedException>(data['data']); + return deserialize<_i74.AuthenticationFailedException>(data['data']); + } + if (dataClassName == 'DataRightsException') { + return deserialize<_i75.DataRightsException>(data['data']); } if (dataClassName == 'EndpointDisabledException') { - return deserialize<_i34.EndpointDisabledException>(data['data']); + return deserialize<_i76.EndpointDisabledException>(data['data']); } if (dataClassName == 'EnrollmentException') { - return deserialize<_i35.EnrollmentException>(data['data']); + return deserialize<_i77.EnrollmentException>(data['data']); + } + if (dataClassName == 'MfaEnrollmentRequiredException') { + return deserialize<_i78.MfaEnrollmentRequiredException>(data['data']); + } + if (dataClassName == 'MfaRequiredException') { + return deserialize<_i79.MfaRequiredException>(data['data']); + } + if (dataClassName == 'NoticeDeliveryException') { + return deserialize<_i80.NoticeDeliveryException>(data['data']); } if (dataClassName == 'OtpRequestException') { - return deserialize<_i36.OtpRequestException>(data['data']); + return deserialize<_i81.OtpRequestException>(data['data']); + } + if (dataClassName == 'SessionExpiredException') { + return deserialize<_i82.SessionExpiredException>(data['data']); } if (dataClassName == 'MicroArea') { - return deserialize<_i37.MicroArea>(data['data']); + return deserialize<_i83.MicroArea>(data['data']); } if (dataClassName == 'OtpChallenge') { - return deserialize<_i38.OtpChallenge>(data['data']); + return deserialize<_i84.OtpChallenge>(data['data']); } if (dataClassName == 'Patient') { - return deserialize<_i39.Patient>(data['data']); + return deserialize<_i85.Patient>(data['data']); + } + if (dataClassName == 'PushToken') { + return deserialize<_i86.PushToken>(data['data']); + } + if (dataClassName == 'ScheduledNotice') { + return deserialize<_i87.ScheduledNotice>(data['data']); + } + if (dataClassName == 'StaffAccount') { + return deserialize<_i88.StaffAccount>(data['data']); + } + if (dataClassName == 'TermsVersion') { + return deserialize<_i89.TermsVersion>(data['data']); } if (dataClassName == 'TriageAnswer') { - return deserialize<_i40.TriageAnswer>(data['data']); + return deserialize<_i90.TriageAnswer>(data['data']); } if (dataClassName == 'TriageSession') { - return deserialize<_i41.TriageSession>(data['data']); + return deserialize<_i91.TriageSession>(data['data']); } if (dataClassName == 'Ubs') { - return deserialize<_i42.Ubs>(data['data']); + return deserialize<_i92.Ubs>(data['data']); } if (dataClassName == 'User') { - return deserialize<_i43.User>(data['data']); + return deserialize<_i93.User>(data['data']); } if (dataClassName == 'UserCredential') { - return deserialize<_i44.UserCredential>(data['data']); + return deserialize<_i94.UserCredential>(data['data']); } if (dataClassName == 'Visit') { - return deserialize<_i45.Visit>(data['data']); + return deserialize<_i95.Visit>(data['data']); } return super.deserializeByClassName(data); } diff --git a/backend/sinalacs_client/lib/src/protocol/push_token.dart b/backend/sinalacs_client/lib/src/protocol/push_token.dart new file mode 100644 index 00000000..0da3d441 --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/push_token.dart @@ -0,0 +1,160 @@ +/* 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; + +/// Token de push (FCM/APNs) do aparelho de um paciente que consentiu com +/// `segmentedPush` (RF14, decisão §3.2). Uma linha por token: se o mesmo token +/// aparece para outro titular, a linha muda de dono, nunca duplica. +/// +/// O token identifica um aparelho, não uma pessoa, mas junto de `userId` liga +/// aparelho a titular — por isso some na revogação do consentimento. +abstract class PushToken implements _i1.SerializableModel { + PushToken._({ + this.id, + required this.userId, + this.microAreaId, + required this.token, + required this.platform, + required this.createdAt, + required this.updatedAt, + }); + + factory PushToken({ + _i1.UuidValue? id, + required _i1.UuidValue userId, + _i1.UuidValue? microAreaId, + required String token, + required String platform, + required DateTime createdAt, + required DateTime updatedAt, + }) = _PushTokenImpl; + + factory PushToken.fromJson(Map jsonSerialization) { + return PushToken( + id: jsonSerialization['id'] == null + ? null + : _i1.UuidValueJsonExtension.fromJson(jsonSerialization['id']), + userId: _i1.UuidValueJsonExtension.fromJson(jsonSerialization['userId']), + microAreaId: jsonSerialization['microAreaId'] == null + ? null + : _i1.UuidValueJsonExtension.fromJson( + jsonSerialization['microAreaId'], + ), + token: jsonSerialization['token'] as String, + platform: jsonSerialization['platform'] as String, + createdAt: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['createdAt'], + ), + updatedAt: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['updatedAt'], + ), + ); + } + + /// The database id, set if the object has been inserted into the + /// database or if it has been fetched from the database. Otherwise, + /// the id will be null. + _i1.UuidValue? id; + + _i1.UuidValue userId; + + _i1.UuidValue? microAreaId; + + String token; + + String platform; + + DateTime createdAt; + + DateTime updatedAt; + + /// Returns a shallow copy of this [PushToken] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + PushToken copyWith({ + _i1.UuidValue? id, + _i1.UuidValue? userId, + _i1.UuidValue? microAreaId, + String? token, + String? platform, + DateTime? createdAt, + DateTime? updatedAt, + }); + @override + Map toJson() { + return { + '__className__': 'PushToken', + if (id != null) 'id': id?.toJson(), + 'userId': userId.toJson(), + if (microAreaId != null) 'microAreaId': microAreaId?.toJson(), + 'token': token, + 'platform': platform, + 'createdAt': createdAt.toJson(), + 'updatedAt': updatedAt.toJson(), + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _PushTokenImpl extends PushToken { + _PushTokenImpl({ + _i1.UuidValue? id, + required _i1.UuidValue userId, + _i1.UuidValue? microAreaId, + required String token, + required String platform, + required DateTime createdAt, + required DateTime updatedAt, + }) : super._( + id: id, + userId: userId, + microAreaId: microAreaId, + token: token, + platform: platform, + createdAt: createdAt, + updatedAt: updatedAt, + ); + + /// Returns a shallow copy of this [PushToken] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + PushToken copyWith({ + Object? id = _Undefined, + _i1.UuidValue? userId, + Object? microAreaId = _Undefined, + String? token, + String? platform, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return PushToken( + id: id is _i1.UuidValue? ? id : this.id, + userId: userId ?? this.userId, + microAreaId: microAreaId is _i1.UuidValue? + ? microAreaId + : this.microAreaId, + token: token ?? this.token, + platform: platform ?? this.platform, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } +} diff --git a/backend/sinalacs_client/lib/src/protocol/scheduled_notice.dart b/backend/sinalacs_client/lib/src/protocol/scheduled_notice.dart new file mode 100644 index 00000000..bd8313c9 --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/scheduled_notice.dart @@ -0,0 +1,227 @@ +/* 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/scheduled_notice_status.dart' as _i2; + +/// Aviso comunitário agendado por staff (RF14, #46). O texto fica aqui porque o +/// staff o digitou para ser enviado; ele nunca vai para audit_logs. +abstract class ScheduledNotice implements _i1.SerializableModel { + ScheduledNotice._({ + this.id, + required this.createdBy, + required this.microAreaId, + required this.audience, + required this.title, + required this.message, + required this.scheduledFor, + required this.status, + this.recipients, + this.accepted, + this.failureReason, + required this.createdAt, + this.sentAt, + }); + + factory ScheduledNotice({ + _i1.UuidValue? id, + required _i1.UuidValue createdBy, + required _i1.UuidValue microAreaId, + required String audience, + required String title, + required String message, + required DateTime scheduledFor, + required _i2.ScheduledNoticeStatus status, + int? recipients, + int? accepted, + String? failureReason, + required DateTime createdAt, + DateTime? sentAt, + }) = _ScheduledNoticeImpl; + + factory ScheduledNotice.fromJson(Map jsonSerialization) { + return ScheduledNotice( + id: jsonSerialization['id'] == null + ? null + : _i1.UuidValueJsonExtension.fromJson(jsonSerialization['id']), + createdBy: _i1.UuidValueJsonExtension.fromJson( + jsonSerialization['createdBy'], + ), + microAreaId: _i1.UuidValueJsonExtension.fromJson( + jsonSerialization['microAreaId'], + ), + audience: jsonSerialization['audience'] as String, + title: jsonSerialization['title'] as String, + message: jsonSerialization['message'] as String, + scheduledFor: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['scheduledFor'], + ), + status: _i2.ScheduledNoticeStatus.fromJson( + (jsonSerialization['status'] as String), + ), + recipients: jsonSerialization['recipients'] as int?, + accepted: jsonSerialization['accepted'] as int?, + failureReason: jsonSerialization['failureReason'] as String?, + createdAt: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['createdAt'], + ), + sentAt: jsonSerialization['sentAt'] == null + ? null + : _i1.DateTimeJsonExtension.fromJson(jsonSerialization['sentAt']), + ); + } + + /// The database id, set if the object has been inserted into the + /// database or if it has been fetched from the database. Otherwise, + /// the id will be null. + _i1.UuidValue? id; + + _i1.UuidValue createdBy; + + _i1.UuidValue microAreaId; + + String audience; + + String title; + + String message; + + DateTime scheduledFor; + + _i2.ScheduledNoticeStatus status; + + int? recipients; + + int? accepted; + + String? failureReason; + + DateTime createdAt; + + DateTime? sentAt; + + /// Returns a shallow copy of this [ScheduledNotice] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + ScheduledNotice copyWith({ + _i1.UuidValue? id, + _i1.UuidValue? createdBy, + _i1.UuidValue? microAreaId, + String? audience, + String? title, + String? message, + DateTime? scheduledFor, + _i2.ScheduledNoticeStatus? status, + int? recipients, + int? accepted, + String? failureReason, + DateTime? createdAt, + DateTime? sentAt, + }); + @override + Map toJson() { + return { + '__className__': 'ScheduledNotice', + if (id != null) 'id': id?.toJson(), + 'createdBy': createdBy.toJson(), + 'microAreaId': microAreaId.toJson(), + 'audience': audience, + 'title': title, + 'message': message, + 'scheduledFor': scheduledFor.toJson(), + 'status': status.toJson(), + if (recipients != null) 'recipients': recipients, + if (accepted != null) 'accepted': accepted, + if (failureReason != null) 'failureReason': failureReason, + 'createdAt': createdAt.toJson(), + if (sentAt != null) 'sentAt': sentAt?.toJson(), + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _ScheduledNoticeImpl extends ScheduledNotice { + _ScheduledNoticeImpl({ + _i1.UuidValue? id, + required _i1.UuidValue createdBy, + required _i1.UuidValue microAreaId, + required String audience, + required String title, + required String message, + required DateTime scheduledFor, + required _i2.ScheduledNoticeStatus status, + int? recipients, + int? accepted, + String? failureReason, + required DateTime createdAt, + DateTime? sentAt, + }) : super._( + id: id, + createdBy: createdBy, + microAreaId: microAreaId, + audience: audience, + title: title, + message: message, + scheduledFor: scheduledFor, + status: status, + recipients: recipients, + accepted: accepted, + failureReason: failureReason, + createdAt: createdAt, + sentAt: sentAt, + ); + + /// Returns a shallow copy of this [ScheduledNotice] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + ScheduledNotice copyWith({ + Object? id = _Undefined, + _i1.UuidValue? createdBy, + _i1.UuidValue? microAreaId, + String? audience, + String? title, + String? message, + DateTime? scheduledFor, + _i2.ScheduledNoticeStatus? status, + Object? recipients = _Undefined, + Object? accepted = _Undefined, + Object? failureReason = _Undefined, + DateTime? createdAt, + Object? sentAt = _Undefined, + }) { + return ScheduledNotice( + id: id is _i1.UuidValue? ? id : this.id, + createdBy: createdBy ?? this.createdBy, + microAreaId: microAreaId ?? this.microAreaId, + audience: audience ?? this.audience, + title: title ?? this.title, + message: message ?? this.message, + scheduledFor: scheduledFor ?? this.scheduledFor, + status: status ?? this.status, + recipients: recipients is int? ? recipients : this.recipients, + accepted: accepted is int? ? accepted : this.accepted, + failureReason: failureReason is String? + ? failureReason + : this.failureReason, + createdAt: createdAt ?? this.createdAt, + sentAt: sentAt is DateTime? ? sentAt : this.sentAt, + ); + } +} diff --git a/backend/sinalacs_client/lib/src/protocol/staff_account.dart b/backend/sinalacs_client/lib/src/protocol/staff_account.dart new file mode 100644 index 00000000..46b5e3f4 --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/staff_account.dart @@ -0,0 +1,187 @@ +/* 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; + +/// Conta de staff do backoffice (coordenador ou administrador). +/// +/// Tabela própria, e não uma coluna em `acs`: staff não é ACS, não tem UBS nem +/// sincronização, e misturar os dois faria `acs.enrollmentId` virar um +/// namespace compartilhado. O id é o UUID do usuário (mesma decisão de `Acs`). +/// O papel mora em `users.role` (`coordinator` | `admin`); a credencial, em +/// `user_credentials`. +abstract class StaffAccount implements _i1.SerializableModel { + StaffAccount._({ + this.id, + required this.enrollmentId, + required this.active, + this.ubsId, + this.activationCodeHash, + this.activationCodeExpiresAt, + this.activationCodeIssuedBy, + this.activationCodeIssuedAt, + }); + + factory StaffAccount({ + _i1.UuidValue? id, + required String enrollmentId, + required bool active, + _i1.UuidValue? ubsId, + String? activationCodeHash, + DateTime? activationCodeExpiresAt, + String? activationCodeIssuedBy, + DateTime? activationCodeIssuedAt, + }) = _StaffAccountImpl; + + factory StaffAccount.fromJson(Map jsonSerialization) { + return StaffAccount( + id: jsonSerialization['id'] == null + ? null + : _i1.UuidValueJsonExtension.fromJson(jsonSerialization['id']), + enrollmentId: jsonSerialization['enrollmentId'] as String, + active: _i1.BoolJsonExtension.fromJson(jsonSerialization['active']), + ubsId: jsonSerialization['ubsId'] == null + ? null + : _i1.UuidValueJsonExtension.fromJson(jsonSerialization['ubsId']), + activationCodeHash: jsonSerialization['activationCodeHash'] as String?, + activationCodeExpiresAt: + jsonSerialization['activationCodeExpiresAt'] == null + ? null + : _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['activationCodeExpiresAt'], + ), + activationCodeIssuedBy: + jsonSerialization['activationCodeIssuedBy'] as String?, + activationCodeIssuedAt: + jsonSerialization['activationCodeIssuedAt'] == null + ? null + : _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['activationCodeIssuedAt'], + ), + ); + } + + /// The database id, set if the object has been inserted into the + /// database or if it has been fetched from the database. Otherwise, + /// the id will be null. + _i1.UuidValue? id; + + String enrollmentId; + + bool active; + + /// UBS do coordenador; nulo para administrador. Coordenador sem UBS é recusado. + _i1.UuidValue? ubsId; + + String? activationCodeHash; + + DateTime? activationCodeExpiresAt; + + String? activationCodeIssuedBy; + + DateTime? activationCodeIssuedAt; + + /// Returns a shallow copy of this [StaffAccount] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + StaffAccount copyWith({ + _i1.UuidValue? id, + String? enrollmentId, + bool? active, + _i1.UuidValue? ubsId, + String? activationCodeHash, + DateTime? activationCodeExpiresAt, + String? activationCodeIssuedBy, + DateTime? activationCodeIssuedAt, + }); + @override + Map toJson() { + return { + '__className__': 'StaffAccount', + if (id != null) 'id': id?.toJson(), + 'enrollmentId': enrollmentId, + 'active': active, + if (ubsId != null) 'ubsId': ubsId?.toJson(), + if (activationCodeHash != null) 'activationCodeHash': activationCodeHash, + if (activationCodeExpiresAt != null) + 'activationCodeExpiresAt': activationCodeExpiresAt?.toJson(), + if (activationCodeIssuedBy != null) + 'activationCodeIssuedBy': activationCodeIssuedBy, + if (activationCodeIssuedAt != null) + 'activationCodeIssuedAt': activationCodeIssuedAt?.toJson(), + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _StaffAccountImpl extends StaffAccount { + _StaffAccountImpl({ + _i1.UuidValue? id, + required String enrollmentId, + required bool active, + _i1.UuidValue? ubsId, + String? activationCodeHash, + DateTime? activationCodeExpiresAt, + String? activationCodeIssuedBy, + DateTime? activationCodeIssuedAt, + }) : super._( + id: id, + enrollmentId: enrollmentId, + active: active, + ubsId: ubsId, + activationCodeHash: activationCodeHash, + activationCodeExpiresAt: activationCodeExpiresAt, + activationCodeIssuedBy: activationCodeIssuedBy, + activationCodeIssuedAt: activationCodeIssuedAt, + ); + + /// Returns a shallow copy of this [StaffAccount] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + StaffAccount copyWith({ + Object? id = _Undefined, + String? enrollmentId, + bool? active, + Object? ubsId = _Undefined, + Object? activationCodeHash = _Undefined, + Object? activationCodeExpiresAt = _Undefined, + Object? activationCodeIssuedBy = _Undefined, + Object? activationCodeIssuedAt = _Undefined, + }) { + return StaffAccount( + id: id is _i1.UuidValue? ? id : this.id, + enrollmentId: enrollmentId ?? this.enrollmentId, + active: active ?? this.active, + ubsId: ubsId is _i1.UuidValue? ? ubsId : this.ubsId, + activationCodeHash: activationCodeHash is String? + ? activationCodeHash + : this.activationCodeHash, + activationCodeExpiresAt: activationCodeExpiresAt is DateTime? + ? activationCodeExpiresAt + : this.activationCodeExpiresAt, + activationCodeIssuedBy: activationCodeIssuedBy is String? + ? activationCodeIssuedBy + : this.activationCodeIssuedBy, + activationCodeIssuedAt: activationCodeIssuedAt is DateTime? + ? activationCodeIssuedAt + : this.activationCodeIssuedAt, + ); + } +} diff --git a/backend/sinalacs_client/lib/src/protocol/terms_version.dart b/backend/sinalacs_client/lib/src/protocol/terms_version.dart new file mode 100644 index 00000000..e1963452 --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/terms_version.dart @@ -0,0 +1,209 @@ +/* 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; + +/// Versão dos termos anunciada ao paciente (LGPD-RF18, #46). O TEXTO jurídico +/// não fica aqui: vive no app do paciente. Aqui só o anúncio e a revisão. +abstract class TermsVersion implements _i1.SerializableModel { + TermsVersion._({ + this.id, + required this.version, + required this.summary, + required this.effectiveFrom, + required this.createdBy, + required this.createdAt, + this.legallyReviewedAt, + this.legallyReviewedBy, + this.publishedAt, + this.publishedBy, + }); + + factory TermsVersion({ + _i1.UuidValue? id, + required String version, + required String summary, + required DateTime effectiveFrom, + required _i1.UuidValue createdBy, + required DateTime createdAt, + DateTime? legallyReviewedAt, + _i1.UuidValue? legallyReviewedBy, + DateTime? publishedAt, + _i1.UuidValue? publishedBy, + }) = _TermsVersionImpl; + + factory TermsVersion.fromJson(Map jsonSerialization) { + return TermsVersion( + id: jsonSerialization['id'] == null + ? null + : _i1.UuidValueJsonExtension.fromJson(jsonSerialization['id']), + version: jsonSerialization['version'] as String, + summary: jsonSerialization['summary'] as String, + effectiveFrom: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['effectiveFrom'], + ), + createdBy: _i1.UuidValueJsonExtension.fromJson( + jsonSerialization['createdBy'], + ), + createdAt: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['createdAt'], + ), + legallyReviewedAt: jsonSerialization['legallyReviewedAt'] == null + ? null + : _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['legallyReviewedAt'], + ), + legallyReviewedBy: jsonSerialization['legallyReviewedBy'] == null + ? null + : _i1.UuidValueJsonExtension.fromJson( + jsonSerialization['legallyReviewedBy'], + ), + publishedAt: jsonSerialization['publishedAt'] == null + ? null + : _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['publishedAt'], + ), + publishedBy: jsonSerialization['publishedBy'] == null + ? null + : _i1.UuidValueJsonExtension.fromJson( + jsonSerialization['publishedBy'], + ), + ); + } + + /// The database id, set if the object has been inserted into the + /// database or if it has been fetched from the database. Otherwise, + /// the id will be null. + _i1.UuidValue? id; + + String version; + + String summary; + + DateTime effectiveFrom; + + _i1.UuidValue createdBy; + + DateTime createdAt; + + DateTime? legallyReviewedAt; + + _i1.UuidValue? legallyReviewedBy; + + DateTime? publishedAt; + + _i1.UuidValue? publishedBy; + + /// Returns a shallow copy of this [TermsVersion] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + TermsVersion copyWith({ + _i1.UuidValue? id, + String? version, + String? summary, + DateTime? effectiveFrom, + _i1.UuidValue? createdBy, + DateTime? createdAt, + DateTime? legallyReviewedAt, + _i1.UuidValue? legallyReviewedBy, + DateTime? publishedAt, + _i1.UuidValue? publishedBy, + }); + @override + Map toJson() { + return { + '__className__': 'TermsVersion', + if (id != null) 'id': id?.toJson(), + 'version': version, + 'summary': summary, + 'effectiveFrom': effectiveFrom.toJson(), + 'createdBy': createdBy.toJson(), + 'createdAt': createdAt.toJson(), + if (legallyReviewedAt != null) + 'legallyReviewedAt': legallyReviewedAt?.toJson(), + if (legallyReviewedBy != null) + 'legallyReviewedBy': legallyReviewedBy?.toJson(), + if (publishedAt != null) 'publishedAt': publishedAt?.toJson(), + if (publishedBy != null) 'publishedBy': publishedBy?.toJson(), + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _TermsVersionImpl extends TermsVersion { + _TermsVersionImpl({ + _i1.UuidValue? id, + required String version, + required String summary, + required DateTime effectiveFrom, + required _i1.UuidValue createdBy, + required DateTime createdAt, + DateTime? legallyReviewedAt, + _i1.UuidValue? legallyReviewedBy, + DateTime? publishedAt, + _i1.UuidValue? publishedBy, + }) : super._( + id: id, + version: version, + summary: summary, + effectiveFrom: effectiveFrom, + createdBy: createdBy, + createdAt: createdAt, + legallyReviewedAt: legallyReviewedAt, + legallyReviewedBy: legallyReviewedBy, + publishedAt: publishedAt, + publishedBy: publishedBy, + ); + + /// Returns a shallow copy of this [TermsVersion] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + TermsVersion copyWith({ + Object? id = _Undefined, + String? version, + String? summary, + DateTime? effectiveFrom, + _i1.UuidValue? createdBy, + DateTime? createdAt, + Object? legallyReviewedAt = _Undefined, + Object? legallyReviewedBy = _Undefined, + Object? publishedAt = _Undefined, + Object? publishedBy = _Undefined, + }) { + return TermsVersion( + id: id is _i1.UuidValue? ? id : this.id, + version: version ?? this.version, + summary: summary ?? this.summary, + effectiveFrom: effectiveFrom ?? this.effectiveFrom, + createdBy: createdBy ?? this.createdBy, + createdAt: createdAt ?? this.createdAt, + legallyReviewedAt: legallyReviewedAt is DateTime? + ? legallyReviewedAt + : this.legallyReviewedAt, + legallyReviewedBy: legallyReviewedBy is _i1.UuidValue? + ? legallyReviewedBy + : this.legallyReviewedBy, + publishedAt: publishedAt is DateTime? ? publishedAt : this.publishedAt, + publishedBy: publishedBy is _i1.UuidValue? + ? publishedBy + : this.publishedBy, + ); + } +} diff --git a/backend/sinalacs_client/lib/src/protocol/ubs.dart b/backend/sinalacs_client/lib/src/protocol/ubs.dart index 98fb63b1..b1dc570f 100644 --- a/backend/sinalacs_client/lib/src/protocol/ubs.dart +++ b/backend/sinalacs_client/lib/src/protocol/ubs.dart @@ -21,6 +21,7 @@ abstract class Ubs implements _i1.SerializableModel { required this.address, required this.city, required this.state, + this.contactPhone, }); factory Ubs({ @@ -29,6 +30,7 @@ abstract class Ubs implements _i1.SerializableModel { required String address, required String city, required String state, + String? contactPhone, }) = _UbsImpl; factory Ubs.fromJson(Map jsonSerialization) { @@ -40,6 +42,7 @@ abstract class Ubs implements _i1.SerializableModel { address: jsonSerialization['address'] as String, city: jsonSerialization['city'] as String, state: jsonSerialization['state'] as String, + contactPhone: jsonSerialization['contactPhone'] as String?, ); } @@ -56,6 +59,10 @@ abstract class Ubs implements _i1.SerializableModel { String state; + /// Telefone de contato da UBS para o ACS escalar um caso (RF13). Dado da + /// unidade, não de pessoa. `null` = ainda não cadastrado: o app avisa, não liga. + String? contactPhone; + /// Returns a shallow copy of this [Ubs] /// with some or all fields replaced by the given arguments. @_i1.useResult @@ -65,6 +72,7 @@ abstract class Ubs implements _i1.SerializableModel { String? address, String? city, String? state, + String? contactPhone, }); @override Map toJson() { @@ -75,6 +83,7 @@ abstract class Ubs implements _i1.SerializableModel { 'address': address, 'city': city, 'state': state, + if (contactPhone != null) 'contactPhone': contactPhone, }; } @@ -93,12 +102,14 @@ class _UbsImpl extends Ubs { required String address, required String city, required String state, + String? contactPhone, }) : super._( id: id, name: name, address: address, city: city, state: state, + contactPhone: contactPhone, ); /// Returns a shallow copy of this [Ubs] @@ -111,6 +122,7 @@ class _UbsImpl extends Ubs { String? address, String? city, String? state, + Object? contactPhone = _Undefined, }) { return Ubs( id: id is _i1.UuidValue? ? id : this.id, @@ -118,6 +130,7 @@ class _UbsImpl extends Ubs { address: address ?? this.address, city: city ?? this.city, state: state ?? this.state, + contactPhone: contactPhone is String? ? contactPhone : this.contactPhone, ); } } diff --git a/backend/sinalacs_client/lib/src/protocol/user_credential.dart b/backend/sinalacs_client/lib/src/protocol/user_credential.dart index eb68a328..13eb3f99 100644 --- a/backend/sinalacs_client/lib/src/protocol/user_credential.dart +++ b/backend/sinalacs_client/lib/src/protocol/user_credential.dart @@ -32,9 +32,14 @@ abstract class UserCredential implements _i1.SerializableModel { required this.parallelism, required this.failedAttempts, this.lockedUntil, + int? lockStreak, required this.createdAt, required this.updatedAt, - }); + this.totpSecretEncrypted, + this.totpKeyVersion, + this.totpEnabledAt, + this.totpLastStep, + }) : lockStreak = lockStreak ?? 0; factory UserCredential({ _i1.UuidValue? id, @@ -46,8 +51,13 @@ abstract class UserCredential implements _i1.SerializableModel { required int parallelism, required int failedAttempts, DateTime? lockedUntil, + int? lockStreak, required DateTime createdAt, required DateTime updatedAt, + String? totpSecretEncrypted, + int? totpKeyVersion, + DateTime? totpEnabledAt, + int? totpLastStep, }) = _UserCredentialImpl; factory UserCredential.fromJson(Map jsonSerialization) { @@ -67,12 +77,21 @@ abstract class UserCredential implements _i1.SerializableModel { : _i1.DateTimeJsonExtension.fromJson( jsonSerialization['lockedUntil'], ), + lockStreak: jsonSerialization['lockStreak'] as int?, createdAt: _i1.DateTimeJsonExtension.fromJson( jsonSerialization['createdAt'], ), updatedAt: _i1.DateTimeJsonExtension.fromJson( jsonSerialization['updatedAt'], ), + totpSecretEncrypted: jsonSerialization['totpSecretEncrypted'] as String?, + totpKeyVersion: jsonSerialization['totpKeyVersion'] as int?, + totpEnabledAt: jsonSerialization['totpEnabledAt'] == null + ? null + : _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['totpEnabledAt'], + ), + totpLastStep: jsonSerialization['totpLastStep'] as int?, ); } @@ -103,10 +122,26 @@ abstract class UserCredential implements _i1.SerializableModel { /// `null` = não bloqueado. DateTime? lockedUntil; + /// Rodadas de bloqueio seguidas, sem um login válido no meio. Alimenta o + /// bloqueio progressivo (15 min x 2^lockStreak, teto 24 h). Zera no login válido. + int lockStreak; + DateTime createdAt; DateTime updatedAt; + /// MFA por TOTP (RFC 6238). `null` = sem segredo gravado. O segredo é cifrado + /// (AES-256-GCM, a mesma chave dos dados clínicos); **nunca** em claro. + String? totpSecretEncrypted; + + int? totpKeyVersion; + + /// `null` = enrollment começou e não foi confirmado: a MFA ainda NÃO vale. + DateTime? totpEnabledAt; + + /// Último passo de 30 s aceito. O mesmo código não entra duas vezes (replay). + int? totpLastStep; + /// Returns a shallow copy of this [UserCredential] /// with some or all fields replaced by the given arguments. @_i1.useResult @@ -120,8 +155,13 @@ abstract class UserCredential implements _i1.SerializableModel { int? parallelism, int? failedAttempts, DateTime? lockedUntil, + int? lockStreak, DateTime? createdAt, DateTime? updatedAt, + String? totpSecretEncrypted, + int? totpKeyVersion, + DateTime? totpEnabledAt, + int? totpLastStep, }); @override Map toJson() { @@ -136,8 +176,14 @@ abstract class UserCredential implements _i1.SerializableModel { 'parallelism': parallelism, 'failedAttempts': failedAttempts, if (lockedUntil != null) 'lockedUntil': lockedUntil?.toJson(), + 'lockStreak': lockStreak, 'createdAt': createdAt.toJson(), 'updatedAt': updatedAt.toJson(), + if (totpSecretEncrypted != null) + 'totpSecretEncrypted': totpSecretEncrypted, + if (totpKeyVersion != null) 'totpKeyVersion': totpKeyVersion, + if (totpEnabledAt != null) 'totpEnabledAt': totpEnabledAt?.toJson(), + if (totpLastStep != null) 'totpLastStep': totpLastStep, }; } @@ -160,8 +206,13 @@ class _UserCredentialImpl extends UserCredential { required int parallelism, required int failedAttempts, DateTime? lockedUntil, + int? lockStreak, required DateTime createdAt, required DateTime updatedAt, + String? totpSecretEncrypted, + int? totpKeyVersion, + DateTime? totpEnabledAt, + int? totpLastStep, }) : super._( id: id, userId: userId, @@ -172,8 +223,13 @@ class _UserCredentialImpl extends UserCredential { parallelism: parallelism, failedAttempts: failedAttempts, lockedUntil: lockedUntil, + lockStreak: lockStreak, createdAt: createdAt, updatedAt: updatedAt, + totpSecretEncrypted: totpSecretEncrypted, + totpKeyVersion: totpKeyVersion, + totpEnabledAt: totpEnabledAt, + totpLastStep: totpLastStep, ); /// Returns a shallow copy of this [UserCredential] @@ -190,8 +246,13 @@ class _UserCredentialImpl extends UserCredential { int? parallelism, int? failedAttempts, Object? lockedUntil = _Undefined, + int? lockStreak, DateTime? createdAt, DateTime? updatedAt, + Object? totpSecretEncrypted = _Undefined, + Object? totpKeyVersion = _Undefined, + Object? totpEnabledAt = _Undefined, + Object? totpLastStep = _Undefined, }) { return UserCredential( id: id is _i1.UuidValue? ? id : this.id, @@ -203,8 +264,19 @@ class _UserCredentialImpl extends UserCredential { parallelism: parallelism ?? this.parallelism, failedAttempts: failedAttempts ?? this.failedAttempts, lockedUntil: lockedUntil is DateTime? ? lockedUntil : this.lockedUntil, + lockStreak: lockStreak ?? this.lockStreak, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, + totpSecretEncrypted: totpSecretEncrypted is String? + ? totpSecretEncrypted + : this.totpSecretEncrypted, + totpKeyVersion: totpKeyVersion is int? + ? totpKeyVersion + : this.totpKeyVersion, + totpEnabledAt: totpEnabledAt is DateTime? + ? totpEnabledAt + : this.totpEnabledAt, + totpLastStep: totpLastStep is int? ? totpLastStep : this.totpLastStep, ); } } diff --git a/backend/sinalacs_client/lib/src/protocol/visit.dart b/backend/sinalacs_client/lib/src/protocol/visit.dart index 56918dc4..9eeceb0c 100644 --- a/backend/sinalacs_client/lib/src/protocol/visit.dart +++ b/backend/sinalacs_client/lib/src/protocol/visit.dart @@ -12,16 +12,19 @@ // ignore_for_file: no_leading_underscores_for_library_prefixes import 'package:serverpod_client/serverpod_client.dart' as _i1; -import 'enums/arrival_method.dart' as _i2; -import 'enums/risk_level.dart' as _i3; -import 'enums/sync_status.dart' as _i4; +import 'enums/visit_authorship.dart' as _i2; +import 'enums/arrival_method.dart' as _i3; +import 'enums/risk_level.dart' as _i4; +import 'enums/sync_status.dart' as _i5; /// Visita domiciliar. Registrada offline e sincronizada depois. abstract class Visit implements _i1.SerializableModel { Visit._({ this.id, required this.patientId, - required this.acsId, + this.acsId, + _i2.VisitAuthorship? authorship, + this.originDeviceId, required this.scheduledAt, this.startedAt, this.completedAt, @@ -31,28 +34,31 @@ abstract class Visit implements _i1.SerializableModel { String? notesEncrypted, int? notesKeyVersion, required this.syncStatus, - _i2.ArrivalMethod? arrivalMethod, + _i3.ArrivalMethod? arrivalMethod, required this.localId, this.syncAt, required this.version, - }) : notesEncrypted = notesEncrypted ?? '', + }) : authorship = authorship ?? _i2.VisitAuthorship.acs, + notesEncrypted = notesEncrypted ?? '', notesKeyVersion = notesKeyVersion ?? 1, - arrivalMethod = arrivalMethod ?? _i2.ArrivalMethod.manual; + arrivalMethod = arrivalMethod ?? _i3.ArrivalMethod.manual; factory Visit({ _i1.UuidValue? id, required _i1.UuidValue patientId, - required _i1.UuidValue acsId, + _i1.UuidValue? acsId, + _i2.VisitAuthorship? authorship, + String? originDeviceId, required DateTime scheduledAt, DateTime? startedAt, DateTime? completedAt, required String status, - required _i3.RiskLevel riskLevelBefore, - _i3.RiskLevel? riskLevelAfter, + required _i4.RiskLevel riskLevelBefore, + _i4.RiskLevel? riskLevelAfter, String? notesEncrypted, int? notesKeyVersion, - required _i4.SyncStatus syncStatus, - _i2.ArrivalMethod? arrivalMethod, + required _i5.SyncStatus syncStatus, + _i3.ArrivalMethod? arrivalMethod, required _i1.UuidValue localId, DateTime? syncAt, required int version, @@ -66,7 +72,15 @@ abstract class Visit implements _i1.SerializableModel { patientId: _i1.UuidValueJsonExtension.fromJson( jsonSerialization['patientId'], ), - acsId: _i1.UuidValueJsonExtension.fromJson(jsonSerialization['acsId']), + acsId: jsonSerialization['acsId'] == null + ? null + : _i1.UuidValueJsonExtension.fromJson(jsonSerialization['acsId']), + authorship: jsonSerialization['authorship'] == null + ? null + : _i2.VisitAuthorship.fromJson( + (jsonSerialization['authorship'] as String), + ), + originDeviceId: jsonSerialization['originDeviceId'] as String?, scheduledAt: _i1.DateTimeJsonExtension.fromJson( jsonSerialization['scheduledAt'], ), @@ -79,22 +93,22 @@ abstract class Visit implements _i1.SerializableModel { jsonSerialization['completedAt'], ), status: jsonSerialization['status'] as String, - riskLevelBefore: _i3.RiskLevel.fromJson( + riskLevelBefore: _i4.RiskLevel.fromJson( (jsonSerialization['riskLevelBefore'] as String), ), riskLevelAfter: jsonSerialization['riskLevelAfter'] == null ? null - : _i3.RiskLevel.fromJson( + : _i4.RiskLevel.fromJson( (jsonSerialization['riskLevelAfter'] as String), ), notesEncrypted: jsonSerialization['notesEncrypted'] as String?, notesKeyVersion: jsonSerialization['notesKeyVersion'] as int?, - syncStatus: _i4.SyncStatus.fromJson( + syncStatus: _i5.SyncStatus.fromJson( (jsonSerialization['syncStatus'] as String), ), arrivalMethod: jsonSerialization['arrivalMethod'] == null ? null - : _i2.ArrivalMethod.fromJson( + : _i3.ArrivalMethod.fromJson( (jsonSerialization['arrivalMethod'] as String), ), localId: _i1.UuidValueJsonExtension.fromJson( @@ -114,7 +128,16 @@ abstract class Visit implements _i1.SerializableModel { _i1.UuidValue patientId; - _i1.UuidValue acsId; + /// Autor da visita. Nulo SÓ quando `authorship == legacyUnclaimed`. + _i1.UuidValue? acsId; + + /// `legacyUnclaimed` = visita gravada no aparelho antes de existir dono (migração v7): + /// a autoria é desconhecida e `acsId` é nulo. NUNCA preencher `acsId` com quem transportou. + _i2.VisitAuthorship authorship; + + /// Instalação do app de onde veio uma visita legada (`visits.syncLegacy`). + /// Nulo nas visitas com autor ACS. + String? originDeviceId; DateTime scheduledAt; @@ -124,21 +147,21 @@ abstract class Visit implements _i1.SerializableModel { String status; - _i3.RiskLevel riskLevelBefore; + _i4.RiskLevel riskLevelBefore; - _i3.RiskLevel? riskLevelAfter; + _i4.RiskLevel? riskLevelAfter; /// JSON de Map, cifrado. Ver patient.spy.yaml para o padrão. String notesEncrypted; int notesKeyVersion; - _i4.SyncStatus syncStatus; + _i5.SyncStatus syncStatus; /// Como o check-in desta visita foi registrado (RF12, decisão §4). Default /// `manual` até a integração nativa de geofencing existir — hoje nenhum /// código produz `geofence`. - _i2.ArrivalMethod arrivalMethod; + _i3.ArrivalMethod arrivalMethod; /// Identificador gerado no dispositivo, usado para deduplicar na sincronização. _i1.UuidValue localId; @@ -154,16 +177,18 @@ abstract class Visit implements _i1.SerializableModel { _i1.UuidValue? id, _i1.UuidValue? patientId, _i1.UuidValue? acsId, + _i2.VisitAuthorship? authorship, + String? originDeviceId, DateTime? scheduledAt, DateTime? startedAt, DateTime? completedAt, String? status, - _i3.RiskLevel? riskLevelBefore, - _i3.RiskLevel? riskLevelAfter, + _i4.RiskLevel? riskLevelBefore, + _i4.RiskLevel? riskLevelAfter, String? notesEncrypted, int? notesKeyVersion, - _i4.SyncStatus? syncStatus, - _i2.ArrivalMethod? arrivalMethod, + _i5.SyncStatus? syncStatus, + _i3.ArrivalMethod? arrivalMethod, _i1.UuidValue? localId, DateTime? syncAt, int? version, @@ -174,7 +199,9 @@ abstract class Visit implements _i1.SerializableModel { '__className__': 'Visit', if (id != null) 'id': id?.toJson(), 'patientId': patientId.toJson(), - 'acsId': acsId.toJson(), + if (acsId != null) 'acsId': acsId?.toJson(), + 'authorship': authorship.toJson(), + if (originDeviceId != null) 'originDeviceId': originDeviceId, 'scheduledAt': scheduledAt.toJson(), if (startedAt != null) 'startedAt': startedAt?.toJson(), if (completedAt != null) 'completedAt': completedAt?.toJson(), @@ -203,17 +230,19 @@ class _VisitImpl extends Visit { _VisitImpl({ _i1.UuidValue? id, required _i1.UuidValue patientId, - required _i1.UuidValue acsId, + _i1.UuidValue? acsId, + _i2.VisitAuthorship? authorship, + String? originDeviceId, required DateTime scheduledAt, DateTime? startedAt, DateTime? completedAt, required String status, - required _i3.RiskLevel riskLevelBefore, - _i3.RiskLevel? riskLevelAfter, + required _i4.RiskLevel riskLevelBefore, + _i4.RiskLevel? riskLevelAfter, String? notesEncrypted, int? notesKeyVersion, - required _i4.SyncStatus syncStatus, - _i2.ArrivalMethod? arrivalMethod, + required _i5.SyncStatus syncStatus, + _i3.ArrivalMethod? arrivalMethod, required _i1.UuidValue localId, DateTime? syncAt, required int version, @@ -221,6 +250,8 @@ class _VisitImpl extends Visit { id: id, patientId: patientId, acsId: acsId, + authorship: authorship, + originDeviceId: originDeviceId, scheduledAt: scheduledAt, startedAt: startedAt, completedAt: completedAt, @@ -243,17 +274,19 @@ class _VisitImpl extends Visit { Visit copyWith({ Object? id = _Undefined, _i1.UuidValue? patientId, - _i1.UuidValue? acsId, + Object? acsId = _Undefined, + _i2.VisitAuthorship? authorship, + Object? originDeviceId = _Undefined, DateTime? scheduledAt, Object? startedAt = _Undefined, Object? completedAt = _Undefined, String? status, - _i3.RiskLevel? riskLevelBefore, + _i4.RiskLevel? riskLevelBefore, Object? riskLevelAfter = _Undefined, String? notesEncrypted, int? notesKeyVersion, - _i4.SyncStatus? syncStatus, - _i2.ArrivalMethod? arrivalMethod, + _i5.SyncStatus? syncStatus, + _i3.ArrivalMethod? arrivalMethod, _i1.UuidValue? localId, Object? syncAt = _Undefined, int? version, @@ -261,13 +294,17 @@ class _VisitImpl extends Visit { return Visit( id: id is _i1.UuidValue? ? id : this.id, patientId: patientId ?? this.patientId, - acsId: acsId ?? this.acsId, + acsId: acsId is _i1.UuidValue? ? acsId : this.acsId, + authorship: authorship ?? this.authorship, + originDeviceId: originDeviceId is String? + ? originDeviceId + : this.originDeviceId, scheduledAt: scheduledAt ?? this.scheduledAt, startedAt: startedAt is DateTime? ? startedAt : this.startedAt, completedAt: completedAt is DateTime? ? completedAt : this.completedAt, status: status ?? this.status, riskLevelBefore: riskLevelBefore ?? this.riskLevelBefore, - riskLevelAfter: riskLevelAfter is _i3.RiskLevel? + riskLevelAfter: riskLevelAfter is _i4.RiskLevel? ? riskLevelAfter : this.riskLevelAfter, notesEncrypted: notesEncrypted ?? this.notesEncrypted, diff --git a/backend/sinalacs_server/README.md b/backend/sinalacs_server/README.md index 4ed5f1ea..c128bc91 100644 --- a/backend/sinalacs_server/README.md +++ b/backend/sinalacs_server/README.md @@ -1,15 +1,23 @@ # sinalacs_server -This is the starting point for your Serverpod server. +Servidor Serverpod do SinalACS: endpoints RPC (`lib/src/endpoints/`), regras de +domínio, migrações (`migrations/`) e ferramentas de operador (`bin/`). -To run your server, you first need to start Postgres and Redis. It's easiest to do with Docker. +Não suba este servidor sozinho: a stack de desenvolvimento (PostgreSQL, +Mosquitto, Traefik e os seeds) fica na raiz do repositório. - docker compose up --build --detach +```bash +./scripts/dev/bootstrap_env.sh # na raiz, uma vez +docker compose up --build # na raiz +``` -Then you can start the Serverpod server. +Testes: - dart bin/main.dart +```bash +dart test # unitários + integração (Postgres de teste em localhost:9090) +dart test test/unit # só os herméticos +``` -When you are finished, you can shut down Serverpod with `Ctrl-C`, then stop Postgres and Redis. - - docker compose stop +Comandos do Serverpod, migrações, segredos, seeds e camadas de `lib/src/`: +[backend/CLAUDE.md](../CLAUDE.md). Visão geral e configuração: +[README.md](../../README.md). diff --git a/backend/sinalacs_server/bin/audit_chain_check.dart b/backend/sinalacs_server/bin/audit_chain_check.dart index a53d470a..77b3d11a 100644 --- a/backend/sinalacs_server/bin/audit_chain_check.dart +++ b/backend/sinalacs_server/bin/audit_chain_check.dart @@ -17,6 +17,10 @@ import 'package:sinalacs_server/src/runtime/alert_runtime.dart'; /// 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. +/// +/// Lê a tabela em lotes (`AuditChainVerifier.batchSize`), sem orçamento de +/// tempo: a CLI vai até o fim. Falha de leitura ou segredo ausente também sai +/// `1` — verificação que não terminou nunca é "íntegra". Future main(List args) async { final pod = Serverpod(args, Protocol(), Endpoints()); final session = await pod.createSession(); @@ -35,7 +39,8 @@ Future main(List args) async { exitCode = 0; } else { stderr.writeln( - 'FALHA na sequence ${result.brokenAtSequence}: ${result.reason} ' + 'FALHA [${result.reasonCode?.name}] na sequence ' + '${result.brokenAtSequence ?? '-'}: ${result.reason} ' '(${result.checked} linha(s) íntegra(s) antes da quebra)', ); exitCode = 1; diff --git a/backend/sinalacs_server/bin/issue_staff_activation_code.dart b/backend/sinalacs_server/bin/issue_staff_activation_code.dart new file mode 100644 index 00000000..e5ec20b5 --- /dev/null +++ b/backend/sinalacs_server/bin/issue_staff_activation_code.dart @@ -0,0 +1,66 @@ +import 'dart:io'; + +import 'package:postgres/postgres.dart' as pg; +import 'package:sinalacs_server/src/application/auth/staff_activation_code.dart'; +import 'package:sinalacs_server/src/ops/staff_activation_issuer.dart'; + +/// Emite o código de ativação de uso único da MFA de uma conta de staff (#48). +/// +/// `dart run bin/issue_staff_activation_code.dart MATRICULA --issued-by QUEM [--ttl-hours 24]` +/// +/// Ferramenta de OPERAÇÃO: roda com acesso ao banco (as mesmas variáveis +/// `SERVERPOD_DATABASE_*` do servidor) e entrega o código fora de banda a quem +/// vai ativar a conta. O código aparece UMA vez, na saída; o banco guarda só o +/// hash. A emissão fica em `staff_accounts.activationCodeIssuedBy/At`. +/// +/// Saída 2: argumento ausente, matrícula inexistente ou conta inativa (sem +/// distinguir os dois últimos, e sem imprimir código nenhum). +Future main(List args) async { + String? matricula; + String? issuedBy; + var ttlHours = StaffActivationCode.defaultValidity.inHours; + for (var i = 0; i < args.length; i++) { + switch (args[i]) { + case '--issued-by': + issuedBy = i + 1 < args.length ? args[++i] : null; + case '--ttl-hours': + ttlHours = int.tryParse(i + 1 < args.length ? args[++i] : '') ?? 0; + default: + matricula ??= args[i]; + } + } + if (matricula == null || issuedBy == null || issuedBy.trim().isEmpty || ttlHours <= 0) { + stderr.writeln('uso: dart run bin/issue_staff_activation_code.dart --issued-by [--ttl-hours 24]'); + exit(2); + } + + final env = Platform.environment; + final conexao = await pg.Connection.open( + pg.Endpoint( + host: env['SERVERPOD_DATABASE_HOST'] ?? 'localhost', + port: int.parse(env['SERVERPOD_DATABASE_PORT'] ?? '5432'), + database: env['SERVERPOD_DATABASE_NAME'] ?? 'sinalacs_db', + username: env['SERVERPOD_DATABASE_USER'] ?? 'sinalacs_user', + password: env['SERVERPOD_DATABASE_PASSWORD'], + ), + settings: pg.ConnectionSettings( + sslMode: env['SERVERPOD_DATABASE_REQUIRE_SSL'] == 'true' ? pg.SslMode.require : pg.SslMode.disable, + ), + ); + try { + final r = await issueStaffActivationCode( + conexao, + matricula: matricula, + issuedBy: issuedBy, + validity: Duration(hours: ttlHours), + ); + if (r.status != IssueStatus.issued) { + stderr.writeln('Matrícula inexistente ou conta inativa. Nenhum código foi emitido.'); + exit(2); + } + stdout.writeln('Código de ativação (mostrado uma única vez): ${r.code}'); + stdout.writeln('Vale até ${r.expiresAt!.toIso8601String()} e só pode ser usado uma vez.'); + } finally { + await conexao.close(); + } +} diff --git a/backend/sinalacs_server/bin/main.dart b/backend/sinalacs_server/bin/main.dart index 6ce4809b..794684c1 100644 --- a/backend/sinalacs_server/bin/main.dart +++ b/backend/sinalacs_server/bin/main.dart @@ -1,7 +1,22 @@ +import 'dart:async'; +import 'dart:io'; + import 'package:sinalacs_server/server.dart'; +import 'package:sinalacs_server/src/runtime/uncaught_error_policy.dart'; -/// This is the starting point for your Serverpod server. Typically, there is -/// no need to modify this file. +/// This is the starting point for your Serverpod server. +/// +/// `run` roda numa zona protegida (#56, defesa em profundidade): um erro +/// assíncrono sem dono não derruba mais o processo com um `Unhandled exception` +/// opaco. Erro transitório de E/S é registrado e o processo segue; qualquer +/// outro é registrado (só o tipo) e encerra com código != 0 para a política de +/// restart do Docker assumir. Não substitui a zona do dispatcher MQTT. void main(List args) { - run(args); + runZonedGuarded( + () => run(args), + buildUncaughtErrorHandler( + log: (m) => stderr.writeln('[processo] $m'), + exitFn: exit, + ), + ); } diff --git a/backend/sinalacs_server/bin/seed_acs_credentials.dart b/backend/sinalacs_server/bin/seed_acs_credentials.dart index 24e4569c..f3294ac3 100644 --- a/backend/sinalacs_server/bin/seed_acs_credentials.dart +++ b/backend/sinalacs_server/bin/seed_acs_credentials.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:postgres/postgres.dart'; +import 'package:sinalacs_server/src/application/auth/password_hasher.dart'; import 'package:sinalacs_server/src/infrastructure/crypto/argon2_password_hasher.dart'; /// Terceira metade do seed de desenvolvimento: a credencial institucional. @@ -35,13 +36,28 @@ Future main(List args) async { exit(2); } + final adminPassword = env['DEV_ADMIN_PASSWORD']; + if (adminPassword == null || adminPassword.isEmpty) { + stderr.writeln( + 'DEV_ADMIN_PASSWORD não definida. Acrescente-a ao .env (por exemplo ' + '`echo "DEV_ADMIN_PASSWORD=\$(openssl rand -hex 32)" >> .env`) ou rode ' + './scripts/dev/bootstrap_env.sh numa máquina nova.', + ); + exit(2); + } + // UUID do ACS semeado. `development.sql` cria a linha de `users` e a de `acs` // com ESTE mesmo UUID fixo, e é a de `acs` que carrega // `enrollmentId = 'ACS-001'` — o ACS do dev-login. `user_credentials.userId` // tem FK para `users`, então um UUID que não exista ali falha alto em vez de // gravar uma credencial órfã que ninguém consegue usar. const acsId = '00000000-0000-4000-8000-000000000002'; - final digest = await const Argon2PasswordHasher().derive(password); + // UUID do admin do backoffice (`users` role admin + `staff_accounts` + // ADM-001, criados por `development.sql`). Mesma FK: id inexistente falha alto. + const adminId = '00000000-0000-4000-8000-000000000090'; + const hasher = Argon2PasswordHasher(); + final digest = await hasher.derive(password); + final adminDigest = await hasher.derive(adminPassword); final connection = await Connection.open( Endpoint( @@ -59,7 +75,7 @@ Future main(List args) async { ); try { - await connection.execute( + Future grava(String userId, PasswordDigest d) => connection.execute( Sql.named( 'INSERT INTO "user_credentials" ' ' ("userId", "passwordHash", "passwordSalt", "memoryKb", ' @@ -72,17 +88,24 @@ Future main(List args) async { ' "lockedUntil" = NULL, "updatedAt" = NOW()', ), parameters: { - 'userId': acsId, - 'hash': digest.hashBase64, - 'salt': digest.saltBase64, - 'memoryKb': digest.memoryKb, - 'iterations': digest.iterations, - 'parallelism': digest.parallelism, + 'userId': userId, + 'hash': d.hashBase64, + 'salt': d.saltBase64, + 'memoryKb': d.memoryKb, + 'iterations': d.iterations, + 'parallelism': d.parallelism, }, ); + + await grava(acsId, digest); stdout.writeln( 'Credencial de desenvolvimento do ACS ${acsId.substring(0, 8)}… gravada.', ); + await grava(adminId, adminDigest); + stdout.writeln( + 'Credencial de desenvolvimento do admin ${adminId.substring(0, 8)}… ' + 'gravada.', + ); } finally { await connection.close(); } diff --git a/backend/sinalacs_server/bin/seed_e2e_fixtures.dart b/backend/sinalacs_server/bin/seed_e2e_fixtures.dart new file mode 100644 index 00000000..e0af9059 --- /dev/null +++ b/backend/sinalacs_server/bin/seed_e2e_fixtures.dart @@ -0,0 +1,419 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; + +import 'package:postgres/postgres.dart'; +import 'package:sinalacs_server/src/application/audit/audit_chain.dart'; +import 'package:sinalacs_server/src/application/auth/cpf.dart'; +import 'package:sinalacs_server/src/config/app_config.dart'; +import 'package:sinalacs_server/src/infrastructure/crypto/argon2_password_hasher.dart'; +import 'package:sinalacs_server/src/infrastructure/crypto/encrypted_json.dart'; +import 'package:sinalacs_server/src/infrastructure/crypto/health_data_cipher.dart'; +import 'package:sinalacs_server/src/infrastructure/crypto/hmac_cpf_hasher.dart'; +import 'package:sinalacs_server/src/infrastructure/crypto/rotating_ip_hasher.dart'; +import 'package:sinalacs_server/src/infrastructure/testing/e2e_fixtures.dart'; +import 'package:sinalacs_server/src/application/auth/staff_activation_code.dart'; + +/// Seed da stack de e2e (`docker-compose.e2e.yml`): UBS, microáreas, dois ACS com +/// credencial e pacientes SINTÉTICOS com UUIDs e CPFs novos a cada execução. +/// Escreve o manifesto (`.e2e/fixtures.json`, modo 600) que os testes leem — +/// nenhum identificador é fixo no código. +/// +/// Só escreve no banco `sinalacs_e2e`: nem o de desenvolvimento nem o +/// `sinalacs_test` do `dart test`. CPF, senha e token nunca vão para a saída. +Future main(List args) async { + final env = Platform.environment; + final refusal = e2eSeedRefusal(env); + if (refusal != null) { + stderr.writeln('Recusando rodar: $refusal'); + exit(2); + } + final password = env['SERVERPOD_DATABASE_PASSWORD']!; + + final config = AppConfig.fromEnvironment(); + // A MESMA chave e o MESMO pepper que o servidor usa (vêm do mesmo .env). + final cipher = HealthDataCipher(keyHex: config.healthDataEncryptionKey, keyVersion: 1); + final hasher = HmacCpfHasher(pepper: config.cpfHashPepper); + + final fixtures = generateE2eFixtures(Random.secure()); + final acsDigest = await const Argon2PasswordHasher().derive(fixtures.acs.password); + final staffDigest = await const Argon2PasswordHasher().derive(fixtures.staff.password); + final coordinatorDigest = await const Argon2PasswordHasher().derive(fixtures.coordinator.password); + final secondAcsDigest = await const Argon2PasswordHasher().derive(fixtures.secondAcs.password); + + final connection = await Connection.open( + Endpoint( + host: env['SERVERPOD_DATABASE_HOST'] ?? 'localhost', + port: int.parse(env['SERVERPOD_DATABASE_PORT'] ?? '5432'), + database: 'sinalacs_e2e', + username: env['SERVERPOD_DATABASE_USER'] ?? 'postgres', + password: password, + ), + settings: const ConnectionSettings(sslMode: SslMode.disable), + ); + + Map? trilha; + try { + await connection.runTx((tx) async { + await tx.execute( + Sql.named('INSERT INTO "ubs" ("id","name","address","city","state","contactPhone") ' + "VALUES (@id, 'UBS E2E', 'Endereço de teste', 'São Paulo', 'SP', '+55 11 5550-0199')"), + parameters: {'id': fixtures.ubsId}, + ); + for (final entry in {fixtures.microAreaId: 'Microárea E2E', fixtures.otherMicroAreaId: 'Outra Microárea E2E'}.entries) { + await tx.execute( + Sql.named('INSERT INTO "micro_areas" ("id","name","ubsId","geoJsonBoundary") VALUES (@id,@name,@ubs,\'{}\')'), + parameters: {'id': entry.key, 'name': entry.value, 'ubs': fixtures.ubsId}, + ); + } + for (final p in fixtures.patients) { + final cpf = Cpf.tryParse(p.cpf)!; + await tx.execute( + Sql.named('INSERT INTO "users" ("id","cpfHash","name","birthDate","role","microAreaId","createdAt","updatedAt") ' + "VALUES (@id,@hash,@name,@birth::date,'patient',@area,NOW(),NOW())"), + parameters: {'id': p.id, 'hash': hasher.hash(cpf), 'name': p.name, 'birth': p.birthDate, 'area': p.microAreaId}, + ); + final conditions = p.chronic ? ['hipertensão'] : []; + final encrypted = await cipher.encryptJson(conditions); + final encryptedContact = await cipher.encryptJson('Contato E2E'); + await tx.execute( + Sql.named('INSERT INTO "patients" ("id","emergencyContactEncrypted","emergencyContactKeyVersion","isChronic","chronicConditionsEncrypted","chronicConditionsKeyVersion") ' + 'VALUES (@id,@contactCipher,@contactVer,@chronic,@cipher,@ver)'), + parameters: { + 'id': p.id, + 'contactCipher': encryptedContact.ciphertextBase64, + 'contactVer': encryptedContact.keyVersion, + 'chronic': p.chronic, + 'cipher': encrypted.ciphertextBase64, + 'ver': encrypted.keyVersion, + }, + ); + } + // Os ACS entram por matrícula e senha (RF07), nunca por CPF: o cpfHash é + // só um valor único para satisfazer o NOT NULL, e não é um CPF. Os dois + // ficam na MESMA microárea (fila por dono, envio diferido). + for (final (acs, digest, name) in [ + (fixtures.acs, acsDigest, 'ACS E2E'), + (fixtures.secondAcs, secondAcsDigest, 'ACS E2E B'), + ]) { + await tx.execute( + Sql.named('INSERT INTO "users" ("id","cpfHash","name","birthDate","role","microAreaId","createdAt","updatedAt") ' + "VALUES (@id,@hash,@name,'1985-01-01','acs',@area,NOW(),NOW())"), + parameters: {'id': acs.id, 'hash': 'e2e-acs-${acs.id}', 'name': name, 'area': fixtures.microAreaId}, + ); + await tx.execute( + Sql.named('INSERT INTO "acs" ("id","enrollmentId","ubsId","active") VALUES (@id,@matricula,@ubs,true)'), + parameters: {'id': acs.id, 'matricula': acs.matricula, 'ubs': fixtures.ubsId}, + ); + await tx.execute( + Sql.named('INSERT INTO "user_credentials" ("userId","passwordHash","passwordSalt","memoryKb","iterations","parallelism","failedAttempts","createdAt","updatedAt") ' + 'VALUES (@userId,@hash,@salt,@memoryKb,@iterations,@parallelism,0,NOW(),NOW())'), + parameters: { + 'userId': acs.id, + 'hash': digest.hashBase64, + 'salt': digest.saltBase64, + 'memoryKb': digest.memoryKb, + 'iterations': digest.iterations, + 'parallelism': digest.parallelism, + }, + ); + } + // Administrador do backoffice (RF07 para o staff): `users` com papel `admin`, + // `staff_accounts` e credencial Argon2id, SEM TOTP (a MFA é ativada pela + // tela do app admin). Fora de qualquer microárea. + final staff = fixtures.staff; + await tx.execute( + Sql.named('INSERT INTO "users" ("id","cpfHash","name","birthDate","role","microAreaId","createdAt","updatedAt") ' + "VALUES (@id,@hash,'Admin E2E','1985-01-01','admin',NULL,NOW(),NOW())"), + parameters: {'id': staff.id, 'hash': 'e2e-staff-${staff.id}'}, + ); + await tx.execute( + Sql.named('INSERT INTO "staff_accounts" ("id","enrollmentId","active") VALUES (@id,@matricula,true)'), + parameters: {'id': staff.id, 'matricula': staff.matricula}, + ); + // Painel do admin (#40): 3 alertas determinísticos na microárea das fixtures, + // para o e2e provar contagens e TMRAV reais. O reconhecimento vem 60 s depois + // do disparo, então o TMRAV esperado é exatamente 60 s. + // + // OPT-IN (`E2E_SEED_ADMIN_ALERTS=1`, ligado só por `admin_login_e2e.sh`): o + // seed é compartilhado e estes alertas são do paciente `main`, então semeá-los + // sempre alteraria o histórico que as jornadas do paciente e do ACS conferem. + if (env['E2E_SEED_ADMIN_ALERTS'] == '1') { + final paciente = fixtures.byRole('main'); + Future alerta(String id, String risco, String status, {int? reconhecidoEmSegundos}) => tx.execute( + Sql.named('INSERT INTO "alerts" ("id","patientId","acsId","microAreaId","triggeredAt","acknowledgedAt",' + '"riskLevel","locationHash","status","mqttTopic","deviceId","retryCount","version") ' + "VALUES (@id,@patient,@acs,@ma,NOW() - interval '1 hour'," + "CASE WHEN @ack::int IS NULL THEN NULL ELSE NOW() - interval '1 hour' + (@ack::int * interval '1 second') END," + "@risco,'hash-e2e',@status,@topic,'device-e2e',0,0)"), + parameters: { + 'id': id, + 'patient': paciente.id, + 'acs': fixtures.acs.id, + 'ma': fixtures.microAreaId, + 'ack': reconhecidoEmSegundos, + 'risco': risco, + 'status': status, + 'topic': 'sinalacs/v1/microareas/${fixtures.microAreaId}/alerts', + }, + ); + await alerta(fixtures.adminAlertIds[0], 'red', 'pending'); + await alerta(fixtures.adminAlertIds[1], 'red', 'acknowledged', reconhecidoEmSegundos: 60); + await alerta(fixtures.adminAlertIds[2], 'green', 'pending'); + } + // Escalonamento (#44), OPT-IN (`E2E_SEED_ALERT_ESCALATION=1`, só por + // `admin_alertas_escalonar_e2e.sh`): um vermelho pendente do paciente + // `main`, na microárea das fixtures (UBS do coordenador), disparado há + // `overdueAge` — já vencido com o prazo de 30 s da stack de e2e. O + // segundo alerta (`freshId`) NÃO nasce aqui: o script o insere no meio do + // teste (ver `E2eEscalationAlerts`). + if (env['E2E_SEED_ALERT_ESCALATION'] == '1') { + await tx.execute( + Sql.named('INSERT INTO "alerts" ("id","patientId","acsId","microAreaId","triggeredAt",' + '"riskLevel","locationHash","status","mqttTopic","deviceId","retryCount","version") ' + "VALUES (@id,@patient,@acs,@ma,NOW() - (@idade::int * interval '1 second')," + "'red','hash-e2e','pending',@topic,'device-e2e',0,0)"), + parameters: { + 'id': fixtures.escalationAlerts.overdueId, + 'patient': fixtures.byRole('main').id, + 'acs': fixtures.acs.id, + 'ma': fixtures.microAreaId, + 'idade': E2eEscalationAlerts.overdueAge.inSeconds, + 'topic': 'sinalacs/v1/microareas/${fixtures.microAreaId}/alerts', + }, + ); + } + // Pedidos do titular (#42), OPT-IN (`E2E_SEED_DATA_REQUESTS=1`, só por + // `admin_titular_e2e.sh`): uma correção JÁ VENCIDA (paciente `main`) e uma + // exclusão no prazo (paciente `chronic`, com um token de push para provar a + // limpeza). Os textos são sintéticos e o e2e procura por eles no log. + if (env['E2E_SEED_DATA_REQUESTS'] == '1') { + Future pedido(String userId, String tipo, String texto, {required int criadoHaDias, required int prazoEmDias}) async { + final cifrado = await cipher.encryptJson(texto); + await tx.execute( + Sql.named('INSERT INTO "data_subject_requests" ("userId","requestType","detailsEncrypted","detailsKeyVersion",' + '"status","createdAt","dueAt") VALUES (@u,@t,@c,@v,\'open\',' + "NOW() - (@criado::int * interval '1 day'), NOW() + (@prazo::int * interval '1 day'))"), + parameters: {'u': userId, 't': tipo, 'c': cifrado.ciphertextBase64, 'v': cifrado.keyVersion, 'criado': criadoHaDias, 'prazo': prazoEmDias}, + ); + } + await pedido(fixtures.byRole('main').id, 'correction', 'E2E-CORRECAO telefone de contato desatualizado', + criadoHaDias: 20, prazoEmDias: -5); + await pedido(fixtures.byRole('chronic').id, 'deletion', 'E2E-EXCLUSAO pedido do titular', + criadoHaDias: 1, prazoEmDias: 14); + await tx.execute( + Sql.named('INSERT INTO "push_tokens" ("userId","token","platform","createdAt","updatedAt") ' + "VALUES (@u,@tok,'android',NOW(),NOW())"), + parameters: {'u': fixtures.byRole('chronic').id, 'tok': 'e2e-push-${fixtures.byRole('chronic').id}'}, + ); + } + // Código de ativação (#48): só o hash vai ao banco; o claro segue no manifesto. + await tx.execute( + Sql.named('UPDATE "staff_accounts" SET "activationCodeHash"=@h, "activationCodeExpiresAt"=@e, ' + '"activationCodeIssuedBy"=\'e2e\', "activationCodeIssuedAt"=NOW() WHERE "id"=@id'), + parameters: { + 'id': staff.id, + 'h': StaffActivationCode.hash(staff.activationCode), + 'e': DateTime.now().toUtc().add(StaffActivationCode.defaultValidity), + }, + ); + await tx.execute( + Sql.named('INSERT INTO "user_credentials" ("userId","passwordHash","passwordSalt","memoryKb","iterations","parallelism","failedAttempts","createdAt","updatedAt") ' + 'VALUES (@userId,@hash,@salt,@memoryKb,@iterations,@parallelism,0,NOW(),NOW())'), + parameters: { + 'userId': staff.id, + 'hash': staffDigest.hashBase64, + 'salt': staffDigest.saltBase64, + 'memoryKb': staffDigest.memoryKb, + 'iterations': staffDigest.iterations, + 'parallelism': staffDigest.parallelism, + }, + ); + // Coordenador do backoffice (issue #43): o MESMO arranjo do administrador + // — `users` + `staff_accounts` + código de ativação + credencial Argon2id, + // sem TOTP —, com duas diferenças: o papel é `coordinator` e o + // `staff_accounts."ubsId"` está preenchido com a UBS destas fixtures. É o + // que dá escopo ao papel no e2e: o coordenador vê os ACS da UBS (as duas + // microáreas são dela) e não vê a equipe do backoffice. + final coordinator = fixtures.coordinator; + await tx.execute( + Sql.named('INSERT INTO "users" ("id","cpfHash","name","birthDate","role","microAreaId","createdAt","updatedAt") ' + "VALUES (@id,@hash,'Coordenador E2E','1985-01-01','coordinator',NULL,NOW(),NOW())"), + parameters: {'id': coordinator.id, 'hash': 'e2e-coordinator-${coordinator.id}'}, + ); + await tx.execute( + Sql.named('INSERT INTO "staff_accounts" ("id","enrollmentId","active","ubsId") VALUES (@id,@matricula,true,@ubs)'), + parameters: {'id': coordinator.id, 'matricula': coordinator.matricula, 'ubs': fixtures.ubsId}, + ); + await tx.execute( + Sql.named('UPDATE "staff_accounts" SET "activationCodeHash"=@h, "activationCodeExpiresAt"=@e, ' + '"activationCodeIssuedBy"=\'e2e\', "activationCodeIssuedAt"=NOW() WHERE "id"=@id'), + parameters: { + 'id': coordinator.id, + 'h': StaffActivationCode.hash(coordinator.activationCode), + 'e': DateTime.now().toUtc().add(StaffActivationCode.defaultValidity), + }, + ); + await tx.execute( + Sql.named('INSERT INTO "user_credentials" ("userId","passwordHash","passwordSalt","memoryKb","iterations","parallelism","failedAttempts","createdAt","updatedAt") ' + 'VALUES (@userId,@hash,@salt,@memoryKb,@iterations,@parallelism,0,NOW(),NOW())'), + parameters: { + 'userId': coordinator.id, + 'hash': coordinatorDigest.hashBase64, + 'salt': coordinatorDigest.saltBase64, + 'memoryKb': coordinatorDigest.memoryKb, + 'iterations': coordinatorDigest.iterations, + 'parallelism': coordinatorDigest.parallelism, + }, + ); + // Trilha de auditoria (#45), OPT-IN (`E2E_SEED_AUDIT_TRAIL=1`, só por + // `admin_auditoria_e2e.sh`): ver [_semearTrilha]. + if (env['E2E_SEED_AUDIT_TRAIL'] == '1') { + trilha = await _semearTrilha(tx, fixtures, config.auditChainSecret); + } + }); + } finally { + await connection.close(); + } + + final out = File(args.isNotEmpty ? args.first : '.e2e/fixtures.json'); + final manifesto = fixtures.toJson(); + if (trilha != null) manifesto['auditTrail'] = trilha; + writeManifestPrivately(out, jsonEncode(manifesto)); + stdout.writeln('Fixtures de e2e gravadas (${fixtures.patients.length} pacientes, 2 ACS, 1 admin, 1 coordenador) ' + 'em ${out.path}.'); +} + +/// Trilha de auditoria sintética do e2e da #45 (`admin_auditoria_e2e.sh`). +/// +/// [_linhasDaTrilha] linhas encadeadas a partir da gênese — o banco de e2e +/// nasce vazio e nada grava `audit_logs` antes do seed —, com a MESMA +/// [AuditChain] e o MESMO [RotatingIpHasher] que o `OrmAuditTrail` usa (e o +/// mesmo `AUDIT_CHAIN_SECRET` do `.env` que o servidor lê). Não passa pelo +/// `OrmAuditTrail` porque ele exige uma `Session` do Serverpod e carimba +/// `DateTime.now()`: a jornada precisa de linhas no passado (filtro de +/// período) e de duas com o MESMO `timestamp` (ordem estável por `sequence`). +/// +/// Desenho (ver o teste `apps/admin/integration_test/admin_auditoria_e2e.dart`): +/// * autores em rodízio: administrador, coordenador e o ACS das fixtures +/// (o único com microárea: é o que o coordenador enxerga); +/// * linhas 1–15 com 40 a 33 dias de idade (fora do período do teste, de 30 +/// dias até ontem) e 16–60 com 28 a ~2,3 dias (dentro): margem de dias para +/// as duas pontas do período, que a tela define em minutos; +/// * as linhas 30 e 31 com o mesmo `timestamp`; +/// * uma única `escalated:no_ack_timeout` (alvo da busca); +/// * a linha [_sequenciaAdulterada] é a que o script adultera no meio do teste. +/// +/// Timestamps em segundos inteiros, gravados como texto sem fuso (a coluna é +/// `timestamp without time zone` em UTC, como o Serverpod grava): o +/// `toIso8601String` do HMAC é o mesmo que o verificador recalcula ao ler. +/// +/// Devolve o bloco `auditTrail` do manifesto, com as contagens que o teste +/// confere — derivadas das linhas aqui, nunca duplicadas à mão no teste. +Future> _semearTrilha(TxSession tx, E2eFixtures fixtures, String segredo) async { + final chain = AuditChain(secret: segredo); + final ipHasher = RotatingIpHasher(secret: segredo); + final random = Random.secure(); + final agora = DateTime.now().toUtc(); + final base = DateTime.utc(agora.year, agora.month, agora.day, agora.hour, agora.minute, agora.second); + + final autores = [ + (id: fixtures.staff.id, papel: 'admin'), + (id: fixtures.coordinator.id, papel: 'coordinator'), + (id: fixtures.acs.id, papel: 'acs'), + ]; + const leituras = ['patient_record', 'admin_alerts', 'visit', 'admin_micro_areas']; + + var previous = AuditChain.genesisHash; + DateTime? anterior; + var exportacoes = 0, noPeriodo = 0, doAcs = 0, escalonadas = 0; + int? primeiroNoPeriodo, ultimoNoPeriodo; + for (var seq = 1; seq <= _linhasDaTrilha; seq++) { + final autor = autores[seq % 3]; + final idade = seq <= 15 + ? const Duration(days: 40) - Duration(hours: 12 * (seq - 1)) + : const Duration(days: 28) - Duration(hours: 14 * (seq - 16)); + // Linhas 30 e 31: o mesmo instante (empate de timestamp). + final timestamp = seq == 31 ? anterior! : base.subtract(idade); + anterior = timestamp; + + final String acao, recurso, resultado; + String? resourceId; + if (seq == 37) { + // A única linha que a busca "escalated" encontra. + (acao, recurso, resultado) = ('write', 'admin_alert', 'escalated:no_ack_timeout'); + resourceId = generateUuidV4(random); + } else if (autor.papel == 'admin' && seq % 2 == 0) { + (acao, recurso, resultado) = ('export', 'admin_audit_logs', 'success'); + resourceId = generateUuidV4(random); + } else if (seq % 7 == 0) { + (acao, recurso, resultado) = ('read', leituras[seq % leituras.length], 'denied'); + } else if (seq % 5 == 0) { + (acao, recurso, resultado) = ('login', autor.papel == 'acs' ? 'session' : 'staff_session', 'success'); + } else if (autor.papel == 'acs' && seq % 2 == 0) { + (acao, recurso, resultado) = ('write', 'visit', 'created'); + resourceId = generateUuidV4(random); + } else { + (acao, recurso, resultado) = ('read', leituras[seq % leituras.length], 'success'); + if (recurso == 'patient_record') resourceId = generateUuidV4(random); + } + + final ipHash = ipHasher.hash('e2e-seed-$seq'); + final entryHash = chain.computeEntryHash(AuditChainFields( + sequence: seq, + previousHash: previous, + userId: autor.id, + actionType: acao, + resourceType: recurso, + resourceId: resourceId, + timestamp: timestamp, + ipHash: ipHash, + result: resultado, + )); + await tx.execute( + Sql.named('INSERT INTO "audit_logs" ("userId","actionType","resourceType","resourceId","timestamp","ipHash",' + '"result","sequence","previousHash","entryHash") VALUES (@u,@a,@r,@rid::uuid,@ts::timestamp,@ip,@res,@seq,@prev,@hash)'), + parameters: { + 'u': autor.id, + 'a': acao, + 'r': recurso, + 'rid': resourceId, + // Sem o `Z`: a coluna não tem fuso e guarda o instante UTC. + 'ts': timestamp.toIso8601String().replaceFirst('Z', ''), + 'ip': ipHash, + 'res': resultado, + 'seq': seq, + 'prev': previous, + 'hash': entryHash, + }, + ); + previous = entryHash; + + if (acao == 'export') exportacoes++; + if (autor.papel == 'acs') doAcs++; + if (resultado == 'escalated:no_ack_timeout') escalonadas++; + if (seq >= 16) { + noPeriodo++; + primeiroNoPeriodo ??= seq; + ultimoNoPeriodo = seq; + } + } + return { + 'rows': _linhasDaTrilha, + 'exportRows': exportacoes, + 'acsRows': doAcs, + 'escalatedRows': escalonadas, + 'periodRows': noPeriodo, + 'periodFirstSequence': primeiroNoPeriodo, + 'periodLastSequence': ultimoNoPeriodo, + 'sameTimestampSequences': [30, 31], + 'tamperSequence': _sequenciaAdulterada, + }; +} + +/// Mais que uma página da tela (50): "Carregar mais" tem o que trazer mesmo +/// antes das linhas que o próprio teste gera. +const _linhasDaTrilha = 60; + +/// Dentro do período do teste e de autoria do ACS (visível ao coordenador). +const _sequenciaAdulterada = 50; diff --git a/backend/sinalacs_server/bin/seed_health_data.dart b/backend/sinalacs_server/bin/seed_health_data.dart index ae54d1bd..5af58748 100644 --- a/backend/sinalacs_server/bin/seed_health_data.dart +++ b/backend/sinalacs_server/bin/seed_health_data.dart @@ -5,11 +5,12 @@ import 'package:sinalacs_server/src/config/app_config.dart'; import 'package:sinalacs_server/src/infrastructure/crypto/encrypted_json.dart'; import 'package:sinalacs_server/src/infrastructure/crypto/health_data_cipher.dart'; -/// Segunda metade do seed de desenvolvimento: os campos clínicos CIFRADOS. +/// Segunda metade do seed de desenvolvimento: os campos CIFRADOS de `patients`. /// /// `seeds/development.sql` é SQL puro, executado por `psql` fora do processo /// Dart, e por isso não consegue chamar [HealthDataCipher] — -/// `patients.chronicConditionsEncrypted` é AES-256-GCM, não uma expressão que +/// `patients.chronicConditionsEncrypted` e +/// `patients.emergencyContactEncrypted` são AES-256-GCM, não expressões que /// o Postgres saiba montar. As duas alternativas eram colar um ciphertext /// literal no `.sql` (que quebra silenciosamente a cada mudança de algoritmo /// ou de chave) ou mover só esses campos para um passo Dart pós-boot. Este @@ -29,6 +30,18 @@ const _chronicConditionsByPatient = >{ '00000000-0000-4000-8000-000000000009': [], }; +/// Contato de emergência sintético por paciente (PII de terceiro — cifrado +/// pelo mesmo motivo de chronicConditions, RNF03/INV-04). Mesmos UUIDs do +/// `development.sql`: o `.sql` grava ciphertext vazio e este script completa. +const _emergencyContactByPatient = { + '00000000-0000-4000-8000-000000000001': 'Contato de desenvolvimento', + '00000000-0000-4000-8000-000000000005': 'Contato de desenvolvimento', + '00000000-0000-4000-8000-000000000006': 'Contato de desenvolvimento', + '00000000-0000-4000-8000-000000000007': 'Contato de desenvolvimento', + '00000000-0000-4000-8000-000000000008': 'Contato de desenvolvimento', + '00000000-0000-4000-8000-000000000009': 'Contato de desenvolvimento', +}; + Future main(List args) async { final env = Platform.environment; final config = AppConfig.fromEnvironment(); @@ -57,10 +70,13 @@ Future main(List args) async { ); try { - var atualizados = 0; + // Conjunto de pacientes tocados (não contador de linhas): dois laços + // separados de propósito — condições crônicas e contato de emergência + // podem evoluir independentemente no futuro. + final atualizados = {}; for (final entry in _chronicConditionsByPatient.entries) { final encrypted = await cipher.encryptJson(entry.value); - final result = await connection.execute( + await connection.execute( Sql.named( 'UPDATE "patients" ' ' SET "chronicConditionsEncrypted" = @ciphertext, ' @@ -73,11 +89,28 @@ Future main(List args) async { 'id': entry.key, }, ); - atualizados += result.affectedRows; + atualizados.add(entry.key); + } + for (final entry in _emergencyContactByPatient.entries) { + final encrypted = await cipher.encryptJson(entry.value); + await connection.execute( + Sql.named( + 'UPDATE "patients" ' + ' SET "emergencyContactEncrypted" = @ciphertext, ' + ' "emergencyContactKeyVersion" = @keyVersion ' + ' WHERE "id" = @id', + ), + parameters: { + 'ciphertext': encrypted.ciphertextBase64, + 'keyVersion': encrypted.keyVersion, + 'id': entry.key, + }, + ); + atualizados.add(entry.key); } stdout.writeln( - 'Seed de dados clínicos cifrados: $atualizados linha(s) de patients ' - 'atualizada(s).', + 'Seed de campos cifrados de patients: ${atualizados.length} paciente(s) ' + 'atualizado(s).', ); } finally { await connection.close(); diff --git a/backend/sinalacs_server/dart_test.yaml b/backend/sinalacs_server/dart_test.yaml index 2b60c7dd..c8020275 100644 --- a/backend/sinalacs_server/dart_test.yaml +++ b/backend/sinalacs_server/dart_test.yaml @@ -1,2 +1,3 @@ tags: integration: {} + resiliencia: {} diff --git a/backend/sinalacs_server/lib/server.dart b/backend/sinalacs_server/lib/server.dart index 07105b1f..1c3f2627 100644 --- a/backend/sinalacs_server/lib/server.dart +++ b/backend/sinalacs_server/lib/server.dart @@ -3,10 +3,15 @@ import 'dart:io'; import 'package:serverpod/serverpod.dart'; +import 'src/application/alerts/red_alert_service.dart'; import 'src/application/auth/development_auth_service.dart'; +import 'src/application/notices/notice_scheduling_service.dart'; +import 'src/application/retention/expurge_future_call.dart'; +import 'src/application/retention/expurge_service.dart'; import 'src/generated/endpoints.dart'; import 'src/generated/protocol.dart'; import 'src/runtime/alert_runtime.dart'; +import 'src/runtime/notice_dispatch_loop.dart'; import 'src/web/routes/app_config_route.dart'; import 'src/web/routes/root.dart'; @@ -74,6 +79,14 @@ void run(List args) async { // Rede de segurança do outbox: publica o que ficou para trás quando o broker // estava fora, ou quando o processo caiu entre o commit e a publicação. _startOutboxSweeper(pod); + + // Disparo dos avisos comunitários agendados (RF14, #46). + _startNoticeDispatchLoop(pod); + + // Rotina de retenção (LGPD-RF07): registra o FutureCall de expurgo e agenda + // a primeira janela diária (03:00 UTC). Depois do `start()`: o + // FutureCallManager só existe a partir daí. + _registerRetentionExpurge(pod); } /// Intervalo da varredura do outbox. @@ -91,15 +104,80 @@ void _startOutboxSweeper(Serverpod pod) { if (published > 0) { session.log('Outbox: $published entrega(s) publicada(s) na varredura.'); } - } catch (error, stackTrace) { + } catch (error) { // A varredura nunca pode morrer: é o que garante a entrega eventual. - stderr.writeln('Falha na varredura do outbox: $error\n$stackTrace'); + // Só o tipo do erro: `$error`/`$stackTrace` brutos podem carregar + // atributos de alerta num aggregator de log (VAZ-01). + stderr.writeln('Falha na varredura do outbox (${error.runtimeType}).'); } finally { await session.close(); } }); } +/// Laço de disparo dos avisos agendados. Na partida, avisos presos em `sending` +/// viram `unknown` (nunca são reenviados). Um erro é registrado só pelo tipo +/// (o aviso carrega texto digitado pelo staff) e o laço segue. +void _startNoticeDispatchLoop(Serverpod pod) { + Future withService(Future Function(NoticeSchedulingService) body) async { + final session = await pod.createSession(enableLogging: false); + try { + return await body( + AlertRuntime.instance.noticeSchedulingServiceFor(session), + ); + } finally { + await session.close(); + } + } + + final loop = NoticeDispatchLoop( + startup: () async { + final moved = await withService((s) => s.recoverStuckSending()); + if (moved > 0) { + stderr.writeln('Avisos: $moved aviso(s) em envio na queda viraram "unknown".'); + } + }, + tick: () => withService((s) => s.dispatchDue()), + onError: (error) => + stderr.writeln('Falha no disparo de avisos (${error.runtimeType}).'), + )..start(); + for (final signal in [ProcessSignal.sigterm, ProcessSignal.sigint]) { + try { + signal.watch().listen((_) => loop.stop()); + } on Object { + // Plataforma sem esse sinal: o processo encerra os timers ao sair. + } + } +} + +/// Registra o FutureCall de expurgo (LGPD-RF07) e garante exatamente um +/// agendamento pendente da próxima janela diária. +void _registerRetentionExpurge(Serverpod pod) { + final retentionConfig = AlertRuntime.instance.config; + pod.registerFutureCall( + ExpurgeFutureCall( + policy: RetentionPolicy( + alertsDays: retentionConfig.retentionAlertDays, + visitsDays: retentionConfig.retentionVisitDays, + triageSessionsDays: retentionConfig.retentionTriageDays, + ), + ), + retentionExpurgeFutureCallName, + ); + unawaited(_ensureRetentionExpurgeScheduled(pod)); +} + +/// Idempotente: [ensureRetentionExpurgeScheduled] remove pendentes com o +/// identifier fixo antes de inserir, então restarts não empilham janelas. +Future _ensureRetentionExpurgeScheduled(Serverpod pod) async { + final session = await pod.createSession(enableLogging: false); + try { + await ensureRetentionExpurgeScheduled(session); + } finally { + await session.close(); + } +} + /// Liga o dispatcher MQTT e encaminha os ACKs recebidos para o serviço de /// alerta. /// @@ -117,7 +195,7 @@ Future _connectAlertDispatcher(Serverpod pod) async { id: ack.acsId, role: UserRole.acs, microAreaId: ack.microAreaId, - deviceId: 'mqtt', + deviceId: RedAlertService.mqttAckDeviceId, ), alertId: ack.alertId, ); diff --git a/backend/sinalacs_server/lib/src/application/admin/admin_account_service.dart b/backend/sinalacs_server/lib/src/application/admin/admin_account_service.dart new file mode 100644 index 00000000..691725e6 --- /dev/null +++ b/backend/sinalacs_server/lib/src/application/admin/admin_account_service.dart @@ -0,0 +1,617 @@ +import 'dart:math'; + +import 'package:sinalacs_server/src/application/admin/admin_scope.dart'; +import 'package:sinalacs_server/src/application/admin/initial_password.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/auth/institutional_auth_service.dart'; +import 'package:sinalacs_server/src/application/auth/password_hasher.dart'; +import 'package:sinalacs_server/src/application/auth/staff_activation_code.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; + +// A `AdminScope`/`AdminScopeStore` vêm com o serviço: quem implementa o store +// (ou o falsifica no teste) não precisa de um segundo import para falar a +// língua do escopo — mesmo arranjo de `admin_read_service.dart`. +export 'package:sinalacs_server/src/application/admin/admin_scope.dart'; + +/// Contas do backoffice sobre o banco (issue #43). Interface à parte do ORM, no +/// espírito de `AdminReadStore`: o serviço e seus testes não conhecem SQL. +/// +/// Estende a porta estreita do [AdminScopeResolver] (`ubsOf`) porque é ela que +/// resolve o papel e o escopo de toda operação — a leitura da UBS do +/// coordenador é a mesma consulta para os dois serviços do backoffice. +abstract interface class AdminAccountStore implements AdminScopeStore { + /// ACS do escopo: o administrador vê os do sistema inteiro, o coordenador só + /// os da própria UBS. Inclui quem ainda não tem microárea. + Future> acsList(AdminScope scope); + + /// Contas de equipe — lista do sistema inteiro, só para o administrador. + Future> staffList(); + + /// ACS do escopo, ou `null` para "não existe" **e** para "não é do escopo": + /// a mesma resposta, para não revelar território alheio. + Future acsById(AdminScope scope, String acsId); + + /// Conta de equipe, ou `null` se não existir. + Future staffById(String staffId); + + /// A microárea [microAreaId] com a UBS dona dela, ou `null` se não existir. + /// + /// A UBS vem junto porque o escopo do coordenador é decidido comparando-a + /// com `staff_accounts.ubsId`: devolvesse só a microárea e o serviço faria + /// uma segunda consulta pela UBS — e uma decisão de território feita sobre + /// duas leituras é uma decisão sobre dois alvos diferentes. + Future<({String? ubsId, String? name})?> microAreaFor(String microAreaId); + + /// `true` se já existe um ACS com a matrícula [enrollmentId]. + /// + /// É a pré-checagem do cadastro, para a resposta limpa do caso comum. Quem + /// fecha a corrida é o índice único de `acs."enrollmentId"`, tratado em + /// [insertAcs] — entre esta consulta e o INSERT cabe outro cadastro. + Future enrollmentIdTaken(String enrollmentId); + + /// Cria `users` (papel `acs`), `acs` e `user_credentials` numa **única + /// transação**, com [digest] já derivado do lado de fora (o serviço é quem + /// sorteia a senha e nunca a guarda). + /// + /// Devolve o ACS criado — já no formato da listagem — ou `null` quando o + /// índice único da matrícula dispara, ou seja, quando outro cadastro passou + /// entre a pré-checagem e este INSERT. Nada fica gravado nesse caso: as três + /// linhas são da mesma transação. + Future insertAcs({ + required String name, + required String enrollmentId, + required String microAreaId, + required String ubsId, + required PasswordDigest digest, + required DateTime at, + }); + + /// Move o ACS [acsId] para a microárea [microAreaId], gravando + /// `users."microAreaId"` e `acs."ubsId"` (o de [ubsId], derivado da + /// microárea-alvo pelo serviço) numa **única transação**. + /// + /// O predicado de [scope] vai **dentro** do `WHERE` do `UPDATE`: um ACS que + /// saia do escopo entre a pré-checagem do serviço e esta escrita não é movido + /// — a checagem e a escrita não podem ser duas decisões sobre estados + /// diferentes (TOCTOU). `null` significa exatamente isso: nada foi movido, + /// seja porque o ACS não existe, seja porque não é do escopo. + Future setAcsMicroArea({ + required String acsId, + required String microAreaId, + required String ubsId, + required AdminScope scope, + required DateTime at, + }); + + /// Liga/desliga o acesso do ACS [acsId] numa **única transação**, com o + /// predicado de [scope] dentro do `WHERE` (mesma defesa de [setAcsMicroArea]). + /// + /// Desativar revoga, **na mesma transação**, todas as famílias de refresh + /// token e todos os tokens de envio diferido da conta, com [at] como carimbo + /// da revogação: a flag e as revogações são um só estado, e não existe + /// instante observável em que o ACS esteja inativo e ainda com sessão viva + /// (o `findAccount` do refresh só recusaria na próxima renovação). Reativar + /// não toca em token nenhum — revogado é revogado, e o acesso novo se obtém + /// com um login novo. + /// + /// Devolve `true` quando o ACS existe no escopo e a flag foi gravada — o + /// valor anterior não importa (desativar um já inativo também grava). `false` + /// significa que nada foi escrito: o ACS não existe, está fora do escopo ou + /// o id não é UUID. + Future setAcsActive({ + required String acsId, + required AdminScope scope, + required bool active, + required DateTime at, + }); + + /// Zera a MFA da conta de equipe [staffId] **e** emite o código de ativação + /// novo numa **única transação**, devolvendo `true` quando alguma linha foi + /// alcançada. + /// + /// As duas escritas são um só estado: a conta que perdeu a MFA sem código + /// nenhum fica trancada fora (o login do staff **sempre** exige MFA, e sem + /// segredo gravado `beginStaffTotpEnrollment` não tem como ativar), e um + /// código emitido sobre uma MFA ainda viva seria um segundo caminho de + /// ativação concorrente com o autenticador que a pessoa já tem. Ou as duas + /// valem, ou nenhuma aconteceu. + /// + /// [codeHash] já chega derivado ([StaffActivationCode.hash]) e [issuedBy] é o + /// UUID do ator — este store não vê o código em claro nem conhece quem o + /// sorteou. + /// + /// `false` = nada foi escrito: o id não é UUID, não existe linha em + /// `staff_accounts` ou a conta está inativa. A checagem é a da própria + /// transação (existência **e** `active` na leitura de dentro dela), e não a + /// pré-checagem do serviço: entre uma e outra cabe a desativação de outra + /// requisição, e um código emitido para uma conta desativada seria uma + /// credencial de ativação viva para quem não pode entrar. + Future resetStaffMfa({ + required String staffId, + required String codeHash, + required DateTime expiresAt, + required String issuedBy, + required DateTime at, + }); +} + +/// Gestão de contas do backoffice (issue #43): listagem de ACS e da equipe, +/// cadastro, vínculo de microárea, ativação/desativação e redefinição de +/// senha/MFA. +/// +/// Papel e escopo vêm do [AdminScopeResolver] — a regra única do backoffice +/// (PRD §4.2.2): só `coordinator` e `admin`; o coordenador enxerga e opera +/// **apenas** a própria UBS (`staff_accounts.ubsId`), fail-closed sem UBS; o +/// administrador, o sistema inteiro. Toda recusa é a mesma (`acesso restrito ao +/// backoffice` / `não encontrado`), sem distinguir "não existe" de "não é seu". +/// +/// **Auditoria fail-closed**, sempre com `AuditTrail.record` — nunca +/// `recordSafely`: se a linha não grava, a operação não pode seguir em silêncio. +/// +/// - **Recusa:** a linha `denied` entra na trilha **antes** de a exceção subir. +/// É o que [AdminScopeResolver] faz para papel e escopo, e o que [_negar] faz +/// para um alvo fora do escopo ou uma entrada inválida. +/// - **Leitura:** audita `read`/`success` **antes** de consultar o store — o +/// dado nunca sai sem a linha. +/// - **Escrita:** recusa audita antes de lançar; sucesso audita **depois** do +/// commit, com o id do alvo em `resourceId`. +class AdminAccountService { + AdminAccountService({ + required this.store, + required this.credentials, + required this.totpStore, + required this.activationStore, + required this.hasher, + required this.audit, + Random? random, + DateTime Function()? clock, + }) : _random = random ?? Random.secure(), + _clock = clock ?? DateTime.now; + + final AdminAccountStore store; + + /// Credencial de login do ACS (RF07): senha inicial e redefinição. + final AcsCredentialStore credentials; + + /// Estado da MFA (`user_credentials.totp*`), do ACS e do staff. + final TotpStore totpStore; + + /// Código de ativação de uso único da MFA do staff (#48). + final StaffActivationStore activationStore; + + final PasswordHasher hasher; + final AuditTrail audit; + + /// Sorteio das credenciais entregues fora de banda (senha inicial, código de + /// ativação). + final Random _random; + + /// Relógio dos carimbos de tempo. + final DateTime Function() _clock; + + /// A regra única de papel/escopo, sobre o [store] (que também é a porta da + /// UBS do coordenador). + late final AdminScopeResolver _resolver = AdminScopeResolver( + store: store, + audit: audit, + ); + + Future> acsList(AuthenticatedUser user) async { + const recurso = 'admin_acs'; + final escopo = await _resolver.resolve(user, recurso: recurso); + await _auditarLeitura(user, recurso); + return store.acsList(escopo); + } + + Future> staffList(AuthenticatedUser user) async { + const recurso = 'admin_staff'; + await _resolver.requireAdmin(user, recurso: recurso); + await _auditarLeitura(user, recurso); + return store.staffList(); + } + + /// Cadastra um ACS na UBS da microárea escolhida e devolve a senha inicial + /// gerada — que existe **só** nesta resposta (ver `AcsInitialPassword`). + /// + /// A UBS **nunca** vem do pedido: sai da microárea, que é o único dado que o + /// escopo do coordenador pode comparar. Microárea inexistente e microárea de + /// outra UBS recebem a **mesma** mensagem; separá-las diria ao coordenador, + /// por tentativa, quais microáreas existem fora da UBS dele. + /// + /// A ordem é deliberada: papel/escopo (recusa auditada), entrada, alvo e + /// matrícula — e só então o sorteio da senha, porque derivar o Argon2id é + /// caro e não deve acontecer para um pedido que já vai ser recusado. A + /// corrida que passa pela pré-checagem da matrícula é fechada pelo índice + /// único do banco e chega aqui como `insertAcs` devolvendo `null`: mesmo + /// desfecho, mesma mensagem, nunca um erro de servidor. + Future createAcs( + AuthenticatedUser user, { + required String name, + required String enrollmentId, + required String microAreaId, + }) async { + const recurso = 'admin_acs'; + final escopo = await _resolver.resolve( + user, + recurso: recurso, + actionType: 'write', + ); + + final nome = name.trim(); + final matricula = enrollmentId.trim(); + final ma = await store.microAreaFor(microAreaId); + final ubsDaMicroarea = ma?.ubsId; + + if (nome.isEmpty || + nome.length > 120 || + matricula.isEmpty || + matricula.length > 32) { + await _negar(user, recurso, message: 'Informe nome e matrícula do ACS.'); + } + if (ubsDaMicroarea == null || + (escopo.ubsId != null && ubsDaMicroarea != escopo.ubsId)) { + await _negar(user, recurso, message: 'Microárea não encontrada.'); + } + if (await store.enrollmentIdTaken(matricula)) { + await _negar( + user, + recurso, + message: 'Já existe um ACS com esta matrícula.', + ); + } + + final senha = AcsInitialPassword.generate(_random); + final digest = await hasher.derive(senha); + final acs = await store.insertAcs( + name: nome, + enrollmentId: matricula, + microAreaId: microAreaId, + ubsId: ubsDaMicroarea, + digest: digest, + at: _clock().toUtc(), + ); + if (acs == null) { + await _negar( + user, + recurso, + message: 'Já existe um ACS com esta matrícula.', + ); + } + await _auditar(user, recurso, result: 'created', resourceId: acs.id); + return AdminAcsCreationResult(acs: acs, initialPassword: senha); + } + + /// Move um ACS para outra microárea e devolve a linha já com o território + /// novo. + /// + /// As validações são as da criação, pela mesma razão: microárea inexistente e + /// microárea de outra UBS recebem a **mesma** mensagem (senão o coordenador + /// descobre, por tentativa, quais microáreas existem fora da UBS dele), e o + /// ACS inexistente e o ACS de outra UBS recebem a **mesma** recusa — um ACS + /// fora do escopo não existe para quem perguntou. + /// + /// A ordem é: papel/escopo (recusa auditada) → microárea-alvo → ACS-alvo → + /// escrita. A UBS do vínculo sai da microárea-alvo, nunca do chamador: para o + /// administrador o escopo é nulo, e um `acs."ubsId"` nulo seria um ACS sem + /// UBS nenhuma. + /// + /// As duas pré-checagens são para a resposta limpa do caso comum; quem fecha + /// a corrida é o `WHERE` do store, que repete o escopo dentro da própria + /// escrita e devolve `null` se o ACS saiu do escopo nesse meio-tempo — mesmo + /// desfecho, mesma mensagem, nada movido. A releitura com [AdminAccountStore.acsById] + /// depois do commit é o que garante que a linha devolvida é a do banco, e não + /// a que o `UPDATE` achou que gravou. + Future setAcsMicroArea( + AuthenticatedUser user, { + required String acsId, + required String microAreaId, + }) async { + const recurso = 'admin_acs'; + final escopo = await _resolver.resolve( + user, + recurso: recurso, + actionType: 'write', + ); + + final ma = await store.microAreaFor(microAreaId); + final ubsDaMicroarea = ma?.ubsId; + if (ubsDaMicroarea == null || + (escopo.ubsId != null && ubsDaMicroarea != escopo.ubsId)) { + await _negar(user, recurso, message: 'Microárea não encontrada.'); + } + if (await store.acsById(escopo, acsId) == null) { + await _negar(user, recurso, message: 'ACS não encontrado.'); + } + + final movido = await store.setAcsMicroArea( + acsId: acsId, + microAreaId: microAreaId, + ubsId: ubsDaMicroarea, + scope: escopo, + at: _clock().toUtc(), + ); + if (movido == null) { + await _negar(user, recurso, message: 'ACS não encontrado.'); + } + // A leitura é preferida à devolvida pelo `UPDATE`; o `??` só cobre o caso + // extremo de o ACS ter saído do escopo entre o commit e esta releitura — + // aí a linha do `UPDATE` ainda descreve o vínculo que de fato aconteceu. + final acs = await store.acsById(escopo, acsId) ?? movido; + await _auditar( + user, + recurso, + result: 'micro_area_changed', + resourceId: acs.id, + ); + return acs; + } + + /// Ativa ou desativa o ACS, com a revogação das sessões na desativação. + /// + /// A flag e as revogações são da **mesma** transação, do lado do store: o + /// ACS desativado perde a sessão na hora, e não na próxima renovação — o + /// `findAccount` do refresh só recusaria lá, e a desativação precisa valer + /// agora. A reativação **não** ressuscita token nenhum: o acesso novo se + /// obtém com um login novo (senha + TOTP). + /// + /// Desativar um ACS já inativo (e reativar um já ativo) é idempotente: a + /// operação conclui e audita uma linha nova — o que a trilha registra é o + /// pedido do operador, e um pedido repetido é um fato novo. + /// + /// As recusas são as do vínculo, pela mesma razão: ACS inexistente e ACS de + /// outra UBS recebem a **mesma** mensagem — um ACS fora do escopo não existe + /// para quem perguntou. + Future setAcsActive( + AuthenticatedUser user, { + required String acsId, + required bool active, + }) async { + const recurso = 'admin_acs'; + final escopo = await _resolver.resolve( + user, + recurso: recurso, + actionType: 'write', + ); + + final antes = await store.acsById(escopo, acsId); + if (antes == null) { + await _negar(user, recurso, message: 'ACS não encontrado.'); + } + + final mudou = await store.setAcsActive( + acsId: acsId, + scope: escopo, + active: active, + at: _clock().toUtc(), + ); + if (!mudou) { + await _negar(user, recurso, message: 'ACS não encontrado.'); + } + + // A releitura é a do banco; o `??` só cobre o caso extremo de o ACS ter + // saído do escopo entre o commit e esta leitura — aí a linha da + // pré-checagem, com a flag que a transação gravou, ainda descreve o estado. + final acs = await store.acsById(escopo, acsId) ?? antes.copyWith(active: active); + await _auditar( + user, + recurso, + result: active ? 'activated' : 'deactivated', + resourceId: acs.id, + ); + return acs; + } + + /// Redefine a senha do ACS e devolve a nova, que existe **só** nesta + /// resposta — mesma regra da senha inicial: o servidor guarda apenas o hash + /// Argon2id e não há como recuperá-la depois (ver `AcsInitialPassword`). + /// + /// A senha **nunca** vem do operador: é sorteada pelo servidor, com o mesmo + /// formato da inicial, pela razão registrada lá (uma senha escolhida por + /// quem tem pressa vira a credencial do RF07). O caminho da escrita é o + /// `saveCredential` que já existia — redefinir é substituir a credencial —, + /// e é ele que zera o estado de bloqueio: a senha nova não tem relação com + /// as tentativas da antiga, e uma conta bloqueada que continuasse bloqueada + /// depois do reset deixaria o ACS fora até `lockedUntil` vencer, com uma + /// senha que ele nem conhece. + /// + /// As sessões **em curso não são revogadas** (decisão D8 do plano): o JWT e + /// o refresh token existentes seguem valendo até expirar. Quem precisa cortar + /// o acesso na hora é a desativação ([setAcsActive]). + /// + /// A ordem é a da criação: papel/escopo (recusa auditada), alvo, e só então o + /// sorteio — derivar o Argon2id é caro e não deve acontecer para um pedido que + /// já vai ser recusado. + Future resetAcsPassword( + AuthenticatedUser user, { + required String acsId, + }) async { + const recurso = 'admin_acs'; + final escopo = await _resolver.resolve( + user, + recurso: recurso, + actionType: 'write', + ); + + final acs = await store.acsById(escopo, acsId); + if (acs == null) { + await _negar(user, recurso, message: 'ACS não encontrado.'); + } + + final senha = AcsInitialPassword.generate(_random); + final digest = await hasher.derive(senha); + await credentials.saveCredential(acs.id, digest, _clock().toUtc()); + await _auditar( + user, + recurso, + result: 'password_reset', + resourceId: acs.id, + ); + return AdminPasswordResetResult(newPassword: senha); + } + + /// Redefine a MFA do ACS: apaga o segredo e o estado da ativação, para ele + /// ativar de novo pela tela. + /// + /// É o caminho que faltava para a recusa de `beginTotpEnrollment` ("Peça a + /// redefinição à coordenação"): com as quatro colunas `totp*` zeradas, a + /// entrada seguinte pede a ativação outra vez, e o aplicativo autenticador + /// antigo deixa de valer — o segredo dele não existe mais em lugar nenhum. + /// + /// A senha **não** é tocada: a MFA é o segundo fator e redefini-la não muda o + /// primeiro. O estado de bloqueio também fica como está — redefinir a MFA não + /// é desbloquear a conta (é a redefinição de senha que zera o bloqueio). + /// + /// Idempotente: um ACS sem MFA passa pela mesma limpeza (que não acha nada a + /// apagar) e audita `mfa_reset` do mesmo jeito — o que a trilha registra é o + /// pedido do operador, como na (des)ativação. + Future resetAcsMfa( + AuthenticatedUser user, { + required String acsId, + }) async { + const recurso = 'admin_acs'; + final escopo = await _resolver.resolve( + user, + recurso: recurso, + actionType: 'write', + ); + + final acs = await store.acsById(escopo, acsId); + if (acs == null) { + await _negar(user, recurso, message: 'ACS não encontrado.'); + } + + await totpStore.clearTotp(acs.id, _clock().toUtc()); + await _auditar(user, recurso, result: 'mfa_reset', resourceId: acs.id); + } + + /// Redefine a MFA de uma conta de equipe e emite o código de ativação novo, + /// que existe **só** nesta resposta — é o deferimento da #48: até aqui só a + /// CLI de operador emitia, e a emissão não entrava em `audit_logs`. + /// + /// **Só o administrador**, e **nunca a própria conta**: uma conta que + /// redefine a própria MFA não tem segundo fator nenhum no intervalo (o + /// código novo é mostrado na mesma tela em que se acabou de entrar) — quem + /// perdeu o autenticador pede a outro administrador. A recusa da própria + /// conta é do chamador sobre si mesmo, e por isso vem **antes** de o alvo ser + /// lido. + /// + /// Conta inexistente e conta inativa recebem a **mesma** mensagem: separá-las + /// diria quem existe (e está desativado) a quem tentou. A pré-checagem é para + /// a resposta limpa do caso comum; quem decide de verdade é o `WHERE` da + /// transação do store, que repete existência e `active` — um `false` de lá + /// cai na mesma recusa, e nada fica gravado (nem a MFA zerada). + /// + /// A ordem é a das outras operações de escrita: papel (recusa auditada), + /// entrada do chamador, alvo — e só então o sorteio, que é barato, mas não + /// deve acontecer para um pedido que já vai ser recusado. + /// + /// O [AdminStaffMfaResetResult.activationCode] devolvido **não** é guardado em + /// lugar nenhum em claro: a coluna recebe o SHA-256 + /// ([StaffActivationCode.hash]) e a validade é a de + /// [StaffActivationCode.defaultValidity] contada do relógio do serviço. + /// [StaffActivationCode.matches] é o que o login usa para conferir o código + /// que a pessoa digitar. A linha de auditoria registra o **pedido** com o id + /// do alvo (`mfa_reset`), depois do commit: é a primeira vez que uma emissão + /// de código de ativação entra na trilha encadeada. + Future resetStaffMfa( + AuthenticatedUser user, { + required String staffId, + }) async { + const recurso = 'admin_staff'; + await _resolver.requireAdmin(user, recurso: recurso, actionType: 'write'); + + // A comparação é entre ids **canônicos**, não entre strings cruas: a forma + // canônica de um UUID é a minúscula, e tanto o `UuidValue` do Serverpod + // quanto o `::uuid` do Postgres aceitam a mesma conta grafada em + // maiúsculas. Comparando as strings como chegaram, um administrador que + // mandasse o próprio id com uma letra maiúscula passaria pela recusa e + // redefiniria a própria MFA — a propriedade que a decisão 9.4 do plano + // proíbe —, e a trilha guardaria o id torto, como se fosse outro alvo. + // A validação de formato continua sendo do store (um id que não é UUID cai + // na resposta de "não existe", sem chegar ao `::uuid`). + final alvo = staffId.toLowerCase(); + if (alvo == user.id.toLowerCase()) { + await _negar( + user, + recurso, + message: + 'Uma conta não redefine a própria MFA: peça a outro administrador.', + ); + } + + final conta = await store.staffById(alvo); + if (conta == null || !conta.active) { + await _negar(user, recurso, message: 'Conta de equipe não encontrada.'); + } + + final codigo = StaffActivationCode.generate(_random); + final agora = _clock().toUtc(); + final expira = agora.add(StaffActivationCode.defaultValidity); + final gravou = await store.resetStaffMfa( + staffId: alvo, + codeHash: StaffActivationCode.hash(codigo), + expiresAt: expira, + issuedBy: user.id, + at: agora, + ); + if (!gravou) { + await _negar(user, recurso, message: 'Conta de equipe não encontrada.'); + } + await _auditar(user, recurso, result: 'mfa_reset', resourceId: alvo); + return AdminStaffMfaResetResult( + activationCode: codigo, + activationCodeExpiresAt: expira, + ); + } + + /// Leitura bem-sucedida: a linha entra na trilha **antes** de o store ser + /// consultado, mesmo arranjo de `AdminReadService`. + Future _auditarLeitura(AuthenticatedUser user, String recurso) => + audit.record( + AuditEvent( + userId: user.id, + actionType: 'read', + resourceType: recurso, + result: 'success', + ), + ); + + // Os dois helpers abaixo são a interface interna das operações de escrita da + // #43. + + /// Recusa com linha `denied` na trilha ANTES de lançar (fail-closed), a mesma + /// mensagem para "não existe" e "não é seu". [message] só é sobre a própria + /// entrada do operador (validação) — nunca revela existência de outro território. + Future _negar( + AuthenticatedUser user, + String recurso, { + String message = 'Não foi possível concluir a operação com os dados informados.', + }) async { + await audit.record( + AuditEvent( + userId: user.id, + actionType: 'write', + resourceType: recurso, + result: 'denied', + ), + ); + throw AdminInvalidRequestException(message: message); + } + + /// Sucesso: audita DEPOIS do commit, com `record` (não `recordSafely`). + Future _auditar( + AuthenticatedUser user, + String recurso, { + required String result, + required String resourceId, + }) => audit.record( + AuditEvent( + userId: user.id, + actionType: 'write', + resourceType: recurso, + resourceId: resourceId, + result: result, + ), + ); +} diff --git a/backend/sinalacs_server/lib/src/application/admin/admin_labels.dart b/backend/sinalacs_server/lib/src/application/admin/admin_labels.dart new file mode 100644 index 00000000..8a8a05db --- /dev/null +++ b/backend/sinalacs_server/lib/src/application/admin/admin_labels.dart @@ -0,0 +1,32 @@ +/// Rótulos minimizados do backoffice (spec/lgpd_design.md): o admin identifica, +/// não qualifica. Nunca recebe nem devolve nome, CPF ou contato de paciente. +abstract final class AdminLabels { + static String _sufixo(String id) { + final limpo = id.replaceAll('-', ''); + return limpo.length < 4 + ? '????' + : limpo.substring(limpo.length - 4).toUpperCase(); + } + + /// `#` + 4 hex finais do UUID. É rótulo, não identificador: não volta à pessoa. + static String patient(String patientId) => '#${_sufixo(patientId)}'; + + static const _papeis = { + 'admin': 'Administrador', + 'coordinator': 'Coordenador', + 'acs': 'ACS', + 'patient': 'Paciente', + }; + + static String user({ + required String role, + required String id, + String? enrollmentId, + }) { + final papel = _papeis[role] ?? role; + if (role == 'patient') return 'Paciente ${patient(id)}'; + if (enrollmentId != null && enrollmentId.isNotEmpty) + return '$enrollmentId ($papel)'; + return '$papel ${patient(id)}'; + } +} diff --git a/backend/sinalacs_server/lib/src/application/admin/admin_monitoring_service.dart b/backend/sinalacs_server/lib/src/application/admin/admin_monitoring_service.dart new file mode 100644 index 00000000..e0c21c39 --- /dev/null +++ b/backend/sinalacs_server/lib/src/application/admin/admin_monitoring_service.dart @@ -0,0 +1,110 @@ +import 'package:sinalacs_server/src/application/admin/admin_read_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/generated/protocol.dart'; + +/// Monitoração contínua do backoffice (#44, decisão D5 do ADR +/// `docs/superpowers/specs/2026-10-11-adr-tempo-real-alertas-admin.md`). +/// +/// O painel consulta [alertsPulse] a cada 30 s. Uma linha `read` por pulso +/// encheria a trilha com uma linha por coordenador a cada 30 s; por isso a +/// leitura contínua vira uma **sessão**: +/// +/// 1. **Papel e escopo primeiro, sempre:** o [AdminScopeResolver] de todo o +/// backoffice; a recusa é auditada (`denied`) e fail-closed. +/// 2. **O pulso não grava `read`.** Consulta o store direto: só contagens e +/// instantes, sem identidade. +/// 3. **START fail-closed:** sem sessão viva do usuário — nenhum pulso nos +/// [sessionIdle] anteriores; exatamente 90 s ainda é sessão viva — o pulso +/// grava `monitoring_session_start` com `record` ANTES de ler. Se a linha +/// não grava, a exceção sobe, nenhum dado sai e o mapa não muda: o próximo +/// pulso tenta o START de novo. Um pulso que chega depois da janela sem END +/// (sessão órfã) abre outra sessão, visível na trilha. +/// 4. **END best-effort:** [endMonitoringSession] remove a entrada e grava +/// `monitoring_session_end` com `recordSafely`. É a única exceção +/// justificada à regra do projeto de usar `record`: a linha de fim não +/// protege dado nenhum (nada é lido aqui), e o app pode ser morto ou perder +/// a rede antes de chamá-la. Idempotente: sem sessão, não grava nada. +/// +/// O mapa [sessions] (`userId → último pulso`) é estado do **processo**, não +/// da requisição: vem do `AlertRuntime`, porque os endpoints são construídos +/// por requisição. O backend é de instância única (PRD); um reinício só abre +/// sessão nova, na direção conservadora. Dois primeiros pulsos concorrentes do +/// mesmo usuário podem gravar dois START — também na direção conservadora. +class AdminMonitoringService { + AdminMonitoringService({ + required this.store, + required this.audit, + required this.sessions, + required this.threshold, + DateTime Function()? clock, + }) : _clock = clock ?? DateTime.now; + + final AdminReadStore store; + final AuditTrail audit; + + /// `userId → instante do último pulso aceito`, compartilhado pelo processo. + final Map sessions; + + /// Prazo do alerta vermelho (`AppConfig.redAlertEscalationSeconds`): o mesmo + /// de `AdminAlert.overdue` e do escalonamento. + final Duration threshold; + + final DateTime Function() _clock; + + /// Três vezes o intervalo de 30 s do app: tolera dois pulsos perdidos. + static const sessionIdle = Duration(seconds: 90); + + static const _recursoPulso = 'admin_pulse'; + static const _recursoSessao = 'admin_monitoring'; + static const _inicio = 'monitoring_session_start'; + static const _fim = 'monitoring_session_end'; + + late final AdminScopeResolver _resolver = AdminScopeResolver( + store: store, + audit: audit, + ); + + Future alertsPulse(AuthenticatedUser user) async { + final escopo = await _resolver.resolve(user, recurso: _recursoPulso); + final agora = _clock().toUtc(); + + final ultimo = sessions[user.id]; + if (ultimo == null || agora.difference(ultimo) > sessionIdle) { + // Fail-closed: se lançar, o mapa fica como estava e nada é lido. + await audit.record(_evento(user, _inicio)); + } + sessions[user.id] = agora; + + final indicadores = await store.indicators(escopo, now: agora); + final contagens = await store.pulse( + escopo, + now: agora, + threshold: threshold, + ); + return AdminAlertsPulse( + indicators: indicadores, + overdueRed: contagens.overdueRed, + maxChangedAt: contagens.maxChangedAt, + serverNow: agora, + ); + } + + Future endMonitoringSession(AuthenticatedUser user) async { + // A recusa grava `denied` com o actionType do que foi tentado (o fim da + // sessão), fail-closed como em todo o backoffice. + await _resolver.resolve(user, recurso: _recursoSessao, actionType: _fim); + if (sessions.remove(user.id) == null) return; + // Best-effort de propósito (ver o doc da classe): a entrada já saiu do + // mapa, então uma falha aqui só deixa a sessão sem linha de fim — o + // próximo pulso abre outra com START. + await audit.recordSafely(_evento(user, _fim)); + } + + AuditEvent _evento(AuthenticatedUser user, String actionType) => AuditEvent( + userId: user.id, + actionType: actionType, + resourceType: _recursoSessao, + result: 'success', + ); +} diff --git a/backend/sinalacs_server/lib/src/application/admin/admin_read_service.dart b/backend/sinalacs_server/lib/src/application/admin/admin_read_service.dart new file mode 100644 index 00000000..3b51c039 --- /dev/null +++ b/backend/sinalacs_server/lib/src/application/admin/admin_read_service.dart @@ -0,0 +1,279 @@ +import 'package:sinalacs_server/src/application/admin/admin_scope.dart'; +import 'package:sinalacs_server/src/application/audit/audit_chain.dart'; +import 'package:sinalacs_server/src/application/audit/audit_row_sealer.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/generated/protocol.dart'; + +// `AdminScope` e `AdminScopeStore` nasceram aqui e mudaram de casa na #43; o +// `export` mantém o alcance de quem já importava este arquivo. +export 'package:sinalacs_server/src/application/admin/admin_scope.dart'; + +/// O que o pulso da monitoração (#44, D5) lê além dos indicadores: só uma +/// contagem e um instante, nunca um alerta. +typedef AlertsPulseCounts = ({int overdueRed, DateTime? maxChangedAt}); + +/// Uma linha da auditoria como o store a devolve (#45, Task 5): o que a tela +/// vê ([entry], com `integrity` ainda no padrão `unverifiable`) e o que só o +/// servidor vê ([seal]: campos da cadeia e `entryHash` da `sequence - 1`). +/// Interno: não é modelo `.spy.yaml`, não serializa, não sai do servidor. +class AdminAuditRow { + const AdminAuditRow({ + required this.entry, + required this.seal, + this.actorRole, + this.actorEnrollmentId, + }); + + final AdminAuditEntry entry; + final AuditSealInput seal; + + /// Papel do autor (`users.role`), nulo se o autor não existe mais. Só para + /// a exportação (#45, Task 10: colunas `autor`/`papel_autor` de D6). + final String? actorRole; + + /// Matrícula do autor (ACS ou staff), nula para paciente. Só para a + /// exportação; a tela continua com o `userLabel` pronto. + final String? actorEnrollmentId; +} + +/// Página do store da auditoria, por keyset (mesmo contrato de +/// [AdminAuditPage]), com as entradas de selo internas. +class AdminAuditRowPage { + const AdminAuditRowPage({required this.items, this.nextBeforeSequence}); + + final List items; + final int? nextBeforeSequence; +} + +/// Leitura do backoffice. Interface à parte do ORM, no espírito de +/// `AlertStore`: o serviço e seus testes não conhecem SQL. +abstract interface class AdminReadStore implements AdminScopeStore { + /// Contagens por risco e TMRAV no escopo. O TMRAV olha os 30 dias anteriores + /// a [now]; sem alerta vermelho reconhecido na janela, `tmravSeconds` é nulo. + Future indicators(AdminScope scope, {required DateTime now}); + + Future> microAreas(AdminScope scope); + + /// Pulso da monitoração (#44, D5), no escopo: `overdueRed` conta os alertas + /// com a MESMA regra de `AdminAlert.overdue` (vermelho, pendente, sem + /// escalonamento, estritamente além de [threshold] em [now]); + /// `maxChangedAt` é o maior instante de mudança (disparo, ACK ou + /// escalonamento) entre os alertas do escopo, nulo se não há alerta. + Future pulse( + AdminScope scope, { + required DateTime now, + required Duration threshold, + }); + + /// Vencidos primeiro, depois mais recentes (`overdue` desc, `triggeredAt` + /// desc, `id` desc: ordem estável). Assim nenhum vermelho vencido fica além + /// da primeira página (#44). `nextOffset` nulo = fim da lista. + /// + /// [now] é o relógio do **servidor**: `AdminAlert.overdue` é calculado com ele + /// (#44), nunca com o do aparelho. Escalonar não tira o alerta da lista nem do + /// filtro `pending` (D3). + Future alerts( + AdminScope scope, { + String? microAreaId, + AlertStatus? status, + required int limit, + required int offset, + required DateTime now, + }); + + /// Mais recentes primeiro, por keyset (`sequence` menor que [beforeSequence]). + /// + /// O escopo não filtra só o recurso auditado: filtra o **autor** da linha — o + /// coordenador só vê linhas de atores com microárea na UBS dele; linhas de + /// staff (sem microárea) não aparecem para ele. + /// + /// [filter] (#45) já chega validado pelo serviço e só **restringe** o + /// escopo, nunca o alarga: nulo = sem filtro. + /// + /// Cada linha traz também o [AuditSealInput] (#45, Task 5): os campos da + /// cadeia e o `entryHash` da linha `sequence - 1` lido do banco (nulo se + /// ela não existe) — o vizinho da cadeia, não o da página. + Future auditLogs( + AdminScope scope, { + AdminAuditFilter? filter, + required int limit, + int? beforeSequence, + }); +} + +/// Regras de leitura do backoffice (issue #40). Papel e escopo vêm do +/// [AdminScopeResolver] — a regra única do backoffice (issue #43): só +/// `coordinator` e `admin` leem; `acs` e `patient` são recusados mesmo com token +/// válido, e a recusa é auditada. O administrador vê o sistema; o coordenador, a +/// UBS de `staff_accounts.ubsId` — coordenador sem UBS é recusado (fail-closed), +/// em **todos** os métodos. +/// +/// 1. **Auditoria antes do dado:** cada leitura grava uma linha `read` e só então +/// consulta o store. `record` (não `recordSafely`): se a linha não grava, a +/// exceção sobe e nenhum dado sai. +/// 2. **Paginação:** `limit` de 1 a 100, `offset` não negativo; fora disso, +/// [AdminInvalidRequestException] (não é erro de permissão). +/// +/// A auditoria de logs segue o mesmo escopo, com a junção `users` × +/// `micro_areas` que a #40 havia adiado: o coordenador lê as linhas dos atores +/// da UBS dele; as de staff (sem microárea) só o administrador lê. +class AdminReadService { + AdminReadService({ + required this.store, + required this.audit, + required AuditChain auditChain, + DateTime Function()? clock, + }) : _sealer = AuditRowSealer(auditChain), + _clock = clock ?? DateTime.now; + + final AdminReadStore store; + final AuditTrail audit; + + /// Sela cada linha da auditoria com a MESMA [AuditChain] que grava a trilha. + final AuditRowSealer _sealer; + final DateTime Function() _clock; + + /// A regra única de papel/escopo, sobre o [store] (que também é a porta da + /// UBS do coordenador). + late final AdminScopeResolver _resolver = AdminScopeResolver( + store: store, + audit: audit, + ); + + static const maxLimit = 100; + static const _paginacaoInvalida = 'Parâmetro de paginação inválido.'; + + Future indicators(AuthenticatedUser user) async { + final escopo = await _resolver.resolve(user, recurso: 'admin_indicators'); + await _auditar(user, 'admin_indicators', 'success'); + return store.indicators(escopo, now: _clock().toUtc()); + } + + Future> microAreas(AuthenticatedUser user) async { + final escopo = await _resolver.resolve(user, recurso: 'admin_micro_areas'); + await _auditar(user, 'admin_micro_areas', 'success'); + return store.microAreas(escopo); + } + + Future alerts( + AuthenticatedUser user, { + String? microAreaId, + AlertStatus? status, + int limit = 50, + int offset = 0, + }) async { + final escopo = await _resolver.resolve(user, recurso: 'admin_alerts'); + _validarPagina(limit, offset); + await _auditar(user, 'admin_alerts', 'success'); + return store.alerts( + escopo, + microAreaId: microAreaId, + status: status, + limit: limit, + offset: offset, + now: _clock().toUtc(), + ); + } + + /// Trilha de auditoria no escopo, com [filter] opcional (#45). Papel e + /// escopo primeiro (a recusa é auditada `denied`); depois paginação e + /// filtro — entrada inválida não é acesso, então não grava `success`. + /// + /// Toda linha devolvida sai selada pelo [AuditRowSealer] (D3); o + /// `integrity` de cada uma é sempre reescrito aqui, nunca herdado do store. + Future auditLogs( + AuthenticatedUser user, { + AdminAuditFilter? filter, + int limit = 50, + int? beforeSequence, + }) async { + const recurso = 'admin_audit_logs'; + final escopo = await _resolver.resolve(user, recurso: recurso); + _validarPagina(limit, 0); + validarFiltroDeAuditoria(filter); + await _auditar(user, recurso, 'success'); + final pagina = await store.auditLogs( + escopo, + filter: filter, + limit: limit, + beforeSequence: beforeSequence, + ); + return AdminAuditPage( + items: [ + for (final linha in pagina.items) + linha.entry.copyWith(integrity: _sealer.sealInput(linha.seal)), + ], + nextBeforeSequence: pagina.nextBeforeSequence, + ); + } + + /// Ações que existem em `audit_logs.actionType` (D1). + static const acoesDeAuditoria = {'read', 'write', 'login', 'export'}; + + /// Janela máxima entre `from` e `to` (D1). + static const janelaMaximaDoFiltro = Duration(days: 366); + + static final _rotulo = RegExp(r'^[a-z0-9_:]{1,64}$'); + static const _filtroInvalido = 'Filtro de auditoria inválido.'; + + /// Regras do [AdminAuditFilter] (D1/D2), puras e públicas para a exportação + /// (Task 10) usar a mesma. Lança [AdminInvalidRequestException] com uma + /// mensagem sobre a própria entrada; nunca ecoa o valor recebido. + static void validarFiltroDeAuditoria(AdminAuditFilter? f) { + if (f == null) return; + void recusar(String detalhe) => throw AdminInvalidRequestException( + message: '$_filtroInvalido $detalhe', + ); + + final matricula = f.actorEnrollmentId; + if (matricula != null && (matricula.isEmpty || matricula.length > 32)) { + recusar('A matrícula deve ter de 1 a 32 caracteres.'); + } + final acao = f.actionType; + if (acao != null && !acoesDeAuditoria.contains(acao)) { + recusar('Ação desconhecida.'); + } + final tipo = f.resourceType; + if (tipo != null && !_rotulo.hasMatch(tipo)) { + recusar('Tipo de recurso inválido.'); + } + final resultado = f.result; + if (resultado != null && !_rotulo.hasMatch(resultado)) { + recusar('Resultado inválido.'); + } + final de = f.from; + final ate = f.to; + if (de != null && ate != null) { + if (!de.isBefore(ate)) { + recusar('O início do período deve ser anterior ao fim.'); + } + if (ate.difference(de) > janelaMaximaDoFiltro) { + recusar('O período é de no máximo 366 dias.'); + } + } + final busca = f.search; + if (busca != null && (busca.length < 2 || busca.length > 64)) { + recusar('A busca deve ter de 2 a 64 caracteres.'); + } + } + + void _validarPagina(int limit, int offset) { + if (limit < 1 || limit > maxLimit || offset < 0) { + throw AdminInvalidRequestException(message: _paginacaoInvalida); + } + } + + Future _auditar( + AuthenticatedUser user, + String recurso, + String result, + ) => audit.record( + AuditEvent( + userId: user.id, + actionType: 'read', + resourceType: recurso, + result: result, + ), + ); +} diff --git a/backend/sinalacs_server/lib/src/application/admin/admin_scope.dart b/backend/sinalacs_server/lib/src/application/admin/admin_scope.dart new file mode 100644 index 00000000..b70059d3 --- /dev/null +++ b/backend/sinalacs_server/lib/src/application/admin/admin_scope.dart @@ -0,0 +1,92 @@ +import 'package:sinalacs_server/src/application/audit/audit_trail.dart'; +import 'package:sinalacs_server/src/application/auth/authorization.dart'; +import 'package:sinalacs_server/src/application/auth/development_auth_service.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; + +/// Até onde o chamador enxerga no backoffice (PRD §4.2.2): o administrador vê o +/// sistema inteiro; o coordenador, só a sua UBS. +class AdminScope { + const AdminScope.system() : ubsId = null; + const AdminScope.ubs(String this.ubsId); + + /// `null` = sistema inteiro. + final String? ubsId; +} + +/// A única coisa que o resolvedor precisa saber sobre o mundo: a UBS do +/// coordenador. Porta estreita, cumprida tanto pela leitura (`OrmAdminReadStore`) +/// quanto pela gestão de contas (issue #43). +abstract interface class AdminScopeStore { + /// UBS do coordenador (`staff_accounts.ubsId`), ou `null` se não houver. + Future ubsOf(String staffId); +} + +/// A regra única de papel e escopo do backoffice (PRD §4.2.2), extraída do +/// `AdminReadService` para servir também à gestão de contas (issue #43). +/// +/// 1. **Papel:** só `coordinator` e `admin` ([Authorization.staffRoles]); `acs` e +/// `patient` são recusados mesmo com token válido. +/// 2. **Escopo:** o administrador vê o sistema; o coordenador, a UBS de +/// `staff_accounts.ubsId`. Coordenador sem UBS é recusado (fail-closed). +/// 3. **Recusa auditada, fail-closed:** toda recusa grava uma linha com +/// `result: denied` **antes** de lançar, e com `record` (não `recordSafely`): +/// se a linha não grava, a exceção da trilha sobe no lugar da recusa. +/// +/// O `actionType` da linha de recusa é o do **caminho que chamou** +/// ([actionType], `read` por padrão): uma tentativa de escrita recusada por +/// papel ou escopo grava `write`, porque é isso que a linha descreve — o que a +/// requisição tentou fazer, não o que ela conseguiu. +class AdminScopeResolver { + AdminScopeResolver({required this.store, required this.audit}); + + final AdminScopeStore store; + final AuditTrail audit; + + /// A mesma recusa para todos os papéis e todos os recursos, para não revelar + /// por que o acesso foi barrado. + static const negado = 'acesso restrito ao backoffice'; + + /// O escopo de [user] para [recurso] (usado nas linhas de auditoria), ou a + /// recusa auditada. + Future resolve( + AuthenticatedUser user, { + required String recurso, + String actionType = 'read', + }) async { + if (!Authorization.staffRoles.contains(user.role)) { + return _negar(user, recurso, actionType); + } + if (user.role == UserRole.admin) return const AdminScope.system(); + final ubs = await store.ubsOf(user.id); + if (ubs == null) return _negar(user, recurso, actionType); + return AdminScope.ubs(ubs); + } + + /// Exige o papel `admin`: usado pelo que o coordenador não pode ver nem fazer, + /// mesmo dentro da própria UBS. + Future requireAdmin( + AuthenticatedUser user, { + required String recurso, + String actionType = 'read', + }) async { + if (user.role != UserRole.admin) { + await _negar(user, recurso, actionType); + } + } + + Future _negar( + AuthenticatedUser user, + String recurso, + String actionType, + ) async { + await audit.record( + AuditEvent( + userId: user.id, + actionType: actionType, + resourceType: recurso, + result: 'denied', + ), + ); + throw AlertPermissionException(message: negado); + } +} diff --git a/backend/sinalacs_server/lib/src/application/admin/alert_escalation_service.dart b/backend/sinalacs_server/lib/src/application/admin/alert_escalation_service.dart new file mode 100644 index 00000000..6c224ade --- /dev/null +++ b/backend/sinalacs_server/lib/src/application/admin/alert_escalation_service.dart @@ -0,0 +1,196 @@ +import 'package:sinalacs_server/src/application/admin/admin_scope.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/generated/protocol.dart'; + +export 'package:sinalacs_server/src/application/admin/admin_scope.dart'; + +/// Como terminou a tentativa de escalonar (issue #44). +enum EscalationOutcome { + /// Escalonou agora. + escalated, + + /// Já estava escalonado: nada foi escrito, vale o carimbo original. + alreadyEscalated, + + /// Ainda dentro do prazo (ou `triggeredAt` no futuro): não vence. + notDue, + + /// Já confirmado/resolvido. + notPending, + + /// Alerta amarelo/verde: só o vermelho escala. + notRed, + + /// Não existe **ou** é de outra UBS: a mesma resposta para os dois. + notFound, +} + +/// Desfecho do store: [escalatedAt] vem preenchido em `escalated` e +/// `alreadyEscalated` (neste último, o carimbo ORIGINAL). +class EscalationAttempt { + const EscalationAttempt(this.outcome, {this.escalatedAt}); + + final EscalationOutcome outcome; + final DateTime? escalatedAt; +} + +/// Porta do escalonamento sobre o banco. O serviço não conhece SQL. +abstract interface class AlertEscalationStore implements AdminScopeStore { + /// Decide e escreve numa **única** transação: trava a linha, relê e avalia, + /// nesta ordem fixa de precedência (o primeiro que casar decide): + /// `notFound` (inexistente **ou** fora do escopo, pela UBS da microárea do + /// alerta; alerta sem microárea só existe para o administrador) > `notRed` > + /// `notPending` > `alreadyEscalated` (`escalatedAt` já preenchido) > + /// `notDue`. O serviço não decide sobre estado velho. + /// + /// O prazo é decidido **no store** (em SQL no real), com o [now] e o + /// [threshold] recebidos: vence só se `now − triggeredAt > threshold`, + /// **estritamente** (exatamente no limite ainda não vence); `triggeredAt` no + /// futuro nunca vence. + /// + /// A auditoria é gravada **pelo store**, na mesma transação, a partir de + /// [auditFor], só em `escalated` (depois do `UPDATE` e do reforço no outbox) + /// e em `alreadyEscalated` — esta com o motivo **pedido agora** + /// (`already_escalated:`), sem tocar o alerta nem o outbox, e + /// devolvendo o `escalatedAt` ORIGINAL. Se a linha não grava, nada é + /// escrito. Nas recusas o store não audita: quem audita é o serviço. Nunca + /// altera `AlertStatus`, `acknowledgedAt`, `acsId` nem `version` (o alerta + /// segue pendente). + Future escalate( + AdminScope scope, { + required String alertId, + required String actorId, + required EscalationReason reason, + required DateTime now, + required Duration threshold, + required AuditEvent Function(EscalationOutcome) auditFor, + }); +} + +/// Escalonamento de alerta vermelho sem confirmação (issue #44). +/// +/// Papel e escopo vêm do [AdminScopeResolver] (`write`). Toda recusa é auditada +/// com `record` (nunca `recordSafely`) **antes** de a exceção subir. +class AlertEscalationService { + AlertEscalationService({ + required this.store, + required this.audit, + required this.threshold, + DateTime Function()? clock, + }) : _clock = clock ?? DateTime.now; + + final AlertEscalationStore store; + final AuditTrail audit; + + /// `AppConfig.redAlertEscalationSeconds` como [Duration]. + final Duration threshold; + final DateTime Function() _clock; + + late final AdminScopeResolver _resolver = AdminScopeResolver( + store: store, + audit: audit, + ); + + static const _recurso = 'admin_alert'; + static final _uuid = RegExp( + r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$', + ); + + Future escalate( + AuthenticatedUser user, { + required String alertId, + required EscalationReason reason, + }) async { + final scope = await _resolver.resolve( + user, + recurso: _recurso, + actionType: 'write', + ); + + if (!_uuid.hasMatch(alertId)) { + await _negar( + user, + 'denied_invalid_id', + null, + 'Identificador de alerta inválido.', + ); + } + final id = alertId.toLowerCase(); + + final attempt = await store.escalate( + scope, + alertId: id, + actorId: user.id, + reason: reason, + now: _clock().toUtc(), + threshold: threshold, + auditFor: (outcome) => AuditEvent( + userId: user.id, + actionType: 'write', + resourceType: _recurso, + resourceId: id, + result: + '${outcome == EscalationOutcome.alreadyEscalated ? 'already_escalated' : 'escalated'}:${_snake(reason)}', + ), + ); + + switch (attempt.outcome) { + case EscalationOutcome.escalated: + case EscalationOutcome.alreadyEscalated: + return AdminEscalationResult( + alertId: id, + escalatedAt: attempt.escalatedAt!, + alreadyEscalated: + attempt.outcome == EscalationOutcome.alreadyEscalated, + ); + case EscalationOutcome.notDue: + return _negar( + user, + 'denied_not_due', + id, + 'O prazo para escalonar este alerta ainda não venceu.', + ); + case EscalationOutcome.notPending: + return _negar(user, 'denied_not_pending', id, 'Alerta já confirmado.'); + case EscalationOutcome.notRed: + return _negar( + user, + 'denied_not_red', + id, + 'Só alertas vermelhos podem ser escalonados.', + ); + case EscalationOutcome.notFound: + return _negar( + user, + 'denied_not_found', + id, + 'Alerta não encontrado.', + ); + } + } + + Future _negar( + AuthenticatedUser user, + String result, + String? resourceId, + String message, + ) async { + await audit.record( + AuditEvent( + userId: user.id, + actionType: 'write', + resourceType: _recurso, + resourceId: resourceId, + result: result, + ), + ); + throw AdminInvalidRequestException(message: message); + } + + static String _snake(EscalationReason r) => switch (r) { + EscalationReason.noAckTimeout => 'no_ack_timeout', + EscalationReason.acsUnreachable => 'acs_unreachable', + EscalationReason.patientRecontact => 'patient_recontact', + }; +} diff --git a/backend/sinalacs_server/lib/src/application/admin/audit_chain_status_service.dart b/backend/sinalacs_server/lib/src/application/admin/audit_chain_status_service.dart new file mode 100644 index 00000000..ca31d5a0 --- /dev/null +++ b/backend/sinalacs_server/lib/src/application/admin/audit_chain_status_service.dart @@ -0,0 +1,464 @@ +import 'dart:async'; + +import 'package:sinalacs_server/src/application/admin/admin_scope.dart'; +import 'package:sinalacs_server/src/application/audit/audit_chain.dart'; +import 'package:sinalacs_server/src/application/audit/audit_chain_checkpoint_store.dart'; +import 'package:sinalacs_server/src/application/audit/audit_chain_verifier.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/generated/protocol.dart'; + +/// O que a verificação global precisa saber do mundo além da cadeia: a UBS do +/// coordenador ([AdminScopeStore]) e se uma linha de `audit_logs` pertence a +/// uma UBS — pelo **autor** da linha, a mesma regra de escopo de +/// `admin.auditLogs`. +abstract interface class AuditChainScopeStore implements AdminScopeStore { + /// `true` se a linha de [sequence] existe e o autor dela tem microárea na + /// UBS [ubsId]. Linha inexistente, autor sem microárea (staff) ou de outra + /// UBS: `false`. + Future auditRowInUbs(int sequence, String ubsId); +} + +/// Uma verificação global concluída: o resultado do verificador e o instante +/// em que terminou. Interna; nunca serializa. +class AuditChainSnapshot { + const AuditChainSnapshot({ + required this.verification, + required this.at, + required this.startedAt, + }); + + final AuditChainVerification verification; + final DateTime at; + + /// Quando a verificação começou a ler a cadeia. É o que decide se o + /// retrato serve a quem exige uma verificação posterior a um instante + /// (`currentState(notBefore:)`). + final DateTime startedAt; +} + +/// Estado do **processo** da verificação global (#45, D9): o `Future` em +/// andamento (single-flight) e o último resultado (cache de +/// [AuditChainStatusService.cacheTtl]). Vive no `AlertRuntime`, porque os +/// endpoints são construídos por requisição; o backend é de instância única +/// (PRD), então um por processo basta. +class AuditChainStatusCache { + Future? inFlight; + + /// Início da verificação em [inFlight]. + DateTime? inFlightStartedAt; + AuditChainSnapshot? last; + + void reset() { + inFlight = null; + inFlightStartedAt = null; + last = null; + } +} + +/// `admin.verifyAuditChain` (#45, Task 6; decisões D3 e D9). +/// +/// 1. **Papel e escopo primeiro** ([AdminScopeResolver], recurso +/// `admin_audit_chain`): `acs`/`patient`/coordenador sem UBS são recusados +/// com `denied` auditado, sem ler a cadeia. +/// 2. **Auditoria antes de verificar:** `read`/`admin_audit_chain`/`success` +/// com `record` (fail-closed) — também quando a resposta sai do cache. +/// 3. **Single-flight e cache:** uma verificação em andamento é compartilhada +/// por todas as chamadas do processo; a concluída vale por [cacheTtl] no +/// [AuditChainStatusCache]. Relógio injetável; relógio que volta no tempo +/// invalida o cache. A verificação compartilhada usa o reader da requisição +/// que a iniciou (e que a espera, então a sessão dela segue aberta). +/// 4. **Nada além de cadeia íntegra e não vazia vira `verified`** ([stateOf]): +/// quebra ⇒ `broken`; banco, prazo de leitura, `Error` ou segredo ausente ⇒ +/// `unavailable`; cadeia vazia ou orçamento esgotado ⇒ `inconclusive`. +/// Nenhuma falha vira 500 genérico: tudo que a leitura lança — `Exception` +/// **ou** `Error` — é `unavailable/databaseError`. +/// 5. **Projeção por papel (D9, Review Focus 7):** o administrador recebe o +/// status completo; o coordenador recebe o estado (um `broken` continua +/// `broken`: o painel não pode dizer íntegra), mas `firstBrokenSequence` e +/// `reasonCode` da quebra só se a linha quebrada for de um autor da UBS +/// dele, e nunca `checkedRows`/`headSequence` (volume do sistema). +/// +/// O texto livre `reason` do verificador não sai daqui: o cliente recebe só +/// o `reasonCode` fechado. Segredo trocado aparece como `broken` já na +/// `sequence` 1 (Review Focus 3): "falha", não necessariamente adulteração. +/// +/// 6. **Checkpoint de cauda (Task 7, D4):** o verificador sozinho não vê +/// linhas apagadas do **fim** da cadeia (a cadeia mais curta continua +/// contígua e encadeada). Depois de uma cadeia `ok`, [_ancorarCauda] lê os +/// [checkpointWindow] checkpoints mais recentes e confere a assinatura de +/// cada um com a MESMA [AuditChain] da escrita +/// ([AuditChain.checkpointMatches]). O mais recente **válido** é a âncora: +/// se a linha daquela `sequence` não existe mais, ou existe com outro +/// `entryHash` (cauda apagada e a aplicação regravou por cima), o +/// resultado é `broken/tailTruncated`. Checkpoint com assinatura inválida +/// é **ignorado** como âncora e **sinalizado**: sem truncamento provado +/// pela âncora válida, o resultado é `inconclusive/checkpointInvalid`, +/// nunca `verified` — e a linha não é apagada (é evidência). Falha ao ler +/// checkpoints é `unavailable/databaseError`: sem âncora conferida, não há +/// `verified`. +/// +/// Só um resultado final `verified` grava checkpoint novo +/// (`headSequence`, `headEntryHash`), e só se avança: a âncora lida já em +/// `sequence >= head` não regrava, e o store recusa atomicamente qualquer +/// `sequence` menor ou igual à maior gravada (corrida de duas +/// verificações no mesmo `sequence` termina sem erro). Gravar é melhor +/// esforço: falha (Exception ou Error) **não** muda o `verified` — a +/// cadeia foi conferida — nem derruba a resposta, mas também não some: +/// vai para [onCheckpointWriteFailure] (log de servidor, sem hash). +/// +/// **Limites conhecidos (registrados em Pendências do plano):** restaurar +/// o banco inteiro de um backup mais antigo — cadeia **e** checkpoints +/// juntos — passa como íntegro, porque os dois voltam coerentes entre si; +/// e quem tem escrita no banco e apaga a cauda também pode apagar os +/// checkpoints (sem forjar um novo, que exige o segredo). O checkpoint +/// reduz o risco do truncamento, não o elimina. +class AuditChainStatusService { + AuditChainStatusService({ + required this.store, + required this.audit, + required AuditChainReader reader, + required String secret, + required AuditChainCheckpointStore checkpoints, + required this.cache, + this.onCheckpointWriteFailure, + this.budget = defaultBudget, + this.readTimeout = defaultReadTimeout, + DateTime Function()? clock, + }) : _reader = reader, + _secret = secret, + _checkpoints = checkpoints, + _clock = clock ?? DateTime.now; + + final AuditChainScopeStore store; + final AuditTrail audit; + final AuditChainStatusCache cache; + + /// Orçamento da verificação (D9), conferido entre lotes pelo verificador. + final Duration budget; + + /// Prazo de cada leitura de lote: uma consulta que não volta vira + /// `unavailable/databaseError`, não uma requisição presa. + final Duration readTimeout; + + /// Chamado quando gravar o checkpoint falha. Recebe o erro; nunca o + /// hash. Se ele próprio lançar, é engolido: a resposta não cai por log. + final void Function(Object error)? onCheckpointWriteFailure; + + final AuditChainReader _reader; + final String _secret; + final AuditChainCheckpointStore _checkpoints; + final DateTime Function() _clock; + + /// Quantos checkpoints mais recentes são conferidos a cada verificação. + /// Um inválido entre eles já basta para `inconclusive`; o válido mais novo + /// entre eles é a âncora. + static const checkpointWindow = 100; + + static const defaultBudget = Duration(seconds: 10); + static const defaultReadTimeout = Duration(seconds: 10); + static const cacheTtl = Duration(seconds: 60); + static const recurso = 'admin_audit_chain'; + + late final AdminScopeResolver _resolver = AdminScopeResolver( + store: store, + audit: audit, + ); + + Future verify(AuthenticatedUser user) async { + final escopo = await _resolver.resolve(user, recurso: recurso); + await audit.record( + AuditEvent( + userId: user.id, + actionType: 'read', + resourceType: recurso, + result: 'success', + ), + ); + final retrato = await _resultadoGlobal(); + return _projetar(retrato, escopo); + } + + /// Só o estado global, **sem** papel, escopo nem linha de auditoria + /// própria: para quem já autorizou e audita o ato maior — a exportação + /// (#45, Task 10), que grava o estado em `audit_exports.chainState`. + /// + /// **Fresco:** só serve um retrato de verificação **iniciada** em + /// [notBefore] ou depois. O cache de 60 s e um single-flight iniciado + /// antes são ignorados (uma adulteração feita entre a verificação em cache + /// e a exportação não pode sair como `verified`); a verificação nova é a + /// mesma de [verify] — single-flight para quem vier depois, orçamento, + /// prazos, `Exception`/`Error` ⇒ `unavailable` — e atualiza o cache. Nunca + /// lança (ver [_verificarCadeia]). + Future currentState({ + required DateTime notBefore, + }) async => stateOf( + (await _resultadoGlobal(notBefore: notBefore.toUtc())).verification, + ); + + /// Estado de integridade de um resultado do verificador (D3). Público e + /// puro para o teste de propriedade: só `ok` com pelo menos uma linha é + /// `verified`. + static AuditIntegrityState stateOf(AuditChainVerification v) { + if (v.ok) { + return v.checked > 0 + ? AuditIntegrityState.verified + : AuditIntegrityState.inconclusive; + } + return switch (v.reasonCode) { + AuditChainReason.hashMismatch || + AuditChainReason.linkBroken || + AuditChainReason.sequenceGap || + AuditChainReason.tailTruncated => AuditIntegrityState.broken, + AuditChainReason.timeBudget || + AuditChainReason.checkpointInvalid => AuditIntegrityState.inconclusive, + AuditChainReason.databaseError || + AuditChainReason.secretUnavailable || + null => AuditIntegrityState.unavailable, + }; + } + + /// [notBefore] (UTC) exige retrato de verificação iniciada nesse instante + /// ou depois; nulo = qualquer um dentro do [cacheTtl]. + Future _resultadoGlobal({DateTime? notBefore}) { + final agora = _clock().toUtc(); + bool serve(DateTime inicio) => + notBefore == null || !inicio.isBefore(notBefore); + + final ultimo = cache.last; + if (ultimo != null && serve(ultimo.startedAt)) { + final idade = agora.difference(ultimo.at); + if (!idade.isNegative && idade < cacheTtl) return Future.value(ultimo); + } + final andamento = cache.inFlight; + final inicioDoAndamento = cache.inFlightStartedAt; + if (andamento != null && + inicioDoAndamento != null && + serve(inicioDoAndamento)) { + return andamento; + } + + final novo = _verificarCadeia(inicio: agora).then((retrato) { + // Um single-flight antigo que termine depois não sobrescreve um + // retrato mais novo. + final atual = cache.last; + if (atual == null || !retrato.startedAt.isBefore(atual.startedAt)) { + cache.last = retrato; + } + return retrato; + }); + cache.inFlight = novo; + cache.inFlightStartedAt = agora; + // Limpa só se ainda for o mesmo (um reset de teste pode ter trocado). + novo.whenComplete(() { + if (identical(cache.inFlight, novo)) { + cache.inFlight = null; + cache.inFlightStartedAt = null; + } + }); + return novo; + } + + /// Nunca lança: qualquer falha da leitura — `Exception` ou `Error` — é + /// `databaseError`. A âncora de cauda (Task 7) entra depois do + /// verificador; a gravação do checkpoint, depois do estado final. + Future _verificarCadeia({ + required DateTime inicio, + }) async { + final reader = _ReaderComPrazo(_reader, readTimeout); + AuditChainVerification resultado; + int? maiorCheckpoint; + try { + resultado = await AuditChainVerifier( + reader: reader, + secret: _secret, + ).verify(budget: budget, clock: _clock); + if (resultado.ok) { + final ancorado = await _ancorarCauda(resultado, reader); + resultado = ancorado.resultado; + maiorCheckpoint = ancorado.maiorValido; + } + } catch (_) { + resultado = const AuditChainVerification( + ok: false, + checked: 0, + reasonCode: AuditChainReason.databaseError, + ); + } + if (stateOf(resultado) == AuditIntegrityState.verified) { + await _gravarCheckpoint(resultado, maiorCheckpoint); + } + return AuditChainSnapshot( + verification: resultado, + at: _clock().toUtc(), + startedAt: inicio, + ); + } + + /// Confere a cadeia `ok` [v] contra o checkpoint válido mais recente. + /// Lança em falha de leitura (o chamador transforma em `databaseError`). + Future<({AuditChainVerification resultado, int? maiorValido})> _ancorarCauda( + AuditChainVerification v, + AuditChainReader reader, + ) async { + final chain = AuditChain(secret: _secret); + final lidos = await _checkpoints + .latest(limit: checkpointWindow) + .timeout(readTimeout); + + AuditChainCheckpointRecord? ancora; + var invalido = false; + for (final c in lidos) { + if (chain.checkpointMatches(c.sequence, c.entryHash, c.signature)) { + // Lidos em ordem decrescente: o primeiro válido é o mais recente. + if (ancora == null || c.sequence > ancora.sequence) ancora = c; + } else { + invalido = true; + } + } + + if (ancora != null) { + // `entryAt`, não `checked`: se a cadeia cresceu enquanto era lida, a + // linha do checkpoint pode estar além do que o verificador viu — e + // existir com o mesmo hash não é truncamento. + final linha = await reader.entryAt(ancora.sequence); + if (linha == null || linha.entryHash != ancora.entryHash) { + return ( + resultado: AuditChainVerification( + ok: false, + checked: v.checked, + brokenAtSequence: ancora.sequence, + reason: linha == null + ? 'a cadeia termina antes do último checkpoint ' + '(cauda apagada)' + : 'entryHash diverge do checkpoint ' + '(cauda apagada e regravada)', + reasonCode: AuditChainReason.tailTruncated, + ), + maiorValido: ancora.sequence, + ); + } + } + + if (invalido) { + return ( + resultado: AuditChainVerification( + ok: false, + checked: v.checked, + reason: + 'checkpoint com assinatura inválida ignorado ' + '(adulteração ou segredo trocado)', + reasonCode: AuditChainReason.checkpointInvalid, + ), + maiorValido: ancora?.sequence, + ); + } + return (resultado: v, maiorValido: ancora?.sequence); + } + + /// Melhor esforço: nunca lança e nunca muda o resultado. + Future _gravarCheckpoint( + AuditChainVerification v, + int? maiorExistente, + ) async { + final hash = v.headEntryHash; + if (hash == null || v.checked < 1) return; + if (maiorExistente != null && maiorExistente >= v.checked) return; + try { + await _checkpoints + .advanceTo( + AuditChainCheckpointRecord( + sequence: v.checked, + entryHash: hash, + signature: AuditChain( + secret: _secret, + ).computeCheckpointSignature(v.checked, hash), + ), + ) + .timeout(readTimeout); + } catch (erro) { + try { + onCheckpointWriteFailure?.call(erro); + } catch (_) { + // O log também falhou; a resposta segue. + } + } + } + + Future _projetar( + AuditChainSnapshot retrato, + AdminScope escopo, + ) async { + final v = retrato.verification; + final estado = stateOf(v); + final quebrada = estado == AuditIntegrityState.broken; + final ubs = escopo.ubsId; + + if (ubs == null) { + return AdminAuditChainStatus( + state: estado, + checkedRows: v.checked, + headSequence: estado == AuditIntegrityState.verified ? v.checked : null, + firstBrokenSequence: quebrada ? v.brokenAtSequence : null, + brokenInScope: quebrada ? true : null, + reasonCode: v.ok ? null : v.reasonCode, + verifiedAt: retrato.at, + ); + } + + if (!quebrada) { + // O coordenador só aprende o motivo de falha de infraestrutura + // (`unavailable`) ou de checkpoint inválido; `timeBudget` (e qualquer + // outro motivo de `inconclusive`) revelaria o tamanho da cadeia global. + final motivoVisivel = + estado == AuditIntegrityState.unavailable || + v.reasonCode == AuditChainReason.checkpointInvalid; + return AdminAuditChainStatus( + state: estado, + reasonCode: !v.ok && motivoVisivel ? v.reasonCode : null, + verifiedAt: retrato.at, + ); + } + + bool? noEscopo; + final seq = v.brokenAtSequence; + if (seq == null) { + noEscopo = false; + } else { + try { + noEscopo = await store.auditRowInUbs(seq, ubs); + } catch (_) { + // Pertencimento desconhecido: continua `broken`, sem sequence. + noEscopo = null; + } + } + return AdminAuditChainStatus( + state: estado, + firstBrokenSequence: noEscopo == true ? seq : null, + brokenInScope: noEscopo, + reasonCode: noEscopo == true ? v.reasonCode : null, + verifiedAt: retrato.at, + ); + } +} + +/// Aplica [_prazo] a cada leitura de lote: estourar lança +/// `TimeoutException`, que o verificador já trata como `databaseError`. +class _ReaderComPrazo implements AuditChainReader { + _ReaderComPrazo(this._interno, this._prazo); + + final AuditChainReader _interno; + final Duration _prazo; + + @override + Future> readBatch({ + required int afterSequence, + required int limit, + }) => _interno + .readBatch(afterSequence: afterSequence, limit: limit) + .timeout(_prazo); + + @override + Future entryAt(int sequence) => + _interno.entryAt(sequence).timeout(_prazo); +} diff --git a/backend/sinalacs_server/lib/src/application/admin/audit_export_service.dart b/backend/sinalacs_server/lib/src/application/admin/audit_export_service.dart new file mode 100644 index 00000000..73344c27 --- /dev/null +++ b/backend/sinalacs_server/lib/src/application/admin/audit_export_service.dart @@ -0,0 +1,311 @@ +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; +import 'package:sinalacs_server/src/application/admin/admin_labels.dart'; +import 'package:sinalacs_server/src/application/admin/admin_read_service.dart'; +import 'package:sinalacs_server/src/application/audit/audit_chain.dart'; +import 'package:sinalacs_server/src/application/audit/audit_csv.dart'; +import 'package:sinalacs_server/src/application/audit/audit_row_sealer.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/generated/protocol.dart'; +import 'package:uuid/uuid.dart'; + +/// O que a exportação precisa do mundo (#45, Task 10). Interface à parte do +/// ORM, como `AdminReadStore`: o serviço e seus testes não conhecem SQL. +/// +/// Toda leitura é do **sistema inteiro** (só o administrador exporta, D5) e +/// limitada a `sequence <= headSequence`: o recorte é fixado antes de o +/// evento `export` existir. +abstract interface class AuditExportStore implements AdminScopeStore { + /// Maior `sequence` de `audit_logs` agora (0 com a tabela vazia). + Future headSequence(); + + /// Linhas do recorte ([filter], `sequence <= headSequence`), contadas até + /// no máximo [cap]: devolve `min(total, cap)`. + Future countUpTo( + AdminAuditFilter filter, { + required int headSequence, + required int cap, + }); + + /// As linhas do recorte em ordem **crescente** de `sequence`, no máximo + /// [limit], cada uma com a entrada interna do selo (vizinho da cadeia lido + /// do banco) e o papel/matrícula do autor. + Future> readUpTo( + AdminAuditFilter filter, { + required int headSequence, + required int limit, + }); + + /// **Uma** transação: insere [registro] em `audit_exports` e apensa + /// [evento] à cadeia (`OrmAuditTrail.recordInTransaction`). Se qualquer um + /// falhar, nenhum dos dois fica. + Future commitExport(AuditExport registro, AuditEvent evento); +} + +/// Falha da exportação depois de autorizada e validada (leitura, geração ou +/// gravação). A mensagem é **fixa**: nunca carrega texto do banco, do CSV +/// nem do erro original. +class AuditExportFailedException implements Exception { + const AuditExportFailedException(); + + static const mensagem = + 'Não foi possível gerar a exportação da auditoria. Tente novamente.'; + + String get message => mensagem; + + @override + String toString() => 'AuditExportFailedException: $mensagem'; +} + +/// `admin.exportAuditLogs` (#45, Task 10; D5–D8, Review Focus 6 e 8). +/// +/// Ordem fixa (D8): +/// 1. **Só `admin`** ([AdminScopeResolver.requireAdmin], recurso +/// [recursoDaRecusa], `actionType: export`): coordenador, ACS e paciente +/// recebem `AlertPermissionException` com `denied` auditado, e **nenhuma** +/// linha é lida. +/// 2. **Filtro com período obrigatório** (`from` **e** `to`) e as regras de +/// `AdminReadService.validarFiltroDeAuditoria` (janela ≤ 366 dias, `from < +/// to`): recusa `AdminInvalidRequestException` **sem** auditar (entrada +/// inválida não é acesso). +/// 3. `headSequence` capturado **antes** de tudo; o recorte é +/// `sequence <= head`. O evento `export` entra depois, com `sequence` +/// maior: o arquivo nunca descreve a si mesmo e não há recursão (Review +/// Focus 6). Exportar de novo inclui o `export` anterior — esperado. +/// 4. `COUNT` limitado; acima de [maxRows] ⇒ `refused_limit` auditado +/// (`record`, fail-closed) e `AdminInvalidRequestException`. +/// 5. Lê as linhas e os selos, gera o CSV ([AuditCsv]) e o SHA-256 em memória +/// (sem arquivo em disco), e pega o estado da cadeia ([chainState]) de uma +/// verificação global **iniciada depois do início da exportação** (nunca +/// o retrato em cache anterior a ela). `unavailable`/`inconclusive`/ +/// `broken` são gravados como estão. +/// 6. **Uma transação:** `audit_exports` + `export/admin_audit_logs/success` +/// com `resourceId` = id da exportação ([AuditExportStore.commitExport]). +/// Só depois do commit o CSV é devolvido. +/// +/// Falha em 3–6 ⇒ `failed` gravado com `record` **fora** de transação (sem +/// conteúdo) e [AuditExportFailedException] de mensagem fixa. Se nem o +/// `failed` grava, a mesma exceção genérica sobe: nada sai. +/// +/// **Resposta perdida depois do commit (Review Focus 8):** se a conexão cai +/// entre o commit e a entrega, fica um evento `export` sem arquivo entregue. +/// Aceito: erra para o lado de auditar a mais; não há o que desfazer. +/// +/// Os campos da cadeia nunca saem daqui: o CSV é montado só a partir de +/// [AuditExportRow] (D6), e o selo vem do mesmo [AuditRowSealer] da tela. +class AuditExportService { + AuditExportService({ + required this.store, + required this.audit, + required AuditChain auditChain, + required this._chainState, + this.onFailure, + DateTime Function()? clock, + String Function()? newId, + }) : _sealer = AuditRowSealer(auditChain), + _clock = clock ?? DateTime.now, + _newId = newId ?? (() => const Uuid().v4()); + + final AuditExportStore store; + final AuditTrail audit; + + /// Recebe o erro original de uma falha (para log de servidor: só o tipo). + /// Se ele próprio lançar, é engolido. + final void Function(Object error)? onFailure; + + final AuditRowSealer _sealer; + final Future Function(DateTime notBefore) _chainState; + final DateTime Function() _clock; + final String Function() _newId; + + /// Teto de linhas por arquivo (D5). Acima disso: estreitar os filtros. + static const maxRows = 5000; + + /// Recurso das linhas de recusa e de falha (nenhuma exportação existe). + static const recursoDaRecusa = 'admin_audit_export'; + + /// Recurso da linha de sucesso (D7): o que foi exportado; o `resourceId` é + /// o id em `audit_exports`. + static const recursoExportado = 'admin_audit_logs'; + + static const formato = 'csv'; + + static const _periodoObrigatorio = + 'Filtro de auditoria inválido. A exportação exige início e fim do ' + 'período.'; + static const _acimaDoTeto = + 'O recorte passa de $maxRows linhas. Estreite os filtros para exportar.'; + + late final AdminScopeResolver _resolver = AdminScopeResolver( + store: store, + audit: audit, + ); + + Future export( + AuthenticatedUser user, + AdminAuditFilter? filter, + ) async { + // Início da exportação: o estado da cadeia gravado tem de vir de uma + // verificação iniciada a partir daqui, nunca de um retrato anterior. + final inicio = _clock().toUtc(); + await _resolver.requireAdmin( + user, + recurso: recursoDaRecusa, + actionType: 'export', + ); + final f = _validar(filter); + + final int head; + final int total; + try { + head = await store.headSequence(); + total = await store.countUpTo(f, headSequence: head, cap: maxRows + 1); + } catch (erro) { + return _falhar(user, erro); + } + if (total > maxRows) { + await _registrar(user, 'refused_limit'); + throw AdminInvalidRequestException(message: _acimaDoTeto); + } + + final String csv; + final String sha; + final int? primeira; + final int? ultima; + final int quantidade; + final AuditIntegrityState estado; + try { + final linhas = await store.readUpTo( + f, + headSequence: head, + limit: maxRows, + ); + final rows = [for (final l in linhas) _linhaDoCsv(l)]; + csv = AuditCsv.build(rows); + sha = sha256.convert(utf8.encode(csv)).toString(); + quantidade = rows.length; + primeira = rows.isEmpty ? null : rows.first.sequence; + ultima = rows.isEmpty ? null : rows.last.sequence; + estado = await _chainState(inicio); + } catch (erro) { + return _falhar(user, erro); + } + + final agora = _clock().toUtc(); + final id = _newId(); + try { + await store.commitExport( + AuditExport( + id: UuidValue.fromString(id), + requestedBy: UuidValue.fromString(user.id), + requestedAt: agora, + filtersJson: _filtrosJson(f), + format: formato, + rowCount: quantidade, + firstSequence: primeira, + lastSequence: ultima, + contentSha256: sha, + chainState: estado.name, + ), + AuditEvent( + userId: user.id, + actionType: 'export', + resourceType: recursoExportado, + resourceId: id, + result: 'success', + ), + ); + } catch (erro) { + return _falhar(user, erro); + } + + return AdminAuditExport( + fileName: nomeDoArquivo(f.from!, f.to!), + csv: csv, + rowCount: quantidade, + firstSequence: primeira, + lastSequence: ultima, + contentSha256: sha, + chainState: estado, + generatedAt: agora, + ); + } + + /// `auditoria__.csv`, UTC compacto (`20261001T000000Z`): só o + /// período, nunca filtro de matrícula, busca ou outro dado pessoal. + static String nomeDoArquivo(DateTime from, DateTime to) => + 'auditoria_${_compacto(from)}_${_compacto(to)}.csv'; + + static String _compacto(DateTime t) { + final u = t.toUtc(); + String d2(int v) => v.toString().padLeft(2, '0'); + return '${u.year.toString().padLeft(4, '0')}${d2(u.month)}${d2(u.day)}' + 'T${d2(u.hour)}${d2(u.minute)}${d2(u.second)}Z'; + } + + AdminAuditFilter _validar(AdminAuditFilter? filter) { + if (filter == null || filter.from == null || filter.to == null) { + throw AdminInvalidRequestException(message: _periodoObrigatorio); + } + AdminReadService.validarFiltroDeAuditoria(filter); + return filter; + } + + /// Só os parâmetros do filtro (D7), sem o marcador de classe do protocolo; + /// nunca linhas. + static String _filtrosJson(AdminAuditFilter f) => + jsonEncode(f.toJson()..remove('__className__')); + + /// Linha do CSV (D6): `autor` é a matrícula (ACS/staff) ou o rótulo + /// minimizado (`Paciente #A18F`); `rotulo_recurso` é `#` + 4 hex do + /// `resourceId`, nunca o UUID; o selo é o do [AuditRowSealer]. + AuditExportRow _linhaDoCsv(AdminAuditRow l) { + final campos = l.seal.row.fields; + final papel = l.actorRole ?? 'desconhecido'; + final matricula = l.actorEnrollmentId; + final autor = papel == 'patient' || matricula == null || matricula.isEmpty + ? AdminLabels.user(role: papel, id: campos.userId) + : matricula; + final recurso = campos.resourceId; + return AuditExportRow( + sequence: campos.sequence, + timestampUtc: l.entry.timestamp.toUtc(), + autor: autor, + papelAutor: papel, + acao: l.entry.actionType, + recurso: l.entry.resourceType, + rotuloRecurso: recurso == null ? '' : AdminLabels.patient(recurso), + resultado: l.entry.result, + selo: _sealer.sealInput(l.seal).name, + ); + } + + Future _registrar(AuthenticatedUser user, String result) => + audit.record( + AuditEvent( + userId: user.id, + actionType: 'export', + resourceType: recursoDaRecusa, + result: result, + ), + ); + + /// `failed` com `record` (fail-closed, fora de transação) e a exceção + /// genérica. Se a trilha também falhar, a exceção continua a genérica: + /// nenhum texto do banco sobe, e nada é entregue. + Future _falhar(AuthenticatedUser user, Object erro) async { + try { + onFailure?.call(erro); + } catch (_) { + // O log falhou; a recusa segue. + } + try { + await _registrar(user, 'failed'); + } catch (_) { + // Sem a linha `failed`, ainda assim nada sai. + } + throw const AuditExportFailedException(); + } +} diff --git a/backend/sinalacs_server/lib/src/application/admin/data_subject_case_service.dart b/backend/sinalacs_server/lib/src/application/admin/data_subject_case_service.dart new file mode 100644 index 00000000..ae1a19b9 --- /dev/null +++ b/backend/sinalacs_server/lib/src/application/admin/data_subject_case_service.dart @@ -0,0 +1,368 @@ +import 'dart:io'; + +import 'package:sinalacs_server/src/application/admin/admin_read_service.dart'; +import 'package:sinalacs_server/src/application/audit/audit_trail.dart'; +import 'package:sinalacs_server/src/application/auth/authorization.dart'; +import 'package:sinalacs_server/src/application/auth/development_auth_service.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; + +/// Persistência do atendimento de pedidos do titular. Interface à parte do ORM, +/// no espírito de `AdminReadStore`: o serviço e seus testes não conhecem SQL. +abstract interface class DataSubjectCaseStore { + /// UBS do coordenador (`staff_accounts.ubsId`), ou `null` se não houver. + Future ubsOf(String staffId); + + /// Titular (`users.id`) do pedido, para o aviso pós-decisão. Sem filtro de + /// escopo: só é chamado depois de a decisão ter passado pelo escopo. + Future subjectOf(String requestId); + + /// Prazo mais próximo primeiro (`dueAt` crescente, `id` desempata): os + /// vencidos sobem ao topo. `nextOffset` nulo = fim da lista. Nunca decifra + /// `details` nem a nota. + Future list( + AdminScope scope, { + DataSubjectRequestStatus? status, + required int limit, + required int offset, + required DateTime now, + }); + + /// `null` = não existe OU fora do escopo (indistinguíveis de propósito). + Future find( + AdminScope scope, + String id, { + required DateTime now, + }); + + /// Transação: trava o pedido (`pg_advisory_xact_lock`), confere [from], grava + /// status/nota/decidedBy, executa a anonimização se [anonymize] e grava a + /// auditoria via [audit]. Devolve `false` se o estado atual não está em + /// [from] (corrida perdida). + Future decide( + AdminScope scope, + String id, { + required Set from, + required DataSubjectRequestStatus to, + required String? resolution, + required String decidedBy, + required bool anonymize, + required AuditEvent audit, + required DateTime now, + }); +} + +/// Aviso ao titular depois de o pedido ser decidido. Melhor esforço: a falha +/// nunca desfaz a decisão (o serviço captura e registra só em stderr). +abstract interface class DataSubjectNotifier { + Future decided(String userId, DataSubjectRequestStatus status); +} + +/// Regras do atendimento de pedidos do titular no backoffice (issue #42). +/// +/// 1. **Papel e escopo:** como em [AdminReadService] — só `coordinator` e +/// `admin`; o coordenador enxerga a UBS de `staff_accounts.ubsId` e, sem UBS, +/// é recusado (fail-closed) em todos os métodos. Recusas são auditadas. +/// 2. **Não encontrado:** pedido inexistente e pedido fora do escopo geram a +/// mesma exceção, com a mesma mensagem. +/// 3. **Auditoria antes do dado:** a leitura grava `read` com `record` (não +/// `recordSafely`) antes de consultar o store. A auditoria das decisões viaja +/// dentro de [DataSubjectCaseStore.decide], na mesma transação. +/// 4. **Transições:** `open → inReview → completed|rejected` e +/// `open → completed|rejected`; `completed` e `rejected` são finais. +/// 5. **Nota/motivo:** de 3 a 500 caracteres após `trim`; nunca vai para +/// mensagem de exceção nem para evento de auditoria. +/// 6. **Anonimização:** só ao atender (`completed`) um pedido de exclusão. +class DataSubjectCaseService { + DataSubjectCaseService({ + required this.store, + required this.audit, + DateTime Function()? clock, + DataSubjectNotifier? notifier, + }) : _clock = clock ?? DateTime.now, + _notifier = notifier; + + final DataSubjectCaseStore store; + final AuditTrail audit; + final DateTime Function() _clock; + final DataSubjectNotifier? _notifier; + + static const maxLimit = 100; + static const notaMin = 3; + static const notaMax = 500; + static const _recurso = 'admin_data_subject_requests'; + static const _recursoDecisao = 'data_subject_request'; + static const _negado = 'acesso restrito ao backoffice'; + static const _paginacaoInvalida = 'Parâmetro de paginação inválido.'; + static const _naoEncontrado = 'Pedido não encontrado.'; + static const _transicaoInvalida = + 'O pedido não está num estado que permita esta ação.'; + + /// Corrida perdida, pedido que saiu do escopo entre a leitura e o lock, ou + /// dado inconsistente no store: a mesma frase neutra, que não diz se o pedido + /// existe nem em que estado está. + static const _indisponivel = + 'Pedido não encontrado ou já decidido. Atualize a lista.'; + static const _notaInvalida = + 'O texto deve ter de $notaMin a $notaMax caracteres.'; + static const _notaObrigatoria = + 'Pedido de correção exige uma nota de resposta.'; + + Future list( + AuthenticatedUser user, { + DataSubjectRequestStatus? status, + int limit = 50, + int offset = 0, + }) async { + final escopo = await _escopo(user); + if (limit < 1 || limit > maxLimit || offset < 0) { + throw AdminInvalidRequestException(message: _paginacaoInvalida); + } + await _auditarLeitura(user, null); + final agora = _clock().toUtc(); + final pagina = await store.list( + escopo, + status: status, + limit: limit, + offset: offset, + now: agora, + ); + return AdminDataSubjectRequestPage( + items: [ + for (final i in pagina.items) + AdminDataSubjectRequest( + id: i.id, + type: i.type, + status: i.status, + createdAt: i.createdAt, + dueAt: i.dueAt, + overdue: _vencido(i.status, i.dueAt, agora), + patientLabel: i.patientLabel, + ), + ], + nextOffset: pagina.nextOffset, + ); + } + + Future get( + AuthenticatedUser user, + String id, + ) async { + final escopo = await _escopo(user); + await _auditarLeitura(user, id); + return _achar(escopo, id, _clock().toUtc()); + } + + Future startReview(AuthenticatedUser user, String id) async { + final escopo = await _escopo(user); + final agora = _clock().toUtc(); + final pedido = await _achar(escopo, id, agora); + _exigirEstado(pedido, const {DataSubjectRequestStatus.open}); + await _decidir( + user, + escopo, + pedido, + from: const {DataSubjectRequestStatus.open}, + to: DataSubjectRequestStatus.inReview, + resolution: null, + anonymize: false, + agora: agora, + ); + } + + /// [note] é obrigatória no pedido de correção; na exclusão é opcional, mas + /// quando vem precisa passar na mesma validação. + Future complete( + AuthenticatedUser user, + String id, { + String? note, + }) async { + final escopo = await _escopo(user); + final agora = _clock().toUtc(); + final pedido = await _achar(escopo, id, agora); + _exigirEstado(pedido, _abertos); + final correcao = pedido.type == DataSubjectRequestType.correction; + if (correcao && note == null) { + throw AdminInvalidRequestException(message: _notaObrigatoria); + } + final texto = note == null ? null : _validarNota(note); + await _decidir( + user, + escopo, + pedido, + from: _abertos, + to: DataSubjectRequestStatus.completed, + resolution: texto, + anonymize: pedido.type == DataSubjectRequestType.deletion, + agora: agora, + // Exclusão atendida: os tokens foram apagados, não há o que avisar. + avisar: pedido.type != DataSubjectRequestType.deletion, + ); + } + + Future reject( + AuthenticatedUser user, + String id, { + required String reason, + }) async { + final escopo = await _escopo(user); + final agora = _clock().toUtc(); + final pedido = await _achar(escopo, id, agora); + _exigirEstado(pedido, _abertos); + final motivo = _validarNota(reason); + await _decidir( + user, + escopo, + pedido, + from: _abertos, + to: DataSubjectRequestStatus.rejected, + resolution: motivo, + anonymize: false, + agora: agora, + avisar: true, + ); + } + + static const _abertos = { + DataSubjectRequestStatus.open, + DataSubjectRequestStatus.inReview, + }; + + static bool _vencido( + DataSubjectRequestStatus status, + DateTime dueAt, + DateTime agora, + ) => _abertos.contains(status) && agora.isAfter(dueAt); + + Future _achar( + AdminScope escopo, + String id, + DateTime agora, + ) async { + final d = await store.find(escopo, id, now: agora); + if (d == null) throw AdminInvalidRequestException(message: _naoEncontrado); + return AdminDataSubjectRequestDetail( + id: d.id, + type: d.type, + status: d.status, + createdAt: d.createdAt, + dueAt: d.dueAt, + overdue: _vencido(d.status, d.dueAt, agora), + patientLabel: d.patientLabel, + details: d.details, + resolution: d.resolution, + decidedAt: d.decidedAt, + ); + } + + void _exigirEstado( + AdminDataSubjectRequestDetail pedido, + Set permitidos, + ) { + if (!permitidos.contains(pedido.status)) { + throw AdminInvalidRequestException(message: _transicaoInvalida); + } + } + + Future _decidir( + AuthenticatedUser user, + AdminScope escopo, + AdminDataSubjectRequestDetail pedido, { + required Set from, + required DataSubjectRequestStatus to, + required String? resolution, + required bool anonymize, + required DateTime agora, + bool avisar = false, + }) async { + final bool ok; + try { + ok = await store.decide( + escopo, + pedido.id, + from: from, + to: to, + resolution: resolution, + decidedBy: user.id, + anonymize: anonymize, + audit: AuditEvent( + userId: user.id, + actionType: 'write', + resourceType: _recursoDecisao, + resourceId: pedido.id, + result: switch (to) { + DataSubjectRequestStatus.inReview => 'in_review', + DataSubjectRequestStatus.completed => 'completed', + DataSubjectRequestStatus.rejected => 'rejected', + DataSubjectRequestStatus.open => 'open', + }, + ), + now: agora, + ); + } on StateError { + // Dado inconsistente (titular que não é paciente, anonimização fora de + // pedido de exclusão): a transação já foi desfeita no store; o texto do + // erro fica fora da resposta. + throw AdminInvalidRequestException(message: _indisponivel); + } + // Corrida perdida (outro analista decidiu entre a leitura e o lock) ou + // pedido que saiu do escopo: a mesma mensagem neutra nos dois casos. + if (!ok) throw AdminInvalidRequestException(message: _indisponivel); + if (avisar) await _avisar(pedido.id, to); + } + + /// Pós-commit, melhor esforço. Só o tipo da exceção vai para o stderr: nunca + /// nota, motivo nem texto da falha. + Future _avisar(String pedidoId, DataSubjectRequestStatus to) async { + final notifier = _notifier; + if (notifier == null) return; + try { + final titular = await store.subjectOf(pedidoId); + if (titular == null) return; + await notifier.decided(titular, to); + } catch (e) { + stderr.writeln('aviso ao titular falhou: ${e.runtimeType}'); + } + } + + String _validarNota(String texto) { + final t = texto.trim(); + if (t.length < notaMin || t.length > notaMax) { + throw AdminInvalidRequestException(message: _notaInvalida); + } + return t; + } + + Future _escopo(AuthenticatedUser user) async { + if (!Authorization.staffRoles.contains(user.role)) { + await _auditarNegado(user); + throw AlertPermissionException(message: _negado); + } + if (user.role == UserRole.admin) return const AdminScope.system(); + final ubs = await store.ubsOf(user.id); + if (ubs == null) { + await _auditarNegado(user); + throw AlertPermissionException(message: _negado); + } + return AdminScope.ubs(ubs); + } + + Future _auditarNegado(AuthenticatedUser user) => audit.record( + AuditEvent( + userId: user.id, + actionType: 'read', + resourceType: _recurso, + result: 'denied', + ), + ); + + Future _auditarLeitura(AuthenticatedUser user, String? id) => + audit.record( + AuditEvent( + userId: user.id, + actionType: 'read', + resourceType: _recurso, + resourceId: id, + result: 'success', + ), + ); +} diff --git a/backend/sinalacs_server/lib/src/application/admin/initial_password.dart b/backend/sinalacs_server/lib/src/application/admin/initial_password.dart new file mode 100644 index 00000000..f6f9744b --- /dev/null +++ b/backend/sinalacs_server/lib/src/application/admin/initial_password.dart @@ -0,0 +1,48 @@ +import 'dart:math'; + +/// Senha inicial do ACS recém-cadastrado (issue #43): sorteada pelo servidor e +/// mostrada ao operador **uma única vez**. +/// +/// **Por que não vem do operador.** Uma senha digitada pelo coordenador é uma +/// senha escolhida por quem tem pressa, e ela vira a credencial do RF07: o +/// login institucional não tem como exigir força de uma senha que já nasceu +/// fraca, e a política de bloqueio por tentativas (achado F6) protege contra +/// força bruta, não contra `acs1234`. O caminho escolhido é o que o repositório +/// já usa para credencial entregue fora de banda — o código de ativação do +/// staff (issue #48) e as senhas semeadas de desenvolvimento: o servidor +/// **sorteia**, mostra uma vez e não guarda, porque só o hash Argon2id vai para +/// `user_credentials`. Quem cadastra repassa a senha ao ACS e não a vê de novo; +/// perdê-la significa redefinir, nunca recuperar. +/// +/// **Formato.** 16 caracteres de um alfabeto de 31 símbolos — 31^16 ≈ 2^79,3, +/// os "~80 bits" da issue —, em quatro grupos de quatro, como o +/// `StaffActivationCode`. O alfabeto é `ABC…Z` e `2…9` **sem** `0`/`O` e +/// `1`/`I`/`L`: são os pares que se confundem quando a senha é lida em voz +/// alta, copiada de um papel ou digitada num teclado de celular, e uma senha +/// inicial que chega trocada ao ACS vira uma tentativa falha a mais (ou um +/// bloqueio de conta) que ninguém sabe explicar. Os grupos fazem o mesmo +/// trabalho: quem digita confere bloco a bloco e um erro fica visível no grupo +/// errado, em vez de no meio de 16 caracteres indistinguíveis. +class AcsInitialPassword { + const AcsInitialPassword._(); + + static const _alphabet = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; + static const _length = 16; + static const _groupSize = 4; + + /// Sorteia uma senha nova. [random] existe para os testes; sem ele o sorteio + /// é [Random.secure]. Um `Random()` comum **não** serve em produção: ele é + /// determinístico a partir da semente, e quem adivinha a semente recebe a + /// credencial de todo ACS cadastrado naquele dia. + static String generate([Random? random]) { + final r = random ?? Random.secure(); + final caracteres = List.generate( + _length, + (_) => _alphabet[r.nextInt(_alphabet.length)], + ); + return [ + for (var i = 0; i < _length; i += _groupSize) + caracteres.sublist(i, i + _groupSize).join(), + ].join('-'); + } +} diff --git a/backend/sinalacs_server/lib/src/application/alerts/alert_outbox_dispatcher.dart b/backend/sinalacs_server/lib/src/application/alerts/alert_outbox_dispatcher.dart index 7b01ef71..bed9a4ad 100644 --- a/backend/sinalacs_server/lib/src/application/alerts/alert_outbox_dispatcher.dart +++ b/backend/sinalacs_server/lib/src/application/alerts/alert_outbox_dispatcher.dart @@ -57,7 +57,9 @@ class AlertOutboxDispatcher { Future _publishOne(PendingDelivery entry) async { try { - _publisher.publish(entry.delivery); + // Só depois da confirmação do broker (H5, #56): marcar antes deixava um + // alerta perdido numa conexão morta dado como entregue para sempre. + await _publisher.publish(entry.delivery); await _outbox.markPublished(entry.entryId); return true; } catch (error) { @@ -68,7 +70,7 @@ class AlertOutboxDispatcher { ); stderr.writeln( 'Entrega do alerta ${entry.delivery.alertId} adiada ' - '(tentativa ${entry.attempts}): $error', + '(tentativa ${entry.attempts}, erro ${error.runtimeType}).', ); return false; } diff --git a/backend/sinalacs_server/lib/src/application/alerts/red_alert_service.dart b/backend/sinalacs_server/lib/src/application/alerts/red_alert_service.dart index c3bc8700..80fc87b4 100644 --- a/backend/sinalacs_server/lib/src/application/alerts/red_alert_service.dart +++ b/backend/sinalacs_server/lib/src/application/alerts/red_alert_service.dart @@ -1,5 +1,6 @@ import 'package:uuid/uuid.dart'; +import 'package:sinalacs_server/src/application/audit/audit_trail.dart'; import 'package:sinalacs_server/src/application/auth/authorization.dart'; import 'package:sinalacs_server/src/application/auth/development_auth_service.dart'; import 'package:sinalacs_server/src/domain/entities/alert_delivery.dart'; @@ -8,13 +9,26 @@ import 'package:sinalacs_server/src/generated/protocol.dart'; const _uuid = Uuid(); abstract interface class AlertPublisher { - void publish(AlertDelivery alert); + /// Completa quando o broker confirma a entrega (PUBACK do QoS 1) e falha + /// quando não há conexão, quando ela cai antes da confirmação ou quando a + /// confirmação não chega no prazo (H5, #56). Só depois de completar a + /// entrega pode ser dada como publicada. + Future publish(AlertDelivery alert); } abstract interface class AlertStore { Future save(AlertDelivery alert, {required String deviceId}); - Future acknowledge({required String alertId, required String acsId, required String microAreaId}); + /// Confirma o alerta no território do ACS. Quando o alerta existe na + /// microárea, grava [audit] na MESMA transação da mudança de status (#44): + /// se a trilha falhar, o ACK é desfeito — nenhum alerta vermelho muda de + /// estado sem registro. Sem alerta na microárea, nada é gravado. + Future acknowledge({ + required String alertId, + required String acsId, + required String microAreaId, + required AuditEvent audit, + }); /// Recupera o alerta já criado para esta chave de idempotência, se houver. /// @@ -95,6 +109,12 @@ class AlertStatusSnapshot { final _locationCellFormat = RegExp(r'^-?\d{1,6}:-?\d{1,6}$'); class RedAlertService { + /// `deviceId` sentinela do ACK que chega por MQTT (`lib/server.dart`): esse + /// caminho não tem token, e `acsId`/`microAreaId` vêm do payload. Único + /// lugar onde o valor existe; o servidor o usa ao montar o usuário do ACK e + /// [acknowledge] o usa para não auditar esse ACK como o do RPC (F3, #44). + static const mqttAckDeviceId = 'mqtt'; + RedAlertService({ required AlertStore store, required AlertOutbox outbox, @@ -182,7 +202,24 @@ class RedAlertService { if (alertId.trim().isEmpty) { throw ArgumentError('O identificador do alerta é obrigatório.'); } - return _store.acknowledge(alertId: alertId, acsId: user.id, microAreaId: user.microAreaId!); + // Toda confirmação que encontra o alerta é auditada, inclusive a repetida + // do mesmo ACS: o log é append-only e registra cada ato (decisão #44). + // Sem identidade de paciente: só quem confirmou e qual alerta. O ACK por + // MQTT não é autenticado (a identidade vem do payload), então a trilha o + // marca `acknowledged_mqtt` em vez de atribuí-lo como um ACK do RPC. + final porMqtt = user.deviceId == mqttAckDeviceId; + return _store.acknowledge( + alertId: alertId, + acsId: user.id, + microAreaId: user.microAreaId!, + audit: AuditEvent( + userId: user.id, + actionType: 'write', + resourceType: 'alert', + resourceId: alertId, + result: porMqtt ? 'acknowledged_mqtt' : 'acknowledged', + ), + ); } /// Status do alerta mais recente do próprio paciente autenticado (RF05, diff --git a/backend/sinalacs_server/lib/src/application/audit/audit_chain.dart b/backend/sinalacs_server/lib/src/application/audit/audit_chain.dart index 6062a499..8994c4fe 100644 --- a/backend/sinalacs_server/lib/src/application/audit/audit_chain.dart +++ b/backend/sinalacs_server/lib/src/application/audit/audit_chain.dart @@ -57,12 +57,37 @@ class AuditChain { /// 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; + bool matches(AuditChainFields fields, String entryHash) => + _constantTimeEquals(computeEntryHash(fields), entryHash); + + /// Assinatura de um checkpoint de cauda (#45, Task 7, D4): + /// HMAC-SHA256(segredo, `"checkpoint" ␟ sequence ␟ entryHash`), em hex. + /// + /// Mesmo segredo e mesmo separador da linha, mas domínio separado: o + /// payload de linha começa por um inteiro, este pela palavra + /// `checkpoint`, então nenhuma assinatura de checkpoint serve de + /// `entryHash` de linha (nem o contrário). + String computeCheckpointSignature(int sequence, String entryHash) => + Hmac(sha256, _secret) + .convert( + utf8.encode( + ['checkpoint', sequence.toString(), entryHash].join(_separator), + ), + ) + .toString(); + + /// Confere a assinatura de um checkpoint em tempo constante. + bool checkpointMatches(int sequence, String entryHash, String signature) => + _constantTimeEquals( + computeCheckpointSignature(sequence, entryHash), + signature, + ); + + static bool _constantTimeEquals(String expected, String actual) { + if (expected.length != actual.length) return false; var difference = 0; for (var index = 0; index < expected.length; index++) { - difference |= expected.codeUnitAt(index) ^ entryHash.codeUnitAt(index); + difference |= expected.codeUnitAt(index) ^ actual.codeUnitAt(index); } return difference == 0; } diff --git a/backend/sinalacs_server/lib/src/application/audit/audit_chain_checkpoint_store.dart b/backend/sinalacs_server/lib/src/application/audit/audit_chain_checkpoint_store.dart new file mode 100644 index 00000000..290ab301 --- /dev/null +++ b/backend/sinalacs_server/lib/src/application/audit/audit_chain_checkpoint_store.dart @@ -0,0 +1,33 @@ +/// Um checkpoint de cauda da cadeia de `audit_logs` (#45, Task 7, D4): o +/// `entryHash` da linha de [sequence] no momento de uma verificação global +/// `verified`, com [signature] = `AuditChain.computeCheckpointSignature`. +/// Interno: nenhum destes campos vai ao cliente. +class AuditChainCheckpointRecord { + const AuditChainCheckpointRecord({ + required this.sequence, + required this.entryHash, + required this.signature, + }); + + final int sequence; + final String entryHash; + final String signature; +} + +/// Onde os checkpoints de cauda vivem (`audit_chain_checkpoints`). Interface +/// em `application/`, implementação ORM em +/// `infrastructure/database/orm_audit_checkpoint_store.dart` — o serviço é +/// testável sem Postgres. +abstract interface class AuditChainCheckpointStore { + /// Os [limit] checkpoints de maior `sequence`, em ordem **decrescente** de + /// `sequence`. Devolve as linhas como estão no banco, sem conferir + /// assinatura: quem confere é o serviço, com a `AuditChain`. + Future> latest({required int limit}); + + /// Grava [checkpoint] só se ele **avança**: nenhuma linha com `sequence` + /// maior ou igual pode existir. Uma corrida que perde (outra verificação já + /// gravou a mesma `sequence` ou uma maior) termina sem erro e sem linha — + /// é o índice único `audit_chain_checkpoints_sequence_idx` mais um lock + /// que tornam isso atômico. Falha de banco lança. + Future advanceTo(AuditChainCheckpointRecord checkpoint); +} 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 index ceffecc8..8cad5b35 100644 --- a/backend/sinalacs_server/lib/src/application/audit/audit_chain_verifier.dart +++ b/backend/sinalacs_server/lib/src/application/audit/audit_chain_verifier.dart @@ -1,4 +1,5 @@ import 'package:sinalacs_server/src/application/audit/audit_chain.dart'; +import 'package:sinalacs_server/src/generated/protocol.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 @@ -14,8 +15,18 @@ class AuditChainEntry { /// 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. +/// +/// Leitura sempre limitada (#45, G5): nada aqui carrega a tabela inteira. abstract interface class AuditChainReader { - Future> readInOrder(); + /// Até [limit] linhas com `sequence > afterSequence`, em ordem crescente de + /// `sequence`. Lista menor que [limit] (ou vazia) = fim da tabela. + Future> readBatch({ + required int afterSequence, + required int limit, + }); + + /// A linha de [sequence], ou nulo se não existe. + Future entryAt(int sequence); } /// Resultado de uma verificação da cadeia. @@ -23,18 +34,32 @@ abstract interface class AuditChainReader { /// `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á. +/// +/// `reasonCode` é o motivo fechado (#45, D9) que pode ir ao cliente; `reason` +/// é o texto livre para o operador (CLI, log) e **não** vai ao cliente. +/// `ok == false` nem sempre é adulteração: [AuditChainReason.timeBudget], +/// [AuditChainReason.databaseError] e [AuditChainReason.secretUnavailable] +/// dizem que a verificação **não terminou** — nunca que a cadeia é íntegra. class AuditChainVerification { const AuditChainVerification({ required this.ok, required this.checked, this.brokenAtSequence, this.reason, + this.reasonCode, + this.headEntryHash, }); final bool ok; final int checked; final int? brokenAtSequence; final String? reason; + final AuditChainReason? reasonCode; + + /// `entryHash` da última linha conferida (a de `sequence == checked`), só + /// quando `ok` e `checked > 0`. Uso interno: é o que o checkpoint de cauda + /// (#45, Task 7) assina. Nunca vai ao cliente. + final String? headEntryHash; } /// Verifica a integridade da cadeia de hash de `audit_logs` (LGPD-RT03). @@ -50,59 +75,131 @@ class AuditChainVerification { /// **inserção**; /// 3. `entryHash` recalculado bate com o gravado — detecta **edição de /// conteúdo**, e forjar um hash que bata exige o segredo. +/// +/// Lê em lotes de [batchSize] por keyset de `sequence` (#45, G5): o estado +/// (`sequence` e `entryHash` esperados) atravessa a fronteira entre lotes, +/// então uma linha removida na fronteira quebra a contiguidade igual a uma +/// removida no meio. O primeiro lote começa abaixo de qualquer `sequence` +/// possível, para que uma linha com `sequence <= 0` também seja vista. +/// +/// Com [verify]`(budget:)`, o orçamento é conferido entre lotes; esgotado com +/// linhas ainda por ler, o resultado é `ok: false` com +/// [AuditChainReason.timeBudget] — verificação parcial nunca vira íntegra. class AuditChainVerifier { AuditChainVerifier({ required AuditChainReader reader, required String secret, - }) : _reader = reader, - _chain = AuditChain(secret: secret); + }) : _reader = reader, + _secretAvailable = secret.trim().isNotEmpty, + _chain = AuditChain(secret: secret); final AuditChainReader _reader; + final bool _secretAvailable; final AuditChain _chain; - Future verify() async { - final entries = await _reader.readInOrder(); + static const batchSize = 1000; + + /// Menor `int` de 64 bits: o `afterSequence` do primeiro lote. + static const _antesDeTudo = -0x8000000000000000; + + Future verify({ + Duration? budget, + DateTime Function()? clock, + }) async { + if (!_secretAvailable) { + return const AuditChainVerification( + ok: false, + checked: 0, + reason: 'segredo da cadeia ausente', + reasonCode: AuditChainReason.secretUnavailable, + ); + } + final agora = clock ?? DateTime.now; + final inicio = agora(); var expectedSequence = 1; var expectedPreviousHash = AuditChain.genesisHash; + var afterSequence = _antesDeTudo; - for (final entry in entries) { - final fields = entry.fields; - - if (fields.sequence != expectedSequence) { + while (true) { + final List entries; + try { + entries = await _reader.readBatch( + afterSequence: afterSequence, + limit: batchSize, + ); + } on Exception { 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)', + reason: 'falha ao ler audit_logs', + reasonCode: AuditChainReason.databaseError, ); } - 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)', - ); + 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)', + reasonCode: AuditChainReason.sequenceGap, + ); + } + + 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)', + reasonCode: AuditChainReason.linkBroken, + ); + } + + 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)', + reasonCode: AuditChainReason.hashMismatch, + ); + } + + expectedPreviousHash = entry.entryHash; + expectedSequence++; } - if (!_chain.matches(fields, entry.entryHash)) { + if (entries.length < batchSize) break; + afterSequence = entries.last.fields.sequence; + + if (budget != null && agora().difference(inicio) > budget) { 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)', + reason: + 'orçamento de tempo esgotado antes do fim da cadeia ' + '(verificação parcial)', + reasonCode: AuditChainReason.timeBudget, ); } - - expectedPreviousHash = entry.entryHash; - expectedSequence++; } - return AuditChainVerification(ok: true, checked: entries.length); + final checked = expectedSequence - 1; + return AuditChainVerification( + ok: true, + checked: checked, + headEntryHash: checked > 0 ? expectedPreviousHash : null, + ); } } diff --git a/backend/sinalacs_server/lib/src/application/audit/audit_csv.dart b/backend/sinalacs_server/lib/src/application/audit/audit_csv.dart new file mode 100644 index 00000000..c759151c --- /dev/null +++ b/backend/sinalacs_server/lib/src/application/audit/audit_csv.dart @@ -0,0 +1,91 @@ +/// Linha exportada da trilha de auditoria (D6). +/// +/// Tipo próprio e fechado: o gerador não recebe `AuditLog`/`AuditChainEntry`, +/// então `userId`, `resourceId`, `ipHash`, `previousHash` e `entryHash` não +/// têm como chegar ao CSV. Adicionar campo aqui quebra o teste de lista +/// fechada de propósito. +class AuditExportRow { + const AuditExportRow({ + required this.sequence, + required this.timestampUtc, + required this.autor, + required this.papelAutor, + required this.acao, + required this.recurso, + required this.rotuloRecurso, + required this.resultado, + required this.selo, + }); + + final int sequence; + final DateTime timestampUtc; + final String autor; + final String papelAutor; + final String acao; + final String recurso; + final String rotuloRecurso; + final String resultado; + + /// Estado do selo da linha como texto (ex.: `valid`), sem acoplar ao enum + /// do cliente gerado. + final String selo; +} + +/// Gerador puro do CSV de exportação da auditoria (RFC 4180, CRLF, UTF-8). +abstract final class AuditCsv { + static const columns = [ + 'sequence', + 'timestamp_utc', + 'autor', + 'papel_autor', + 'acao', + 'recurso', + 'rotulo_recurso', + 'resultado', + 'selo', + ]; + + static const _crlf = '\r\n'; + + static String build(List rows) { + final out = StringBuffer() + ..write(columns.join(',')) + ..write(_crlf); + for (final r in rows) { + // `sequence` (int) e `timestamp_utc` (ISO-8601 gerado aqui) nunca vêm + // de texto externo: não passam pela defesa de fórmula, que prefixaria + // apenas por engano um valor legítimo. Todo o resto é texto de origem + // externa e é neutralizado. + out + ..write( + [ + r.sequence.toString(), + r.timestampUtc.toUtc().toIso8601String(), + _texto(r.autor), + _texto(r.papelAutor), + _texto(r.acao), + _texto(r.recurso), + _texto(r.rotuloRecurso), + _texto(r.resultado), + _texto(r.selo), + ].map(_escape).join(','), + ) + ..write(_crlf); + } + return out.toString(); + } + + /// Injeção de fórmula (CSV injection): célula que começa com `=`, `+`, `-` + /// ou `@` (também depois de TAB/CR/LF/espaços iniciais) ganha o prefixo `'`. + static final _inicioDeFormula = RegExp(r'^[\t\r\n ]*[=+\-@]'); + + static String _texto(String valor) => + _inicioDeFormula.hasMatch(valor) ? "'$valor" : valor; + + static String _escape(String campo) { + if (campo.contains(RegExp(r'[",\r\n]'))) { + return '"${campo.replaceAll('"', '""')}"'; + } + return campo; + } +} diff --git a/backend/sinalacs_server/lib/src/application/audit/audit_row_sealer.dart b/backend/sinalacs_server/lib/src/application/audit/audit_row_sealer.dart new file mode 100644 index 00000000..1cb572c8 --- /dev/null +++ b/backend/sinalacs_server/lib/src/application/audit/audit_row_sealer.dart @@ -0,0 +1,65 @@ +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'; + +/// O que o selo de uma linha precisa e que **nunca** sai do servidor: os +/// campos da cadeia da própria linha (com `entryHash`) e o `entryHash` da linha +/// `sequence - 1` lido do banco — nulo quando essa linha não existe. Não é +/// modelo `.spy.yaml` de propósito: não tem serialização. +class AuditSealInput { + const AuditSealInput({required this.row, required this.previousEntryHash}); + + final AuditChainEntry row; + final String? previousEntryHash; +} + +/// Selo por linha de `audit_logs` (#45, D3) para a tela do backoffice. +/// +/// Usa a MESMA [AuditChain] que grava a trilha (`OrmAuditTrail`): nenhum +/// segundo cálculo de HMAC. Cada linha é conferida contra o próprio conteúdo +/// e contra o `entryHash` da linha `sequence - 1` **que o banco devolve** — +/// nunca contra a linha vizinha da página: com filtro, as linhas de uma página +/// não têm `sequence` contígua, e compará-las entre si daria "elo quebrado" +/// falso (Review Focus 1). +/// +/// Regra, nesta ordem: +/// 1. `sequence < 1` ou HMAC que não confere ⇒ [AuditRowSeal.invalid] — o +/// HMAC já prova a adulteração, com ou sem vizinho; +/// 2. `sequence == 1`: o elo é a gênese ⇒ `verified` se `previousHash` é +/// [AuditChain.genesisHash], senão `invalid`; +/// 3. vizinho ausente (`previousEntryHash` nulo) ⇒ [AuditRowSeal.unverifiable] +/// — **nunca** `verified` por omissão; +/// 4. `previousHash` diferente do `entryHash` do vizinho ⇒ `invalid`; +/// 5. senão ⇒ [AuditRowSeal.verified]. +/// +/// **Limite (documentado):** o selo olha só para trás. Se a linha N for +/// editada no banco, N fica `invalid`, mas a linha N+1 — cujo `previousHash` +/// aponta para o `entryHash` ORIGINAL de N, que continua gravado — segue +/// `verified`: o `entryHash` de N não foi recalculado, então o elo confere. +/// Quem vê a quebra a jusante, remoção de linhas e truncamento de cauda é a +/// verificação global ([AuditChainVerifier]), não o selo. +class AuditRowSealer { + AuditRowSealer(this._chain); + + final AuditChain _chain; + + AuditRowSeal seal(AuditChainEntry row, {required String? previousEntryHash}) { + final fields = row.fields; + if (fields.sequence < 1 || !_chain.matches(fields, row.entryHash)) { + return AuditRowSeal.invalid; + } + if (fields.sequence == 1) { + return fields.previousHash == AuditChain.genesisHash + ? AuditRowSeal.verified + : AuditRowSeal.invalid; + } + if (previousEntryHash == null) return AuditRowSeal.unverifiable; + return fields.previousHash == previousEntryHash + ? AuditRowSeal.verified + : AuditRowSeal.invalid; + } + + /// Atalho para o par que o store devolve. + AuditRowSeal sealInput(AuditSealInput input) => + seal(input.row, previousEntryHash: input.previousEntryHash); +} diff --git a/backend/sinalacs_server/lib/src/application/audit/audit_trail.dart b/backend/sinalacs_server/lib/src/application/audit/audit_trail.dart index 91025ae4..28a7694f 100644 --- a/backend/sinalacs_server/lib/src/application/audit/audit_trail.dart +++ b/backend/sinalacs_server/lib/src/application/audit/audit_trail.dart @@ -56,7 +56,8 @@ abstract class AuditTrail { await record(event); } catch (error) { stderr.writeln( - 'Falha ao gravar auditoria (${event.actionType}/${event.resourceType}): $error.', + 'Falha ao gravar auditoria (${event.actionType}/${event.resourceType}, ' + 'erro ${error.runtimeType}).', ); } } diff --git a/backend/sinalacs_server/lib/src/application/auth/authorization.dart b/backend/sinalacs_server/lib/src/application/auth/authorization.dart index cbc2b520..9e44215b 100644 --- a/backend/sinalacs_server/lib/src/application/auth/authorization.dart +++ b/backend/sinalacs_server/lib/src/application/auth/authorization.dart @@ -16,6 +16,10 @@ import 'package:sinalacs_server/src/generated/protocol.dart'; /// serializada ao cliente. Uma guarda que lançasse um tipo próprio quebraria as /// duas traduções de uma vez. abstract final class Authorization { + /// Papéis do backoffice. Os endpoints de staff (issue #40) usam este conjunto + /// com `requireMicroArea: false`: staff não é territorializado. + static const Set staffRoles = {UserRole.coordinator, UserRole.admin}; + /// Exige que [user] tenha um dos [roles] e, quando [requireMicroArea], que o /// token carregue um território. /// diff --git a/backend/sinalacs_server/lib/src/application/auth/institutional_auth_service.dart b/backend/sinalacs_server/lib/src/application/auth/institutional_auth_service.dart index 70b6c862..64dbe75e 100644 --- a/backend/sinalacs_server/lib/src/application/auth/institutional_auth_service.dart +++ b/backend/sinalacs_server/lib/src/application/auth/institutional_auth_service.dart @@ -1,8 +1,87 @@ +import 'dart:math'; +import 'dart:typed_data'; + 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/auth/password_hasher.dart'; +import 'package:sinalacs_server/src/application/auth/staff_activation_code.dart'; +import 'package:sinalacs_server/src/application/auth/totp.dart'; +import 'package:sinalacs_server/src/application/auth/totp_secret_vault.dart'; import 'package:sinalacs_server/src/generated/protocol.dart'; +/// Estado da MFA de um ACS, como está gravado. +class TotpEnrollment { + const TotpEnrollment({required this.sealed, required this.enabled, required this.lastStep}); + + final SealedSecret sealed; + + /// `false` = a ativação começou e não foi confirmada: a MFA ainda NÃO vale. + final bool enabled; + final int? lastStep; +} + +/// Escrita do estado da MFA. Interface à parte de [AcsCredentialStore] de +/// propósito: quem só lê credencial (e os fakes que já existem) não muda. +abstract interface class TotpStore { + /// Grava um segredo **pendente**. Devolve `false` sem escrever quando a MFA + /// já está ativa na linha: uma ativação concorrente que confirmou antes não + /// pode ser sobrescrita por quem só conhece a senha. + Future saveSecret(String acsId, SealedSecret secret, DateTime at); + + /// Ativa a MFA com [pending], o segredo que o código conferiu. Devolve + /// `false` sem escrever quando a linha já não está pendente com esse mesmo + /// segredo (outra confirmação ganhou, ou um novo início trocou o segredo). + Future enable(String acsId, SealedSecret pending, int step, DateTime at); + + /// Avança o último passo aceito para [step]. Devolve `true` só se a linha + /// avançou; `false` = outra requisição já gravou este passo (ou um maior): + /// o código é um replay e a sessão **não** pode ser emitida. + Future registerStep(String acsId, int step); + + /// Apaga o estado da MFA da conta — as **quatro** colunas `totp*` voltam a + /// NULL — e carimba `updatedAt` com [at]. É a redefinição pela coordenação + /// (issue #43): o caminho que a recusa "Peça a redefinição à coordenação" + /// não tinha. + /// + /// As quatro juntas, numa instrução: apagar só o segredo deixaria + /// `totpEnabledAt` para trás e a conta continuaria com MFA **ativa** + /// apontando para um segredo que não existe (o login passaria a exigir um + /// código que ninguém consegue gerar); apagar só a ativação deixaria um + /// segredo órfão na linha. Nenhuma política mora aqui: como nas outras + /// escritas do estado, quem decide *quando* redefinir é o serviço. + Future clearTotp(String acsId, DateTime at); +} + +/// Código de ativação vigente de uma conta de staff (#48). +class StaffActivationRecord { + const StaffActivationRecord({required this.codeHash, required this.expiresAt}); + + final String codeHash; + final DateTime expiresAt; +} + +/// Código de ativação de uso único da MFA do staff. Interface à parte, como +/// [TotpStore]: só a audiência `staff` a usa. +abstract interface class StaffActivationStore { + /// Grava o hash do código e a validade, substituindo qualquer código anterior. + Future issue( + String staffId, { + required String codeHash, + required DateTime expiresAt, + required String issuedBy, + required DateTime at, + }); + + /// Código vigente da conta, ou `null` (nunca emitido ou já consumido). + Future find(String staffId); + + /// Apaga hash e validade; `issuedBy`/`issuedAt` ficam como último registro. + Future clear(String staffId); +} + +/// Quem está entrando: o ACS (com território) ou o staff do backoffice. +enum CredentialAudience { acs, staff } + /// O que o login precisa saber sobre uma credencial, sem `application/` /// conhecer o ORM. class AcsCredentialRecord { @@ -10,9 +89,12 @@ class AcsCredentialRecord { required this.acsId, required this.microAreaId, required this.active, + this.role = UserRole.acs, required this.digest, required this.failedAttempts, required this.lockedUntil, + this.lockStreak = 0, + this.totp, }); final String acsId; @@ -22,9 +104,21 @@ class AcsCredentialRecord { final String? microAreaId; final bool active; + + /// `users.role` da conta. Na audiência `staff` decide quem entra + /// (`coordinator` ou `admin`) e é o papel que vai no token; na audiência + /// `acs` o store já só devolve linha de papel `acs`. + final UserRole role; + final PasswordDigest digest; final int failedAttempts; final DateTime? lockedUntil; + + /// Quantas vezes seguidas a conta bloqueou sem um login válido no meio. + final int lockStreak; + + /// Estado da MFA. `null` = nenhum segredo gravado (sem MFA). + final TotpEnrollment? totp; } /// Acesso à credencial do ACS e ao estado de bloqueio. @@ -70,7 +164,8 @@ abstract interface class AcsCredentialStore { /// **mesma** mensagem; o caminho da inexistente ainda deriva um hash /// descartado, para o tempo de resposta não dizer o que a mensagem cala. /// 2. A conta bloqueia depois de [maxFailedAttempts] falhas, por -/// [lockDuration] — a exigência de "registro de tentativas de acesso" de +/// [lockDuration] na primeira rodada e o dobro a cada rodada seguinte sem um +/// login válido no meio (até [maxLockDuration]; ver [lockDurationFor]) — a exigência de "registro de tentativas de acesso" de /// `spec/lgpd_design.md` e o achado F6 de `spec/security_assessment.md`. /// A contagem em si é aplicada pelo store numa única instrução, para não /// perder atualização sob concorrência. @@ -88,30 +183,235 @@ class InstitutionalAuthService { required this.store, required this.hasher, required this.audit, - }); + this.totpStore, + this.activationStore, + this.vault, + this.requireMfa = false, + this.audience = CredentialAudience.acs, + Random? random, + }) : _random = random ?? Random.secure(); final AcsCredentialStore store; final PasswordHasher hasher; final AuditTrail audit; + /// Escrita do estado da MFA. Obrigatório quando algum ACS tem MFA (ou para + /// ativá-la); a falta dele com MFA ativa é erro de montagem ([StateError]). + final TotpStore? totpStore; + + /// Código de ativação de uso único da MFA do staff (#48). Obrigatório na + /// audiência `staff`; ignorado na `acs`. + final StaffActivationStore? activationStore; + + /// Cofre do segredo TOTP. Mesma regra de [totpStore]. + final TotpSecretVault? vault; + + /// `REQUIRE_ACS_MFA`: ACS sem MFA ativa não entra; precisa ativá-la antes. + final bool requireMfa; + + /// Para quem este serviço abre sessão. `staff` (backoffice) não tem + /// território e tem MFA sempre obrigatória, independente de [requireMfa]. + final CredentialAudience audience; + + bool get _mfaObrigatoria => audience == CredentialAudience.staff || requireMfa; + + /// Sorteia o segredo TOTP. `Random.secure()` fora dos testes. + final Random _random; + static const maxFailedAttempts = 5; static const lockDuration = Duration(minutes: 15); + /// Teto do bloqueio progressivo: bloquear para sempre transformaria o limite + /// num ataque ao acesso do próprio ACS. + static const maxLockDuration = Duration(hours: 24); + + /// Bloqueio da [streak]-ésima rodada seguida (0 = a primeira): `15 min × 2^streak`, + /// no máximo [maxLockDuration]. Sequência negativa vale como zero. + static Duration lockDurationFor(int streak) { + final n = streak < 0 ? 0 : streak; + // 2^7 × 15 min já passa de 24 h; limitar o expoente evita estouro em `<<`. + if (n >= 7) return maxLockDuration; + final d = lockDuration * (1 << n); + return d > maxLockDuration ? maxLockDuration : d; + } + /// Marcador de ausência de identificador de aparelho — mesma convenção de /// `orm_onboarding_store.dart` ('nao-aplicavel-onboarding'). static const deviceIdAbsent = 'nao-aplicavel-login-institucional'; static const _invalidCredentials = 'Matrícula ou senha inválidos.'; + static const _invalidCode = 'Código de verificação inválido.'; + + static const _invalidActivation = 'Código de ativação inválido ou expirado.'; + /// Só chega a quem provou conhecer a senha (a ordem do `login` garante isso). - static const _lockMessage = 'Acesso temporariamente bloqueado por tentativas ' + static const _lockMessage = + 'Acesso temporariamente bloqueado por tentativas ' 'inválidas. Tente novamente em alguns minutos.'; Future login({ required String matricula, required String password, String? deviceId, + String? totpCode, DateTime? now, + }) async { + final at = (now ?? DateTime.now()).toUtc(); + final record = await _authenticatePassword( + matricula: matricula, + password: password, + at: at, + ); + + // Só depois da senha: quem não a conhece recebe a mensagem única e nunca + // fica sabendo se a conta tem MFA. + final totp = record.totp; + if (totp != null && totp.enabled) { + final codigo = totpCode?.trim() ?? ''; + if (codigo.isEmpty) { + // A senha conferiu; falta o código. Não é falha: não conta tentativa. + throw MfaRequiredException(message: 'Informe o código do aplicativo autenticador.'); + } + final segredo = await _vault().open(totp.sealed); + final passo = Totp.verify(segredo, codigo, at, lastStep: totp.lastStep); + if (passo == null) { + await _registrarFalha(record, at); + await _recordAudit(record.acsId, 'denied_totp'); + throw AuthenticationFailedException(message: _invalidCode); + } + // O `verify` acima leu `lastStep` antes; duas requisições com o mesmo + // código passam as duas por ele. Quem decide é o `UPDATE` condicional: + // só uma avança a linha, e a outra é replay (RFC 6238 §5.2). + if (!await _totpStore().registerStep(record.acsId, passo)) { + await _registrarFalha(record, at); + await _recordAudit(record.acsId, 'denied_totp_replay'); + throw AuthenticationFailedException(message: _invalidCode); + } + } else if (_mfaObrigatoria) { + await _recordAudit(record.acsId, 'denied_mfa_not_enrolled'); + throw MfaEnrollmentRequiredException( + message: 'Ative a verificação em duas etapas antes de entrar.', + ); + } + + await store.registerSuccessfulLogin(record.acsId, at); + await _recordAudit(record.acsId, 'granted'); + + final staff = audience == CredentialAudience.staff; + return AuthenticatedUser( + id: record.acsId, + // `_authenticatePassword` só devolve staff com papel `coordinator` ou + // `admin`. + role: staff ? record.role : UserRole.acs, + // Staff não tem território: a sessão dele nunca carrega microárea, nem + // se a linha de `users` tiver uma. Para o ACS, `_authenticatePassword` + // só devolve linha com território. + microAreaId: staff ? null : record.microAreaId!, + deviceId: deviceId ?? deviceIdAbsent, + ); + } + + /// Começa a ativação da MFA. Exige matrícula **e senha** (o ACS ainda não tem + /// token) e conta tentativa errada como o login. Só grava o segredo como + /// "pendente": a MFA vale depois de [confirmTotpEnrollment]. + Future beginTotpEnrollment({ + required String matricula, + required String password, + String? activationCode, + DateTime? now, + }) async { + final at = (now ?? DateTime.now()).toUtc(); + final record = await _authenticatePassword(matricula: matricula, password: password, at: at); + if (record.totp?.enabled ?? false) { + throw AuthenticationFailedException( + message: 'A verificação em duas etapas já está ativa. Peça a redefinição à coordenação.', + ); + } + await _exigirCodigoDeAtivacao(record, activationCode, at); + final bytes = Uint8List.fromList(List.generate(20, (_) => _random.nextInt(256))); + if (!await _totpStore().saveSecret(record.acsId, await _vault().seal(bytes), at)) { + // Uma confirmação concorrente ativou a MFA entre a leitura e a escrita. + throw AuthenticationFailedException( + message: 'A verificação em duas etapas já está ativa. Peça a redefinição à coordenação.', + ); + } + // Sobrescreve qualquer segredo pendente anterior: fica na trilha. + await _recordAudit(record.acsId, 'mfa_enrollment_started'); + final base32 = Totp.base32(bytes); + return TotpEnrollmentStart( + secretBase32: base32, + otpauthUri: Totp.otpauthUri(secretBase32: base32, account: matricula.trim()), + ); + } + + /// Confirma a ativação com um código válido do segredo pendente. + Future confirmTotpEnrollment({ + required String matricula, + required String password, + required String code, + String? activationCode, + DateTime? now, + }) async { + final at = (now ?? DateTime.now()).toUtc(); + final record = await _authenticatePassword(matricula: matricula, password: password, at: at); + final totp = record.totp; + if (totp == null || totp.enabled) { + throw AuthenticationFailedException(message: 'Não há ativação pendente para esta matrícula.'); + } + await _exigirCodigoDeAtivacao(record, activationCode, at); + final passo = Totp.verify(await _vault().open(totp.sealed), code, at); + if (passo == null) { + await _registrarFalha(record, at); + await _recordAudit(record.acsId, 'denied_totp_enrollment'); + throw AuthenticationFailedException(message: _invalidCode); + } + if (!await _totpStore().enable(record.acsId, totp.sealed, passo, at)) { + await _registrarFalha(record, at); + await _recordAudit(record.acsId, 'denied_totp_enrollment_race'); + throw AuthenticationFailedException(message: 'Não há ativação pendente para esta matrícula.'); + } + await activationStore?.clear(record.acsId); + await _recordAudit(record.acsId, 'mfa_enabled'); + } + + /// Só a audiência staff exige o código. Erro conta como falha de login e vira + /// auditoria; o texto é um só para não dizer se o código existiu, expirou ou + /// já foi usado. + Future _exigirCodigoDeAtivacao( + AcsCredentialRecord record, + String? code, + DateTime at, + ) async { + if (audience != CredentialAudience.staff) return; + final store = activationStore ?? (throw StateError('staff sem store de código de ativação')); + final vigente = await store.find(record.acsId); + final ok = vigente != null && + code != null && + at.isBefore(vigente.expiresAt) && + StaffActivationCode.matches(code, vigente.codeHash); + if (ok) return; + await _registrarFalha(record, at); + await _recordAudit(record.acsId, 'denied_staff_activation_code'); + throw AuthenticationFailedException(message: _invalidActivation); + } + + TotpSecretVault _vault() => vault ?? (throw StateError('MFA ativa sem cofre configurado')); + + TotpStore _totpStore() => + totpStore ?? (throw StateError('MFA ativa sem store de TOTP configurado')); + + /// Tudo o que o login decide antes de abrir a sessão: entrada em branco, + /// matrícula inexistente, senha errada (com a contagem e o bloqueio), + /// bloqueio ativo, acesso inativo, papel que não é de staff (audiência + /// `staff`) e ausência de território (audiência `acs`). Devolve a linha + /// só quando a senha confere e nada disso recusa; nos outros casos lança + /// exatamente o que o `login` sempre lançou. A ativação da MFA reaproveita + /// este caminho: provar a senha ali custa o mesmo que no login. + Future _authenticatePassword({ + required String matricula, + required String password, + required DateTime at, }) async { final enrollmentId = matricula.trim(); if (enrollmentId.isEmpty || password.isEmpty) { @@ -120,7 +420,6 @@ class InstitutionalAuthService { ); } - final at = (now ?? DateTime.now()).toUtc(); final record = await store.findByEnrollmentId(enrollmentId); if (record == null) { @@ -189,14 +488,7 @@ class InstitutionalAuthService { // O reinício viaja como decisão (`restartCounter`) porque quem aplica a // contagem é o store, numa única instrução: somar aqui, sobre o valor // lido acima, perderia tentativas concorrentes. - final lockExpirou = lockedUntil != null && !lockedUntil.isAfter(at); - await store.registerFailedAttempt( - record.acsId, - restartCounter: lockExpirou, - maxFailedAttempts: maxFailedAttempts, - lockUntil: at.add(lockDuration), - at: at, - ); + await _registrarFalha(record, at); await _recordAudit(record.acsId, 'denied_credentials'); throw AuthenticationFailedException(message: _invalidCredentials); } @@ -213,33 +505,60 @@ class InstitutionalAuthService { throw AuthenticationFailedException(message: 'Este acesso está inativo.'); } - final microAreaId = record.microAreaId; - if (microAreaId == null) { - await _recordAudit(record.acsId, 'denied_no_territory'); - throw AuthenticationFailedException( - message: 'Este acesso não está vinculado a uma microárea.', - ); + // Só o coordenador e o administrador entram no backoffice. A recusa usa a + // mensagem genérica de propósito, mesmo com a senha certa: dizer "papel + // errado" confirmaria a quem tem a senha de um ACS que aquela matrícula + // existe numa tabela que não é a dele. A trilha guarda o motivo real. + if (audience == CredentialAudience.staff && + record.role != UserRole.coordinator && + record.role != UserRole.admin) { + await _recordAudit(record.acsId, 'denied_role'); + throw AuthenticationFailedException(message: _invalidCredentials); } - await store.registerSuccessfulLogin(record.acsId, at); - await _recordAudit(record.acsId, 'granted'); + // Território é exigência do ACS (INV-01); o staff não tem microárea. + if (audience == CredentialAudience.acs) { + final microAreaId = record.microAreaId; + if (microAreaId == null) { + await _recordAudit(record.acsId, 'denied_no_territory'); + throw AuthenticationFailedException( + message: 'Este acesso não está vinculado a uma microárea.', + ); + } + } - return AuthenticatedUser( - id: record.acsId, - role: UserRole.acs, - microAreaId: microAreaId, - deviceId: deviceId ?? deviceIdAbsent, + return record; + } + + /// Conta uma tentativa falha (senha errada ou código TOTP errado). + /// Bloqueio vencido recomeça o contador — a mesma regra do ramo da senha + /// errada, num lugar só. + Future _registrarFalha(AcsCredentialRecord record, DateTime at) { + final lockedUntil = record.lockedUntil; + final lockExpirou = lockedUntil != null && !lockedUntil.isAfter(at); + return store.registerFailedAttempt( + record.acsId, + restartCounter: lockExpirou, + maxFailedAttempts: maxFailedAttempts, + lockUntil: at.add(lockDurationFor(record.lockStreak)), + at: at, ); } + /// Distingue a audiência na trilha: revisar a atividade do backoffice não + /// pode exigir juntar `userId` com `staff_accounts` à mão. Vale para todo + /// evento deste serviço (login e ativação da MFA); o ACS continua `session`. + String get _recursoDeAuditoria => + audience == CredentialAudience.staff ? 'staff_session' : 'session'; + /// Best-effort, como toda auditoria deste repositório: uma trilha fora do ar /// não pode impedir um ACS de entrar. Future _recordAudit(String userId, String result) => audit.recordSafely( - AuditEvent( - userId: userId, - actionType: 'login', - resourceType: 'session', - result: result, - ), - ); + AuditEvent( + userId: userId, + actionType: 'login', + resourceType: _recursoDeAuditoria, + result: result, + ), + ); } diff --git a/backend/sinalacs_server/lib/src/application/auth/opaque_token.dart b/backend/sinalacs_server/lib/src/application/auth/opaque_token.dart new file mode 100644 index 00000000..fcecf948 --- /dev/null +++ b/backend/sinalacs_server/lib/src/application/auth/opaque_token.dart @@ -0,0 +1,29 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:crypto/crypto.dart'; + +/// Peças comuns dos tokens opacos do ACS (refresh token e token de envio +/// diferido): 256 bits aleatórios em base64url sem `=`, e o SHA-256 em hex que +/// é a única forma persistida. Um hash simples basta porque o token não vem de +/// uma escolha humana — não há espaço de busca a enumerar. +abstract final class OpaqueToken { + /// Token novo: 32 bytes de [random] (`Random.secure()` em produção). + static String generate(Random random) => base64Url + .encode(List.generate(32, (_) => random.nextInt(256))) + .replaceAll('=', ''); + + /// O que vai para o banco: o SHA-256 em hex, nunca o token. + static String hash(String token) => + sha256.convert(utf8.encode(token)).toString(); + + /// UUID v4 a partir de [random], para o `id` da linha. + static String uuid(Random random) { + final b = List.generate(16, (_) => random.nextInt(256)); + b[6] = (b[6] & 0x0f) | 0x40; + b[8] = (b[8] & 0x3f) | 0x80; + final h = b.map((x) => x.toRadixString(16).padLeft(2, '0')).join(); + return '${h.substring(0, 8)}-${h.substring(8, 12)}-${h.substring(12, 16)}-' + '${h.substring(16, 20)}-${h.substring(20)}'; + } +} diff --git a/backend/sinalacs_server/lib/src/application/auth/refresh_token_service.dart b/backend/sinalacs_server/lib/src/application/auth/refresh_token_service.dart new file mode 100644 index 00000000..378c22ba --- /dev/null +++ b/backend/sinalacs_server/lib/src/application/auth/refresh_token_service.dart @@ -0,0 +1,230 @@ +import 'dart:math'; + +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/auth/opaque_token.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; + +class RefreshAccount { + const RefreshAccount({required this.active, required this.microAreaId}); + final bool active; + final String? microAreaId; +} + +class RefreshTokenRecord { + const RefreshTokenRecord({ + required this.id, + required this.userId, + required this.familyId, + required this.deviceId, + required this.issuedAt, + required this.idleExpiresAt, + required this.absoluteExpiresAt, + this.rotatedAt, + this.revokedAt, + }); + + final String id; + final String userId; + final String familyId; + final String deviceId; + final DateTime issuedAt; + final DateTime idleExpiresAt; + final DateTime absoluteExpiresAt; + final DateTime? rotatedAt; + final DateTime? revokedAt; +} + +abstract interface class RefreshTokenStore { + /// Insere **só se** a família não estiver revogada. `false` = a família foi + /// revogada e nada foi gravado (fecha a corrida com `revokeFamily`). + Future insert(RefreshTokenRecord record, String tokenHash); + Future findByHash(String tokenHash); + + /// Marca `rotatedAt` **só se** a linha ainda não foi rotacionada nem + /// revogada (um `UPDATE` condicional). `false` = outra requisição ganhou. + Future markRotated(String id, DateTime at); + + Future revokeFamily(String familyId, DateTime at); + + /// Todas as famílias do usuário, para a desativação da conta (#43); o + /// `logout` continua revogando só a própria família. + /// + /// Um token emitido na janela de uma desativação concorrente (o login pode + /// ter começado antes de a flag ser gravada) já nasce inutilizável: toda + /// renovação relê a conta e, inativa, revoga a família e recusa — a mesma + /// releitura que sustenta INV-01. + Future revokeAllForUser(String userId, DateTime at); + + Future findAccount(String userId); + + /// Poda do próprio usuário: tokens cujo teto absoluto já passou. + Future deleteExpiredFor(String userId, DateTime before); +} + +class RefreshedSession { + const RefreshedSession({required this.user, required this.refreshToken}); + final AuthenticatedUser user; + final String refreshToken; +} + +class RefreshTokenService { + RefreshTokenService({required this.store, required this.audit, Random? random}) + : _random = random ?? Random.secure(); + + final RefreshTokenStore store; + final AuditTrail audit; + final Random _random; + + static const idleWindow = Duration(hours: 2); + static const absoluteWindow = Duration(hours: 8); + /// Tolerância de rede: a resposta de um refresh pode se perder e o app + /// reapresenta o token antigo. Dentro deste prazo emitimos um filho novo sem + /// revogar. Troca aceita: dois filhos da mesma família podem coexistir por + /// até este prazo (um ladrão com o token roubado também se beneficia), por + /// isso o desfecho é auditado à parte como `refresh_granted_grace`. + static const reuseGrace = Duration(seconds: 30); + static const deniedMessage = 'Sessão expirada. Entre novamente.'; + + /// Primeiro token de uma família nova (login por senha+TOTP). + Future issue(AuthenticatedUser user, {DateTime? now}) async { + final at = (now ?? DateTime.now()).toUtc(); + if (user.role != UserRole.acs) { + throw ArgumentError.value(user.role, 'user.role', 'só o ACS tem refresh'); + } + await store.deleteExpiredFor(user.id, at); + final token = await _insert( + userId: user.id, + familyId: _uuid(), + deviceId: user.deviceId, + at: at, + absoluteExpiresAt: at.add(absoluteWindow), + ); + if (token == null) { + throw StateError('Família nova não pode estar revogada.'); + } + return token; + } + + Future refresh({ + required String refreshToken, + required String deviceId, + DateTime? now, + }) async { + final at = (now ?? DateTime.now()).toUtc(); + final record = await store.findByHash(_hash(refreshToken)); + if (record == null) throw _denied(); // sem sujeito para auditar + + if (record.revokedAt != null) { + await _audit(record.userId, 'denied_revoked'); + throw _denied(); + } + if (record.deviceId != deviceId) { + await store.revokeFamily(record.familyId, at); + await _audit(record.userId, 'denied_device'); + throw _denied(); + } + final rotatedAt = record.rotatedAt; + if (rotatedAt != null && at.difference(rotatedAt) > reuseGrace) { + await store.revokeFamily(record.familyId, at); + await _audit(record.userId, 'denied_reuse'); + throw _denied(); + } + + if (!at.isBefore(record.absoluteExpiresAt) || + !at.isBefore(record.idleExpiresAt)) { + await _audit(record.userId, 'denied_expired'); + throw _denied(); + } + + final account = await store.findAccount(record.userId); + final microAreaId = account?.microAreaId; + if (account == null || !account.active || microAreaId == null) { + await store.revokeFamily(record.familyId, at); + await _audit(record.userId, 'denied_inactive'); + throw _denied(); + } + + // Quem perde a corrida do UPDATE é uma chamada concorrente com o mesmo + // token: se a linha só foi rotacionada, segue na tolerância do reuso; se + // foi revogada nesse meio-tempo, recusa. + if (rotatedAt == null && !await store.markRotated(record.id, at)) { + final fresh = await store.findByHash(_hash(refreshToken)); + if (fresh == null || fresh.revokedAt != null) { + await _audit(record.userId, 'denied_revoked'); + throw _denied(); + } + } + + final child = await _insert( + userId: record.userId, + familyId: record.familyId, + deviceId: deviceId, + at: at, + absoluteExpiresAt: record.absoluteExpiresAt, + ); + if (child == null) { + await _audit(record.userId, 'denied_revoked'); + throw _denied(); + } + await _audit(record.userId, + rotatedAt != null ? 'refresh_granted_grace' : 'refresh_granted'); + return RefreshedSession( + user: AuthenticatedUser( + id: record.userId, + role: UserRole.acs, + microAreaId: microAreaId, // relida agora (INV-01) + deviceId: deviceId, + ), + refreshToken: child, + ); + } + + Future revoke(String refreshToken, {DateTime? now}) async { + final at = (now ?? DateTime.now()).toUtc(); + final record = await store.findByHash(_hash(refreshToken)); + if (record == null) return; + await store.revokeFamily(record.familyId, at); + await _audit(record.userId, 'logout'); + } + + Future _insert({ + required String userId, + required String familyId, + required String deviceId, + required DateTime at, + required DateTime absoluteExpiresAt, + }) async { + final token = OpaqueToken.generate(_random); + final idle = at.add(idleWindow); + final stored = await store.insert( + RefreshTokenRecord( + id: _uuid(), + userId: userId, + familyId: familyId, + deviceId: deviceId, + issuedAt: at, + idleExpiresAt: idle.isAfter(absoluteExpiresAt) ? absoluteExpiresAt : idle, + absoluteExpiresAt: absoluteExpiresAt, + ), + _hash(token), + ); + return stored ? token : null; + } + + String _hash(String token) => OpaqueToken.hash(token); + + String _uuid() => OpaqueToken.uuid(_random); + + SessionExpiredException _denied() => + SessionExpiredException(message: deniedMessage); + + Future _audit(String userId, String result) => audit.recordSafely( + AuditEvent( + userId: userId, + actionType: 'login', + resourceType: 'session_refresh', + result: result, + ), + ); +} diff --git a/backend/sinalacs_server/lib/src/application/auth/staff_activation_code.dart b/backend/sinalacs_server/lib/src/application/auth/staff_activation_code.dart new file mode 100644 index 00000000..76bed6e7 --- /dev/null +++ b/backend/sinalacs_server/lib/src/application/auth/staff_activation_code.dart @@ -0,0 +1,43 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:crypto/crypto.dart'; + +/// Código de ativação de uso único da MFA do staff (issue #48). +/// +/// 130 bits de entropia: SHA-256 puro basta como hash (não há senha fraca a +/// proteger com KDF lento), e o código só existe em claro na saída da CLI. +class StaffActivationCode { + const StaffActivationCode._(); + + static const defaultValidity = Duration(hours: 24); + static const _alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + static const _length = 26; + + static String generate([Random? random]) { + final r = random ?? Random.secure(); + final raw = List.generate(_length, (_) => _alphabet[r.nextInt(_alphabet.length)]); + final grupos = []; + for (var i = 0; i < raw.length; i += 4) { + grupos.add(raw.sublist(i, i + 4 > raw.length ? raw.length : i + 4).join()); + } + return grupos.join('-'); + } + + static String normalize(String input) => input.replaceAll(RegExp(r'[\s-]'), '').toUpperCase(); + + static String hash(String code) => sha256.convert(utf8.encode(normalize(code))).toString(); + + /// Comparação em tempo constante do hash de [input] com [storedHash]. + static bool matches(String input, String storedHash) { + if (normalize(input).isEmpty) return false; + final a = utf8.encode(hash(input)); + final b = utf8.encode(storedHash); + if (a.length != b.length) return false; + var diff = 0; + for (var i = 0; i < a.length; i++) { + diff |= a[i] ^ b[i]; + } + return diff == 0; + } +} diff --git a/backend/sinalacs_server/lib/src/application/auth/totp.dart b/backend/sinalacs_server/lib/src/application/auth/totp.dart new file mode 100644 index 00000000..24e3fb98 --- /dev/null +++ b/backend/sinalacs_server/lib/src/application/auth/totp.dart @@ -0,0 +1,89 @@ +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; + +/// TOTP (RFC 6238) com SHA-1, 6 dígitos e passo de 30 s — o padrão que todo +/// aplicativo autenticador entende. Função pura: sem relógio, sem I/O. +class Totp { + Totp._(); + + static const period = 30; + static const digits = 6; + static const _alfabeto = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + + static int stepOf(DateTime at) => at.toUtc().millisecondsSinceEpoch ~/ 1000 ~/ period; + + static String code(Uint8List secret, DateTime at) => _hotp(secret, stepOf(at)); + + /// Passo (`stepOf`) em que [code] confere, dentro de ±[window] passos de [at], + /// ou `null`. Com [lastStep], só aceita passo **maior** que ele: é o que + /// impede reaproveitar um código já usado dentro da janela (replay). + static int? verify( + Uint8List secret, + String code, + DateTime at, { + int window = 1, + int? lastStep, + }) { + final limpo = code.replaceAll(RegExp(r'\s'), ''); + if (limpo.length != digits || !RegExp(r'^\d+$').hasMatch(limpo)) return null; + final atual = stepOf(at); + // Todos os passos da janela são calculados e comparados, sem sair no + // primeiro que confere, e cada comparação percorre os seis dígitos: o tempo + // de resposta não diz em que passo, nem em que dígito, o código divergiu. + int? aceito; + for (var passo = atual - window; passo <= atual + window; passo++) { + final confere = _iguaisEmTempoConstante(_hotp(secret, passo), limpo); + if (confere && aceito == null && (lastStep == null || passo > lastStep)) { + aceito = passo; + } + } + return aceito; + } + + /// Compara sem saída antecipada no primeiro caractere diferente. + static bool _iguaisEmTempoConstante(String a, String b) { + if (a.length != b.length) return false; + var diferenca = 0; + for (var i = 0; i < a.length; i++) { + diferenca |= a.codeUnitAt(i) ^ b.codeUnitAt(i); + } + return diferenca == 0; + } + + static String _hotp(Uint8List secret, int counter) { + final mensagem = ByteData(8)..setUint64(0, counter); + final h = Hmac(sha1, secret).convert(mensagem.buffer.asUint8List()).bytes; + final o = h[h.length - 1] & 0x0f; + final bin = ((h[o] & 0x7f) << 24) | (h[o + 1] << 16) | (h[o + 2] << 8) | h[o + 3]; + return (bin % 1000000).toString().padLeft(digits, '0'); + } + + /// Base32 (RFC 4648, sem preenchimento): o formato do segredo que o aplicativo + /// autenticador pede quando a pessoa não lê o QR. + static String base32(Uint8List bytes) { + var bits = 0; + var valor = 0; + final saida = StringBuffer(); + for (final b in bytes) { + valor = (valor << 8) | b; + bits += 8; + while (bits >= 5) { + saida.write(_alfabeto[(valor >> (bits - 5)) & 31]); + bits -= 5; + } + valor &= (1 << bits) - 1; + } + if (bits > 0) saida.write(_alfabeto[(valor << (5 - bits)) & 31]); + return saida.toString(); + } + + static String otpauthUri({ + required String secretBase32, + required String account, + String issuer = 'SinalACS', + }) => + 'otpauth://totp/${Uri.encodeComponent(issuer)}:${Uri.encodeComponent(account)}' + '?secret=$secretBase32&issuer=${Uri.encodeComponent(issuer)}' + '&algorithm=SHA1&digits=$digits&period=$period'; +} diff --git a/backend/sinalacs_server/lib/src/application/auth/totp_secret_vault.dart b/backend/sinalacs_server/lib/src/application/auth/totp_secret_vault.dart new file mode 100644 index 00000000..94967ac9 --- /dev/null +++ b/backend/sinalacs_server/lib/src/application/auth/totp_secret_vault.dart @@ -0,0 +1,16 @@ +import 'dart:typed_data'; + +/// Segredo TOTP já cifrado, com a versão da chave que o cifrou. +class SealedSecret { + const SealedSecret({required this.ciphertextBase64, required this.keyVersion}); + + final String ciphertextBase64; + final int keyVersion; +} + +/// Cifra e decifra o segredo TOTP. A camada de aplicação não conhece a cifra: +/// quem decide o algoritmo é `infrastructure/` (`HealthCipherTotpVault`). +abstract interface class TotpSecretVault { + Future seal(Uint8List secret); + Future open(SealedSecret sealed); +} diff --git a/backend/sinalacs_server/lib/src/application/auth/upload_token_service.dart b/backend/sinalacs_server/lib/src/application/auth/upload_token_service.dart new file mode 100644 index 00000000..95645e06 --- /dev/null +++ b/backend/sinalacs_server/lib/src/application/auth/upload_token_service.dart @@ -0,0 +1,172 @@ +import 'dart:math'; + +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/auth/institutional_auth_service.dart'; +import 'package:sinalacs_server/src/application/auth/opaque_token.dart'; +import 'package:sinalacs_server/src/application/auth/refresh_token_service.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; + +class UploadTokenRecord { + const UploadTokenRecord({ + required this.id, + required this.userId, + required this.deviceId, + required this.issuedAt, + required this.expiresAt, + this.revokedAt, + }); + + final String id; + final String userId; + final String deviceId; + final DateTime issuedAt; + final DateTime expiresAt; + final DateTime? revokedAt; +} + +abstract interface class UploadTokenStore { + /// Revoga (com `revokedAt = record.issuedAt`) o token vigente do mesmo + /// (`userId`, `deviceId`) e grava [record] — sem janela em que dois + /// vigentes do mesmo par sobrevivam a emissões concorrentes. + Future replace(UploadTokenRecord record, String tokenHash); + Future findByHash(String tokenHash); + + /// Marca `revokedAt` se a linha ainda está vigente; nada se já revogada. + Future revoke(String id, DateTime at); + + /// Todos os tokens do usuário, para a desativação da conta (#43): o envio + /// diferido não depende de sessão, então derrubar só o refresh deixaria o + /// aparelho de um ACS desativado subindo visita em nome dele até o token + /// vencer (7 dias). + /// + /// Como no refresh, um token emitido na janela de uma desativação + /// concorrente já nasce inutilizável: [UploadTokenService.resolve] relê a + /// conta a cada uso e, inativa, revoga e recusa. + Future revokeAllForUser(String userId, DateTime at); + + /// Mesma leitura do refresh token: só conta de ACS, com `acs.active` e a + /// microárea atual de `users`. + Future findAccount(String userId); + + /// Poda do próprio usuário: tokens cujo prazo já passou. + Future deleteExpiredFor(String userId, DateTime before); +} + +/// Token de envio diferido do ACS (D7 do plano 2026-10-03). +/// +/// Deixa o aparelho subir as visitas pendentes de um ACS que já saiu, **com a +/// autoria dele**, sem sessão: o único uso é `visits.syncDeferred` (e a +/// revogação). Não lê nada, não renova sessão, não serve a nenhuma outra +/// chamada — por isso é independente da família do refresh token, e o "Sair" +/// não o derruba (o app o revoga quando a fila do dono zera). +/// +/// Mesmo desenho do [RefreshTokenService]: token opaco de 256 bits, só o +/// SHA-256 no banco, amarrado ao `deviceId` da instalação, e toda recusa é a +/// mesma [SessionExpiredException] com [deniedMessage] — sem dizer o motivo. +/// A microárea do usuário devolvido é **relida do banco** a cada uso (INV-01): +/// o envio diferido entra no território atual do dono, nunca no da emissão. +class UploadTokenService { + UploadTokenService({required this.store, required this.audit, Random? random}) + : _random = random ?? Random.secure(); + + final UploadTokenStore store; + final AuditTrail audit; + final Random _random; + + static const lifetime = Duration(days: 7); + static const deniedMessage = 'Envio não autorizado. Entre novamente.'; + + /// Emite um token novo para (`user.id`, `user.deviceId`) e revoga o anterior + /// do mesmo par. Exige um aparelho real: o sentinela de + /// `InstitutionalAuthService.deviceIdAbsent` é público e anularia a amarração. + Future issue(AuthenticatedUser user, {DateTime? now}) async { + final at = (now ?? DateTime.now()).toUtc(); + if (user.role != UserRole.acs) { + throw ArgumentError.value(user.role, 'user.role', 'só o ACS tem envio diferido'); + } + final device = user.deviceId; + if (device.trim().isEmpty || device == InstitutionalAuthService.deviceIdAbsent) { + throw ArgumentError.value('', 'user.deviceId', 'aparelho obrigatório'); + } + await store.deleteExpiredFor(user.id, at); + final token = OpaqueToken.generate(_random); + await store.replace( + UploadTokenRecord( + id: OpaqueToken.uuid(_random), + userId: user.id, + deviceId: device, + issuedAt: at, + expiresAt: at.add(lifetime), + ), + OpaqueToken.hash(token), + ); + await _audit(user.id, 'upload_token_issued'); + return token; + } + + /// O dono do token, como ACS da microárea ATUAL. Qualquer recusa — + /// desconhecido, vencido, revogado, outro aparelho, conta inativa ou sem + /// microárea — lança a mesma [SessionExpiredException]. + Future resolve({ + required String uploadToken, + required String deviceId, + DateTime? now, + }) async { + final at = (now ?? DateTime.now()).toUtc(); + final record = await store.findByHash(OpaqueToken.hash(uploadToken)); + if (record == null) throw _denied(); // sem sujeito para auditar + + if (record.revokedAt != null) { + await _audit(record.userId, 'upload_token_denied_revoked'); + throw _denied(); + } + if (record.deviceId != deviceId) { + // Token fora do aparelho que o recebeu: sinal de vazamento. Revoga. + await store.revoke(record.id, at); + await _audit(record.userId, 'upload_token_denied_device'); + throw _denied(); + } + if (!at.isBefore(record.expiresAt)) { + await _audit(record.userId, 'upload_token_denied_expired'); + throw _denied(); + } + + final account = await store.findAccount(record.userId); + final microAreaId = account?.microAreaId; + if (account == null || !account.active || microAreaId == null) { + await store.revoke(record.id, at); + await _audit(record.userId, 'upload_token_denied_inactive'); + throw _denied(); + } + + return AuthenticatedUser( + id: record.userId, + role: UserRole.acs, + microAreaId: microAreaId, // relida agora (INV-01) + deviceId: record.deviceId, + ); + } + + /// Revoga o token. Idempotente: já revogado ou desconhecido não lança nem + /// audita de novo. + Future revoke(String uploadToken, {DateTime? now}) async { + final at = (now ?? DateTime.now()).toUtc(); + final record = await store.findByHash(OpaqueToken.hash(uploadToken)); + if (record == null || record.revokedAt != null) return; + await store.revoke(record.id, at); + await _audit(record.userId, 'upload_token_revoked'); + } + + SessionExpiredException _denied() => + SessionExpiredException(message: deniedMessage); + + Future _audit(String userId, String result) => audit.recordSafely( + AuditEvent( + userId: userId, + actionType: 'login', + resourceType: 'upload_token', + result: result, + ), + ); +} diff --git a/backend/sinalacs_server/lib/src/application/common/uuid_format.dart b/backend/sinalacs_server/lib/src/application/common/uuid_format.dart new file mode 100644 index 00000000..8e68acfd --- /dev/null +++ b/backend/sinalacs_server/lib/src/application/common/uuid_format.dart @@ -0,0 +1,8 @@ +final RegExp _uuidFormat = RegExp( + r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$', +); + +/// `true` se [value] tem o formato UUID 8-4-4-4-12 (hex). Serve para barrar um +/// identificador vindo do cliente antes de ele chegar a um `::uuid` do SQL, que +/// viraria erro 500 em vez de entrada inválida. +bool isUuidFormat(String value) => _uuidFormat.hasMatch(value); diff --git a/backend/sinalacs_server/lib/src/application/notices/notice_scheduling_service.dart b/backend/sinalacs_server/lib/src/application/notices/notice_scheduling_service.dart new file mode 100644 index 00000000..831b9a9d --- /dev/null +++ b/backend/sinalacs_server/lib/src/application/notices/notice_scheduling_service.dart @@ -0,0 +1,507 @@ +import 'package:sinalacs_server/src/application/audit/audit_trail.dart'; +import 'package:sinalacs_server/src/application/auth/authorization.dart'; +import 'package:sinalacs_server/src/application/auth/development_auth_service.dart'; +import 'package:sinalacs_server/src/application/common/uuid_format.dart'; +import 'package:sinalacs_server/src/application/notices/notice_service.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; + +/// Envia agora um aviso a uma microárea (a Task 1.4 extrai isto de +/// `NoticeService`). O texto só trafega por aqui, nunca pela auditoria. +typedef NoticeSender = Future Function({ + required String microAreaId, + required String audience, + required String title, + required String message, + required String actorId, + String? auditResourceId, +}); + +/// Antecedência mínima entre o pedido e o horário agendado. +const Duration noticeMinimumLeadTime = Duration(minutes: 1); + +/// Linha de `scheduled_notices` como a camada de aplicação a enxerga. +class ScheduledNoticeSnapshot { + const ScheduledNoticeSnapshot({ + required this.id, + required this.createdBy, + required this.microAreaId, + required this.audience, + required this.title, + required this.message, + required this.scheduledFor, + required this.status, + required this.createdAt, + this.recipients, + this.accepted, + this.failureReason, + this.sentAt, + this.microAreaName, + this.createdByLabel, + }); + + final String id; + final String createdBy; + final String microAreaId; + + /// Nome da microárea e matrícula de quem criou: só o `history` do store os + /// resolve (JOIN); nas demais leituras ficam nulos. + final String? microAreaName; + final String? createdByLabel; + final String audience; + final String title; + final String message; + final DateTime scheduledFor; + final ScheduledNoticeStatus status; + final int? recipients; + final int? accepted; + final String? failureReason; + final DateTime createdAt; + final DateTime? sentAt; +} + +/// Item do histórico: deliberadamente SEM o texto da mensagem (o backoffice +/// lista títulos e contagens; o texto só existe no aviso e no envio). +class NoticeHistoryItem { + const NoticeHistoryItem({ + required this.id, + required this.createdBy, + required this.microAreaId, + required this.audience, + required this.title, + required this.scheduledFor, + required this.status, + this.recipients, + this.accepted, + this.sentAt, + this.failureReason, + this.microAreaName, + this.createdByLabel, + }); + + factory NoticeHistoryItem.from(ScheduledNoticeSnapshot s) => + NoticeHistoryItem( + id: s.id, + createdBy: s.createdBy, + microAreaId: s.microAreaId, + audience: s.audience, + title: s.title, + scheduledFor: s.scheduledFor, + status: s.status, + recipients: s.recipients, + accepted: s.accepted, + sentAt: s.sentAt, + failureReason: s.failureReason, + microAreaName: s.microAreaName, + createdByLabel: s.createdByLabel, + ); + + final String id; + + /// Quem criou o aviso (id do usuário; o endpoint o traduz em matrícula). + final String createdBy; + final String microAreaId; + final String audience; + final String title; + final DateTime scheduledFor; + final ScheduledNoticeStatus status; + final int? recipients; + final int? accepted; + final DateTime? sentAt; + final String? failureReason; + final String? microAreaName; + + /// Matrícula de quem criou (nunca nome de paciente). + final String? createdByLabel; +} + +/// Janela após a qual um aviso preso em `sending` é dado como de resultado +/// desconhecido na partida. +const Duration noticeStuckSendingAfter = Duration(minutes: 10); +const String noticeStuckSendingReason = + 'Resultado desconhecido: o servidor reiniciou durante o envio'; + +/// Motivo gravado quando o disparo recusa o aviso porque quem o agendou já não +/// pode enviar para a microárea (desativado, sem conta de staff, papel ou UBS +/// trocados). Não há reenvio: o aviso termina `failed`. +const String noticeAuthorRevokedReason = + 'Quem agendou já não pode enviar avisos para esta microárea'; + +/// Autoridade atual de um usuário de staff, lida no disparo (não no token). +class StaffAuthority { + const StaffAuthority({required this.role, required this.active, this.ubsId}); + + final UserRole role; + final bool active; + + /// UBS do coordenador; nula para administrador. + final String? ubsId; +} + +/// Limites de paginação do histórico. +const int noticeHistoryDefaultLimit = 50; +const int noticeHistoryMaxLimit = 100; + +/// Aviso que a varredura de partida levou de `sending` a `unknown`: só id e +/// autor, nunca o texto. +class StuckNotice { + const StuckNotice({required this.id, required this.createdBy}); + + final String id; + final String createdBy; +} + +/// Persistência dos avisos agendados. A implementação ORM fica em +/// `infrastructure/` (Task 1.5). +abstract interface class ScheduledNoticeStore { + /// UBS do coordenador (`staff_accounts.ubsId`), ou `null` se não houver. + Future ubsOfStaff(String userId); + + /// UBS da microárea, ou `null` se a microárea não existe. + Future ubsOfMicroArea(String microAreaId); + + /// Papel, situação e UBS atuais do usuário de staff, ou `null` se ele não tem + /// linha em `staff_accounts` (o disparo trata isso como recusa). + Future authorityOf(String userId); + + Future insert({ + required String createdBy, + required String microAreaId, + required String audience, + required String title, + required String message, + required DateTime scheduledFor, + required DateTime createdAt, + }); + + /// Linha pelo id, ou `null` se não existe. + Future findById(String id); + + Future cancelIfPending(String id); + + /// pending -> sending atômico: devolve só as linhas que ESTA chamada reservou. + Future> claimDue(DateTime now, {int limit = 20}); + + Future finish( + String id, { + required ScheduledNoticeStatus status, + int? recipients, + int? accepted, + String? failureReason, + required DateTime at, + }); + + /// Varredura de partida: leva `sending` com `scheduledFor` anterior a + /// [before] a `unknown` (nunca de volta a `pending`: não há reenvio + /// automático). Devolve os avisos que moveu (id e autor), para a trilha. + Future> markStaleSendingUnknown({ + required DateTime before, + required String reason, + required DateTime at, + }); + + Future> history({ + String? microAreaId, + String? ubsId, + required int limit, + required int offset, + }); +} + +/// Agendamento de aviso comunitário pelo backoffice (RF14, #46). +/// +/// Só `coordinator`/`admin`; o coordenador só agenda para microárea da própria +/// UBS (fail-closed sem UBS). Toda negação é auditada com `await` antes de +/// lançar. O texto do aviso nunca entra na auditoria. +class NoticeSchedulingService { + NoticeSchedulingService({ + required ScheduledNoticeStore store, + required AuditTrail audit, + required NoticeSender sendNow, + DateTime Function()? clock, + }) : _store = store, + _audit = audit, + _sendNow = sendNow, + _clock = clock ?? DateTime.now; + + final ScheduledNoticeStore _store; + final AuditTrail _audit; + final NoticeSender _sendNow; + final DateTime Function() _clock; + + Future schedule( + AuthenticatedUser user, { + required String microAreaId, + required String audience, + required String title, + required String message, + required DateTime scheduledFor, + }) async { + if (!Authorization.staffRoles.contains(user.role)) { + await _deny(user, microAreaId, 'denied_role'); + } + _requireUuid(microAreaId); + final target = await _store.ubsOfMicroArea(microAreaId); + if (user.role == UserRole.coordinator) { + final own = await _store.ubsOfStaff(user.id); + if (own == null || target == null || own != target) { + await _deny(user, microAreaId, 'denied_territory'); + } + } else if (target == null) { + // Admin: microárea inexistente é entrada inválida, não negação. + throw AdminInvalidRequestException(message: 'Microárea não encontrada.'); + } + + final cleanTitle = title.trim(); + final cleanMessage = message.trim(); + if (cleanTitle.isEmpty || cleanMessage.isEmpty) { + throw AdminInvalidRequestException(message: 'Informe o título e a mensagem do aviso.'); + } + if (cleanTitle.length > noticeTitleMaxLength) { + throw AdminInvalidRequestException( + message: 'O título pode ter no máximo $noticeTitleMaxLength caracteres.', + ); + } + if (cleanMessage.length > noticeMessageMaxLength) { + throw AdminInvalidRequestException( + message: 'A mensagem pode ter no máximo $noticeMessageMaxLength caracteres.', + ); + } + if (!noticeAudiences.contains(audience)) { + throw AdminInvalidRequestException(message: 'Público do aviso desconhecido.'); + } + final now = _clock(); + if (scheduledFor.isBefore(now.add(noticeMinimumLeadTime))) { + throw AdminInvalidRequestException( + message: 'O horário do aviso deve ser pelo menos 1 minuto à frente.', + ); + } + + final id = await _store.insert( + createdBy: user.id, + microAreaId: microAreaId, + audience: audience, + title: cleanTitle, + message: cleanMessage, + scheduledFor: scheduledFor.toUtc(), + createdAt: now.toUtc(), + ); + await _audit.record(AuditEvent( + userId: user.id, + actionType: 'write', + resourceType: 'community_notice', + resourceId: id, + result: 'scheduled', + )); + return id; + } + + /// Cancela um aviso ainda `pending`. Coordenador só cancela o da própria UBS. + Future cancel(AuthenticatedUser user, String id) async { + if (!Authorization.staffRoles.contains(user.role)) { + await _deny(user, id, 'denied_role'); + } + _requireUuid(id); + final notice = await _store.findById(id); + if (notice == null) { + throw AdminInvalidRequestException(message: 'Aviso não encontrado.'); + } + if (user.role == UserRole.coordinator) { + final own = await _store.ubsOfStaff(user.id); + final target = await _store.ubsOfMicroArea(notice.microAreaId); + if (own == null || target == null || own != target) { + await _deny(user, id, 'denied_territory'); + } + } + // Atômico: se o despacho reservou o aviso entre a leitura e aqui, recusa. + if (!await _store.cancelIfPending(id)) { + throw AdminInvalidRequestException(message: 'Só avisos pendentes podem ser cancelados.'); + } + await _audit.record(AuditEvent( + userId: user.id, + actionType: 'write', + resourceType: 'community_notice', + resourceId: id, + result: 'cancelled', + )); + } + + /// Histórico de avisos, sem o texto. Coordenador: só a própria UBS. + Future> history( + AuthenticatedUser user, { + String? microAreaId, + int limit = noticeHistoryDefaultLimit, + int offset = 0, + }) async { + if (!Authorization.staffRoles.contains(user.role)) { + await _deny(user, microAreaId ?? '', 'denied_role', actionType: 'read'); + } + if (microAreaId != null) _requireUuid(microAreaId); + String? ubsId; + if (user.role == UserRole.coordinator) { + ubsId = await _store.ubsOfStaff(user.id); + if (ubsId == null) { + await _deny(user, microAreaId ?? '', 'denied_territory', actionType: 'read'); + } + if (microAreaId != null && await _store.ubsOfMicroArea(microAreaId) != ubsId) { + await _deny(user, microAreaId, 'denied_territory', actionType: 'read'); + } + } + // Fail-closed: audita ANTES de consultar; se não grava, o store não é chamado. + await _audit.record(AuditEvent( + userId: user.id, + actionType: 'read', + resourceType: 'community_notice', + result: 'granted', + )); + final rows = await _store.history( + microAreaId: microAreaId, + ubsId: ubsId, + limit: limit.clamp(1, noticeHistoryMaxLimit), + offset: offset < 0 ? 0 : offset, + ); + return rows.map(NoticeHistoryItem.from).toList(); + } + + /// Dispara os avisos vencidos. A reserva `pending -> sending` é atômica + /// ([ScheduledNoticeStore.claimDue]), então duas chamadas nunca enviam o mesmo + /// aviso. Sem reenvio automático: falha vira `failed`, resultado desconhecido + /// vira `unknown`. Um erro num aviso nunca derruba os demais. Devolve quantos + /// avisos esta chamada processou. Antes de enviar, reconfere a autoridade de + /// quem agendou ([_authorMayStillSend]): quem perdeu o direito não envia, e o + /// aviso termina `failed`. + Future dispatchDue() async { + final claimed = await _store.claimDue(_clock().toUtc()); + for (final notice in claimed) { + try { + if (!await _authorMayStillSend(notice)) { + await _finish(notice, ScheduledNoticeStatus.failed, reason: noticeAuthorRevokedReason); + await _audit.recordSafely(AuditEvent( + userId: notice.createdBy, + actionType: 'write', + resourceType: 'community_notice', + resourceId: notice.id, + result: 'denied_author_revoked', + )); + continue; + } + final r = await _sendNow( + microAreaId: notice.microAreaId, + audience: notice.audience, + title: notice.title, + message: notice.message, + actorId: notice.createdBy, + auditResourceId: notice.id, + ); + await _finish(notice, ScheduledNoticeStatus.sent, + recipients: r.recipients, accepted: r.accepted); + } on NoticeDeliveryException catch (e) { + if (isNoticeOutcomeUnknown(e)) { + // Já auditado como `unknown` por quem enviou. + await _finish(notice, ScheduledNoticeStatus.unknown, reason: e.message); + } else { + await _failed(notice, e.message); + } + } catch (_) { + await _failed(notice, 'Falha inesperada ao enviar o aviso.'); + } + } + return claimed.length; + } + + /// Varredura de partida: `sending` há mais de [noticeStuckSendingAfter] vira + /// `unknown` (o processo caiu entre a reserva e o fim; o envio pode ter + /// ocorrido, então jamais volta a `pending`). Grava uma linha de auditoria + /// (`unknown_after_restart`, autor = quem agendou, recurso = o aviso) por aviso + /// movido, com `recordSafely`: é a partida do servidor e a trilha não pode + /// impedir o boot. Devolve quantas linhas moveu. + Future recoverStuckSending() async { + final moved = await _store.markStaleSendingUnknown( + before: _clock().toUtc().subtract(noticeStuckSendingAfter), + reason: noticeStuckSendingReason, + at: _clock().toUtc(), + ); + for (final notice in moved) { + await _audit.recordSafely(AuditEvent( + userId: notice.createdBy, + actionType: 'write', + resourceType: 'community_notice', + resourceId: notice.id, + result: 'unknown_after_restart', + )); + } + return moved.length; + } + + /// Regra do agendamento reaplicada no disparo, com o estado de AGORA: staff + /// ativo; coordenador só para microárea da própria UBS. Fail-closed. + Future _authorMayStillSend(ScheduledNoticeSnapshot notice) async { + final authority = await _store.authorityOf(notice.createdBy); + if (authority == null || !authority.active) return false; + if (!Authorization.staffRoles.contains(authority.role)) return false; + if (authority.role == UserRole.coordinator) { + final target = await _store.ubsOfMicroArea(notice.microAreaId); + return authority.ubsId != null && target != null && authority.ubsId == target; + } + return true; + } + + Future _failed(ScheduledNoticeSnapshot notice, String reason) async { + await _finish(notice, ScheduledNoticeStatus.failed, reason: reason); + await _audit.recordSafely(AuditEvent( + userId: notice.createdBy, + actionType: 'write', + resourceType: 'community_notice', + resourceId: notice.id, + result: 'failed', + )); + } + + Future _finish( + ScheduledNoticeSnapshot notice, + ScheduledNoticeStatus status, { + int? recipients, + int? accepted, + String? reason, + }) async { + try { + await _store.finish( + notice.id, + status: status, + recipients: recipients, + accepted: accepted, + failureReason: reason, + at: _clock().toUtc(), + ); + } catch (_) { + // Não derruba o laço: a linha fica `sending` (nunca reenviada). + } + } + + /// Identificador do cliente que não é UUID é entrada inválida, não erro 500 + /// do `::uuid` do SQL. Vem depois da autorização e antes de qualquer store. + static void _requireUuid(String value) { + if (!isUuidFormat(value)) { + throw AdminInvalidRequestException(message: 'Identificador inválido.'); + } + } + + /// Grava a negação (só ator e microárea; nunca o texto) e lança. Fail-closed: + /// `record` (não `recordSafely`), então se a gravação falha a exceção sobe e a + /// operação continua negada. O `resourceId` vem do cliente: só vai se for UUID, + /// senão a trilha ORM lançaria. + Future _deny( + AuthenticatedUser user, + String microAreaId, + String result, { + String actionType = 'write', + }) async { + await _audit.record(AuditEvent( + userId: user.id, + actionType: actionType, + resourceType: 'community_notice', + resourceId: isUuidFormat(microAreaId) ? microAreaId : null, + result: result, + )); + throw StateError('Sem permissão para esta operação de avisos.'); + } +} diff --git a/backend/sinalacs_server/lib/src/application/notices/notice_service.dart b/backend/sinalacs_server/lib/src/application/notices/notice_service.dart new file mode 100644 index 00000000..e479be72 --- /dev/null +++ b/backend/sinalacs_server/lib/src/application/notices/notice_service.dart @@ -0,0 +1,172 @@ +import 'package:sinalacs_server/src/application/audit/audit_trail.dart'; +import 'package:sinalacs_server/src/application/auth/authorization.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/infrastructure/push/gorush_client.dart'; + +const int noticeTitleMaxLength = 60; +const int noticeMessageMaxLength = 240; + +/// Quem recebe: todos os consentidos da microárea, ou só os crônicos. +const Set noticeAudiences = {'everyone', 'chronic'}; + +/// Mensagem da falha com resultado desconhecido (o pedido saiu e a resposta não +/// voltou). A exceção gerada só tem `message`, então ela é o discriminador. +const String noticeOutcomeUnknownMessage = 'O envio demorou e o resultado é desconhecido: ' + 'alguns pacientes podem já ter recebido o aviso. Confira antes de reenviar.'; + +bool isNoticeOutcomeUnknown(Object error) => + error is NoticeDeliveryException && error.message == noticeOutcomeUnknownMessage; + +/// Resolve os destinatários de um aviso. Interface aqui, implementação ORM em +/// `infrastructure/`, mesmo padrão dos demais stores. +abstract interface class NoticeRecipientStore { + /// Aparelhos dos pacientes da [microAreaId] cuja linha de consentimento + /// **mais recente** de `segmentedPush` é `granted` — a existência do token não + /// basta. [chronicOnly] restringe a quem tem `patients.isChronic`. + Future> consentedTargets({ + required String microAreaId, + required bool chronicOnly, + }); + + /// Apaga os tokens que o provedor declarou inválidos. + Future deleteTokens(List tokens); +} + +class NoticeSendSnapshot { + const NoticeSendSnapshot({required this.recipients, required this.accepted}); + + final int recipients; + final int accepted; +} + +/// Aviso comunitário do ACS aos pacientes da própria microárea (RF14). +/// +/// A microárea vem SEMPRE do token do ACS, nunca de parâmetro (invariante de +/// território). O payload leva só o texto digitado e a tela a abrir: nenhum dado +/// do paciente (decisão §3.2). +class NoticeService { + NoticeService({ + required NoticeRecipientStore store, + required PushSender? sender, + required AuditTrail audit, + }) : _store = store, + _sender = sender, + _audit = audit; + + final NoticeRecipientStore _store; + final PushSender? _sender; + final AuditTrail _audit; + + Future sendSegmented( + AuthenticatedUser user, { + required String title, + required String message, + required String audience, + }) async { + Authorization.require( + user, + roles: {UserRole.acs}, + onDenied: () => StateError('Somente o ACS envia avisos à comunidade.'), + ); + final cleanTitle = title.trim(); + final cleanMessage = message.trim(); + if (cleanTitle.isEmpty || cleanMessage.isEmpty) { + throw DataRightsException(message: 'Informe o título e a mensagem do aviso.'); + } + if (cleanTitle.length > noticeTitleMaxLength) { + throw DataRightsException( + message: 'O título pode ter no máximo $noticeTitleMaxLength caracteres.', + ); + } + if (cleanMessage.length > noticeMessageMaxLength) { + throw DataRightsException( + message: 'A mensagem pode ter no máximo $noticeMessageMaxLength caracteres.', + ); + } + if (!noticeAudiences.contains(audience)) { + throw DataRightsException(message: 'Público do aviso desconhecido.'); + } + return sendToMicroArea( + microAreaId: user.microAreaId!, + audience: audience, + title: cleanTitle, + message: cleanMessage, + actorId: user.id, + ); + } + + /// Envio já validado a uma microárea, sem checar papel: quem chama decide a + /// autorização (ACS pelo token em [sendSegmented]; o despacho dos agendados, + /// que foram autorizados na criação). Audita com [actorId] e a microárea. + /// + /// [auditResourceId] é o `resourceId` da auditoria (padrão: a microárea; o + /// despacho agendado passa o id do aviso). + /// + /// Resultado desconhecido lança [NoticeDeliveryException] com a mensagem + /// [noticeOutcomeUnknownMessage]; use [isNoticeOutcomeUnknown] para distinguir. + Future sendToMicroArea({ + required String microAreaId, + required String audience, + required String title, + required String message, + required String actorId, + String? auditResourceId, + }) async { + final auditId = auditResourceId ?? microAreaId; + final sender = _sender; + if (sender == null) { + throw NoticeDeliveryException( + message: 'O envio de avisos não está configurado neste ambiente.', + ); + } + + final targets = await _store.consentedTargets( + microAreaId: microAreaId, + chronicOnly: audience == 'chronic', + ); + if (targets.isEmpty) { + await _record(actorId, auditId, 'no_recipients'); + return const NoticeSendSnapshot(recipients: 0, accepted: 0); + } + + final PushSendReport report; + try { + report = await sender.send( + PushMessage(title: title, body: message, data: const {'screen': 'notices'}), + targets, + ); + } on PushGatewayException catch (error) { + if (error.outcomeUnknown) { + // O pedido saiu e a resposta não voltou: parte dos pacientes pode já ter + // recebido. Dizer "tente de novo" levaria ao aviso em duplicata. + await _record(actorId, auditId, 'unknown'); + throw NoticeDeliveryException( + message: noticeOutcomeUnknownMessage, + ); + } + throw NoticeDeliveryException( + message: 'Não foi possível entregar o aviso agora. Tente de novo em instantes.', + ); + } + if (report.invalidTokens.isNotEmpty) { + try { + await _store.deleteTokens(report.invalidTokens); + } catch (_) { + // A poda é higiene: o aviso já saiu, e um erro aqui levaria o ACS a reenviar. + } + } + await _record(actorId, auditId, report.accepted > 0 ? 'granted' : 'not_delivered'); + return NoticeSendSnapshot(recipients: targets.length, accepted: report.accepted); + } + + /// Só quem enviou e para qual microárea; o texto do aviso nunca entra aqui. + Future _record(String actorId, String resourceId, String result) => + _audit.recordSafely(AuditEvent( + userId: actorId, + actionType: 'write', + resourceType: 'community_notice', + resourceId: resourceId, + result: result, + )); +} diff --git a/backend/sinalacs_server/lib/src/application/onboarding/onboarding_service.dart b/backend/sinalacs_server/lib/src/application/onboarding/onboarding_service.dart index 847015ad..cfa5baee 100644 --- a/backend/sinalacs_server/lib/src/application/onboarding/onboarding_service.dart +++ b/backend/sinalacs_server/lib/src/application/onboarding/onboarding_service.dart @@ -79,7 +79,10 @@ abstract interface class OnboardingStore { /// Versão do texto de política vigente. Mesmo padrão que /// `spec/lgpd_design.md` define para a política de privacidade — trocar /// exige nova versão publicada, não incrementar este literal sem mudança de -/// texto real. +/// texto real. É também a versão do Termo de Uso e da Política de Privacidade +/// que o app paciente exibe (`legalDocumentsVersion` em +/// `apps/patient/lib/core/legal/legal_documents.dart`); o teste +/// `apps/patient/test/legal_documents_test.dart` falha se as duas divergirem. const String consentPolicyVersion = '2026.1'; const _tokenLifetime = Duration(minutes: 15); @@ -131,7 +134,7 @@ class OnboardingService { return EnrollmentTokenResult(token: token, expiresAt: expiresAt); } - /// Consome o convite, exige o consentimento obrigatório, grava as 3 + /// Consome o convite, exige o consentimento obrigatório, grava as 4 /// finalidades em `consent_logs` (aceite ou recusa) e emite a sessão do /// paciente. Tudo ou nada: se o consentimento obrigatório for recusado, /// o token permanece válido (a pessoa pode tentar de novo lendo o mesmo @@ -146,6 +149,15 @@ class OnboardingService { message: 'O consentimento para processamento de dados de saúde é obrigatório.', ); } + // O aceite do Termo de Uso e da Política de Privacidade é a outra + // condição de uso (LGPD-RF18: "aceite explícito no cadastro"). Checado + // antes de consumir o convite, pelo mesmo motivo do consentimento de + // saúde: a recusa não pode queimar o QR. + if (consents[ConsentPurpose.termsOfUse] != true) { + throw EnrollmentException( + message: 'É preciso aceitar o Termo de Uso e a Política de Privacidade.', + ); + } final now = _clock(); final tokenHash = _hash(token); diff --git a/backend/sinalacs_server/lib/src/application/patients/data_subject_rights_service.dart b/backend/sinalacs_server/lib/src/application/patients/data_subject_rights_service.dart new file mode 100644 index 00000000..739d6187 --- /dev/null +++ b/backend/sinalacs_server/lib/src/application/patients/data_subject_rights_service.dart @@ -0,0 +1,351 @@ +import 'dart:io'; + +import 'package:sinalacs_server/src/application/audit/audit_trail.dart'; +import 'package:sinalacs_server/src/application/auth/authorization.dart'; +import 'package:sinalacs_server/src/application/auth/development_auth_service.dart'; +import 'package:sinalacs_server/src/application/onboarding/onboarding_service.dart' + show ConsentLogEntry, consentPolicyVersion; +import 'package:sinalacs_server/src/application/patients/patient_data_overview_service.dart' + show ConsentRecordSnapshot, DataSubjectRequestSnapshot; +import 'package:sinalacs_server/src/application/patients/terms_change_schedule.dart'; +import 'package:sinalacs_server/src/application/patients/terms_publication_service.dart' show TermsVersionStore; +import 'package:sinalacs_server/src/generated/protocol.dart'; + +/// Recusa de escrita para a conta já anonimizada por uma exclusão atendida +/// (#42). Genérica: quem a recebe é o portador de um JWT que ainda não venceu, +/// e a resposta certa para ele é voltar ao login, que também recusa. +const removedAccountMessage = 'Sessão encerrada. Entre novamente.'; + +/// Persistência das operações do titular sobre os próprios dados. Interface +/// aqui, implementação ORM em `infrastructure/`, mesmo padrão de +/// `PatientDataOverviewStore`. +/// +/// Toda **escrita** recusa a conta anonimizada (#42) com +/// `StateError(removedAccountMessage)`, que o endpoint traduz em +/// `AlertPermissionException`. A checagem mora no store, e não no serviço, +/// porque precisa rodar dentro da transação e depois do lock por titular: só +/// assim não corre com a anonimização, que segura os mesmos locks. +abstract interface class DataSubjectRightsStore { + /// Grava uma linha nova, assinada, em `consent_logs` — nunca edita uma + /// anterior (append-only, LGPD-RF04). + Future recordConsent(ConsentLogEntry entry); + + /// Grava o `denied` de `segmentedPush` e apaga os tokens de push do titular na + /// MESMA transação, sob o lock por titular do registro de token: sem consentimento, + /// sem token, e nenhum dos dois efeitos acontece sem o outro (RF14). + Future recordConsentRevokingPush(ConsentLogEntry entry); + + /// Grava `entry` **só se** a linha mais recente do propósito ainda não for um + /// `granted` na mesma versão, de forma atômica por titular: duas chamadas + /// simultâneas resultam em uma linha, e a segunda recebe a da primeira em + /// `existing`. + Future<({String? id, ConsentRecordSnapshot? existing})> recordConsentUnlessCurrent( + ConsentLogEntry entry, + ); + + /// A linha mais recente (por `timestamp`) daquela finalidade, ou `null`. + Future latestConsent(String userId, ConsentPurpose purpose); + + /// Cria o pedido de exclusão **só se** não houver um aberto, de forma atômica: + /// dois chamadores simultâneos resultam em uma única linha aberta, e o segundo + /// recebe a do primeiro com `created == false`. + Future<({DataSubjectRequestSnapshot request, bool created})> createDeletionRequestIfNoneOpen({ + required String userId, + required DateTime createdAt, + required DateTime dueAt, + }); + + Future createRequest({ + required String userId, + required DataSubjectRequestType type, + required String? details, + required DateTime createdAt, + required DateTime dueAt, + }); +} + +/// O aviso de mudança dos termos, como o serviço o entrega ao endpoint. +class TermsChangeNoticeSnapshot { + const TermsChangeNoticeSnapshot({ + required this.version, + required this.effectiveFrom, + required this.summary, + }); + + final String version; + final DateTime effectiveFrom; + final String summary; +} + +/// A regra do aceite vigente, num lugar só: a linha mais recente é um `granted` +/// na [version] em vigor. Usada pela consulta do login e pela gravação atômica. +bool isCurrentAcceptance(ConsentRecordSnapshot? latest, {required String version}) => + latest != null && latest.action == 'granted' && latest.version == version; + +/// Prazo de resposta a um pedido do titular (spec/lgpd_design.md, 596-597). +const Duration dataSubjectRequestDeadline = Duration(days: 15); + +/// Teto do texto de um pedido de correção, contado depois do `trim()`. O app +/// usa o mesmo número no `maxLength` do campo. +const int correctionDetailsMaxLength = 500; + +/// Direitos do titular exercidos pelo próprio app (LGPD-RF05 e LGPD-RF08): +/// conceder/revogar finalidades opcionais e pedir exclusão ou correção. +/// +/// `userId` vem SEMPRE de `user.id` — nunca de parâmetro —, pelo mesmo motivo +/// de INV-05 em `triage.evaluate`. `requireMicroArea: false`, mesmo motivo de +/// `PatientDataOverviewService.myData`: o escopo é o titular, não o território. +class DataSubjectRightsService { + DataSubjectRightsService({ + required DataSubjectRightsStore store, + required AuditTrail audit, + DateTime Function()? clock, + Future Function()? termsChangeReader, + TermsVersionStore? termsVersionStore, + }) : _store = store, + _audit = audit, + _termsVersionStore = termsVersionStore, + _termsChangeReaderOverride = termsChangeReader, + _clock = clock ?? DateTime.now; + + final DataSubjectRightsStore _store; + final AuditTrail _audit; + final DateTime Function() _clock; + final TermsVersionStore? _termsVersionStore; + final Future Function()? _termsChangeReaderOverride; + + /// Agenda publicada em `terms_versions`. Uma linha cuja vigência esteja a + /// menos de 15 dias da publicação (dado inconsistente; a publicação já valida) + /// não derruba o app do paciente: vira "sem aviso", e só o tipo do erro é + /// registrado. + Future _readSchedule() async { + final override = _termsChangeReaderOverride; + if (override != null) return override(); + final store = _termsVersionStore; + if (store == null) return null; + final row = await store.currentPublished(_clock().toUtc()); + final publishedAt = row?.publishedAt; + if (row == null || publishedAt == null) return null; + try { + return TermsChangeSchedule( + version: row.version, + publishedAt: publishedAt.toUtc(), + effectiveFrom: row.effectiveFrom.toUtc(), + summary: row.summary, + ); + } on ArgumentError catch (error) { + stderr.writeln('termsChangeNotice: linha de terms_versions inválida (${error.runtimeType})'); + return null; + } + } + + /// Concede ou revoga uma finalidade opcional. `healthDataProcessing` é + /// recusado nas duas direções: é a base legal do app inteiro — inclusive do + /// alerta de emergência — e retirá-lo exige a exclusão dos dados em até 15 + /// dias (LGPD-RF07), que é o que [requestDeletion] registra. + Future updateConsent( + AuthenticatedUser user, { + required ConsentPurpose purpose, + required bool granted, + }) async { + _requirePatient(user); + if (purpose == ConsentPurpose.healthDataProcessing) { + throw DataRightsException( + message: 'O consentimento para dados de saúde é obrigatório para usar o app. ' + 'Para retirá-lo, solicite a exclusão dos seus dados.', + ); + } + if (purpose == ConsentPurpose.termsOfUse) { + throw DataRightsException( + message: 'O aceite do Termo de Uso é feito no cadastro e não é alterado aqui. ' + 'Para deixar de usar o app, solicite a exclusão dos seus dados.', + ); + } + + // Revogar avisos apaga os tokens na mesma transação do `denied` (RF14). + return _record( + user, + purpose: purpose, + action: granted ? 'granted' : 'denied', + revokePush: purpose == ConsentPurpose.segmentedPush && !granted, + ); + } + + /// Aceite explícito do Termo de Uso e da Política de Privacidade por quem + /// entrou por CPF + OTP sem passar pelo onboarding (LGPD-RF18) — ou que + /// aceitou uma versão anterior. É a única via de escrita de `termsOfUse` + /// fora do cadastro: [updateConsent] continua recusando esse propósito, para + /// que o termo não vire uma chave liga/desliga no painel. + Future acceptTermsOfUse(AuthenticatedUser user) async { + _requirePatient(user); + final now = _clock().toUtc(); + final result = await _store.recordConsentUnlessCurrent(ConsentLogEntry( + userId: user.id, + purpose: ConsentPurpose.termsOfUse, + action: 'granted', + version: consentPolicyVersion, + timestamp: now, + )); + // Idempotente: já aceitou a versão vigente, devolve a linha existente em + // vez de crescer o histórico e a trilha de auditoria a cada chamada. + if (result.existing != null) return result.existing!; + await _auditConsent(user, result.id!); + return ConsentRecordSnapshot( + purpose: ConsentPurpose.termsOfUse.name, + action: 'granted', + version: consentPolicyVersion, + timestamp: now, + ); + } + + /// Aviso de mudança dos termos ativo agora, ou `null` (LGPD-RF18, 15 dias de + /// antecedência). Lê a agenda publicada pelo admin em `terms_versions`; não + /// grava nem audita nada do titular. + Future termsChangeNotice(AuthenticatedUser user) async { + _requirePatient(user); + final schedule = await _readSchedule(); + if (schedule == null || !schedule.isActiveAt(_clock().toUtc())) return null; + return TermsChangeNoticeSnapshot( + version: schedule.version, + effectiveFrom: schedule.effectiveFrom, + summary: schedule.summary, + ); + } + + /// `true` quando a linha mais recente de `termsOfUse` é um `granted` na versão + /// vigente (LGPD-RF18). É o que o app consulta depois do login por OTP: um + /// `bool`, em vez do painel "Meus dados" inteiro — que também gravaria uma + /// linha de auditoria de leitura a cada login. + Future hasAcceptedCurrentTerms(AuthenticatedUser user) async { + _requirePatient(user); + return isCurrentAcceptance( + await _store.latestConsent(user.id, ConsentPurpose.termsOfUse), + version: consentPolicyVersion, + ); + } + + /// `true` quando a decisão mais recente do titular para [purpose] é + /// `granted`. Um `bool`, sem ler o painel "Meus dados" e sem auditoria de + /// leitura: o app pergunta isto a cada login antes de falar com o provedor de + /// push, e essa pergunta não é o titular abrindo o próprio painel. + Future hasGrantedConsent(AuthenticatedUser user, ConsentPurpose purpose) async { + _requirePatient(user); + final latest = await _store.latestConsent(user.id, purpose); + return latest != null && latest.action == 'granted'; + } + + Future _record( + AuthenticatedUser user, { + required ConsentPurpose purpose, + required String action, + bool revokePush = false, + }) async { + final now = _clock().toUtc(); + final entry = ConsentLogEntry( + userId: user.id, + purpose: purpose, + action: action, + version: consentPolicyVersion, + timestamp: now, + ); + final id = revokePush + ? await _store.recordConsentRevokingPush(entry) + : await _store.recordConsent(entry); + await _audit.recordSafely(AuditEvent( + userId: user.id, + actionType: 'write', + resourceType: 'consent_log', + resourceId: id, + result: 'granted', + )); + + return ConsentRecordSnapshot( + purpose: purpose.name, + action: action, + version: consentPolicyVersion, + timestamp: now, + ); + } + + Future _auditConsent(AuthenticatedUser user, String id) => _audit.recordSafely(AuditEvent( + userId: user.id, + actionType: 'write', + resourceType: 'consent_log', + resourceId: id, + result: 'granted', + )); + + /// Pede a exclusão/anonimização dos próprios dados. Idempotente enquanto + /// houver um pedido de exclusão em aberto: pedir de novo devolve o mesmo, em + /// vez de empilhar pedidos iguais para a equipe. + Future requestDeletion(AuthenticatedUser user) async { + _requirePatient(user); + final now = _clock().toUtc(); + final result = await _store.createDeletionRequestIfNoneOpen( + userId: user.id, + createdAt: now, + dueAt: now.add(dataSubjectRequestDeadline), + ); + await _audit.recordSafely(AuditEvent( + userId: user.id, + actionType: 'write', + resourceType: 'data_subject_request', + resourceId: result.request.id, + result: result.created ? 'granted' : 'repeated', + )); + return result.request; + } + + /// Pede a correção de um dado. Ao contrário da exclusão, NÃO é idempotente: + /// cada pedido carrega um texto próprio, e devolver um pedido anterior + /// descartaria em silêncio o texto que a pessoa acabou de escrever. + Future requestCorrection( + AuthenticatedUser user, { + required String details, + }) async { + _requirePatient(user); + final trimmed = details.trim(); + if (trimmed.isEmpty) { + throw DataRightsException(message: 'Descreva o que precisa ser corrigido.'); + } + if (trimmed.length > correctionDetailsMaxLength) { + throw DataRightsException( + message: 'A descrição pode ter no máximo $correctionDetailsMaxLength caracteres.', + ); + } + return _create(user, DataSubjectRequestType.correction, trimmed); + } + + Future _create( + AuthenticatedUser user, + DataSubjectRequestType type, + String? details, + ) async { + final now = _clock().toUtc(); + final request = await _store.createRequest( + userId: user.id, + type: type, + details: details, + createdAt: now, + dueAt: now.add(dataSubjectRequestDeadline), + ); + // Só o id do pedido: o texto da correção pode citar condição de saúde e + // não entra na trilha (ver `AuditEvent.result`). + await _audit.recordSafely(AuditEvent( + userId: user.id, + actionType: 'write', + resourceType: 'data_subject_request', + resourceId: request.id, + result: 'granted', + )); + return request; + } + + void _requirePatient(AuthenticatedUser user) => Authorization.require( + user, + roles: {UserRole.patient}, + onDenied: () => + StateError('Somente o próprio paciente pode exercer direitos sobre os seus dados.'), + requireMicroArea: false, + ); +} diff --git a/backend/sinalacs_server/lib/src/application/patients/patient_data_overview_service.dart b/backend/sinalacs_server/lib/src/application/patients/patient_data_overview_service.dart index 7a4a3a3c..ca025d12 100644 --- a/backend/sinalacs_server/lib/src/application/patients/patient_data_overview_service.dart +++ b/backend/sinalacs_server/lib/src/application/patients/patient_data_overview_service.dart @@ -34,6 +34,34 @@ class RiskEventSnapshot { final DateTime recordedAt; } +/// Um pedido do próprio titular (LGPD-RF08: exclusão ou correção), do jeito +/// que a store enxerga — `details` já decifrado. +class DataSubjectRequestSnapshot { + const DataSubjectRequestSnapshot({ + required this.id, + required this.type, + required this.status, + required this.details, + required this.createdAt, + required this.dueAt, + this.resolution, + }); + + final String id; + final DataSubjectRequestType type; + final DataSubjectRequestStatus status; + + /// Texto do pedido de correção; `null` num pedido de exclusão. + final String? details; + final DateTime createdAt; + final DateTime dueAt; + + /// Nota de resposta ou motivo da recusa do backoffice (#42), já decifrada; + /// `null` enquanto o pedido não foi decidido (ou foi atendido sem nota). Só + /// chega ao próprio titular. + final String? resolution; +} + /// Tudo que o painel "Meus Dados" mostra sobre o próprio paciente. class PatientDataSnapshot { const PatientDataSnapshot({ @@ -44,6 +72,7 @@ class PatientDataSnapshot { required this.chronicConditions, required this.consents, required this.riskHistory, + this.requests = const [], }); final String name; @@ -53,6 +82,9 @@ class PatientDataSnapshot { final List chronicConditions; final List consents; final List riskHistory; + + /// Mais recente primeiro. + final List requests; } /// Consulta a tudo que compõe o painel "Meus Dados" de um único paciente. diff --git a/backend/sinalacs_server/lib/src/application/patients/push_token_service.dart b/backend/sinalacs_server/lib/src/application/patients/push_token_service.dart new file mode 100644 index 00000000..7d3eb611 --- /dev/null +++ b/backend/sinalacs_server/lib/src/application/patients/push_token_service.dart @@ -0,0 +1,120 @@ +import 'package:sinalacs_server/src/application/audit/audit_trail.dart'; +import 'package:sinalacs_server/src/application/auth/authorization.dart'; +import 'package:sinalacs_server/src/application/auth/development_auth_service.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; + +/// Persistência dos tokens de push (RF14, decisão §3.2). Interface aqui, +/// implementação ORM em `infrastructure/`. +abstract interface class PushTokenStore { + /// Registra o token **só se** a linha mais recente de `segmentedPush` do + /// titular for `granted`, tudo numa transação sob lock por titular: a leitura + /// do consentimento e a gravação não têm janela entre si, e a revogação + /// (`recordConsentRevokingPush`) espera o mesmo lock. + /// + /// - [PushRegistration.registered]: token novo, ou já era do titular. + /// - [PushRegistration.ownerChanged]: o token existia para outro titular e mudou + /// de dono. + /// - [PushRegistration.refused]: sem consentimento vigente — e nesse caso apaga + /// o vínculo de outro titular com o mesmo token, porque quem apresenta o token + /// está com o aparelho em mãos. + /// + /// Depois de gravar, o titular nunca fica com mais de [maxPushTokensPerUser] + /// tokens: os mais antigos (por `updatedAt`) saem. + Future registerIfConsented({ + required String userId, + required String? microAreaId, + required String token, + required String platform, + required DateTime now, + }); +} + +/// Desfecho de [PushTokenStore.registerIfConsented]. +enum PushRegistration { registered, ownerChanged, refused } + +/// O desfecho mais, quando o token mudou de dono, quem era o dono anterior — para +/// o titular que perdeu o vínculo sem agir também ter rastro (LGPD-RF08). +class PushRegistrationResult { + const PushRegistrationResult(this.outcome, {this.previousOwnerId}); + + final PushRegistration outcome; + final String? previousOwnerId; +} + +/// Teto de aparelhos por titular. Passou do teto, o token mais antigo sai: quem +/// troca de celular nunca é recusado por causa de aparelhos velhos. +const int maxPushTokensPerUser = 10; + +/// Teto do token: o do FCM tem cerca de 160 caracteres, o do APNs 64. +const int pushTokenMaxLength = 4096; +const Set pushPlatforms = {'android', 'ios'}; + +/// Registro do aparelho do paciente para avisos segmentados (RF14). +/// +/// `userId` vem SEMPRE de `user.id` (INV-05). Só grava com o consentimento +/// `segmentedPush` vigente: o token liga aparelho a titular, então sem base +/// legal ele nem é guardado. +class PushTokenService { + PushTokenService({ + required PushTokenStore store, + required AuditTrail audit, + DateTime Function()? clock, + }) : _store = store, + _audit = audit, + _clock = clock ?? DateTime.now; + + final PushTokenStore _store; + final AuditTrail _audit; + final DateTime Function() _clock; + + Future register( + AuthenticatedUser user, { + required String token, + required String platform, + }) async { + Authorization.require( + user, + roles: {UserRole.patient}, + onDenied: () => StateError('Somente o próprio paciente registra o aparelho para avisos.'), + requireMicroArea: false, + ); + final trimmed = token.trim(); + if (trimmed.isEmpty || + trimmed.length > pushTokenMaxLength || + !pushPlatforms.contains(platform)) { + throw DataRightsException(message: 'Aparelho inválido para receber avisos.'); + } + final result = await _store.registerIfConsented( + userId: user.id, + microAreaId: user.microAreaId, + token: trimmed, + platform: platform, + now: _clock().toUtc(), + ); + if (result.outcome == PushRegistration.refused) { + throw DataRightsException( + message: 'Ative "Avisos da equipe de saúde" em Meus Dados para receber avisos.', + ); + } + // Só a troca de dono deixa rastro: é o único evento que move um vínculo + // aparelho↔titular sem ação do titular anterior. Registrar e repetir não + // auditam, para não gravar uma linha por login; o token nunca entra na trilha. + if (result.outcome == PushRegistration.ownerChanged) { + await _audit.recordSafely(AuditEvent( + userId: user.id, + actionType: 'write', + resourceType: 'push_token', + result: 'granted', + )); + final previous = result.previousOwnerId; + if (previous != null) { + await _audit.recordSafely(AuditEvent( + userId: previous, + actionType: 'write', + resourceType: 'push_token', + result: 'lost', + )); + } + } + } +} diff --git a/backend/sinalacs_server/lib/src/application/patients/terms_change_schedule.dart b/backend/sinalacs_server/lib/src/application/patients/terms_change_schedule.dart new file mode 100644 index 00000000..46d7bd0b --- /dev/null +++ b/backend/sinalacs_server/lib/src/application/patients/terms_change_schedule.dart @@ -0,0 +1,54 @@ +import 'package:sinalacs_server/src/application/onboarding/onboarding_service.dart' + show consentPolicyVersion; + +/// Antecedência mínima do aviso de mudança dos termos (LGPD-RF18): 15 dias +/// entre a publicação do aviso e a vigência da versão nova. +const Duration termsChangeNoticePeriod = Duration(days: 15); + +/// Uma versão nova dos termos anunciada antes de valer. +/// +/// O construtor recusa, com `ArgumentError` (que roda também no binário de +/// release, ao contrário de `assert`), uma vigência a menos de 15 dias da +/// publicação: publicar o aviso tarde é um erro de quem edita o repositório, não +/// algo que o app deva corrigir depois. A regra compara as datas declaradas; não +/// prova que o aviso chegou a alguém 15 dias antes (um `publishedAt` retroativo +/// passa). Não é `const` porque `DateTime.difference` não é constante. +/// +/// A agenda vem de `terms_versions`: o admin rascunha, a revisão jurídica +/// registra e só então publica (`TermsPublicationService`); o cartão do paciente +/// lê a publicada mais recente. Para agendar uma mudança: acrescente o texto no +/// app (`upcomingLegalDocuments`), publique a versão no backoffice e só depois — +/// na data de vigência — mude `consentPolicyVersion` e `legalDocumentsVersion`. +class TermsChangeSchedule { + TermsChangeSchedule({ + required this.version, + required this.publishedAt, + required this.effectiveFrom, + required this.summary, + }) { + if (version == consentPolicyVersion) { + throw ArgumentError.value(version, 'version', 'a versão anunciada já é a vigente'); + } + if (effectiveFrom.difference(publishedAt) < termsChangeNoticePeriod) { + throw ArgumentError.value( + effectiveFrom, + 'effectiveFrom', + 'o aviso exige 15 dias entre a publicação e a vigência', + ); + } + } + + final String version; + final DateTime publishedAt; + final DateTime effectiveFrom; + final String summary; + + /// Ativa de [publishedAt] (inclusive) até [effectiveFrom] (exclusive): a partir + /// da vigência já não é aviso, é o convite ao aceite da versão nova. + bool isActiveAt(DateTime now) => !now.isBefore(publishedAt) && now.isBefore(effectiveFrom); +} + +/// Espelho de referência da versão embarcada no app: o backend NÃO a lê mais para +/// o aviso (a agenda é `terms_versions`); `upcoming_legal_documents_test.dart` do +/// app ainda a usa para exigir que o texto novo esteja embarcado. Fica `null`. +final TermsChangeSchedule? upcomingTermsChange = null; diff --git a/backend/sinalacs_server/lib/src/application/patients/terms_publication_service.dart b/backend/sinalacs_server/lib/src/application/patients/terms_publication_service.dart new file mode 100644 index 00000000..707372df --- /dev/null +++ b/backend/sinalacs_server/lib/src/application/patients/terms_publication_service.dart @@ -0,0 +1,218 @@ +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/common/uuid_format.dart'; +import 'package:sinalacs_server/src/application/onboarding/onboarding_service.dart' + show consentPolicyVersion; +import 'package:sinalacs_server/src/application/patients/terms_change_schedule.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; + +/// Tamanho máximo do resumo da mudança dos termos. +const int termsSummaryMaxLength = 280; + +/// Linha de `terms_versions` como a camada de aplicação a enxerga. +class TermsVersionSnapshot { + const TermsVersionSnapshot({ + required this.id, + required this.version, + required this.summary, + required this.effectiveFrom, + required this.createdAt, + this.legallyReviewedAt, + this.legallyReviewedBy, + this.publishedAt, + this.publishedBy, + }); + + final String id; + final String version; + final String summary; + final DateTime effectiveFrom; + final DateTime createdAt; + final DateTime? legallyReviewedAt; + final String? legallyReviewedBy; + final DateTime? publishedAt; + final String? publishedBy; +} + +/// Persistência das versões dos termos. A implementação ORM fica em +/// `infrastructure/` (Tasks 2.3/2.4). +abstract interface class TermsVersionStore { + Future insertDraft({ + required String version, + required String summary, + required DateTime effectiveFrom, + required String createdBy, + required DateTime createdAt, + }); + + Future findById(String id); + + Future versionExists(String version); + + /// Atômico: só marca se ainda não revisada nem publicada. + Future markReviewed(String id, {required String reviewedBy, required DateTime at}); + + /// Atômico: só publica se revisada e ainda não publicada. + Future markPublished(String id, {required String publishedBy, required DateTime at}); + + Future> list(); + + /// A publicada de maior `publishedAt` cuja vigência ainda não começou, ou + /// `null`. É assim que "uma só agenda ativa" se resolve: a última publicada + /// substitui a anterior, sem coluna de substituição. + Future currentPublished(DateTime now); +} + +/// Rascunho, revisão jurídica e publicação das versões dos termos (LGPD-RF18, +/// #46). Só `admin`. O resumo e o texto nunca entram na auditoria. +class TermsPublicationService { + TermsPublicationService({ + required TermsVersionStore store, + required AuditTrail audit, + DateTime Function()? clock, + }) : _store = store, + _audit = audit, + _clock = clock ?? DateTime.now; + + final TermsVersionStore _store; + final AuditTrail _audit; + final DateTime Function() _clock; + + /// Rascunha uma versão. Não impõe os 15 dias: isso é da publicação. + Future draft( + AuthenticatedUser user, { + required String version, + required String summary, + required DateTime effectiveFrom, + }) async { + await _requireAdmin(user, null); + final cleanVersion = version.trim(); + final cleanSummary = summary.trim(); + if (cleanVersion.isEmpty) { + throw AdminInvalidRequestException(message: 'Informe o número da versão.'); + } + if (cleanVersion == consentPolicyVersion) { + throw AdminInvalidRequestException(message: 'Esta já é a versão vigente dos termos.'); + } + if (cleanSummary.isEmpty) { + throw AdminInvalidRequestException(message: 'Informe o resumo da mudança.'); + } + if (cleanSummary.length > termsSummaryMaxLength) { + throw AdminInvalidRequestException( + message: 'O resumo pode ter no máximo $termsSummaryMaxLength caracteres.', + ); + } + final now = _clock(); + if (!effectiveFrom.isAfter(now)) { + throw AdminInvalidRequestException(message: 'A vigência deve estar no futuro.'); + } + if (await _store.versionExists(cleanVersion)) { + throw AdminInvalidRequestException(message: 'Já existe uma versão com este número.'); + } + final id = await _store.insertDraft( + version: cleanVersion, + summary: cleanSummary, + effectiveFrom: effectiveFrom.toUtc(), + createdBy: user.id, + createdAt: now.toUtc(), + ); + await _write(user, id, 'drafted'); + return (await _store.findById(id))!; + } + + /// Registra a revisão jurídica. Nunca sobrescreve quem/quando. + Future markReviewed(AuthenticatedUser user, String id) async { + await _requireAdmin(user, id); + final row = await _find(id); + if (row.publishedAt != null || row.legallyReviewedAt != null) { + throw AdminInvalidRequestException(message: 'A versão já foi revisada.'); + } + if (!await _store.markReviewed(id, reviewedBy: user.id, at: _clock().toUtc())) { + throw AdminInvalidRequestException(message: 'A versão já foi revisada.'); + } + await _write(user, id, 'reviewed'); + return (await _store.findById(id))!; + } + + /// Publica uma versão revisada, com vigência a pelo menos 15 dias. + Future publish(AuthenticatedUser user, String id) async { + await _requireAdmin(user, id); + final row = await _find(id); + if (row.publishedAt != null) { + throw AdminInvalidRequestException(message: 'A versão já foi publicada.'); + } + if (row.legallyReviewedAt == null) { + await _write(user, id, 'denied_not_reviewed'); + throw AdminInvalidRequestException( + message: 'A versão ainda não foi revisada juridicamente.', + ); + } + final now = _clock().toUtc(); + try { + // Reaproveita o validador da regra dos 15 dias. + TermsChangeSchedule( + version: row.version, + publishedAt: now, + effectiveFrom: row.effectiveFrom, + summary: row.summary, + ); + } on ArgumentError { + await _write(user, id, 'denied_notice_period'); + throw AdminInvalidRequestException(message: 'A vigência exige 15 dias de antecedência.'); + } + if (!await _store.markPublished(id, publishedBy: user.id, at: now)) { + throw AdminInvalidRequestException(message: 'A versão já foi publicada.'); + } + await _write(user, id, 'published'); + return (await _store.findById(id))!; + } + + Future> list(AuthenticatedUser user) async { + await _requireAdmin(user, null, actionType: 'read'); + // Fail-closed: audita ANTES de listar. + await _audit.record(AuditEvent( + userId: user.id, + actionType: 'read', + resourceType: 'terms_version', + result: 'granted', + )); + return _store.list(); + } + + Future _find(String id) async { + if (!isUuidFormat(id)) { + throw AdminInvalidRequestException(message: 'Identificador inválido.'); + } + final row = await _store.findById(id); + if (row == null) { + throw AdminInvalidRequestException(message: 'Versão não encontrada.'); + } + return row; + } + + Future _write(AuthenticatedUser user, String id, String result) => + _audit.record(AuditEvent( + userId: user.id, + actionType: 'write', + resourceType: 'terms_version', + resourceId: isUuidFormat(id) ? id : null, + result: result, + )); + + /// Só `admin`. Grava a negação (await) antes de lançar; `resourceId` só se UUID. + Future _requireAdmin( + AuthenticatedUser user, + String? id, { + String actionType = 'write', + }) async { + if (user.role == UserRole.admin) return; + await _audit.record(AuditEvent( + userId: user.id, + actionType: actionType, + resourceType: 'terms_version', + resourceId: id != null && isUuidFormat(id) ? id : null, + result: 'denied_role', + )); + throw StateError('Sem permissão para esta operação dos termos.'); + } +} diff --git a/backend/sinalacs_server/lib/src/application/retention/expurge_future_call.dart b/backend/sinalacs_server/lib/src/application/retention/expurge_future_call.dart new file mode 100644 index 00000000..4f99c652 --- /dev/null +++ b/backend/sinalacs_server/lib/src/application/retention/expurge_future_call.dart @@ -0,0 +1,78 @@ +import 'dart:io'; + +import 'package:serverpod/protocol.dart' show FutureCallEntry; +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/retention/expurge_service.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_retention_store.dart'; + +/// Nome registrado no boot via `pod.registerFutureCall`. +const retentionExpurgeFutureCallName = 'retentionExpurge'; + +/// Identificador fixo do agendamento diário: um novo boot (ou uma nova +/// execução) remove qualquer pendente com este identificador antes de agendar, +/// então restarts não empilham execuções. +const retentionExpurgeScheduleIdentifier = 'retention-expurge-diario'; + +/// Rotina diária de expurgo (LGPD-RF07), registrada como +/// `retentionExpurge`. +/// +/// O tipo de argumento é [SerializableModel] (o próprio limite da superclasse) +/// porque este call não recebe parâmetro: a entrada agendada grava +/// `serializedObject` nulo e nada é desserializado. +/// +/// O registro do resultado é a decisão C1 — log operacional (stdout + +/// `session.log`), fora da cadeia de `audit_logs`: um job agendado não tem +/// usuário, e a evolução para a cadeia (ator de sistema) está planejada em +/// spec/lgpd_data_audit_action.md §4. +class ExpurgeFutureCall extends FutureCall { + ExpurgeFutureCall({required RetentionPolicy policy}) : _policy = policy; + + final RetentionPolicy _policy; + + @override + Future invoke(Session session, SerializableModel? object) async { + // Agenda a próxima janela ANTES de purgar: uma queda no meio da execução + // não pode custar o agendamento de amanhã. + await ensureRetentionExpurgeScheduled(session); + + final service = ExpurgeService( + store: OrmRetentionStore(session: () => session), + policy: _policy, + ); + try { + final result = await service.run(); + stdout.writeln('Expurgo LGPD-RF07 concluído: ${result.describe()}.'); + session.log('retention_expurge: ${result.describe()}'); + final retidos = result.describeRetained(); + if (retidos != null) { + stdout.writeln(retidos); + session.log(retidos); + } + } catch (error) { + // VAZ-01: só o tipo da falha, nunca o objeto bruto. + stderr.writeln('Falha no expurgo LGPD-RF07 (${error.runtimeType}).'); + } + } +} + +/// Agenda (idempotente) a próxima execução para o próximo 03:00 UTC. +Future ensureRetentionExpurgeScheduled( + Session session, { + DateTime Function()? clock, +}) async { + final next = nextDailyRun((clock ?? DateTime.now)().toUtc()); + await FutureCallEntry.db.deleteWhere( + session, + where: (row) => row.identifier.equals(retentionExpurgeScheduleIdentifier), + ); + await FutureCallEntry.db.insertRow( + session, + FutureCallEntry( + name: retentionExpurgeFutureCallName, + serializedObject: null, + time: next, + serverId: 'default', + identifier: retentionExpurgeScheduleIdentifier, + ), + ); +} diff --git a/backend/sinalacs_server/lib/src/application/retention/expurge_service.dart b/backend/sinalacs_server/lib/src/application/retention/expurge_service.dart new file mode 100644 index 00000000..4d6c7a72 --- /dev/null +++ b/backend/sinalacs_server/lib/src/application/retention/expurge_service.dart @@ -0,0 +1,163 @@ +/// Política de retenção (LGPD-RF07). +/// +/// Os valores vêm do `AppConfig` (`RETENTION_*_DAYS`, com defaults na tabela +/// de retenção de spec/lgpd_design.md §5.6: alertas 2 anos; visitas e triagens +/// 5 anos). Esta classe só carrega os prazos e calcula os cortes — a validação +/// de inteiro positivo fica no `AppConfig`, que é quem lê o ambiente. +class RetentionPolicy { + const RetentionPolicy({ + required this.alertsDays, + required this.visitsDays, + required this.triageSessionsDays, + }) : assert(alertsDays > 0), + assert(visitsDays > 0), + assert(triageSessionsDays > 0); + + /// Retenção de `alerts`, em dias. + final int alertsDays; + + /// Retenção de `visits`, em dias. + final int visitsDays; + + /// Retenção de `triage_sessions`, em dias. + final int triageSessionsDays; + + /// Os instantes-limite por tabela: linhas anteriores a estes cortes estão + /// vencidas e são candidatas ao expurgo. + RetentionCutoffs cutoffsAt(DateTime nowUtc) { + final now = nowUtc.toUtc(); + return RetentionCutoffs( + alerts: now.subtract(Duration(days: alertsDays)), + visits: now.subtract(Duration(days: visitsDays)), + triageSessions: now.subtract(Duration(days: triageSessionsDays)), + ); + } +} + +/// Os cortes por tabela, calculados por [RetentionPolicy.cutoffsAt]. +class RetentionCutoffs { + const RetentionCutoffs({ + required this.alerts, + required this.visits, + required this.triageSessions, + }); + + final DateTime alerts; + final DateTime visits; + final DateTime triageSessions; +} + +/// Contagens do expurgo, apuradas ANTES dos DELETEs (decisão B3): são o único +/// resíduo operacional que sobra do que foi removido, e vão para o log da +/// rotina — a trilha de auditoria em si (`audit_logs`) não é tocada. +class ExpurgeCounts { + const ExpurgeCounts({ + required this.alerts, + required this.alertDeliveries, + required this.alertIdempotencyKeys, + required this.alertOutbox, + required this.visits, + required this.triageSessions, + required this.alertsByRisk, + this.skippedPendingRed = 0, + }); + + final int alerts; + final int alertDeliveries; + final int alertIdempotencyKeys; + final int alertOutbox; + final int visits; + final int triageSessions; + + /// `alerts` desdobrados por `riskLevel` — a parte "série epidemiológica" que + /// a contagem preserva antes do DELETE físico. + final Map alertsByRisk; + + /// Alertas vermelhos vencidos que o expurgo PULOU por ainda estarem + /// `pending` (D7, #44). Fora de [alerts]: não saíram. + final int skippedPendingRed; +} + +/// Persistência do expurgo: conta e remove as linhas vencidas de forma +/// atômica. A implementação ORM faz tudo numa única transação sob advisory +/// lock. +abstract interface class RetentionStore { + Future purgeDue(RetentionCutoffs cutoffs); +} + +/// Resultado de uma rodada, pronto para o log operacional (decisão C1). +class ExpurgeResult { + const ExpurgeResult({ + required this.ranAtUtc, + required this.cutoffs, + required this.counts, + }); + + final DateTime ranAtUtc; + final RetentionCutoffs cutoffs; + final ExpurgeCounts counts; + + /// A linha do log operacional — nunca carrega dado de paciente, só contagens + /// e cortes (a regra de sanitização VAZ-01 vale também aqui). + String describe() { + final porRisco = counts.alertsByRisk.isEmpty + ? 'nenhum' + : counts.alertsByRisk.entries + .map((entry) => '${entry.key}=${entry.value}') + .join(', '); + return 'alertas=${counts.alerts} (risco: $porRisco), ' + 'entregas=${counts.alertDeliveries}, ' + 'idempotencia=${counts.alertIdempotencyKeys}, ' + 'outbox=${counts.alertOutbox}, ' + 'visitas=${counts.visits}, ' + 'triagens=${counts.triageSessions}; ' + 'cortes: alertas<${cutoffs.alerts.toIso8601String()}, ' + 'visitas<${cutoffs.visits.toIso8601String()}, ' + 'triagens<${cutoffs.triageSessions.toIso8601String()}'; + } + + /// Aviso operacional dos vermelhos pendentes retidos; `null` quando não há + /// nenhum. Só a contagem, sem identidade. + String? describeRetained() => counts.skippedPendingRed > 0 + ? 'Expurgo: ${counts.skippedPendingRed} alerta(s) vermelho(s) ' + 'pendente(s) retido(s)' + : null; +} + +/// Rotina de retenção (LGPD-RF07): calcula os cortes e delega a contagem + +/// remoção ao [RetentionStore]. +/// +/// DELETE físico (decisão B1): o requisito aceita "anonimizados **ou +/// eliminados** de forma segura" (lgpd_design §143; RT09), e o schema não +/// permite anonimização in-place sem migração (`patientId` é FK NOT NULL) — +/// decisões e evolução em spec/lgpd_data_audit_action.md §4. +class ExpurgeService { + ExpurgeService({ + required RetentionStore store, + required RetentionPolicy policy, + DateTime Function()? clock, + }) : _store = store, + _policy = policy, + _clock = clock ?? DateTime.now; + + final RetentionStore _store; + final RetentionPolicy _policy; + final DateTime Function() _clock; + + Future run() async { + final nowUtc = _clock().toUtc(); + final cutoffs = _policy.cutoffsAt(nowUtc); + final counts = await _store.purgeDue(cutoffs); + return ExpurgeResult(ranAtUtc: nowUtc, cutoffs: cutoffs, counts: counts); + } +} + +/// Próxima execução diária às [hourUtc] horas UTC: hoje, se essa hora ainda +/// não passou; senão amanhã. Pura — testável com relógio fixo. +DateTime nextDailyRun(DateTime nowUtc, {int hourUtc = 3}) { + final now = nowUtc.toUtc(); + final today = DateTime.utc(now.year, now.month, now.day, hourUtc); + return today.isAfter(now) + ? today + : DateTime.utc(now.year, now.month, now.day + 1, hourUtc); +} diff --git a/backend/sinalacs_server/lib/src/application/triage/triage_session_service.dart b/backend/sinalacs_server/lib/src/application/triage/triage_session_service.dart index 43589e17..5fcc9320 100644 --- a/backend/sinalacs_server/lib/src/application/triage/triage_session_service.dart +++ b/backend/sinalacs_server/lib/src/application/triage/triage_session_service.dart @@ -160,7 +160,8 @@ class TriageSessionService { )); } catch (error) { stderr.writeln( - 'Falha ao gravar a sessão de triagem do paciente ${user.id}: $error.', + 'Falha ao gravar a sessão de triagem do paciente ${user.id} ' + '(${error.runtimeType}).', ); } diff --git a/backend/sinalacs_server/lib/src/application/ubs/ubs_contact_service.dart b/backend/sinalacs_server/lib/src/application/ubs/ubs_contact_service.dart new file mode 100644 index 00000000..75b5d942 --- /dev/null +++ b/backend/sinalacs_server/lib/src/application/ubs/ubs_contact_service.dart @@ -0,0 +1,43 @@ +import 'package:sinalacs_server/src/application/auth/authorization.dart'; +import 'package:sinalacs_server/src/application/auth/development_auth_service.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; + +class UbsContactRecord { + const UbsContactRecord({required this.name, required this.phone}); + + final String name; + final String? phone; +} + +abstract interface class UbsContactStore { + /// UBS do ACS `acsId`, ou `null` se o ACS ou a UBS não existirem. + Future findForAcs(String acsId); +} + +/// Contato da UBS para o botão de escalonamento do ACS (RF13). +/// +/// A UBS vem do **token** (`user.id` é o id do ACS), nunca de parâmetro — mesma +/// regra de território das outras consultas do ACS. +class UbsContactService { + UbsContactService({required this.store}); + + final UbsContactStore store; + + Future contactFor(AuthenticatedUser user) async { + Authorization.require( + user, + roles: {UserRole.acs}, + onDenied: () => StateError('Somente ACS consultam o contato da UBS.'), + ); + + final record = await store.findForAcs(user.id); + if (record == null) { + throw StateError('UBS do ACS não encontrada.'); + } + final phone = record.phone?.trim(); + return UbsContact( + name: record.name, + phone: (phone == null || phone.isEmpty) ? null : phone, + ); + } +} 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 index 7ab88515..1bc5ca94 100644 --- a/backend/sinalacs_server/lib/src/application/visits/visit_sync_service.dart +++ b/backend/sinalacs_server/lib/src/application/visits/visit_sync_service.dart @@ -17,6 +17,8 @@ class VisitRecord { this.id, required this.patientId, required this.acsId, + this.authorship = VisitAuthorship.acs, + this.originDeviceId, required this.scheduledAt, this.startedAt, this.completedAt, @@ -33,7 +35,16 @@ class VisitRecord { final UuidValue? id; final UuidValue patientId; - final UuidValue acsId; + + /// Autor da visita. Nulo **só** quando [authorship] é + /// [VisitAuthorship.legacyUnclaimed]: a visita veio do aparelho sem dono + /// conhecido (D4), e quem a transportou **não** é o autor. + final UuidValue? acsId; + + final VisitAuthorship authorship; + + /// Instalação de onde veio uma visita legada; nulo nas visitas com autor. + final String? originDeviceId; final DateTime scheduledAt; final DateTime? startedAt; final DateTime? completedAt; @@ -58,7 +69,9 @@ class VisitRecord { VisitRecord copyWith({ Object? id = _keep, UuidValue? patientId, - UuidValue? acsId, + Object? acsId = _keep, + VisitAuthorship? authorship, + Object? originDeviceId = _keep, DateTime? scheduledAt, Object? startedAt = _keep, Object? completedAt = _keep, @@ -75,7 +88,10 @@ class VisitRecord { VisitRecord( id: id == _keep ? this.id : id as UuidValue?, patientId: patientId ?? this.patientId, - acsId: acsId ?? this.acsId, + acsId: acsId == _keep ? this.acsId : acsId as UuidValue?, + authorship: authorship ?? this.authorship, + originDeviceId: + originDeviceId == _keep ? this.originDeviceId : originDeviceId as String?, scheduledAt: scheduledAt ?? this.scheduledAt, startedAt: startedAt == _keep ? this.startedAt : startedAt as DateTime?, completedAt: completedAt == _keep ? this.completedAt : completedAt as DateTime?, @@ -120,6 +136,13 @@ abstract interface class VisitStore { /// Visitas da microárea cujo `syncAt` é posterior a `since` — o cursor /// incremental de `visits.pull` (RF15, decisão §5). Future> listChangedInMicroArea(UuidValue microAreaId, DateTime since); + + /// `true` se o ACS existe e a conta está ativa (`acs.active`). + /// + /// Só `syncLegacy` consulta: o token prova quem é o transportador, não que a + /// conta continua ativa — e uma visita sem autor só entra pela mão de quem + /// ainda responde pelo território. + Future isActiveAcs(UuidValue acsId); } /// Sincronização das visitas registradas offline pelo ACS. @@ -146,6 +169,20 @@ class VisitSyncService { final AuditTrail _audit; final DateTime Function() _clock; + /// Teto de visitas por chamada de envio desvinculado do autor (`syncLegacy`; + /// a Task 6 reaproveita no `syncDeferred`). O `sync` comum NÃO tem teto. + static const int maxLegacyBatch = 200; + + /// Recusa um lote acima de [maxLegacyBatch] com `ArgumentError` (o endpoint + /// traduz para `AlertValidationException`). Pública para o endpoint poder + /// checar ANTES de abrir a transação. + static void checkLegacyBatchSize(int length) { + if (length > maxLegacyBatch) { + throw ArgumentError.value(length, 'visits', + 'lote acima do limite de $maxLegacyBatch visitas por envio'); + } + } + /// Sincronização central→dispositivo: visitas da microárea do ACS /// alteradas desde `since`, para reconciliar um device que ficou offline ou /// foi reinstalado. Território vem sempre do token (INV-01), nunca de @@ -215,10 +252,83 @@ class VisitSyncService { return results; } + /// Envio das visitas LEGADAS do aparelho — gravadas antes de existir dono + /// por visita (migração v7 do app), de autoria desconhecida (D4). + /// + /// A sessão de [transporter] é só o **transporte**: precisa ser de um ACS + /// territorializado e ativo, e cada visita passa pela mesma barreira de + /// território do `sync` (microárea do paciente × microárea de quem + /// transporta, INV-01), mas o transportador **nunca** vira autor — + /// `acsId` fica nulo, `authorship` é `legacyUnclaimed` e `originDeviceId` + /// guarda [deviceId]. Reenvio do mesmo `localId` é idempotente; um `localId` + /// que já existe com autor ACS é recusado (`rejected`) e a autoria original + /// fica intacta. + /// + /// Um evento `visit_legacy_sync` por LOTE vai para `audit_logs`, com o + /// transportador e sem paciente, `localId` ou nota; e um + /// `visit_legacy_sync_item` por visita efetivamente criada ou alterada, com + /// o id da visita (`resourceId`) — nenhum para recusa ou reenvio sem efeito. + /// No máximo [maxLegacyBatch] visitas por chamada. + Future> syncLegacy({ + required AuthenticatedUser transporter, + required String deviceId, + required List entries, + }) async { + Authorization.require( + transporter, + roles: {UserRole.acs}, + onDenied: () => StateError( + 'Somente ACS territorializados podem enviar visitas legadas.'), + ); + if (deviceId.trim().isEmpty) { + throw ArgumentError.value(deviceId, 'deviceId', 'deviceId é obrigatório'); + } + // Antes de qualquer consulta ao store. + checkLegacyBatchSize(entries.length); + if (!await _store.isActiveAcs(UuidValue.fromString(transporter.id))) { + throw StateError('Somente ACS ativos podem enviar visitas legadas.'); + } + + final results = []; + final written = []; + for (final entry in entries) { + results.add(await _syncOne( + user: transporter, + entry: entry, + legacyDeviceId: deviceId, + legacyWritten: written, + )); + } + + // A trilha usa outra conexão, FORA da transação do lote: gravar os itens + // dentro do laço deixaria linhas apontando para visitas que nunca + // persistiram quando uma entrada posterior lança e o lote é desfeito. Por + // isso os itens só saem aqui, depois do laço inteiro, logo antes do lote. + for (final visit in written) { + await _auditLegacyItem(transporter, visit); + } + + // Best-effort, como o `pull`: a trilha fora do ar não pode impedir que o + // trabalho de campo suba. O evento é o LOTE — nada clínico, nenhum + // identificador de paciente. + await _audit.recordSafely(AuditEvent( + userId: transporter.id, + actionType: 'write', + resourceType: 'visit_legacy', + result: 'visit_legacy_sync', + )); + return results; + } + + /// Uma visita do lote. Com [legacyDeviceId] nulo é o `sync` comum (o autor + /// é [user]); preenchido, é o envio legado (sem autor, [user] só transporta). Future _syncOne({ required AuthenticatedUser user, required VisitSyncEntry entry, + String? legacyDeviceId, + List? legacyWritten, }) async { + final legacy = legacyDeviceId != null; if (entry.localId.trim().isEmpty) { // Terminal: um localId vazio não vira válido reenviando o mesmo lote. return VisitSyncResult( @@ -267,16 +377,12 @@ class VisitSyncService { await _audit.recordSafely(AuditEvent( userId: user.id, actionType: 'write', - resourceType: 'visit', + resourceType: legacy ? 'visit_legacy' : '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', - ); + return _outsideTerritory(entry); } final acsId = UuidValue.fromString(user.id); @@ -285,7 +391,10 @@ class VisitSyncService { if (existing == null) { final inserted = await _store.insert(VisitRecord( patientId: patientId, - acsId: acsId, + // Legado: autoria desconhecida. NUNCA gravar quem transportou. + acsId: legacy ? null : acsId, + authorship: legacy ? VisitAuthorship.legacyUnclaimed : VisitAuthorship.acs, + originDeviceId: legacyDeviceId, scheduledAt: entry.scheduledAt, completedAt: entry.completedAt, status: entry.status, @@ -298,6 +407,7 @@ class VisitSyncService { syncAt: _clock().toUtc(), version: 1, )); + legacyWritten?.add(inserted); return VisitSyncResult( localId: entry.localId, syncStatus: SyncStatus.synced, @@ -305,10 +415,68 @@ class VisitSyncService { ); } - // 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. + if (legacy) { + // Antes de QUALQUER resposta que dependa da linha existente: o `localId` + // tem de ser de uma visita do MESMO paciente da entrada (cujo território + // já foi conferido acima — logo, a linha também é do território de quem + // transporta). Sem isto, `synced`/'versão diferente' viraria um oráculo: + // quem chuta um `localId` descobriria que a visita existe, e em que + // versão, mesmo sendo de outro paciente ou de outra microárea. A + // resposta é a mesma da recusa territorial, para não distinguir os casos. + if (existing.patientId != patientId) { + await _audit.recordSafely(AuditEvent( + userId: user.id, + actionType: 'write', + resourceType: 'visit_legacy', + resourceId: patientId.uuid, + result: 'denied_patient_mismatch', + )); + return _outsideTerritory(entry); + } + + // O envio legado só ALTERA visitas SEM autor que vieram do mesmo + // aparelho. Uma visita com autor ACS não vira "desconhecida" por um + // reenvio: na mesma versão o reenvio é só a idempotência (a visita já + // subiu, possivelmente pelo próprio autor antes da migração v7) e + // devolve `synced` sem gravar nada; em outra versão, `rejected`. + if (existing.authorship != VisitAuthorship.legacyUnclaimed || + existing.acsId != null) { + if (entry.version == existing.version) { + return VisitSyncResult( + localId: entry.localId, + syncStatus: SyncStatus.synced, + serverVersion: existing.version, + ); + } + return VisitSyncResult( + localId: entry.localId, + syncStatus: SyncStatus.rejected, + message: 'visita já registrada com autor — versão diferente', + ); + } + // Proteção contra palpite errado (um localId colidindo entre aparelhos, + // um cliente com bug), NÃO controle de segurança: `deviceId` é declarado + // pelo próprio cliente e qualquer portador de sessão pode repeti-lo. + if (existing.originDeviceId != legacyDeviceId) { + return VisitSyncResult( + localId: entry.localId, + syncStatus: SyncStatus.rejected, + message: 'visita legada de outro aparelho', + ); + } + } else if (existing.acsId == null) { + // Visita sem autor (legado, D4): o `sync` comum NÃO a reivindica — se + // reivindicasse, qualquer ACS do território viraria autor de um trabalho + // de campo que ninguém sabe de quem é. Terminal: não muda por retentar. + return VisitSyncResult( + localId: entry.localId, + syncStatus: SyncStatus.rejected, + message: 'visita sem autor registrado', + ); + } else if (existing.acsId != acsId) { + // A visita pertence ao ACS que a registrou. Um ACS não sincroniza a + // visita de outro, mesmo conhecendo o localId. Terminal: o dono do + // registro não muda por retentar. return VisitSyncResult( localId: entry.localId, syncStatus: SyncStatus.rejected, @@ -338,6 +506,9 @@ class VisitSyncService { ); } + // `acsId`, `authorship` e `originDeviceId` também ficam de fora: a autoria + // é decidida na criação e nenhuma atualização a muda (nem o envio legado + // preenche um autor, nem o `sync` comum chega aqui com visita sem autor). // `arrivalMethod` fica de fora deliberadamente: é definido na criação da // visita (como `scheduledAt`/`riskLevelBefore`), não numa atualização de // conclusão — `copyWith` sem o argumento preserva o valor gravado no @@ -352,10 +523,30 @@ class VisitSyncService { version: existing.version + 1, )); + legacyWritten?.add(updated); return VisitSyncResult( localId: entry.localId, syncStatus: SyncStatus.synced, serverVersion: updated.version, ); } + + /// A recusa territorial, única para fora do território e para `localId` de + /// outro paciente: não cita a microárea alheia nem o paciente. Terminal. + static VisitSyncResult _outsideTerritory(VisitSyncEntry entry) => VisitSyncResult( + localId: entry.localId, + syncStatus: SyncStatus.rejected, + message: 'paciente fora da sua microárea', + ); + + /// Uma linha por visita legada criada ou alterada: quem transportou e QUAL + /// visita — só o id, nada clínico. Best-effort como o resto da trilha. + Future _auditLegacyItem(AuthenticatedUser transporter, VisitRecord visit) => + _audit.recordSafely(AuditEvent( + userId: transporter.id, + actionType: 'write', + resourceType: 'visit_legacy', + resourceId: visit.id?.uuid, + result: 'visit_legacy_sync_item', + )); } diff --git a/backend/sinalacs_server/lib/src/config/app_config.dart b/backend/sinalacs_server/lib/src/config/app_config.dart index 4e13fb4a..f2a5fbfc 100644 --- a/backend/sinalacs_server/lib/src/config/app_config.dart +++ b/backend/sinalacs_server/lib/src/config/app_config.dart @@ -17,12 +17,28 @@ class AppConfig { required this.mqttPassword, required this.mqttUseTls, required this.mqttCaCertificatePath, + this.mqttClientCertificatePath, + this.mqttClientKeyPath, required this.appEnv, required this.enableDevLogin, + this.requireAcsMfa = false, + this.gorushUrl, + this.retentionAlertDays = defaultRetentionAlertDays, + this.retentionVisitDays = defaultRetentionVisitDays, + this.retentionTriageDays = defaultRetentionTriageDays, + this.redAlertEscalationSeconds = defaultRedAlertEscalationSeconds, }); final String mqttBroker; + /// Endereço do Gorush, o relé de push para FCM/APNs (RF14, decisão §3.2), sem + /// barra final. `null` desliga o envio de avisos: o backend sobe e responde + /// tudo o mais, e `notices.sendSegmented` recusa com uma mensagem clara. + final String? gorushUrl; + + /// Tempo máximo de uma chamada ao Gorush, do connect à resposta inteira. + Duration get gorushTimeout => const Duration(seconds: 5); + /// 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 @@ -75,9 +91,37 @@ class AppConfig { final String? mqttPassword; final bool mqttUseTls; final String? mqttCaCertificatePath; + + /// Certificado e chave de CLIENTE para o mTLS do broker (`MQTT_CLIENT_CERT_PATH` + /// e `MQTT_CLIENT_KEY_PATH`). Vêm sempre juntos; fora de `development` com + /// TLS são obrigatórios. + final String? mqttClientCertificatePath; + final String? mqttClientKeyPath; final String appEnv; final bool enableDevLogin; + /// `REQUIRE_ACS_MFA`: ACS sem MFA (TOTP) ativa não entra pelo login + /// institucional; precisa ativá-la antes. Padrão **ligado fora de + /// `development`** ([resolveRequireAcsMfa]). O default do construtor é + /// `false` só para as configurações montadas à mão nos testes. + final bool requireAcsMfa; + + /// Prazos de retenção do expurgo LGPD-RF07, em dias — `RETENTION_ALERT_DAYS`, + /// `RETENTION_VISIT_DAYS`, `RETENTION_TRIAGE_DAYS`. Opcionais: os defaults + /// são a tabela de retenção de spec/lgpd_design.md §5.6 (alertas 2 anos, + /// visitas e triagens 5 anos). Só inteiro positivo com teto sanitário de + /// 36500 dias: um valor errado aqui apaga dados reais de forma irreversível. + final int retentionAlertDays; + final int retentionVisitDays; + final int retentionTriageDays; + + /// `RED_ALERT_ESCALATION_SECONDS`: prazo, em segundos, para o alerta vermelho + /// ser reconhecido antes de ficar elegível a escalonamento (issue #44). + /// Opcional; padrão 300 (5 min). Só inteiro de 30 a 3600: abaixo disso o + /// escalonamento dispara por ruído, acima o alerta fica sem resposta tempo + /// demais. Fora da faixa, falha no boot. + final int redAlertEscalationSeconds; + bool get isProduction => appEnv == 'production'; /// Segredo usado quando `JWT_SECRET` não é informado em desenvolvimento. @@ -110,6 +154,16 @@ class AppConfig { /// restrito a `development` por [_resolveSecret]. static const developmentCpfHashPepper = 'development-cpf-hash-pepper'; + /// Defaults da política de retenção (spec/lgpd_design.md §5.6). + static const defaultRetentionAlertDays = 730; // 2 anos + static const defaultRetentionVisitDays = 1825; // 5 anos + static const defaultRetentionTriageDays = 1825; // 5 anos + + /// Prazo padrão e faixa válida do escalonamento do alerta vermelho (#44). + static const defaultRedAlertEscalationSeconds = 300; + static const minRedAlertEscalationSeconds = 30; + static const maxRedAlertEscalationSeconds = 3600; + static const _knownSmsGateways = {'log'}; /// Aceita apenas os gateways implementados, e `log` apenas em `development`. @@ -148,6 +202,9 @@ class AppConfig { factory AppConfig.fromEnvironment() => AppConfig.fromMap(Platform.environment); + static String? _blankToNull(String? value) => + (value == null || value.trim().isEmpty) ? null : value; + /// Mesma leitura, a partir de um mapa qualquer. /// /// `Platform.environment` não é substituível dentro do processo, então as @@ -156,6 +213,21 @@ class AppConfig { factory AppConfig.fromMap(Map environment) { final appEnv = environment['APP_ENV'] ?? 'development'; + final mqttUseTls = environment['MQTT_USE_TLS'] == 'true'; + final mqttClientCert = _blankToNull(environment['MQTT_CLIENT_CERT_PATH']); + final mqttClientKey = _blankToNull(environment['MQTT_CLIENT_KEY_PATH']); + if ((mqttClientCert == null) != (mqttClientKey == null)) { + throw StateError( + 'MQTT_CLIENT_CERT_PATH e MQTT_CLIENT_KEY_PATH devem vir juntos.', + ); + } + if (appEnv != 'development' && mqttUseTls && mqttClientCert == null) { + throw StateError( + 'MQTT_CLIENT_CERT_PATH e MQTT_CLIENT_KEY_PATH são obrigatórios ' + 'fora de development com MQTT_USE_TLS=true (mTLS do broker).', + ); + } + return AppConfig( mqttBroker: environment['MQTT_BROKER'] ?? 'localhost:1883', jwtSecret: _resolveSecret( @@ -186,13 +258,95 @@ class AppConfig { ), mqttUsername: environment['MQTT_USERNAME'], mqttPassword: environment['MQTT_PASSWORD'], - mqttUseTls: environment['MQTT_USE_TLS'] == 'true', + mqttUseTls: mqttUseTls, mqttCaCertificatePath: environment['MQTT_CA_CERT_PATH'], + mqttClientCertificatePath: mqttClientCert, + mqttClientKeyPath: mqttClientKey, appEnv: appEnv, enableDevLogin: environment['ENABLE_DEV_LOGIN'] == 'true', + requireAcsMfa: resolveRequireAcsMfa( + value: environment['REQUIRE_ACS_MFA'], + appEnv: appEnv, + ), + gorushUrl: _resolveGorushUrl(environment['GORUSH_URL']), + retentionAlertDays: _resolveRetentionDays( + environment['RETENTION_ALERT_DAYS'], + fallback: defaultRetentionAlertDays, + envVarName: 'RETENTION_ALERT_DAYS', + ), + retentionVisitDays: _resolveRetentionDays( + environment['RETENTION_VISIT_DAYS'], + fallback: defaultRetentionVisitDays, + envVarName: 'RETENTION_VISIT_DAYS', + ), + retentionTriageDays: _resolveRetentionDays( + environment['RETENTION_TRIAGE_DAYS'], + fallback: defaultRetentionTriageDays, + envVarName: 'RETENTION_TRIAGE_DAYS', + ), + redAlertEscalationSeconds: _resolveEscalationSeconds( + environment['RED_ALERT_ESCALATION_SECONDS'], + ), ); } + /// Inteiro de segundos entre 30 e 3600; vazio vale o padrão. Malformado ou + /// fora da faixa falha no boot em vez de escalonar cedo ou tarde demais. + static int _resolveEscalationSeconds(String? value) { + final raw = value?.trim() ?? ''; + if (raw.isEmpty) return defaultRedAlertEscalationSeconds; + final seconds = int.tryParse(raw); + if (seconds == null || + seconds < minRedAlertEscalationSeconds || + seconds > maxRedAlertEscalationSeconds) { + throw ArgumentError.value( + value, + 'RED_ALERT_ESCALATION_SECONDS', + 'use um inteiro de segundos entre $minRedAlertEscalationSeconds e ' + '$maxRedAlertEscalationSeconds.', + ); + } + return seconds; + } + + /// Inteiro positivo de dias, com teto sanitário de 36500 (100 anos) — um + /// `RETENTION_*_DAYS` malformado ou absurdo falha no boot em vez de apagar + /// mais do que a política manda. + static int _resolveRetentionDays( + String? value, { + required int fallback, + required String envVarName, + }) { + final raw = value?.trim() ?? ''; + if (raw.isEmpty) return fallback; + final days = int.tryParse(raw); + if (days == null || days <= 0 || days > 36500) { + throw StateError( + '$envVarName inválido ("$value"): use um inteiro positivo de dias ' + '(1 a 36500).', + ); + } + return days; + } + + /// Com a variável presente, só a string exata `true` liga (mesma regra de + /// `ENABLE_DEV_LOGIN`). Ausente, vale `true` em todo ambiente que não seja + /// `development`: esquecer a variável em produção não pode desligar a MFA. + static bool resolveRequireAcsMfa({required String? value, required String appEnv}) => + value != null ? value == 'true' : appEnv != 'development'; + + static String? _resolveGorushUrl(String? value) { + var url = value?.trim() ?? ''; + if (url.isEmpty) return null; + if (!url.startsWith('http://') && !url.startsWith('https://')) { + throw StateError('GORUSH_URL deve começar com http:// ou https://.'); + } + while (url.endsWith('/')) { + url = url.substring(0, url.length - 1); + } + return url; + } + /// Decide um segredo de assinatura, recusando subir com um valor fraco. /// /// Regra comum aos quatro segredos (`JWT_SECRET`, `AUDIT_CHAIN_SECRET`, diff --git a/backend/sinalacs_server/lib/src/domain/entities/alert_delivery.dart b/backend/sinalacs_server/lib/src/domain/entities/alert_delivery.dart index 537659af..aa43eee2 100644 --- a/backend/sinalacs_server/lib/src/domain/entities/alert_delivery.dart +++ b/backend/sinalacs_server/lib/src/domain/entities/alert_delivery.dart @@ -9,6 +9,7 @@ class AlertDelivery { required this.locationHash, this.locationCell, required this.triggeredAt, + this.escalated = false, }); static const topicPrefix = 'sinalacs/v1/microareas'; @@ -26,6 +27,12 @@ class AlertDelivery { final String? locationCell; final DateTime triggeredAt; + /// Reforço de um alerta já entregue, enfileirado pelo escalonamento do + /// backoffice (#44, D2). Aditivo: só vai no envelope quando `true`, e o app + /// do ACS (que ignora chaves desconhecidas) segue lendo a versão 1. Não + /// carrega quem escalonou nem o motivo — nenhuma identidade a mais. + final bool escalated; + String get topic => '$topicPrefix/$microAreaId/alerts'; String toJson() => jsonEncode({ @@ -37,6 +44,7 @@ class AlertDelivery { 'location_hash': locationHash, if (locationCell != null) 'location_cell': locationCell, 'triggered_at': triggeredAt.toUtc().toIso8601String(), + if (escalated) 'escalated': true, }); /// Reidrata um envelope gravado no outbox. @@ -60,6 +68,8 @@ class AlertDelivery { locationHash: json['location_hash'] as String, locationCell: json['location_cell'] as String?, triggeredAt: DateTime.parse(triggeredAtRaw), + // Sem isto o drenador republicaria o reforço sem o marcador. + escalated: json['escalated'] == true, ); } on FormatException { return null; diff --git a/backend/sinalacs_server/lib/src/endpoints/admin_endpoint.dart b/backend/sinalacs_server/lib/src/endpoints/admin_endpoint.dart new file mode 100644 index 00000000..bd20979f --- /dev/null +++ b/backend/sinalacs_server/lib/src/endpoints/admin_endpoint.dart @@ -0,0 +1,477 @@ +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/patients/terms_publication_service.dart'; +import 'package:sinalacs_server/src/endpoints/authenticated_endpoint.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/runtime/alert_runtime.dart'; + +/// Backoffice, só para `coordinator` e `admin`: leitura (issue #40), gestão de +/// contas (issue #43), atendimento de pedidos do titular (issue #42) e +/// escalonamento de alerta vermelho (issue #44, `AlertEscalationService`). +/// +/// A leitura — indicadores, microáreas, alertas e auditoria — é do +/// `AdminReadService`; a listagem de ACS e de equipe, o cadastro, o vínculo de +/// microárea, a (des)ativação, as redefinições de senha/MFA do ACS e a +/// redefinição da MFA do staff (com a emissão do código de ativação pela tela, +/// o deferimento da #48) são do `AdminAccountService`; os pedidos do titular +/// são do `DataSubjectCaseService` — sempre na mesma dupla serviço/store. +/// +/// O papel, o escopo (sistema para o administrador, UBS para o coordenador), a +/// paginação e a auditoria de cada operação são dos serviços; o endpoint só +/// verifica o token e delega. Nenhum método devolve nome, CPF ou contato de +/// paciente: o paciente é um rótulo (`#A18F`), e o que sai dos cadastros é a +/// identificação **profissional** (nome, matrícula, UBS, microárea). +class AdminEndpoint extends AuthenticatedEndpoint { + Future indicators( + Session session, { + required String accessToken, + }) => AlertRuntime.instance + .adminReadServiceFor(session) + .indicators(authenticate(accessToken)); + + Future> microAreas( + Session session, { + required String accessToken, + }) => AlertRuntime.instance + .adminReadServiceFor(session) + .microAreas(authenticate(accessToken)); + + Future alerts( + Session session, { + required String accessToken, + String? microAreaId, + AlertStatus? status, + int limit = 50, + int offset = 0, + }) => AlertRuntime.instance + .adminReadServiceFor(session) + .alerts( + authenticate(accessToken), + microAreaId: microAreaId, + status: status, + limit: limit, + offset: offset, + ); + + /// Pulso da monitoração (#44, D5): indicadores, `overdueRed`, `maxChangedAt` + /// e o relógio do servidor, sem identidade. Não grava linha `read` por + /// chamada: a leitura contínua é uma sessão (`monitoring_session_start` + /// fail-closed quando não há pulso nos 90 s anteriores). Ver + /// `AdminMonitoringService`. + Future alertsPulse( + Session session, { + required String accessToken, + }) => AlertRuntime.instance + .adminMonitoringServiceFor(session) + .alertsPulse(authenticate(accessToken)); + + /// Fecha a sessão de monitoramento do chamador (`monitoring_session_end`, + /// best-effort). Idempotente: sem sessão aberta, não grava nada. + Future endMonitoringSession( + Session session, { + required String accessToken, + }) => AlertRuntime.instance + .adminMonitoringServiceFor(session) + .endMonitoringSession(authenticate(accessToken)); + + /// Trilha de auditoria no escopo de quem chama, por keyset. [filter] (#45) + /// é opcional e só restringe; inválido vira [AdminInvalidRequestException]. + Future auditLogs( + Session session, { + required String accessToken, + AdminAuditFilter? filter, + int limit = 50, + int? beforeSequence, + }) => AlertRuntime.instance + .adminReadServiceFor(session) + .auditLogs( + authenticate(accessToken), + filter: filter, + limit: limit, + beforeSequence: beforeSequence, + ); + + /// Verificação global da cadeia de `audit_logs` (#45, D3/D9). Só `verified` + /// é íntegra; o coordenador não recebe `sequence` nem motivo de quebra fora + /// da UBS dele. Auditada antes de verificar; single-flight e cache de 60 s + /// por processo. Ver `AuditChainStatusService`. + Future verifyAuditChain( + Session session, { + required String accessToken, + }) => AlertRuntime.instance + .auditChainStatusServiceFor(session) + .verify(authenticate(accessToken)); + + /// Exportação da trilha de auditoria em CSV (#45, D5–D8). **Só o + /// administrador**; o filtro exige `from` e `to` (janela ≤ 366 dias); teto + /// de `AuditExportService.maxRows` linhas (acima: `refused_limit` e + /// `AdminInvalidRequestException`). O recorte é fixado antes do evento + /// `export`, que entra na mesma transação do registro em `audit_exports` — + /// o arquivo nunca contém a própria exportação. Ver `AuditExportService`. + Future exportAuditLogs( + Session session, { + required String accessToken, + required AdminAuditFilter filter, + }) => AlertRuntime.instance + .auditExportServiceFor(session) + .export(authenticate(accessToken), filter); + + /// Pedidos do titular no escopo de quem chama, prazo mais próximo primeiro. + /// Nunca traz o texto do pedido nem a nota. + Future dataSubjectRequests( + Session session, { + required String accessToken, + DataSubjectRequestStatus? status, + int limit = 50, + int offset = 0, + }) => AlertRuntime.instance + .dataSubjectCaseServiceFor(session) + .list( + authenticate(accessToken), + status: status, + limit: limit, + offset: offset, + ); + + /// Detalhe de um pedido, com o texto decifrado. A leitura é auditada antes. + /// Inexistente e fora do escopo dão a mesma recusa. + Future dataSubjectRequest( + Session session, { + required String accessToken, + required String id, + }) => AlertRuntime.instance + .dataSubjectCaseServiceFor(session) + .get(authenticate(accessToken), id); + + /// `open → inReview`. + Future startDataSubjectReview( + Session session, { + required String accessToken, + required String id, + }) => AlertRuntime.instance + .dataSubjectCaseServiceFor(session) + .startReview(authenticate(accessToken), id); + + /// Atende o pedido. Correção exige [note]; exclusão anonimiza o titular na + /// mesma transação da mudança de status e da auditoria. + Future completeDataSubjectRequest( + Session session, { + required String accessToken, + required String id, + String? note, + }) => AlertRuntime.instance + .dataSubjectCaseServiceFor(session) + .complete(authenticate(accessToken), id, note: note); + + /// Recusa o pedido com um motivo de 3 a 500 caracteres, que o titular vê. + Future rejectDataSubjectRequest( + Session session, { + required String accessToken, + required String id, + required String reason, + }) => AlertRuntime.instance + .dataSubjectCaseServiceFor(session) + .reject(authenticate(accessToken), id, reason: reason); + /// ACS visíveis para o chamador: o administrador vê o sistema, o coordenador + /// só os da própria UBS. + Future> acs( + Session session, { + required String accessToken, + }) => AlertRuntime.instance + .adminAccountServiceFor(session) + .acsList(authenticate(accessToken)); + + /// Contas de equipe do backoffice — só o administrador. + Future> staff( + Session session, { + required String accessToken, + }) => AlertRuntime.instance + .adminAccountServiceFor(session) + .staffList(authenticate(accessToken)); + + /// Cadastra um ACS e devolve a senha inicial gerada, que **só** existe nesta + /// resposta: o servidor guarda apenas o hash (ver `AcsInitialPassword`). + /// A UBS vem da microárea, nunca do pedido. + Future createAcs( + Session session, { + required String accessToken, + required String name, + required String enrollmentId, + required String microAreaId, + }) => AlertRuntime.instance + .adminAccountServiceFor(session) + .createAcs( + authenticate(accessToken), + name: name, + enrollmentId: enrollmentId, + microAreaId: microAreaId, + ); + + /// Move um ACS existente para outra microárea — da própria UBS, para o + /// coordenador; de qualquer UBS, para o administrador. A UBS do vínculo sai + /// da microárea-alvo, nunca do pedido, e devolve a linha já no território + /// novo. O ACS renovará a sessão (refresh token) já no território novo; o JWT + /// em curso continua com o antigo até o refresh. + Future setAcsMicroArea( + Session session, { + required String accessToken, + required String acsId, + required String microAreaId, + }) => AlertRuntime.instance + .adminAccountServiceFor(session) + .setAcsMicroArea( + authenticate(accessToken), + acsId: acsId, + microAreaId: microAreaId, + ); + + /// Liga/desliga o acesso de um ACS — da própria UBS, para o coordenador; de + /// qualquer UBS, para o administrador. + /// + /// Desativar revoga, na mesma transação da flag, todas as sessões (refresh + /// token) e todos os tokens de envio diferido da conta: o aparelho perde o + /// acesso na hora, inclusive o envio offline das visitas pendentes, e o login + /// por senha passa a recusar com `'Este acesso está inativo.'`. Reativar + /// devolve o acesso pelo login — os tokens revogados não voltam. + Future setAcsActive( + Session session, { + required String accessToken, + required String acsId, + required bool active, + }) => AlertRuntime.instance + .adminAccountServiceFor(session) + .setAcsActive(authenticate(accessToken), acsId: acsId, active: active); + + /// Redefine a senha do ACS — da própria UBS, para o coordenador; de qualquer + /// UBS, para o administrador — e devolve a nova, que **só** existe nesta + /// resposta. + /// + /// A senha é sorteada pelo servidor (nunca escolhida pelo operador) e o + /// bloqueio da conta é zerado: a credencial nova não herda as tentativas da + /// antiga. As sessões em curso **não** são revogadas; cortá-las na hora é a + /// desativação. + Future resetAcsPassword( + Session session, { + required String accessToken, + required String acsId, + }) => AlertRuntime.instance + .adminAccountServiceFor(session) + .resetAcsPassword(authenticate(accessToken), acsId: acsId); + + /// Redefine a MFA do ACS: apaga o segredo TOTP gravado, para ele ativar de + /// novo na próxima entrada (é o caminho que a recusa "Peça a redefinição à + /// coordenação" não tinha). A senha e o bloqueio não são tocados. + Future resetAcsMfa( + Session session, { + required String accessToken, + required String acsId, + }) => AlertRuntime.instance + .adminAccountServiceFor(session) + .resetAcsMfa(authenticate(accessToken), acsId: acsId); + + /// Redefine a MFA de uma conta de equipe — só o administrador, nunca a + /// própria conta — e devolve o código de ativação novo, que **só** existe + /// nesta resposta (é o deferimento da #48: a CLI de operador era o único + /// emissor, e a emissão não entrava na trilha). + /// + /// As duas escritas — as quatro colunas `totp*` zeradas e o + /// `activationCodeHash`/validade/emissor gravados — são uma transação só, e a + /// linha `admin_staff`/`mfa_reset` entra em `audit_logs` com o id do alvo. + /// Quem redefine mostra o código uma vez; perdido de novo, o caminho é + /// redefinir outra vez, nunca recuperar. + Future resetStaffMfa( + Session session, { + required String accessToken, + required String staffId, + }) => AlertRuntime.instance + .adminAccountServiceFor(session) + .resetStaffMfa(authenticate(accessToken), staffId: staffId); + + /// Escalona um alerta vermelho pendente cujo prazo sem confirmação venceu + /// (#44). Grava data, autor e motivo fechado, reenfileira **uma** entrega de + /// reforço para o tópico da microárea e audita — numa transação só; o alerta + /// segue pendente. Repetir é idempotente (`alreadyEscalated`, carimbo + /// original). A resposta não traz identidade: só id, carimbo e o indicador. + Future escalateAlert( + Session session, { + required String accessToken, + required String alertId, + required EscalationReason reason, + }) => AlertRuntime.instance + .alertEscalationServiceFor(session) + .escalate(authenticate(accessToken), alertId: alertId, reason: reason); + + /// Agenda um aviso comunitário a uma microárea (#46) e devolve o id. Só + /// `coordinator`/`admin`; o coordenador só na própria UBS. A negação já vem + /// auditada do serviço; o texto nunca entra na trilha. + Future scheduleNotice( + Session session, { + required String accessToken, + required String microAreaId, + required String audience, + required String title, + required String message, + required DateTime scheduledFor, + }) async { + final user = authenticate(accessToken); + try { + return await AlertRuntime.instance + .noticeSchedulingServiceFor(session) + .schedule( + user, + microAreaId: microAreaId, + audience: audience, + title: title, + message: message, + scheduledFor: scheduledFor, + ); + } on StateError catch (error) { + throw AlertPermissionException(message: error.message); + } + } + + /// Cancela um aviso ainda `pending` (#46). Aviso já reservado/enviado: + /// [AdminInvalidRequestException]. + Future cancelScheduledNotice( + Session session, { + required String accessToken, + required String id, + }) async { + final user = authenticate(accessToken); + try { + await AlertRuntime.instance + .noticeSchedulingServiceFor(session) + .cancel(user, id); + } on StateError catch (error) { + throw AlertPermissionException(message: error.message); + } + } + + /// Histórico de avisos, mais recentes primeiro, **sem o texto** (#46). + /// Coordenador: só a própria UBS. + Future noticeHistory( + Session session, { + required String accessToken, + String? microAreaId, + int limit = 50, + int offset = 0, + }) async { + final user = authenticate(accessToken); + try { + final items = await AlertRuntime.instance + .noticeSchedulingServiceFor(session) + .history(user, microAreaId: microAreaId, limit: limit, offset: offset); + return NoticeHistoryPage( + entries: [ + for (final i in items) + NoticeHistoryEntry( + id: i.id, + microAreaId: i.microAreaId, + microAreaName: i.microAreaName ?? '—', + audience: i.audience, + title: i.title, + status: i.status, + scheduledFor: i.scheduledFor, + sentAt: i.sentAt, + recipients: i.recipients, + accepted: i.accepted, + failureReason: i.failureReason, + createdByLabel: i.createdByLabel ?? '—', + ), + ], + ); + } on StateError catch (error) { + throw AlertPermissionException(message: error.message); + } + } + + // --- Versões dos termos (#46, LGPD-RF18). Só `admin`; a negação já vem + // auditada do serviço e o resumo nunca entra na trilha. --- + + static TermsVersionEntry _termsEntry(TermsVersionSnapshot r) => + TermsVersionEntry( + id: r.id, + version: r.version, + summary: r.summary, + effectiveFrom: r.effectiveFrom, + createdAt: r.createdAt, + legallyReviewedAt: r.legallyReviewedAt, + publishedAt: r.publishedAt, + ); + + /// Versões dos termos, mais recentes primeiro. + Future> termsVersions( + Session session, { + required String accessToken, + }) async { + final user = authenticate(accessToken); + try { + final rows = await AlertRuntime.instance + .termsPublicationServiceFor(session) + .list(user); + return [for (final r in rows) _termsEntry(r)]; + } on StateError catch (error) { + throw AlertPermissionException(message: error.message); + } + } + + /// Rascunha uma versão dos termos. + Future draftTermsVersion( + Session session, { + required String accessToken, + required String version, + required String summary, + required DateTime effectiveFrom, + }) async { + final user = authenticate(accessToken); + try { + return _termsEntry( + await AlertRuntime.instance + .termsPublicationServiceFor(session) + .draft( + user, + version: version, + summary: summary, + effectiveFrom: effectiveFrom, + ), + ); + } on StateError catch (error) { + throw AlertPermissionException(message: error.message); + } + } + + /// Registra a revisão jurídica de uma versão. + Future markTermsReviewed( + Session session, { + required String accessToken, + required String id, + }) async { + final user = authenticate(accessToken); + try { + return _termsEntry( + await AlertRuntime.instance + .termsPublicationServiceFor(session) + .markReviewed(user, id), + ); + } on StateError catch (error) { + throw AlertPermissionException(message: error.message); + } + } + + /// Publica uma versão revisada, com vigência a pelo menos 15 dias. + Future publishTermsVersion( + Session session, { + required String accessToken, + required String id, + }) async { + final user = authenticate(accessToken); + try { + return _termsEntry( + await AlertRuntime.instance + .termsPublicationServiceFor(session) + .publish(user, id), + ); + } on StateError catch (error) { + throw AlertPermissionException(message: error.message); + } + } +} diff --git a/backend/sinalacs_server/lib/src/endpoints/auth_endpoint.dart b/backend/sinalacs_server/lib/src/endpoints/auth_endpoint.dart index dc4cd2d0..27ac2eea 100644 --- a/backend/sinalacs_server/lib/src/endpoints/auth_endpoint.dart +++ b/backend/sinalacs_server/lib/src/endpoints/auth_endpoint.dart @@ -1,6 +1,7 @@ import 'package:serverpod/serverpod.dart'; import 'package:sinalacs_server/src/application/auth/cpf.dart'; import 'package:sinalacs_server/src/application/auth/development_auth_service.dart'; +import 'package:sinalacs_server/src/application/auth/institutional_auth_service.dart'; import 'package:sinalacs_server/src/generated/protocol.dart'; import 'package:sinalacs_server/src/runtime/alert_runtime.dart'; @@ -17,7 +18,8 @@ class AuthEndpoint extends Endpoint { bool get requireLogin => false; /// TTL da sessão do paciente (LGPD-RT06). O ACS continua nos 15 minutos - /// padrão — a renovação dele é silenciosa, por credencial em memória. + /// padrão, e a renovação dele é o refresh token rotativo (`refreshSession`): + /// sem senha nem TOTP, mas só para quem ainda guarda o token opaco do aparelho. static const patientSessionLifetime = Duration(hours: 1); /// UUIDs fixos do seed de desenvolvimento. Dados sintéticos. @@ -68,11 +70,17 @@ class AuthEndpoint extends Endpoint { /// `AuthenticationFailedException`, com a mensagem que o serviço escolheu: /// mensagem idêntica para matrícula inexistente e senha errada (ver /// `InstitutionalAuthService`). + /// + /// MFA: com a verificação em duas etapas ativa, a senha certa sem + /// [totpCode] recebe `MfaRequiredException` (o app pede o código e repete a + /// chamada); com `REQUIRE_ACS_MFA` ligado e a MFA não ativada, recebe + /// `MfaEnrollmentRequiredException` (o app chama `beginTotpEnrollment`). Future loginInstitutional( Session session, { required String matricula, required String password, String? deviceId, + String? totpCode, }) async { final runtime = AlertRuntime.instance; @@ -80,14 +88,142 @@ class AuthEndpoint extends Endpoint { matricula: matricula, password: password, deviceId: deviceId, + totpCode: totpCode, ); + // Sem `deviceId` real não há aparelho a que amarrar o refresh token: o + // valor sentinela de `InstitutionalAuthService` é público e qualquer um o + // apresentaria, anulando a amarração. Então o login só devolve o JWT. O + // próprio sentinela, enviado pelo cliente, conta como "sem aparelho" pelo + // mesmo motivo — e porque `UploadTokenService.issue` o recusaria DEPOIS de + // o refresh token já estar gravado. + final temAparelho = deviceId != null && + deviceId.trim().isNotEmpty && + deviceId != InstitutionalAuthService.deviceIdAbsent; + final refreshToken = temAparelho + ? await runtime.refreshTokenServiceFor(session).issue(user) + : null; + // Token de envio diferido (D7 do plano 2026-10-03), pela mesma regra do + // aparelho: serve só a `visits.syncDeferred`, com a autoria deste ACS, e + // sobrevive ao fim da sessão. Um novo login no mesmo aparelho revoga o + // anterior. + final uploadToken = temAparelho + ? await runtime.uploadTokenServiceFor(session).issue(user) + : null; + return DevelopmentLoginResult( + accessToken: runtime.auth.issueToken(user), + tokenType: 'Bearer', + refreshToken: refreshToken, + uploadToken: uploadToken, + ); + } + + /// Login do backoffice (coordenador/administrador): matrícula + senha + TOTP. + /// + /// A MFA é obrigatória em todo ambiente (não há `REQUIRE_*_MFA` para o + /// staff). Sem refresh token e sem token de envio diferido: a sessão dura os + /// 15 minutos do JWT, e o `deviceId` do token é o sentinela de "sem + /// aparelho". O token sai com o papel gravado em `users` e **sem** microárea: + /// staff não é territorializado, e os caminhos do ACS o recusam por papel. + /// As recusas são as de [loginInstitutional]: mensagem única para matrícula + /// inexistente e senha errada, `MfaRequiredException` sem o código, e o mesmo + /// bloqueio por tentativas. + Future loginStaff( + Session session, { + required String matricula, + required String password, + String? totpCode, + }) async { + final runtime = AlertRuntime.instance; + final user = await runtime.staffAuthServiceFor(session).login( + matricula: matricula, + password: password, + totpCode: totpCode, + ); return DevelopmentLoginResult( accessToken: runtime.auth.issueToken(user), tokenType: 'Bearer', ); } + /// Começa a ativação da MFA do staff. Sem token: o staff prova matrícula, + /// senha **e o código de ativação de uso único** (#48), emitido fora de banda + /// por `bin/issue_staff_activation_code.dart`; a MFA é obrigatória, então não + /// há login possível antes dela. Código errado, expirado ou já usado recebe a + /// mesma recusa e conta como tentativa falha da conta. + Future beginStaffTotpEnrollment( + Session session, { + required String matricula, + required String password, + required String activationCode, + }) => + AlertRuntime.instance.staffAuthServiceFor(session).beginTotpEnrollment( + matricula: matricula, + password: password, + activationCode: activationCode, + ); + + /// Confirma a ativação da MFA do staff com o primeiro código do autenticador. + Future confirmStaffTotpEnrollment( + Session session, { + required String matricula, + required String password, + required String activationCode, + required String code, + }) => + AlertRuntime.instance.staffAuthServiceFor(session).confirmTotpEnrollment( + matricula: matricula, + password: password, + activationCode: activationCode, + code: code, + ); + + /// Renova a sessão do ACS sem pedir senha nem TOTP (LGPD-RT06). Público por + /// desenho: quem chama já perdeu o JWT de 15 min — o refresh token, opaco e de + /// uso único, é a credencial. Toda recusa é a mesma `SessionExpiredException`. + Future refreshSession( + Session session, { + required String refreshToken, + required String deviceId, + }) async { + final runtime = AlertRuntime.instance; + final renewed = await runtime + .refreshTokenServiceFor(session) + .refresh(refreshToken: refreshToken, deviceId: deviceId); + return DevelopmentLoginResult( + accessToken: runtime.auth.issueToken(renewed.user), + tokenType: 'Bearer', + refreshToken: renewed.refreshToken, + ); + } + + /// Encerra o turno: revoga a família inteira. Idempotente. + Future logout(Session session, {required String refreshToken}) => + AlertRuntime.instance + .refreshTokenServiceFor(session) + .revoke(refreshToken); + + /// Começa a ativação da MFA do ACS (RF07). Sem token: o ACS prova matrícula e senha. + Future beginTotpEnrollment( + Session session, { + required String matricula, + required String password, + }) => + AlertRuntime.instance + .institutionalAuthServiceFor(session) + .beginTotpEnrollment(matricula: matricula, password: password); + + /// Confirma a ativação com o primeiro código do autenticador. + Future confirmTotpEnrollment( + Session session, { + required String matricula, + required String password, + required String code, + }) => + AlertRuntime.instance + .institutionalAuthServiceFor(session) + .confirmTotpEnrollment(matricula: matricula, password: password, code: code); + /// Pedido do código de acesso (RF01). Público por definição: quem chama /// ainda não tem sessão. A resposta é sempre a mesma — não revela se o CPF /// está cadastrado (ver `PasswordlessAuthService.requestOtp`). @@ -152,10 +288,10 @@ class AuthEndpoint extends Endpoint { return DevelopmentLoginResult( // 1 hora **neste caminho**, o de login com OTP verificado — que é o que - // `spec/lgpd_design.md` LGPD-RT06 exige para o paciente. Não é o TTL de - // toda sessão de paciente: há dois caminhos de emissão, e o de onboarding - // (`onboarding_endpoint.dart`) ainda emite o padrão de 15 minutos, lacuna - // do RF02 registrada no plano. + // `spec/lgpd_design.md` LGPD-RT06 exige para o paciente. Os dois caminhos + // de emissão — este e `onboarding.completeEnrollment` — usam + // `patientSessionLifetime`; `onboarding_endpoint_test.dart` prende o + // segundo. // Com o padrão de 15 minutos, e sem refresh token, o paciente teria de // receber um SMS novo a cada 15 minutos: o código OTP não pode ser // reapresentado como a senha do ACS pode, então não existe renovação diff --git a/backend/sinalacs_server/lib/src/endpoints/devices_endpoint.dart b/backend/sinalacs_server/lib/src/endpoints/devices_endpoint.dart new file mode 100644 index 00000000..b16f811d --- /dev/null +++ b/backend/sinalacs_server/lib/src/endpoints/devices_endpoint.dart @@ -0,0 +1,26 @@ +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/endpoints/authenticated_endpoint.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/runtime/alert_runtime.dart'; + +/// Aparelhos do paciente para avisos segmentados (RF14, decisão §3.2). +class DevicesEndpoint extends AuthenticatedEndpoint { + /// Registra o token de push do aparelho do paciente autenticado. Só grava com + /// o consentimento `segmentedPush` vigente; sem ele, [DataRightsException]. + Future registerPushToken( + Session session, { + required String accessToken, + required String token, + required String platform, + }) async { + final user = authenticate(accessToken); + + try { + await AlertRuntime.instance + .pushTokenServiceFor(session) + .register(user, token: token, platform: platform); + } on StateError catch (error) { + throw AlertPermissionException(message: error.message); + } + } +} diff --git a/backend/sinalacs_server/lib/src/endpoints/notices_endpoint.dart b/backend/sinalacs_server/lib/src/endpoints/notices_endpoint.dart new file mode 100644 index 00000000..0cc222ee --- /dev/null +++ b/backend/sinalacs_server/lib/src/endpoints/notices_endpoint.dart @@ -0,0 +1,30 @@ +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/endpoints/authenticated_endpoint.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/runtime/alert_runtime.dart'; + +/// Avisos comunitários do ACS (RF14, decisão §3.2). +class NoticesEndpoint extends AuthenticatedEndpoint { + /// Envia um aviso segmentado aos pacientes da microárea do ACS que + /// consentiram com `segmentedPush`. A microárea vem do token, nunca de + /// parâmetro. Auditoria: uma linha `community_notice` com quem enviou e a + /// microárea; o texto do aviso nunca entra na trilha. + Future sendSegmented( + Session session, { + required String accessToken, + required String title, + required String message, + required String audience, + }) async { + final user = authenticate(accessToken); + + try { + final r = await AlertRuntime.instance + .noticeServiceFor(session) + .sendSegmented(user, title: title, message: message, audience: audience); + return NoticeSendResult(recipients: r.recipients, accepted: r.accepted); + } on StateError catch (error) { + throw AlertPermissionException(message: error.message); + } + } +} diff --git a/backend/sinalacs_server/lib/src/endpoints/onboarding_endpoint.dart b/backend/sinalacs_server/lib/src/endpoints/onboarding_endpoint.dart index b446256b..1e2efbe2 100644 --- a/backend/sinalacs_server/lib/src/endpoints/onboarding_endpoint.dart +++ b/backend/sinalacs_server/lib/src/endpoints/onboarding_endpoint.dart @@ -41,8 +41,9 @@ class OnboardingEndpoint extends Endpoint { required bool healthDataConsent, required bool remindersConsent, required bool pushConsent, + required bool termsAccepted, }) async { - // Consumir o convite e gravar os 3 `consent_logs` formam uma unidade só — + // Consumir o convite e gravar os 4 `consent_logs` formam uma unidade só — // mesmo arranjo de `AlertsEndpoint.createRedAlert`. A emissão do token de // sessão NÃO participa: acontece depois do commit, sobre o usuário já // resolvido, e não depende de nenhuma escrita adicional. @@ -55,14 +56,16 @@ class OnboardingEndpoint extends Endpoint { ConsentPurpose.healthDataProcessing: healthDataConsent, ConsentPurpose.localReminders: remindersConsent, ConsentPurpose.segmentedPush: pushConsent, + ConsentPurpose.termsOfUse: termsAccepted, }, ); }); // Mesmo motivo do RF01 (`AuthEndpoint.verifyOtp`): o convite de uso único // não se reapresenta como a senha do ACS, então não há renovação - // silenciosa — 15 minutos padrão bastaria pouco. Ver PROGRESS.md - // "Um defeito do RF02 que esta entrega mediu". + // silenciosa, então a sessão usa `patientSessionLifetime` (1 hora), como o + // login por OTP. O TTL de 15 minutos era um defeito do RF02, corrigido — + // ver PROGRESS.md "Um defeito do RF02 que esta entrega mediu". return EnrollmentResult( accessToken: AlertRuntime.instance.auth.issueToken( user, diff --git a/backend/sinalacs_server/lib/src/endpoints/patients_endpoint.dart b/backend/sinalacs_server/lib/src/endpoints/patients_endpoint.dart index 02cdca6a..ef4c7791 100644 --- a/backend/sinalacs_server/lib/src/endpoints/patients_endpoint.dart +++ b/backend/sinalacs_server/lib/src/endpoints/patients_endpoint.dart @@ -1,4 +1,5 @@ import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/patients/patient_data_overview_service.dart'; import 'package:sinalacs_server/src/endpoints/authenticated_endpoint.dart'; import 'package:sinalacs_server/src/generated/protocol.dart'; import 'package:sinalacs_server/src/runtime/alert_runtime.dart'; @@ -9,6 +10,9 @@ import 'package:sinalacs_server/src/runtime/alert_runtime.dart'; /// (`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. +/// +/// Serve também o próprio paciente: "Perfil clínico", "Meus Dados" e os +/// direitos do titular (LGPD-RF05/RF08) — sempre escopados pelo id do token. class PatientsEndpoint extends AuthenticatedEndpoint { Future> listMicroArea( Session session, { @@ -90,9 +94,175 @@ class PatientsEndpoint extends AuthenticatedEndpoint { for (final r in snapshot.riskHistory) PatientRiskEvent(source: r.source, riskLevel: r.riskLevel, recordedAt: r.recordedAt), ], + requests: [for (final r in snapshot.requests) _requestRecord(r)], + ); + } on StateError catch (error) { + throw AlertPermissionException(message: error.message); + } + } + + /// Concede ou revoga, pelo próprio titular, uma finalidade opcional de + /// consentimento (LGPD-RF05): uma linha nova em `consent_logs`, nunca a + /// edição da anterior. `healthDataProcessing` volta como + /// [DataRightsException] — retirá-lo passa pelo pedido de exclusão. + Future updateConsent( + Session session, { + required String accessToken, + required ConsentPurpose purpose, + required bool granted, + }) async { + final user = authenticate(accessToken); + + try { + final record = await AlertRuntime.instance + .dataSubjectRightsServiceFor(session) + .updateConsent(user, purpose: purpose, granted: granted); + return PatientConsentRecord( + purpose: record.purpose, + action: record.action, + version: record.version, + timestamp: record.timestamp, + ); + } on StateError catch (error) { + throw AlertPermissionException(message: error.message); + } + } + + /// Aceite do Termo de Uso e da Política de Privacidade vigentes (LGPD-RF18) + /// por quem entrou por OTP sem passar pelo onboarding, ou aceitou uma versão + /// anterior. Só paciente; grava uma linha assinada em `consent_logs` só se a + /// versão vigente ainda não foi aceita — repetir devolve a existente. + Future acceptTermsOfUse( + Session session, { + required String accessToken, + }) async { + final user = authenticate(accessToken); + + try { + final record = await AlertRuntime.instance + .dataSubjectRightsServiceFor(session) + .acceptTermsOfUse(user); + return PatientConsentRecord( + purpose: record.purpose, + action: record.action, + version: record.version, + timestamp: record.timestamp, + ); + } on StateError catch (error) { + throw AlertPermissionException(message: error.message); + } + } + + /// Se o paciente já aceitou o Termo de Uso e a Política de Privacidade da + /// versão vigente (LGPD-RF18). O app consulta depois do login por OTP para + /// decidir se mostra o convite ao aceite — um `bool`, sem ler o painel + /// "Meus Dados". Não grava auditoria: devolve ao próprio titular um fato + /// sobre o consentimento dele. + Future hasAcceptedCurrentTerms( + Session session, { + required String accessToken, + }) async { + final user = authenticate(accessToken); + + try { + return await AlertRuntime.instance + .dataSubjectRightsServiceFor(session) + .hasAcceptedCurrentTerms(user); + } on StateError catch (error) { + throw AlertPermissionException(message: error.message); + } + } + + /// Se a decisão mais recente do titular para [purpose] é `granted` — um + /// `bool`, sem ler o painel "Meus Dados" e sem linha de auditoria de leitura. + /// O app pergunta isto a cada login antes de pedir o token ao provedor de + /// push (RF14). + Future hasGrantedConsent( + Session session, { + required String accessToken, + required ConsentPurpose purpose, + }) async { + final user = authenticate(accessToken); + + try { + return await AlertRuntime.instance + .dataSubjectRightsServiceFor(session) + .hasGrantedConsent(user, purpose); + } on StateError catch (error) { + throw AlertPermissionException(message: error.message); + } + } + + /// Aviso de mudança dos termos ativo agora (LGPD-RF18, 15 dias de antecedência), + /// ou `null`. Só paciente. Lê a agenda publicada em `terms_versions` e não grava + /// linha de auditoria: nada do titular é lido nem gravado. + Future termsChangeNotice( + Session session, { + required String accessToken, + }) async { + final user = authenticate(accessToken); + + try { + final notice = await AlertRuntime.instance + .dataSubjectRightsServiceFor(session) + .termsChangeNotice(user); + return notice == null + ? null + : TermsChangeNotice( + version: notice.version, + effectiveFrom: notice.effectiveFrom, + summary: notice.summary, + ); + } on StateError catch (error) { + throw AlertPermissionException(message: error.message); + } + } + + /// Pedido de exclusão/anonimização dos próprios dados (LGPD-RF08). + /// Idempotente enquanto houver um pedido de exclusão em aberto. + Future requestDataDeletion( + Session session, { + required String accessToken, + }) async { + final user = authenticate(accessToken); + + try { + return _requestRecord( + await AlertRuntime.instance.dataSubjectRightsServiceFor(session).requestDeletion(user), ); } on StateError catch (error) { throw AlertPermissionException(message: error.message); } } + + /// Pedido de correção de um dado (LGPD-RF08). [details] é texto livre do + /// titular, gravado cifrado; vazio ou acima de 500 caracteres volta como + /// [DataRightsException]. + Future requestDataCorrection( + Session session, { + required String accessToken, + required String details, + }) async { + final user = authenticate(accessToken); + + try { + return _requestRecord( + await AlertRuntime.instance + .dataSubjectRightsServiceFor(session) + .requestCorrection(user, details: details), + ); + } on StateError catch (error) { + throw AlertPermissionException(message: error.message); + } + } + + static PatientDataSubjectRequestRecord _requestRecord(DataSubjectRequestSnapshot r) => + PatientDataSubjectRequestRecord( + type: r.type, + status: r.status, + details: r.details, + createdAt: r.createdAt, + dueAt: r.dueAt, + resolution: r.resolution, + ); } diff --git a/backend/sinalacs_server/lib/src/endpoints/ubs_endpoint.dart b/backend/sinalacs_server/lib/src/endpoints/ubs_endpoint.dart new file mode 100644 index 00000000..f98b9d5b --- /dev/null +++ b/backend/sinalacs_server/lib/src/endpoints/ubs_endpoint.dart @@ -0,0 +1,19 @@ +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/endpoints/authenticated_endpoint.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/runtime/alert_runtime.dart'; + +/// Contato da UBS do ACS (RF13). +class UbsEndpoint extends AuthenticatedEndpoint { + Future myContact( + Session session, { + required String accessToken, + }) async { + final user = authenticate(accessToken); + try { + return await AlertRuntime.instance.ubsContactServiceFor(session).contactFor(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 index 9696241e..c61b4f15 100644 --- a/backend/sinalacs_server/lib/src/endpoints/visits_endpoint.dart +++ b/backend/sinalacs_server/lib/src/endpoints/visits_endpoint.dart @@ -1,4 +1,6 @@ import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/audit/audit_trail.dart'; +import 'package:sinalacs_server/src/application/visits/visit_sync_service.dart'; import 'package:sinalacs_server/src/endpoints/authenticated_endpoint.dart'; import 'package:sinalacs_server/src/generated/protocol.dart'; import 'package:sinalacs_server/src/runtime/alert_runtime.dart'; @@ -35,6 +37,108 @@ class VisitsEndpoint extends AuthenticatedEndpoint { } } + /// Envio das visitas LEGADAS do aparelho (autoria desconhecida, D4 do plano + /// 2026-10-03): gravadas antes de existir dono por visita no banco local. + /// + /// A sessão do ACS é só o transporte — ver `VisitSyncService.syncLegacy`. + /// Mesma transação por lote e mesma tradução de erros de [sync]. + Future> syncLegacy( + Session session, { + required String accessToken, + required String deviceId, + required List visits, + }) async { + final user = authenticate(accessToken); + + if (visits.isEmpty) return []; + + try { + // Teto do lote ANTES de abrir a transação: nada toca o banco. + VisitSyncService.checkLegacyBatchSize(visits.length); + return await session.db.transaction((transaction) async { + final service = + AlertRuntime.instance.visitSyncServiceFor(session, transaction: transaction); + return service.syncLegacy( + transporter: user, + deviceId: deviceId, + entries: visits, + ); + }); + } on ArgumentError catch (error) { + throw AlertValidationException(message: '${error.message}'); + } on StateError catch (error) { + throw AlertPermissionException(message: error.message); + } + } + + /// Envio DIFERIDO (D7 do plano 2026-10-03): sobe as visitas pendentes de um + /// ACS que pode já ter saído, autenticado pelo token de envio que o + /// `auth.loginInstitutional` emitiu para ele neste aparelho — não pelo JWT. + /// + /// Público por desenho para o `authenticate(...)`: o token opaco É a + /// credencial, e o único poder dele é este envio. O usuário resolvido é o + /// DONO do token, com a microárea relida do banco agora, e o lote passa + /// pelo MESMO `VisitSyncService.sync` do envio comum: autoria do dono, + /// território atual do dono, `localId` de outro agente recusado. Toda recusa + /// do token é a mesma `SessionExpiredException`, sem motivo e sem o token. + /// + /// No máximo `VisitSyncService.maxLegacyBatch` visitas por chamada, checado + /// antes de qualquer acesso ao banco. Um evento `visit_deferred_sync` por + /// lote gravado vai para `audit_logs`, com o dono e nada clínico. + Future> syncDeferred( + Session session, { + required String uploadToken, + required String deviceId, + required List visits, + }) async { + final runtime = AlertRuntime.instance; + try { + // Teto do lote ANTES de resolver o token e de abrir a transação. + VisitSyncService.checkLegacyBatchSize(visits.length); + } on ArgumentError catch (error) { + throw AlertValidationException(message: '${error.message}'); + } + + // Fora da transação do lote: uma recusa que revoga o token precisa + // persistir mesmo quando a chamada termina em exceção. + final owner = await runtime + .uploadTokenServiceFor(session) + .resolve(uploadToken: uploadToken, deviceId: deviceId); + + if (visits.isEmpty) return []; + + final List results; + try { + results = await session.db.transaction((transaction) async { + final service = + runtime.visitSyncServiceFor(session, transaction: transaction); + return service.sync(user: owner, entries: visits); + }); + } on ArgumentError catch (error) { + throw AlertValidationException(message: '${error.message}'); + } on StateError catch (error) { + throw AlertPermissionException(message: error.message); + } + + // Depois do commit: só lote que de fato persistiu vira linha na trilha. + await runtime.auditTrailFor(session).recordSafely(AuditEvent( + userId: owner.id, + actionType: 'write', + resourceType: 'visit_deferred', + result: 'visit_deferred_sync', + )); + return results; + } + + /// Revoga o token de envio diferido (o app chama quando a fila do dono + /// zera). Público pelo mesmo motivo de [syncDeferred]; idempotente, e um + /// token desconhecido é ignorado em silêncio. + Future revokeUploadToken( + Session session, { + required String uploadToken, + }) => + AlertRuntime.instance.uploadTokenServiceFor(session).revoke(uploadToken); + /// Sincronização central→dispositivo: visitas da microárea do ACS /// autenticado alteradas após `since`, para reconciliar um device que /// ficou offline ou foi reinstalado. diff --git a/backend/sinalacs_server/lib/src/generated/acs_refresh_token.dart b/backend/sinalacs_server/lib/src/generated/acs_refresh_token.dart new file mode 100644 index 00000000..da95331d --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/acs_refresh_token.dart @@ -0,0 +1,708 @@ +/* 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; + +/// Refresh token do ACS (LGPD-RT06). Uma linha por token emitido; os tokens de +/// um mesmo login formam uma *família* (`familyId`) e a rotação encadeia +/// dentro dela. +/// +/// **Nunca** o token: só o SHA-256 dele em hex. O token é 256 bits aleatórios, +/// então um hash simples basta (não há senha humana para adivinhar). +abstract class AcsRefreshToken + implements _i1.TableRow<_i1.UuidValue?>, _i1.ProtocolSerialization { + AcsRefreshToken._({ + this.id, + required this.userId, + required this.familyId, + required this.tokenHash, + required this.deviceId, + required this.issuedAt, + required this.idleExpiresAt, + required this.absoluteExpiresAt, + this.rotatedAt, + this.revokedAt, + }); + + factory AcsRefreshToken({ + _i1.UuidValue? id, + required _i1.UuidValue userId, + required _i1.UuidValue familyId, + required String tokenHash, + required String deviceId, + required DateTime issuedAt, + required DateTime idleExpiresAt, + required DateTime absoluteExpiresAt, + DateTime? rotatedAt, + DateTime? revokedAt, + }) = _AcsRefreshTokenImpl; + + factory AcsRefreshToken.fromJson(Map jsonSerialization) { + return AcsRefreshToken( + id: jsonSerialization['id'] == null + ? null + : _i1.UuidValueJsonExtension.fromJson(jsonSerialization['id']), + userId: _i1.UuidValueJsonExtension.fromJson(jsonSerialization['userId']), + familyId: _i1.UuidValueJsonExtension.fromJson( + jsonSerialization['familyId'], + ), + tokenHash: jsonSerialization['tokenHash'] as String, + deviceId: jsonSerialization['deviceId'] as String, + issuedAt: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['issuedAt'], + ), + idleExpiresAt: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['idleExpiresAt'], + ), + absoluteExpiresAt: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['absoluteExpiresAt'], + ), + rotatedAt: jsonSerialization['rotatedAt'] == null + ? null + : _i1.DateTimeJsonExtension.fromJson(jsonSerialization['rotatedAt']), + revokedAt: jsonSerialization['revokedAt'] == null + ? null + : _i1.DateTimeJsonExtension.fromJson(jsonSerialization['revokedAt']), + ); + } + + static final t = AcsRefreshTokenTable(); + + static const db = AcsRefreshTokenRepository._(); + + @override + _i1.UuidValue? id; + + _i1.UuidValue userId; + + /// Todos os tokens nascidos do mesmo login por senha+TOTP. + _i1.UuidValue familyId; + + String tokenHash; + + /// Aparelho que recebeu o token. Token apresentado por outro aparelho + /// revoga a família. + String deviceId; + + DateTime issuedAt; + + /// Janela ociosa: renovada a cada rotação, nunca além de `absoluteExpiresAt`. + DateTime idleExpiresAt; + + /// Teto do turno, fixado no login por senha+TOTP e herdado pelos filhos. + DateTime absoluteExpiresAt; + + /// `null` = ainda não usado. Preenchido na rotação. + DateTime? rotatedAt; + + /// `null` = vigente. Preenchido na revogação da família ou no logout. + DateTime? revokedAt; + + @override + _i1.Table<_i1.UuidValue?> get table => t; + + /// Returns a shallow copy of this [AcsRefreshToken] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + AcsRefreshToken copyWith({ + _i1.UuidValue? id, + _i1.UuidValue? userId, + _i1.UuidValue? familyId, + String? tokenHash, + String? deviceId, + DateTime? issuedAt, + DateTime? idleExpiresAt, + DateTime? absoluteExpiresAt, + DateTime? rotatedAt, + DateTime? revokedAt, + }); + @override + Map toJson() { + return { + '__className__': 'AcsRefreshToken', + if (id != null) 'id': id?.toJson(), + 'userId': userId.toJson(), + 'familyId': familyId.toJson(), + 'tokenHash': tokenHash, + 'deviceId': deviceId, + 'issuedAt': issuedAt.toJson(), + 'idleExpiresAt': idleExpiresAt.toJson(), + 'absoluteExpiresAt': absoluteExpiresAt.toJson(), + if (rotatedAt != null) 'rotatedAt': rotatedAt?.toJson(), + if (revokedAt != null) 'revokedAt': revokedAt?.toJson(), + }; + } + + @override + Map toJsonForProtocol() { + return { + '__className__': 'AcsRefreshToken', + if (id != null) 'id': id?.toJson(), + 'userId': userId.toJson(), + 'familyId': familyId.toJson(), + 'tokenHash': tokenHash, + 'deviceId': deviceId, + 'issuedAt': issuedAt.toJson(), + 'idleExpiresAt': idleExpiresAt.toJson(), + 'absoluteExpiresAt': absoluteExpiresAt.toJson(), + if (rotatedAt != null) 'rotatedAt': rotatedAt?.toJson(), + if (revokedAt != null) 'revokedAt': revokedAt?.toJson(), + }; + } + + static AcsRefreshTokenInclude include() { + return AcsRefreshTokenInclude._(); + } + + static AcsRefreshTokenIncludeList includeList({ + _i1.WhereExpressionBuilder? where, + int? limit, + int? offset, + _i1.OrderByBuilder? orderBy, + bool orderDescending = false, + _i1.OrderByListBuilder? orderByList, + AcsRefreshTokenInclude? include, + }) { + return AcsRefreshTokenIncludeList._( + where: where, + limit: limit, + offset: offset, + orderBy: orderBy?.call(AcsRefreshToken.t), + orderDescending: orderDescending, + orderByList: orderByList?.call(AcsRefreshToken.t), + include: include, + ); + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _AcsRefreshTokenImpl extends AcsRefreshToken { + _AcsRefreshTokenImpl({ + _i1.UuidValue? id, + required _i1.UuidValue userId, + required _i1.UuidValue familyId, + required String tokenHash, + required String deviceId, + required DateTime issuedAt, + required DateTime idleExpiresAt, + required DateTime absoluteExpiresAt, + DateTime? rotatedAt, + DateTime? revokedAt, + }) : super._( + id: id, + userId: userId, + familyId: familyId, + tokenHash: tokenHash, + deviceId: deviceId, + issuedAt: issuedAt, + idleExpiresAt: idleExpiresAt, + absoluteExpiresAt: absoluteExpiresAt, + rotatedAt: rotatedAt, + revokedAt: revokedAt, + ); + + /// Returns a shallow copy of this [AcsRefreshToken] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + AcsRefreshToken copyWith({ + Object? id = _Undefined, + _i1.UuidValue? userId, + _i1.UuidValue? familyId, + String? tokenHash, + String? deviceId, + DateTime? issuedAt, + DateTime? idleExpiresAt, + DateTime? absoluteExpiresAt, + Object? rotatedAt = _Undefined, + Object? revokedAt = _Undefined, + }) { + return AcsRefreshToken( + id: id is _i1.UuidValue? ? id : this.id, + userId: userId ?? this.userId, + familyId: familyId ?? this.familyId, + tokenHash: tokenHash ?? this.tokenHash, + deviceId: deviceId ?? this.deviceId, + issuedAt: issuedAt ?? this.issuedAt, + idleExpiresAt: idleExpiresAt ?? this.idleExpiresAt, + absoluteExpiresAt: absoluteExpiresAt ?? this.absoluteExpiresAt, + rotatedAt: rotatedAt is DateTime? ? rotatedAt : this.rotatedAt, + revokedAt: revokedAt is DateTime? ? revokedAt : this.revokedAt, + ); + } +} + +class AcsRefreshTokenUpdateTable extends _i1.UpdateTable { + AcsRefreshTokenUpdateTable(super.table); + + _i1.ColumnValue<_i1.UuidValue, _i1.UuidValue> userId(_i1.UuidValue value) => + _i1.ColumnValue( + table.userId, + value, + ); + + _i1.ColumnValue<_i1.UuidValue, _i1.UuidValue> familyId(_i1.UuidValue value) => + _i1.ColumnValue( + table.familyId, + value, + ); + + _i1.ColumnValue tokenHash(String value) => _i1.ColumnValue( + table.tokenHash, + value, + ); + + _i1.ColumnValue deviceId(String value) => _i1.ColumnValue( + table.deviceId, + value, + ); + + _i1.ColumnValue issuedAt(DateTime value) => + _i1.ColumnValue( + table.issuedAt, + value, + ); + + _i1.ColumnValue idleExpiresAt(DateTime value) => + _i1.ColumnValue( + table.idleExpiresAt, + value, + ); + + _i1.ColumnValue absoluteExpiresAt(DateTime value) => + _i1.ColumnValue( + table.absoluteExpiresAt, + value, + ); + + _i1.ColumnValue rotatedAt(DateTime? value) => + _i1.ColumnValue( + table.rotatedAt, + value, + ); + + _i1.ColumnValue revokedAt(DateTime? value) => + _i1.ColumnValue( + table.revokedAt, + value, + ); +} + +class AcsRefreshTokenTable extends _i1.Table<_i1.UuidValue?> { + AcsRefreshTokenTable({super.tableRelation}) + : super(tableName: 'acs_refresh_tokens') { + updateTable = AcsRefreshTokenUpdateTable(this); + userId = _i1.ColumnUuid( + 'userId', + this, + ); + familyId = _i1.ColumnUuid( + 'familyId', + this, + ); + tokenHash = _i1.ColumnString( + 'tokenHash', + this, + ); + deviceId = _i1.ColumnString( + 'deviceId', + this, + ); + issuedAt = _i1.ColumnDateTime( + 'issuedAt', + this, + ); + idleExpiresAt = _i1.ColumnDateTime( + 'idleExpiresAt', + this, + ); + absoluteExpiresAt = _i1.ColumnDateTime( + 'absoluteExpiresAt', + this, + ); + rotatedAt = _i1.ColumnDateTime( + 'rotatedAt', + this, + ); + revokedAt = _i1.ColumnDateTime( + 'revokedAt', + this, + ); + } + + late final AcsRefreshTokenUpdateTable updateTable; + + late final _i1.ColumnUuid userId; + + /// Todos os tokens nascidos do mesmo login por senha+TOTP. + late final _i1.ColumnUuid familyId; + + late final _i1.ColumnString tokenHash; + + /// Aparelho que recebeu o token. Token apresentado por outro aparelho + /// revoga a família. + late final _i1.ColumnString deviceId; + + late final _i1.ColumnDateTime issuedAt; + + /// Janela ociosa: renovada a cada rotação, nunca além de `absoluteExpiresAt`. + late final _i1.ColumnDateTime idleExpiresAt; + + /// Teto do turno, fixado no login por senha+TOTP e herdado pelos filhos. + late final _i1.ColumnDateTime absoluteExpiresAt; + + /// `null` = ainda não usado. Preenchido na rotação. + late final _i1.ColumnDateTime rotatedAt; + + /// `null` = vigente. Preenchido na revogação da família ou no logout. + late final _i1.ColumnDateTime revokedAt; + + @override + List<_i1.Column> get columns => [ + id, + userId, + familyId, + tokenHash, + deviceId, + issuedAt, + idleExpiresAt, + absoluteExpiresAt, + rotatedAt, + revokedAt, + ]; +} + +class AcsRefreshTokenInclude extends _i1.IncludeObject { + AcsRefreshTokenInclude._(); + + @override + Map get includes => {}; + + @override + _i1.Table<_i1.UuidValue?> get table => AcsRefreshToken.t; +} + +class AcsRefreshTokenIncludeList extends _i1.IncludeList { + AcsRefreshTokenIncludeList._({ + _i1.WhereExpressionBuilder? where, + super.limit, + super.offset, + super.orderBy, + super.orderDescending, + super.orderByList, + super.include, + }) { + super.where = where?.call(AcsRefreshToken.t); + } + + @override + Map get includes => include?.includes ?? {}; + + @override + _i1.Table<_i1.UuidValue?> get table => AcsRefreshToken.t; +} + +class AcsRefreshTokenRepository { + const AcsRefreshTokenRepository._(); + + /// Returns a list of [AcsRefreshToken]s matching the given query parameters. + /// + /// Use [where] to specify which items to include in the return value. + /// If none is specified, all items will be returned. + /// + /// To specify the order of the items use [orderBy] or [orderByList] + /// when sorting by multiple columns. + /// + /// The maximum number of items can be set by [limit]. If no limit is set, + /// all items matching the query will be returned. + /// + /// [offset] defines how many items to skip, after which [limit] (or all) + /// items are read from the database. + /// + /// ```dart + /// var persons = await Persons.db.find( + /// session, + /// where: (t) => t.lastName.equals('Jones'), + /// orderBy: (t) => t.firstName, + /// limit: 100, + /// ); + /// ``` + Future> find( + _i1.DatabaseSession session, { + _i1.WhereExpressionBuilder? where, + int? limit, + int? offset, + _i1.OrderByBuilder? orderBy, + bool orderDescending = false, + _i1.OrderByListBuilder? orderByList, + _i1.Transaction? transaction, + _i1.LockMode? lockMode, + _i1.LockBehavior? lockBehavior, + }) async { + return session.db.find( + where: where?.call(AcsRefreshToken.t), + orderBy: orderBy?.call(AcsRefreshToken.t), + orderByList: orderByList?.call(AcsRefreshToken.t), + orderDescending: orderDescending, + limit: limit, + offset: offset, + transaction: transaction, + lockMode: lockMode, + lockBehavior: lockBehavior, + ); + } + + /// Returns the first matching [AcsRefreshToken] matching the given query parameters. + /// + /// Use [where] to specify which items to include in the return value. + /// If none is specified, all items will be returned. + /// + /// To specify the order use [orderBy] or [orderByList] + /// when sorting by multiple columns. + /// + /// [offset] defines how many items to skip, after which the next one will be picked. + /// + /// ```dart + /// var youngestPerson = await Persons.db.findFirstRow( + /// session, + /// where: (t) => t.lastName.equals('Jones'), + /// orderBy: (t) => t.age, + /// ); + /// ``` + Future findFirstRow( + _i1.DatabaseSession session, { + _i1.WhereExpressionBuilder? where, + int? offset, + _i1.OrderByBuilder? orderBy, + bool orderDescending = false, + _i1.OrderByListBuilder? orderByList, + _i1.Transaction? transaction, + _i1.LockMode? lockMode, + _i1.LockBehavior? lockBehavior, + }) async { + return session.db.findFirstRow( + where: where?.call(AcsRefreshToken.t), + orderBy: orderBy?.call(AcsRefreshToken.t), + orderByList: orderByList?.call(AcsRefreshToken.t), + orderDescending: orderDescending, + offset: offset, + transaction: transaction, + lockMode: lockMode, + lockBehavior: lockBehavior, + ); + } + + /// Finds a single [AcsRefreshToken] by its [id] or null if no such row exists. + Future findById( + _i1.DatabaseSession session, + _i1.UuidValue id, { + _i1.Transaction? transaction, + _i1.LockMode? lockMode, + _i1.LockBehavior? lockBehavior, + }) async { + return session.db.findById( + id, + transaction: transaction, + lockMode: lockMode, + lockBehavior: lockBehavior, + ); + } + + /// Inserts all [AcsRefreshToken]s in the list and returns the inserted rows. + /// + /// The returned [AcsRefreshToken]s will have their `id` fields set. + /// + /// This is an atomic operation, meaning that if one of the rows fails to + /// insert, none of the rows will be inserted. + /// + /// If [ignoreConflicts] is set to `true`, rows that conflict with existing + /// rows are silently skipped, and only the successfully inserted rows are + /// returned. + Future> insert( + _i1.DatabaseSession session, + List rows, { + _i1.Transaction? transaction, + bool ignoreConflicts = false, + }) async { + return session.db.insert( + rows, + transaction: transaction, + ignoreConflicts: ignoreConflicts, + ); + } + + /// Inserts a single [AcsRefreshToken] and returns the inserted row. + /// + /// The returned [AcsRefreshToken] will have its `id` field set. + Future insertRow( + _i1.DatabaseSession session, + AcsRefreshToken row, { + _i1.Transaction? transaction, + }) async { + return session.db.insertRow( + row, + transaction: transaction, + ); + } + + /// Updates all [AcsRefreshToken]s in the list and returns the updated rows. If + /// [columns] is provided, only those columns will be updated. Defaults to + /// all columns. + /// This is an atomic operation, meaning that if one of the rows fails to + /// update, none of the rows will be updated. + Future> update( + _i1.DatabaseSession session, + List rows, { + _i1.ColumnSelections? columns, + _i1.Transaction? transaction, + }) async { + return session.db.update( + rows, + columns: columns?.call(AcsRefreshToken.t), + transaction: transaction, + ); + } + + /// Updates a single [AcsRefreshToken]. The row needs to have its id set. + /// Optionally, a list of [columns] can be provided to only update those + /// columns. Defaults to all columns. + Future updateRow( + _i1.DatabaseSession session, + AcsRefreshToken row, { + _i1.ColumnSelections? columns, + _i1.Transaction? transaction, + }) async { + return session.db.updateRow( + row, + columns: columns?.call(AcsRefreshToken.t), + transaction: transaction, + ); + } + + /// Updates a single [AcsRefreshToken] by its [id] with the specified [columnValues]. + /// Returns the updated row or null if no row with the given id exists. + Future updateById( + _i1.DatabaseSession session, + _i1.UuidValue id, { + required _i1.ColumnValueListBuilder + columnValues, + _i1.Transaction? transaction, + }) async { + return session.db.updateById( + id, + columnValues: columnValues(AcsRefreshToken.t.updateTable), + transaction: transaction, + ); + } + + /// Updates all [AcsRefreshToken]s matching the [where] expression with the specified [columnValues]. + /// Returns the list of updated rows. + Future> updateWhere( + _i1.DatabaseSession session, { + required _i1.ColumnValueListBuilder + columnValues, + required _i1.WhereExpressionBuilder where, + int? limit, + int? offset, + _i1.OrderByBuilder? orderBy, + _i1.OrderByListBuilder? orderByList, + bool orderDescending = false, + _i1.Transaction? transaction, + }) async { + return session.db.updateWhere( + columnValues: columnValues(AcsRefreshToken.t.updateTable), + where: where(AcsRefreshToken.t), + limit: limit, + offset: offset, + orderBy: orderBy?.call(AcsRefreshToken.t), + orderByList: orderByList?.call(AcsRefreshToken.t), + orderDescending: orderDescending, + transaction: transaction, + ); + } + + /// Deletes all [AcsRefreshToken]s in the list and returns the deleted rows. + /// This is an atomic operation, meaning that if one of the rows fail to + /// be deleted, none of the rows will be deleted. + Future> delete( + _i1.DatabaseSession session, + List rows, { + _i1.Transaction? transaction, + }) async { + return session.db.delete( + rows, + transaction: transaction, + ); + } + + /// Deletes a single [AcsRefreshToken]. + Future deleteRow( + _i1.DatabaseSession session, + AcsRefreshToken row, { + _i1.Transaction? transaction, + }) async { + return session.db.deleteRow( + row, + transaction: transaction, + ); + } + + /// Deletes all rows matching the [where] expression. + Future> deleteWhere( + _i1.DatabaseSession session, { + required _i1.WhereExpressionBuilder where, + _i1.Transaction? transaction, + }) async { + return session.db.deleteWhere( + where: where(AcsRefreshToken.t), + transaction: transaction, + ); + } + + /// Counts the number of rows matching the [where] expression. If omitted, + /// will return the count of all rows in the table. + Future count( + _i1.DatabaseSession session, { + _i1.WhereExpressionBuilder? where, + int? limit, + _i1.Transaction? transaction, + }) async { + return session.db.count( + where: where?.call(AcsRefreshToken.t), + limit: limit, + transaction: transaction, + ); + } + + /// Acquires row-level locks on [AcsRefreshToken] rows matching the [where] expression. + Future lockRows( + _i1.DatabaseSession session, { + required _i1.WhereExpressionBuilder where, + required _i1.LockMode lockMode, + required _i1.Transaction transaction, + _i1.LockBehavior lockBehavior = _i1.LockBehavior.wait, + }) async { + return session.db.lockRows( + where: where(AcsRefreshToken.t), + lockMode: lockMode, + lockBehavior: lockBehavior, + transaction: transaction, + ); + } +} diff --git a/backend/sinalacs_server/lib/src/generated/acs_upload_token.dart b/backend/sinalacs_server/lib/src/generated/acs_upload_token.dart new file mode 100644 index 00000000..4dc960eb --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/acs_upload_token.dart @@ -0,0 +1,623 @@ +/* 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; + +/// Token de envio diferido do ACS (D7 do plano 2026-10-03). Deixa o aparelho +/// subir as visitas pendentes de um ACS que já saiu — com a autoria DELE — +/// sem sessão e sem nenhum outro poder: o único uso é `visits.syncDeferred` +/// (e `visits.revokeUploadToken`). Um vigente por (usuário, aparelho): a +/// emissão revoga o anterior. Validade de 7 dias, sem rotação. +/// +/// **Nunca** o token: só o SHA-256 dele em hex. O token é 256 bits aleatórios, +/// então um hash simples basta (não há senha humana para adivinhar). +abstract class AcsUploadToken + implements _i1.TableRow<_i1.UuidValue?>, _i1.ProtocolSerialization { + AcsUploadToken._({ + this.id, + required this.userId, + required this.tokenHash, + required this.deviceId, + required this.issuedAt, + required this.expiresAt, + this.revokedAt, + }); + + factory AcsUploadToken({ + _i1.UuidValue? id, + required _i1.UuidValue userId, + required String tokenHash, + required String deviceId, + required DateTime issuedAt, + required DateTime expiresAt, + DateTime? revokedAt, + }) = _AcsUploadTokenImpl; + + factory AcsUploadToken.fromJson(Map jsonSerialization) { + return AcsUploadToken( + id: jsonSerialization['id'] == null + ? null + : _i1.UuidValueJsonExtension.fromJson(jsonSerialization['id']), + userId: _i1.UuidValueJsonExtension.fromJson(jsonSerialization['userId']), + tokenHash: jsonSerialization['tokenHash'] as String, + deviceId: jsonSerialization['deviceId'] as String, + issuedAt: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['issuedAt'], + ), + expiresAt: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['expiresAt'], + ), + revokedAt: jsonSerialization['revokedAt'] == null + ? null + : _i1.DateTimeJsonExtension.fromJson(jsonSerialization['revokedAt']), + ); + } + + static final t = AcsUploadTokenTable(); + + static const db = AcsUploadTokenRepository._(); + + @override + _i1.UuidValue? id; + + _i1.UuidValue userId; + + String tokenHash; + + /// Instalação que recebeu o token. Token apresentado por outro aparelho é + /// recusado e revogado. + String deviceId; + + DateTime issuedAt; + + /// Teto fixo (`issuedAt` + 7 dias); o uso não o estende. + DateTime expiresAt; + + /// `null` = vigente. Preenchido na revogação (pedido do app, novo login no + /// mesmo aparelho, aparelho divergente ou conta inativa/sem microárea). + DateTime? revokedAt; + + @override + _i1.Table<_i1.UuidValue?> get table => t; + + /// Returns a shallow copy of this [AcsUploadToken] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + AcsUploadToken copyWith({ + _i1.UuidValue? id, + _i1.UuidValue? userId, + String? tokenHash, + String? deviceId, + DateTime? issuedAt, + DateTime? expiresAt, + DateTime? revokedAt, + }); + @override + Map toJson() { + return { + '__className__': 'AcsUploadToken', + if (id != null) 'id': id?.toJson(), + 'userId': userId.toJson(), + 'tokenHash': tokenHash, + 'deviceId': deviceId, + 'issuedAt': issuedAt.toJson(), + 'expiresAt': expiresAt.toJson(), + if (revokedAt != null) 'revokedAt': revokedAt?.toJson(), + }; + } + + @override + Map toJsonForProtocol() { + return { + '__className__': 'AcsUploadToken', + if (id != null) 'id': id?.toJson(), + 'userId': userId.toJson(), + 'tokenHash': tokenHash, + 'deviceId': deviceId, + 'issuedAt': issuedAt.toJson(), + 'expiresAt': expiresAt.toJson(), + if (revokedAt != null) 'revokedAt': revokedAt?.toJson(), + }; + } + + static AcsUploadTokenInclude include() { + return AcsUploadTokenInclude._(); + } + + static AcsUploadTokenIncludeList includeList({ + _i1.WhereExpressionBuilder? where, + int? limit, + int? offset, + _i1.OrderByBuilder? orderBy, + bool orderDescending = false, + _i1.OrderByListBuilder? orderByList, + AcsUploadTokenInclude? include, + }) { + return AcsUploadTokenIncludeList._( + where: where, + limit: limit, + offset: offset, + orderBy: orderBy?.call(AcsUploadToken.t), + orderDescending: orderDescending, + orderByList: orderByList?.call(AcsUploadToken.t), + include: include, + ); + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _AcsUploadTokenImpl extends AcsUploadToken { + _AcsUploadTokenImpl({ + _i1.UuidValue? id, + required _i1.UuidValue userId, + required String tokenHash, + required String deviceId, + required DateTime issuedAt, + required DateTime expiresAt, + DateTime? revokedAt, + }) : super._( + id: id, + userId: userId, + tokenHash: tokenHash, + deviceId: deviceId, + issuedAt: issuedAt, + expiresAt: expiresAt, + revokedAt: revokedAt, + ); + + /// Returns a shallow copy of this [AcsUploadToken] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + AcsUploadToken copyWith({ + Object? id = _Undefined, + _i1.UuidValue? userId, + String? tokenHash, + String? deviceId, + DateTime? issuedAt, + DateTime? expiresAt, + Object? revokedAt = _Undefined, + }) { + return AcsUploadToken( + id: id is _i1.UuidValue? ? id : this.id, + userId: userId ?? this.userId, + tokenHash: tokenHash ?? this.tokenHash, + deviceId: deviceId ?? this.deviceId, + issuedAt: issuedAt ?? this.issuedAt, + expiresAt: expiresAt ?? this.expiresAt, + revokedAt: revokedAt is DateTime? ? revokedAt : this.revokedAt, + ); + } +} + +class AcsUploadTokenUpdateTable extends _i1.UpdateTable { + AcsUploadTokenUpdateTable(super.table); + + _i1.ColumnValue<_i1.UuidValue, _i1.UuidValue> userId(_i1.UuidValue value) => + _i1.ColumnValue( + table.userId, + value, + ); + + _i1.ColumnValue tokenHash(String value) => _i1.ColumnValue( + table.tokenHash, + value, + ); + + _i1.ColumnValue deviceId(String value) => _i1.ColumnValue( + table.deviceId, + value, + ); + + _i1.ColumnValue issuedAt(DateTime value) => + _i1.ColumnValue( + table.issuedAt, + value, + ); + + _i1.ColumnValue expiresAt(DateTime value) => + _i1.ColumnValue( + table.expiresAt, + value, + ); + + _i1.ColumnValue revokedAt(DateTime? value) => + _i1.ColumnValue( + table.revokedAt, + value, + ); +} + +class AcsUploadTokenTable extends _i1.Table<_i1.UuidValue?> { + AcsUploadTokenTable({super.tableRelation}) + : super(tableName: 'acs_upload_tokens') { + updateTable = AcsUploadTokenUpdateTable(this); + userId = _i1.ColumnUuid( + 'userId', + this, + ); + tokenHash = _i1.ColumnString( + 'tokenHash', + this, + ); + deviceId = _i1.ColumnString( + 'deviceId', + this, + ); + issuedAt = _i1.ColumnDateTime( + 'issuedAt', + this, + ); + expiresAt = _i1.ColumnDateTime( + 'expiresAt', + this, + ); + revokedAt = _i1.ColumnDateTime( + 'revokedAt', + this, + ); + } + + late final AcsUploadTokenUpdateTable updateTable; + + late final _i1.ColumnUuid userId; + + late final _i1.ColumnString tokenHash; + + /// Instalação que recebeu o token. Token apresentado por outro aparelho é + /// recusado e revogado. + late final _i1.ColumnString deviceId; + + late final _i1.ColumnDateTime issuedAt; + + /// Teto fixo (`issuedAt` + 7 dias); o uso não o estende. + late final _i1.ColumnDateTime expiresAt; + + /// `null` = vigente. Preenchido na revogação (pedido do app, novo login no + /// mesmo aparelho, aparelho divergente ou conta inativa/sem microárea). + late final _i1.ColumnDateTime revokedAt; + + @override + List<_i1.Column> get columns => [ + id, + userId, + tokenHash, + deviceId, + issuedAt, + expiresAt, + revokedAt, + ]; +} + +class AcsUploadTokenInclude extends _i1.IncludeObject { + AcsUploadTokenInclude._(); + + @override + Map get includes => {}; + + @override + _i1.Table<_i1.UuidValue?> get table => AcsUploadToken.t; +} + +class AcsUploadTokenIncludeList extends _i1.IncludeList { + AcsUploadTokenIncludeList._({ + _i1.WhereExpressionBuilder? where, + super.limit, + super.offset, + super.orderBy, + super.orderDescending, + super.orderByList, + super.include, + }) { + super.where = where?.call(AcsUploadToken.t); + } + + @override + Map get includes => include?.includes ?? {}; + + @override + _i1.Table<_i1.UuidValue?> get table => AcsUploadToken.t; +} + +class AcsUploadTokenRepository { + const AcsUploadTokenRepository._(); + + /// Returns a list of [AcsUploadToken]s matching the given query parameters. + /// + /// Use [where] to specify which items to include in the return value. + /// If none is specified, all items will be returned. + /// + /// To specify the order of the items use [orderBy] or [orderByList] + /// when sorting by multiple columns. + /// + /// The maximum number of items can be set by [limit]. If no limit is set, + /// all items matching the query will be returned. + /// + /// [offset] defines how many items to skip, after which [limit] (or all) + /// items are read from the database. + /// + /// ```dart + /// var persons = await Persons.db.find( + /// session, + /// where: (t) => t.lastName.equals('Jones'), + /// orderBy: (t) => t.firstName, + /// limit: 100, + /// ); + /// ``` + Future> find( + _i1.DatabaseSession session, { + _i1.WhereExpressionBuilder? where, + int? limit, + int? offset, + _i1.OrderByBuilder? orderBy, + bool orderDescending = false, + _i1.OrderByListBuilder? orderByList, + _i1.Transaction? transaction, + _i1.LockMode? lockMode, + _i1.LockBehavior? lockBehavior, + }) async { + return session.db.find( + where: where?.call(AcsUploadToken.t), + orderBy: orderBy?.call(AcsUploadToken.t), + orderByList: orderByList?.call(AcsUploadToken.t), + orderDescending: orderDescending, + limit: limit, + offset: offset, + transaction: transaction, + lockMode: lockMode, + lockBehavior: lockBehavior, + ); + } + + /// Returns the first matching [AcsUploadToken] matching the given query parameters. + /// + /// Use [where] to specify which items to include in the return value. + /// If none is specified, all items will be returned. + /// + /// To specify the order use [orderBy] or [orderByList] + /// when sorting by multiple columns. + /// + /// [offset] defines how many items to skip, after which the next one will be picked. + /// + /// ```dart + /// var youngestPerson = await Persons.db.findFirstRow( + /// session, + /// where: (t) => t.lastName.equals('Jones'), + /// orderBy: (t) => t.age, + /// ); + /// ``` + Future findFirstRow( + _i1.DatabaseSession session, { + _i1.WhereExpressionBuilder? where, + int? offset, + _i1.OrderByBuilder? orderBy, + bool orderDescending = false, + _i1.OrderByListBuilder? orderByList, + _i1.Transaction? transaction, + _i1.LockMode? lockMode, + _i1.LockBehavior? lockBehavior, + }) async { + return session.db.findFirstRow( + where: where?.call(AcsUploadToken.t), + orderBy: orderBy?.call(AcsUploadToken.t), + orderByList: orderByList?.call(AcsUploadToken.t), + orderDescending: orderDescending, + offset: offset, + transaction: transaction, + lockMode: lockMode, + lockBehavior: lockBehavior, + ); + } + + /// Finds a single [AcsUploadToken] by its [id] or null if no such row exists. + Future findById( + _i1.DatabaseSession session, + _i1.UuidValue id, { + _i1.Transaction? transaction, + _i1.LockMode? lockMode, + _i1.LockBehavior? lockBehavior, + }) async { + return session.db.findById( + id, + transaction: transaction, + lockMode: lockMode, + lockBehavior: lockBehavior, + ); + } + + /// Inserts all [AcsUploadToken]s in the list and returns the inserted rows. + /// + /// The returned [AcsUploadToken]s will have their `id` fields set. + /// + /// This is an atomic operation, meaning that if one of the rows fails to + /// insert, none of the rows will be inserted. + /// + /// If [ignoreConflicts] is set to `true`, rows that conflict with existing + /// rows are silently skipped, and only the successfully inserted rows are + /// returned. + Future> insert( + _i1.DatabaseSession session, + List rows, { + _i1.Transaction? transaction, + bool ignoreConflicts = false, + }) async { + return session.db.insert( + rows, + transaction: transaction, + ignoreConflicts: ignoreConflicts, + ); + } + + /// Inserts a single [AcsUploadToken] and returns the inserted row. + /// + /// The returned [AcsUploadToken] will have its `id` field set. + Future insertRow( + _i1.DatabaseSession session, + AcsUploadToken row, { + _i1.Transaction? transaction, + }) async { + return session.db.insertRow( + row, + transaction: transaction, + ); + } + + /// Updates all [AcsUploadToken]s in the list and returns the updated rows. If + /// [columns] is provided, only those columns will be updated. Defaults to + /// all columns. + /// This is an atomic operation, meaning that if one of the rows fails to + /// update, none of the rows will be updated. + Future> update( + _i1.DatabaseSession session, + List rows, { + _i1.ColumnSelections? columns, + _i1.Transaction? transaction, + }) async { + return session.db.update( + rows, + columns: columns?.call(AcsUploadToken.t), + transaction: transaction, + ); + } + + /// Updates a single [AcsUploadToken]. The row needs to have its id set. + /// Optionally, a list of [columns] can be provided to only update those + /// columns. Defaults to all columns. + Future updateRow( + _i1.DatabaseSession session, + AcsUploadToken row, { + _i1.ColumnSelections? columns, + _i1.Transaction? transaction, + }) async { + return session.db.updateRow( + row, + columns: columns?.call(AcsUploadToken.t), + transaction: transaction, + ); + } + + /// Updates a single [AcsUploadToken] by its [id] with the specified [columnValues]. + /// Returns the updated row or null if no row with the given id exists. + Future updateById( + _i1.DatabaseSession session, + _i1.UuidValue id, { + required _i1.ColumnValueListBuilder columnValues, + _i1.Transaction? transaction, + }) async { + return session.db.updateById( + id, + columnValues: columnValues(AcsUploadToken.t.updateTable), + transaction: transaction, + ); + } + + /// Updates all [AcsUploadToken]s matching the [where] expression with the specified [columnValues]. + /// Returns the list of updated rows. + Future> updateWhere( + _i1.DatabaseSession session, { + required _i1.ColumnValueListBuilder columnValues, + required _i1.WhereExpressionBuilder where, + int? limit, + int? offset, + _i1.OrderByBuilder? orderBy, + _i1.OrderByListBuilder? orderByList, + bool orderDescending = false, + _i1.Transaction? transaction, + }) async { + return session.db.updateWhere( + columnValues: columnValues(AcsUploadToken.t.updateTable), + where: where(AcsUploadToken.t), + limit: limit, + offset: offset, + orderBy: orderBy?.call(AcsUploadToken.t), + orderByList: orderByList?.call(AcsUploadToken.t), + orderDescending: orderDescending, + transaction: transaction, + ); + } + + /// Deletes all [AcsUploadToken]s in the list and returns the deleted rows. + /// This is an atomic operation, meaning that if one of the rows fail to + /// be deleted, none of the rows will be deleted. + Future> delete( + _i1.DatabaseSession session, + List rows, { + _i1.Transaction? transaction, + }) async { + return session.db.delete( + rows, + transaction: transaction, + ); + } + + /// Deletes a single [AcsUploadToken]. + Future deleteRow( + _i1.DatabaseSession session, + AcsUploadToken row, { + _i1.Transaction? transaction, + }) async { + return session.db.deleteRow( + row, + transaction: transaction, + ); + } + + /// Deletes all rows matching the [where] expression. + Future> deleteWhere( + _i1.DatabaseSession session, { + required _i1.WhereExpressionBuilder where, + _i1.Transaction? transaction, + }) async { + return session.db.deleteWhere( + where: where(AcsUploadToken.t), + transaction: transaction, + ); + } + + /// Counts the number of rows matching the [where] expression. If omitted, + /// will return the count of all rows in the table. + Future count( + _i1.DatabaseSession session, { + _i1.WhereExpressionBuilder? where, + int? limit, + _i1.Transaction? transaction, + }) async { + return session.db.count( + where: where?.call(AcsUploadToken.t), + limit: limit, + transaction: transaction, + ); + } + + /// Acquires row-level locks on [AcsUploadToken] rows matching the [where] expression. + Future lockRows( + _i1.DatabaseSession session, { + required _i1.WhereExpressionBuilder where, + required _i1.LockMode lockMode, + required _i1.Transaction transaction, + _i1.LockBehavior lockBehavior = _i1.LockBehavior.wait, + }) async { + return session.db.lockRows( + where: where(AcsUploadToken.t), + lockMode: lockMode, + lockBehavior: lockBehavior, + transaction: transaction, + ); + } +} diff --git a/backend/sinalacs_server/lib/src/generated/alert.dart b/backend/sinalacs_server/lib/src/generated/alert.dart index fbae2391..0ec43dd8 100644 --- a/backend/sinalacs_server/lib/src/generated/alert.dart +++ b/backend/sinalacs_server/lib/src/generated/alert.dart @@ -14,6 +14,7 @@ import 'package:serverpod/serverpod.dart' as _i1; import 'enums/risk_level.dart' as _i2; import 'enums/alert_status.dart' as _i3; +import 'enums/escalation_reason.dart' as _i4; /// Alerta de urgência disparado pelo paciente. /// @@ -39,6 +40,9 @@ abstract class Alert required this.deviceId, required this.retryCount, required this.version, + this.escalatedAt, + this.escalatedBy, + this.escalationReason, }); factory Alert({ @@ -58,6 +62,9 @@ abstract class Alert required String deviceId, required int retryCount, required int version, + DateTime? escalatedAt, + _i1.UuidValue? escalatedBy, + _i4.EscalationReason? escalationReason, }) = _AlertImpl; factory Alert.fromJson(Map jsonSerialization) { @@ -102,6 +109,21 @@ abstract class Alert deviceId: jsonSerialization['deviceId'] as String, retryCount: jsonSerialization['retryCount'] as int, version: jsonSerialization['version'] as int, + escalatedAt: jsonSerialization['escalatedAt'] == null + ? null + : _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['escalatedAt'], + ), + escalatedBy: jsonSerialization['escalatedBy'] == null + ? null + : _i1.UuidValueJsonExtension.fromJson( + jsonSerialization['escalatedBy'], + ), + escalationReason: jsonSerialization['escalationReason'] == null + ? null + : _i4.EscalationReason.fromJson( + (jsonSerialization['escalationReason'] as String), + ), ); } @@ -144,6 +166,13 @@ abstract class Alert int version; + /// Escalonamento pelo backoffice (#44). Anuláveis: alerta não escalonado. + DateTime? escalatedAt; + + _i1.UuidValue? escalatedBy; + + _i4.EscalationReason? escalationReason; + @override _i1.Table<_i1.UuidValue?> get table => t; @@ -167,6 +196,9 @@ abstract class Alert String? deviceId, int? retryCount, int? version, + DateTime? escalatedAt, + _i1.UuidValue? escalatedBy, + _i4.EscalationReason? escalationReason, }); @override Map toJson() { @@ -188,6 +220,10 @@ abstract class Alert 'deviceId': deviceId, 'retryCount': retryCount, 'version': version, + if (escalatedAt != null) 'escalatedAt': escalatedAt?.toJson(), + if (escalatedBy != null) 'escalatedBy': escalatedBy?.toJson(), + if (escalationReason != null) + 'escalationReason': escalationReason?.toJson(), }; } @@ -211,6 +247,10 @@ abstract class Alert 'deviceId': deviceId, 'retryCount': retryCount, 'version': version, + if (escalatedAt != null) 'escalatedAt': escalatedAt?.toJson(), + if (escalatedBy != null) 'escalatedBy': escalatedBy?.toJson(), + if (escalationReason != null) + 'escalationReason': escalationReason?.toJson(), }; } @@ -264,6 +304,9 @@ class _AlertImpl extends Alert { required String deviceId, required int retryCount, required int version, + DateTime? escalatedAt, + _i1.UuidValue? escalatedBy, + _i4.EscalationReason? escalationReason, }) : super._( id: id, patientId: patientId, @@ -281,6 +324,9 @@ class _AlertImpl extends Alert { deviceId: deviceId, retryCount: retryCount, version: version, + escalatedAt: escalatedAt, + escalatedBy: escalatedBy, + escalationReason: escalationReason, ); /// Returns a shallow copy of this [Alert] @@ -304,6 +350,9 @@ class _AlertImpl extends Alert { String? deviceId, int? retryCount, int? version, + Object? escalatedAt = _Undefined, + Object? escalatedBy = _Undefined, + Object? escalationReason = _Undefined, }) { return Alert( id: id is _i1.UuidValue? ? id : this.id, @@ -326,6 +375,13 @@ class _AlertImpl extends Alert { deviceId: deviceId ?? this.deviceId, retryCount: retryCount ?? this.retryCount, version: version ?? this.version, + escalatedAt: escalatedAt is DateTime? ? escalatedAt : this.escalatedAt, + escalatedBy: escalatedBy is _i1.UuidValue? + ? escalatedBy + : this.escalatedBy, + escalationReason: escalationReason is _i4.EscalationReason? + ? escalationReason + : this.escalationReason, ); } } @@ -421,6 +477,26 @@ class AlertUpdateTable extends _i1.UpdateTable { table.version, value, ); + + _i1.ColumnValue escalatedAt(DateTime? value) => + _i1.ColumnValue( + table.escalatedAt, + value, + ); + + _i1.ColumnValue<_i1.UuidValue, _i1.UuidValue> escalatedBy( + _i1.UuidValue? value, + ) => _i1.ColumnValue( + table.escalatedBy, + value, + ); + + _i1.ColumnValue<_i4.EscalationReason, _i4.EscalationReason> escalationReason( + _i4.EscalationReason? value, + ) => _i1.ColumnValue( + table.escalationReason, + value, + ); } class AlertTable extends _i1.Table<_i1.UuidValue?> { @@ -488,6 +564,19 @@ class AlertTable extends _i1.Table<_i1.UuidValue?> { 'version', this, ); + escalatedAt = _i1.ColumnDateTime( + 'escalatedAt', + this, + ); + escalatedBy = _i1.ColumnUuid( + 'escalatedBy', + this, + ); + escalationReason = _i1.ColumnEnum( + 'escalationReason', + this, + _i1.EnumSerialization.byName, + ); } late final AlertUpdateTable updateTable; @@ -524,6 +613,13 @@ class AlertTable extends _i1.Table<_i1.UuidValue?> { late final _i1.ColumnInt version; + /// Escalonamento pelo backoffice (#44). Anuláveis: alerta não escalonado. + late final _i1.ColumnDateTime escalatedAt; + + late final _i1.ColumnUuid escalatedBy; + + late final _i1.ColumnEnum<_i4.EscalationReason> escalationReason; + @override List<_i1.Column> get columns => [ id, @@ -542,6 +638,9 @@ class AlertTable extends _i1.Table<_i1.UuidValue?> { deviceId, retryCount, version, + escalatedAt, + escalatedBy, + escalationReason, ]; } diff --git a/backend/sinalacs_server/lib/src/generated/api/admin_acs.dart b/backend/sinalacs_server/lib/src/generated/api/admin_acs.dart new file mode 100644 index 00000000..f5772920 --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/api/admin_acs.dart @@ -0,0 +1,188 @@ +/* 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; + +/// ACS no backoffice (#43): identificação profissional, território e estado de +/// acesso. Nunca inclui CPF, senha ou contato. +/// +/// `mfaActive` é `user_credentials.totpEnabledAt IS NOT NULL` — MFA **ativa**, +/// não apenas iniciada: um enrollment pendente (`totpEnabledAt` nulo) não vale +/// como proteção e a tela precisa poder dizer isso. +abstract class AdminAcs + implements _i1.SerializableModel, _i1.ProtocolSerialization { + AdminAcs._({ + required this.id, + required this.name, + required this.enrollmentId, + required this.ubsId, + required this.ubsName, + this.microAreaId, + this.microAreaName, + required this.active, + required this.mfaActive, + }); + + factory AdminAcs({ + required String id, + required String name, + required String enrollmentId, + required String ubsId, + required String ubsName, + String? microAreaId, + String? microAreaName, + required bool active, + required bool mfaActive, + }) = _AdminAcsImpl; + + factory AdminAcs.fromJson(Map jsonSerialization) { + return AdminAcs( + id: jsonSerialization['id'] as String, + name: jsonSerialization['name'] as String, + enrollmentId: jsonSerialization['enrollmentId'] as String, + ubsId: jsonSerialization['ubsId'] as String, + ubsName: jsonSerialization['ubsName'] as String, + microAreaId: jsonSerialization['microAreaId'] as String?, + microAreaName: jsonSerialization['microAreaName'] as String?, + active: _i1.BoolJsonExtension.fromJson(jsonSerialization['active']), + mfaActive: _i1.BoolJsonExtension.fromJson(jsonSerialization['mfaActive']), + ); + } + + String id; + + String name; + + String enrollmentId; + + String ubsId; + + String ubsName; + + /// Nulos enquanto o ACS não tem território (`users.microAreaId`). A listagem + /// inclui quem não tem microárea, para poder vinculá-lo. + String? microAreaId; + + String? microAreaName; + + bool active; + + bool mfaActive; + + /// Returns a shallow copy of this [AdminAcs] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + AdminAcs copyWith({ + String? id, + String? name, + String? enrollmentId, + String? ubsId, + String? ubsName, + String? microAreaId, + String? microAreaName, + bool? active, + bool? mfaActive, + }); + @override + Map toJson() { + return { + '__className__': 'AdminAcs', + 'id': id, + 'name': name, + 'enrollmentId': enrollmentId, + 'ubsId': ubsId, + 'ubsName': ubsName, + if (microAreaId != null) 'microAreaId': microAreaId, + if (microAreaName != null) 'microAreaName': microAreaName, + 'active': active, + 'mfaActive': mfaActive, + }; + } + + @override + Map toJsonForProtocol() { + return { + '__className__': 'AdminAcs', + 'id': id, + 'name': name, + 'enrollmentId': enrollmentId, + 'ubsId': ubsId, + 'ubsName': ubsName, + if (microAreaId != null) 'microAreaId': microAreaId, + if (microAreaName != null) 'microAreaName': microAreaName, + 'active': active, + 'mfaActive': mfaActive, + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _AdminAcsImpl extends AdminAcs { + _AdminAcsImpl({ + required String id, + required String name, + required String enrollmentId, + required String ubsId, + required String ubsName, + String? microAreaId, + String? microAreaName, + required bool active, + required bool mfaActive, + }) : super._( + id: id, + name: name, + enrollmentId: enrollmentId, + ubsId: ubsId, + ubsName: ubsName, + microAreaId: microAreaId, + microAreaName: microAreaName, + active: active, + mfaActive: mfaActive, + ); + + /// Returns a shallow copy of this [AdminAcs] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + AdminAcs copyWith({ + String? id, + String? name, + String? enrollmentId, + String? ubsId, + String? ubsName, + Object? microAreaId = _Undefined, + Object? microAreaName = _Undefined, + bool? active, + bool? mfaActive, + }) { + return AdminAcs( + id: id ?? this.id, + name: name ?? this.name, + enrollmentId: enrollmentId ?? this.enrollmentId, + ubsId: ubsId ?? this.ubsId, + ubsName: ubsName ?? this.ubsName, + microAreaId: microAreaId is String? ? microAreaId : this.microAreaId, + microAreaName: microAreaName is String? + ? microAreaName + : this.microAreaName, + active: active ?? this.active, + mfaActive: mfaActive ?? this.mfaActive, + ); + } +} diff --git a/backend/sinalacs_server/lib/src/generated/api/admin_acs_creation_result.dart b/backend/sinalacs_server/lib/src/generated/api/admin_acs_creation_result.dart new file mode 100644 index 00000000..dd258b4e --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/api/admin_acs_creation_result.dart @@ -0,0 +1,103 @@ +/* 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 '../api/admin_acs.dart' as _i2; +import 'package:sinalacs_server/src/generated/protocol.dart' as _i3; + +/// ACS recém-cadastrado e a senha inicial gerada (issue #43). +/// +/// A senha existe **só** aqui e na resposta de `admin.createAcs`: não há coluna, +/// log nem linha de auditoria que a guarde — o servidor persiste apenas o hash +/// Argon2id em `user_credentials`. A trilha registra `created` com o id do ACS e +/// nunca a credencial. Quem cadastra mostra uma vez; perdida a senha, o caminho +/// é redefinir, não recuperar (operação seguinte da mesma issue). +abstract class AdminAcsCreationResult + implements _i1.SerializableModel, _i1.ProtocolSerialization { + AdminAcsCreationResult._({ + required this.acs, + required this.initialPassword, + }); + + factory AdminAcsCreationResult({ + required _i2.AdminAcs acs, + required String initialPassword, + }) = _AdminAcsCreationResultImpl; + + factory AdminAcsCreationResult.fromJson( + Map jsonSerialization, + ) { + return AdminAcsCreationResult( + acs: _i3.Protocol().deserialize<_i2.AdminAcs>(jsonSerialization['acs']), + initialPassword: jsonSerialization['initialPassword'] as String, + ); + } + + _i2.AdminAcs acs; + + String initialPassword; + + /// Returns a shallow copy of this [AdminAcsCreationResult] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + AdminAcsCreationResult copyWith({ + _i2.AdminAcs? acs, + String? initialPassword, + }); + @override + Map toJson() { + return { + '__className__': 'AdminAcsCreationResult', + 'acs': acs.toJson(), + 'initialPassword': initialPassword, + }; + } + + @override + Map toJsonForProtocol() { + return { + '__className__': 'AdminAcsCreationResult', + 'acs': acs.toJsonForProtocol(), + 'initialPassword': initialPassword, + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _AdminAcsCreationResultImpl extends AdminAcsCreationResult { + _AdminAcsCreationResultImpl({ + required _i2.AdminAcs acs, + required String initialPassword, + }) : super._( + acs: acs, + initialPassword: initialPassword, + ); + + /// Returns a shallow copy of this [AdminAcsCreationResult] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + AdminAcsCreationResult copyWith({ + _i2.AdminAcs? acs, + String? initialPassword, + }) { + return AdminAcsCreationResult( + acs: acs ?? this.acs.copyWith(), + initialPassword: initialPassword ?? this.initialPassword, + ); + } +} diff --git a/backend/sinalacs_server/lib/src/generated/api/admin_alert.dart b/backend/sinalacs_server/lib/src/generated/api/admin_alert.dart new file mode 100644 index 00000000..6b3259ba --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/api/admin_alert.dart @@ -0,0 +1,179 @@ +/* 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 '../enums/alert_status.dart' as _i3; + +/// Alerta para o backoffice. `patientLabel` é `#` + 4 hex do UUID: o backoffice +/// identifica, não qualifica (spec/lgpd_design.md). Nunca nome, CPF ou contato. +abstract class AdminAlert + implements _i1.SerializableModel, _i1.ProtocolSerialization { + AdminAlert._({ + required this.id, + required this.patientLabel, + required this.microAreaName, + required this.riskLevel, + required this.status, + required this.triggeredAt, + this.escalatedAt, + required this.overdue, + }); + + factory AdminAlert({ + required String id, + required String patientLabel, + required String microAreaName, + required _i2.RiskLevel riskLevel, + required _i3.AlertStatus status, + required DateTime triggeredAt, + DateTime? escalatedAt, + required bool overdue, + }) = _AdminAlertImpl; + + factory AdminAlert.fromJson(Map jsonSerialization) { + return AdminAlert( + id: jsonSerialization['id'] as String, + patientLabel: jsonSerialization['patientLabel'] as String, + microAreaName: jsonSerialization['microAreaName'] as String, + riskLevel: _i2.RiskLevel.fromJson( + (jsonSerialization['riskLevel'] as String), + ), + status: _i3.AlertStatus.fromJson((jsonSerialization['status'] as String)), + triggeredAt: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['triggeredAt'], + ), + escalatedAt: jsonSerialization['escalatedAt'] == null + ? null + : _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['escalatedAt'], + ), + overdue: _i1.BoolJsonExtension.fromJson(jsonSerialization['overdue']), + ); + } + + String id; + + String patientLabel; + + String microAreaName; + + _i2.RiskLevel riskLevel; + + _i3.AlertStatus status; + + DateTime triggeredAt; + + DateTime? escalatedAt; + + /// Sem reconhecimento além do prazo configurado; calculado na leitura. + bool overdue; + + /// Returns a shallow copy of this [AdminAlert] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + AdminAlert copyWith({ + String? id, + String? patientLabel, + String? microAreaName, + _i2.RiskLevel? riskLevel, + _i3.AlertStatus? status, + DateTime? triggeredAt, + DateTime? escalatedAt, + bool? overdue, + }); + @override + Map toJson() { + return { + '__className__': 'AdminAlert', + 'id': id, + 'patientLabel': patientLabel, + 'microAreaName': microAreaName, + 'riskLevel': riskLevel.toJson(), + 'status': status.toJson(), + 'triggeredAt': triggeredAt.toJson(), + if (escalatedAt != null) 'escalatedAt': escalatedAt?.toJson(), + 'overdue': overdue, + }; + } + + @override + Map toJsonForProtocol() { + return { + '__className__': 'AdminAlert', + 'id': id, + 'patientLabel': patientLabel, + 'microAreaName': microAreaName, + 'riskLevel': riskLevel.toJson(), + 'status': status.toJson(), + 'triggeredAt': triggeredAt.toJson(), + if (escalatedAt != null) 'escalatedAt': escalatedAt?.toJson(), + 'overdue': overdue, + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _AdminAlertImpl extends AdminAlert { + _AdminAlertImpl({ + required String id, + required String patientLabel, + required String microAreaName, + required _i2.RiskLevel riskLevel, + required _i3.AlertStatus status, + required DateTime triggeredAt, + DateTime? escalatedAt, + required bool overdue, + }) : super._( + id: id, + patientLabel: patientLabel, + microAreaName: microAreaName, + riskLevel: riskLevel, + status: status, + triggeredAt: triggeredAt, + escalatedAt: escalatedAt, + overdue: overdue, + ); + + /// Returns a shallow copy of this [AdminAlert] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + AdminAlert copyWith({ + String? id, + String? patientLabel, + String? microAreaName, + _i2.RiskLevel? riskLevel, + _i3.AlertStatus? status, + DateTime? triggeredAt, + Object? escalatedAt = _Undefined, + bool? overdue, + }) { + return AdminAlert( + id: id ?? this.id, + patientLabel: patientLabel ?? this.patientLabel, + microAreaName: microAreaName ?? this.microAreaName, + riskLevel: riskLevel ?? this.riskLevel, + status: status ?? this.status, + triggeredAt: triggeredAt ?? this.triggeredAt, + escalatedAt: escalatedAt is DateTime? ? escalatedAt : this.escalatedAt, + overdue: overdue ?? this.overdue, + ); + } +} diff --git a/backend/sinalacs_server/lib/src/generated/api/admin_alert_page.dart b/backend/sinalacs_server/lib/src/generated/api/admin_alert_page.dart new file mode 100644 index 00000000..bd20e2bb --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/api/admin_alert_page.dart @@ -0,0 +1,99 @@ +/* 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 '../api/admin_alert.dart' as _i2; +import 'package:sinalacs_server/src/generated/protocol.dart' as _i3; + +/// Página de alertas. `nextOffset` nulo = não há mais páginas. +abstract class AdminAlertPage + implements _i1.SerializableModel, _i1.ProtocolSerialization { + AdminAlertPage._({ + required this.items, + this.nextOffset, + }); + + factory AdminAlertPage({ + required List<_i2.AdminAlert> items, + int? nextOffset, + }) = _AdminAlertPageImpl; + + factory AdminAlertPage.fromJson(Map jsonSerialization) { + return AdminAlertPage( + items: _i3.Protocol().deserialize>( + jsonSerialization['items'], + ), + nextOffset: jsonSerialization['nextOffset'] as int?, + ); + } + + List<_i2.AdminAlert> items; + + int? nextOffset; + + /// Returns a shallow copy of this [AdminAlertPage] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + AdminAlertPage copyWith({ + List<_i2.AdminAlert>? items, + int? nextOffset, + }); + @override + Map toJson() { + return { + '__className__': 'AdminAlertPage', + 'items': items.toJson(valueToJson: (v) => v.toJson()), + if (nextOffset != null) 'nextOffset': nextOffset, + }; + } + + @override + Map toJsonForProtocol() { + return { + '__className__': 'AdminAlertPage', + 'items': items.toJson(valueToJson: (v) => v.toJsonForProtocol()), + if (nextOffset != null) 'nextOffset': nextOffset, + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _AdminAlertPageImpl extends AdminAlertPage { + _AdminAlertPageImpl({ + required List<_i2.AdminAlert> items, + int? nextOffset, + }) : super._( + items: items, + nextOffset: nextOffset, + ); + + /// Returns a shallow copy of this [AdminAlertPage] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + AdminAlertPage copyWith({ + List<_i2.AdminAlert>? items, + Object? nextOffset = _Undefined, + }) { + return AdminAlertPage( + items: items ?? this.items.map((e0) => e0.copyWith()).toList(), + nextOffset: nextOffset is int? ? nextOffset : this.nextOffset, + ); + } +} diff --git a/backend/sinalacs_server/lib/src/generated/api/admin_alerts_pulse.dart b/backend/sinalacs_server/lib/src/generated/api/admin_alerts_pulse.dart new file mode 100644 index 00000000..73d45858 --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/api/admin_alerts_pulse.dart @@ -0,0 +1,137 @@ +/* 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 '../api/admin_indicators.dart' as _i2; +import 'package:sinalacs_server/src/generated/protocol.dart' as _i3; + +/// Pulso da monitoração do backoffice (issue #44, D5): o que o painel consulta +/// a cada 30 s. Sem identidade nenhuma — nem o rótulo do paciente: só contagens +/// e instantes. O dashboard se atualiza por aqui (`indicators` são os mesmos de +/// `admin.indicators`, no mesmo escopo); a lista só é relida por `admin.alerts` +/// quando `maxChangedAt` muda. `overdueRed` segue a regra de `AdminAlert.overdue`. +/// `maxChangedAt` é nulo quando o escopo não tem alerta; `serverNow` é o +/// relógio do servidor, nunca o do aparelho. +abstract class AdminAlertsPulse + implements _i1.SerializableModel, _i1.ProtocolSerialization { + AdminAlertsPulse._({ + required this.indicators, + required this.overdueRed, + this.maxChangedAt, + required this.serverNow, + }); + + factory AdminAlertsPulse({ + required _i2.AdminIndicators indicators, + required int overdueRed, + DateTime? maxChangedAt, + required DateTime serverNow, + }) = _AdminAlertsPulseImpl; + + factory AdminAlertsPulse.fromJson(Map jsonSerialization) { + return AdminAlertsPulse( + indicators: _i3.Protocol().deserialize<_i2.AdminIndicators>( + jsonSerialization['indicators'], + ), + overdueRed: jsonSerialization['overdueRed'] as int, + maxChangedAt: jsonSerialization['maxChangedAt'] == null + ? null + : _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['maxChangedAt'], + ), + serverNow: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['serverNow'], + ), + ); + } + + _i2.AdminIndicators indicators; + + int overdueRed; + + DateTime? maxChangedAt; + + DateTime serverNow; + + /// Returns a shallow copy of this [AdminAlertsPulse] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + AdminAlertsPulse copyWith({ + _i2.AdminIndicators? indicators, + int? overdueRed, + DateTime? maxChangedAt, + DateTime? serverNow, + }); + @override + Map toJson() { + return { + '__className__': 'AdminAlertsPulse', + 'indicators': indicators.toJson(), + 'overdueRed': overdueRed, + if (maxChangedAt != null) 'maxChangedAt': maxChangedAt?.toJson(), + 'serverNow': serverNow.toJson(), + }; + } + + @override + Map toJsonForProtocol() { + return { + '__className__': 'AdminAlertsPulse', + 'indicators': indicators.toJsonForProtocol(), + 'overdueRed': overdueRed, + if (maxChangedAt != null) 'maxChangedAt': maxChangedAt?.toJson(), + 'serverNow': serverNow.toJson(), + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _AdminAlertsPulseImpl extends AdminAlertsPulse { + _AdminAlertsPulseImpl({ + required _i2.AdminIndicators indicators, + required int overdueRed, + DateTime? maxChangedAt, + required DateTime serverNow, + }) : super._( + indicators: indicators, + overdueRed: overdueRed, + maxChangedAt: maxChangedAt, + serverNow: serverNow, + ); + + /// Returns a shallow copy of this [AdminAlertsPulse] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + AdminAlertsPulse copyWith({ + _i2.AdminIndicators? indicators, + int? overdueRed, + Object? maxChangedAt = _Undefined, + DateTime? serverNow, + }) { + return AdminAlertsPulse( + indicators: indicators ?? this.indicators.copyWith(), + overdueRed: overdueRed ?? this.overdueRed, + maxChangedAt: maxChangedAt is DateTime? + ? maxChangedAt + : this.maxChangedAt, + serverNow: serverNow ?? this.serverNow, + ); + } +} diff --git a/backend/sinalacs_server/lib/src/generated/api/admin_audit_chain_status.dart b/backend/sinalacs_server/lib/src/generated/api/admin_audit_chain_status.dart new file mode 100644 index 00000000..814e21d3 --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/api/admin_audit_chain_status.dart @@ -0,0 +1,191 @@ +/* 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/audit_integrity_state.dart' as _i2; +import '../enums/audit_chain_reason.dart' as _i3; + +/// Resultado de `admin.verifyAuditChain` (#45, D3/D9). Nunca carrega hash, +/// segredo nem conteúdo de linha. +/// - `state`: só `verified` é verde; qualquer outro, nunca. +/// - `checkedRows`: linhas conferidas antes da quebra (ou o total). Só para o +/// administrador; nulo para o coordenador (não infere volume de outra UBS). +/// - `headSequence`: última `sequence` da cadeia íntegra. Só administrador e +/// só com `verified`. +/// - `firstBrokenSequence`: onde a cadeia quebrou. Para o coordenador, só se +/// a linha quebrada for de um autor da UBS dele; senão nulo. +/// - `brokenInScope`: com `broken`, se a quebra é visível no escopo de quem +/// chama (sempre `true` para o administrador); nulo nos outros estados ou se +/// o pertencimento não pôde ser apurado. +/// - `reasonCode`: motivo fechado; o texto livre do verificador não sai do +/// servidor. Para o coordenador, quebra fora do escopo vem sem motivo. +/// - `verifiedAt`: quando a verificação servida terminou (cache de 60 s). +abstract class AdminAuditChainStatus + implements _i1.SerializableModel, _i1.ProtocolSerialization { + AdminAuditChainStatus._({ + required this.state, + this.checkedRows, + this.headSequence, + this.firstBrokenSequence, + this.brokenInScope, + this.reasonCode, + required this.verifiedAt, + }); + + factory AdminAuditChainStatus({ + required _i2.AuditIntegrityState state, + int? checkedRows, + int? headSequence, + int? firstBrokenSequence, + bool? brokenInScope, + _i3.AuditChainReason? reasonCode, + required DateTime verifiedAt, + }) = _AdminAuditChainStatusImpl; + + factory AdminAuditChainStatus.fromJson( + Map jsonSerialization, + ) { + return AdminAuditChainStatus( + state: _i2.AuditIntegrityState.fromJson( + (jsonSerialization['state'] as String), + ), + checkedRows: jsonSerialization['checkedRows'] as int?, + headSequence: jsonSerialization['headSequence'] as int?, + firstBrokenSequence: jsonSerialization['firstBrokenSequence'] as int?, + brokenInScope: jsonSerialization['brokenInScope'] == null + ? null + : _i1.BoolJsonExtension.fromJson(jsonSerialization['brokenInScope']), + reasonCode: jsonSerialization['reasonCode'] == null + ? null + : _i3.AuditChainReason.fromJson( + (jsonSerialization['reasonCode'] as String), + ), + verifiedAt: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['verifiedAt'], + ), + ); + } + + _i2.AuditIntegrityState state; + + int? checkedRows; + + int? headSequence; + + int? firstBrokenSequence; + + bool? brokenInScope; + + _i3.AuditChainReason? reasonCode; + + DateTime verifiedAt; + + /// Returns a shallow copy of this [AdminAuditChainStatus] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + AdminAuditChainStatus copyWith({ + _i2.AuditIntegrityState? state, + int? checkedRows, + int? headSequence, + int? firstBrokenSequence, + bool? brokenInScope, + _i3.AuditChainReason? reasonCode, + DateTime? verifiedAt, + }); + @override + Map toJson() { + return { + '__className__': 'AdminAuditChainStatus', + 'state': state.toJson(), + if (checkedRows != null) 'checkedRows': checkedRows, + if (headSequence != null) 'headSequence': headSequence, + if (firstBrokenSequence != null) + 'firstBrokenSequence': firstBrokenSequence, + if (brokenInScope != null) 'brokenInScope': brokenInScope, + if (reasonCode != null) 'reasonCode': reasonCode?.toJson(), + 'verifiedAt': verifiedAt.toJson(), + }; + } + + @override + Map toJsonForProtocol() { + return { + '__className__': 'AdminAuditChainStatus', + 'state': state.toJson(), + if (checkedRows != null) 'checkedRows': checkedRows, + if (headSequence != null) 'headSequence': headSequence, + if (firstBrokenSequence != null) + 'firstBrokenSequence': firstBrokenSequence, + if (brokenInScope != null) 'brokenInScope': brokenInScope, + if (reasonCode != null) 'reasonCode': reasonCode?.toJson(), + 'verifiedAt': verifiedAt.toJson(), + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _AdminAuditChainStatusImpl extends AdminAuditChainStatus { + _AdminAuditChainStatusImpl({ + required _i2.AuditIntegrityState state, + int? checkedRows, + int? headSequence, + int? firstBrokenSequence, + bool? brokenInScope, + _i3.AuditChainReason? reasonCode, + required DateTime verifiedAt, + }) : super._( + state: state, + checkedRows: checkedRows, + headSequence: headSequence, + firstBrokenSequence: firstBrokenSequence, + brokenInScope: brokenInScope, + reasonCode: reasonCode, + verifiedAt: verifiedAt, + ); + + /// Returns a shallow copy of this [AdminAuditChainStatus] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + AdminAuditChainStatus copyWith({ + _i2.AuditIntegrityState? state, + Object? checkedRows = _Undefined, + Object? headSequence = _Undefined, + Object? firstBrokenSequence = _Undefined, + Object? brokenInScope = _Undefined, + Object? reasonCode = _Undefined, + DateTime? verifiedAt, + }) { + return AdminAuditChainStatus( + state: state ?? this.state, + checkedRows: checkedRows is int? ? checkedRows : this.checkedRows, + headSequence: headSequence is int? ? headSequence : this.headSequence, + firstBrokenSequence: firstBrokenSequence is int? + ? firstBrokenSequence + : this.firstBrokenSequence, + brokenInScope: brokenInScope is bool? + ? brokenInScope + : this.brokenInScope, + reasonCode: reasonCode is _i3.AuditChainReason? + ? reasonCode + : this.reasonCode, + verifiedAt: verifiedAt ?? this.verifiedAt, + ); + } +} diff --git a/backend/sinalacs_server/lib/src/generated/api/admin_audit_entry.dart b/backend/sinalacs_server/lib/src/generated/api/admin_audit_entry.dart new file mode 100644 index 00000000..8033b161 --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/api/admin_audit_entry.dart @@ -0,0 +1,175 @@ +/* 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/audit_row_seal.dart' as _i2; + +/// Linha de auditoria para o backoffice. Sem `ipHash`, `previousHash` nem +/// `entryHash`: a cadeia de hash é do servidor, não da tela. +abstract class AdminAuditEntry + implements _i1.SerializableModel, _i1.ProtocolSerialization { + AdminAuditEntry._({ + required this.id, + required this.sequence, + required this.userLabel, + required this.actionType, + required this.resourceType, + required this.timestamp, + required this.result, + _i2.AuditRowSeal? integrity, + }) : integrity = integrity ?? _i2.AuditRowSeal.unverifiable; + + factory AdminAuditEntry({ + required String id, + required int sequence, + required String userLabel, + required String actionType, + required String resourceType, + required DateTime timestamp, + required String result, + _i2.AuditRowSeal? integrity, + }) = _AdminAuditEntryImpl; + + factory AdminAuditEntry.fromJson(Map jsonSerialization) { + return AdminAuditEntry( + id: jsonSerialization['id'] as String, + sequence: jsonSerialization['sequence'] as int, + userLabel: jsonSerialization['userLabel'] as String, + actionType: jsonSerialization['actionType'] as String, + resourceType: jsonSerialization['resourceType'] as String, + timestamp: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['timestamp'], + ), + result: jsonSerialization['result'] as String, + integrity: jsonSerialization['integrity'] == null + ? null + : _i2.AuditRowSeal.fromJson( + (jsonSerialization['integrity'] as String), + ), + ); + } + + String id; + + int sequence; + + String userLabel; + + String actionType; + + String resourceType; + + DateTime timestamp; + + String result; + + /// Selo da linha (#45, D3), calculado no servidor. O padrão é + /// `unverifiable` — nunca `verified` por omissão. + _i2.AuditRowSeal integrity; + + /// Returns a shallow copy of this [AdminAuditEntry] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + AdminAuditEntry copyWith({ + String? id, + int? sequence, + String? userLabel, + String? actionType, + String? resourceType, + DateTime? timestamp, + String? result, + _i2.AuditRowSeal? integrity, + }); + @override + Map toJson() { + return { + '__className__': 'AdminAuditEntry', + 'id': id, + 'sequence': sequence, + 'userLabel': userLabel, + 'actionType': actionType, + 'resourceType': resourceType, + 'timestamp': timestamp.toJson(), + 'result': result, + 'integrity': integrity.toJson(), + }; + } + + @override + Map toJsonForProtocol() { + return { + '__className__': 'AdminAuditEntry', + 'id': id, + 'sequence': sequence, + 'userLabel': userLabel, + 'actionType': actionType, + 'resourceType': resourceType, + 'timestamp': timestamp.toJson(), + 'result': result, + 'integrity': integrity.toJson(), + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _AdminAuditEntryImpl extends AdminAuditEntry { + _AdminAuditEntryImpl({ + required String id, + required int sequence, + required String userLabel, + required String actionType, + required String resourceType, + required DateTime timestamp, + required String result, + _i2.AuditRowSeal? integrity, + }) : super._( + id: id, + sequence: sequence, + userLabel: userLabel, + actionType: actionType, + resourceType: resourceType, + timestamp: timestamp, + result: result, + integrity: integrity, + ); + + /// Returns a shallow copy of this [AdminAuditEntry] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + AdminAuditEntry copyWith({ + String? id, + int? sequence, + String? userLabel, + String? actionType, + String? resourceType, + DateTime? timestamp, + String? result, + _i2.AuditRowSeal? integrity, + }) { + return AdminAuditEntry( + id: id ?? this.id, + sequence: sequence ?? this.sequence, + userLabel: userLabel ?? this.userLabel, + actionType: actionType ?? this.actionType, + resourceType: resourceType ?? this.resourceType, + timestamp: timestamp ?? this.timestamp, + result: result ?? this.result, + integrity: integrity ?? this.integrity, + ); + } +} diff --git a/backend/sinalacs_server/lib/src/generated/api/admin_audit_export.dart b/backend/sinalacs_server/lib/src/generated/api/admin_audit_export.dart new file mode 100644 index 00000000..53fc1671 --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/api/admin_audit_export.dart @@ -0,0 +1,185 @@ +/* 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/audit_integrity_state.dart' as _i2; + +/// Resultado de `admin.exportAuditLogs` (#45, D5–D8). Só o administrador +/// exporta. O arquivo é o CSV de `AuditCsv` (colunas fixas de D6: nunca +/// `userId`, `resourceId`, `ipHash`, `previousHash`, `entryHash` nem segredo). +/// - `fileName`: `auditoria__.csv` em UTC compacto; sem dado pessoal. +/// - `csv`: o conteúdo (UTF-8, RFC 4180, CRLF), gerado em memória no servidor. +/// - `rowCount`, `firstSequence`, `lastSequence`: o recorte exportado (as duas +/// sequences são nulas com recorte vazio). O próprio evento `export` fica +/// sempre FORA do arquivo (`sequence` maior que `lastSequence`). +/// - `contentSha256`: SHA-256 (hex minúsculo) dos bytes UTF-8 de `csv`, o mesmo +/// gravado em `audit_exports`. +/// - `chainState`: estado da verificação global da cadeia no momento da +/// exportação (cache de 60 s); qualquer coisa diferente de `verified` é +/// registrada e devolvida como está. +/// - `generatedAt`: instante da exportação (UTC), o `requestedAt` gravado. +abstract class AdminAuditExport + implements _i1.SerializableModel, _i1.ProtocolSerialization { + AdminAuditExport._({ + required this.fileName, + required this.csv, + required this.rowCount, + this.firstSequence, + this.lastSequence, + required this.contentSha256, + required this.chainState, + required this.generatedAt, + }); + + factory AdminAuditExport({ + required String fileName, + required String csv, + required int rowCount, + int? firstSequence, + int? lastSequence, + required String contentSha256, + required _i2.AuditIntegrityState chainState, + required DateTime generatedAt, + }) = _AdminAuditExportImpl; + + factory AdminAuditExport.fromJson(Map jsonSerialization) { + return AdminAuditExport( + fileName: jsonSerialization['fileName'] as String, + csv: jsonSerialization['csv'] as String, + rowCount: jsonSerialization['rowCount'] as int, + firstSequence: jsonSerialization['firstSequence'] as int?, + lastSequence: jsonSerialization['lastSequence'] as int?, + contentSha256: jsonSerialization['contentSha256'] as String, + chainState: _i2.AuditIntegrityState.fromJson( + (jsonSerialization['chainState'] as String), + ), + generatedAt: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['generatedAt'], + ), + ); + } + + String fileName; + + String csv; + + int rowCount; + + int? firstSequence; + + int? lastSequence; + + String contentSha256; + + _i2.AuditIntegrityState chainState; + + DateTime generatedAt; + + /// Returns a shallow copy of this [AdminAuditExport] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + AdminAuditExport copyWith({ + String? fileName, + String? csv, + int? rowCount, + int? firstSequence, + int? lastSequence, + String? contentSha256, + _i2.AuditIntegrityState? chainState, + DateTime? generatedAt, + }); + @override + Map toJson() { + return { + '__className__': 'AdminAuditExport', + 'fileName': fileName, + 'csv': csv, + 'rowCount': rowCount, + if (firstSequence != null) 'firstSequence': firstSequence, + if (lastSequence != null) 'lastSequence': lastSequence, + 'contentSha256': contentSha256, + 'chainState': chainState.toJson(), + 'generatedAt': generatedAt.toJson(), + }; + } + + @override + Map toJsonForProtocol() { + return { + '__className__': 'AdminAuditExport', + 'fileName': fileName, + 'csv': csv, + 'rowCount': rowCount, + if (firstSequence != null) 'firstSequence': firstSequence, + if (lastSequence != null) 'lastSequence': lastSequence, + 'contentSha256': contentSha256, + 'chainState': chainState.toJson(), + 'generatedAt': generatedAt.toJson(), + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _AdminAuditExportImpl extends AdminAuditExport { + _AdminAuditExportImpl({ + required String fileName, + required String csv, + required int rowCount, + int? firstSequence, + int? lastSequence, + required String contentSha256, + required _i2.AuditIntegrityState chainState, + required DateTime generatedAt, + }) : super._( + fileName: fileName, + csv: csv, + rowCount: rowCount, + firstSequence: firstSequence, + lastSequence: lastSequence, + contentSha256: contentSha256, + chainState: chainState, + generatedAt: generatedAt, + ); + + /// Returns a shallow copy of this [AdminAuditExport] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + AdminAuditExport copyWith({ + String? fileName, + String? csv, + int? rowCount, + Object? firstSequence = _Undefined, + Object? lastSequence = _Undefined, + String? contentSha256, + _i2.AuditIntegrityState? chainState, + DateTime? generatedAt, + }) { + return AdminAuditExport( + fileName: fileName ?? this.fileName, + csv: csv ?? this.csv, + rowCount: rowCount ?? this.rowCount, + firstSequence: firstSequence is int? ? firstSequence : this.firstSequence, + lastSequence: lastSequence is int? ? lastSequence : this.lastSequence, + contentSha256: contentSha256 ?? this.contentSha256, + chainState: chainState ?? this.chainState, + generatedAt: generatedAt ?? this.generatedAt, + ); + } +} diff --git a/backend/sinalacs_server/lib/src/generated/api/admin_audit_filter.dart b/backend/sinalacs_server/lib/src/generated/api/admin_audit_filter.dart new file mode 100644 index 00000000..6acb5f11 --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/api/admin_audit_filter.dart @@ -0,0 +1,191 @@ +/* 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/user_role.dart' as _i2; + +/// Filtro da trilha de auditoria do backoffice (#45, D1/D2/D10). Todos os +/// campos são opcionais e combinados por AND; nulo = sem restrição. Nunca +/// alarga o escopo: o coordenador continua vendo só os autores da UBS dele. +/// +/// Validação no `AdminReadService` (antes de auditar a leitura): +/// - `actorEnrollmentId`: matrícula do autor (ACS ou staff), igualdade sem +/// caixa, 1 a 32 caracteres. Paciente não é filtrável por identificador. +/// - `actionType`: `read`, `write`, `login` ou `export`. +/// - `resourceType`, `result`: `^[a-z0-9_:]{1,64}$`, igualdade (o `:` e os +/// dígitos cobrem resultados como `escalated:no_ack_timeout`, da #44). +/// - `from`/`to`: UTC; `from` inclusivo, `to` exclusivo, `from < to` e janela +/// de no máximo 366 dias quando os dois vêm. +/// - `search`: 2 a 64 caracteres; casa só o início da matrícula do autor, +/// `resourceType` e `result` (sem caixa, curingas escapados). Nunca hash, +/// IP, UUID ou nome. +/// Não há filtro por `resourceId` (D10: pode ser id de paciente). +abstract class AdminAuditFilter + implements _i1.SerializableModel, _i1.ProtocolSerialization { + AdminAuditFilter._({ + this.actorEnrollmentId, + this.actorRole, + this.actionType, + this.resourceType, + this.result, + this.from, + this.to, + this.search, + }); + + factory AdminAuditFilter({ + String? actorEnrollmentId, + _i2.UserRole? actorRole, + String? actionType, + String? resourceType, + String? result, + DateTime? from, + DateTime? to, + String? search, + }) = _AdminAuditFilterImpl; + + factory AdminAuditFilter.fromJson(Map jsonSerialization) { + return AdminAuditFilter( + actorEnrollmentId: jsonSerialization['actorEnrollmentId'] as String?, + actorRole: jsonSerialization['actorRole'] == null + ? null + : _i2.UserRole.fromJson((jsonSerialization['actorRole'] as String)), + actionType: jsonSerialization['actionType'] as String?, + resourceType: jsonSerialization['resourceType'] as String?, + result: jsonSerialization['result'] as String?, + from: jsonSerialization['from'] == null + ? null + : _i1.DateTimeJsonExtension.fromJson(jsonSerialization['from']), + to: jsonSerialization['to'] == null + ? null + : _i1.DateTimeJsonExtension.fromJson(jsonSerialization['to']), + search: jsonSerialization['search'] as String?, + ); + } + + String? actorEnrollmentId; + + _i2.UserRole? actorRole; + + String? actionType; + + String? resourceType; + + String? result; + + DateTime? from; + + DateTime? to; + + String? search; + + /// Returns a shallow copy of this [AdminAuditFilter] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + AdminAuditFilter copyWith({ + String? actorEnrollmentId, + _i2.UserRole? actorRole, + String? actionType, + String? resourceType, + String? result, + DateTime? from, + DateTime? to, + String? search, + }); + @override + Map toJson() { + return { + '__className__': 'AdminAuditFilter', + if (actorEnrollmentId != null) 'actorEnrollmentId': actorEnrollmentId, + if (actorRole != null) 'actorRole': actorRole?.toJson(), + if (actionType != null) 'actionType': actionType, + if (resourceType != null) 'resourceType': resourceType, + if (result != null) 'result': result, + if (from != null) 'from': from?.toJson(), + if (to != null) 'to': to?.toJson(), + if (search != null) 'search': search, + }; + } + + @override + Map toJsonForProtocol() { + return { + '__className__': 'AdminAuditFilter', + if (actorEnrollmentId != null) 'actorEnrollmentId': actorEnrollmentId, + if (actorRole != null) 'actorRole': actorRole?.toJson(), + if (actionType != null) 'actionType': actionType, + if (resourceType != null) 'resourceType': resourceType, + if (result != null) 'result': result, + if (from != null) 'from': from?.toJson(), + if (to != null) 'to': to?.toJson(), + if (search != null) 'search': search, + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _AdminAuditFilterImpl extends AdminAuditFilter { + _AdminAuditFilterImpl({ + String? actorEnrollmentId, + _i2.UserRole? actorRole, + String? actionType, + String? resourceType, + String? result, + DateTime? from, + DateTime? to, + String? search, + }) : super._( + actorEnrollmentId: actorEnrollmentId, + actorRole: actorRole, + actionType: actionType, + resourceType: resourceType, + result: result, + from: from, + to: to, + search: search, + ); + + /// Returns a shallow copy of this [AdminAuditFilter] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + AdminAuditFilter copyWith({ + Object? actorEnrollmentId = _Undefined, + Object? actorRole = _Undefined, + Object? actionType = _Undefined, + Object? resourceType = _Undefined, + Object? result = _Undefined, + Object? from = _Undefined, + Object? to = _Undefined, + Object? search = _Undefined, + }) { + return AdminAuditFilter( + actorEnrollmentId: actorEnrollmentId is String? + ? actorEnrollmentId + : this.actorEnrollmentId, + actorRole: actorRole is _i2.UserRole? ? actorRole : this.actorRole, + actionType: actionType is String? ? actionType : this.actionType, + resourceType: resourceType is String? ? resourceType : this.resourceType, + result: result is String? ? result : this.result, + from: from is DateTime? ? from : this.from, + to: to is DateTime? ? to : this.to, + search: search is String? ? search : this.search, + ); + } +} diff --git a/backend/sinalacs_server/lib/src/generated/api/admin_audit_page.dart b/backend/sinalacs_server/lib/src/generated/api/admin_audit_page.dart new file mode 100644 index 00000000..71aeda80 --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/api/admin_audit_page.dart @@ -0,0 +1,102 @@ +/* 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 '../api/admin_audit_entry.dart' as _i2; +import 'package:sinalacs_server/src/generated/protocol.dart' as _i3; + +/// Página de auditoria por keyset: `nextBeforeSequence` é o `sequence` da última +/// linha devolvida (o log só cresce, então `offset` repetiria linhas). +abstract class AdminAuditPage + implements _i1.SerializableModel, _i1.ProtocolSerialization { + AdminAuditPage._({ + required this.items, + this.nextBeforeSequence, + }); + + factory AdminAuditPage({ + required List<_i2.AdminAuditEntry> items, + int? nextBeforeSequence, + }) = _AdminAuditPageImpl; + + factory AdminAuditPage.fromJson(Map jsonSerialization) { + return AdminAuditPage( + items: _i3.Protocol().deserialize>( + jsonSerialization['items'], + ), + nextBeforeSequence: jsonSerialization['nextBeforeSequence'] as int?, + ); + } + + List<_i2.AdminAuditEntry> items; + + int? nextBeforeSequence; + + /// Returns a shallow copy of this [AdminAuditPage] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + AdminAuditPage copyWith({ + List<_i2.AdminAuditEntry>? items, + int? nextBeforeSequence, + }); + @override + Map toJson() { + return { + '__className__': 'AdminAuditPage', + 'items': items.toJson(valueToJson: (v) => v.toJson()), + if (nextBeforeSequence != null) 'nextBeforeSequence': nextBeforeSequence, + }; + } + + @override + Map toJsonForProtocol() { + return { + '__className__': 'AdminAuditPage', + 'items': items.toJson(valueToJson: (v) => v.toJsonForProtocol()), + if (nextBeforeSequence != null) 'nextBeforeSequence': nextBeforeSequence, + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _AdminAuditPageImpl extends AdminAuditPage { + _AdminAuditPageImpl({ + required List<_i2.AdminAuditEntry> items, + int? nextBeforeSequence, + }) : super._( + items: items, + nextBeforeSequence: nextBeforeSequence, + ); + + /// Returns a shallow copy of this [AdminAuditPage] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + AdminAuditPage copyWith({ + List<_i2.AdminAuditEntry>? items, + Object? nextBeforeSequence = _Undefined, + }) { + return AdminAuditPage( + items: items ?? this.items.map((e0) => e0.copyWith()).toList(), + nextBeforeSequence: nextBeforeSequence is int? + ? nextBeforeSequence + : this.nextBeforeSequence, + ); + } +} diff --git a/backend/sinalacs_server/lib/src/generated/api/admin_data_subject_request.dart b/backend/sinalacs_server/lib/src/generated/api/admin_data_subject_request.dart new file mode 100644 index 00000000..0e2d7ac2 --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/api/admin_data_subject_request.dart @@ -0,0 +1,165 @@ +/* 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/data_subject_request_type.dart' as _i2; +import '../enums/data_subject_request_status.dart' as _i3; + +/// Linha da lista de pedidos do titular no backoffice. Sem nome nem CPF: +/// `patientLabel` é um rótulo curto (`#A18F`) derivado do id do titular. +abstract class AdminDataSubjectRequest + implements _i1.SerializableModel, _i1.ProtocolSerialization { + AdminDataSubjectRequest._({ + required this.id, + required this.type, + required this.status, + required this.createdAt, + required this.dueAt, + required this.overdue, + required this.patientLabel, + }); + + factory AdminDataSubjectRequest({ + required String id, + required _i2.DataSubjectRequestType type, + required _i3.DataSubjectRequestStatus status, + required DateTime createdAt, + required DateTime dueAt, + required bool overdue, + required String patientLabel, + }) = _AdminDataSubjectRequestImpl; + + factory AdminDataSubjectRequest.fromJson( + Map jsonSerialization, + ) { + return AdminDataSubjectRequest( + id: jsonSerialization['id'] as String, + type: _i2.DataSubjectRequestType.fromJson( + (jsonSerialization['type'] as String), + ), + status: _i3.DataSubjectRequestStatus.fromJson( + (jsonSerialization['status'] as String), + ), + createdAt: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['createdAt'], + ), + dueAt: _i1.DateTimeJsonExtension.fromJson(jsonSerialization['dueAt']), + overdue: _i1.BoolJsonExtension.fromJson(jsonSerialization['overdue']), + patientLabel: jsonSerialization['patientLabel'] as String, + ); + } + + String id; + + _i2.DataSubjectRequestType type; + + _i3.DataSubjectRequestStatus status; + + DateTime createdAt; + + DateTime dueAt; + + /// Vencido: status `open`/`inReview` e agora > `dueAt`. + bool overdue; + + String patientLabel; + + /// Returns a shallow copy of this [AdminDataSubjectRequest] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + AdminDataSubjectRequest copyWith({ + String? id, + _i2.DataSubjectRequestType? type, + _i3.DataSubjectRequestStatus? status, + DateTime? createdAt, + DateTime? dueAt, + bool? overdue, + String? patientLabel, + }); + @override + Map toJson() { + return { + '__className__': 'AdminDataSubjectRequest', + 'id': id, + 'type': type.toJson(), + 'status': status.toJson(), + 'createdAt': createdAt.toJson(), + 'dueAt': dueAt.toJson(), + 'overdue': overdue, + 'patientLabel': patientLabel, + }; + } + + @override + Map toJsonForProtocol() { + return { + '__className__': 'AdminDataSubjectRequest', + 'id': id, + 'type': type.toJson(), + 'status': status.toJson(), + 'createdAt': createdAt.toJson(), + 'dueAt': dueAt.toJson(), + 'overdue': overdue, + 'patientLabel': patientLabel, + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _AdminDataSubjectRequestImpl extends AdminDataSubjectRequest { + _AdminDataSubjectRequestImpl({ + required String id, + required _i2.DataSubjectRequestType type, + required _i3.DataSubjectRequestStatus status, + required DateTime createdAt, + required DateTime dueAt, + required bool overdue, + required String patientLabel, + }) : super._( + id: id, + type: type, + status: status, + createdAt: createdAt, + dueAt: dueAt, + overdue: overdue, + patientLabel: patientLabel, + ); + + /// Returns a shallow copy of this [AdminDataSubjectRequest] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + AdminDataSubjectRequest copyWith({ + String? id, + _i2.DataSubjectRequestType? type, + _i3.DataSubjectRequestStatus? status, + DateTime? createdAt, + DateTime? dueAt, + bool? overdue, + String? patientLabel, + }) { + return AdminDataSubjectRequest( + id: id ?? this.id, + type: type ?? this.type, + status: status ?? this.status, + createdAt: createdAt ?? this.createdAt, + dueAt: dueAt ?? this.dueAt, + overdue: overdue ?? this.overdue, + patientLabel: patientLabel ?? this.patientLabel, + ); + } +} diff --git a/backend/sinalacs_server/lib/src/generated/api/admin_data_subject_request_detail.dart b/backend/sinalacs_server/lib/src/generated/api/admin_data_subject_request_detail.dart new file mode 100644 index 00000000..15e8d40b --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/api/admin_data_subject_request_detail.dart @@ -0,0 +1,204 @@ +/* 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/data_subject_request_type.dart' as _i2; +import '../enums/data_subject_request_status.dart' as _i3; + +/// Detalhe de um pedido do titular. `details` e `resolution` são decifrados +/// só aqui (nunca na lista) e a leitura é auditada antes do dado. +abstract class AdminDataSubjectRequestDetail + implements _i1.SerializableModel, _i1.ProtocolSerialization { + AdminDataSubjectRequestDetail._({ + required this.id, + required this.type, + required this.status, + required this.createdAt, + required this.dueAt, + required this.overdue, + required this.patientLabel, + this.details, + this.resolution, + this.decidedAt, + }); + + factory AdminDataSubjectRequestDetail({ + required String id, + required _i2.DataSubjectRequestType type, + required _i3.DataSubjectRequestStatus status, + required DateTime createdAt, + required DateTime dueAt, + required bool overdue, + required String patientLabel, + String? details, + String? resolution, + DateTime? decidedAt, + }) = _AdminDataSubjectRequestDetailImpl; + + factory AdminDataSubjectRequestDetail.fromJson( + Map jsonSerialization, + ) { + return AdminDataSubjectRequestDetail( + id: jsonSerialization['id'] as String, + type: _i2.DataSubjectRequestType.fromJson( + (jsonSerialization['type'] as String), + ), + status: _i3.DataSubjectRequestStatus.fromJson( + (jsonSerialization['status'] as String), + ), + createdAt: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['createdAt'], + ), + dueAt: _i1.DateTimeJsonExtension.fromJson(jsonSerialization['dueAt']), + overdue: _i1.BoolJsonExtension.fromJson(jsonSerialization['overdue']), + patientLabel: jsonSerialization['patientLabel'] as String, + details: jsonSerialization['details'] as String?, + resolution: jsonSerialization['resolution'] as String?, + decidedAt: jsonSerialization['decidedAt'] == null + ? null + : _i1.DateTimeJsonExtension.fromJson(jsonSerialization['decidedAt']), + ); + } + + String id; + + _i2.DataSubjectRequestType type; + + _i3.DataSubjectRequestStatus status; + + DateTime createdAt; + + DateTime dueAt; + + bool overdue; + + String patientLabel; + + String? details; + + String? resolution; + + DateTime? decidedAt; + + /// Returns a shallow copy of this [AdminDataSubjectRequestDetail] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + AdminDataSubjectRequestDetail copyWith({ + String? id, + _i2.DataSubjectRequestType? type, + _i3.DataSubjectRequestStatus? status, + DateTime? createdAt, + DateTime? dueAt, + bool? overdue, + String? patientLabel, + String? details, + String? resolution, + DateTime? decidedAt, + }); + @override + Map toJson() { + return { + '__className__': 'AdminDataSubjectRequestDetail', + 'id': id, + 'type': type.toJson(), + 'status': status.toJson(), + 'createdAt': createdAt.toJson(), + 'dueAt': dueAt.toJson(), + 'overdue': overdue, + 'patientLabel': patientLabel, + if (details != null) 'details': details, + if (resolution != null) 'resolution': resolution, + if (decidedAt != null) 'decidedAt': decidedAt?.toJson(), + }; + } + + @override + Map toJsonForProtocol() { + return { + '__className__': 'AdminDataSubjectRequestDetail', + 'id': id, + 'type': type.toJson(), + 'status': status.toJson(), + 'createdAt': createdAt.toJson(), + 'dueAt': dueAt.toJson(), + 'overdue': overdue, + 'patientLabel': patientLabel, + if (details != null) 'details': details, + if (resolution != null) 'resolution': resolution, + if (decidedAt != null) 'decidedAt': decidedAt?.toJson(), + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _AdminDataSubjectRequestDetailImpl extends AdminDataSubjectRequestDetail { + _AdminDataSubjectRequestDetailImpl({ + required String id, + required _i2.DataSubjectRequestType type, + required _i3.DataSubjectRequestStatus status, + required DateTime createdAt, + required DateTime dueAt, + required bool overdue, + required String patientLabel, + String? details, + String? resolution, + DateTime? decidedAt, + }) : super._( + id: id, + type: type, + status: status, + createdAt: createdAt, + dueAt: dueAt, + overdue: overdue, + patientLabel: patientLabel, + details: details, + resolution: resolution, + decidedAt: decidedAt, + ); + + /// Returns a shallow copy of this [AdminDataSubjectRequestDetail] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + AdminDataSubjectRequestDetail copyWith({ + String? id, + _i2.DataSubjectRequestType? type, + _i3.DataSubjectRequestStatus? status, + DateTime? createdAt, + DateTime? dueAt, + bool? overdue, + String? patientLabel, + Object? details = _Undefined, + Object? resolution = _Undefined, + Object? decidedAt = _Undefined, + }) { + return AdminDataSubjectRequestDetail( + id: id ?? this.id, + type: type ?? this.type, + status: status ?? this.status, + createdAt: createdAt ?? this.createdAt, + dueAt: dueAt ?? this.dueAt, + overdue: overdue ?? this.overdue, + patientLabel: patientLabel ?? this.patientLabel, + details: details is String? ? details : this.details, + resolution: resolution is String? ? resolution : this.resolution, + decidedAt: decidedAt is DateTime? ? decidedAt : this.decidedAt, + ); + } +} diff --git a/backend/sinalacs_server/lib/src/generated/api/admin_data_subject_request_page.dart b/backend/sinalacs_server/lib/src/generated/api/admin_data_subject_request_page.dart new file mode 100644 index 00000000..8646ed44 --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/api/admin_data_subject_request_page.dart @@ -0,0 +1,101 @@ +/* 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 '../api/admin_data_subject_request.dart' as _i2; +import 'package:sinalacs_server/src/generated/protocol.dart' as _i3; + +/// Página de pedidos do titular. `nextOffset` nulo = não há mais páginas. +abstract class AdminDataSubjectRequestPage + implements _i1.SerializableModel, _i1.ProtocolSerialization { + AdminDataSubjectRequestPage._({ + required this.items, + this.nextOffset, + }); + + factory AdminDataSubjectRequestPage({ + required List<_i2.AdminDataSubjectRequest> items, + int? nextOffset, + }) = _AdminDataSubjectRequestPageImpl; + + factory AdminDataSubjectRequestPage.fromJson( + Map jsonSerialization, + ) { + return AdminDataSubjectRequestPage( + items: _i3.Protocol().deserialize>( + jsonSerialization['items'], + ), + nextOffset: jsonSerialization['nextOffset'] as int?, + ); + } + + List<_i2.AdminDataSubjectRequest> items; + + int? nextOffset; + + /// Returns a shallow copy of this [AdminDataSubjectRequestPage] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + AdminDataSubjectRequestPage copyWith({ + List<_i2.AdminDataSubjectRequest>? items, + int? nextOffset, + }); + @override + Map toJson() { + return { + '__className__': 'AdminDataSubjectRequestPage', + 'items': items.toJson(valueToJson: (v) => v.toJson()), + if (nextOffset != null) 'nextOffset': nextOffset, + }; + } + + @override + Map toJsonForProtocol() { + return { + '__className__': 'AdminDataSubjectRequestPage', + 'items': items.toJson(valueToJson: (v) => v.toJsonForProtocol()), + if (nextOffset != null) 'nextOffset': nextOffset, + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _AdminDataSubjectRequestPageImpl extends AdminDataSubjectRequestPage { + _AdminDataSubjectRequestPageImpl({ + required List<_i2.AdminDataSubjectRequest> items, + int? nextOffset, + }) : super._( + items: items, + nextOffset: nextOffset, + ); + + /// Returns a shallow copy of this [AdminDataSubjectRequestPage] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + AdminDataSubjectRequestPage copyWith({ + List<_i2.AdminDataSubjectRequest>? items, + Object? nextOffset = _Undefined, + }) { + return AdminDataSubjectRequestPage( + items: items ?? this.items.map((e0) => e0.copyWith()).toList(), + nextOffset: nextOffset is int? ? nextOffset : this.nextOffset, + ); + } +} diff --git a/backend/sinalacs_server/lib/src/generated/api/admin_escalation_result.dart b/backend/sinalacs_server/lib/src/generated/api/admin_escalation_result.dart new file mode 100644 index 00000000..afcb2937 --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/api/admin_escalation_result.dart @@ -0,0 +1,112 @@ +/* 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; + +/// Resultado de escalonar um alerta pelo backoffice. `alreadyEscalated` torna +/// a operação idempotente: repetir o pedido não duplica o escalonamento. +abstract class AdminEscalationResult + implements _i1.SerializableModel, _i1.ProtocolSerialization { + AdminEscalationResult._({ + required this.alertId, + required this.escalatedAt, + required this.alreadyEscalated, + }); + + factory AdminEscalationResult({ + required String alertId, + required DateTime escalatedAt, + required bool alreadyEscalated, + }) = _AdminEscalationResultImpl; + + factory AdminEscalationResult.fromJson( + Map jsonSerialization, + ) { + return AdminEscalationResult( + alertId: jsonSerialization['alertId'] as String, + escalatedAt: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['escalatedAt'], + ), + alreadyEscalated: _i1.BoolJsonExtension.fromJson( + jsonSerialization['alreadyEscalated'], + ), + ); + } + + String alertId; + + DateTime escalatedAt; + + bool alreadyEscalated; + + /// Returns a shallow copy of this [AdminEscalationResult] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + AdminEscalationResult copyWith({ + String? alertId, + DateTime? escalatedAt, + bool? alreadyEscalated, + }); + @override + Map toJson() { + return { + '__className__': 'AdminEscalationResult', + 'alertId': alertId, + 'escalatedAt': escalatedAt.toJson(), + 'alreadyEscalated': alreadyEscalated, + }; + } + + @override + Map toJsonForProtocol() { + return { + '__className__': 'AdminEscalationResult', + 'alertId': alertId, + 'escalatedAt': escalatedAt.toJson(), + 'alreadyEscalated': alreadyEscalated, + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _AdminEscalationResultImpl extends AdminEscalationResult { + _AdminEscalationResultImpl({ + required String alertId, + required DateTime escalatedAt, + required bool alreadyEscalated, + }) : super._( + alertId: alertId, + escalatedAt: escalatedAt, + alreadyEscalated: alreadyEscalated, + ); + + /// Returns a shallow copy of this [AdminEscalationResult] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + AdminEscalationResult copyWith({ + String? alertId, + DateTime? escalatedAt, + bool? alreadyEscalated, + }) { + return AdminEscalationResult( + alertId: alertId ?? this.alertId, + escalatedAt: escalatedAt ?? this.escalatedAt, + alreadyEscalated: alreadyEscalated ?? this.alreadyEscalated, + ); + } +} diff --git a/backend/sinalacs_server/lib/src/generated/api/admin_indicators.dart b/backend/sinalacs_server/lib/src/generated/api/admin_indicators.dart new file mode 100644 index 00000000..5e11fa30 --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/api/admin_indicators.dart @@ -0,0 +1,147 @@ +/* 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; + +/// Indicadores do painel do backoffice (issue #40). Contagens por risco e o +/// TMRAV, no escopo do chamador (sistema inteiro para o administrador, a UBS +/// para o coordenador). `tmravSeconds` é nulo quando não há alerta vermelho +/// reconhecido na janela: sem amostra não há média, e zero seria mentira. +abstract class AdminIndicators + implements _i1.SerializableModel, _i1.ProtocolSerialization { + AdminIndicators._({ + required this.red, + required this.yellow, + required this.green, + required this.openRedAlerts, + required this.acknowledgedRedAlerts, + this.tmravSeconds, + }); + + factory AdminIndicators({ + required int red, + required int yellow, + required int green, + required int openRedAlerts, + required int acknowledgedRedAlerts, + int? tmravSeconds, + }) = _AdminIndicatorsImpl; + + factory AdminIndicators.fromJson(Map jsonSerialization) { + return AdminIndicators( + red: jsonSerialization['red'] as int, + yellow: jsonSerialization['yellow'] as int, + green: jsonSerialization['green'] as int, + openRedAlerts: jsonSerialization['openRedAlerts'] as int, + acknowledgedRedAlerts: jsonSerialization['acknowledgedRedAlerts'] as int, + tmravSeconds: jsonSerialization['tmravSeconds'] as int?, + ); + } + + int red; + + int yellow; + + int green; + + int openRedAlerts; + + int acknowledgedRedAlerts; + + int? tmravSeconds; + + /// Returns a shallow copy of this [AdminIndicators] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + AdminIndicators copyWith({ + int? red, + int? yellow, + int? green, + int? openRedAlerts, + int? acknowledgedRedAlerts, + int? tmravSeconds, + }); + @override + Map toJson() { + return { + '__className__': 'AdminIndicators', + 'red': red, + 'yellow': yellow, + 'green': green, + 'openRedAlerts': openRedAlerts, + 'acknowledgedRedAlerts': acknowledgedRedAlerts, + if (tmravSeconds != null) 'tmravSeconds': tmravSeconds, + }; + } + + @override + Map toJsonForProtocol() { + return { + '__className__': 'AdminIndicators', + 'red': red, + 'yellow': yellow, + 'green': green, + 'openRedAlerts': openRedAlerts, + 'acknowledgedRedAlerts': acknowledgedRedAlerts, + if (tmravSeconds != null) 'tmravSeconds': tmravSeconds, + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _AdminIndicatorsImpl extends AdminIndicators { + _AdminIndicatorsImpl({ + required int red, + required int yellow, + required int green, + required int openRedAlerts, + required int acknowledgedRedAlerts, + int? tmravSeconds, + }) : super._( + red: red, + yellow: yellow, + green: green, + openRedAlerts: openRedAlerts, + acknowledgedRedAlerts: acknowledgedRedAlerts, + tmravSeconds: tmravSeconds, + ); + + /// Returns a shallow copy of this [AdminIndicators] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + AdminIndicators copyWith({ + int? red, + int? yellow, + int? green, + int? openRedAlerts, + int? acknowledgedRedAlerts, + Object? tmravSeconds = _Undefined, + }) { + return AdminIndicators( + red: red ?? this.red, + yellow: yellow ?? this.yellow, + green: green ?? this.green, + openRedAlerts: openRedAlerts ?? this.openRedAlerts, + acknowledgedRedAlerts: + acknowledgedRedAlerts ?? this.acknowledgedRedAlerts, + tmravSeconds: tmravSeconds is int? ? tmravSeconds : this.tmravSeconds, + ); + } +} diff --git a/backend/sinalacs_server/lib/src/generated/api/admin_micro_area.dart b/backend/sinalacs_server/lib/src/generated/api/admin_micro_area.dart new file mode 100644 index 00000000..c47458cd --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/api/admin_micro_area.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; + +/// Microárea e o ACS vinculado, para a listagem somente leitura do backoffice. +abstract class AdminMicroArea + implements _i1.SerializableModel, _i1.ProtocolSerialization { + AdminMicroArea._({ + required this.id, + required this.name, + required this.acsName, + required this.acsEnrollmentId, + required this.acsActive, + }); + + factory AdminMicroArea({ + required String id, + required String name, + required String acsName, + required String acsEnrollmentId, + required bool acsActive, + }) = _AdminMicroAreaImpl; + + factory AdminMicroArea.fromJson(Map jsonSerialization) { + return AdminMicroArea( + id: jsonSerialization['id'] as String, + name: jsonSerialization['name'] as String, + acsName: jsonSerialization['acsName'] as String, + acsEnrollmentId: jsonSerialization['acsEnrollmentId'] as String, + acsActive: _i1.BoolJsonExtension.fromJson(jsonSerialization['acsActive']), + ); + } + + String id; + + String name; + + String acsName; + + String acsEnrollmentId; + + bool acsActive; + + /// Returns a shallow copy of this [AdminMicroArea] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + AdminMicroArea copyWith({ + String? id, + String? name, + String? acsName, + String? acsEnrollmentId, + bool? acsActive, + }); + @override + Map toJson() { + return { + '__className__': 'AdminMicroArea', + 'id': id, + 'name': name, + 'acsName': acsName, + 'acsEnrollmentId': acsEnrollmentId, + 'acsActive': acsActive, + }; + } + + @override + Map toJsonForProtocol() { + return { + '__className__': 'AdminMicroArea', + 'id': id, + 'name': name, + 'acsName': acsName, + 'acsEnrollmentId': acsEnrollmentId, + 'acsActive': acsActive, + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _AdminMicroAreaImpl extends AdminMicroArea { + _AdminMicroAreaImpl({ + required String id, + required String name, + required String acsName, + required String acsEnrollmentId, + required bool acsActive, + }) : super._( + id: id, + name: name, + acsName: acsName, + acsEnrollmentId: acsEnrollmentId, + acsActive: acsActive, + ); + + /// Returns a shallow copy of this [AdminMicroArea] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + AdminMicroArea copyWith({ + String? id, + String? name, + String? acsName, + String? acsEnrollmentId, + bool? acsActive, + }) { + return AdminMicroArea( + id: id ?? this.id, + name: name ?? this.name, + acsName: acsName ?? this.acsName, + acsEnrollmentId: acsEnrollmentId ?? this.acsEnrollmentId, + acsActive: acsActive ?? this.acsActive, + ); + } +} diff --git a/backend/sinalacs_server/lib/src/generated/api/admin_password_reset_result.dart b/backend/sinalacs_server/lib/src/generated/api/admin_password_reset_result.dart new file mode 100644 index 00000000..19942338 --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/api/admin_password_reset_result.dart @@ -0,0 +1,80 @@ +/* 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; + +/// Senha nova do ACS redefinida pelo backoffice (issue #43). +/// +/// Existe **só** nesta resposta: não há coluna, log nem linha de auditoria que +/// a guarde — o servidor persiste apenas o hash Argon2id em `user_credentials`. +/// A senha é sorteada pelo servidor (o operador não a escolhe), pelo mesmo +/// motivo da senha inicial: uma senha escolhida por quem tem pressa vira a +/// credencial do RF07. Quem redefine mostra uma vez; perdida de novo, o caminho +/// é redefinir outra vez, nunca recuperar. +abstract class AdminPasswordResetResult + implements _i1.SerializableModel, _i1.ProtocolSerialization { + AdminPasswordResetResult._({required this.newPassword}); + + factory AdminPasswordResetResult({required String newPassword}) = + _AdminPasswordResetResultImpl; + + factory AdminPasswordResetResult.fromJson( + Map jsonSerialization, + ) { + return AdminPasswordResetResult( + newPassword: jsonSerialization['newPassword'] as String, + ); + } + + String newPassword; + + /// Returns a shallow copy of this [AdminPasswordResetResult] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + AdminPasswordResetResult copyWith({String? newPassword}); + @override + Map toJson() { + return { + '__className__': 'AdminPasswordResetResult', + 'newPassword': newPassword, + }; + } + + @override + Map toJsonForProtocol() { + return { + '__className__': 'AdminPasswordResetResult', + 'newPassword': newPassword, + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _AdminPasswordResetResultImpl extends AdminPasswordResetResult { + _AdminPasswordResetResultImpl({required String newPassword}) + : super._(newPassword: newPassword); + + /// Returns a shallow copy of this [AdminPasswordResetResult] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + AdminPasswordResetResult copyWith({String? newPassword}) { + return AdminPasswordResetResult( + newPassword: newPassword ?? this.newPassword, + ); + } +} diff --git a/backend/sinalacs_server/lib/src/generated/api/admin_staff.dart b/backend/sinalacs_server/lib/src/generated/api/admin_staff.dart new file mode 100644 index 00000000..b88fe056 --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/api/admin_staff.dart @@ -0,0 +1,161 @@ +/* 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/user_role.dart' as _i2; + +/// Conta de equipe do backoffice (#43): identificação profissional, papel, +/// UBS e estado de acesso. Nunca inclui CPF, senha ou contato. +/// +/// `ubsName` é nulo para o administrador (vê o sistema inteiro) e para o +/// coordenador ainda sem UBS — que é recusado em tudo até ter uma. +/// `mfaActive` tem a mesma leitura de `AdminAcs`: `totpEnabledAt` preenchido. +abstract class AdminStaff + implements _i1.SerializableModel, _i1.ProtocolSerialization { + AdminStaff._({ + required this.id, + required this.name, + required this.enrollmentId, + required this.role, + this.ubsName, + required this.active, + required this.mfaActive, + }); + + factory AdminStaff({ + required String id, + required String name, + required String enrollmentId, + required _i2.UserRole role, + String? ubsName, + required bool active, + required bool mfaActive, + }) = _AdminStaffImpl; + + factory AdminStaff.fromJson(Map jsonSerialization) { + return AdminStaff( + id: jsonSerialization['id'] as String, + name: jsonSerialization['name'] as String, + enrollmentId: jsonSerialization['enrollmentId'] as String, + role: _i2.UserRole.fromJson((jsonSerialization['role'] as String)), + ubsName: jsonSerialization['ubsName'] as String?, + active: _i1.BoolJsonExtension.fromJson(jsonSerialization['active']), + mfaActive: _i1.BoolJsonExtension.fromJson(jsonSerialization['mfaActive']), + ); + } + + String id; + + String name; + + String enrollmentId; + + _i2.UserRole role; + + String? ubsName; + + bool active; + + bool mfaActive; + + /// Returns a shallow copy of this [AdminStaff] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + AdminStaff copyWith({ + String? id, + String? name, + String? enrollmentId, + _i2.UserRole? role, + String? ubsName, + bool? active, + bool? mfaActive, + }); + @override + Map toJson() { + return { + '__className__': 'AdminStaff', + 'id': id, + 'name': name, + 'enrollmentId': enrollmentId, + 'role': role.toJson(), + if (ubsName != null) 'ubsName': ubsName, + 'active': active, + 'mfaActive': mfaActive, + }; + } + + @override + Map toJsonForProtocol() { + return { + '__className__': 'AdminStaff', + 'id': id, + 'name': name, + 'enrollmentId': enrollmentId, + 'role': role.toJson(), + if (ubsName != null) 'ubsName': ubsName, + 'active': active, + 'mfaActive': mfaActive, + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _AdminStaffImpl extends AdminStaff { + _AdminStaffImpl({ + required String id, + required String name, + required String enrollmentId, + required _i2.UserRole role, + String? ubsName, + required bool active, + required bool mfaActive, + }) : super._( + id: id, + name: name, + enrollmentId: enrollmentId, + role: role, + ubsName: ubsName, + active: active, + mfaActive: mfaActive, + ); + + /// Returns a shallow copy of this [AdminStaff] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + AdminStaff copyWith({ + String? id, + String? name, + String? enrollmentId, + _i2.UserRole? role, + Object? ubsName = _Undefined, + bool? active, + bool? mfaActive, + }) { + return AdminStaff( + id: id ?? this.id, + name: name ?? this.name, + enrollmentId: enrollmentId ?? this.enrollmentId, + role: role ?? this.role, + ubsName: ubsName is String? ? ubsName : this.ubsName, + active: active ?? this.active, + mfaActive: mfaActive ?? this.mfaActive, + ); + } +} diff --git a/backend/sinalacs_server/lib/src/generated/api/admin_staff_mfa_reset_result.dart b/backend/sinalacs_server/lib/src/generated/api/admin_staff_mfa_reset_result.dart new file mode 100644 index 00000000..1de24a5d --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/api/admin_staff_mfa_reset_result.dart @@ -0,0 +1,110 @@ +/* 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; + +/// Redefinição da MFA de uma conta de equipe pelo backoffice (issue #43), com +/// o código de ativação novo emitido **pela tela** — o deferimento da #48. +/// +/// Fecha a pendência registrada no `PROGRESS.md` (L665): até aqui só a CLI de +/// operador `bin/issue_staff_activation_code.dart` emitia, e a emissão não +/// entrava em `audit_logs`. Agora a operação faz as duas coisas numa transação +/// só — zera as quatro colunas `totp*` da conta e grava +/// `activationCodeHash/ExpiresAt/IssuedBy/IssuedAt` — e a linha +/// `admin_staff`/`mfa_reset` com o id do alvo fica na trilha. +/// +/// O código existe **só** nesta resposta: o servidor guarda apenas o SHA-256 +/// (`StaffActivationCode.hash`), como no `#48`. Quem redefine mostra uma vez; +/// perdido de novo, o caminho é redefinir outra vez, nunca recuperar. +abstract class AdminStaffMfaResetResult + implements _i1.SerializableModel, _i1.ProtocolSerialization { + AdminStaffMfaResetResult._({ + required this.activationCode, + required this.activationCodeExpiresAt, + }); + + factory AdminStaffMfaResetResult({ + required String activationCode, + required DateTime activationCodeExpiresAt, + }) = _AdminStaffMfaResetResultImpl; + + factory AdminStaffMfaResetResult.fromJson( + Map jsonSerialization, + ) { + return AdminStaffMfaResetResult( + activationCode: jsonSerialization['activationCode'] as String, + activationCodeExpiresAt: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['activationCodeExpiresAt'], + ), + ); + } + + String activationCode; + + DateTime activationCodeExpiresAt; + + /// Returns a shallow copy of this [AdminStaffMfaResetResult] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + AdminStaffMfaResetResult copyWith({ + String? activationCode, + DateTime? activationCodeExpiresAt, + }); + @override + Map toJson() { + return { + '__className__': 'AdminStaffMfaResetResult', + 'activationCode': activationCode, + 'activationCodeExpiresAt': activationCodeExpiresAt.toJson(), + }; + } + + @override + Map toJsonForProtocol() { + return { + '__className__': 'AdminStaffMfaResetResult', + 'activationCode': activationCode, + 'activationCodeExpiresAt': activationCodeExpiresAt.toJson(), + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _AdminStaffMfaResetResultImpl extends AdminStaffMfaResetResult { + _AdminStaffMfaResetResultImpl({ + required String activationCode, + required DateTime activationCodeExpiresAt, + }) : super._( + activationCode: activationCode, + activationCodeExpiresAt: activationCodeExpiresAt, + ); + + /// Returns a shallow copy of this [AdminStaffMfaResetResult] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + AdminStaffMfaResetResult copyWith({ + String? activationCode, + DateTime? activationCodeExpiresAt, + }) { + return AdminStaffMfaResetResult( + activationCode: activationCode ?? this.activationCode, + activationCodeExpiresAt: + activationCodeExpiresAt ?? this.activationCodeExpiresAt, + ); + } +} diff --git a/backend/sinalacs_server/lib/src/generated/api/development_login_result.dart b/backend/sinalacs_server/lib/src/generated/api/development_login_result.dart index e5834355..fc8e2b4f 100644 --- a/backend/sinalacs_server/lib/src/generated/api/development_login_result.dart +++ b/backend/sinalacs_server/lib/src/generated/api/development_login_result.dart @@ -19,11 +19,15 @@ abstract class DevelopmentLoginResult DevelopmentLoginResult._({ required this.accessToken, required this.tokenType, + this.refreshToken, + this.uploadToken, }); factory DevelopmentLoginResult({ required String accessToken, required String tokenType, + String? refreshToken, + String? uploadToken, }) = _DevelopmentLoginResultImpl; factory DevelopmentLoginResult.fromJson( @@ -32,6 +36,8 @@ abstract class DevelopmentLoginResult return DevelopmentLoginResult( accessToken: jsonSerialization['accessToken'] as String, tokenType: jsonSerialization['tokenType'] as String, + refreshToken: jsonSerialization['refreshToken'] as String?, + uploadToken: jsonSerialization['uploadToken'] as String?, ); } @@ -39,12 +45,24 @@ abstract class DevelopmentLoginResult String tokenType; + /// Só no `loginInstitutional` e no `refreshSession`. `null` nos demais + /// emissores (paciente, `developmentLogin`). + String? refreshToken; + + /// Token de envio diferido (D7): só no `loginInstitutional` com `deviceId` + /// real. Serve apenas a `visits.syncDeferred`/`visits.revokeUploadToken`. + /// `null` em todos os outros emissores (paciente, `developmentLogin`, + /// `refreshSession`). + String? uploadToken; + /// Returns a shallow copy of this [DevelopmentLoginResult] /// with some or all fields replaced by the given arguments. @_i1.useResult DevelopmentLoginResult copyWith({ String? accessToken, String? tokenType, + String? refreshToken, + String? uploadToken, }); @override Map toJson() { @@ -52,6 +70,8 @@ abstract class DevelopmentLoginResult '__className__': 'DevelopmentLoginResult', 'accessToken': accessToken, 'tokenType': tokenType, + if (refreshToken != null) 'refreshToken': refreshToken, + if (uploadToken != null) 'uploadToken': uploadToken, }; } @@ -61,6 +81,8 @@ abstract class DevelopmentLoginResult '__className__': 'DevelopmentLoginResult', 'accessToken': accessToken, 'tokenType': tokenType, + if (refreshToken != null) 'refreshToken': refreshToken, + if (uploadToken != null) 'uploadToken': uploadToken, }; } @@ -70,13 +92,19 @@ abstract class DevelopmentLoginResult } } +class _Undefined {} + class _DevelopmentLoginResultImpl extends DevelopmentLoginResult { _DevelopmentLoginResultImpl({ required String accessToken, required String tokenType, + String? refreshToken, + String? uploadToken, }) : super._( accessToken: accessToken, tokenType: tokenType, + refreshToken: refreshToken, + uploadToken: uploadToken, ); /// Returns a shallow copy of this [DevelopmentLoginResult] @@ -86,10 +114,14 @@ class _DevelopmentLoginResultImpl extends DevelopmentLoginResult { DevelopmentLoginResult copyWith({ String? accessToken, String? tokenType, + Object? refreshToken = _Undefined, + Object? uploadToken = _Undefined, }) { return DevelopmentLoginResult( accessToken: accessToken ?? this.accessToken, tokenType: tokenType ?? this.tokenType, + refreshToken: refreshToken is String? ? refreshToken : this.refreshToken, + uploadToken: uploadToken is String? ? uploadToken : this.uploadToken, ); } } diff --git a/backend/sinalacs_server/lib/src/generated/api/notice_history_entry.dart b/backend/sinalacs_server/lib/src/generated/api/notice_history_entry.dart new file mode 100644 index 00000000..ce0cb365 --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/api/notice_history_entry.dart @@ -0,0 +1,229 @@ +/* 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/scheduled_notice_status.dart' as _i2; + +/// Linha do histórico de avisos agendados (#46). Deliberadamente SEM o texto +/// da mensagem: o backoffice lista título, alvo e resultado. +/// +/// `createdByLabel` é a matrícula de quem agendou ("—" se a conta não tem). +/// `recipients`/`accepted` só existem depois do envio; `failureReason` só em +/// `failed`/`unknown`. +abstract class NoticeHistoryEntry + implements _i1.SerializableModel, _i1.ProtocolSerialization { + NoticeHistoryEntry._({ + required this.id, + required this.microAreaId, + required this.microAreaName, + required this.audience, + required this.title, + required this.status, + required this.scheduledFor, + this.sentAt, + this.recipients, + this.accepted, + this.failureReason, + required this.createdByLabel, + }); + + factory NoticeHistoryEntry({ + required String id, + required String microAreaId, + required String microAreaName, + required String audience, + required String title, + required _i2.ScheduledNoticeStatus status, + required DateTime scheduledFor, + DateTime? sentAt, + int? recipients, + int? accepted, + String? failureReason, + required String createdByLabel, + }) = _NoticeHistoryEntryImpl; + + factory NoticeHistoryEntry.fromJson(Map jsonSerialization) { + return NoticeHistoryEntry( + id: jsonSerialization['id'] as String, + microAreaId: jsonSerialization['microAreaId'] as String, + microAreaName: jsonSerialization['microAreaName'] as String, + audience: jsonSerialization['audience'] as String, + title: jsonSerialization['title'] as String, + status: _i2.ScheduledNoticeStatus.fromJson( + (jsonSerialization['status'] as String), + ), + scheduledFor: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['scheduledFor'], + ), + sentAt: jsonSerialization['sentAt'] == null + ? null + : _i1.DateTimeJsonExtension.fromJson(jsonSerialization['sentAt']), + recipients: jsonSerialization['recipients'] as int?, + accepted: jsonSerialization['accepted'] as int?, + failureReason: jsonSerialization['failureReason'] as String?, + createdByLabel: jsonSerialization['createdByLabel'] as String, + ); + } + + String id; + + String microAreaId; + + String microAreaName; + + String audience; + + String title; + + _i2.ScheduledNoticeStatus status; + + DateTime scheduledFor; + + DateTime? sentAt; + + int? recipients; + + int? accepted; + + String? failureReason; + + String createdByLabel; + + /// Returns a shallow copy of this [NoticeHistoryEntry] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + NoticeHistoryEntry copyWith({ + String? id, + String? microAreaId, + String? microAreaName, + String? audience, + String? title, + _i2.ScheduledNoticeStatus? status, + DateTime? scheduledFor, + DateTime? sentAt, + int? recipients, + int? accepted, + String? failureReason, + String? createdByLabel, + }); + @override + Map toJson() { + return { + '__className__': 'NoticeHistoryEntry', + 'id': id, + 'microAreaId': microAreaId, + 'microAreaName': microAreaName, + 'audience': audience, + 'title': title, + 'status': status.toJson(), + 'scheduledFor': scheduledFor.toJson(), + if (sentAt != null) 'sentAt': sentAt?.toJson(), + if (recipients != null) 'recipients': recipients, + if (accepted != null) 'accepted': accepted, + if (failureReason != null) 'failureReason': failureReason, + 'createdByLabel': createdByLabel, + }; + } + + @override + Map toJsonForProtocol() { + return { + '__className__': 'NoticeHistoryEntry', + 'id': id, + 'microAreaId': microAreaId, + 'microAreaName': microAreaName, + 'audience': audience, + 'title': title, + 'status': status.toJson(), + 'scheduledFor': scheduledFor.toJson(), + if (sentAt != null) 'sentAt': sentAt?.toJson(), + if (recipients != null) 'recipients': recipients, + if (accepted != null) 'accepted': accepted, + if (failureReason != null) 'failureReason': failureReason, + 'createdByLabel': createdByLabel, + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _NoticeHistoryEntryImpl extends NoticeHistoryEntry { + _NoticeHistoryEntryImpl({ + required String id, + required String microAreaId, + required String microAreaName, + required String audience, + required String title, + required _i2.ScheduledNoticeStatus status, + required DateTime scheduledFor, + DateTime? sentAt, + int? recipients, + int? accepted, + String? failureReason, + required String createdByLabel, + }) : super._( + id: id, + microAreaId: microAreaId, + microAreaName: microAreaName, + audience: audience, + title: title, + status: status, + scheduledFor: scheduledFor, + sentAt: sentAt, + recipients: recipients, + accepted: accepted, + failureReason: failureReason, + createdByLabel: createdByLabel, + ); + + /// Returns a shallow copy of this [NoticeHistoryEntry] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + NoticeHistoryEntry copyWith({ + String? id, + String? microAreaId, + String? microAreaName, + String? audience, + String? title, + _i2.ScheduledNoticeStatus? status, + DateTime? scheduledFor, + Object? sentAt = _Undefined, + Object? recipients = _Undefined, + Object? accepted = _Undefined, + Object? failureReason = _Undefined, + String? createdByLabel, + }) { + return NoticeHistoryEntry( + id: id ?? this.id, + microAreaId: microAreaId ?? this.microAreaId, + microAreaName: microAreaName ?? this.microAreaName, + audience: audience ?? this.audience, + title: title ?? this.title, + status: status ?? this.status, + scheduledFor: scheduledFor ?? this.scheduledFor, + sentAt: sentAt is DateTime? ? sentAt : this.sentAt, + recipients: recipients is int? ? recipients : this.recipients, + accepted: accepted is int? ? accepted : this.accepted, + failureReason: failureReason is String? + ? failureReason + : this.failureReason, + createdByLabel: createdByLabel ?? this.createdByLabel, + ); + } +} diff --git a/backend/sinalacs_server/lib/src/generated/api/notice_history_page.dart b/backend/sinalacs_server/lib/src/generated/api/notice_history_page.dart new file mode 100644 index 00000000..2218e312 --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/api/notice_history_page.dart @@ -0,0 +1,75 @@ +/* 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 '../api/notice_history_entry.dart' as _i2; +import 'package:sinalacs_server/src/generated/protocol.dart' as _i3; + +/// Página do histórico de avisos agendados (#46), mais recentes primeiro. +abstract class NoticeHistoryPage + implements _i1.SerializableModel, _i1.ProtocolSerialization { + NoticeHistoryPage._({required this.entries}); + + factory NoticeHistoryPage({required List<_i2.NoticeHistoryEntry> entries}) = + _NoticeHistoryPageImpl; + + factory NoticeHistoryPage.fromJson(Map jsonSerialization) { + return NoticeHistoryPage( + entries: _i3.Protocol().deserialize>( + jsonSerialization['entries'], + ), + ); + } + + List<_i2.NoticeHistoryEntry> entries; + + /// Returns a shallow copy of this [NoticeHistoryPage] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + NoticeHistoryPage copyWith({List<_i2.NoticeHistoryEntry>? entries}); + @override + Map toJson() { + return { + '__className__': 'NoticeHistoryPage', + 'entries': entries.toJson(valueToJson: (v) => v.toJson()), + }; + } + + @override + Map toJsonForProtocol() { + return { + '__className__': 'NoticeHistoryPage', + 'entries': entries.toJson(valueToJson: (v) => v.toJsonForProtocol()), + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _NoticeHistoryPageImpl extends NoticeHistoryPage { + _NoticeHistoryPageImpl({required List<_i2.NoticeHistoryEntry> entries}) + : super._(entries: entries); + + /// Returns a shallow copy of this [NoticeHistoryPage] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + NoticeHistoryPage copyWith({List<_i2.NoticeHistoryEntry>? entries}) { + return NoticeHistoryPage( + entries: entries ?? this.entries.map((e0) => e0.copyWith()).toList(), + ); + } +} diff --git a/backend/sinalacs_server/lib/src/generated/api/notice_send_result.dart b/backend/sinalacs_server/lib/src/generated/api/notice_send_result.dart new file mode 100644 index 00000000..eee2855e --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/api/notice_send_result.dart @@ -0,0 +1,95 @@ +/* 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; + +/// Resultado de `notices.sendSegmented` (RF14): quantos pacientes da microárea +/// consentiram em receber o aviso e quantas notificações o relé aceitou. +/// Só contagens — nunca a lista de destinatários. +abstract class NoticeSendResult + implements _i1.SerializableModel, _i1.ProtocolSerialization { + NoticeSendResult._({ + required this.recipients, + required this.accepted, + }); + + factory NoticeSendResult({ + required int recipients, + required int accepted, + }) = _NoticeSendResultImpl; + + factory NoticeSendResult.fromJson(Map jsonSerialization) { + return NoticeSendResult( + recipients: jsonSerialization['recipients'] as int, + accepted: jsonSerialization['accepted'] as int, + ); + } + + int recipients; + + int accepted; + + /// Returns a shallow copy of this [NoticeSendResult] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + NoticeSendResult copyWith({ + int? recipients, + int? accepted, + }); + @override + Map toJson() { + return { + '__className__': 'NoticeSendResult', + 'recipients': recipients, + 'accepted': accepted, + }; + } + + @override + Map toJsonForProtocol() { + return { + '__className__': 'NoticeSendResult', + 'recipients': recipients, + 'accepted': accepted, + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _NoticeSendResultImpl extends NoticeSendResult { + _NoticeSendResultImpl({ + required int recipients, + required int accepted, + }) : super._( + recipients: recipients, + accepted: accepted, + ); + + /// Returns a shallow copy of this [NoticeSendResult] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + NoticeSendResult copyWith({ + int? recipients, + int? accepted, + }) { + return NoticeSendResult( + recipients: recipients ?? this.recipients, + accepted: accepted ?? this.accepted, + ); + } +} diff --git a/backend/sinalacs_server/lib/src/generated/api/patient_data_overview.dart b/backend/sinalacs_server/lib/src/generated/api/patient_data_overview.dart index f98430f6..9742e17d 100644 --- a/backend/sinalacs_server/lib/src/generated/api/patient_data_overview.dart +++ b/backend/sinalacs_server/lib/src/generated/api/patient_data_overview.dart @@ -14,7 +14,8 @@ import 'package:serverpod/serverpod.dart' as _i1; import '../api/patient_consent_record.dart' as _i2; import '../api/patient_risk_event.dart' as _i3; -import 'package:sinalacs_server/src/generated/protocol.dart' as _i4; +import '../api/patient_data_subject_request_record.dart' as _i4; +import 'package:sinalacs_server/src/generated/protocol.dart' as _i5; /// Painel "Meus Dados" do próprio paciente autenticado — confirmação de /// existência de tratamento e acesso aos dados pessoais, critério de aceite @@ -32,6 +33,7 @@ abstract class PatientDataOverview required this.chronicConditions, required this.consents, required this.riskHistory, + required this.requests, }); factory PatientDataOverview({ @@ -42,6 +44,7 @@ abstract class PatientDataOverview required List chronicConditions, required List<_i2.PatientConsentRecord> consents, required List<_i3.PatientRiskEvent> riskHistory, + required List<_i4.PatientDataSubjectRequestRecord> requests, }) = _PatientDataOverviewImpl; factory PatientDataOverview.fromJson(Map jsonSerialization) { @@ -52,15 +55,19 @@ abstract class PatientDataOverview ), emergencyContact: jsonSerialization['emergencyContact'] as String, isChronic: _i1.BoolJsonExtension.fromJson(jsonSerialization['isChronic']), - chronicConditions: _i4.Protocol().deserialize>( + chronicConditions: _i5.Protocol().deserialize>( jsonSerialization['chronicConditions'], ), - consents: _i4.Protocol().deserialize>( + consents: _i5.Protocol().deserialize>( jsonSerialization['consents'], ), - riskHistory: _i4.Protocol().deserialize>( + riskHistory: _i5.Protocol().deserialize>( jsonSerialization['riskHistory'], ), + requests: _i5.Protocol() + .deserialize>( + jsonSerialization['requests'], + ), ); } @@ -78,6 +85,8 @@ abstract class PatientDataOverview List<_i3.PatientRiskEvent> riskHistory; + List<_i4.PatientDataSubjectRequestRecord> requests; + /// Returns a shallow copy of this [PatientDataOverview] /// with some or all fields replaced by the given arguments. @_i1.useResult @@ -89,6 +98,7 @@ abstract class PatientDataOverview List? chronicConditions, List<_i2.PatientConsentRecord>? consents, List<_i3.PatientRiskEvent>? riskHistory, + List<_i4.PatientDataSubjectRequestRecord>? requests, }); @override Map toJson() { @@ -101,6 +111,7 @@ abstract class PatientDataOverview 'chronicConditions': chronicConditions.toJson(), 'consents': consents.toJson(valueToJson: (v) => v.toJson()), 'riskHistory': riskHistory.toJson(valueToJson: (v) => v.toJson()), + 'requests': requests.toJson(valueToJson: (v) => v.toJson()), }; } @@ -117,6 +128,7 @@ abstract class PatientDataOverview 'riskHistory': riskHistory.toJson( valueToJson: (v) => v.toJsonForProtocol(), ), + 'requests': requests.toJson(valueToJson: (v) => v.toJsonForProtocol()), }; } @@ -135,6 +147,7 @@ class _PatientDataOverviewImpl extends PatientDataOverview { required List chronicConditions, required List<_i2.PatientConsentRecord> consents, required List<_i3.PatientRiskEvent> riskHistory, + required List<_i4.PatientDataSubjectRequestRecord> requests, }) : super._( name: name, birthDate: birthDate, @@ -143,6 +156,7 @@ class _PatientDataOverviewImpl extends PatientDataOverview { chronicConditions: chronicConditions, consents: consents, riskHistory: riskHistory, + requests: requests, ); /// Returns a shallow copy of this [PatientDataOverview] @@ -157,6 +171,7 @@ class _PatientDataOverviewImpl extends PatientDataOverview { List? chronicConditions, List<_i2.PatientConsentRecord>? consents, List<_i3.PatientRiskEvent>? riskHistory, + List<_i4.PatientDataSubjectRequestRecord>? requests, }) { return PatientDataOverview( name: name ?? this.name, @@ -168,6 +183,7 @@ class _PatientDataOverviewImpl extends PatientDataOverview { consents: consents ?? this.consents.map((e0) => e0.copyWith()).toList(), riskHistory: riskHistory ?? this.riskHistory.map((e0) => e0.copyWith()).toList(), + requests: requests ?? this.requests.map((e0) => e0.copyWith()).toList(), ); } } diff --git a/backend/sinalacs_server/lib/src/generated/api/patient_data_subject_request_record.dart b/backend/sinalacs_server/lib/src/generated/api/patient_data_subject_request_record.dart new file mode 100644 index 00000000..04eccc18 --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/api/patient_data_subject_request_record.dart @@ -0,0 +1,156 @@ +/* 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/data_subject_request_type.dart' as _i2; +import '../enums/data_subject_request_status.dart' as _i3; + +/// Um pedido do próprio titular, para o painel "Meus Dados". `details` já +/// decifrado — é texto que o próprio titular escreveu (direito de acesso). +abstract class PatientDataSubjectRequestRecord + implements _i1.SerializableModel, _i1.ProtocolSerialization { + PatientDataSubjectRequestRecord._({ + required this.type, + required this.status, + this.details, + required this.createdAt, + required this.dueAt, + this.resolution, + }); + + factory PatientDataSubjectRequestRecord({ + required _i2.DataSubjectRequestType type, + required _i3.DataSubjectRequestStatus status, + String? details, + required DateTime createdAt, + required DateTime dueAt, + String? resolution, + }) = _PatientDataSubjectRequestRecordImpl; + + factory PatientDataSubjectRequestRecord.fromJson( + Map jsonSerialization, + ) { + return PatientDataSubjectRequestRecord( + type: _i2.DataSubjectRequestType.fromJson( + (jsonSerialization['type'] as String), + ), + status: _i3.DataSubjectRequestStatus.fromJson( + (jsonSerialization['status'] as String), + ), + details: jsonSerialization['details'] as String?, + createdAt: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['createdAt'], + ), + dueAt: _i1.DateTimeJsonExtension.fromJson(jsonSerialization['dueAt']), + resolution: jsonSerialization['resolution'] as String?, + ); + } + + _i2.DataSubjectRequestType type; + + _i3.DataSubjectRequestStatus status; + + String? details; + + DateTime createdAt; + + DateTime dueAt; + + /// Nota de resposta do backoffice, já decifrada; nula até a decisão. + String? resolution; + + /// Returns a shallow copy of this [PatientDataSubjectRequestRecord] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + PatientDataSubjectRequestRecord copyWith({ + _i2.DataSubjectRequestType? type, + _i3.DataSubjectRequestStatus? status, + String? details, + DateTime? createdAt, + DateTime? dueAt, + String? resolution, + }); + @override + Map toJson() { + return { + '__className__': 'PatientDataSubjectRequestRecord', + 'type': type.toJson(), + 'status': status.toJson(), + if (details != null) 'details': details, + 'createdAt': createdAt.toJson(), + 'dueAt': dueAt.toJson(), + if (resolution != null) 'resolution': resolution, + }; + } + + @override + Map toJsonForProtocol() { + return { + '__className__': 'PatientDataSubjectRequestRecord', + 'type': type.toJson(), + 'status': status.toJson(), + if (details != null) 'details': details, + 'createdAt': createdAt.toJson(), + 'dueAt': dueAt.toJson(), + if (resolution != null) 'resolution': resolution, + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _PatientDataSubjectRequestRecordImpl + extends PatientDataSubjectRequestRecord { + _PatientDataSubjectRequestRecordImpl({ + required _i2.DataSubjectRequestType type, + required _i3.DataSubjectRequestStatus status, + String? details, + required DateTime createdAt, + required DateTime dueAt, + String? resolution, + }) : super._( + type: type, + status: status, + details: details, + createdAt: createdAt, + dueAt: dueAt, + resolution: resolution, + ); + + /// Returns a shallow copy of this [PatientDataSubjectRequestRecord] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + PatientDataSubjectRequestRecord copyWith({ + _i2.DataSubjectRequestType? type, + _i3.DataSubjectRequestStatus? status, + Object? details = _Undefined, + DateTime? createdAt, + DateTime? dueAt, + Object? resolution = _Undefined, + }) { + return PatientDataSubjectRequestRecord( + type: type ?? this.type, + status: status ?? this.status, + details: details is String? ? details : this.details, + createdAt: createdAt ?? this.createdAt, + dueAt: dueAt ?? this.dueAt, + resolution: resolution is String? ? resolution : this.resolution, + ); + } +} diff --git a/backend/sinalacs_server/lib/src/generated/api/terms_change_notice.dart b/backend/sinalacs_server/lib/src/generated/api/terms_change_notice.dart new file mode 100644 index 00000000..c756833f --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/api/terms_change_notice.dart @@ -0,0 +1,109 @@ +/* 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; + +/// Aviso de mudança dos termos (LGPD-RF18): a versão que passa a valer, a data +/// de vigência e um resumo do que muda. Publicado com pelo menos 15 dias de +/// antecedência (`TermsChangeSchedule`). Só texto público; nada do titular. +abstract class TermsChangeNotice + implements _i1.SerializableModel, _i1.ProtocolSerialization { + TermsChangeNotice._({ + required this.version, + required this.effectiveFrom, + required this.summary, + }); + + factory TermsChangeNotice({ + required String version, + required DateTime effectiveFrom, + required String summary, + }) = _TermsChangeNoticeImpl; + + factory TermsChangeNotice.fromJson(Map jsonSerialization) { + return TermsChangeNotice( + version: jsonSerialization['version'] as String, + effectiveFrom: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['effectiveFrom'], + ), + summary: jsonSerialization['summary'] as String, + ); + } + + String version; + + DateTime effectiveFrom; + + String summary; + + /// Returns a shallow copy of this [TermsChangeNotice] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + TermsChangeNotice copyWith({ + String? version, + DateTime? effectiveFrom, + String? summary, + }); + @override + Map toJson() { + return { + '__className__': 'TermsChangeNotice', + 'version': version, + 'effectiveFrom': effectiveFrom.toJson(), + 'summary': summary, + }; + } + + @override + Map toJsonForProtocol() { + return { + '__className__': 'TermsChangeNotice', + 'version': version, + 'effectiveFrom': effectiveFrom.toJson(), + 'summary': summary, + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _TermsChangeNoticeImpl extends TermsChangeNotice { + _TermsChangeNoticeImpl({ + required String version, + required DateTime effectiveFrom, + required String summary, + }) : super._( + version: version, + effectiveFrom: effectiveFrom, + summary: summary, + ); + + /// Returns a shallow copy of this [TermsChangeNotice] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + TermsChangeNotice copyWith({ + String? version, + DateTime? effectiveFrom, + String? summary, + }) { + return TermsChangeNotice( + version: version ?? this.version, + effectiveFrom: effectiveFrom ?? this.effectiveFrom, + summary: summary ?? this.summary, + ); + } +} diff --git a/backend/sinalacs_server/lib/src/generated/api/terms_version_entry.dart b/backend/sinalacs_server/lib/src/generated/api/terms_version_entry.dart new file mode 100644 index 00000000..0bca4182 --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/api/terms_version_entry.dart @@ -0,0 +1,172 @@ +/* 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; + +/// Versão dos termos de uso no backoffice (#46, LGPD-RF18). Sem os campos de +/// quem revisou/publicou: o admin vê quando, não a identidade. +abstract class TermsVersionEntry + implements _i1.SerializableModel, _i1.ProtocolSerialization { + TermsVersionEntry._({ + required this.id, + required this.version, + required this.summary, + required this.effectiveFrom, + required this.createdAt, + this.legallyReviewedAt, + this.publishedAt, + }); + + factory TermsVersionEntry({ + required String id, + required String version, + required String summary, + required DateTime effectiveFrom, + required DateTime createdAt, + DateTime? legallyReviewedAt, + DateTime? publishedAt, + }) = _TermsVersionEntryImpl; + + factory TermsVersionEntry.fromJson(Map jsonSerialization) { + return TermsVersionEntry( + id: jsonSerialization['id'] as String, + version: jsonSerialization['version'] as String, + summary: jsonSerialization['summary'] as String, + effectiveFrom: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['effectiveFrom'], + ), + createdAt: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['createdAt'], + ), + legallyReviewedAt: jsonSerialization['legallyReviewedAt'] == null + ? null + : _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['legallyReviewedAt'], + ), + publishedAt: jsonSerialization['publishedAt'] == null + ? null + : _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['publishedAt'], + ), + ); + } + + String id; + + String version; + + String summary; + + DateTime effectiveFrom; + + DateTime createdAt; + + DateTime? legallyReviewedAt; + + DateTime? publishedAt; + + /// Returns a shallow copy of this [TermsVersionEntry] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + TermsVersionEntry copyWith({ + String? id, + String? version, + String? summary, + DateTime? effectiveFrom, + DateTime? createdAt, + DateTime? legallyReviewedAt, + DateTime? publishedAt, + }); + @override + Map toJson() { + return { + '__className__': 'TermsVersionEntry', + 'id': id, + 'version': version, + 'summary': summary, + 'effectiveFrom': effectiveFrom.toJson(), + 'createdAt': createdAt.toJson(), + if (legallyReviewedAt != null) + 'legallyReviewedAt': legallyReviewedAt?.toJson(), + if (publishedAt != null) 'publishedAt': publishedAt?.toJson(), + }; + } + + @override + Map toJsonForProtocol() { + return { + '__className__': 'TermsVersionEntry', + 'id': id, + 'version': version, + 'summary': summary, + 'effectiveFrom': effectiveFrom.toJson(), + 'createdAt': createdAt.toJson(), + if (legallyReviewedAt != null) + 'legallyReviewedAt': legallyReviewedAt?.toJson(), + if (publishedAt != null) 'publishedAt': publishedAt?.toJson(), + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _TermsVersionEntryImpl extends TermsVersionEntry { + _TermsVersionEntryImpl({ + required String id, + required String version, + required String summary, + required DateTime effectiveFrom, + required DateTime createdAt, + DateTime? legallyReviewedAt, + DateTime? publishedAt, + }) : super._( + id: id, + version: version, + summary: summary, + effectiveFrom: effectiveFrom, + createdAt: createdAt, + legallyReviewedAt: legallyReviewedAt, + publishedAt: publishedAt, + ); + + /// Returns a shallow copy of this [TermsVersionEntry] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + TermsVersionEntry copyWith({ + String? id, + String? version, + String? summary, + DateTime? effectiveFrom, + DateTime? createdAt, + Object? legallyReviewedAt = _Undefined, + Object? publishedAt = _Undefined, + }) { + return TermsVersionEntry( + id: id ?? this.id, + version: version ?? this.version, + summary: summary ?? this.summary, + effectiveFrom: effectiveFrom ?? this.effectiveFrom, + createdAt: createdAt ?? this.createdAt, + legallyReviewedAt: legallyReviewedAt is DateTime? + ? legallyReviewedAt + : this.legallyReviewedAt, + publishedAt: publishedAt is DateTime? ? publishedAt : this.publishedAt, + ); + } +} diff --git a/backend/sinalacs_server/lib/src/generated/api/totp_enrollment_start.dart b/backend/sinalacs_server/lib/src/generated/api/totp_enrollment_start.dart new file mode 100644 index 00000000..ced900b8 --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/api/totp_enrollment_start.dart @@ -0,0 +1,94 @@ +/* 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; + +/// Início da ativação da MFA: o segredo para o autenticador (base32) e a URI +/// `otpauth://` que vira o QR. Volta só nesta resposta; nunca é gravado em claro. +abstract class TotpEnrollmentStart + implements _i1.SerializableModel, _i1.ProtocolSerialization { + TotpEnrollmentStart._({ + required this.secretBase32, + required this.otpauthUri, + }); + + factory TotpEnrollmentStart({ + required String secretBase32, + required String otpauthUri, + }) = _TotpEnrollmentStartImpl; + + factory TotpEnrollmentStart.fromJson(Map jsonSerialization) { + return TotpEnrollmentStart( + secretBase32: jsonSerialization['secretBase32'] as String, + otpauthUri: jsonSerialization['otpauthUri'] as String, + ); + } + + String secretBase32; + + String otpauthUri; + + /// Returns a shallow copy of this [TotpEnrollmentStart] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + TotpEnrollmentStart copyWith({ + String? secretBase32, + String? otpauthUri, + }); + @override + Map toJson() { + return { + '__className__': 'TotpEnrollmentStart', + 'secretBase32': secretBase32, + 'otpauthUri': otpauthUri, + }; + } + + @override + Map toJsonForProtocol() { + return { + '__className__': 'TotpEnrollmentStart', + 'secretBase32': secretBase32, + 'otpauthUri': otpauthUri, + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _TotpEnrollmentStartImpl extends TotpEnrollmentStart { + _TotpEnrollmentStartImpl({ + required String secretBase32, + required String otpauthUri, + }) : super._( + secretBase32: secretBase32, + otpauthUri: otpauthUri, + ); + + /// Returns a shallow copy of this [TotpEnrollmentStart] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + TotpEnrollmentStart copyWith({ + String? secretBase32, + String? otpauthUri, + }) { + return TotpEnrollmentStart( + secretBase32: secretBase32 ?? this.secretBase32, + otpauthUri: otpauthUri ?? this.otpauthUri, + ); + } +} diff --git a/backend/sinalacs_server/lib/src/generated/api/ubs_contact.dart b/backend/sinalacs_server/lib/src/generated/api/ubs_contact.dart new file mode 100644 index 00000000..327886fb --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/api/ubs_contact.dart @@ -0,0 +1,96 @@ +/* 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; + +/// Contato da UBS do ACS autenticado (RF13). Só o que o botão "Ligar para a UBS" +/// precisa: o nome para a mensagem e o telefone para o discador. +abstract class UbsContact + implements _i1.SerializableModel, _i1.ProtocolSerialization { + UbsContact._({ + required this.name, + this.phone, + }); + + factory UbsContact({ + required String name, + String? phone, + }) = _UbsContactImpl; + + factory UbsContact.fromJson(Map jsonSerialization) { + return UbsContact( + name: jsonSerialization['name'] as String, + phone: jsonSerialization['phone'] as String?, + ); + } + + String name; + + String? phone; + + /// Returns a shallow copy of this [UbsContact] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + UbsContact copyWith({ + String? name, + String? phone, + }); + @override + Map toJson() { + return { + '__className__': 'UbsContact', + 'name': name, + if (phone != null) 'phone': phone, + }; + } + + @override + Map toJsonForProtocol() { + return { + '__className__': 'UbsContact', + 'name': name, + if (phone != null) 'phone': phone, + }; + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _UbsContactImpl extends UbsContact { + _UbsContactImpl({ + required String name, + String? phone, + }) : super._( + name: name, + phone: phone, + ); + + /// Returns a shallow copy of this [UbsContact] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + UbsContact copyWith({ + String? name, + Object? phone = _Undefined, + }) { + return UbsContact( + name: name ?? this.name, + phone: phone is String? ? phone : this.phone, + ); + } +} diff --git a/backend/sinalacs_server/lib/src/generated/audit_chain_checkpoint.dart b/backend/sinalacs_server/lib/src/generated/audit_chain_checkpoint.dart new file mode 100644 index 00000000..2a962e6a --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/audit_chain_checkpoint.dart @@ -0,0 +1,557 @@ +/* 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; + +/// Ponto de verificação da cadeia de `audit_logs` (#45): `entryHash` na +/// posição `sequence`, assinado, para detectar truncamento do final da cadeia. +abstract class AuditChainCheckpoint + implements _i1.TableRow<_i1.UuidValue?>, _i1.ProtocolSerialization { + AuditChainCheckpoint._({ + this.id, + required this.sequence, + required this.entryHash, + required this.signature, + required this.createdAt, + }); + + factory AuditChainCheckpoint({ + _i1.UuidValue? id, + required int sequence, + required String entryHash, + required String signature, + required DateTime createdAt, + }) = _AuditChainCheckpointImpl; + + factory AuditChainCheckpoint.fromJson( + Map jsonSerialization, + ) { + return AuditChainCheckpoint( + id: jsonSerialization['id'] == null + ? null + : _i1.UuidValueJsonExtension.fromJson(jsonSerialization['id']), + sequence: jsonSerialization['sequence'] as int, + entryHash: jsonSerialization['entryHash'] as String, + signature: jsonSerialization['signature'] as String, + createdAt: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['createdAt'], + ), + ); + } + + static final t = AuditChainCheckpointTable(); + + static const db = AuditChainCheckpointRepository._(); + + @override + _i1.UuidValue? id; + + int sequence; + + String entryHash; + + String signature; + + DateTime createdAt; + + @override + _i1.Table<_i1.UuidValue?> get table => t; + + /// Returns a shallow copy of this [AuditChainCheckpoint] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + AuditChainCheckpoint copyWith({ + _i1.UuidValue? id, + int? sequence, + String? entryHash, + String? signature, + DateTime? createdAt, + }); + @override + Map toJson() { + return { + '__className__': 'AuditChainCheckpoint', + if (id != null) 'id': id?.toJson(), + 'sequence': sequence, + 'entryHash': entryHash, + 'signature': signature, + 'createdAt': createdAt.toJson(), + }; + } + + @override + Map toJsonForProtocol() { + return { + '__className__': 'AuditChainCheckpoint', + if (id != null) 'id': id?.toJson(), + 'sequence': sequence, + 'entryHash': entryHash, + 'signature': signature, + 'createdAt': createdAt.toJson(), + }; + } + + static AuditChainCheckpointInclude include() { + return AuditChainCheckpointInclude._(); + } + + static AuditChainCheckpointIncludeList includeList({ + _i1.WhereExpressionBuilder? where, + int? limit, + int? offset, + _i1.OrderByBuilder? orderBy, + bool orderDescending = false, + _i1.OrderByListBuilder? orderByList, + AuditChainCheckpointInclude? include, + }) { + return AuditChainCheckpointIncludeList._( + where: where, + limit: limit, + offset: offset, + orderBy: orderBy?.call(AuditChainCheckpoint.t), + orderDescending: orderDescending, + orderByList: orderByList?.call(AuditChainCheckpoint.t), + include: include, + ); + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _AuditChainCheckpointImpl extends AuditChainCheckpoint { + _AuditChainCheckpointImpl({ + _i1.UuidValue? id, + required int sequence, + required String entryHash, + required String signature, + required DateTime createdAt, + }) : super._( + id: id, + sequence: sequence, + entryHash: entryHash, + signature: signature, + createdAt: createdAt, + ); + + /// Returns a shallow copy of this [AuditChainCheckpoint] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + AuditChainCheckpoint copyWith({ + Object? id = _Undefined, + int? sequence, + String? entryHash, + String? signature, + DateTime? createdAt, + }) { + return AuditChainCheckpoint( + id: id is _i1.UuidValue? ? id : this.id, + sequence: sequence ?? this.sequence, + entryHash: entryHash ?? this.entryHash, + signature: signature ?? this.signature, + createdAt: createdAt ?? this.createdAt, + ); + } +} + +class AuditChainCheckpointUpdateTable + extends _i1.UpdateTable { + AuditChainCheckpointUpdateTable(super.table); + + _i1.ColumnValue sequence(int value) => _i1.ColumnValue( + table.sequence, + value, + ); + + _i1.ColumnValue entryHash(String value) => _i1.ColumnValue( + table.entryHash, + value, + ); + + _i1.ColumnValue signature(String value) => _i1.ColumnValue( + table.signature, + value, + ); + + _i1.ColumnValue createdAt(DateTime value) => + _i1.ColumnValue( + table.createdAt, + value, + ); +} + +class AuditChainCheckpointTable extends _i1.Table<_i1.UuidValue?> { + AuditChainCheckpointTable({super.tableRelation}) + : super(tableName: 'audit_chain_checkpoints') { + updateTable = AuditChainCheckpointUpdateTable(this); + sequence = _i1.ColumnInt( + 'sequence', + this, + ); + entryHash = _i1.ColumnString( + 'entryHash', + this, + ); + signature = _i1.ColumnString( + 'signature', + this, + ); + createdAt = _i1.ColumnDateTime( + 'createdAt', + this, + ); + } + + late final AuditChainCheckpointUpdateTable updateTable; + + late final _i1.ColumnInt sequence; + + late final _i1.ColumnString entryHash; + + late final _i1.ColumnString signature; + + late final _i1.ColumnDateTime createdAt; + + @override + List<_i1.Column> get columns => [ + id, + sequence, + entryHash, + signature, + createdAt, + ]; +} + +class AuditChainCheckpointInclude extends _i1.IncludeObject { + AuditChainCheckpointInclude._(); + + @override + Map get includes => {}; + + @override + _i1.Table<_i1.UuidValue?> get table => AuditChainCheckpoint.t; +} + +class AuditChainCheckpointIncludeList extends _i1.IncludeList { + AuditChainCheckpointIncludeList._({ + _i1.WhereExpressionBuilder? where, + super.limit, + super.offset, + super.orderBy, + super.orderDescending, + super.orderByList, + super.include, + }) { + super.where = where?.call(AuditChainCheckpoint.t); + } + + @override + Map get includes => include?.includes ?? {}; + + @override + _i1.Table<_i1.UuidValue?> get table => AuditChainCheckpoint.t; +} + +class AuditChainCheckpointRepository { + const AuditChainCheckpointRepository._(); + + /// Returns a list of [AuditChainCheckpoint]s matching the given query parameters. + /// + /// Use [where] to specify which items to include in the return value. + /// If none is specified, all items will be returned. + /// + /// To specify the order of the items use [orderBy] or [orderByList] + /// when sorting by multiple columns. + /// + /// The maximum number of items can be set by [limit]. If no limit is set, + /// all items matching the query will be returned. + /// + /// [offset] defines how many items to skip, after which [limit] (or all) + /// items are read from the database. + /// + /// ```dart + /// var persons = await Persons.db.find( + /// session, + /// where: (t) => t.lastName.equals('Jones'), + /// orderBy: (t) => t.firstName, + /// limit: 100, + /// ); + /// ``` + Future> find( + _i1.DatabaseSession session, { + _i1.WhereExpressionBuilder? where, + int? limit, + int? offset, + _i1.OrderByBuilder? orderBy, + bool orderDescending = false, + _i1.OrderByListBuilder? orderByList, + _i1.Transaction? transaction, + _i1.LockMode? lockMode, + _i1.LockBehavior? lockBehavior, + }) async { + return session.db.find( + where: where?.call(AuditChainCheckpoint.t), + orderBy: orderBy?.call(AuditChainCheckpoint.t), + orderByList: orderByList?.call(AuditChainCheckpoint.t), + orderDescending: orderDescending, + limit: limit, + offset: offset, + transaction: transaction, + lockMode: lockMode, + lockBehavior: lockBehavior, + ); + } + + /// Returns the first matching [AuditChainCheckpoint] matching the given query parameters. + /// + /// Use [where] to specify which items to include in the return value. + /// If none is specified, all items will be returned. + /// + /// To specify the order use [orderBy] or [orderByList] + /// when sorting by multiple columns. + /// + /// [offset] defines how many items to skip, after which the next one will be picked. + /// + /// ```dart + /// var youngestPerson = await Persons.db.findFirstRow( + /// session, + /// where: (t) => t.lastName.equals('Jones'), + /// orderBy: (t) => t.age, + /// ); + /// ``` + Future findFirstRow( + _i1.DatabaseSession session, { + _i1.WhereExpressionBuilder? where, + int? offset, + _i1.OrderByBuilder? orderBy, + bool orderDescending = false, + _i1.OrderByListBuilder? orderByList, + _i1.Transaction? transaction, + _i1.LockMode? lockMode, + _i1.LockBehavior? lockBehavior, + }) async { + return session.db.findFirstRow( + where: where?.call(AuditChainCheckpoint.t), + orderBy: orderBy?.call(AuditChainCheckpoint.t), + orderByList: orderByList?.call(AuditChainCheckpoint.t), + orderDescending: orderDescending, + offset: offset, + transaction: transaction, + lockMode: lockMode, + lockBehavior: lockBehavior, + ); + } + + /// Finds a single [AuditChainCheckpoint] by its [id] or null if no such row exists. + Future findById( + _i1.DatabaseSession session, + _i1.UuidValue id, { + _i1.Transaction? transaction, + _i1.LockMode? lockMode, + _i1.LockBehavior? lockBehavior, + }) async { + return session.db.findById( + id, + transaction: transaction, + lockMode: lockMode, + lockBehavior: lockBehavior, + ); + } + + /// Inserts all [AuditChainCheckpoint]s in the list and returns the inserted rows. + /// + /// The returned [AuditChainCheckpoint]s will have their `id` fields set. + /// + /// This is an atomic operation, meaning that if one of the rows fails to + /// insert, none of the rows will be inserted. + /// + /// If [ignoreConflicts] is set to `true`, rows that conflict with existing + /// rows are silently skipped, and only the successfully inserted rows are + /// returned. + Future> insert( + _i1.DatabaseSession session, + List rows, { + _i1.Transaction? transaction, + bool ignoreConflicts = false, + }) async { + return session.db.insert( + rows, + transaction: transaction, + ignoreConflicts: ignoreConflicts, + ); + } + + /// Inserts a single [AuditChainCheckpoint] and returns the inserted row. + /// + /// The returned [AuditChainCheckpoint] will have its `id` field set. + Future insertRow( + _i1.DatabaseSession session, + AuditChainCheckpoint row, { + _i1.Transaction? transaction, + }) async { + return session.db.insertRow( + row, + transaction: transaction, + ); + } + + /// Updates all [AuditChainCheckpoint]s in the list and returns the updated rows. If + /// [columns] is provided, only those columns will be updated. Defaults to + /// all columns. + /// This is an atomic operation, meaning that if one of the rows fails to + /// update, none of the rows will be updated. + Future> update( + _i1.DatabaseSession session, + List rows, { + _i1.ColumnSelections? columns, + _i1.Transaction? transaction, + }) async { + return session.db.update( + rows, + columns: columns?.call(AuditChainCheckpoint.t), + transaction: transaction, + ); + } + + /// Updates a single [AuditChainCheckpoint]. The row needs to have its id set. + /// Optionally, a list of [columns] can be provided to only update those + /// columns. Defaults to all columns. + Future updateRow( + _i1.DatabaseSession session, + AuditChainCheckpoint row, { + _i1.ColumnSelections? columns, + _i1.Transaction? transaction, + }) async { + return session.db.updateRow( + row, + columns: columns?.call(AuditChainCheckpoint.t), + transaction: transaction, + ); + } + + /// Updates a single [AuditChainCheckpoint] by its [id] with the specified [columnValues]. + /// Returns the updated row or null if no row with the given id exists. + Future updateById( + _i1.DatabaseSession session, + _i1.UuidValue id, { + required _i1.ColumnValueListBuilder + columnValues, + _i1.Transaction? transaction, + }) async { + return session.db.updateById( + id, + columnValues: columnValues(AuditChainCheckpoint.t.updateTable), + transaction: transaction, + ); + } + + /// Updates all [AuditChainCheckpoint]s matching the [where] expression with the specified [columnValues]. + /// Returns the list of updated rows. + Future> updateWhere( + _i1.DatabaseSession session, { + required _i1.ColumnValueListBuilder + columnValues, + required _i1.WhereExpressionBuilder where, + int? limit, + int? offset, + _i1.OrderByBuilder? orderBy, + _i1.OrderByListBuilder? orderByList, + bool orderDescending = false, + _i1.Transaction? transaction, + }) async { + return session.db.updateWhere( + columnValues: columnValues(AuditChainCheckpoint.t.updateTable), + where: where(AuditChainCheckpoint.t), + limit: limit, + offset: offset, + orderBy: orderBy?.call(AuditChainCheckpoint.t), + orderByList: orderByList?.call(AuditChainCheckpoint.t), + orderDescending: orderDescending, + transaction: transaction, + ); + } + + /// Deletes all [AuditChainCheckpoint]s in the list and returns the deleted rows. + /// This is an atomic operation, meaning that if one of the rows fail to + /// be deleted, none of the rows will be deleted. + Future> delete( + _i1.DatabaseSession session, + List rows, { + _i1.Transaction? transaction, + }) async { + return session.db.delete( + rows, + transaction: transaction, + ); + } + + /// Deletes a single [AuditChainCheckpoint]. + Future deleteRow( + _i1.DatabaseSession session, + AuditChainCheckpoint row, { + _i1.Transaction? transaction, + }) async { + return session.db.deleteRow( + row, + transaction: transaction, + ); + } + + /// Deletes all rows matching the [where] expression. + Future> deleteWhere( + _i1.DatabaseSession session, { + required _i1.WhereExpressionBuilder where, + _i1.Transaction? transaction, + }) async { + return session.db.deleteWhere( + where: where(AuditChainCheckpoint.t), + transaction: transaction, + ); + } + + /// Counts the number of rows matching the [where] expression. If omitted, + /// will return the count of all rows in the table. + Future count( + _i1.DatabaseSession session, { + _i1.WhereExpressionBuilder? where, + int? limit, + _i1.Transaction? transaction, + }) async { + return session.db.count( + where: where?.call(AuditChainCheckpoint.t), + limit: limit, + transaction: transaction, + ); + } + + /// Acquires row-level locks on [AuditChainCheckpoint] rows matching the [where] expression. + Future lockRows( + _i1.DatabaseSession session, { + required _i1.WhereExpressionBuilder where, + required _i1.LockMode lockMode, + required _i1.Transaction transaction, + _i1.LockBehavior lockBehavior = _i1.LockBehavior.wait, + }) async { + return session.db.lockRows( + where: where(AuditChainCheckpoint.t), + lockMode: lockMode, + lockBehavior: lockBehavior, + transaction: transaction, + ); + } +} diff --git a/backend/sinalacs_server/lib/src/generated/audit_export.dart b/backend/sinalacs_server/lib/src/generated/audit_export.dart new file mode 100644 index 00000000..44c41367 --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/audit_export.dart @@ -0,0 +1,693 @@ +/* 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; + +/// Registro de cada exportação da trilha de auditoria (#45). Append-only: +/// guarda quem exportou, os filtros, o intervalo de `sequence` coberto e o +/// SHA-256 do conteúdo, para que a própria exportação seja auditável. +abstract class AuditExport + implements _i1.TableRow<_i1.UuidValue?>, _i1.ProtocolSerialization { + AuditExport._({ + this.id, + required this.requestedBy, + required this.requestedAt, + required this.filtersJson, + required this.format, + required this.rowCount, + this.firstSequence, + this.lastSequence, + required this.contentSha256, + required this.chainState, + }); + + factory AuditExport({ + _i1.UuidValue? id, + required _i1.UuidValue requestedBy, + required DateTime requestedAt, + required String filtersJson, + required String format, + required int rowCount, + int? firstSequence, + int? lastSequence, + required String contentSha256, + required String chainState, + }) = _AuditExportImpl; + + factory AuditExport.fromJson(Map jsonSerialization) { + return AuditExport( + id: jsonSerialization['id'] == null + ? null + : _i1.UuidValueJsonExtension.fromJson(jsonSerialization['id']), + requestedBy: _i1.UuidValueJsonExtension.fromJson( + jsonSerialization['requestedBy'], + ), + requestedAt: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['requestedAt'], + ), + filtersJson: jsonSerialization['filtersJson'] as String, + format: jsonSerialization['format'] as String, + rowCount: jsonSerialization['rowCount'] as int, + firstSequence: jsonSerialization['firstSequence'] as int?, + lastSequence: jsonSerialization['lastSequence'] as int?, + contentSha256: jsonSerialization['contentSha256'] as String, + chainState: jsonSerialization['chainState'] as String, + ); + } + + static final t = AuditExportTable(); + + static const db = AuditExportRepository._(); + + @override + _i1.UuidValue? id; + + _i1.UuidValue requestedBy; + + DateTime requestedAt; + + /// Só os parâmetros do filtro (`AdminAuditFilter.toJson`, chaves + /// `from`/`to` e as opcionais), nunca linhas exportadas. LGPD: guarda o + /// `search` e o `actorEnrollmentId` digitados pelo administrador — texto + /// livre de até 64 caracteres que casa só com matrícula do autor, + /// `resourceType` e `result` (nunca nome, CPF, UUID ou hash), mas que o + /// operador pode preencher com qualquer coisa: tratar como dado pessoal + /// do staff (identificação profissional) e não ecoar em log. + String filtersJson; + + String format; + + int rowCount; + + int? firstSequence; + + int? lastSequence; + + String contentSha256; + + /// Resultado da verificação da cadeia no momento da exportação. + String chainState; + + @override + _i1.Table<_i1.UuidValue?> get table => t; + + /// Returns a shallow copy of this [AuditExport] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + AuditExport copyWith({ + _i1.UuidValue? id, + _i1.UuidValue? requestedBy, + DateTime? requestedAt, + String? filtersJson, + String? format, + int? rowCount, + int? firstSequence, + int? lastSequence, + String? contentSha256, + String? chainState, + }); + @override + Map toJson() { + return { + '__className__': 'AuditExport', + if (id != null) 'id': id?.toJson(), + 'requestedBy': requestedBy.toJson(), + 'requestedAt': requestedAt.toJson(), + 'filtersJson': filtersJson, + 'format': format, + 'rowCount': rowCount, + if (firstSequence != null) 'firstSequence': firstSequence, + if (lastSequence != null) 'lastSequence': lastSequence, + 'contentSha256': contentSha256, + 'chainState': chainState, + }; + } + + @override + Map toJsonForProtocol() { + return { + '__className__': 'AuditExport', + if (id != null) 'id': id?.toJson(), + 'requestedBy': requestedBy.toJson(), + 'requestedAt': requestedAt.toJson(), + 'filtersJson': filtersJson, + 'format': format, + 'rowCount': rowCount, + if (firstSequence != null) 'firstSequence': firstSequence, + if (lastSequence != null) 'lastSequence': lastSequence, + 'contentSha256': contentSha256, + 'chainState': chainState, + }; + } + + static AuditExportInclude include() { + return AuditExportInclude._(); + } + + static AuditExportIncludeList includeList({ + _i1.WhereExpressionBuilder? where, + int? limit, + int? offset, + _i1.OrderByBuilder? orderBy, + bool orderDescending = false, + _i1.OrderByListBuilder? orderByList, + AuditExportInclude? include, + }) { + return AuditExportIncludeList._( + where: where, + limit: limit, + offset: offset, + orderBy: orderBy?.call(AuditExport.t), + orderDescending: orderDescending, + orderByList: orderByList?.call(AuditExport.t), + include: include, + ); + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _AuditExportImpl extends AuditExport { + _AuditExportImpl({ + _i1.UuidValue? id, + required _i1.UuidValue requestedBy, + required DateTime requestedAt, + required String filtersJson, + required String format, + required int rowCount, + int? firstSequence, + int? lastSequence, + required String contentSha256, + required String chainState, + }) : super._( + id: id, + requestedBy: requestedBy, + requestedAt: requestedAt, + filtersJson: filtersJson, + format: format, + rowCount: rowCount, + firstSequence: firstSequence, + lastSequence: lastSequence, + contentSha256: contentSha256, + chainState: chainState, + ); + + /// Returns a shallow copy of this [AuditExport] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + AuditExport copyWith({ + Object? id = _Undefined, + _i1.UuidValue? requestedBy, + DateTime? requestedAt, + String? filtersJson, + String? format, + int? rowCount, + Object? firstSequence = _Undefined, + Object? lastSequence = _Undefined, + String? contentSha256, + String? chainState, + }) { + return AuditExport( + id: id is _i1.UuidValue? ? id : this.id, + requestedBy: requestedBy ?? this.requestedBy, + requestedAt: requestedAt ?? this.requestedAt, + filtersJson: filtersJson ?? this.filtersJson, + format: format ?? this.format, + rowCount: rowCount ?? this.rowCount, + firstSequence: firstSequence is int? ? firstSequence : this.firstSequence, + lastSequence: lastSequence is int? ? lastSequence : this.lastSequence, + contentSha256: contentSha256 ?? this.contentSha256, + chainState: chainState ?? this.chainState, + ); + } +} + +class AuditExportUpdateTable extends _i1.UpdateTable { + AuditExportUpdateTable(super.table); + + _i1.ColumnValue<_i1.UuidValue, _i1.UuidValue> requestedBy( + _i1.UuidValue value, + ) => _i1.ColumnValue( + table.requestedBy, + value, + ); + + _i1.ColumnValue requestedAt(DateTime value) => + _i1.ColumnValue( + table.requestedAt, + value, + ); + + _i1.ColumnValue filtersJson(String value) => _i1.ColumnValue( + table.filtersJson, + value, + ); + + _i1.ColumnValue format(String value) => _i1.ColumnValue( + table.format, + value, + ); + + _i1.ColumnValue rowCount(int value) => _i1.ColumnValue( + table.rowCount, + value, + ); + + _i1.ColumnValue firstSequence(int? value) => _i1.ColumnValue( + table.firstSequence, + value, + ); + + _i1.ColumnValue lastSequence(int? value) => _i1.ColumnValue( + table.lastSequence, + value, + ); + + _i1.ColumnValue contentSha256(String value) => + _i1.ColumnValue( + table.contentSha256, + value, + ); + + _i1.ColumnValue chainState(String value) => _i1.ColumnValue( + table.chainState, + value, + ); +} + +class AuditExportTable extends _i1.Table<_i1.UuidValue?> { + AuditExportTable({super.tableRelation}) : super(tableName: 'audit_exports') { + updateTable = AuditExportUpdateTable(this); + requestedBy = _i1.ColumnUuid( + 'requestedBy', + this, + ); + requestedAt = _i1.ColumnDateTime( + 'requestedAt', + this, + ); + filtersJson = _i1.ColumnString( + 'filtersJson', + this, + ); + format = _i1.ColumnString( + 'format', + this, + ); + rowCount = _i1.ColumnInt( + 'rowCount', + this, + ); + firstSequence = _i1.ColumnInt( + 'firstSequence', + this, + ); + lastSequence = _i1.ColumnInt( + 'lastSequence', + this, + ); + contentSha256 = _i1.ColumnString( + 'contentSha256', + this, + ); + chainState = _i1.ColumnString( + 'chainState', + this, + ); + } + + late final AuditExportUpdateTable updateTable; + + late final _i1.ColumnUuid requestedBy; + + late final _i1.ColumnDateTime requestedAt; + + /// Só os parâmetros do filtro (`AdminAuditFilter.toJson`, chaves + /// `from`/`to` e as opcionais), nunca linhas exportadas. LGPD: guarda o + /// `search` e o `actorEnrollmentId` digitados pelo administrador — texto + /// livre de até 64 caracteres que casa só com matrícula do autor, + /// `resourceType` e `result` (nunca nome, CPF, UUID ou hash), mas que o + /// operador pode preencher com qualquer coisa: tratar como dado pessoal + /// do staff (identificação profissional) e não ecoar em log. + late final _i1.ColumnString filtersJson; + + late final _i1.ColumnString format; + + late final _i1.ColumnInt rowCount; + + late final _i1.ColumnInt firstSequence; + + late final _i1.ColumnInt lastSequence; + + late final _i1.ColumnString contentSha256; + + /// Resultado da verificação da cadeia no momento da exportação. + late final _i1.ColumnString chainState; + + @override + List<_i1.Column> get columns => [ + id, + requestedBy, + requestedAt, + filtersJson, + format, + rowCount, + firstSequence, + lastSequence, + contentSha256, + chainState, + ]; +} + +class AuditExportInclude extends _i1.IncludeObject { + AuditExportInclude._(); + + @override + Map get includes => {}; + + @override + _i1.Table<_i1.UuidValue?> get table => AuditExport.t; +} + +class AuditExportIncludeList extends _i1.IncludeList { + AuditExportIncludeList._({ + _i1.WhereExpressionBuilder? where, + super.limit, + super.offset, + super.orderBy, + super.orderDescending, + super.orderByList, + super.include, + }) { + super.where = where?.call(AuditExport.t); + } + + @override + Map get includes => include?.includes ?? {}; + + @override + _i1.Table<_i1.UuidValue?> get table => AuditExport.t; +} + +class AuditExportRepository { + const AuditExportRepository._(); + + /// Returns a list of [AuditExport]s matching the given query parameters. + /// + /// Use [where] to specify which items to include in the return value. + /// If none is specified, all items will be returned. + /// + /// To specify the order of the items use [orderBy] or [orderByList] + /// when sorting by multiple columns. + /// + /// The maximum number of items can be set by [limit]. If no limit is set, + /// all items matching the query will be returned. + /// + /// [offset] defines how many items to skip, after which [limit] (or all) + /// items are read from the database. + /// + /// ```dart + /// var persons = await Persons.db.find( + /// session, + /// where: (t) => t.lastName.equals('Jones'), + /// orderBy: (t) => t.firstName, + /// limit: 100, + /// ); + /// ``` + Future> find( + _i1.DatabaseSession session, { + _i1.WhereExpressionBuilder? where, + int? limit, + int? offset, + _i1.OrderByBuilder? orderBy, + bool orderDescending = false, + _i1.OrderByListBuilder? orderByList, + _i1.Transaction? transaction, + _i1.LockMode? lockMode, + _i1.LockBehavior? lockBehavior, + }) async { + return session.db.find( + where: where?.call(AuditExport.t), + orderBy: orderBy?.call(AuditExport.t), + orderByList: orderByList?.call(AuditExport.t), + orderDescending: orderDescending, + limit: limit, + offset: offset, + transaction: transaction, + lockMode: lockMode, + lockBehavior: lockBehavior, + ); + } + + /// Returns the first matching [AuditExport] matching the given query parameters. + /// + /// Use [where] to specify which items to include in the return value. + /// If none is specified, all items will be returned. + /// + /// To specify the order use [orderBy] or [orderByList] + /// when sorting by multiple columns. + /// + /// [offset] defines how many items to skip, after which the next one will be picked. + /// + /// ```dart + /// var youngestPerson = await Persons.db.findFirstRow( + /// session, + /// where: (t) => t.lastName.equals('Jones'), + /// orderBy: (t) => t.age, + /// ); + /// ``` + Future findFirstRow( + _i1.DatabaseSession session, { + _i1.WhereExpressionBuilder? where, + int? offset, + _i1.OrderByBuilder? orderBy, + bool orderDescending = false, + _i1.OrderByListBuilder? orderByList, + _i1.Transaction? transaction, + _i1.LockMode? lockMode, + _i1.LockBehavior? lockBehavior, + }) async { + return session.db.findFirstRow( + where: where?.call(AuditExport.t), + orderBy: orderBy?.call(AuditExport.t), + orderByList: orderByList?.call(AuditExport.t), + orderDescending: orderDescending, + offset: offset, + transaction: transaction, + lockMode: lockMode, + lockBehavior: lockBehavior, + ); + } + + /// Finds a single [AuditExport] by its [id] or null if no such row exists. + Future findById( + _i1.DatabaseSession session, + _i1.UuidValue id, { + _i1.Transaction? transaction, + _i1.LockMode? lockMode, + _i1.LockBehavior? lockBehavior, + }) async { + return session.db.findById( + id, + transaction: transaction, + lockMode: lockMode, + lockBehavior: lockBehavior, + ); + } + + /// Inserts all [AuditExport]s in the list and returns the inserted rows. + /// + /// The returned [AuditExport]s will have their `id` fields set. + /// + /// This is an atomic operation, meaning that if one of the rows fails to + /// insert, none of the rows will be inserted. + /// + /// If [ignoreConflicts] is set to `true`, rows that conflict with existing + /// rows are silently skipped, and only the successfully inserted rows are + /// returned. + Future> insert( + _i1.DatabaseSession session, + List rows, { + _i1.Transaction? transaction, + bool ignoreConflicts = false, + }) async { + return session.db.insert( + rows, + transaction: transaction, + ignoreConflicts: ignoreConflicts, + ); + } + + /// Inserts a single [AuditExport] and returns the inserted row. + /// + /// The returned [AuditExport] will have its `id` field set. + Future insertRow( + _i1.DatabaseSession session, + AuditExport row, { + _i1.Transaction? transaction, + }) async { + return session.db.insertRow( + row, + transaction: transaction, + ); + } + + /// Updates all [AuditExport]s in the list and returns the updated rows. If + /// [columns] is provided, only those columns will be updated. Defaults to + /// all columns. + /// This is an atomic operation, meaning that if one of the rows fails to + /// update, none of the rows will be updated. + Future> update( + _i1.DatabaseSession session, + List rows, { + _i1.ColumnSelections? columns, + _i1.Transaction? transaction, + }) async { + return session.db.update( + rows, + columns: columns?.call(AuditExport.t), + transaction: transaction, + ); + } + + /// Updates a single [AuditExport]. The row needs to have its id set. + /// Optionally, a list of [columns] can be provided to only update those + /// columns. Defaults to all columns. + Future updateRow( + _i1.DatabaseSession session, + AuditExport row, { + _i1.ColumnSelections? columns, + _i1.Transaction? transaction, + }) async { + return session.db.updateRow( + row, + columns: columns?.call(AuditExport.t), + transaction: transaction, + ); + } + + /// Updates a single [AuditExport] by its [id] with the specified [columnValues]. + /// Returns the updated row or null if no row with the given id exists. + Future updateById( + _i1.DatabaseSession session, + _i1.UuidValue id, { + required _i1.ColumnValueListBuilder columnValues, + _i1.Transaction? transaction, + }) async { + return session.db.updateById( + id, + columnValues: columnValues(AuditExport.t.updateTable), + transaction: transaction, + ); + } + + /// Updates all [AuditExport]s matching the [where] expression with the specified [columnValues]. + /// Returns the list of updated rows. + Future> updateWhere( + _i1.DatabaseSession session, { + required _i1.ColumnValueListBuilder columnValues, + required _i1.WhereExpressionBuilder where, + int? limit, + int? offset, + _i1.OrderByBuilder? orderBy, + _i1.OrderByListBuilder? orderByList, + bool orderDescending = false, + _i1.Transaction? transaction, + }) async { + return session.db.updateWhere( + columnValues: columnValues(AuditExport.t.updateTable), + where: where(AuditExport.t), + limit: limit, + offset: offset, + orderBy: orderBy?.call(AuditExport.t), + orderByList: orderByList?.call(AuditExport.t), + orderDescending: orderDescending, + transaction: transaction, + ); + } + + /// Deletes all [AuditExport]s in the list and returns the deleted rows. + /// This is an atomic operation, meaning that if one of the rows fail to + /// be deleted, none of the rows will be deleted. + Future> delete( + _i1.DatabaseSession session, + List rows, { + _i1.Transaction? transaction, + }) async { + return session.db.delete( + rows, + transaction: transaction, + ); + } + + /// Deletes a single [AuditExport]. + Future deleteRow( + _i1.DatabaseSession session, + AuditExport row, { + _i1.Transaction? transaction, + }) async { + return session.db.deleteRow( + row, + transaction: transaction, + ); + } + + /// Deletes all rows matching the [where] expression. + Future> deleteWhere( + _i1.DatabaseSession session, { + required _i1.WhereExpressionBuilder where, + _i1.Transaction? transaction, + }) async { + return session.db.deleteWhere( + where: where(AuditExport.t), + transaction: transaction, + ); + } + + /// Counts the number of rows matching the [where] expression. If omitted, + /// will return the count of all rows in the table. + Future count( + _i1.DatabaseSession session, { + _i1.WhereExpressionBuilder? where, + int? limit, + _i1.Transaction? transaction, + }) async { + return session.db.count( + where: where?.call(AuditExport.t), + limit: limit, + transaction: transaction, + ); + } + + /// Acquires row-level locks on [AuditExport] rows matching the [where] expression. + Future lockRows( + _i1.DatabaseSession session, { + required _i1.WhereExpressionBuilder where, + required _i1.LockMode lockMode, + required _i1.Transaction transaction, + _i1.LockBehavior lockBehavior = _i1.LockBehavior.wait, + }) async { + return session.db.lockRows( + where: where(AuditExport.t), + lockMode: lockMode, + lockBehavior: lockBehavior, + transaction: transaction, + ); + } +} diff --git a/backend/sinalacs_server/lib/src/generated/data_subject_request.dart b/backend/sinalacs_server/lib/src/generated/data_subject_request.dart new file mode 100644 index 00000000..b2f8d203 --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/data_subject_request.dart @@ -0,0 +1,762 @@ +/* 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/data_subject_request_type.dart' as _i2; +import 'enums/data_subject_request_status.dart' as _i3; + +/// Pedido do titular sobre os próprios dados (LGPD-RF08): exclusão ou +/// correção, com prazo de resposta de 15 dias (spec/lgpd_design.md 596-597). +/// +/// `details` é texto livre do titular e pode citar condição de saúde — por +/// isso é cifrado na aplicação (AES-256-GCM), pelo mesmo motivo e com o +/// mesmo `HealthDataCipher` de `visits.notes` (RNF03/INV-04). Num pedido de +/// exclusão guarda o JSON `null` cifrado. +abstract class DataSubjectRequest + implements _i1.TableRow<_i1.UuidValue?>, _i1.ProtocolSerialization { + DataSubjectRequest._({ + this.id, + required this.userId, + required this.requestType, + required this.detailsEncrypted, + required this.detailsKeyVersion, + required this.status, + required this.createdAt, + required this.dueAt, + this.decidedAt, + this.decidedBy, + this.resolutionEncrypted, + this.resolutionKeyVersion, + }); + + factory DataSubjectRequest({ + _i1.UuidValue? id, + required _i1.UuidValue userId, + required _i2.DataSubjectRequestType requestType, + required String detailsEncrypted, + required int detailsKeyVersion, + required _i3.DataSubjectRequestStatus status, + required DateTime createdAt, + required DateTime dueAt, + DateTime? decidedAt, + _i1.UuidValue? decidedBy, + String? resolutionEncrypted, + int? resolutionKeyVersion, + }) = _DataSubjectRequestImpl; + + factory DataSubjectRequest.fromJson(Map jsonSerialization) { + return DataSubjectRequest( + id: jsonSerialization['id'] == null + ? null + : _i1.UuidValueJsonExtension.fromJson(jsonSerialization['id']), + userId: _i1.UuidValueJsonExtension.fromJson(jsonSerialization['userId']), + requestType: _i2.DataSubjectRequestType.fromJson( + (jsonSerialization['requestType'] as String), + ), + detailsEncrypted: jsonSerialization['detailsEncrypted'] as String, + detailsKeyVersion: jsonSerialization['detailsKeyVersion'] as int, + status: _i3.DataSubjectRequestStatus.fromJson( + (jsonSerialization['status'] as String), + ), + createdAt: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['createdAt'], + ), + dueAt: _i1.DateTimeJsonExtension.fromJson(jsonSerialization['dueAt']), + decidedAt: jsonSerialization['decidedAt'] == null + ? null + : _i1.DateTimeJsonExtension.fromJson(jsonSerialization['decidedAt']), + decidedBy: jsonSerialization['decidedBy'] == null + ? null + : _i1.UuidValueJsonExtension.fromJson(jsonSerialization['decidedBy']), + resolutionEncrypted: jsonSerialization['resolutionEncrypted'] as String?, + resolutionKeyVersion: jsonSerialization['resolutionKeyVersion'] as int?, + ); + } + + static final t = DataSubjectRequestTable(); + + static const db = DataSubjectRequestRepository._(); + + @override + _i1.UuidValue? id; + + _i1.UuidValue userId; + + _i2.DataSubjectRequestType requestType; + + String detailsEncrypted; + + int detailsKeyVersion; + + _i3.DataSubjectRequestStatus status; + + DateTime createdAt; + + /// Prazo de resposta: `createdAt` + 15 dias. + DateTime dueAt; + + /// Decisão do backoffice (#42); nulos enquanto o pedido está `open`. + DateTime? decidedAt; + + _i1.UuidValue? decidedBy; + + /// Nota de resposta/motivo da recusa: texto livre, cifrado como `details`. + String? resolutionEncrypted; + + int? resolutionKeyVersion; + + @override + _i1.Table<_i1.UuidValue?> get table => t; + + /// Returns a shallow copy of this [DataSubjectRequest] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + DataSubjectRequest copyWith({ + _i1.UuidValue? id, + _i1.UuidValue? userId, + _i2.DataSubjectRequestType? requestType, + String? detailsEncrypted, + int? detailsKeyVersion, + _i3.DataSubjectRequestStatus? status, + DateTime? createdAt, + DateTime? dueAt, + DateTime? decidedAt, + _i1.UuidValue? decidedBy, + String? resolutionEncrypted, + int? resolutionKeyVersion, + }); + @override + Map toJson() { + return { + '__className__': 'DataSubjectRequest', + if (id != null) 'id': id?.toJson(), + 'userId': userId.toJson(), + 'requestType': requestType.toJson(), + 'detailsEncrypted': detailsEncrypted, + 'detailsKeyVersion': detailsKeyVersion, + 'status': status.toJson(), + 'createdAt': createdAt.toJson(), + 'dueAt': dueAt.toJson(), + if (decidedAt != null) 'decidedAt': decidedAt?.toJson(), + if (decidedBy != null) 'decidedBy': decidedBy?.toJson(), + if (resolutionEncrypted != null) + 'resolutionEncrypted': resolutionEncrypted, + if (resolutionKeyVersion != null) + 'resolutionKeyVersion': resolutionKeyVersion, + }; + } + + @override + Map toJsonForProtocol() { + return { + '__className__': 'DataSubjectRequest', + if (id != null) 'id': id?.toJson(), + 'userId': userId.toJson(), + 'requestType': requestType.toJson(), + 'detailsEncrypted': detailsEncrypted, + 'detailsKeyVersion': detailsKeyVersion, + 'status': status.toJson(), + 'createdAt': createdAt.toJson(), + 'dueAt': dueAt.toJson(), + if (decidedAt != null) 'decidedAt': decidedAt?.toJson(), + if (decidedBy != null) 'decidedBy': decidedBy?.toJson(), + if (resolutionEncrypted != null) + 'resolutionEncrypted': resolutionEncrypted, + if (resolutionKeyVersion != null) + 'resolutionKeyVersion': resolutionKeyVersion, + }; + } + + static DataSubjectRequestInclude include() { + return DataSubjectRequestInclude._(); + } + + static DataSubjectRequestIncludeList includeList({ + _i1.WhereExpressionBuilder? where, + int? limit, + int? offset, + _i1.OrderByBuilder? orderBy, + bool orderDescending = false, + _i1.OrderByListBuilder? orderByList, + DataSubjectRequestInclude? include, + }) { + return DataSubjectRequestIncludeList._( + where: where, + limit: limit, + offset: offset, + orderBy: orderBy?.call(DataSubjectRequest.t), + orderDescending: orderDescending, + orderByList: orderByList?.call(DataSubjectRequest.t), + include: include, + ); + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _DataSubjectRequestImpl extends DataSubjectRequest { + _DataSubjectRequestImpl({ + _i1.UuidValue? id, + required _i1.UuidValue userId, + required _i2.DataSubjectRequestType requestType, + required String detailsEncrypted, + required int detailsKeyVersion, + required _i3.DataSubjectRequestStatus status, + required DateTime createdAt, + required DateTime dueAt, + DateTime? decidedAt, + _i1.UuidValue? decidedBy, + String? resolutionEncrypted, + int? resolutionKeyVersion, + }) : super._( + id: id, + userId: userId, + requestType: requestType, + detailsEncrypted: detailsEncrypted, + detailsKeyVersion: detailsKeyVersion, + status: status, + createdAt: createdAt, + dueAt: dueAt, + decidedAt: decidedAt, + decidedBy: decidedBy, + resolutionEncrypted: resolutionEncrypted, + resolutionKeyVersion: resolutionKeyVersion, + ); + + /// Returns a shallow copy of this [DataSubjectRequest] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + DataSubjectRequest copyWith({ + Object? id = _Undefined, + _i1.UuidValue? userId, + _i2.DataSubjectRequestType? requestType, + String? detailsEncrypted, + int? detailsKeyVersion, + _i3.DataSubjectRequestStatus? status, + DateTime? createdAt, + DateTime? dueAt, + Object? decidedAt = _Undefined, + Object? decidedBy = _Undefined, + Object? resolutionEncrypted = _Undefined, + Object? resolutionKeyVersion = _Undefined, + }) { + return DataSubjectRequest( + id: id is _i1.UuidValue? ? id : this.id, + userId: userId ?? this.userId, + requestType: requestType ?? this.requestType, + detailsEncrypted: detailsEncrypted ?? this.detailsEncrypted, + detailsKeyVersion: detailsKeyVersion ?? this.detailsKeyVersion, + status: status ?? this.status, + createdAt: createdAt ?? this.createdAt, + dueAt: dueAt ?? this.dueAt, + decidedAt: decidedAt is DateTime? ? decidedAt : this.decidedAt, + decidedBy: decidedBy is _i1.UuidValue? ? decidedBy : this.decidedBy, + resolutionEncrypted: resolutionEncrypted is String? + ? resolutionEncrypted + : this.resolutionEncrypted, + resolutionKeyVersion: resolutionKeyVersion is int? + ? resolutionKeyVersion + : this.resolutionKeyVersion, + ); + } +} + +class DataSubjectRequestUpdateTable + extends _i1.UpdateTable { + DataSubjectRequestUpdateTable(super.table); + + _i1.ColumnValue<_i1.UuidValue, _i1.UuidValue> userId(_i1.UuidValue value) => + _i1.ColumnValue( + table.userId, + value, + ); + + _i1.ColumnValue<_i2.DataSubjectRequestType, _i2.DataSubjectRequestType> + requestType(_i2.DataSubjectRequestType value) => _i1.ColumnValue( + table.requestType, + value, + ); + + _i1.ColumnValue detailsEncrypted(String value) => + _i1.ColumnValue( + table.detailsEncrypted, + value, + ); + + _i1.ColumnValue detailsKeyVersion(int value) => _i1.ColumnValue( + table.detailsKeyVersion, + value, + ); + + _i1.ColumnValue<_i3.DataSubjectRequestStatus, _i3.DataSubjectRequestStatus> + status(_i3.DataSubjectRequestStatus value) => _i1.ColumnValue( + table.status, + value, + ); + + _i1.ColumnValue createdAt(DateTime value) => + _i1.ColumnValue( + table.createdAt, + value, + ); + + _i1.ColumnValue dueAt(DateTime value) => _i1.ColumnValue( + table.dueAt, + value, + ); + + _i1.ColumnValue decidedAt(DateTime? value) => + _i1.ColumnValue( + table.decidedAt, + value, + ); + + _i1.ColumnValue<_i1.UuidValue, _i1.UuidValue> decidedBy( + _i1.UuidValue? value, + ) => _i1.ColumnValue( + table.decidedBy, + value, + ); + + _i1.ColumnValue resolutionEncrypted(String? value) => + _i1.ColumnValue( + table.resolutionEncrypted, + value, + ); + + _i1.ColumnValue resolutionKeyVersion(int? value) => _i1.ColumnValue( + table.resolutionKeyVersion, + value, + ); +} + +class DataSubjectRequestTable extends _i1.Table<_i1.UuidValue?> { + DataSubjectRequestTable({super.tableRelation}) + : super(tableName: 'data_subject_requests') { + updateTable = DataSubjectRequestUpdateTable(this); + userId = _i1.ColumnUuid( + 'userId', + this, + ); + requestType = _i1.ColumnEnum( + 'requestType', + this, + _i1.EnumSerialization.byName, + ); + detailsEncrypted = _i1.ColumnString( + 'detailsEncrypted', + this, + ); + detailsKeyVersion = _i1.ColumnInt( + 'detailsKeyVersion', + this, + ); + status = _i1.ColumnEnum( + 'status', + this, + _i1.EnumSerialization.byName, + ); + createdAt = _i1.ColumnDateTime( + 'createdAt', + this, + ); + dueAt = _i1.ColumnDateTime( + 'dueAt', + this, + ); + decidedAt = _i1.ColumnDateTime( + 'decidedAt', + this, + ); + decidedBy = _i1.ColumnUuid( + 'decidedBy', + this, + ); + resolutionEncrypted = _i1.ColumnString( + 'resolutionEncrypted', + this, + ); + resolutionKeyVersion = _i1.ColumnInt( + 'resolutionKeyVersion', + this, + ); + } + + late final DataSubjectRequestUpdateTable updateTable; + + late final _i1.ColumnUuid userId; + + late final _i1.ColumnEnum<_i2.DataSubjectRequestType> requestType; + + late final _i1.ColumnString detailsEncrypted; + + late final _i1.ColumnInt detailsKeyVersion; + + late final _i1.ColumnEnum<_i3.DataSubjectRequestStatus> status; + + late final _i1.ColumnDateTime createdAt; + + /// Prazo de resposta: `createdAt` + 15 dias. + late final _i1.ColumnDateTime dueAt; + + /// Decisão do backoffice (#42); nulos enquanto o pedido está `open`. + late final _i1.ColumnDateTime decidedAt; + + late final _i1.ColumnUuid decidedBy; + + /// Nota de resposta/motivo da recusa: texto livre, cifrado como `details`. + late final _i1.ColumnString resolutionEncrypted; + + late final _i1.ColumnInt resolutionKeyVersion; + + @override + List<_i1.Column> get columns => [ + id, + userId, + requestType, + detailsEncrypted, + detailsKeyVersion, + status, + createdAt, + dueAt, + decidedAt, + decidedBy, + resolutionEncrypted, + resolutionKeyVersion, + ]; +} + +class DataSubjectRequestInclude extends _i1.IncludeObject { + DataSubjectRequestInclude._(); + + @override + Map get includes => {}; + + @override + _i1.Table<_i1.UuidValue?> get table => DataSubjectRequest.t; +} + +class DataSubjectRequestIncludeList extends _i1.IncludeList { + DataSubjectRequestIncludeList._({ + _i1.WhereExpressionBuilder? where, + super.limit, + super.offset, + super.orderBy, + super.orderDescending, + super.orderByList, + super.include, + }) { + super.where = where?.call(DataSubjectRequest.t); + } + + @override + Map get includes => include?.includes ?? {}; + + @override + _i1.Table<_i1.UuidValue?> get table => DataSubjectRequest.t; +} + +class DataSubjectRequestRepository { + const DataSubjectRequestRepository._(); + + /// Returns a list of [DataSubjectRequest]s matching the given query parameters. + /// + /// Use [where] to specify which items to include in the return value. + /// If none is specified, all items will be returned. + /// + /// To specify the order of the items use [orderBy] or [orderByList] + /// when sorting by multiple columns. + /// + /// The maximum number of items can be set by [limit]. If no limit is set, + /// all items matching the query will be returned. + /// + /// [offset] defines how many items to skip, after which [limit] (or all) + /// items are read from the database. + /// + /// ```dart + /// var persons = await Persons.db.find( + /// session, + /// where: (t) => t.lastName.equals('Jones'), + /// orderBy: (t) => t.firstName, + /// limit: 100, + /// ); + /// ``` + Future> find( + _i1.DatabaseSession session, { + _i1.WhereExpressionBuilder? where, + int? limit, + int? offset, + _i1.OrderByBuilder? orderBy, + bool orderDescending = false, + _i1.OrderByListBuilder? orderByList, + _i1.Transaction? transaction, + _i1.LockMode? lockMode, + _i1.LockBehavior? lockBehavior, + }) async { + return session.db.find( + where: where?.call(DataSubjectRequest.t), + orderBy: orderBy?.call(DataSubjectRequest.t), + orderByList: orderByList?.call(DataSubjectRequest.t), + orderDescending: orderDescending, + limit: limit, + offset: offset, + transaction: transaction, + lockMode: lockMode, + lockBehavior: lockBehavior, + ); + } + + /// Returns the first matching [DataSubjectRequest] matching the given query parameters. + /// + /// Use [where] to specify which items to include in the return value. + /// If none is specified, all items will be returned. + /// + /// To specify the order use [orderBy] or [orderByList] + /// when sorting by multiple columns. + /// + /// [offset] defines how many items to skip, after which the next one will be picked. + /// + /// ```dart + /// var youngestPerson = await Persons.db.findFirstRow( + /// session, + /// where: (t) => t.lastName.equals('Jones'), + /// orderBy: (t) => t.age, + /// ); + /// ``` + Future findFirstRow( + _i1.DatabaseSession session, { + _i1.WhereExpressionBuilder? where, + int? offset, + _i1.OrderByBuilder? orderBy, + bool orderDescending = false, + _i1.OrderByListBuilder? orderByList, + _i1.Transaction? transaction, + _i1.LockMode? lockMode, + _i1.LockBehavior? lockBehavior, + }) async { + return session.db.findFirstRow( + where: where?.call(DataSubjectRequest.t), + orderBy: orderBy?.call(DataSubjectRequest.t), + orderByList: orderByList?.call(DataSubjectRequest.t), + orderDescending: orderDescending, + offset: offset, + transaction: transaction, + lockMode: lockMode, + lockBehavior: lockBehavior, + ); + } + + /// Finds a single [DataSubjectRequest] by its [id] or null if no such row exists. + Future findById( + _i1.DatabaseSession session, + _i1.UuidValue id, { + _i1.Transaction? transaction, + _i1.LockMode? lockMode, + _i1.LockBehavior? lockBehavior, + }) async { + return session.db.findById( + id, + transaction: transaction, + lockMode: lockMode, + lockBehavior: lockBehavior, + ); + } + + /// Inserts all [DataSubjectRequest]s in the list and returns the inserted rows. + /// + /// The returned [DataSubjectRequest]s will have their `id` fields set. + /// + /// This is an atomic operation, meaning that if one of the rows fails to + /// insert, none of the rows will be inserted. + /// + /// If [ignoreConflicts] is set to `true`, rows that conflict with existing + /// rows are silently skipped, and only the successfully inserted rows are + /// returned. + Future> insert( + _i1.DatabaseSession session, + List rows, { + _i1.Transaction? transaction, + bool ignoreConflicts = false, + }) async { + return session.db.insert( + rows, + transaction: transaction, + ignoreConflicts: ignoreConflicts, + ); + } + + /// Inserts a single [DataSubjectRequest] and returns the inserted row. + /// + /// The returned [DataSubjectRequest] will have its `id` field set. + Future insertRow( + _i1.DatabaseSession session, + DataSubjectRequest row, { + _i1.Transaction? transaction, + }) async { + return session.db.insertRow( + row, + transaction: transaction, + ); + } + + /// Updates all [DataSubjectRequest]s in the list and returns the updated rows. If + /// [columns] is provided, only those columns will be updated. Defaults to + /// all columns. + /// This is an atomic operation, meaning that if one of the rows fails to + /// update, none of the rows will be updated. + Future> update( + _i1.DatabaseSession session, + List rows, { + _i1.ColumnSelections? columns, + _i1.Transaction? transaction, + }) async { + return session.db.update( + rows, + columns: columns?.call(DataSubjectRequest.t), + transaction: transaction, + ); + } + + /// Updates a single [DataSubjectRequest]. The row needs to have its id set. + /// Optionally, a list of [columns] can be provided to only update those + /// columns. Defaults to all columns. + Future updateRow( + _i1.DatabaseSession session, + DataSubjectRequest row, { + _i1.ColumnSelections? columns, + _i1.Transaction? transaction, + }) async { + return session.db.updateRow( + row, + columns: columns?.call(DataSubjectRequest.t), + transaction: transaction, + ); + } + + /// Updates a single [DataSubjectRequest] by its [id] with the specified [columnValues]. + /// Returns the updated row or null if no row with the given id exists. + Future updateById( + _i1.DatabaseSession session, + _i1.UuidValue id, { + required _i1.ColumnValueListBuilder + columnValues, + _i1.Transaction? transaction, + }) async { + return session.db.updateById( + id, + columnValues: columnValues(DataSubjectRequest.t.updateTable), + transaction: transaction, + ); + } + + /// Updates all [DataSubjectRequest]s matching the [where] expression with the specified [columnValues]. + /// Returns the list of updated rows. + Future> updateWhere( + _i1.DatabaseSession session, { + required _i1.ColumnValueListBuilder + columnValues, + required _i1.WhereExpressionBuilder where, + int? limit, + int? offset, + _i1.OrderByBuilder? orderBy, + _i1.OrderByListBuilder? orderByList, + bool orderDescending = false, + _i1.Transaction? transaction, + }) async { + return session.db.updateWhere( + columnValues: columnValues(DataSubjectRequest.t.updateTable), + where: where(DataSubjectRequest.t), + limit: limit, + offset: offset, + orderBy: orderBy?.call(DataSubjectRequest.t), + orderByList: orderByList?.call(DataSubjectRequest.t), + orderDescending: orderDescending, + transaction: transaction, + ); + } + + /// Deletes all [DataSubjectRequest]s in the list and returns the deleted rows. + /// This is an atomic operation, meaning that if one of the rows fail to + /// be deleted, none of the rows will be deleted. + Future> delete( + _i1.DatabaseSession session, + List rows, { + _i1.Transaction? transaction, + }) async { + return session.db.delete( + rows, + transaction: transaction, + ); + } + + /// Deletes a single [DataSubjectRequest]. + Future deleteRow( + _i1.DatabaseSession session, + DataSubjectRequest row, { + _i1.Transaction? transaction, + }) async { + return session.db.deleteRow( + row, + transaction: transaction, + ); + } + + /// Deletes all rows matching the [where] expression. + Future> deleteWhere( + _i1.DatabaseSession session, { + required _i1.WhereExpressionBuilder where, + _i1.Transaction? transaction, + }) async { + return session.db.deleteWhere( + where: where(DataSubjectRequest.t), + transaction: transaction, + ); + } + + /// Counts the number of rows matching the [where] expression. If omitted, + /// will return the count of all rows in the table. + Future count( + _i1.DatabaseSession session, { + _i1.WhereExpressionBuilder? where, + int? limit, + _i1.Transaction? transaction, + }) async { + return session.db.count( + where: where?.call(DataSubjectRequest.t), + limit: limit, + transaction: transaction, + ); + } + + /// Acquires row-level locks on [DataSubjectRequest] rows matching the [where] expression. + Future lockRows( + _i1.DatabaseSession session, { + required _i1.WhereExpressionBuilder where, + required _i1.LockMode lockMode, + required _i1.Transaction transaction, + _i1.LockBehavior lockBehavior = _i1.LockBehavior.wait, + }) async { + return session.db.lockRows( + where: where(DataSubjectRequest.t), + lockMode: lockMode, + lockBehavior: lockBehavior, + transaction: transaction, + ); + } +} diff --git a/backend/sinalacs_server/lib/src/generated/endpoints.dart b/backend/sinalacs_server/lib/src/generated/endpoints.dart index 3e947a9c..6fb8cb55 100644 --- a/backend/sinalacs_server/lib/src/generated/endpoints.dart +++ b/backend/sinalacs_server/lib/src/generated/endpoints.dart @@ -12,62 +12,905 @@ // ignore_for_file: no_leading_underscores_for_library_prefixes 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/onboarding_endpoint.dart' as _i5; -import '../endpoints/patients_endpoint.dart' as _i6; -import '../endpoints/triage_endpoint.dart' as _i7; -import '../endpoints/visits_endpoint.dart' as _i8; -import 'package:sinalacs_server/src/generated/api/visit_sync_entry.dart' as _i9; +import '../endpoints/admin_endpoint.dart' as _i2; +import '../endpoints/alerts_endpoint.dart' as _i3; +import '../endpoints/auth_endpoint.dart' as _i4; +import '../endpoints/devices_endpoint.dart' as _i5; +import '../endpoints/health_endpoint.dart' as _i6; +import '../endpoints/notices_endpoint.dart' as _i7; +import '../endpoints/onboarding_endpoint.dart' as _i8; +import '../endpoints/patients_endpoint.dart' as _i9; +import '../endpoints/triage_endpoint.dart' as _i10; +import '../endpoints/ubs_endpoint.dart' as _i11; +import '../endpoints/visits_endpoint.dart' as _i12; +import 'package:sinalacs_server/src/generated/enums/alert_status.dart' as _i13; +import 'package:sinalacs_server/src/generated/api/admin_audit_filter.dart' + as _i14; +import 'package:sinalacs_server/src/generated/enums/data_subject_request_status.dart' + as _i15; +import 'package:sinalacs_server/src/generated/enums/escalation_reason.dart' + as _i16; +import 'package:sinalacs_server/src/generated/enums/consent_purpose.dart' + as _i17; +import 'package:sinalacs_server/src/generated/api/visit_sync_entry.dart' + as _i18; class Endpoints extends _i1.EndpointDispatch { @override void initializeEndpoints(_i1.Server server) { var endpoints = { - 'alerts': _i2.AlertsEndpoint() + 'admin': _i2.AdminEndpoint() + ..initialize( + server, + 'admin', + null, + ), + 'alerts': _i3.AlertsEndpoint() ..initialize( server, 'alerts', null, ), - 'auth': _i3.AuthEndpoint() + 'auth': _i4.AuthEndpoint() ..initialize( server, 'auth', null, ), - 'health': _i4.HealthEndpoint() + 'devices': _i5.DevicesEndpoint() + ..initialize( + server, + 'devices', + null, + ), + 'health': _i6.HealthEndpoint() ..initialize( server, 'health', null, ), - 'onboarding': _i5.OnboardingEndpoint() - ..initialize( - server, - 'onboarding', - null, + 'notices': _i7.NoticesEndpoint() + ..initialize( + server, + 'notices', + null, + ), + 'onboarding': _i8.OnboardingEndpoint() + ..initialize( + server, + 'onboarding', + null, + ), + 'patients': _i9.PatientsEndpoint() + ..initialize( + server, + 'patients', + null, + ), + 'triage': _i10.TriageEndpoint() + ..initialize( + server, + 'triage', + null, + ), + 'ubs': _i11.UbsEndpoint() + ..initialize( + server, + 'ubs', + null, + ), + 'visits': _i12.VisitsEndpoint() + ..initialize( + server, + 'visits', + null, + ), + }; + connectors['admin'] = _i1.EndpointConnector( + name: 'admin', + endpoint: endpoints['admin']!, + methodConnectors: { + 'indicators': _i1.MethodConnector( + name: 'indicators', + params: { + 'accessToken': _i1.ParameterDescription( + name: 'accessToken', + type: _i1.getType(), + nullable: false, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => (endpoints['admin'] as _i2.AdminEndpoint).indicators( + session, + accessToken: params['accessToken'], + ), + ), + 'microAreas': _i1.MethodConnector( + name: 'microAreas', + params: { + 'accessToken': _i1.ParameterDescription( + name: 'accessToken', + type: _i1.getType(), + nullable: false, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => (endpoints['admin'] as _i2.AdminEndpoint).microAreas( + session, + accessToken: params['accessToken'], + ), + ), + 'alerts': _i1.MethodConnector( + name: 'alerts', + params: { + 'accessToken': _i1.ParameterDescription( + name: 'accessToken', + type: _i1.getType(), + nullable: false, + ), + 'microAreaId': _i1.ParameterDescription( + name: 'microAreaId', + type: _i1.getType(), + nullable: true, + ), + 'status': _i1.ParameterDescription( + name: 'status', + type: _i1.getType<_i13.AlertStatus?>(), + nullable: true, + ), + 'limit': _i1.ParameterDescription( + name: 'limit', + type: _i1.getType(), + nullable: false, + ), + 'offset': _i1.ParameterDescription( + name: 'offset', + type: _i1.getType(), + nullable: false, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => (endpoints['admin'] as _i2.AdminEndpoint).alerts( + session, + accessToken: params['accessToken'], + microAreaId: params['microAreaId'], + status: params['status'], + limit: params['limit'], + offset: params['offset'], + ), + ), + 'alertsPulse': _i1.MethodConnector( + name: 'alertsPulse', + params: { + 'accessToken': _i1.ParameterDescription( + name: 'accessToken', + type: _i1.getType(), + nullable: false, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => (endpoints['admin'] as _i2.AdminEndpoint).alertsPulse( + session, + accessToken: params['accessToken'], + ), + ), + 'endMonitoringSession': _i1.MethodConnector( + name: 'endMonitoringSession', + params: { + 'accessToken': _i1.ParameterDescription( + name: 'accessToken', + type: _i1.getType(), + nullable: false, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => (endpoints['admin'] as _i2.AdminEndpoint) + .endMonitoringSession( + session, + accessToken: params['accessToken'], + ), + ), + 'auditLogs': _i1.MethodConnector( + name: 'auditLogs', + params: { + 'accessToken': _i1.ParameterDescription( + name: 'accessToken', + type: _i1.getType(), + nullable: false, + ), + 'filter': _i1.ParameterDescription( + name: 'filter', + type: _i1.getType<_i14.AdminAuditFilter?>(), + nullable: true, + ), + 'limit': _i1.ParameterDescription( + name: 'limit', + type: _i1.getType(), + nullable: false, + ), + 'beforeSequence': _i1.ParameterDescription( + name: 'beforeSequence', + type: _i1.getType(), + nullable: true, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => (endpoints['admin'] as _i2.AdminEndpoint).auditLogs( + session, + accessToken: params['accessToken'], + filter: params['filter'], + limit: params['limit'], + beforeSequence: params['beforeSequence'], + ), + ), + 'verifyAuditChain': _i1.MethodConnector( + name: 'verifyAuditChain', + params: { + 'accessToken': _i1.ParameterDescription( + name: 'accessToken', + type: _i1.getType(), + nullable: false, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => + (endpoints['admin'] as _i2.AdminEndpoint).verifyAuditChain( + session, + accessToken: params['accessToken'], + ), + ), + 'exportAuditLogs': _i1.MethodConnector( + name: 'exportAuditLogs', + params: { + 'accessToken': _i1.ParameterDescription( + name: 'accessToken', + type: _i1.getType(), + nullable: false, + ), + 'filter': _i1.ParameterDescription( + name: 'filter', + type: _i1.getType<_i14.AdminAuditFilter>(), + nullable: false, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => + (endpoints['admin'] as _i2.AdminEndpoint).exportAuditLogs( + session, + accessToken: params['accessToken'], + filter: params['filter'], + ), + ), + 'dataSubjectRequests': _i1.MethodConnector( + name: 'dataSubjectRequests', + params: { + 'accessToken': _i1.ParameterDescription( + name: 'accessToken', + type: _i1.getType(), + nullable: false, + ), + 'status': _i1.ParameterDescription( + name: 'status', + type: _i1.getType<_i15.DataSubjectRequestStatus?>(), + nullable: true, + ), + 'limit': _i1.ParameterDescription( + name: 'limit', + type: _i1.getType(), + nullable: false, + ), + 'offset': _i1.ParameterDescription( + name: 'offset', + type: _i1.getType(), + nullable: false, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => + (endpoints['admin'] as _i2.AdminEndpoint).dataSubjectRequests( + session, + accessToken: params['accessToken'], + status: params['status'], + limit: params['limit'], + offset: params['offset'], + ), + ), + 'dataSubjectRequest': _i1.MethodConnector( + name: 'dataSubjectRequest', + params: { + 'accessToken': _i1.ParameterDescription( + name: 'accessToken', + type: _i1.getType(), + nullable: false, + ), + 'id': _i1.ParameterDescription( + name: 'id', + type: _i1.getType(), + nullable: false, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => + (endpoints['admin'] as _i2.AdminEndpoint).dataSubjectRequest( + session, + accessToken: params['accessToken'], + id: params['id'], + ), + ), + 'startDataSubjectReview': _i1.MethodConnector( + name: 'startDataSubjectReview', + params: { + 'accessToken': _i1.ParameterDescription( + name: 'accessToken', + type: _i1.getType(), + nullable: false, + ), + 'id': _i1.ParameterDescription( + name: 'id', + type: _i1.getType(), + nullable: false, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => (endpoints['admin'] as _i2.AdminEndpoint) + .startDataSubjectReview( + session, + accessToken: params['accessToken'], + id: params['id'], + ), + ), + 'completeDataSubjectRequest': _i1.MethodConnector( + name: 'completeDataSubjectRequest', + params: { + 'accessToken': _i1.ParameterDescription( + name: 'accessToken', + type: _i1.getType(), + nullable: false, + ), + 'id': _i1.ParameterDescription( + name: 'id', + type: _i1.getType(), + nullable: false, + ), + 'note': _i1.ParameterDescription( + name: 'note', + type: _i1.getType(), + nullable: true, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => (endpoints['admin'] as _i2.AdminEndpoint) + .completeDataSubjectRequest( + session, + accessToken: params['accessToken'], + id: params['id'], + note: params['note'], + ), + ), + 'rejectDataSubjectRequest': _i1.MethodConnector( + name: 'rejectDataSubjectRequest', + params: { + 'accessToken': _i1.ParameterDescription( + name: 'accessToken', + type: _i1.getType(), + nullable: false, + ), + 'id': _i1.ParameterDescription( + name: 'id', + type: _i1.getType(), + nullable: false, + ), + 'reason': _i1.ParameterDescription( + name: 'reason', + type: _i1.getType(), + nullable: false, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => (endpoints['admin'] as _i2.AdminEndpoint) + .rejectDataSubjectRequest( + session, + accessToken: params['accessToken'], + id: params['id'], + reason: params['reason'], + ), + ), + 'acs': _i1.MethodConnector( + name: 'acs', + params: { + 'accessToken': _i1.ParameterDescription( + name: 'accessToken', + type: _i1.getType(), + nullable: false, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => (endpoints['admin'] as _i2.AdminEndpoint).acs( + session, + accessToken: params['accessToken'], + ), + ), + 'staff': _i1.MethodConnector( + name: 'staff', + params: { + 'accessToken': _i1.ParameterDescription( + name: 'accessToken', + type: _i1.getType(), + nullable: false, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => (endpoints['admin'] as _i2.AdminEndpoint).staff( + session, + accessToken: params['accessToken'], + ), + ), + 'createAcs': _i1.MethodConnector( + name: 'createAcs', + params: { + 'accessToken': _i1.ParameterDescription( + name: 'accessToken', + type: _i1.getType(), + nullable: false, + ), + 'name': _i1.ParameterDescription( + name: 'name', + type: _i1.getType(), + nullable: false, + ), + 'enrollmentId': _i1.ParameterDescription( + name: 'enrollmentId', + type: _i1.getType(), + nullable: false, + ), + 'microAreaId': _i1.ParameterDescription( + name: 'microAreaId', + type: _i1.getType(), + nullable: false, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => (endpoints['admin'] as _i2.AdminEndpoint).createAcs( + session, + accessToken: params['accessToken'], + name: params['name'], + enrollmentId: params['enrollmentId'], + microAreaId: params['microAreaId'], + ), + ), + 'setAcsMicroArea': _i1.MethodConnector( + name: 'setAcsMicroArea', + params: { + 'accessToken': _i1.ParameterDescription( + name: 'accessToken', + type: _i1.getType(), + nullable: false, + ), + 'acsId': _i1.ParameterDescription( + name: 'acsId', + type: _i1.getType(), + nullable: false, + ), + 'microAreaId': _i1.ParameterDescription( + name: 'microAreaId', + type: _i1.getType(), + nullable: false, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => + (endpoints['admin'] as _i2.AdminEndpoint).setAcsMicroArea( + session, + accessToken: params['accessToken'], + acsId: params['acsId'], + microAreaId: params['microAreaId'], + ), + ), + 'setAcsActive': _i1.MethodConnector( + name: 'setAcsActive', + params: { + 'accessToken': _i1.ParameterDescription( + name: 'accessToken', + type: _i1.getType(), + nullable: false, + ), + 'acsId': _i1.ParameterDescription( + name: 'acsId', + type: _i1.getType(), + nullable: false, + ), + 'active': _i1.ParameterDescription( + name: 'active', + type: _i1.getType(), + nullable: false, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => (endpoints['admin'] as _i2.AdminEndpoint).setAcsActive( + session, + accessToken: params['accessToken'], + acsId: params['acsId'], + active: params['active'], + ), + ), + 'resetAcsPassword': _i1.MethodConnector( + name: 'resetAcsPassword', + params: { + 'accessToken': _i1.ParameterDescription( + name: 'accessToken', + type: _i1.getType(), + nullable: false, + ), + 'acsId': _i1.ParameterDescription( + name: 'acsId', + type: _i1.getType(), + nullable: false, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => + (endpoints['admin'] as _i2.AdminEndpoint).resetAcsPassword( + session, + accessToken: params['accessToken'], + acsId: params['acsId'], + ), + ), + 'resetAcsMfa': _i1.MethodConnector( + name: 'resetAcsMfa', + params: { + 'accessToken': _i1.ParameterDescription( + name: 'accessToken', + type: _i1.getType(), + nullable: false, + ), + 'acsId': _i1.ParameterDescription( + name: 'acsId', + type: _i1.getType(), + nullable: false, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => (endpoints['admin'] as _i2.AdminEndpoint).resetAcsMfa( + session, + accessToken: params['accessToken'], + acsId: params['acsId'], + ), + ), + 'resetStaffMfa': _i1.MethodConnector( + name: 'resetStaffMfa', + params: { + 'accessToken': _i1.ParameterDescription( + name: 'accessToken', + type: _i1.getType(), + nullable: false, + ), + 'staffId': _i1.ParameterDescription( + name: 'staffId', + type: _i1.getType(), + nullable: false, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => + (endpoints['admin'] as _i2.AdminEndpoint).resetStaffMfa( + session, + accessToken: params['accessToken'], + staffId: params['staffId'], + ), + ), + 'escalateAlert': _i1.MethodConnector( + name: 'escalateAlert', + params: { + 'accessToken': _i1.ParameterDescription( + name: 'accessToken', + type: _i1.getType(), + nullable: false, + ), + 'alertId': _i1.ParameterDescription( + name: 'alertId', + type: _i1.getType(), + nullable: false, + ), + 'reason': _i1.ParameterDescription( + name: 'reason', + type: _i1.getType<_i16.EscalationReason>(), + nullable: false, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => + (endpoints['admin'] as _i2.AdminEndpoint).escalateAlert( + session, + accessToken: params['accessToken'], + alertId: params['alertId'], + reason: params['reason'], + ), + ), + 'scheduleNotice': _i1.MethodConnector( + name: 'scheduleNotice', + params: { + 'accessToken': _i1.ParameterDescription( + name: 'accessToken', + type: _i1.getType(), + nullable: false, + ), + 'microAreaId': _i1.ParameterDescription( + name: 'microAreaId', + type: _i1.getType(), + nullable: false, + ), + 'audience': _i1.ParameterDescription( + name: 'audience', + type: _i1.getType(), + nullable: false, + ), + 'title': _i1.ParameterDescription( + name: 'title', + type: _i1.getType(), + nullable: false, + ), + 'message': _i1.ParameterDescription( + name: 'message', + type: _i1.getType(), + nullable: false, + ), + 'scheduledFor': _i1.ParameterDescription( + name: 'scheduledFor', + type: _i1.getType(), + nullable: false, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => + (endpoints['admin'] as _i2.AdminEndpoint).scheduleNotice( + session, + accessToken: params['accessToken'], + microAreaId: params['microAreaId'], + audience: params['audience'], + title: params['title'], + message: params['message'], + scheduledFor: params['scheduledFor'], + ), + ), + 'cancelScheduledNotice': _i1.MethodConnector( + name: 'cancelScheduledNotice', + params: { + 'accessToken': _i1.ParameterDescription( + name: 'accessToken', + type: _i1.getType(), + nullable: false, + ), + 'id': _i1.ParameterDescription( + name: 'id', + type: _i1.getType(), + nullable: false, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => (endpoints['admin'] as _i2.AdminEndpoint) + .cancelScheduledNotice( + session, + accessToken: params['accessToken'], + id: params['id'], + ), + ), + 'noticeHistory': _i1.MethodConnector( + name: 'noticeHistory', + params: { + 'accessToken': _i1.ParameterDescription( + name: 'accessToken', + type: _i1.getType(), + nullable: false, + ), + 'microAreaId': _i1.ParameterDescription( + name: 'microAreaId', + type: _i1.getType(), + nullable: true, + ), + 'limit': _i1.ParameterDescription( + name: 'limit', + type: _i1.getType(), + nullable: false, + ), + 'offset': _i1.ParameterDescription( + name: 'offset', + type: _i1.getType(), + nullable: false, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => + (endpoints['admin'] as _i2.AdminEndpoint).noticeHistory( + session, + accessToken: params['accessToken'], + microAreaId: params['microAreaId'], + limit: params['limit'], + offset: params['offset'], + ), + ), + 'termsVersions': _i1.MethodConnector( + name: 'termsVersions', + params: { + 'accessToken': _i1.ParameterDescription( + name: 'accessToken', + type: _i1.getType(), + nullable: false, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => + (endpoints['admin'] as _i2.AdminEndpoint).termsVersions( + session, + accessToken: params['accessToken'], + ), ), - 'patients': _i6.PatientsEndpoint() - ..initialize( - server, - 'patients', - null, + 'draftTermsVersion': _i1.MethodConnector( + name: 'draftTermsVersion', + params: { + 'accessToken': _i1.ParameterDescription( + name: 'accessToken', + type: _i1.getType(), + nullable: false, + ), + 'version': _i1.ParameterDescription( + name: 'version', + type: _i1.getType(), + nullable: false, + ), + 'summary': _i1.ParameterDescription( + name: 'summary', + type: _i1.getType(), + nullable: false, + ), + 'effectiveFrom': _i1.ParameterDescription( + name: 'effectiveFrom', + type: _i1.getType(), + nullable: false, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => + (endpoints['admin'] as _i2.AdminEndpoint).draftTermsVersion( + session, + accessToken: params['accessToken'], + version: params['version'], + summary: params['summary'], + effectiveFrom: params['effectiveFrom'], + ), ), - 'triage': _i7.TriageEndpoint() - ..initialize( - server, - 'triage', - null, + 'markTermsReviewed': _i1.MethodConnector( + name: 'markTermsReviewed', + params: { + 'accessToken': _i1.ParameterDescription( + name: 'accessToken', + type: _i1.getType(), + nullable: false, + ), + 'id': _i1.ParameterDescription( + name: 'id', + type: _i1.getType(), + nullable: false, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => + (endpoints['admin'] as _i2.AdminEndpoint).markTermsReviewed( + session, + accessToken: params['accessToken'], + id: params['id'], + ), ), - 'visits': _i8.VisitsEndpoint() - ..initialize( - server, - 'visits', - null, + 'publishTermsVersion': _i1.MethodConnector( + name: 'publishTermsVersion', + params: { + 'accessToken': _i1.ParameterDescription( + name: 'accessToken', + type: _i1.getType(), + nullable: false, + ), + 'id': _i1.ParameterDescription( + name: 'id', + type: _i1.getType(), + nullable: false, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => + (endpoints['admin'] as _i2.AdminEndpoint).publishTermsVersion( + session, + accessToken: params['accessToken'], + id: params['id'], + ), ), - }; + }, + ); connectors['alerts'] = _i1.EndpointConnector( name: 'alerts', endpoint: endpoints['alerts']!, @@ -101,7 +944,7 @@ class Endpoints extends _i1.EndpointDispatch { _i1.Session session, Map params, ) async => - (endpoints['alerts'] as _i2.AlertsEndpoint).createRedAlert( + (endpoints['alerts'] as _i3.AlertsEndpoint).createRedAlert( session, accessToken: params['accessToken'], idempotencyKey: params['idempotencyKey'], @@ -128,7 +971,7 @@ class Endpoints extends _i1.EndpointDispatch { _i1.Session session, Map params, ) async => - (endpoints['alerts'] as _i2.AlertsEndpoint).acknowledge( + (endpoints['alerts'] as _i3.AlertsEndpoint).acknowledge( session, accessToken: params['accessToken'], alertId: params['alertId'], @@ -147,7 +990,7 @@ class Endpoints extends _i1.EndpointDispatch { ( _i1.Session session, Map params, - ) async => (endpoints['alerts'] as _i2.AlertsEndpoint).statusFor( + ) async => (endpoints['alerts'] as _i3.AlertsEndpoint).statusFor( session, accessToken: params['accessToken'], ), @@ -172,7 +1015,7 @@ class Endpoints extends _i1.EndpointDispatch { _i1.Session session, Map params, ) async => - (endpoints['auth'] as _i3.AuthEndpoint).developmentLogin( + (endpoints['auth'] as _i4.AuthEndpoint).developmentLogin( session, role: params['role'], ), @@ -195,17 +1038,219 @@ class Endpoints extends _i1.EndpointDispatch { type: _i1.getType(), nullable: true, ), + 'totpCode': _i1.ParameterDescription( + name: 'totpCode', + type: _i1.getType(), + nullable: true, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => + (endpoints['auth'] as _i4.AuthEndpoint).loginInstitutional( + session, + matricula: params['matricula'], + password: params['password'], + deviceId: params['deviceId'], + totpCode: params['totpCode'], + ), + ), + 'loginStaff': _i1.MethodConnector( + name: 'loginStaff', + params: { + 'matricula': _i1.ParameterDescription( + name: 'matricula', + type: _i1.getType(), + nullable: false, + ), + 'password': _i1.ParameterDescription( + name: 'password', + type: _i1.getType(), + nullable: false, + ), + 'totpCode': _i1.ParameterDescription( + name: 'totpCode', + type: _i1.getType(), + nullable: true, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => (endpoints['auth'] as _i4.AuthEndpoint).loginStaff( + session, + matricula: params['matricula'], + password: params['password'], + totpCode: params['totpCode'], + ), + ), + 'beginStaffTotpEnrollment': _i1.MethodConnector( + name: 'beginStaffTotpEnrollment', + params: { + 'matricula': _i1.ParameterDescription( + name: 'matricula', + type: _i1.getType(), + nullable: false, + ), + 'password': _i1.ParameterDescription( + name: 'password', + type: _i1.getType(), + nullable: false, + ), + 'activationCode': _i1.ParameterDescription( + name: 'activationCode', + type: _i1.getType(), + nullable: false, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => (endpoints['auth'] as _i4.AuthEndpoint) + .beginStaffTotpEnrollment( + session, + matricula: params['matricula'], + password: params['password'], + activationCode: params['activationCode'], + ), + ), + 'confirmStaffTotpEnrollment': _i1.MethodConnector( + name: 'confirmStaffTotpEnrollment', + params: { + 'matricula': _i1.ParameterDescription( + name: 'matricula', + type: _i1.getType(), + nullable: false, + ), + 'password': _i1.ParameterDescription( + name: 'password', + type: _i1.getType(), + nullable: false, + ), + 'activationCode': _i1.ParameterDescription( + name: 'activationCode', + type: _i1.getType(), + nullable: false, + ), + 'code': _i1.ParameterDescription( + name: 'code', + type: _i1.getType(), + nullable: false, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => (endpoints['auth'] as _i4.AuthEndpoint) + .confirmStaffTotpEnrollment( + session, + matricula: params['matricula'], + password: params['password'], + activationCode: params['activationCode'], + code: params['code'], + ), + ), + 'refreshSession': _i1.MethodConnector( + name: 'refreshSession', + params: { + 'refreshToken': _i1.ParameterDescription( + name: 'refreshToken', + type: _i1.getType(), + nullable: false, + ), + 'deviceId': _i1.ParameterDescription( + name: 'deviceId', + type: _i1.getType(), + nullable: false, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => (endpoints['auth'] as _i4.AuthEndpoint).refreshSession( + session, + refreshToken: params['refreshToken'], + deviceId: params['deviceId'], + ), + ), + 'logout': _i1.MethodConnector( + name: 'logout', + params: { + 'refreshToken': _i1.ParameterDescription( + name: 'refreshToken', + type: _i1.getType(), + nullable: false, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => (endpoints['auth'] as _i4.AuthEndpoint).logout( + session, + refreshToken: params['refreshToken'], + ), + ), + 'beginTotpEnrollment': _i1.MethodConnector( + name: 'beginTotpEnrollment', + params: { + 'matricula': _i1.ParameterDescription( + name: 'matricula', + type: _i1.getType(), + nullable: false, + ), + 'password': _i1.ParameterDescription( + name: 'password', + type: _i1.getType(), + nullable: false, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => + (endpoints['auth'] as _i4.AuthEndpoint).beginTotpEnrollment( + session, + matricula: params['matricula'], + password: params['password'], + ), + ), + 'confirmTotpEnrollment': _i1.MethodConnector( + name: 'confirmTotpEnrollment', + params: { + 'matricula': _i1.ParameterDescription( + name: 'matricula', + type: _i1.getType(), + nullable: false, + ), + 'password': _i1.ParameterDescription( + name: 'password', + type: _i1.getType(), + nullable: false, + ), + 'code': _i1.ParameterDescription( + name: 'code', + type: _i1.getType(), + nullable: false, + ), }, call: ( _i1.Session session, Map params, ) async => - (endpoints['auth'] as _i3.AuthEndpoint).loginInstitutional( + (endpoints['auth'] as _i4.AuthEndpoint).confirmTotpEnrollment( session, matricula: params['matricula'], password: params['password'], - deviceId: params['deviceId'], + code: params['code'], ), ), 'requestOtp': _i1.MethodConnector( @@ -226,7 +1271,7 @@ class Endpoints extends _i1.EndpointDispatch { ( _i1.Session session, Map params, - ) async => (endpoints['auth'] as _i3.AuthEndpoint).requestOtp( + ) async => (endpoints['auth'] as _i4.AuthEndpoint).requestOtp( session, cpf: params['cpf'], birthDate: params['birthDate'], @@ -255,7 +1300,7 @@ class Endpoints extends _i1.EndpointDispatch { ( _i1.Session session, Map params, - ) async => (endpoints['auth'] as _i3.AuthEndpoint).verifyOtp( + ) async => (endpoints['auth'] as _i4.AuthEndpoint).verifyOtp( session, cpf: params['cpf'], code: params['code'], @@ -264,6 +1309,43 @@ class Endpoints extends _i1.EndpointDispatch { ), }, ); + connectors['devices'] = _i1.EndpointConnector( + name: 'devices', + endpoint: endpoints['devices']!, + methodConnectors: { + 'registerPushToken': _i1.MethodConnector( + name: 'registerPushToken', + params: { + 'accessToken': _i1.ParameterDescription( + name: 'accessToken', + type: _i1.getType(), + nullable: false, + ), + 'token': _i1.ParameterDescription( + name: 'token', + type: _i1.getType(), + nullable: false, + ), + 'platform': _i1.ParameterDescription( + name: 'platform', + type: _i1.getType(), + nullable: false, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => (endpoints['devices'] as _i5.DevicesEndpoint) + .registerPushToken( + session, + accessToken: params['accessToken'], + token: params['token'], + platform: params['platform'], + ), + ), + }, + ); connectors['health'] = _i1.EndpointConnector( name: 'health', endpoint: endpoints['health']!, @@ -276,7 +1358,50 @@ class Endpoints extends _i1.EndpointDispatch { _i1.Session session, Map params, ) async => - (endpoints['health'] as _i4.HealthEndpoint).check(session), + (endpoints['health'] as _i6.HealthEndpoint).check(session), + ), + }, + ); + connectors['notices'] = _i1.EndpointConnector( + name: 'notices', + endpoint: endpoints['notices']!, + methodConnectors: { + 'sendSegmented': _i1.MethodConnector( + name: 'sendSegmented', + params: { + 'accessToken': _i1.ParameterDescription( + name: 'accessToken', + type: _i1.getType(), + nullable: false, + ), + 'title': _i1.ParameterDescription( + name: 'title', + type: _i1.getType(), + nullable: false, + ), + 'message': _i1.ParameterDescription( + name: 'message', + type: _i1.getType(), + nullable: false, + ), + 'audience': _i1.ParameterDescription( + name: 'audience', + type: _i1.getType(), + nullable: false, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => + (endpoints['notices'] as _i7.NoticesEndpoint).sendSegmented( + session, + accessToken: params['accessToken'], + title: params['title'], + message: params['message'], + audience: params['audience'], + ), ), }, ); @@ -302,7 +1427,7 @@ class Endpoints extends _i1.EndpointDispatch { ( _i1.Session session, Map params, - ) async => (endpoints['onboarding'] as _i5.OnboardingEndpoint) + ) async => (endpoints['onboarding'] as _i8.OnboardingEndpoint) .generateEnrollmentToken( session, accessToken: params['accessToken'], @@ -332,18 +1457,24 @@ class Endpoints extends _i1.EndpointDispatch { type: _i1.getType(), nullable: false, ), + 'termsAccepted': _i1.ParameterDescription( + name: 'termsAccepted', + type: _i1.getType(), + nullable: false, + ), }, call: ( _i1.Session session, Map params, - ) async => (endpoints['onboarding'] as _i5.OnboardingEndpoint) + ) async => (endpoints['onboarding'] as _i8.OnboardingEndpoint) .completeEnrollment( session, token: params['token'], healthDataConsent: params['healthDataConsent'], remindersConsent: params['remindersConsent'], pushConsent: params['pushConsent'], + termsAccepted: params['termsAccepted'], ), ), }, @@ -366,7 +1497,7 @@ class Endpoints extends _i1.EndpointDispatch { _i1.Session session, Map params, ) async => - (endpoints['patients'] as _i6.PatientsEndpoint).listMicroArea( + (endpoints['patients'] as _i9.PatientsEndpoint).listMicroArea( session, accessToken: params['accessToken'], ), @@ -384,7 +1515,7 @@ class Endpoints extends _i1.EndpointDispatch { ( _i1.Session session, Map params, - ) async => (endpoints['patients'] as _i6.PatientsEndpoint) + ) async => (endpoints['patients'] as _i9.PatientsEndpoint) .myChronicConditions( session, accessToken: params['accessToken'], @@ -408,7 +1539,7 @@ class Endpoints extends _i1.EndpointDispatch { ( _i1.Session session, Map params, - ) async => (endpoints['patients'] as _i6.PatientsEndpoint) + ) async => (endpoints['patients'] as _i9.PatientsEndpoint) .updateChronicConditions( session, accessToken: params['accessToken'], @@ -428,11 +1559,168 @@ class Endpoints extends _i1.EndpointDispatch { ( _i1.Session session, Map params, - ) async => (endpoints['patients'] as _i6.PatientsEndpoint).myData( + ) async => (endpoints['patients'] as _i9.PatientsEndpoint).myData( session, accessToken: params['accessToken'], ), ), + 'updateConsent': _i1.MethodConnector( + name: 'updateConsent', + params: { + 'accessToken': _i1.ParameterDescription( + name: 'accessToken', + type: _i1.getType(), + nullable: false, + ), + 'purpose': _i1.ParameterDescription( + name: 'purpose', + type: _i1.getType<_i17.ConsentPurpose>(), + nullable: false, + ), + 'granted': _i1.ParameterDescription( + name: 'granted', + type: _i1.getType(), + nullable: false, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => + (endpoints['patients'] as _i9.PatientsEndpoint).updateConsent( + session, + accessToken: params['accessToken'], + purpose: params['purpose'], + granted: params['granted'], + ), + ), + 'acceptTermsOfUse': _i1.MethodConnector( + name: 'acceptTermsOfUse', + params: { + 'accessToken': _i1.ParameterDescription( + name: 'accessToken', + type: _i1.getType(), + nullable: false, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => (endpoints['patients'] as _i9.PatientsEndpoint) + .acceptTermsOfUse( + session, + accessToken: params['accessToken'], + ), + ), + 'hasAcceptedCurrentTerms': _i1.MethodConnector( + name: 'hasAcceptedCurrentTerms', + params: { + 'accessToken': _i1.ParameterDescription( + name: 'accessToken', + type: _i1.getType(), + nullable: false, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => (endpoints['patients'] as _i9.PatientsEndpoint) + .hasAcceptedCurrentTerms( + session, + accessToken: params['accessToken'], + ), + ), + 'hasGrantedConsent': _i1.MethodConnector( + name: 'hasGrantedConsent', + params: { + 'accessToken': _i1.ParameterDescription( + name: 'accessToken', + type: _i1.getType(), + nullable: false, + ), + 'purpose': _i1.ParameterDescription( + name: 'purpose', + type: _i1.getType<_i17.ConsentPurpose>(), + nullable: false, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => (endpoints['patients'] as _i9.PatientsEndpoint) + .hasGrantedConsent( + session, + accessToken: params['accessToken'], + purpose: params['purpose'], + ), + ), + 'termsChangeNotice': _i1.MethodConnector( + name: 'termsChangeNotice', + params: { + 'accessToken': _i1.ParameterDescription( + name: 'accessToken', + type: _i1.getType(), + nullable: false, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => (endpoints['patients'] as _i9.PatientsEndpoint) + .termsChangeNotice( + session, + accessToken: params['accessToken'], + ), + ), + 'requestDataDeletion': _i1.MethodConnector( + name: 'requestDataDeletion', + params: { + 'accessToken': _i1.ParameterDescription( + name: 'accessToken', + type: _i1.getType(), + nullable: false, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => (endpoints['patients'] as _i9.PatientsEndpoint) + .requestDataDeletion( + session, + accessToken: params['accessToken'], + ), + ), + 'requestDataCorrection': _i1.MethodConnector( + name: 'requestDataCorrection', + params: { + 'accessToken': _i1.ParameterDescription( + name: 'accessToken', + type: _i1.getType(), + nullable: false, + ), + 'details': _i1.ParameterDescription( + name: 'details', + type: _i1.getType(), + nullable: false, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => (endpoints['patients'] as _i9.PatientsEndpoint) + .requestDataCorrection( + session, + accessToken: params['accessToken'], + details: params['details'], + ), + ), }, ); connectors['triage'] = _i1.EndpointConnector( @@ -482,7 +1770,7 @@ class Endpoints extends _i1.EndpointDispatch { ( _i1.Session session, Map params, - ) async => (endpoints['triage'] as _i7.TriageEndpoint).evaluate( + ) async => (endpoints['triage'] as _i10.TriageEndpoint).evaluate( session, accessToken: params['accessToken'], chestPain: params['chestPain'], @@ -495,6 +1783,30 @@ class Endpoints extends _i1.EndpointDispatch { ), }, ); + connectors['ubs'] = _i1.EndpointConnector( + name: 'ubs', + endpoint: endpoints['ubs']!, + methodConnectors: { + 'myContact': _i1.MethodConnector( + name: 'myContact', + params: { + 'accessToken': _i1.ParameterDescription( + name: 'accessToken', + type: _i1.getType(), + nullable: false, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => (endpoints['ubs'] as _i11.UbsEndpoint).myContact( + session, + accessToken: params['accessToken'], + ), + ), + }, + ); connectors['visits'] = _i1.EndpointConnector( name: 'visits', endpoint: endpoints['visits']!, @@ -509,7 +1821,7 @@ class Endpoints extends _i1.EndpointDispatch { ), 'visits': _i1.ParameterDescription( name: 'visits', - type: _i1.getType>(), + type: _i1.getType>(), nullable: false, ), }, @@ -517,12 +1829,93 @@ class Endpoints extends _i1.EndpointDispatch { ( _i1.Session session, Map params, - ) async => (endpoints['visits'] as _i8.VisitsEndpoint).sync( + ) async => (endpoints['visits'] as _i12.VisitsEndpoint).sync( session, accessToken: params['accessToken'], visits: params['visits'], ), ), + 'syncLegacy': _i1.MethodConnector( + name: 'syncLegacy', + params: { + 'accessToken': _i1.ParameterDescription( + name: 'accessToken', + type: _i1.getType(), + nullable: false, + ), + 'deviceId': _i1.ParameterDescription( + name: 'deviceId', + 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 _i12.VisitsEndpoint).syncLegacy( + session, + accessToken: params['accessToken'], + deviceId: params['deviceId'], + visits: params['visits'], + ), + ), + 'syncDeferred': _i1.MethodConnector( + name: 'syncDeferred', + params: { + 'uploadToken': _i1.ParameterDescription( + name: 'uploadToken', + type: _i1.getType(), + nullable: false, + ), + 'deviceId': _i1.ParameterDescription( + name: 'deviceId', + 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 _i12.VisitsEndpoint).syncDeferred( + session, + uploadToken: params['uploadToken'], + deviceId: params['deviceId'], + visits: params['visits'], + ), + ), + 'revokeUploadToken': _i1.MethodConnector( + name: 'revokeUploadToken', + params: { + 'uploadToken': _i1.ParameterDescription( + name: 'uploadToken', + type: _i1.getType(), + nullable: false, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => (endpoints['visits'] as _i12.VisitsEndpoint) + .revokeUploadToken( + session, + uploadToken: params['uploadToken'], + ), + ), 'pull': _i1.MethodConnector( name: 'pull', params: { @@ -541,7 +1934,7 @@ class Endpoints extends _i1.EndpointDispatch { ( _i1.Session session, Map params, - ) async => (endpoints['visits'] as _i8.VisitsEndpoint).pull( + ) async => (endpoints['visits'] as _i12.VisitsEndpoint).pull( session, accessToken: params['accessToken'], since: params['since'], diff --git a/backend/sinalacs_server/lib/src/generated/enums/audit_chain_reason.dart b/backend/sinalacs_server/lib/src/generated/enums/audit_chain_reason.dart new file mode 100644 index 00000000..957ed802 --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/enums/audit_chain_reason.dart @@ -0,0 +1,68 @@ +/* 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; + +/// Motivo fechado de uma verificação da cadeia de `audit_logs` que não deu +/// `ok` (#45, D9). Substitui, para o cliente, o texto livre `reason` do +/// verificador: nunca vai hash, segredo nem conteúdo de linha. +/// `hashMismatch` = HMAC da linha não confere; `linkBroken` = `previousHash` +/// não aponta para a anterior; `sequenceGap` = `sequence` descontínua; +/// `tailTruncated` = cadeia termina antes do último checkpoint válido, ou o +/// `entryHash` naquela `sequence` diverge do checkpoint (Task 7); +/// `checkpointInvalid` = cadeia íntegra, mas há checkpoint com assinatura +/// inválida — ignorado e sinalizado, nunca aceito (Task 7; estado inconclusive); +/// `timeBudget` = orçamento de tempo esgotado antes do fim (resultado parcial, +/// nunca íntegro); `databaseError` = falha ao ler; `secretUnavailable` = +/// segredo da cadeia ausente. +enum AuditChainReason implements _i1.SerializableModel { + hashMismatch, + linkBroken, + sequenceGap, + tailTruncated, + checkpointInvalid, + timeBudget, + databaseError, + secretUnavailable; + + static AuditChainReason fromJson(String name) { + switch (name) { + case 'hashMismatch': + return AuditChainReason.hashMismatch; + case 'linkBroken': + return AuditChainReason.linkBroken; + case 'sequenceGap': + return AuditChainReason.sequenceGap; + case 'tailTruncated': + return AuditChainReason.tailTruncated; + case 'checkpointInvalid': + return AuditChainReason.checkpointInvalid; + case 'timeBudget': + return AuditChainReason.timeBudget; + case 'databaseError': + return AuditChainReason.databaseError; + case 'secretUnavailable': + return AuditChainReason.secretUnavailable; + default: + throw ArgumentError( + 'Value "$name" cannot be converted to "AuditChainReason"', + ); + } + } + + @override + String toJson() => name; + + @override + String toString() => name; +} diff --git a/backend/sinalacs_server/lib/src/generated/enums/audit_integrity_state.dart b/backend/sinalacs_server/lib/src/generated/enums/audit_integrity_state.dart new file mode 100644 index 00000000..feac239e --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/enums/audit_integrity_state.dart @@ -0,0 +1,51 @@ +/* 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; + +/// Estado global da cadeia de `audit_logs` (#45, D3), calculado no servidor +/// por `admin.verifyAuditChain`. **Só `verified` é verde.** `broken` = a +/// verificação achou uma quebra (pode ser adulteração ou segredo trocado: o +/// painel diz "falha", nunca "adulteração" por conta própria); +/// `unavailable` = não deu para verificar (banco, tempo de leitura ou segredo +/// ausente); `inconclusive` = nada a atestar (cadeia vazia) ou verificação +/// parcial (orçamento de tempo esgotado). +enum AuditIntegrityState implements _i1.SerializableModel { + verified, + broken, + unavailable, + inconclusive; + + static AuditIntegrityState fromJson(String name) { + switch (name) { + case 'verified': + return AuditIntegrityState.verified; + case 'broken': + return AuditIntegrityState.broken; + case 'unavailable': + return AuditIntegrityState.unavailable; + case 'inconclusive': + return AuditIntegrityState.inconclusive; + default: + throw ArgumentError( + 'Value "$name" cannot be converted to "AuditIntegrityState"', + ); + } + } + + @override + String toJson() => name; + + @override + String toString() => name; +} diff --git a/backend/sinalacs_server/lib/src/generated/enums/audit_row_seal.dart b/backend/sinalacs_server/lib/src/generated/enums/audit_row_seal.dart new file mode 100644 index 00000000..dd94d260 --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/enums/audit_row_seal.dart @@ -0,0 +1,46 @@ +/* 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; + +/// Selo de integridade de UMA linha de `audit_logs` (#45, D3), calculado no +/// servidor com a mesma `AuditChain` que grava a trilha. Só `verified` é verde: +/// `invalid` = o HMAC da linha ou o elo com a `sequence - 1` não confere; +/// `unverifiable` = a linha `sequence - 1` não existe no banco (o elo não pôde +/// ser conferido). Nunca carrega hash nem segredo. +enum AuditRowSeal implements _i1.SerializableModel { + verified, + invalid, + unverifiable; + + static AuditRowSeal fromJson(String name) { + switch (name) { + case 'verified': + return AuditRowSeal.verified; + case 'invalid': + return AuditRowSeal.invalid; + case 'unverifiable': + return AuditRowSeal.unverifiable; + default: + throw ArgumentError( + 'Value "$name" cannot be converted to "AuditRowSeal"', + ); + } + } + + @override + String toJson() => name; + + @override + String toString() => name; +} diff --git a/backend/sinalacs_server/lib/src/generated/enums/consent_purpose.dart b/backend/sinalacs_server/lib/src/generated/enums/consent_purpose.dart index ca102b58..6902cbf3 100644 --- a/backend/sinalacs_server/lib/src/generated/enums/consent_purpose.dart +++ b/backend/sinalacs_server/lib/src/generated/enums/consent_purpose.dart @@ -13,14 +13,18 @@ import 'package:serverpod/serverpod.dart' as _i1; -/// As três finalidades de consentimento do onboarding (decisão §2.2 de +/// As finalidades de consentimento do onboarding (decisão §2.2 de /// docs/superpowers/specs/2026-09-16-decisoes-produto-pos-validacao.md). -/// `healthDataProcessing` é obrigatória para usar o app; as outras duas -/// podem ser recusadas sem impedir o restante do fluxo. +/// `healthDataProcessing` e `termsOfUse` são obrigatórias para usar o app; +/// as outras duas podem ser recusadas sem impedir o restante do fluxo. +/// `termsOfUse` registra o aceite do Termo de Uso e da Política de +/// Privacidade na versão `consentPolicyVersion` (LGPD-RF18/RF19) — não é +/// consentimento revogável pelo painel, é a condição de uso do app. enum ConsentPurpose implements _i1.SerializableModel { healthDataProcessing, localReminders, - segmentedPush; + segmentedPush, + termsOfUse; static ConsentPurpose fromJson(String name) { switch (name) { @@ -30,6 +34,8 @@ enum ConsentPurpose implements _i1.SerializableModel { return ConsentPurpose.localReminders; case 'segmentedPush': return ConsentPurpose.segmentedPush; + case 'termsOfUse': + return ConsentPurpose.termsOfUse; default: throw ArgumentError( 'Value "$name" cannot be converted to "ConsentPurpose"', diff --git a/backend/sinalacs_server/lib/src/generated/enums/data_subject_request_status.dart b/backend/sinalacs_server/lib/src/generated/enums/data_subject_request_status.dart new file mode 100644 index 00000000..2dd3d18a --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/enums/data_subject_request_status.dart @@ -0,0 +1,47 @@ +/* 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; + +/// Situação de um pedido do titular. `open` é escrito pelo titular; `inReview`, +/// `completed` e `rejected` pelo backoffice (coordenador/admin, #42). +/// `completed` e `rejected` são finais. +enum DataSubjectRequestStatus implements _i1.SerializableModel { + open, + inReview, + completed, + rejected; + + static DataSubjectRequestStatus fromJson(String name) { + switch (name) { + case 'open': + return DataSubjectRequestStatus.open; + case 'inReview': + return DataSubjectRequestStatus.inReview; + case 'completed': + return DataSubjectRequestStatus.completed; + case 'rejected': + return DataSubjectRequestStatus.rejected; + default: + throw ArgumentError( + 'Value "$name" cannot be converted to "DataSubjectRequestStatus"', + ); + } + } + + @override + String toJson() => name; + + @override + String toString() => name; +} diff --git a/backend/sinalacs_server/lib/src/generated/enums/data_subject_request_type.dart b/backend/sinalacs_server/lib/src/generated/enums/data_subject_request_type.dart new file mode 100644 index 00000000..37ee31f2 --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/enums/data_subject_request_type.dart @@ -0,0 +1,40 @@ +/* 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; + +/// Tipo de pedido do titular sobre os próprios dados (LGPD-RF08, Art. 18): +/// exclusão/anonimização ou correção. Ver spec/lgpd_design.md linhas 583-597. +enum DataSubjectRequestType implements _i1.SerializableModel { + deletion, + correction; + + static DataSubjectRequestType fromJson(String name) { + switch (name) { + case 'deletion': + return DataSubjectRequestType.deletion; + case 'correction': + return DataSubjectRequestType.correction; + default: + throw ArgumentError( + 'Value "$name" cannot be converted to "DataSubjectRequestType"', + ); + } + } + + @override + String toJson() => name; + + @override + String toString() => name; +} diff --git a/backend/sinalacs_server/lib/src/generated/enums/escalation_reason.dart b/backend/sinalacs_server/lib/src/generated/enums/escalation_reason.dart new file mode 100644 index 00000000..a1433285 --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/enums/escalation_reason.dart @@ -0,0 +1,43 @@ +/* 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; + +/// Motivo do escalonamento de um alerta (#44). Conjunto fechado, sem texto +/// livre: texto livre num alerta de saúde é dado pessoal sensível (LGPD). +enum EscalationReason implements _i1.SerializableModel { + noAckTimeout, + acsUnreachable, + patientRecontact; + + static EscalationReason fromJson(String name) { + switch (name) { + case 'noAckTimeout': + return EscalationReason.noAckTimeout; + case 'acsUnreachable': + return EscalationReason.acsUnreachable; + case 'patientRecontact': + return EscalationReason.patientRecontact; + default: + throw ArgumentError( + 'Value "$name" cannot be converted to "EscalationReason"', + ); + } + } + + @override + String toJson() => name; + + @override + String toString() => name; +} diff --git a/backend/sinalacs_server/lib/src/generated/enums/scheduled_notice_status.dart b/backend/sinalacs_server/lib/src/generated/enums/scheduled_notice_status.dart new file mode 100644 index 00000000..51463857 --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/enums/scheduled_notice_status.dart @@ -0,0 +1,51 @@ +/* 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; + +/// Ciclo de um aviso agendado. `sending` é a reserva atômica anti-duplicidade. +enum ScheduledNoticeStatus implements _i1.SerializableModel { + pending, + sending, + sent, + unknown, + failed, + cancelled; + + static ScheduledNoticeStatus fromJson(String name) { + switch (name) { + case 'pending': + return ScheduledNoticeStatus.pending; + case 'sending': + return ScheduledNoticeStatus.sending; + case 'sent': + return ScheduledNoticeStatus.sent; + case 'unknown': + return ScheduledNoticeStatus.unknown; + case 'failed': + return ScheduledNoticeStatus.failed; + case 'cancelled': + return ScheduledNoticeStatus.cancelled; + default: + throw ArgumentError( + 'Value "$name" cannot be converted to "ScheduledNoticeStatus"', + ); + } + } + + @override + String toJson() => name; + + @override + String toString() => name; +} diff --git a/backend/sinalacs_server/lib/src/generated/enums/visit_authorship.dart b/backend/sinalacs_server/lib/src/generated/enums/visit_authorship.dart new file mode 100644 index 00000000..4e5b99e8 --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/enums/visit_authorship.dart @@ -0,0 +1,44 @@ +/* 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; + +/// Quem é o autor de uma visita (D4 do plano 2026-10-03). +/// +/// `acs`: o ACS da sessão que a enviou por `visits.sync` — `visits.acsId` é +/// ele. `legacyUnclaimed`: visita gravada no aparelho antes de existir dono por +/// visita (migração v7 do banco local do app); a autoria é DESCONHECIDA e +/// `visits.acsId` fica nulo. Só `visits.syncLegacy` grava este valor. +enum VisitAuthorship implements _i1.SerializableModel { + acs, + legacyUnclaimed; + + static VisitAuthorship fromJson(String name) { + switch (name) { + case 'acs': + return VisitAuthorship.acs; + case 'legacyUnclaimed': + return VisitAuthorship.legacyUnclaimed; + default: + throw ArgumentError( + 'Value "$name" cannot be converted to "VisitAuthorship"', + ); + } + } + + @override + String toJson() => name; + + @override + String toString() => name; +} diff --git a/backend/sinalacs_server/lib/src/generated/exceptions/admin_invalid_request_exception.dart b/backend/sinalacs_server/lib/src/generated/exceptions/admin_invalid_request_exception.dart new file mode 100644 index 00000000..b4b69cf5 --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/exceptions/admin_invalid_request_exception.dart @@ -0,0 +1,75 @@ +/* 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; + +/// Parâmetro do backoffice fora do intervalo (por exemplo, `limit` de paginação). +/// Não é erro de permissão. +abstract class AdminInvalidRequestException + implements + _i1.SerializableException, + _i1.SerializableModel, + _i1.ProtocolSerialization { + AdminInvalidRequestException._({required this.message}); + + factory AdminInvalidRequestException({required String message}) = + _AdminInvalidRequestExceptionImpl; + + factory AdminInvalidRequestException.fromJson( + Map jsonSerialization, + ) { + return AdminInvalidRequestException( + message: jsonSerialization['message'] as String, + ); + } + + String message; + + /// Returns a shallow copy of this [AdminInvalidRequestException] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + AdminInvalidRequestException copyWith({String? message}); + @override + Map toJson() { + return { + '__className__': 'AdminInvalidRequestException', + 'message': message, + }; + } + + @override + Map toJsonForProtocol() { + return { + '__className__': 'AdminInvalidRequestException', + 'message': message, + }; + } + + @override + String toString() { + return 'AdminInvalidRequestException(message: $message)'; + } +} + +class _AdminInvalidRequestExceptionImpl extends AdminInvalidRequestException { + _AdminInvalidRequestExceptionImpl({required String message}) + : super._(message: message); + + /// Returns a shallow copy of this [AdminInvalidRequestException] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + AdminInvalidRequestException copyWith({String? message}) { + return AdminInvalidRequestException(message: message ?? this.message); + } +} diff --git a/backend/sinalacs_server/lib/src/generated/exceptions/data_rights_exception.dart b/backend/sinalacs_server/lib/src/generated/exceptions/data_rights_exception.dart new file mode 100644 index 00000000..962b512d --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/exceptions/data_rights_exception.dart @@ -0,0 +1,72 @@ +/* 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; + +/// Recusa de negócio de uma operação do titular sobre os próprios dados +/// (consentimento obrigatório, texto de correção vazio ou longo demais). +/// Mensagem pronta para a pessoa ler; nunca cita dado do paciente. +abstract class DataRightsException + implements + _i1.SerializableException, + _i1.SerializableModel, + _i1.ProtocolSerialization { + DataRightsException._({required this.message}); + + factory DataRightsException({required String message}) = + _DataRightsExceptionImpl; + + factory DataRightsException.fromJson(Map jsonSerialization) { + return DataRightsException(message: jsonSerialization['message'] as String); + } + + String message; + + /// Returns a shallow copy of this [DataRightsException] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + DataRightsException copyWith({String? message}); + @override + Map toJson() { + return { + '__className__': 'DataRightsException', + 'message': message, + }; + } + + @override + Map toJsonForProtocol() { + return { + '__className__': 'DataRightsException', + 'message': message, + }; + } + + @override + String toString() { + return 'DataRightsException(message: $message)'; + } +} + +class _DataRightsExceptionImpl extends DataRightsException { + _DataRightsExceptionImpl({required String message}) + : super._(message: message); + + /// Returns a shallow copy of this [DataRightsException] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + DataRightsException copyWith({String? message}) { + return DataRightsException(message: message ?? this.message); + } +} diff --git a/backend/sinalacs_server/lib/src/generated/exceptions/mfa_enrollment_required_exception.dart b/backend/sinalacs_server/lib/src/generated/exceptions/mfa_enrollment_required_exception.dart new file mode 100644 index 00000000..662c4d9d --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/exceptions/mfa_enrollment_required_exception.dart @@ -0,0 +1,76 @@ +/* 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; + +/// O servidor exige MFA e este ACS ainda não a ativou: a pessoa precisa +/// ativar (auth.beginTotpEnrollment) antes de entrar. +abstract class MfaEnrollmentRequiredException + implements + _i1.SerializableException, + _i1.SerializableModel, + _i1.ProtocolSerialization { + MfaEnrollmentRequiredException._({required this.message}); + + factory MfaEnrollmentRequiredException({required String message}) = + _MfaEnrollmentRequiredExceptionImpl; + + factory MfaEnrollmentRequiredException.fromJson( + Map jsonSerialization, + ) { + return MfaEnrollmentRequiredException( + message: jsonSerialization['message'] as String, + ); + } + + String message; + + /// Returns a shallow copy of this [MfaEnrollmentRequiredException] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + MfaEnrollmentRequiredException copyWith({String? message}); + @override + Map toJson() { + return { + '__className__': 'MfaEnrollmentRequiredException', + 'message': message, + }; + } + + @override + Map toJsonForProtocol() { + return { + '__className__': 'MfaEnrollmentRequiredException', + 'message': message, + }; + } + + @override + String toString() { + return 'MfaEnrollmentRequiredException(message: $message)'; + } +} + +class _MfaEnrollmentRequiredExceptionImpl + extends MfaEnrollmentRequiredException { + _MfaEnrollmentRequiredExceptionImpl({required String message}) + : super._(message: message); + + /// Returns a shallow copy of this [MfaEnrollmentRequiredException] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + MfaEnrollmentRequiredException copyWith({String? message}) { + return MfaEnrollmentRequiredException(message: message ?? this.message); + } +} diff --git a/backend/sinalacs_server/lib/src/generated/exceptions/mfa_required_exception.dart b/backend/sinalacs_server/lib/src/generated/exceptions/mfa_required_exception.dart new file mode 100644 index 00000000..1b5f3772 --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/exceptions/mfa_required_exception.dart @@ -0,0 +1,75 @@ +/* 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; + +/// O ACS tem MFA ativa, a senha conferiu e faltou o código do autenticador. +/// Só é lançada DEPOIS de a senha estar certa: não revela matrícula nem senha. +abstract class MfaRequiredException + implements + _i1.SerializableException, + _i1.SerializableModel, + _i1.ProtocolSerialization { + MfaRequiredException._({required this.message}); + + factory MfaRequiredException({required String message}) = + _MfaRequiredExceptionImpl; + + factory MfaRequiredException.fromJson( + Map jsonSerialization, + ) { + return MfaRequiredException( + message: jsonSerialization['message'] as String, + ); + } + + String message; + + /// Returns a shallow copy of this [MfaRequiredException] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + MfaRequiredException copyWith({String? message}); + @override + Map toJson() { + return { + '__className__': 'MfaRequiredException', + 'message': message, + }; + } + + @override + Map toJsonForProtocol() { + return { + '__className__': 'MfaRequiredException', + 'message': message, + }; + } + + @override + String toString() { + return 'MfaRequiredException(message: $message)'; + } +} + +class _MfaRequiredExceptionImpl extends MfaRequiredException { + _MfaRequiredExceptionImpl({required String message}) + : super._(message: message); + + /// Returns a shallow copy of this [MfaRequiredException] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + MfaRequiredException copyWith({String? message}) { + return MfaRequiredException(message: message ?? this.message); + } +} diff --git a/backend/sinalacs_server/lib/src/generated/exceptions/notice_delivery_exception.dart b/backend/sinalacs_server/lib/src/generated/exceptions/notice_delivery_exception.dart new file mode 100644 index 00000000..e0a0c35c --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/exceptions/notice_delivery_exception.dart @@ -0,0 +1,76 @@ +/* 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; + +/// O aviso comunitário não pôde ser entregue agora: o relé de push está fora do +/// ar, lento, ou o envio não está configurado neste ambiente. Mensagem pronta +/// para o ACS ler; nunca cita token, destinatário nem o texto enviado. +abstract class NoticeDeliveryException + implements + _i1.SerializableException, + _i1.SerializableModel, + _i1.ProtocolSerialization { + NoticeDeliveryException._({required this.message}); + + factory NoticeDeliveryException({required String message}) = + _NoticeDeliveryExceptionImpl; + + factory NoticeDeliveryException.fromJson( + Map jsonSerialization, + ) { + return NoticeDeliveryException( + message: jsonSerialization['message'] as String, + ); + } + + String message; + + /// Returns a shallow copy of this [NoticeDeliveryException] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + NoticeDeliveryException copyWith({String? message}); + @override + Map toJson() { + return { + '__className__': 'NoticeDeliveryException', + 'message': message, + }; + } + + @override + Map toJsonForProtocol() { + return { + '__className__': 'NoticeDeliveryException', + 'message': message, + }; + } + + @override + String toString() { + return 'NoticeDeliveryException(message: $message)'; + } +} + +class _NoticeDeliveryExceptionImpl extends NoticeDeliveryException { + _NoticeDeliveryExceptionImpl({required String message}) + : super._(message: message); + + /// Returns a shallow copy of this [NoticeDeliveryException] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + NoticeDeliveryException copyWith({String? message}) { + return NoticeDeliveryException(message: message ?? this.message); + } +} diff --git a/backend/sinalacs_server/lib/src/generated/exceptions/session_expired_exception.dart b/backend/sinalacs_server/lib/src/generated/exceptions/session_expired_exception.dart new file mode 100644 index 00000000..16fb02de --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/exceptions/session_expired_exception.dart @@ -0,0 +1,76 @@ +/* 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; + +/// O refresh token não vale mais (expirado, revogado, reusado, de outro +/// aparelho ou de conta desativada). Mensagem única de propósito: o motivo +/// real fica só em audit_logs. +abstract class SessionExpiredException + implements + _i1.SerializableException, + _i1.SerializableModel, + _i1.ProtocolSerialization { + SessionExpiredException._({required this.message}); + + factory SessionExpiredException({required String message}) = + _SessionExpiredExceptionImpl; + + factory SessionExpiredException.fromJson( + Map jsonSerialization, + ) { + return SessionExpiredException( + message: jsonSerialization['message'] as String, + ); + } + + String message; + + /// Returns a shallow copy of this [SessionExpiredException] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + SessionExpiredException copyWith({String? message}); + @override + Map toJson() { + return { + '__className__': 'SessionExpiredException', + 'message': message, + }; + } + + @override + Map toJsonForProtocol() { + return { + '__className__': 'SessionExpiredException', + 'message': message, + }; + } + + @override + String toString() { + return 'SessionExpiredException(message: $message)'; + } +} + +class _SessionExpiredExceptionImpl extends SessionExpiredException { + _SessionExpiredExceptionImpl({required String message}) + : super._(message: message); + + /// Returns a shallow copy of this [SessionExpiredException] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + SessionExpiredException copyWith({String? message}) { + return SessionExpiredException(message: message ?? this.message); + } +} diff --git a/backend/sinalacs_server/lib/src/generated/patient.dart b/backend/sinalacs_server/lib/src/generated/patient.dart index 4877dcc7..e1bc234f 100644 --- a/backend/sinalacs_server/lib/src/generated/patient.dart +++ b/backend/sinalacs_server/lib/src/generated/patient.dart @@ -25,18 +25,22 @@ abstract class Patient implements _i1.TableRow<_i1.UuidValue?>, _i1.ProtocolSerialization { Patient._({ this.id, - required this.emergencyContact, + String? emergencyContactEncrypted, + int? emergencyContactKeyVersion, required this.isChronic, String? chronicConditionsEncrypted, int? chronicConditionsKeyVersion, this.lastLocationHash, this.lastTriageAt, - }) : chronicConditionsEncrypted = chronicConditionsEncrypted ?? '', + }) : emergencyContactEncrypted = emergencyContactEncrypted ?? '', + emergencyContactKeyVersion = emergencyContactKeyVersion ?? 1, + chronicConditionsEncrypted = chronicConditionsEncrypted ?? '', chronicConditionsKeyVersion = chronicConditionsKeyVersion ?? 1; factory Patient({ _i1.UuidValue? id, - required String emergencyContact, + String? emergencyContactEncrypted, + int? emergencyContactKeyVersion, required bool isChronic, String? chronicConditionsEncrypted, int? chronicConditionsKeyVersion, @@ -49,7 +53,10 @@ abstract class Patient id: jsonSerialization['id'] == null ? null : _i1.UuidValueJsonExtension.fromJson(jsonSerialization['id']), - emergencyContact: jsonSerialization['emergencyContact'] as String, + emergencyContactEncrypted: + jsonSerialization['emergencyContactEncrypted'] as String?, + emergencyContactKeyVersion: + jsonSerialization['emergencyContactKeyVersion'] as int?, isChronic: _i1.BoolJsonExtension.fromJson(jsonSerialization['isChronic']), chronicConditionsEncrypted: jsonSerialization['chronicConditionsEncrypted'] as String?, @@ -71,7 +78,17 @@ abstract class Patient @override _i1.UuidValue? id; - String emergencyContact; + /// Contato de emergência do paciente (PII de terceiro: nome/telefone), + /// cifrado em AES-256-GCM (RNF03, INV-04) — mesmo padrão de + /// chronicConditions. Antes era `emergencyContact` em texto claro; agora o + /// par `emergencyContactEncrypted`/`emergencyContactKeyVersion` guarda o + /// ciphertext, cifrado e decifrado na borda do repositório ORM + /// (OrmPatientDirectoryStore), nunca em application/. + String emergencyContactEncrypted; + + /// Versão da chave que cifrou o campo acima, para permitir rotação futura + /// sem reescrever todas as linhas de uma vez. + int emergencyContactKeyVersion; bool isChronic; @@ -102,7 +119,8 @@ abstract class Patient @_i1.useResult Patient copyWith({ _i1.UuidValue? id, - String? emergencyContact, + String? emergencyContactEncrypted, + int? emergencyContactKeyVersion, bool? isChronic, String? chronicConditionsEncrypted, int? chronicConditionsKeyVersion, @@ -114,7 +132,8 @@ abstract class Patient return { '__className__': 'Patient', if (id != null) 'id': id?.toJson(), - 'emergencyContact': emergencyContact, + 'emergencyContactEncrypted': emergencyContactEncrypted, + 'emergencyContactKeyVersion': emergencyContactKeyVersion, 'isChronic': isChronic, 'chronicConditionsEncrypted': chronicConditionsEncrypted, 'chronicConditionsKeyVersion': chronicConditionsKeyVersion, @@ -128,7 +147,8 @@ abstract class Patient return { '__className__': 'Patient', if (id != null) 'id': id?.toJson(), - 'emergencyContact': emergencyContact, + 'emergencyContactEncrypted': emergencyContactEncrypted, + 'emergencyContactKeyVersion': emergencyContactKeyVersion, 'isChronic': isChronic, 'chronicConditionsEncrypted': chronicConditionsEncrypted, 'chronicConditionsKeyVersion': chronicConditionsKeyVersion, @@ -172,7 +192,8 @@ class _Undefined {} class _PatientImpl extends Patient { _PatientImpl({ _i1.UuidValue? id, - required String emergencyContact, + String? emergencyContactEncrypted, + int? emergencyContactKeyVersion, required bool isChronic, String? chronicConditionsEncrypted, int? chronicConditionsKeyVersion, @@ -180,7 +201,8 @@ class _PatientImpl extends Patient { DateTime? lastTriageAt, }) : super._( id: id, - emergencyContact: emergencyContact, + emergencyContactEncrypted: emergencyContactEncrypted, + emergencyContactKeyVersion: emergencyContactKeyVersion, isChronic: isChronic, chronicConditionsEncrypted: chronicConditionsEncrypted, chronicConditionsKeyVersion: chronicConditionsKeyVersion, @@ -194,7 +216,8 @@ class _PatientImpl extends Patient { @override Patient copyWith({ Object? id = _Undefined, - String? emergencyContact, + String? emergencyContactEncrypted, + int? emergencyContactKeyVersion, bool? isChronic, String? chronicConditionsEncrypted, int? chronicConditionsKeyVersion, @@ -203,7 +226,10 @@ class _PatientImpl extends Patient { }) { return Patient( id: id is _i1.UuidValue? ? id : this.id, - emergencyContact: emergencyContact ?? this.emergencyContact, + emergencyContactEncrypted: + emergencyContactEncrypted ?? this.emergencyContactEncrypted, + emergencyContactKeyVersion: + emergencyContactKeyVersion ?? this.emergencyContactKeyVersion, isChronic: isChronic ?? this.isChronic, chronicConditionsEncrypted: chronicConditionsEncrypted ?? this.chronicConditionsEncrypted, @@ -222,9 +248,15 @@ class _PatientImpl extends Patient { class PatientUpdateTable extends _i1.UpdateTable { PatientUpdateTable(super.table); - _i1.ColumnValue emergencyContact(String value) => + _i1.ColumnValue emergencyContactEncrypted(String value) => + _i1.ColumnValue( + table.emergencyContactEncrypted, + value, + ); + + _i1.ColumnValue emergencyContactKeyVersion(int value) => _i1.ColumnValue( - table.emergencyContact, + table.emergencyContactKeyVersion, value, ); @@ -261,9 +293,15 @@ class PatientUpdateTable extends _i1.UpdateTable { class PatientTable extends _i1.Table<_i1.UuidValue?> { PatientTable({super.tableRelation}) : super(tableName: 'patients') { updateTable = PatientUpdateTable(this); - emergencyContact = _i1.ColumnString( - 'emergencyContact', + emergencyContactEncrypted = _i1.ColumnString( + 'emergencyContactEncrypted', this, + hasDefault: true, + ); + emergencyContactKeyVersion = _i1.ColumnInt( + 'emergencyContactKeyVersion', + this, + hasDefault: true, ); isChronic = _i1.ColumnBool( 'isChronic', @@ -291,7 +329,17 @@ class PatientTable extends _i1.Table<_i1.UuidValue?> { late final PatientUpdateTable updateTable; - late final _i1.ColumnString emergencyContact; + /// Contato de emergência do paciente (PII de terceiro: nome/telefone), + /// cifrado em AES-256-GCM (RNF03, INV-04) — mesmo padrão de + /// chronicConditions. Antes era `emergencyContact` em texto claro; agora o + /// par `emergencyContactEncrypted`/`emergencyContactKeyVersion` guarda o + /// ciphertext, cifrado e decifrado na borda do repositório ORM + /// (OrmPatientDirectoryStore), nunca em application/. + late final _i1.ColumnString emergencyContactEncrypted; + + /// Versão da chave que cifrou o campo acima, para permitir rotação futura + /// sem reescrever todas as linhas de uma vez. + late final _i1.ColumnInt emergencyContactKeyVersion; late final _i1.ColumnBool isChronic; @@ -317,7 +365,8 @@ class PatientTable extends _i1.Table<_i1.UuidValue?> { @override List<_i1.Column> get columns => [ id, - emergencyContact, + emergencyContactEncrypted, + emergencyContactKeyVersion, isChronic, chronicConditionsEncrypted, chronicConditionsKeyVersion, diff --git a/backend/sinalacs_server/lib/src/generated/protocol.dart b/backend/sinalacs_server/lib/src/generated/protocol.dart index 182fd669..1bda6735 100644 --- a/backend/sinalacs_server/lib/src/generated/protocol.dart +++ b/backend/sinalacs_server/lib/src/generated/protocol.dart @@ -14,93 +14,199 @@ import 'package:serverpod/serverpod.dart' as _i1; import 'package:serverpod/protocol.dart' as _i2; import 'acs.dart' as _i3; -import 'alert.dart' as _i4; -import 'alert_delivery_record.dart' as _i5; -import 'alert_idempotency_key.dart' as _i6; -import 'alert_outbox_entry.dart' as _i7; -import 'api/alert_ack_result.dart' as _i8; -import 'api/alert_status_result.dart' as _i9; -import 'api/development_login_result.dart' as _i10; -import 'api/enrollment_result.dart' as _i11; -import 'api/enrollment_token_result.dart' as _i12; -import 'api/micro_area_patient.dart' as _i13; -import 'api/patient_consent_record.dart' as _i14; -import 'api/patient_data_overview.dart' as _i15; -import 'api/patient_risk_event.dart' as _i16; -import 'api/red_alert_result.dart' as _i17; -import 'api/service_health.dart' as _i18; -import 'api/triage_result.dart' as _i19; -import 'api/visit_sync_entry.dart' as _i20; -import 'api/visit_sync_result.dart' as _i21; -import 'audit_log.dart' as _i22; -import 'consent_log.dart' as _i23; -import 'enrollment_token.dart' as _i24; -import 'enums/alert_status.dart' as _i25; -import 'enums/arrival_method.dart' as _i26; -import 'enums/consent_purpose.dart' as _i27; -import 'enums/risk_level.dart' as _i28; -import 'enums/sync_status.dart' as _i29; -import 'enums/user_role.dart' as _i30; -import 'exceptions/alert_dispatch_unavailable_exception.dart' as _i31; -import 'exceptions/alert_permission_exception.dart' as _i32; -import 'exceptions/alert_validation_exception.dart' as _i33; -import 'exceptions/authentication_failed_exception.dart' as _i34; -import 'exceptions/endpoint_disabled_exception.dart' as _i35; -import 'exceptions/enrollment_exception.dart' as _i36; -import 'exceptions/otp_request_exception.dart' as _i37; -import 'micro_area.dart' as _i38; -import 'otp_challenge.dart' as _i39; -import 'patient.dart' as _i40; -import 'triage_answer.dart' as _i41; -import 'triage_session.dart' as _i42; -import 'ubs.dart' as _i43; -import 'user.dart' as _i44; -import 'user_credential.dart' as _i45; -import 'visit.dart' as _i46; +import 'acs_refresh_token.dart' as _i4; +import 'acs_upload_token.dart' as _i5; +import 'alert.dart' as _i6; +import 'alert_delivery_record.dart' as _i7; +import 'alert_idempotency_key.dart' as _i8; +import 'alert_outbox_entry.dart' as _i9; +import 'api/admin_acs.dart' as _i10; +import 'api/admin_acs_creation_result.dart' as _i11; +import 'api/admin_alert.dart' as _i12; +import 'api/admin_alert_page.dart' as _i13; +import 'api/admin_alerts_pulse.dart' as _i14; +import 'api/admin_audit_chain_status.dart' as _i15; +import 'api/admin_audit_entry.dart' as _i16; +import 'api/admin_audit_export.dart' as _i17; +import 'api/admin_audit_filter.dart' as _i18; +import 'api/admin_audit_page.dart' as _i19; +import 'api/admin_data_subject_request.dart' as _i20; +import 'api/admin_data_subject_request_detail.dart' as _i21; +import 'api/admin_data_subject_request_page.dart' as _i22; +import 'api/admin_escalation_result.dart' as _i23; +import 'api/admin_indicators.dart' as _i24; +import 'api/admin_micro_area.dart' as _i25; +import 'api/admin_password_reset_result.dart' as _i26; +import 'api/admin_staff.dart' as _i27; +import 'api/admin_staff_mfa_reset_result.dart' as _i28; +import 'api/alert_ack_result.dart' as _i29; +import 'api/alert_status_result.dart' as _i30; +import 'api/development_login_result.dart' as _i31; +import 'api/enrollment_result.dart' as _i32; +import 'api/enrollment_token_result.dart' as _i33; +import 'api/micro_area_patient.dart' as _i34; +import 'api/notice_history_entry.dart' as _i35; +import 'api/notice_history_page.dart' as _i36; +import 'api/notice_send_result.dart' as _i37; +import 'api/patient_consent_record.dart' as _i38; +import 'api/patient_data_overview.dart' as _i39; +import 'api/patient_data_subject_request_record.dart' as _i40; +import 'api/patient_risk_event.dart' as _i41; +import 'api/red_alert_result.dart' as _i42; +import 'api/service_health.dart' as _i43; +import 'api/terms_change_notice.dart' as _i44; +import 'api/terms_version_entry.dart' as _i45; +import 'api/totp_enrollment_start.dart' as _i46; +import 'api/triage_result.dart' as _i47; +import 'api/ubs_contact.dart' as _i48; +import 'api/visit_sync_entry.dart' as _i49; +import 'api/visit_sync_result.dart' as _i50; +import 'audit_chain_checkpoint.dart' as _i51; +import 'audit_export.dart' as _i52; +import 'audit_log.dart' as _i53; +import 'consent_log.dart' as _i54; +import 'data_subject_request.dart' as _i55; +import 'enrollment_token.dart' as _i56; +import 'enums/alert_status.dart' as _i57; +import 'enums/arrival_method.dart' as _i58; +import 'enums/audit_chain_reason.dart' as _i59; +import 'enums/audit_integrity_state.dart' as _i60; +import 'enums/audit_row_seal.dart' as _i61; +import 'enums/consent_purpose.dart' as _i62; +import 'enums/data_subject_request_status.dart' as _i63; +import 'enums/data_subject_request_type.dart' as _i64; +import 'enums/escalation_reason.dart' as _i65; +import 'enums/risk_level.dart' as _i66; +import 'enums/scheduled_notice_status.dart' as _i67; +import 'enums/sync_status.dart' as _i68; +import 'enums/user_role.dart' as _i69; +import 'enums/visit_authorship.dart' as _i70; +import 'exceptions/admin_invalid_request_exception.dart' as _i71; +import 'exceptions/alert_dispatch_unavailable_exception.dart' as _i72; +import 'exceptions/alert_permission_exception.dart' as _i73; +import 'exceptions/alert_validation_exception.dart' as _i74; +import 'exceptions/authentication_failed_exception.dart' as _i75; +import 'exceptions/data_rights_exception.dart' as _i76; +import 'exceptions/endpoint_disabled_exception.dart' as _i77; +import 'exceptions/enrollment_exception.dart' as _i78; +import 'exceptions/mfa_enrollment_required_exception.dart' as _i79; +import 'exceptions/mfa_required_exception.dart' as _i80; +import 'exceptions/notice_delivery_exception.dart' as _i81; +import 'exceptions/otp_request_exception.dart' as _i82; +import 'exceptions/session_expired_exception.dart' as _i83; +import 'micro_area.dart' as _i84; +import 'otp_challenge.dart' as _i85; +import 'patient.dart' as _i86; +import 'push_token.dart' as _i87; +import 'scheduled_notice.dart' as _i88; +import 'staff_account.dart' as _i89; +import 'terms_version.dart' as _i90; +import 'triage_answer.dart' as _i91; +import 'triage_session.dart' as _i92; +import 'ubs.dart' as _i93; +import 'user.dart' as _i94; +import 'user_credential.dart' as _i95; +import 'visit.dart' as _i96; +import 'package:sinalacs_server/src/generated/api/admin_micro_area.dart' + as _i97; +import 'package:sinalacs_server/src/generated/api/admin_acs.dart' as _i98; +import 'package:sinalacs_server/src/generated/api/admin_staff.dart' as _i99; +import 'package:sinalacs_server/src/generated/api/terms_version_entry.dart' + as _i100; import 'package:sinalacs_server/src/generated/api/micro_area_patient.dart' - as _i47; + as _i101; import 'package:sinalacs_server/src/generated/api/visit_sync_result.dart' - as _i48; + as _i102; import 'package:sinalacs_server/src/generated/api/visit_sync_entry.dart' - as _i49; + as _i103; export 'acs.dart'; +export 'acs_refresh_token.dart'; +export 'acs_upload_token.dart'; export 'alert.dart'; export 'alert_delivery_record.dart'; export 'alert_idempotency_key.dart'; export 'alert_outbox_entry.dart'; +export 'api/admin_acs.dart'; +export 'api/admin_acs_creation_result.dart'; +export 'api/admin_alert.dart'; +export 'api/admin_alert_page.dart'; +export 'api/admin_alerts_pulse.dart'; +export 'api/admin_audit_chain_status.dart'; +export 'api/admin_audit_entry.dart'; +export 'api/admin_audit_export.dart'; +export 'api/admin_audit_filter.dart'; +export 'api/admin_audit_page.dart'; +export 'api/admin_data_subject_request.dart'; +export 'api/admin_data_subject_request_detail.dart'; +export 'api/admin_data_subject_request_page.dart'; +export 'api/admin_escalation_result.dart'; +export 'api/admin_indicators.dart'; +export 'api/admin_micro_area.dart'; +export 'api/admin_password_reset_result.dart'; +export 'api/admin_staff.dart'; +export 'api/admin_staff_mfa_reset_result.dart'; export 'api/alert_ack_result.dart'; export 'api/alert_status_result.dart'; export 'api/development_login_result.dart'; export 'api/enrollment_result.dart'; export 'api/enrollment_token_result.dart'; export 'api/micro_area_patient.dart'; +export 'api/notice_history_entry.dart'; +export 'api/notice_history_page.dart'; +export 'api/notice_send_result.dart'; export 'api/patient_consent_record.dart'; export 'api/patient_data_overview.dart'; +export 'api/patient_data_subject_request_record.dart'; export 'api/patient_risk_event.dart'; export 'api/red_alert_result.dart'; export 'api/service_health.dart'; +export 'api/terms_change_notice.dart'; +export 'api/terms_version_entry.dart'; +export 'api/totp_enrollment_start.dart'; export 'api/triage_result.dart'; +export 'api/ubs_contact.dart'; export 'api/visit_sync_entry.dart'; export 'api/visit_sync_result.dart'; +export 'audit_chain_checkpoint.dart'; +export 'audit_export.dart'; export 'audit_log.dart'; export 'consent_log.dart'; +export 'data_subject_request.dart'; export 'enrollment_token.dart'; export 'enums/alert_status.dart'; export 'enums/arrival_method.dart'; +export 'enums/audit_chain_reason.dart'; +export 'enums/audit_integrity_state.dart'; +export 'enums/audit_row_seal.dart'; export 'enums/consent_purpose.dart'; +export 'enums/data_subject_request_status.dart'; +export 'enums/data_subject_request_type.dart'; +export 'enums/escalation_reason.dart'; export 'enums/risk_level.dart'; +export 'enums/scheduled_notice_status.dart'; export 'enums/sync_status.dart'; export 'enums/user_role.dart'; +export 'enums/visit_authorship.dart'; +export 'exceptions/admin_invalid_request_exception.dart'; export 'exceptions/alert_dispatch_unavailable_exception.dart'; export 'exceptions/alert_permission_exception.dart'; export 'exceptions/alert_validation_exception.dart'; export 'exceptions/authentication_failed_exception.dart'; +export 'exceptions/data_rights_exception.dart'; export 'exceptions/endpoint_disabled_exception.dart'; export 'exceptions/enrollment_exception.dart'; +export 'exceptions/mfa_enrollment_required_exception.dart'; +export 'exceptions/mfa_required_exception.dart'; +export 'exceptions/notice_delivery_exception.dart'; export 'exceptions/otp_request_exception.dart'; +export 'exceptions/session_expired_exception.dart'; export 'micro_area.dart'; export 'otp_challenge.dart'; export 'patient.dart'; +export 'push_token.dart'; +export 'scheduled_notice.dart'; +export 'staff_account.dart'; +export 'terms_version.dart'; export 'triage_answer.dart'; export 'triage_session.dart'; export 'ubs.dart'; @@ -198,6 +304,251 @@ class Protocol extends _i1.SerializationManagerServer { ], managed: true, ), + _i2.TableDefinition( + name: 'acs_refresh_tokens', + dartName: 'AcsRefreshToken', + schema: 'public', + module: 'sinalacs', + columns: [ + _i2.ColumnDefinition( + name: 'id', + columnType: _i2.ColumnType.uuid, + isNullable: false, + dartType: 'UuidValue?', + columnDefault: 'gen_random_uuid()', + ), + _i2.ColumnDefinition( + name: 'userId', + columnType: _i2.ColumnType.uuid, + isNullable: false, + dartType: 'UuidValue', + ), + _i2.ColumnDefinition( + name: 'familyId', + columnType: _i2.ColumnType.uuid, + isNullable: false, + dartType: 'UuidValue', + ), + _i2.ColumnDefinition( + name: 'tokenHash', + columnType: _i2.ColumnType.text, + isNullable: false, + dartType: 'String', + ), + _i2.ColumnDefinition( + name: 'deviceId', + columnType: _i2.ColumnType.text, + isNullable: false, + dartType: 'String', + ), + _i2.ColumnDefinition( + name: 'issuedAt', + columnType: _i2.ColumnType.timestampWithoutTimeZone, + isNullable: false, + dartType: 'DateTime', + ), + _i2.ColumnDefinition( + name: 'idleExpiresAt', + columnType: _i2.ColumnType.timestampWithoutTimeZone, + isNullable: false, + dartType: 'DateTime', + ), + _i2.ColumnDefinition( + name: 'absoluteExpiresAt', + columnType: _i2.ColumnType.timestampWithoutTimeZone, + isNullable: false, + dartType: 'DateTime', + ), + _i2.ColumnDefinition( + name: 'rotatedAt', + columnType: _i2.ColumnType.timestampWithoutTimeZone, + isNullable: true, + dartType: 'DateTime?', + ), + _i2.ColumnDefinition( + name: 'revokedAt', + columnType: _i2.ColumnType.timestampWithoutTimeZone, + isNullable: true, + dartType: 'DateTime?', + ), + ], + foreignKeys: [ + _i2.ForeignKeyDefinition( + constraintName: 'acs_refresh_tokens_fk_0', + columns: ['userId'], + referenceTable: 'users', + referenceTableSchema: 'public', + referenceColumns: ['id'], + onUpdate: _i2.ForeignKeyAction.noAction, + onDelete: _i2.ForeignKeyAction.noAction, + matchType: null, + ), + ], + indexes: [ + _i2.IndexDefinition( + indexName: 'acs_refresh_tokens_pkey', + tableSpace: null, + elements: [ + _i2.IndexElementDefinition( + type: _i2.IndexElementDefinitionType.column, + definition: 'id', + ), + ], + type: 'btree', + isUnique: true, + isPrimary: true, + ), + _i2.IndexDefinition( + indexName: 'acs_refresh_tokens_hash_key', + tableSpace: null, + elements: [ + _i2.IndexElementDefinition( + type: _i2.IndexElementDefinitionType.column, + definition: 'tokenHash', + ), + ], + type: 'btree', + isUnique: true, + isPrimary: false, + ), + _i2.IndexDefinition( + indexName: 'acs_refresh_tokens_family_idx', + tableSpace: null, + elements: [ + _i2.IndexElementDefinition( + type: _i2.IndexElementDefinitionType.column, + definition: 'familyId', + ), + ], + type: 'btree', + isUnique: false, + isPrimary: false, + ), + _i2.IndexDefinition( + indexName: 'acs_refresh_tokens_user_idx', + tableSpace: null, + elements: [ + _i2.IndexElementDefinition( + type: _i2.IndexElementDefinitionType.column, + definition: 'userId', + ), + ], + type: 'btree', + isUnique: false, + isPrimary: false, + ), + ], + managed: true, + ), + _i2.TableDefinition( + name: 'acs_upload_tokens', + dartName: 'AcsUploadToken', + schema: 'public', + module: 'sinalacs', + columns: [ + _i2.ColumnDefinition( + name: 'id', + columnType: _i2.ColumnType.uuid, + isNullable: false, + dartType: 'UuidValue?', + columnDefault: 'gen_random_uuid()', + ), + _i2.ColumnDefinition( + name: 'userId', + columnType: _i2.ColumnType.uuid, + isNullable: false, + dartType: 'UuidValue', + ), + _i2.ColumnDefinition( + name: 'tokenHash', + columnType: _i2.ColumnType.text, + isNullable: false, + dartType: 'String', + ), + _i2.ColumnDefinition( + name: 'deviceId', + columnType: _i2.ColumnType.text, + isNullable: false, + dartType: 'String', + ), + _i2.ColumnDefinition( + name: 'issuedAt', + columnType: _i2.ColumnType.timestampWithoutTimeZone, + isNullable: false, + dartType: 'DateTime', + ), + _i2.ColumnDefinition( + name: 'expiresAt', + columnType: _i2.ColumnType.timestampWithoutTimeZone, + isNullable: false, + dartType: 'DateTime', + ), + _i2.ColumnDefinition( + name: 'revokedAt', + columnType: _i2.ColumnType.timestampWithoutTimeZone, + isNullable: true, + dartType: 'DateTime?', + ), + ], + foreignKeys: [ + _i2.ForeignKeyDefinition( + constraintName: 'acs_upload_tokens_fk_0', + columns: ['userId'], + referenceTable: 'users', + referenceTableSchema: 'public', + referenceColumns: ['id'], + onUpdate: _i2.ForeignKeyAction.noAction, + onDelete: _i2.ForeignKeyAction.noAction, + matchType: null, + ), + ], + indexes: [ + _i2.IndexDefinition( + indexName: 'acs_upload_tokens_pkey', + tableSpace: null, + elements: [ + _i2.IndexElementDefinition( + type: _i2.IndexElementDefinitionType.column, + definition: 'id', + ), + ], + type: 'btree', + isUnique: true, + isPrimary: true, + ), + _i2.IndexDefinition( + indexName: 'acs_upload_tokens_hash_key', + tableSpace: null, + elements: [ + _i2.IndexElementDefinition( + type: _i2.IndexElementDefinitionType.column, + definition: 'tokenHash', + ), + ], + type: 'btree', + isUnique: true, + isPrimary: false, + ), + _i2.IndexDefinition( + indexName: 'acs_upload_tokens_user_device_idx', + tableSpace: null, + elements: [ + _i2.IndexElementDefinition( + type: _i2.IndexElementDefinitionType.column, + definition: 'userId', + ), + _i2.IndexElementDefinition( + type: _i2.IndexElementDefinitionType.column, + definition: 'deviceId', + ), + ], + type: 'btree', + isUnique: false, + isPrimary: false, + ), + ], + managed: true, + ), _i2.TableDefinition( name: 'alert_deliveries', dartName: 'AlertDeliveryRecord', @@ -577,6 +928,24 @@ class Protocol extends _i1.SerializationManagerServer { isNullable: false, dartType: 'int', ), + _i2.ColumnDefinition( + name: 'escalatedAt', + columnType: _i2.ColumnType.timestampWithoutTimeZone, + isNullable: true, + dartType: 'DateTime?', + ), + _i2.ColumnDefinition( + name: 'escalatedBy', + columnType: _i2.ColumnType.uuid, + isNullable: true, + dartType: 'UuidValue?', + ), + _i2.ColumnDefinition( + name: 'escalationReason', + columnType: _i2.ColumnType.text, + isNullable: true, + dartType: 'protocol:EscalationReason?', + ), ], foreignKeys: [ _i2.ForeignKeyDefinition( @@ -639,8 +1008,8 @@ class Protocol extends _i1.SerializationManagerServer { managed: true, ), _i2.TableDefinition( - name: 'audit_logs', - dartName: 'AuditLog', + name: 'audit_chain_checkpoints', + dartName: 'AuditChainCheckpoint', schema: 'public', module: 'sinalacs', columns: [ @@ -652,31 +1021,210 @@ class Protocol extends _i1.SerializationManagerServer { columnDefault: 'gen_random_uuid()', ), _i2.ColumnDefinition( - name: 'userId', - columnType: _i2.ColumnType.uuid, + name: 'sequence', + columnType: _i2.ColumnType.bigint, isNullable: false, - dartType: 'UuidValue', + dartType: 'int', ), _i2.ColumnDefinition( - name: 'actionType', + name: 'entryHash', columnType: _i2.ColumnType.text, isNullable: false, dartType: 'String', ), _i2.ColumnDefinition( - name: 'resourceType', + name: 'signature', columnType: _i2.ColumnType.text, isNullable: false, dartType: 'String', ), _i2.ColumnDefinition( - name: 'resourceId', - columnType: _i2.ColumnType.uuid, - isNullable: true, - dartType: 'UuidValue?', - ), - _i2.ColumnDefinition( - name: 'timestamp', + name: 'createdAt', + columnType: _i2.ColumnType.timestampWithoutTimeZone, + isNullable: false, + dartType: 'DateTime', + ), + ], + foreignKeys: [], + indexes: [ + _i2.IndexDefinition( + indexName: 'audit_chain_checkpoints_pkey', + tableSpace: null, + elements: [ + _i2.IndexElementDefinition( + type: _i2.IndexElementDefinitionType.column, + definition: 'id', + ), + ], + type: 'btree', + isUnique: true, + isPrimary: true, + ), + _i2.IndexDefinition( + indexName: 'audit_chain_checkpoints_sequence_idx', + tableSpace: null, + elements: [ + _i2.IndexElementDefinition( + type: _i2.IndexElementDefinitionType.column, + definition: 'sequence', + ), + ], + type: 'btree', + isUnique: true, + isPrimary: false, + ), + ], + managed: true, + ), + _i2.TableDefinition( + name: 'audit_exports', + dartName: 'AuditExport', + schema: 'public', + module: 'sinalacs', + columns: [ + _i2.ColumnDefinition( + name: 'id', + columnType: _i2.ColumnType.uuid, + isNullable: false, + dartType: 'UuidValue?', + columnDefault: 'gen_random_uuid()', + ), + _i2.ColumnDefinition( + name: 'requestedBy', + columnType: _i2.ColumnType.uuid, + isNullable: false, + dartType: 'UuidValue', + ), + _i2.ColumnDefinition( + name: 'requestedAt', + columnType: _i2.ColumnType.timestampWithoutTimeZone, + isNullable: false, + dartType: 'DateTime', + ), + _i2.ColumnDefinition( + name: 'filtersJson', + columnType: _i2.ColumnType.text, + isNullable: false, + dartType: 'String', + ), + _i2.ColumnDefinition( + name: 'format', + columnType: _i2.ColumnType.text, + isNullable: false, + dartType: 'String', + ), + _i2.ColumnDefinition( + name: 'rowCount', + columnType: _i2.ColumnType.bigint, + isNullable: false, + dartType: 'int', + ), + _i2.ColumnDefinition( + name: 'firstSequence', + columnType: _i2.ColumnType.bigint, + isNullable: true, + dartType: 'int?', + ), + _i2.ColumnDefinition( + name: 'lastSequence', + columnType: _i2.ColumnType.bigint, + isNullable: true, + dartType: 'int?', + ), + _i2.ColumnDefinition( + name: 'contentSha256', + columnType: _i2.ColumnType.text, + isNullable: false, + dartType: 'String', + ), + _i2.ColumnDefinition( + name: 'chainState', + columnType: _i2.ColumnType.text, + isNullable: false, + dartType: 'String', + ), + ], + foreignKeys: [ + _i2.ForeignKeyDefinition( + constraintName: 'audit_exports_fk_0', + columns: ['requestedBy'], + referenceTable: 'users', + referenceTableSchema: 'public', + referenceColumns: ['id'], + onUpdate: _i2.ForeignKeyAction.noAction, + onDelete: _i2.ForeignKeyAction.noAction, + matchType: null, + ), + ], + indexes: [ + _i2.IndexDefinition( + indexName: 'audit_exports_pkey', + tableSpace: null, + elements: [ + _i2.IndexElementDefinition( + type: _i2.IndexElementDefinitionType.column, + definition: 'id', + ), + ], + type: 'btree', + isUnique: true, + isPrimary: true, + ), + _i2.IndexDefinition( + indexName: 'audit_exports_requested_at_idx', + tableSpace: null, + elements: [ + _i2.IndexElementDefinition( + type: _i2.IndexElementDefinitionType.column, + definition: 'requestedAt', + ), + ], + type: 'btree', + isUnique: false, + isPrimary: false, + ), + ], + managed: true, + ), + _i2.TableDefinition( + name: 'audit_logs', + dartName: 'AuditLog', + schema: 'public', + module: 'sinalacs', + columns: [ + _i2.ColumnDefinition( + name: 'id', + columnType: _i2.ColumnType.uuid, + isNullable: false, + dartType: 'UuidValue?', + columnDefault: 'gen_random_uuid()', + ), + _i2.ColumnDefinition( + name: 'userId', + columnType: _i2.ColumnType.uuid, + isNullable: false, + dartType: 'UuidValue', + ), + _i2.ColumnDefinition( + name: 'actionType', + columnType: _i2.ColumnType.text, + isNullable: false, + dartType: 'String', + ), + _i2.ColumnDefinition( + name: 'resourceType', + columnType: _i2.ColumnType.text, + isNullable: false, + dartType: 'String', + ), + _i2.ColumnDefinition( + name: 'resourceId', + columnType: _i2.ColumnType.uuid, + isNullable: true, + dartType: 'UuidValue?', + ), + _i2.ColumnDefinition( + name: 'timestamp', columnType: _i2.ColumnType.timestampWithoutTimeZone, isNullable: false, dartType: 'DateTime', @@ -751,6 +1299,36 @@ class Protocol extends _i1.SerializationManagerServer { isUnique: true, isPrimary: false, ), + _i2.IndexDefinition( + indexName: 'audit_logs_timestamp_idx', + tableSpace: null, + elements: [ + _i2.IndexElementDefinition( + type: _i2.IndexElementDefinitionType.column, + definition: 'timestamp', + ), + ], + type: 'btree', + isUnique: false, + isPrimary: false, + ), + _i2.IndexDefinition( + indexName: 'audit_logs_user_sequence_idx', + tableSpace: null, + elements: [ + _i2.IndexElementDefinition( + type: _i2.IndexElementDefinitionType.column, + definition: 'userId', + ), + _i2.IndexElementDefinition( + type: _i2.IndexElementDefinitionType.column, + definition: 'sequence', + ), + ], + type: 'btree', + isUnique: false, + isPrimary: false, + ), ], managed: true, ), @@ -839,15 +1417,601 @@ class Protocol extends _i1.SerializationManagerServer { ), ], type: 'btree', - isUnique: true, - isPrimary: true, + isUnique: true, + isPrimary: true, + ), + ], + managed: true, + ), + _i2.TableDefinition( + name: 'data_subject_requests', + dartName: 'DataSubjectRequest', + schema: 'public', + module: 'sinalacs', + columns: [ + _i2.ColumnDefinition( + name: 'id', + columnType: _i2.ColumnType.uuid, + isNullable: false, + dartType: 'UuidValue?', + columnDefault: 'gen_random_uuid()', + ), + _i2.ColumnDefinition( + name: 'userId', + columnType: _i2.ColumnType.uuid, + isNullable: false, + dartType: 'UuidValue', + ), + _i2.ColumnDefinition( + name: 'requestType', + columnType: _i2.ColumnType.text, + isNullable: false, + dartType: 'protocol:DataSubjectRequestType', + ), + _i2.ColumnDefinition( + name: 'detailsEncrypted', + columnType: _i2.ColumnType.text, + isNullable: false, + dartType: 'String', + ), + _i2.ColumnDefinition( + name: 'detailsKeyVersion', + columnType: _i2.ColumnType.bigint, + isNullable: false, + dartType: 'int', + ), + _i2.ColumnDefinition( + name: 'status', + columnType: _i2.ColumnType.text, + isNullable: false, + dartType: 'protocol:DataSubjectRequestStatus', + ), + _i2.ColumnDefinition( + name: 'createdAt', + columnType: _i2.ColumnType.timestampWithoutTimeZone, + isNullable: false, + dartType: 'DateTime', + ), + _i2.ColumnDefinition( + name: 'dueAt', + columnType: _i2.ColumnType.timestampWithoutTimeZone, + isNullable: false, + dartType: 'DateTime', + ), + _i2.ColumnDefinition( + name: 'decidedAt', + columnType: _i2.ColumnType.timestampWithoutTimeZone, + isNullable: true, + dartType: 'DateTime?', + ), + _i2.ColumnDefinition( + name: 'decidedBy', + columnType: _i2.ColumnType.uuid, + isNullable: true, + dartType: 'UuidValue?', + ), + _i2.ColumnDefinition( + name: 'resolutionEncrypted', + columnType: _i2.ColumnType.text, + isNullable: true, + dartType: 'String?', + ), + _i2.ColumnDefinition( + name: 'resolutionKeyVersion', + columnType: _i2.ColumnType.bigint, + isNullable: true, + dartType: 'int?', + ), + ], + foreignKeys: [ + _i2.ForeignKeyDefinition( + constraintName: 'data_subject_requests_fk_0', + columns: ['userId'], + referenceTable: 'users', + referenceTableSchema: 'public', + referenceColumns: ['id'], + onUpdate: _i2.ForeignKeyAction.noAction, + onDelete: _i2.ForeignKeyAction.noAction, + matchType: null, + ), + _i2.ForeignKeyDefinition( + constraintName: 'data_subject_requests_fk_1', + columns: ['decidedBy'], + referenceTable: 'users', + referenceTableSchema: 'public', + referenceColumns: ['id'], + onUpdate: _i2.ForeignKeyAction.noAction, + onDelete: _i2.ForeignKeyAction.noAction, + matchType: null, + ), + ], + indexes: [ + _i2.IndexDefinition( + indexName: 'data_subject_requests_pkey', + tableSpace: null, + elements: [ + _i2.IndexElementDefinition( + type: _i2.IndexElementDefinitionType.column, + definition: 'id', + ), + ], + type: 'btree', + isUnique: true, + isPrimary: true, + ), + _i2.IndexDefinition( + indexName: 'data_subject_requests_user_id_type_idx', + tableSpace: null, + elements: [ + _i2.IndexElementDefinition( + type: _i2.IndexElementDefinitionType.column, + definition: 'userId', + ), + _i2.IndexElementDefinition( + type: _i2.IndexElementDefinitionType.column, + definition: 'requestType', + ), + ], + type: 'btree', + isUnique: false, + isPrimary: false, + ), + ], + managed: true, + ), + _i2.TableDefinition( + name: 'enrollment_tokens', + dartName: 'EnrollmentToken', + schema: 'public', + module: 'sinalacs', + columns: [ + _i2.ColumnDefinition( + name: 'id', + columnType: _i2.ColumnType.uuid, + isNullable: false, + dartType: 'UuidValue?', + columnDefault: 'gen_random_uuid()', + ), + _i2.ColumnDefinition( + name: 'tokenHash', + columnType: _i2.ColumnType.text, + isNullable: false, + dartType: 'String', + ), + _i2.ColumnDefinition( + name: 'patientId', + columnType: _i2.ColumnType.uuid, + isNullable: false, + dartType: 'UuidValue', + ), + _i2.ColumnDefinition( + name: 'microAreaId', + columnType: _i2.ColumnType.uuid, + isNullable: false, + dartType: 'UuidValue', + ), + _i2.ColumnDefinition( + name: 'createdByAcsId', + columnType: _i2.ColumnType.uuid, + isNullable: false, + dartType: 'UuidValue', + ), + _i2.ColumnDefinition( + name: 'createdAt', + columnType: _i2.ColumnType.timestampWithoutTimeZone, + isNullable: false, + dartType: 'DateTime', + ), + _i2.ColumnDefinition( + name: 'expiresAt', + columnType: _i2.ColumnType.timestampWithoutTimeZone, + isNullable: false, + dartType: 'DateTime', + ), + _i2.ColumnDefinition( + name: 'consumedAt', + columnType: _i2.ColumnType.timestampWithoutTimeZone, + isNullable: true, + dartType: 'DateTime?', + ), + ], + foreignKeys: [ + _i2.ForeignKeyDefinition( + constraintName: 'enrollment_tokens_fk_0', + columns: ['patientId'], + referenceTable: 'patients', + referenceTableSchema: 'public', + referenceColumns: ['id'], + onUpdate: _i2.ForeignKeyAction.noAction, + onDelete: _i2.ForeignKeyAction.noAction, + matchType: null, + ), + _i2.ForeignKeyDefinition( + constraintName: 'enrollment_tokens_fk_1', + columns: ['createdByAcsId'], + referenceTable: 'acs', + referenceTableSchema: 'public', + referenceColumns: ['id'], + onUpdate: _i2.ForeignKeyAction.noAction, + onDelete: _i2.ForeignKeyAction.noAction, + matchType: null, + ), + ], + indexes: [ + _i2.IndexDefinition( + indexName: 'enrollment_tokens_pkey', + tableSpace: null, + elements: [ + _i2.IndexElementDefinition( + type: _i2.IndexElementDefinitionType.column, + definition: 'id', + ), + ], + type: 'btree', + isUnique: true, + isPrimary: true, + ), + _i2.IndexDefinition( + indexName: 'enrollment_tokens_token_hash_key', + tableSpace: null, + elements: [ + _i2.IndexElementDefinition( + type: _i2.IndexElementDefinitionType.column, + definition: 'tokenHash', + ), + ], + type: 'btree', + isUnique: true, + isPrimary: false, + ), + ], + managed: true, + ), + _i2.TableDefinition( + name: 'micro_areas', + dartName: 'MicroArea', + schema: 'public', + module: 'sinalacs', + columns: [ + _i2.ColumnDefinition( + name: 'id', + columnType: _i2.ColumnType.uuid, + isNullable: false, + dartType: 'UuidValue?', + columnDefault: 'gen_random_uuid()', + ), + _i2.ColumnDefinition( + name: 'name', + columnType: _i2.ColumnType.text, + isNullable: false, + dartType: 'String', + ), + _i2.ColumnDefinition( + name: 'ubsId', + columnType: _i2.ColumnType.uuid, + isNullable: false, + dartType: 'UuidValue', + ), + _i2.ColumnDefinition( + name: 'geoJsonBoundary', + columnType: _i2.ColumnType.text, + isNullable: false, + dartType: 'String', + ), + ], + foreignKeys: [], + indexes: [ + _i2.IndexDefinition( + indexName: 'micro_areas_pkey', + tableSpace: null, + elements: [ + _i2.IndexElementDefinition( + type: _i2.IndexElementDefinitionType.column, + definition: 'id', + ), + ], + type: 'btree', + isUnique: true, + isPrimary: true, + ), + _i2.IndexDefinition( + indexName: 'micro_areas_ubs_idx', + tableSpace: null, + elements: [ + _i2.IndexElementDefinition( + type: _i2.IndexElementDefinitionType.column, + definition: 'ubsId', + ), + ], + type: 'btree', + isUnique: false, + isPrimary: false, + ), + ], + managed: true, + ), + _i2.TableDefinition( + name: 'otp_challenges', + dartName: 'OtpChallenge', + schema: 'public', + module: 'sinalacs', + columns: [ + _i2.ColumnDefinition( + name: 'id', + columnType: _i2.ColumnType.uuid, + isNullable: false, + dartType: 'UuidValue?', + columnDefault: 'gen_random_uuid()', + ), + _i2.ColumnDefinition( + name: 'userId', + columnType: _i2.ColumnType.uuid, + isNullable: false, + dartType: 'UuidValue', + ), + _i2.ColumnDefinition( + name: 'codeHash', + columnType: _i2.ColumnType.text, + isNullable: false, + dartType: 'String', + ), + _i2.ColumnDefinition( + name: 'attempts', + columnType: _i2.ColumnType.bigint, + isNullable: false, + dartType: 'int', + ), + _i2.ColumnDefinition( + name: 'createdAt', + columnType: _i2.ColumnType.timestampWithoutTimeZone, + isNullable: false, + dartType: 'DateTime', + ), + _i2.ColumnDefinition( + name: 'expiresAt', + columnType: _i2.ColumnType.timestampWithoutTimeZone, + isNullable: false, + dartType: 'DateTime', + ), + _i2.ColumnDefinition( + name: 'consumedAt', + columnType: _i2.ColumnType.timestampWithoutTimeZone, + isNullable: true, + dartType: 'DateTime?', + ), + ], + foreignKeys: [ + _i2.ForeignKeyDefinition( + constraintName: 'otp_challenges_fk_0', + columns: ['userId'], + referenceTable: 'users', + referenceTableSchema: 'public', + referenceColumns: ['id'], + onUpdate: _i2.ForeignKeyAction.noAction, + onDelete: _i2.ForeignKeyAction.noAction, + matchType: null, + ), + ], + indexes: [ + _i2.IndexDefinition( + indexName: 'otp_challenges_pkey', + tableSpace: null, + elements: [ + _i2.IndexElementDefinition( + type: _i2.IndexElementDefinitionType.column, + definition: 'id', + ), + ], + type: 'btree', + isUnique: true, + isPrimary: true, + ), + _i2.IndexDefinition( + indexName: 'otp_challenges_user_id_created_at_idx', + tableSpace: null, + elements: [ + _i2.IndexElementDefinition( + type: _i2.IndexElementDefinitionType.column, + definition: 'userId', + ), + _i2.IndexElementDefinition( + type: _i2.IndexElementDefinitionType.column, + definition: 'createdAt', + ), + ], + type: 'btree', + isUnique: false, + isPrimary: false, + ), + ], + managed: true, + ), + _i2.TableDefinition( + name: 'patients', + dartName: 'Patient', + schema: 'public', + module: 'sinalacs', + columns: [ + _i2.ColumnDefinition( + name: 'id', + columnType: _i2.ColumnType.uuid, + isNullable: false, + dartType: 'UuidValue?', + columnDefault: 'gen_random_uuid()', + ), + _i2.ColumnDefinition( + name: 'emergencyContactEncrypted', + columnType: _i2.ColumnType.text, + isNullable: false, + dartType: 'String', + columnDefault: '\'\'::text', + ), + _i2.ColumnDefinition( + name: 'emergencyContactKeyVersion', + columnType: _i2.ColumnType.bigint, + isNullable: false, + dartType: 'int', + columnDefault: '1', + ), + _i2.ColumnDefinition( + name: 'isChronic', + columnType: _i2.ColumnType.boolean, + isNullable: false, + dartType: 'bool', + ), + _i2.ColumnDefinition( + name: 'chronicConditionsEncrypted', + columnType: _i2.ColumnType.text, + isNullable: false, + dartType: 'String', + columnDefault: '\'\'::text', + ), + _i2.ColumnDefinition( + name: 'chronicConditionsKeyVersion', + columnType: _i2.ColumnType.bigint, + isNullable: false, + dartType: 'int', + columnDefault: '1', + ), + _i2.ColumnDefinition( + name: 'lastLocationHash', + columnType: _i2.ColumnType.text, + isNullable: true, + dartType: 'String?', + ), + _i2.ColumnDefinition( + name: 'lastTriageAt', + columnType: _i2.ColumnType.timestampWithoutTimeZone, + isNullable: true, + dartType: 'DateTime?', + ), + ], + foreignKeys: [], + indexes: [ + _i2.IndexDefinition( + indexName: 'patients_pkey', + tableSpace: null, + elements: [ + _i2.IndexElementDefinition( + type: _i2.IndexElementDefinitionType.column, + definition: 'id', + ), + ], + type: 'btree', + isUnique: true, + isPrimary: true, + ), + ], + managed: true, + ), + _i2.TableDefinition( + name: 'push_tokens', + dartName: 'PushToken', + schema: 'public', + module: 'sinalacs', + columns: [ + _i2.ColumnDefinition( + name: 'id', + columnType: _i2.ColumnType.uuid, + isNullable: false, + dartType: 'UuidValue?', + columnDefault: 'gen_random_uuid()', + ), + _i2.ColumnDefinition( + name: 'userId', + columnType: _i2.ColumnType.uuid, + isNullable: false, + dartType: 'UuidValue', + ), + _i2.ColumnDefinition( + name: 'microAreaId', + columnType: _i2.ColumnType.uuid, + isNullable: true, + dartType: 'UuidValue?', + ), + _i2.ColumnDefinition( + name: 'token', + columnType: _i2.ColumnType.text, + isNullable: false, + dartType: 'String', + ), + _i2.ColumnDefinition( + name: 'platform', + columnType: _i2.ColumnType.text, + isNullable: false, + dartType: 'String', + ), + _i2.ColumnDefinition( + name: 'createdAt', + columnType: _i2.ColumnType.timestampWithoutTimeZone, + isNullable: false, + dartType: 'DateTime', + ), + _i2.ColumnDefinition( + name: 'updatedAt', + columnType: _i2.ColumnType.timestampWithoutTimeZone, + isNullable: false, + dartType: 'DateTime', + ), + ], + foreignKeys: [ + _i2.ForeignKeyDefinition( + constraintName: 'push_tokens_fk_0', + columns: ['userId'], + referenceTable: 'users', + referenceTableSchema: 'public', + referenceColumns: ['id'], + onUpdate: _i2.ForeignKeyAction.noAction, + onDelete: _i2.ForeignKeyAction.noAction, + matchType: null, + ), + ], + indexes: [ + _i2.IndexDefinition( + indexName: 'push_tokens_pkey', + tableSpace: null, + elements: [ + _i2.IndexElementDefinition( + type: _i2.IndexElementDefinitionType.column, + definition: 'id', + ), + ], + type: 'btree', + isUnique: true, + isPrimary: true, + ), + _i2.IndexDefinition( + indexName: 'push_tokens_token_key', + tableSpace: null, + elements: [ + _i2.IndexElementDefinition( + type: _i2.IndexElementDefinitionType.column, + definition: 'token', + ), + ], + type: 'btree', + isUnique: true, + isPrimary: false, + ), + _i2.IndexDefinition( + indexName: 'push_tokens_user_id_idx', + tableSpace: null, + elements: [ + _i2.IndexElementDefinition( + type: _i2.IndexElementDefinitionType.column, + definition: 'userId', + ), + ], + type: 'btree', + isUnique: false, + isPrimary: false, ), ], managed: true, ), _i2.TableDefinition( - name: 'enrollment_tokens', - dartName: 'EnrollmentToken', + name: 'scheduled_notices', + dartName: 'ScheduledNotice', schema: 'public', module: 'sinalacs', columns: [ @@ -859,13 +2023,7 @@ class Protocol extends _i1.SerializationManagerServer { columnDefault: 'gen_random_uuid()', ), _i2.ColumnDefinition( - name: 'tokenHash', - columnType: _i2.ColumnType.text, - isNullable: false, - dartType: 'String', - ), - _i2.ColumnDefinition( - name: 'patientId', + name: 'createdBy', columnType: _i2.ColumnType.uuid, isNullable: false, dartType: 'UuidValue', @@ -877,25 +2035,61 @@ class Protocol extends _i1.SerializationManagerServer { dartType: 'UuidValue', ), _i2.ColumnDefinition( - name: 'createdByAcsId', - columnType: _i2.ColumnType.uuid, + name: 'audience', + columnType: _i2.ColumnType.text, isNullable: false, - dartType: 'UuidValue', + dartType: 'String', ), _i2.ColumnDefinition( - name: 'createdAt', + name: 'title', + columnType: _i2.ColumnType.text, + isNullable: false, + dartType: 'String', + ), + _i2.ColumnDefinition( + name: 'message', + columnType: _i2.ColumnType.text, + isNullable: false, + dartType: 'String', + ), + _i2.ColumnDefinition( + name: 'scheduledFor', columnType: _i2.ColumnType.timestampWithoutTimeZone, isNullable: false, dartType: 'DateTime', ), _i2.ColumnDefinition( - name: 'expiresAt', + name: 'status', + columnType: _i2.ColumnType.text, + isNullable: false, + dartType: 'protocol:ScheduledNoticeStatus', + ), + _i2.ColumnDefinition( + name: 'recipients', + columnType: _i2.ColumnType.bigint, + isNullable: true, + dartType: 'int?', + ), + _i2.ColumnDefinition( + name: 'accepted', + columnType: _i2.ColumnType.bigint, + isNullable: true, + dartType: 'int?', + ), + _i2.ColumnDefinition( + name: 'failureReason', + columnType: _i2.ColumnType.text, + isNullable: true, + dartType: 'String?', + ), + _i2.ColumnDefinition( + name: 'createdAt', columnType: _i2.ColumnType.timestampWithoutTimeZone, isNullable: false, dartType: 'DateTime', ), _i2.ColumnDefinition( - name: 'consumedAt', + name: 'sentAt', columnType: _i2.ColumnType.timestampWithoutTimeZone, isNullable: true, dartType: 'DateTime?', @@ -903,9 +2097,9 @@ class Protocol extends _i1.SerializationManagerServer { ], foreignKeys: [ _i2.ForeignKeyDefinition( - constraintName: 'enrollment_tokens_fk_0', - columns: ['patientId'], - referenceTable: 'patients', + constraintName: 'scheduled_notices_fk_0', + columns: ['createdBy'], + referenceTable: 'users', referenceTableSchema: 'public', referenceColumns: ['id'], onUpdate: _i2.ForeignKeyAction.noAction, @@ -913,9 +2107,9 @@ class Protocol extends _i1.SerializationManagerServer { matchType: null, ), _i2.ForeignKeyDefinition( - constraintName: 'enrollment_tokens_fk_1', - columns: ['createdByAcsId'], - referenceTable: 'acs', + constraintName: 'scheduled_notices_fk_1', + columns: ['microAreaId'], + referenceTable: 'micro_areas', referenceTableSchema: 'public', referenceColumns: ['id'], onUpdate: _i2.ForeignKeyAction.noAction, @@ -925,7 +2119,7 @@ class Protocol extends _i1.SerializationManagerServer { ], indexes: [ _i2.IndexDefinition( - indexName: 'enrollment_tokens_pkey', + indexName: 'scheduled_notices_pkey', tableSpace: null, elements: [ _i2.IndexElementDefinition( @@ -938,24 +2132,45 @@ class Protocol extends _i1.SerializationManagerServer { isPrimary: true, ), _i2.IndexDefinition( - indexName: 'enrollment_tokens_token_hash_key', + indexName: 'scheduled_notices_due_idx', tableSpace: null, elements: [ _i2.IndexElementDefinition( type: _i2.IndexElementDefinitionType.column, - definition: 'tokenHash', + definition: 'status', + ), + _i2.IndexElementDefinition( + type: _i2.IndexElementDefinitionType.column, + definition: 'scheduledFor', ), ], type: 'btree', - isUnique: true, + isUnique: false, + isPrimary: false, + ), + _i2.IndexDefinition( + indexName: 'scheduled_notices_area_idx', + tableSpace: null, + elements: [ + _i2.IndexElementDefinition( + type: _i2.IndexElementDefinitionType.column, + definition: 'microAreaId', + ), + _i2.IndexElementDefinition( + type: _i2.IndexElementDefinitionType.column, + definition: 'createdAt', + ), + ], + type: 'btree', + isUnique: false, isPrimary: false, ), ], managed: true, ), _i2.TableDefinition( - name: 'micro_areas', - dartName: 'MicroArea', + name: 'staff_accounts', + dartName: 'StaffAccount', schema: 'public', module: 'sinalacs', columns: [ @@ -967,28 +2182,52 @@ class Protocol extends _i1.SerializationManagerServer { columnDefault: 'gen_random_uuid()', ), _i2.ColumnDefinition( - name: 'name', + name: 'enrollmentId', columnType: _i2.ColumnType.text, isNullable: false, dartType: 'String', ), + _i2.ColumnDefinition( + name: 'active', + columnType: _i2.ColumnType.boolean, + isNullable: false, + dartType: 'bool', + ), _i2.ColumnDefinition( name: 'ubsId', columnType: _i2.ColumnType.uuid, - isNullable: false, - dartType: 'UuidValue', + isNullable: true, + dartType: 'UuidValue?', ), _i2.ColumnDefinition( - name: 'geoJsonBoundary', + name: 'activationCodeHash', columnType: _i2.ColumnType.text, - isNullable: false, - dartType: 'String', + isNullable: true, + dartType: 'String?', + ), + _i2.ColumnDefinition( + name: 'activationCodeExpiresAt', + columnType: _i2.ColumnType.timestampWithoutTimeZone, + isNullable: true, + dartType: 'DateTime?', + ), + _i2.ColumnDefinition( + name: 'activationCodeIssuedBy', + columnType: _i2.ColumnType.text, + isNullable: true, + dartType: 'String?', + ), + _i2.ColumnDefinition( + name: 'activationCodeIssuedAt', + columnType: _i2.ColumnType.timestampWithoutTimeZone, + isNullable: true, + dartType: 'DateTime?', ), ], foreignKeys: [], indexes: [ _i2.IndexDefinition( - indexName: 'micro_areas_pkey', + indexName: 'staff_accounts_pkey', tableSpace: null, elements: [ _i2.IndexElementDefinition( @@ -1001,24 +2240,24 @@ class Protocol extends _i1.SerializationManagerServer { isPrimary: true, ), _i2.IndexDefinition( - indexName: 'micro_areas_ubs_idx', + indexName: 'staff_accounts_enrollment_id_key', tableSpace: null, elements: [ _i2.IndexElementDefinition( type: _i2.IndexElementDefinitionType.column, - definition: 'ubsId', + definition: 'enrollmentId', ), ], type: 'btree', - isUnique: false, + isUnique: true, isPrimary: false, ), ], managed: true, ), _i2.TableDefinition( - name: 'otp_challenges', - dartName: 'OtpChallenge', + name: 'terms_versions', + dartName: 'TermsVersion', schema: 'public', module: 'sinalacs', columns: [ @@ -1030,22 +2269,28 @@ class Protocol extends _i1.SerializationManagerServer { columnDefault: 'gen_random_uuid()', ), _i2.ColumnDefinition( - name: 'userId', - columnType: _i2.ColumnType.uuid, + name: 'version', + columnType: _i2.ColumnType.text, isNullable: false, - dartType: 'UuidValue', + dartType: 'String', ), _i2.ColumnDefinition( - name: 'codeHash', + name: 'summary', columnType: _i2.ColumnType.text, isNullable: false, dartType: 'String', ), _i2.ColumnDefinition( - name: 'attempts', - columnType: _i2.ColumnType.bigint, + name: 'effectiveFrom', + columnType: _i2.ColumnType.timestampWithoutTimeZone, isNullable: false, - dartType: 'int', + dartType: 'DateTime', + ), + _i2.ColumnDefinition( + name: 'createdBy', + columnType: _i2.ColumnType.uuid, + isNullable: false, + dartType: 'UuidValue', ), _i2.ColumnDefinition( name: 'createdAt', @@ -1054,22 +2299,54 @@ class Protocol extends _i1.SerializationManagerServer { dartType: 'DateTime', ), _i2.ColumnDefinition( - name: 'expiresAt', + name: 'legallyReviewedAt', columnType: _i2.ColumnType.timestampWithoutTimeZone, - isNullable: false, - dartType: 'DateTime', + isNullable: true, + dartType: 'DateTime?', ), _i2.ColumnDefinition( - name: 'consumedAt', + name: 'legallyReviewedBy', + columnType: _i2.ColumnType.uuid, + isNullable: true, + dartType: 'UuidValue?', + ), + _i2.ColumnDefinition( + name: 'publishedAt', columnType: _i2.ColumnType.timestampWithoutTimeZone, isNullable: true, dartType: 'DateTime?', ), + _i2.ColumnDefinition( + name: 'publishedBy', + columnType: _i2.ColumnType.uuid, + isNullable: true, + dartType: 'UuidValue?', + ), ], foreignKeys: [ _i2.ForeignKeyDefinition( - constraintName: 'otp_challenges_fk_0', - columns: ['userId'], + constraintName: 'terms_versions_fk_0', + columns: ['createdBy'], + referenceTable: 'users', + referenceTableSchema: 'public', + referenceColumns: ['id'], + onUpdate: _i2.ForeignKeyAction.noAction, + onDelete: _i2.ForeignKeyAction.noAction, + matchType: null, + ), + _i2.ForeignKeyDefinition( + constraintName: 'terms_versions_fk_1', + columns: ['legallyReviewedBy'], + referenceTable: 'users', + referenceTableSchema: 'public', + referenceColumns: ['id'], + onUpdate: _i2.ForeignKeyAction.noAction, + onDelete: _i2.ForeignKeyAction.noAction, + matchType: null, + ), + _i2.ForeignKeyDefinition( + constraintName: 'terms_versions_fk_2', + columns: ['publishedBy'], referenceTable: 'users', referenceTableSchema: 'public', referenceColumns: ['id'], @@ -1080,7 +2357,7 @@ class Protocol extends _i1.SerializationManagerServer { ], indexes: [ _i2.IndexDefinition( - indexName: 'otp_challenges_pkey', + indexName: 'terms_versions_pkey', tableSpace: null, elements: [ _i2.IndexElementDefinition( @@ -1093,91 +2370,17 @@ class Protocol extends _i1.SerializationManagerServer { isPrimary: true, ), _i2.IndexDefinition( - indexName: 'otp_challenges_user_id_created_at_idx', - tableSpace: null, - elements: [ - _i2.IndexElementDefinition( - type: _i2.IndexElementDefinitionType.column, - definition: 'userId', - ), - _i2.IndexElementDefinition( - type: _i2.IndexElementDefinitionType.column, - definition: 'createdAt', - ), - ], - type: 'btree', - isUnique: false, - isPrimary: false, - ), - ], - managed: true, - ), - _i2.TableDefinition( - name: 'patients', - dartName: 'Patient', - schema: 'public', - module: 'sinalacs', - columns: [ - _i2.ColumnDefinition( - name: 'id', - columnType: _i2.ColumnType.uuid, - isNullable: false, - dartType: 'UuidValue?', - columnDefault: 'gen_random_uuid()', - ), - _i2.ColumnDefinition( - name: 'emergencyContact', - columnType: _i2.ColumnType.text, - isNullable: false, - dartType: 'String', - ), - _i2.ColumnDefinition( - name: 'isChronic', - columnType: _i2.ColumnType.boolean, - isNullable: false, - dartType: 'bool', - ), - _i2.ColumnDefinition( - name: 'chronicConditionsEncrypted', - columnType: _i2.ColumnType.text, - isNullable: false, - dartType: 'String', - columnDefault: '\'\'::text', - ), - _i2.ColumnDefinition( - name: 'chronicConditionsKeyVersion', - columnType: _i2.ColumnType.bigint, - isNullable: false, - dartType: 'int', - columnDefault: '1', - ), - _i2.ColumnDefinition( - name: 'lastLocationHash', - columnType: _i2.ColumnType.text, - isNullable: true, - dartType: 'String?', - ), - _i2.ColumnDefinition( - name: 'lastTriageAt', - columnType: _i2.ColumnType.timestampWithoutTimeZone, - isNullable: true, - dartType: 'DateTime?', - ), - ], - foreignKeys: [], - indexes: [ - _i2.IndexDefinition( - indexName: 'patients_pkey', + indexName: 'terms_versions_version_key', tableSpace: null, elements: [ _i2.IndexElementDefinition( type: _i2.IndexElementDefinitionType.column, - definition: 'id', + definition: 'version', ), ], type: 'btree', isUnique: true, - isPrimary: true, + isPrimary: false, ), ], managed: true, @@ -1306,6 +2509,12 @@ class Protocol extends _i1.SerializationManagerServer { isNullable: false, dartType: 'String', ), + _i2.ColumnDefinition( + name: 'contactPhone', + columnType: _i2.ColumnType.text, + isNullable: true, + dartType: 'String?', + ), ], foreignKeys: [], indexes: [ @@ -1386,6 +2595,13 @@ class Protocol extends _i1.SerializationManagerServer { isNullable: true, dartType: 'DateTime?', ), + _i2.ColumnDefinition( + name: 'lockStreak', + columnType: _i2.ColumnType.bigint, + isNullable: false, + dartType: 'int', + columnDefault: '0', + ), _i2.ColumnDefinition( name: 'createdAt', columnType: _i2.ColumnType.timestampWithoutTimeZone, @@ -1398,6 +2614,30 @@ class Protocol extends _i1.SerializationManagerServer { isNullable: false, dartType: 'DateTime', ), + _i2.ColumnDefinition( + name: 'totpSecretEncrypted', + columnType: _i2.ColumnType.text, + isNullable: true, + dartType: 'String?', + ), + _i2.ColumnDefinition( + name: 'totpKeyVersion', + columnType: _i2.ColumnType.bigint, + isNullable: true, + dartType: 'int?', + ), + _i2.ColumnDefinition( + name: 'totpEnabledAt', + columnType: _i2.ColumnType.timestampWithoutTimeZone, + isNullable: true, + dartType: 'DateTime?', + ), + _i2.ColumnDefinition( + name: 'totpLastStep', + columnType: _i2.ColumnType.bigint, + isNullable: true, + dartType: 'int?', + ), ], foreignKeys: [ _i2.ForeignKeyDefinition( @@ -1563,8 +2803,21 @@ class Protocol extends _i1.SerializationManagerServer { _i2.ColumnDefinition( name: 'acsId', columnType: _i2.ColumnType.uuid, + isNullable: true, + dartType: 'UuidValue?', + ), + _i2.ColumnDefinition( + name: 'authorship', + columnType: _i2.ColumnType.text, isNullable: false, - dartType: 'UuidValue', + dartType: 'protocol:VisitAuthorship', + columnDefault: '\'acs\'::text', + ), + _i2.ColumnDefinition( + name: 'originDeviceId', + columnType: _i2.ColumnType.text, + isNullable: true, + dartType: 'String?', ), _i2.ColumnDefinition( name: 'scheduledAt', @@ -1733,302 +2986,669 @@ class Protocol extends _i1.SerializationManagerServer { if (t == _i3.Acs) { return _i3.Acs.fromJson(data) as T; } - if (t == _i4.Alert) { - return _i4.Alert.fromJson(data) as T; + if (t == _i4.AcsRefreshToken) { + return _i4.AcsRefreshToken.fromJson(data) as T; + } + if (t == _i5.AcsUploadToken) { + return _i5.AcsUploadToken.fromJson(data) as T; + } + if (t == _i6.Alert) { + return _i6.Alert.fromJson(data) as T; + } + if (t == _i7.AlertDeliveryRecord) { + return _i7.AlertDeliveryRecord.fromJson(data) as T; + } + if (t == _i8.AlertIdempotencyKey) { + return _i8.AlertIdempotencyKey.fromJson(data) as T; + } + if (t == _i9.AlertOutboxEntry) { + return _i9.AlertOutboxEntry.fromJson(data) as T; + } + if (t == _i10.AdminAcs) { + return _i10.AdminAcs.fromJson(data) as T; + } + if (t == _i11.AdminAcsCreationResult) { + return _i11.AdminAcsCreationResult.fromJson(data) as T; + } + if (t == _i12.AdminAlert) { + return _i12.AdminAlert.fromJson(data) as T; + } + if (t == _i13.AdminAlertPage) { + return _i13.AdminAlertPage.fromJson(data) as T; + } + if (t == _i14.AdminAlertsPulse) { + return _i14.AdminAlertsPulse.fromJson(data) as T; + } + if (t == _i15.AdminAuditChainStatus) { + return _i15.AdminAuditChainStatus.fromJson(data) as T; + } + if (t == _i16.AdminAuditEntry) { + return _i16.AdminAuditEntry.fromJson(data) as T; + } + if (t == _i17.AdminAuditExport) { + return _i17.AdminAuditExport.fromJson(data) as T; + } + if (t == _i18.AdminAuditFilter) { + return _i18.AdminAuditFilter.fromJson(data) as T; + } + if (t == _i19.AdminAuditPage) { + return _i19.AdminAuditPage.fromJson(data) as T; + } + if (t == _i20.AdminDataSubjectRequest) { + return _i20.AdminDataSubjectRequest.fromJson(data) as T; + } + if (t == _i21.AdminDataSubjectRequestDetail) { + return _i21.AdminDataSubjectRequestDetail.fromJson(data) as T; + } + if (t == _i22.AdminDataSubjectRequestPage) { + return _i22.AdminDataSubjectRequestPage.fromJson(data) as T; + } + if (t == _i23.AdminEscalationResult) { + return _i23.AdminEscalationResult.fromJson(data) as T; + } + if (t == _i24.AdminIndicators) { + return _i24.AdminIndicators.fromJson(data) as T; + } + if (t == _i25.AdminMicroArea) { + return _i25.AdminMicroArea.fromJson(data) as T; + } + if (t == _i26.AdminPasswordResetResult) { + return _i26.AdminPasswordResetResult.fromJson(data) as T; + } + if (t == _i27.AdminStaff) { + return _i27.AdminStaff.fromJson(data) as T; + } + if (t == _i28.AdminStaffMfaResetResult) { + return _i28.AdminStaffMfaResetResult.fromJson(data) as T; + } + if (t == _i29.AlertAckResult) { + return _i29.AlertAckResult.fromJson(data) as T; + } + if (t == _i30.AlertStatusResult) { + return _i30.AlertStatusResult.fromJson(data) as T; + } + if (t == _i31.DevelopmentLoginResult) { + return _i31.DevelopmentLoginResult.fromJson(data) as T; } - if (t == _i5.AlertDeliveryRecord) { - return _i5.AlertDeliveryRecord.fromJson(data) as T; + if (t == _i32.EnrollmentResult) { + return _i32.EnrollmentResult.fromJson(data) as T; } - if (t == _i6.AlertIdempotencyKey) { - return _i6.AlertIdempotencyKey.fromJson(data) as T; + if (t == _i33.EnrollmentTokenResult) { + return _i33.EnrollmentTokenResult.fromJson(data) as T; } - if (t == _i7.AlertOutboxEntry) { - return _i7.AlertOutboxEntry.fromJson(data) as T; + if (t == _i34.MicroAreaPatient) { + return _i34.MicroAreaPatient.fromJson(data) as T; } - if (t == _i8.AlertAckResult) { - return _i8.AlertAckResult.fromJson(data) as T; + if (t == _i35.NoticeHistoryEntry) { + return _i35.NoticeHistoryEntry.fromJson(data) as T; } - if (t == _i9.AlertStatusResult) { - return _i9.AlertStatusResult.fromJson(data) as T; + if (t == _i36.NoticeHistoryPage) { + return _i36.NoticeHistoryPage.fromJson(data) as T; } - if (t == _i10.DevelopmentLoginResult) { - return _i10.DevelopmentLoginResult.fromJson(data) as T; + if (t == _i37.NoticeSendResult) { + return _i37.NoticeSendResult.fromJson(data) as T; } - if (t == _i11.EnrollmentResult) { - return _i11.EnrollmentResult.fromJson(data) as T; + if (t == _i38.PatientConsentRecord) { + return _i38.PatientConsentRecord.fromJson(data) as T; } - if (t == _i12.EnrollmentTokenResult) { - return _i12.EnrollmentTokenResult.fromJson(data) as T; + if (t == _i39.PatientDataOverview) { + return _i39.PatientDataOverview.fromJson(data) as T; } - if (t == _i13.MicroAreaPatient) { - return _i13.MicroAreaPatient.fromJson(data) as T; + if (t == _i40.PatientDataSubjectRequestRecord) { + return _i40.PatientDataSubjectRequestRecord.fromJson(data) as T; } - if (t == _i14.PatientConsentRecord) { - return _i14.PatientConsentRecord.fromJson(data) as T; + if (t == _i41.PatientRiskEvent) { + return _i41.PatientRiskEvent.fromJson(data) as T; } - if (t == _i15.PatientDataOverview) { - return _i15.PatientDataOverview.fromJson(data) as T; + if (t == _i42.RedAlertResult) { + return _i42.RedAlertResult.fromJson(data) as T; } - if (t == _i16.PatientRiskEvent) { - return _i16.PatientRiskEvent.fromJson(data) as T; + if (t == _i43.ServiceHealth) { + return _i43.ServiceHealth.fromJson(data) as T; } - if (t == _i17.RedAlertResult) { - return _i17.RedAlertResult.fromJson(data) as T; + if (t == _i44.TermsChangeNotice) { + return _i44.TermsChangeNotice.fromJson(data) as T; } - if (t == _i18.ServiceHealth) { - return _i18.ServiceHealth.fromJson(data) as T; + if (t == _i45.TermsVersionEntry) { + return _i45.TermsVersionEntry.fromJson(data) as T; } - if (t == _i19.TriageResult) { - return _i19.TriageResult.fromJson(data) as T; + if (t == _i46.TotpEnrollmentStart) { + return _i46.TotpEnrollmentStart.fromJson(data) as T; } - if (t == _i20.VisitSyncEntry) { - return _i20.VisitSyncEntry.fromJson(data) as T; + if (t == _i47.TriageResult) { + return _i47.TriageResult.fromJson(data) as T; } - if (t == _i21.VisitSyncResult) { - return _i21.VisitSyncResult.fromJson(data) as T; + if (t == _i48.UbsContact) { + return _i48.UbsContact.fromJson(data) as T; } - if (t == _i22.AuditLog) { - return _i22.AuditLog.fromJson(data) as T; + if (t == _i49.VisitSyncEntry) { + return _i49.VisitSyncEntry.fromJson(data) as T; } - if (t == _i23.ConsentLog) { - return _i23.ConsentLog.fromJson(data) as T; + if (t == _i50.VisitSyncResult) { + return _i50.VisitSyncResult.fromJson(data) as T; } - if (t == _i24.EnrollmentToken) { - return _i24.EnrollmentToken.fromJson(data) as T; + if (t == _i51.AuditChainCheckpoint) { + return _i51.AuditChainCheckpoint.fromJson(data) as T; } - if (t == _i25.AlertStatus) { - return _i25.AlertStatus.fromJson(data) as T; + if (t == _i52.AuditExport) { + return _i52.AuditExport.fromJson(data) as T; } - if (t == _i26.ArrivalMethod) { - return _i26.ArrivalMethod.fromJson(data) as T; + if (t == _i53.AuditLog) { + return _i53.AuditLog.fromJson(data) as T; } - if (t == _i27.ConsentPurpose) { - return _i27.ConsentPurpose.fromJson(data) as T; + if (t == _i54.ConsentLog) { + return _i54.ConsentLog.fromJson(data) as T; } - if (t == _i28.RiskLevel) { - return _i28.RiskLevel.fromJson(data) as T; + if (t == _i55.DataSubjectRequest) { + return _i55.DataSubjectRequest.fromJson(data) as T; } - if (t == _i29.SyncStatus) { - return _i29.SyncStatus.fromJson(data) as T; + if (t == _i56.EnrollmentToken) { + return _i56.EnrollmentToken.fromJson(data) as T; } - if (t == _i30.UserRole) { - return _i30.UserRole.fromJson(data) as T; + if (t == _i57.AlertStatus) { + return _i57.AlertStatus.fromJson(data) as T; } - if (t == _i31.AlertDispatchUnavailableException) { - return _i31.AlertDispatchUnavailableException.fromJson(data) as T; + if (t == _i58.ArrivalMethod) { + return _i58.ArrivalMethod.fromJson(data) as T; } - if (t == _i32.AlertPermissionException) { - return _i32.AlertPermissionException.fromJson(data) as T; + if (t == _i59.AuditChainReason) { + return _i59.AuditChainReason.fromJson(data) as T; } - if (t == _i33.AlertValidationException) { - return _i33.AlertValidationException.fromJson(data) as T; + if (t == _i60.AuditIntegrityState) { + return _i60.AuditIntegrityState.fromJson(data) as T; } - if (t == _i34.AuthenticationFailedException) { - return _i34.AuthenticationFailedException.fromJson(data) as T; + if (t == _i61.AuditRowSeal) { + return _i61.AuditRowSeal.fromJson(data) as T; } - if (t == _i35.EndpointDisabledException) { - return _i35.EndpointDisabledException.fromJson(data) as T; + if (t == _i62.ConsentPurpose) { + return _i62.ConsentPurpose.fromJson(data) as T; } - if (t == _i36.EnrollmentException) { - return _i36.EnrollmentException.fromJson(data) as T; + if (t == _i63.DataSubjectRequestStatus) { + return _i63.DataSubjectRequestStatus.fromJson(data) as T; } - if (t == _i37.OtpRequestException) { - return _i37.OtpRequestException.fromJson(data) as T; + if (t == _i64.DataSubjectRequestType) { + return _i64.DataSubjectRequestType.fromJson(data) as T; } - if (t == _i38.MicroArea) { - return _i38.MicroArea.fromJson(data) as T; + if (t == _i65.EscalationReason) { + return _i65.EscalationReason.fromJson(data) as T; } - if (t == _i39.OtpChallenge) { - return _i39.OtpChallenge.fromJson(data) as T; + if (t == _i66.RiskLevel) { + return _i66.RiskLevel.fromJson(data) as T; } - if (t == _i40.Patient) { - return _i40.Patient.fromJson(data) as T; + if (t == _i67.ScheduledNoticeStatus) { + return _i67.ScheduledNoticeStatus.fromJson(data) as T; } - if (t == _i41.TriageAnswer) { - return _i41.TriageAnswer.fromJson(data) as T; + if (t == _i68.SyncStatus) { + return _i68.SyncStatus.fromJson(data) as T; } - if (t == _i42.TriageSession) { - return _i42.TriageSession.fromJson(data) as T; + if (t == _i69.UserRole) { + return _i69.UserRole.fromJson(data) as T; } - if (t == _i43.Ubs) { - return _i43.Ubs.fromJson(data) as T; + if (t == _i70.VisitAuthorship) { + return _i70.VisitAuthorship.fromJson(data) as T; } - if (t == _i44.User) { - return _i44.User.fromJson(data) as T; + if (t == _i71.AdminInvalidRequestException) { + return _i71.AdminInvalidRequestException.fromJson(data) as T; } - if (t == _i45.UserCredential) { - return _i45.UserCredential.fromJson(data) as T; + if (t == _i72.AlertDispatchUnavailableException) { + return _i72.AlertDispatchUnavailableException.fromJson(data) as T; } - if (t == _i46.Visit) { - return _i46.Visit.fromJson(data) as T; + if (t == _i73.AlertPermissionException) { + return _i73.AlertPermissionException.fromJson(data) as T; + } + if (t == _i74.AlertValidationException) { + return _i74.AlertValidationException.fromJson(data) as T; + } + if (t == _i75.AuthenticationFailedException) { + return _i75.AuthenticationFailedException.fromJson(data) as T; + } + if (t == _i76.DataRightsException) { + return _i76.DataRightsException.fromJson(data) as T; + } + if (t == _i77.EndpointDisabledException) { + return _i77.EndpointDisabledException.fromJson(data) as T; + } + if (t == _i78.EnrollmentException) { + return _i78.EnrollmentException.fromJson(data) as T; + } + if (t == _i79.MfaEnrollmentRequiredException) { + return _i79.MfaEnrollmentRequiredException.fromJson(data) as T; + } + if (t == _i80.MfaRequiredException) { + return _i80.MfaRequiredException.fromJson(data) as T; + } + if (t == _i81.NoticeDeliveryException) { + return _i81.NoticeDeliveryException.fromJson(data) as T; + } + if (t == _i82.OtpRequestException) { + return _i82.OtpRequestException.fromJson(data) as T; + } + if (t == _i83.SessionExpiredException) { + return _i83.SessionExpiredException.fromJson(data) as T; + } + if (t == _i84.MicroArea) { + return _i84.MicroArea.fromJson(data) as T; + } + if (t == _i85.OtpChallenge) { + return _i85.OtpChallenge.fromJson(data) as T; + } + if (t == _i86.Patient) { + return _i86.Patient.fromJson(data) as T; + } + if (t == _i87.PushToken) { + return _i87.PushToken.fromJson(data) as T; + } + if (t == _i88.ScheduledNotice) { + return _i88.ScheduledNotice.fromJson(data) as T; + } + if (t == _i89.StaffAccount) { + return _i89.StaffAccount.fromJson(data) as T; + } + if (t == _i90.TermsVersion) { + return _i90.TermsVersion.fromJson(data) as T; + } + if (t == _i91.TriageAnswer) { + return _i91.TriageAnswer.fromJson(data) as T; + } + if (t == _i92.TriageSession) { + return _i92.TriageSession.fromJson(data) as T; + } + if (t == _i93.Ubs) { + return _i93.Ubs.fromJson(data) as T; + } + if (t == _i94.User) { + return _i94.User.fromJson(data) as T; + } + if (t == _i95.UserCredential) { + return _i95.UserCredential.fromJson(data) as T; + } + if (t == _i96.Visit) { + return _i96.Visit.fromJson(data) as T; } if (t == _i1.getType<_i3.Acs?>()) { return (data != null ? _i3.Acs.fromJson(data) : null) as T; } - if (t == _i1.getType<_i4.Alert?>()) { - return (data != null ? _i4.Alert.fromJson(data) : null) as T; + if (t == _i1.getType<_i4.AcsRefreshToken?>()) { + return (data != null ? _i4.AcsRefreshToken.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i5.AcsUploadToken?>()) { + return (data != null ? _i5.AcsUploadToken.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i6.Alert?>()) { + return (data != null ? _i6.Alert.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i7.AlertDeliveryRecord?>()) { + return (data != null ? _i7.AlertDeliveryRecord.fromJson(data) : null) + as T; + } + if (t == _i1.getType<_i8.AlertIdempotencyKey?>()) { + return (data != null ? _i8.AlertIdempotencyKey.fromJson(data) : null) + as T; + } + if (t == _i1.getType<_i9.AlertOutboxEntry?>()) { + return (data != null ? _i9.AlertOutboxEntry.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i10.AdminAcs?>()) { + return (data != null ? _i10.AdminAcs.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i11.AdminAcsCreationResult?>()) { + return (data != null ? _i11.AdminAcsCreationResult.fromJson(data) : null) + as T; + } + if (t == _i1.getType<_i12.AdminAlert?>()) { + return (data != null ? _i12.AdminAlert.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i13.AdminAlertPage?>()) { + return (data != null ? _i13.AdminAlertPage.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i14.AdminAlertsPulse?>()) { + return (data != null ? _i14.AdminAlertsPulse.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i15.AdminAuditChainStatus?>()) { + return (data != null ? _i15.AdminAuditChainStatus.fromJson(data) : null) + as T; + } + if (t == _i1.getType<_i16.AdminAuditEntry?>()) { + return (data != null ? _i16.AdminAuditEntry.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i17.AdminAuditExport?>()) { + return (data != null ? _i17.AdminAuditExport.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i18.AdminAuditFilter?>()) { + return (data != null ? _i18.AdminAuditFilter.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i19.AdminAuditPage?>()) { + return (data != null ? _i19.AdminAuditPage.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i20.AdminDataSubjectRequest?>()) { + return (data != null ? _i20.AdminDataSubjectRequest.fromJson(data) : null) + as T; + } + if (t == _i1.getType<_i21.AdminDataSubjectRequestDetail?>()) { + return (data != null + ? _i21.AdminDataSubjectRequestDetail.fromJson(data) + : null) + as T; + } + if (t == _i1.getType<_i22.AdminDataSubjectRequestPage?>()) { + return (data != null + ? _i22.AdminDataSubjectRequestPage.fromJson(data) + : null) + as T; + } + if (t == _i1.getType<_i23.AdminEscalationResult?>()) { + return (data != null ? _i23.AdminEscalationResult.fromJson(data) : null) + as T; + } + if (t == _i1.getType<_i24.AdminIndicators?>()) { + return (data != null ? _i24.AdminIndicators.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i25.AdminMicroArea?>()) { + return (data != null ? _i25.AdminMicroArea.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i26.AdminPasswordResetResult?>()) { + return (data != null + ? _i26.AdminPasswordResetResult.fromJson(data) + : null) + as T; + } + if (t == _i1.getType<_i27.AdminStaff?>()) { + return (data != null ? _i27.AdminStaff.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i28.AdminStaffMfaResetResult?>()) { + return (data != null + ? _i28.AdminStaffMfaResetResult.fromJson(data) + : null) + as T; + } + if (t == _i1.getType<_i29.AlertAckResult?>()) { + return (data != null ? _i29.AlertAckResult.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i30.AlertStatusResult?>()) { + return (data != null ? _i30.AlertStatusResult.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i31.DevelopmentLoginResult?>()) { + return (data != null ? _i31.DevelopmentLoginResult.fromJson(data) : null) + as T; + } + if (t == _i1.getType<_i32.EnrollmentResult?>()) { + return (data != null ? _i32.EnrollmentResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i5.AlertDeliveryRecord?>()) { - return (data != null ? _i5.AlertDeliveryRecord.fromJson(data) : null) + if (t == _i1.getType<_i33.EnrollmentTokenResult?>()) { + return (data != null ? _i33.EnrollmentTokenResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i6.AlertIdempotencyKey?>()) { - return (data != null ? _i6.AlertIdempotencyKey.fromJson(data) : null) + if (t == _i1.getType<_i34.MicroAreaPatient?>()) { + return (data != null ? _i34.MicroAreaPatient.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i35.NoticeHistoryEntry?>()) { + return (data != null ? _i35.NoticeHistoryEntry.fromJson(data) : null) as T; } - if (t == _i1.getType<_i7.AlertOutboxEntry?>()) { - return (data != null ? _i7.AlertOutboxEntry.fromJson(data) : null) as T; + if (t == _i1.getType<_i36.NoticeHistoryPage?>()) { + return (data != null ? _i36.NoticeHistoryPage.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i37.NoticeSendResult?>()) { + return (data != null ? _i37.NoticeSendResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i8.AlertAckResult?>()) { - return (data != null ? _i8.AlertAckResult.fromJson(data) : null) as T; + if (t == _i1.getType<_i38.PatientConsentRecord?>()) { + return (data != null ? _i38.PatientConsentRecord.fromJson(data) : null) + as T; } - if (t == _i1.getType<_i9.AlertStatusResult?>()) { - return (data != null ? _i9.AlertStatusResult.fromJson(data) : null) as T; + if (t == _i1.getType<_i39.PatientDataOverview?>()) { + return (data != null ? _i39.PatientDataOverview.fromJson(data) : null) + as T; } - if (t == _i1.getType<_i10.DevelopmentLoginResult?>()) { - return (data != null ? _i10.DevelopmentLoginResult.fromJson(data) : null) + if (t == _i1.getType<_i40.PatientDataSubjectRequestRecord?>()) { + return (data != null + ? _i40.PatientDataSubjectRequestRecord.fromJson(data) + : null) as T; } - if (t == _i1.getType<_i11.EnrollmentResult?>()) { - return (data != null ? _i11.EnrollmentResult.fromJson(data) : null) as T; + if (t == _i1.getType<_i41.PatientRiskEvent?>()) { + return (data != null ? _i41.PatientRiskEvent.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i42.RedAlertResult?>()) { + return (data != null ? _i42.RedAlertResult.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i43.ServiceHealth?>()) { + return (data != null ? _i43.ServiceHealth.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i44.TermsChangeNotice?>()) { + return (data != null ? _i44.TermsChangeNotice.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i45.TermsVersionEntry?>()) { + return (data != null ? _i45.TermsVersionEntry.fromJson(data) : null) as T; } - if (t == _i1.getType<_i12.EnrollmentTokenResult?>()) { - return (data != null ? _i12.EnrollmentTokenResult.fromJson(data) : null) + if (t == _i1.getType<_i46.TotpEnrollmentStart?>()) { + return (data != null ? _i46.TotpEnrollmentStart.fromJson(data) : null) as T; } - if (t == _i1.getType<_i13.MicroAreaPatient?>()) { - return (data != null ? _i13.MicroAreaPatient.fromJson(data) : null) as T; + if (t == _i1.getType<_i47.TriageResult?>()) { + return (data != null ? _i47.TriageResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i14.PatientConsentRecord?>()) { - return (data != null ? _i14.PatientConsentRecord.fromJson(data) : null) + if (t == _i1.getType<_i48.UbsContact?>()) { + return (data != null ? _i48.UbsContact.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i49.VisitSyncEntry?>()) { + return (data != null ? _i49.VisitSyncEntry.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i50.VisitSyncResult?>()) { + return (data != null ? _i50.VisitSyncResult.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i51.AuditChainCheckpoint?>()) { + return (data != null ? _i51.AuditChainCheckpoint.fromJson(data) : null) as T; } - if (t == _i1.getType<_i15.PatientDataOverview?>()) { - return (data != null ? _i15.PatientDataOverview.fromJson(data) : null) + if (t == _i1.getType<_i52.AuditExport?>()) { + return (data != null ? _i52.AuditExport.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i53.AuditLog?>()) { + return (data != null ? _i53.AuditLog.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i54.ConsentLog?>()) { + return (data != null ? _i54.ConsentLog.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i55.DataSubjectRequest?>()) { + return (data != null ? _i55.DataSubjectRequest.fromJson(data) : null) as T; } - if (t == _i1.getType<_i16.PatientRiskEvent?>()) { - return (data != null ? _i16.PatientRiskEvent.fromJson(data) : null) as T; + if (t == _i1.getType<_i56.EnrollmentToken?>()) { + return (data != null ? _i56.EnrollmentToken.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i57.AlertStatus?>()) { + return (data != null ? _i57.AlertStatus.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i58.ArrivalMethod?>()) { + return (data != null ? _i58.ArrivalMethod.fromJson(data) : null) as T; } - if (t == _i1.getType<_i17.RedAlertResult?>()) { - return (data != null ? _i17.RedAlertResult.fromJson(data) : null) as T; + if (t == _i1.getType<_i59.AuditChainReason?>()) { + return (data != null ? _i59.AuditChainReason.fromJson(data) : null) as T; } - if (t == _i1.getType<_i18.ServiceHealth?>()) { - return (data != null ? _i18.ServiceHealth.fromJson(data) : null) as T; + if (t == _i1.getType<_i60.AuditIntegrityState?>()) { + return (data != null ? _i60.AuditIntegrityState.fromJson(data) : null) + as T; } - if (t == _i1.getType<_i19.TriageResult?>()) { - return (data != null ? _i19.TriageResult.fromJson(data) : null) as T; + if (t == _i1.getType<_i61.AuditRowSeal?>()) { + return (data != null ? _i61.AuditRowSeal.fromJson(data) : null) as T; } - if (t == _i1.getType<_i20.VisitSyncEntry?>()) { - return (data != null ? _i20.VisitSyncEntry.fromJson(data) : null) as T; + if (t == _i1.getType<_i62.ConsentPurpose?>()) { + return (data != null ? _i62.ConsentPurpose.fromJson(data) : null) as T; } - if (t == _i1.getType<_i21.VisitSyncResult?>()) { - return (data != null ? _i21.VisitSyncResult.fromJson(data) : null) as T; + if (t == _i1.getType<_i63.DataSubjectRequestStatus?>()) { + return (data != null + ? _i63.DataSubjectRequestStatus.fromJson(data) + : null) + as T; } - if (t == _i1.getType<_i22.AuditLog?>()) { - return (data != null ? _i22.AuditLog.fromJson(data) : null) as T; + if (t == _i1.getType<_i64.DataSubjectRequestType?>()) { + return (data != null ? _i64.DataSubjectRequestType.fromJson(data) : null) + as T; } - if (t == _i1.getType<_i23.ConsentLog?>()) { - return (data != null ? _i23.ConsentLog.fromJson(data) : null) as T; + if (t == _i1.getType<_i65.EscalationReason?>()) { + return (data != null ? _i65.EscalationReason.fromJson(data) : null) as T; } - if (t == _i1.getType<_i24.EnrollmentToken?>()) { - return (data != null ? _i24.EnrollmentToken.fromJson(data) : null) as T; + if (t == _i1.getType<_i66.RiskLevel?>()) { + return (data != null ? _i66.RiskLevel.fromJson(data) : null) as T; } - if (t == _i1.getType<_i25.AlertStatus?>()) { - return (data != null ? _i25.AlertStatus.fromJson(data) : null) as T; + if (t == _i1.getType<_i67.ScheduledNoticeStatus?>()) { + return (data != null ? _i67.ScheduledNoticeStatus.fromJson(data) : null) + as T; } - if (t == _i1.getType<_i26.ArrivalMethod?>()) { - return (data != null ? _i26.ArrivalMethod.fromJson(data) : null) as T; + if (t == _i1.getType<_i68.SyncStatus?>()) { + return (data != null ? _i68.SyncStatus.fromJson(data) : null) as T; } - if (t == _i1.getType<_i27.ConsentPurpose?>()) { - return (data != null ? _i27.ConsentPurpose.fromJson(data) : null) as T; + if (t == _i1.getType<_i69.UserRole?>()) { + return (data != null ? _i69.UserRole.fromJson(data) : null) as T; } - if (t == _i1.getType<_i28.RiskLevel?>()) { - return (data != null ? _i28.RiskLevel.fromJson(data) : null) as T; + if (t == _i1.getType<_i70.VisitAuthorship?>()) { + return (data != null ? _i70.VisitAuthorship.fromJson(data) : null) as T; } - if (t == _i1.getType<_i29.SyncStatus?>()) { - return (data != null ? _i29.SyncStatus.fromJson(data) : null) as T; + if (t == _i1.getType<_i71.AdminInvalidRequestException?>()) { + return (data != null + ? _i71.AdminInvalidRequestException.fromJson(data) + : null) + as T; } - if (t == _i1.getType<_i30.UserRole?>()) { - return (data != null ? _i30.UserRole.fromJson(data) : null) as T; + if (t == _i1.getType<_i72.AlertDispatchUnavailableException?>()) { + return (data != null + ? _i72.AlertDispatchUnavailableException.fromJson(data) + : null) + as T; } - if (t == _i1.getType<_i31.AlertDispatchUnavailableException?>()) { + if (t == _i1.getType<_i73.AlertPermissionException?>()) { return (data != null - ? _i31.AlertDispatchUnavailableException.fromJson(data) + ? _i73.AlertPermissionException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i32.AlertPermissionException?>()) { + if (t == _i1.getType<_i74.AlertValidationException?>()) { return (data != null - ? _i32.AlertPermissionException.fromJson(data) + ? _i74.AlertValidationException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i33.AlertValidationException?>()) { + if (t == _i1.getType<_i75.AuthenticationFailedException?>()) { return (data != null - ? _i33.AlertValidationException.fromJson(data) + ? _i75.AuthenticationFailedException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i34.AuthenticationFailedException?>()) { + if (t == _i1.getType<_i76.DataRightsException?>()) { + return (data != null ? _i76.DataRightsException.fromJson(data) : null) + as T; + } + if (t == _i1.getType<_i77.EndpointDisabledException?>()) { return (data != null - ? _i34.AuthenticationFailedException.fromJson(data) + ? _i77.EndpointDisabledException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i35.EndpointDisabledException?>()) { + if (t == _i1.getType<_i78.EnrollmentException?>()) { + return (data != null ? _i78.EnrollmentException.fromJson(data) : null) + as T; + } + if (t == _i1.getType<_i79.MfaEnrollmentRequiredException?>()) { return (data != null - ? _i35.EndpointDisabledException.fromJson(data) + ? _i79.MfaEnrollmentRequiredException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i36.EnrollmentException?>()) { - return (data != null ? _i36.EnrollmentException.fromJson(data) : null) + if (t == _i1.getType<_i80.MfaRequiredException?>()) { + return (data != null ? _i80.MfaRequiredException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i37.OtpRequestException?>()) { - return (data != null ? _i37.OtpRequestException.fromJson(data) : null) + if (t == _i1.getType<_i81.NoticeDeliveryException?>()) { + return (data != null ? _i81.NoticeDeliveryException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i38.MicroArea?>()) { - return (data != null ? _i38.MicroArea.fromJson(data) : null) as T; + if (t == _i1.getType<_i82.OtpRequestException?>()) { + return (data != null ? _i82.OtpRequestException.fromJson(data) : null) + as T; + } + if (t == _i1.getType<_i83.SessionExpiredException?>()) { + return (data != null ? _i83.SessionExpiredException.fromJson(data) : null) + as T; + } + if (t == _i1.getType<_i84.MicroArea?>()) { + return (data != null ? _i84.MicroArea.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i85.OtpChallenge?>()) { + return (data != null ? _i85.OtpChallenge.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i86.Patient?>()) { + return (data != null ? _i86.Patient.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i87.PushToken?>()) { + return (data != null ? _i87.PushToken.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i88.ScheduledNotice?>()) { + return (data != null ? _i88.ScheduledNotice.fromJson(data) : null) as T; } - if (t == _i1.getType<_i39.OtpChallenge?>()) { - return (data != null ? _i39.OtpChallenge.fromJson(data) : null) as T; + if (t == _i1.getType<_i89.StaffAccount?>()) { + return (data != null ? _i89.StaffAccount.fromJson(data) : null) as T; } - if (t == _i1.getType<_i40.Patient?>()) { - return (data != null ? _i40.Patient.fromJson(data) : null) as T; + if (t == _i1.getType<_i90.TermsVersion?>()) { + return (data != null ? _i90.TermsVersion.fromJson(data) : null) as T; } - if (t == _i1.getType<_i41.TriageAnswer?>()) { - return (data != null ? _i41.TriageAnswer.fromJson(data) : null) as T; + if (t == _i1.getType<_i91.TriageAnswer?>()) { + return (data != null ? _i91.TriageAnswer.fromJson(data) : null) as T; } - if (t == _i1.getType<_i42.TriageSession?>()) { - return (data != null ? _i42.TriageSession.fromJson(data) : null) as T; + if (t == _i1.getType<_i92.TriageSession?>()) { + return (data != null ? _i92.TriageSession.fromJson(data) : null) as T; } - if (t == _i1.getType<_i43.Ubs?>()) { - return (data != null ? _i43.Ubs.fromJson(data) : null) as T; + if (t == _i1.getType<_i93.Ubs?>()) { + return (data != null ? _i93.Ubs.fromJson(data) : null) as T; } - if (t == _i1.getType<_i44.User?>()) { - return (data != null ? _i44.User.fromJson(data) : null) as T; + if (t == _i1.getType<_i94.User?>()) { + return (data != null ? _i94.User.fromJson(data) : null) as T; } - if (t == _i1.getType<_i45.UserCredential?>()) { - return (data != null ? _i45.UserCredential.fromJson(data) : null) as T; + if (t == _i1.getType<_i95.UserCredential?>()) { + return (data != null ? _i95.UserCredential.fromJson(data) : null) as T; } - if (t == _i1.getType<_i46.Visit?>()) { - return (data != null ? _i46.Visit.fromJson(data) : null) as T; + if (t == _i1.getType<_i96.Visit?>()) { + return (data != null ? _i96.Visit.fromJson(data) : null) as T; + } + if (t == List<_i12.AdminAlert>) { + return (data as List).map((e) => deserialize<_i12.AdminAlert>(e)).toList() + as T; + } + if (t == List<_i16.AdminAuditEntry>) { + return (data as List) + .map((e) => deserialize<_i16.AdminAuditEntry>(e)) + .toList() + as T; + } + if (t == List<_i20.AdminDataSubjectRequest>) { + return (data as List) + .map((e) => deserialize<_i20.AdminDataSubjectRequest>(e)) + .toList() + as T; } if (t == List) { return (data as List).map((e) => deserialize(e)).toList() as T; } - if (t == List<_i14.PatientConsentRecord>) { + if (t == List<_i35.NoticeHistoryEntry>) { + return (data as List) + .map((e) => deserialize<_i35.NoticeHistoryEntry>(e)) + .toList() + as T; + } + if (t == List<_i38.PatientConsentRecord>) { + return (data as List) + .map((e) => deserialize<_i38.PatientConsentRecord>(e)) + .toList() + as T; + } + if (t == List<_i41.PatientRiskEvent>) { return (data as List) - .map((e) => deserialize<_i14.PatientConsentRecord>(e)) + .map((e) => deserialize<_i41.PatientRiskEvent>(e)) .toList() as T; } - if (t == List<_i16.PatientRiskEvent>) { + if (t == List<_i40.PatientDataSubjectRequestRecord>) { return (data as List) - .map((e) => deserialize<_i16.PatientRiskEvent>(e)) + .map((e) => deserialize<_i40.PatientDataSubjectRequestRecord>(e)) .toList() as T; } @@ -2038,24 +3658,44 @@ class Protocol extends _i1.SerializationManagerServer { ) as T; } - if (t == List<_i47.MicroAreaPatient>) { + if (t == List<_i97.AdminMicroArea>) { + return (data as List) + .map((e) => deserialize<_i97.AdminMicroArea>(e)) + .toList() + as T; + } + if (t == List<_i98.AdminAcs>) { + return (data as List).map((e) => deserialize<_i98.AdminAcs>(e)).toList() + as T; + } + if (t == List<_i99.AdminStaff>) { + return (data as List).map((e) => deserialize<_i99.AdminStaff>(e)).toList() + as T; + } + if (t == List<_i100.TermsVersionEntry>) { + return (data as List) + .map((e) => deserialize<_i100.TermsVersionEntry>(e)) + .toList() + as T; + } + if (t == List<_i101.MicroAreaPatient>) { return (data as List) - .map((e) => deserialize<_i47.MicroAreaPatient>(e)) + .map((e) => deserialize<_i101.MicroAreaPatient>(e)) .toList() as T; } if (t == List) { return (data as List).map((e) => deserialize(e)).toList() as T; } - if (t == List<_i48.VisitSyncResult>) { + if (t == List<_i102.VisitSyncResult>) { return (data as List) - .map((e) => deserialize<_i48.VisitSyncResult>(e)) + .map((e) => deserialize<_i102.VisitSyncResult>(e)) .toList() as T; } - if (t == List<_i49.VisitSyncEntry>) { + if (t == List<_i103.VisitSyncEntry>) { return (data as List) - .map((e) => deserialize<_i49.VisitSyncEntry>(e)) + .map((e) => deserialize<_i103.VisitSyncEntry>(e)) .toList() as T; } @@ -2068,50 +3708,100 @@ class Protocol extends _i1.SerializationManagerServer { static String? getClassNameForType(Type type) { return switch (type) { _i3.Acs => 'Acs', - _i4.Alert => 'Alert', - _i5.AlertDeliveryRecord => 'AlertDeliveryRecord', - _i6.AlertIdempotencyKey => 'AlertIdempotencyKey', - _i7.AlertOutboxEntry => 'AlertOutboxEntry', - _i8.AlertAckResult => 'AlertAckResult', - _i9.AlertStatusResult => 'AlertStatusResult', - _i10.DevelopmentLoginResult => 'DevelopmentLoginResult', - _i11.EnrollmentResult => 'EnrollmentResult', - _i12.EnrollmentTokenResult => 'EnrollmentTokenResult', - _i13.MicroAreaPatient => 'MicroAreaPatient', - _i14.PatientConsentRecord => 'PatientConsentRecord', - _i15.PatientDataOverview => 'PatientDataOverview', - _i16.PatientRiskEvent => 'PatientRiskEvent', - _i17.RedAlertResult => 'RedAlertResult', - _i18.ServiceHealth => 'ServiceHealth', - _i19.TriageResult => 'TriageResult', - _i20.VisitSyncEntry => 'VisitSyncEntry', - _i21.VisitSyncResult => 'VisitSyncResult', - _i22.AuditLog => 'AuditLog', - _i23.ConsentLog => 'ConsentLog', - _i24.EnrollmentToken => 'EnrollmentToken', - _i25.AlertStatus => 'AlertStatus', - _i26.ArrivalMethod => 'ArrivalMethod', - _i27.ConsentPurpose => 'ConsentPurpose', - _i28.RiskLevel => 'RiskLevel', - _i29.SyncStatus => 'SyncStatus', - _i30.UserRole => 'UserRole', - _i31.AlertDispatchUnavailableException => + _i4.AcsRefreshToken => 'AcsRefreshToken', + _i5.AcsUploadToken => 'AcsUploadToken', + _i6.Alert => 'Alert', + _i7.AlertDeliveryRecord => 'AlertDeliveryRecord', + _i8.AlertIdempotencyKey => 'AlertIdempotencyKey', + _i9.AlertOutboxEntry => 'AlertOutboxEntry', + _i10.AdminAcs => 'AdminAcs', + _i11.AdminAcsCreationResult => 'AdminAcsCreationResult', + _i12.AdminAlert => 'AdminAlert', + _i13.AdminAlertPage => 'AdminAlertPage', + _i14.AdminAlertsPulse => 'AdminAlertsPulse', + _i15.AdminAuditChainStatus => 'AdminAuditChainStatus', + _i16.AdminAuditEntry => 'AdminAuditEntry', + _i17.AdminAuditExport => 'AdminAuditExport', + _i18.AdminAuditFilter => 'AdminAuditFilter', + _i19.AdminAuditPage => 'AdminAuditPage', + _i20.AdminDataSubjectRequest => 'AdminDataSubjectRequest', + _i21.AdminDataSubjectRequestDetail => 'AdminDataSubjectRequestDetail', + _i22.AdminDataSubjectRequestPage => 'AdminDataSubjectRequestPage', + _i23.AdminEscalationResult => 'AdminEscalationResult', + _i24.AdminIndicators => 'AdminIndicators', + _i25.AdminMicroArea => 'AdminMicroArea', + _i26.AdminPasswordResetResult => 'AdminPasswordResetResult', + _i27.AdminStaff => 'AdminStaff', + _i28.AdminStaffMfaResetResult => 'AdminStaffMfaResetResult', + _i29.AlertAckResult => 'AlertAckResult', + _i30.AlertStatusResult => 'AlertStatusResult', + _i31.DevelopmentLoginResult => 'DevelopmentLoginResult', + _i32.EnrollmentResult => 'EnrollmentResult', + _i33.EnrollmentTokenResult => 'EnrollmentTokenResult', + _i34.MicroAreaPatient => 'MicroAreaPatient', + _i35.NoticeHistoryEntry => 'NoticeHistoryEntry', + _i36.NoticeHistoryPage => 'NoticeHistoryPage', + _i37.NoticeSendResult => 'NoticeSendResult', + _i38.PatientConsentRecord => 'PatientConsentRecord', + _i39.PatientDataOverview => 'PatientDataOverview', + _i40.PatientDataSubjectRequestRecord => 'PatientDataSubjectRequestRecord', + _i41.PatientRiskEvent => 'PatientRiskEvent', + _i42.RedAlertResult => 'RedAlertResult', + _i43.ServiceHealth => 'ServiceHealth', + _i44.TermsChangeNotice => 'TermsChangeNotice', + _i45.TermsVersionEntry => 'TermsVersionEntry', + _i46.TotpEnrollmentStart => 'TotpEnrollmentStart', + _i47.TriageResult => 'TriageResult', + _i48.UbsContact => 'UbsContact', + _i49.VisitSyncEntry => 'VisitSyncEntry', + _i50.VisitSyncResult => 'VisitSyncResult', + _i51.AuditChainCheckpoint => 'AuditChainCheckpoint', + _i52.AuditExport => 'AuditExport', + _i53.AuditLog => 'AuditLog', + _i54.ConsentLog => 'ConsentLog', + _i55.DataSubjectRequest => 'DataSubjectRequest', + _i56.EnrollmentToken => 'EnrollmentToken', + _i57.AlertStatus => 'AlertStatus', + _i58.ArrivalMethod => 'ArrivalMethod', + _i59.AuditChainReason => 'AuditChainReason', + _i60.AuditIntegrityState => 'AuditIntegrityState', + _i61.AuditRowSeal => 'AuditRowSeal', + _i62.ConsentPurpose => 'ConsentPurpose', + _i63.DataSubjectRequestStatus => 'DataSubjectRequestStatus', + _i64.DataSubjectRequestType => 'DataSubjectRequestType', + _i65.EscalationReason => 'EscalationReason', + _i66.RiskLevel => 'RiskLevel', + _i67.ScheduledNoticeStatus => 'ScheduledNoticeStatus', + _i68.SyncStatus => 'SyncStatus', + _i69.UserRole => 'UserRole', + _i70.VisitAuthorship => 'VisitAuthorship', + _i71.AdminInvalidRequestException => 'AdminInvalidRequestException', + _i72.AlertDispatchUnavailableException => 'AlertDispatchUnavailableException', - _i32.AlertPermissionException => 'AlertPermissionException', - _i33.AlertValidationException => 'AlertValidationException', - _i34.AuthenticationFailedException => 'AuthenticationFailedException', - _i35.EndpointDisabledException => 'EndpointDisabledException', - _i36.EnrollmentException => 'EnrollmentException', - _i37.OtpRequestException => 'OtpRequestException', - _i38.MicroArea => 'MicroArea', - _i39.OtpChallenge => 'OtpChallenge', - _i40.Patient => 'Patient', - _i41.TriageAnswer => 'TriageAnswer', - _i42.TriageSession => 'TriageSession', - _i43.Ubs => 'Ubs', - _i44.User => 'User', - _i45.UserCredential => 'UserCredential', - _i46.Visit => 'Visit', + _i73.AlertPermissionException => 'AlertPermissionException', + _i74.AlertValidationException => 'AlertValidationException', + _i75.AuthenticationFailedException => 'AuthenticationFailedException', + _i76.DataRightsException => 'DataRightsException', + _i77.EndpointDisabledException => 'EndpointDisabledException', + _i78.EnrollmentException => 'EnrollmentException', + _i79.MfaEnrollmentRequiredException => 'MfaEnrollmentRequiredException', + _i80.MfaRequiredException => 'MfaRequiredException', + _i81.NoticeDeliveryException => 'NoticeDeliveryException', + _i82.OtpRequestException => 'OtpRequestException', + _i83.SessionExpiredException => 'SessionExpiredException', + _i84.MicroArea => 'MicroArea', + _i85.OtpChallenge => 'OtpChallenge', + _i86.Patient => 'Patient', + _i87.PushToken => 'PushToken', + _i88.ScheduledNotice => 'ScheduledNotice', + _i89.StaffAccount => 'StaffAccount', + _i90.TermsVersion => 'TermsVersion', + _i91.TriageAnswer => 'TriageAnswer', + _i92.TriageSession => 'TriageSession', + _i93.Ubs => 'Ubs', + _i94.User => 'User', + _i95.UserCredential => 'UserCredential', + _i96.Visit => 'Visit', _ => null, }; } @@ -2128,91 +3818,191 @@ class Protocol extends _i1.SerializationManagerServer { switch (data) { case _i3.Acs(): return 'Acs'; - case _i4.Alert(): + case _i4.AcsRefreshToken(): + return 'AcsRefreshToken'; + case _i5.AcsUploadToken(): + return 'AcsUploadToken'; + case _i6.Alert(): return 'Alert'; - case _i5.AlertDeliveryRecord(): + case _i7.AlertDeliveryRecord(): return 'AlertDeliveryRecord'; - case _i6.AlertIdempotencyKey(): + case _i8.AlertIdempotencyKey(): return 'AlertIdempotencyKey'; - case _i7.AlertOutboxEntry(): + case _i9.AlertOutboxEntry(): return 'AlertOutboxEntry'; - case _i8.AlertAckResult(): + case _i10.AdminAcs(): + return 'AdminAcs'; + case _i11.AdminAcsCreationResult(): + return 'AdminAcsCreationResult'; + case _i12.AdminAlert(): + return 'AdminAlert'; + case _i13.AdminAlertPage(): + return 'AdminAlertPage'; + case _i14.AdminAlertsPulse(): + return 'AdminAlertsPulse'; + case _i15.AdminAuditChainStatus(): + return 'AdminAuditChainStatus'; + case _i16.AdminAuditEntry(): + return 'AdminAuditEntry'; + case _i17.AdminAuditExport(): + return 'AdminAuditExport'; + case _i18.AdminAuditFilter(): + return 'AdminAuditFilter'; + case _i19.AdminAuditPage(): + return 'AdminAuditPage'; + case _i20.AdminDataSubjectRequest(): + return 'AdminDataSubjectRequest'; + case _i21.AdminDataSubjectRequestDetail(): + return 'AdminDataSubjectRequestDetail'; + case _i22.AdminDataSubjectRequestPage(): + return 'AdminDataSubjectRequestPage'; + case _i23.AdminEscalationResult(): + return 'AdminEscalationResult'; + case _i24.AdminIndicators(): + return 'AdminIndicators'; + case _i25.AdminMicroArea(): + return 'AdminMicroArea'; + case _i26.AdminPasswordResetResult(): + return 'AdminPasswordResetResult'; + case _i27.AdminStaff(): + return 'AdminStaff'; + case _i28.AdminStaffMfaResetResult(): + return 'AdminStaffMfaResetResult'; + case _i29.AlertAckResult(): return 'AlertAckResult'; - case _i9.AlertStatusResult(): + case _i30.AlertStatusResult(): return 'AlertStatusResult'; - case _i10.DevelopmentLoginResult(): + case _i31.DevelopmentLoginResult(): return 'DevelopmentLoginResult'; - case _i11.EnrollmentResult(): + case _i32.EnrollmentResult(): return 'EnrollmentResult'; - case _i12.EnrollmentTokenResult(): + case _i33.EnrollmentTokenResult(): return 'EnrollmentTokenResult'; - case _i13.MicroAreaPatient(): + case _i34.MicroAreaPatient(): return 'MicroAreaPatient'; - case _i14.PatientConsentRecord(): + case _i35.NoticeHistoryEntry(): + return 'NoticeHistoryEntry'; + case _i36.NoticeHistoryPage(): + return 'NoticeHistoryPage'; + case _i37.NoticeSendResult(): + return 'NoticeSendResult'; + case _i38.PatientConsentRecord(): return 'PatientConsentRecord'; - case _i15.PatientDataOverview(): + case _i39.PatientDataOverview(): return 'PatientDataOverview'; - case _i16.PatientRiskEvent(): + case _i40.PatientDataSubjectRequestRecord(): + return 'PatientDataSubjectRequestRecord'; + case _i41.PatientRiskEvent(): return 'PatientRiskEvent'; - case _i17.RedAlertResult(): + case _i42.RedAlertResult(): return 'RedAlertResult'; - case _i18.ServiceHealth(): + case _i43.ServiceHealth(): return 'ServiceHealth'; - case _i19.TriageResult(): + case _i44.TermsChangeNotice(): + return 'TermsChangeNotice'; + case _i45.TermsVersionEntry(): + return 'TermsVersionEntry'; + case _i46.TotpEnrollmentStart(): + return 'TotpEnrollmentStart'; + case _i47.TriageResult(): return 'TriageResult'; - case _i20.VisitSyncEntry(): + case _i48.UbsContact(): + return 'UbsContact'; + case _i49.VisitSyncEntry(): return 'VisitSyncEntry'; - case _i21.VisitSyncResult(): + case _i50.VisitSyncResult(): return 'VisitSyncResult'; - case _i22.AuditLog(): + case _i51.AuditChainCheckpoint(): + return 'AuditChainCheckpoint'; + case _i52.AuditExport(): + return 'AuditExport'; + case _i53.AuditLog(): return 'AuditLog'; - case _i23.ConsentLog(): + case _i54.ConsentLog(): return 'ConsentLog'; - case _i24.EnrollmentToken(): + case _i55.DataSubjectRequest(): + return 'DataSubjectRequest'; + case _i56.EnrollmentToken(): return 'EnrollmentToken'; - case _i25.AlertStatus(): + case _i57.AlertStatus(): return 'AlertStatus'; - case _i26.ArrivalMethod(): + case _i58.ArrivalMethod(): return 'ArrivalMethod'; - case _i27.ConsentPurpose(): + case _i59.AuditChainReason(): + return 'AuditChainReason'; + case _i60.AuditIntegrityState(): + return 'AuditIntegrityState'; + case _i61.AuditRowSeal(): + return 'AuditRowSeal'; + case _i62.ConsentPurpose(): return 'ConsentPurpose'; - case _i28.RiskLevel(): + case _i63.DataSubjectRequestStatus(): + return 'DataSubjectRequestStatus'; + case _i64.DataSubjectRequestType(): + return 'DataSubjectRequestType'; + case _i65.EscalationReason(): + return 'EscalationReason'; + case _i66.RiskLevel(): return 'RiskLevel'; - case _i29.SyncStatus(): + case _i67.ScheduledNoticeStatus(): + return 'ScheduledNoticeStatus'; + case _i68.SyncStatus(): return 'SyncStatus'; - case _i30.UserRole(): + case _i69.UserRole(): return 'UserRole'; - case _i31.AlertDispatchUnavailableException(): + case _i70.VisitAuthorship(): + return 'VisitAuthorship'; + case _i71.AdminInvalidRequestException(): + return 'AdminInvalidRequestException'; + case _i72.AlertDispatchUnavailableException(): return 'AlertDispatchUnavailableException'; - case _i32.AlertPermissionException(): + case _i73.AlertPermissionException(): return 'AlertPermissionException'; - case _i33.AlertValidationException(): + case _i74.AlertValidationException(): return 'AlertValidationException'; - case _i34.AuthenticationFailedException(): + case _i75.AuthenticationFailedException(): return 'AuthenticationFailedException'; - case _i35.EndpointDisabledException(): + case _i76.DataRightsException(): + return 'DataRightsException'; + case _i77.EndpointDisabledException(): return 'EndpointDisabledException'; - case _i36.EnrollmentException(): + case _i78.EnrollmentException(): return 'EnrollmentException'; - case _i37.OtpRequestException(): + case _i79.MfaEnrollmentRequiredException(): + return 'MfaEnrollmentRequiredException'; + case _i80.MfaRequiredException(): + return 'MfaRequiredException'; + case _i81.NoticeDeliveryException(): + return 'NoticeDeliveryException'; + case _i82.OtpRequestException(): return 'OtpRequestException'; - case _i38.MicroArea(): + case _i83.SessionExpiredException(): + return 'SessionExpiredException'; + case _i84.MicroArea(): return 'MicroArea'; - case _i39.OtpChallenge(): + case _i85.OtpChallenge(): return 'OtpChallenge'; - case _i40.Patient(): + case _i86.Patient(): return 'Patient'; - case _i41.TriageAnswer(): + case _i87.PushToken(): + return 'PushToken'; + case _i88.ScheduledNotice(): + return 'ScheduledNotice'; + case _i89.StaffAccount(): + return 'StaffAccount'; + case _i90.TermsVersion(): + return 'TermsVersion'; + case _i91.TriageAnswer(): return 'TriageAnswer'; - case _i42.TriageSession(): + case _i92.TriageSession(): return 'TriageSession'; - case _i43.Ubs(): + case _i93.Ubs(): return 'Ubs'; - case _i44.User(): + case _i94.User(): return 'User'; - case _i45.UserCredential(): + case _i95.UserCredential(): return 'UserCredential'; - case _i46.Visit(): + case _i96.Visit(): return 'Visit'; } className = _i2.Protocol().getClassNameForObject(data); @@ -2231,134 +4021,284 @@ class Protocol extends _i1.SerializationManagerServer { if (dataClassName == 'Acs') { return deserialize<_i3.Acs>(data['data']); } + if (dataClassName == 'AcsRefreshToken') { + return deserialize<_i4.AcsRefreshToken>(data['data']); + } + if (dataClassName == 'AcsUploadToken') { + return deserialize<_i5.AcsUploadToken>(data['data']); + } if (dataClassName == 'Alert') { - return deserialize<_i4.Alert>(data['data']); + return deserialize<_i6.Alert>(data['data']); } if (dataClassName == 'AlertDeliveryRecord') { - return deserialize<_i5.AlertDeliveryRecord>(data['data']); + return deserialize<_i7.AlertDeliveryRecord>(data['data']); } if (dataClassName == 'AlertIdempotencyKey') { - return deserialize<_i6.AlertIdempotencyKey>(data['data']); + return deserialize<_i8.AlertIdempotencyKey>(data['data']); } if (dataClassName == 'AlertOutboxEntry') { - return deserialize<_i7.AlertOutboxEntry>(data['data']); + return deserialize<_i9.AlertOutboxEntry>(data['data']); + } + if (dataClassName == 'AdminAcs') { + return deserialize<_i10.AdminAcs>(data['data']); + } + if (dataClassName == 'AdminAcsCreationResult') { + return deserialize<_i11.AdminAcsCreationResult>(data['data']); + } + if (dataClassName == 'AdminAlert') { + return deserialize<_i12.AdminAlert>(data['data']); + } + if (dataClassName == 'AdminAlertPage') { + return deserialize<_i13.AdminAlertPage>(data['data']); + } + if (dataClassName == 'AdminAlertsPulse') { + return deserialize<_i14.AdminAlertsPulse>(data['data']); + } + if (dataClassName == 'AdminAuditChainStatus') { + return deserialize<_i15.AdminAuditChainStatus>(data['data']); + } + if (dataClassName == 'AdminAuditEntry') { + return deserialize<_i16.AdminAuditEntry>(data['data']); + } + if (dataClassName == 'AdminAuditExport') { + return deserialize<_i17.AdminAuditExport>(data['data']); + } + if (dataClassName == 'AdminAuditFilter') { + return deserialize<_i18.AdminAuditFilter>(data['data']); + } + if (dataClassName == 'AdminAuditPage') { + return deserialize<_i19.AdminAuditPage>(data['data']); + } + if (dataClassName == 'AdminDataSubjectRequest') { + return deserialize<_i20.AdminDataSubjectRequest>(data['data']); + } + if (dataClassName == 'AdminDataSubjectRequestDetail') { + return deserialize<_i21.AdminDataSubjectRequestDetail>(data['data']); + } + if (dataClassName == 'AdminDataSubjectRequestPage') { + return deserialize<_i22.AdminDataSubjectRequestPage>(data['data']); + } + if (dataClassName == 'AdminEscalationResult') { + return deserialize<_i23.AdminEscalationResult>(data['data']); + } + if (dataClassName == 'AdminIndicators') { + return deserialize<_i24.AdminIndicators>(data['data']); + } + if (dataClassName == 'AdminMicroArea') { + return deserialize<_i25.AdminMicroArea>(data['data']); + } + if (dataClassName == 'AdminPasswordResetResult') { + return deserialize<_i26.AdminPasswordResetResult>(data['data']); + } + if (dataClassName == 'AdminStaff') { + return deserialize<_i27.AdminStaff>(data['data']); + } + if (dataClassName == 'AdminStaffMfaResetResult') { + return deserialize<_i28.AdminStaffMfaResetResult>(data['data']); } if (dataClassName == 'AlertAckResult') { - return deserialize<_i8.AlertAckResult>(data['data']); + return deserialize<_i29.AlertAckResult>(data['data']); } if (dataClassName == 'AlertStatusResult') { - return deserialize<_i9.AlertStatusResult>(data['data']); + return deserialize<_i30.AlertStatusResult>(data['data']); } if (dataClassName == 'DevelopmentLoginResult') { - return deserialize<_i10.DevelopmentLoginResult>(data['data']); + return deserialize<_i31.DevelopmentLoginResult>(data['data']); } if (dataClassName == 'EnrollmentResult') { - return deserialize<_i11.EnrollmentResult>(data['data']); + return deserialize<_i32.EnrollmentResult>(data['data']); } if (dataClassName == 'EnrollmentTokenResult') { - return deserialize<_i12.EnrollmentTokenResult>(data['data']); + return deserialize<_i33.EnrollmentTokenResult>(data['data']); } if (dataClassName == 'MicroAreaPatient') { - return deserialize<_i13.MicroAreaPatient>(data['data']); + return deserialize<_i34.MicroAreaPatient>(data['data']); + } + if (dataClassName == 'NoticeHistoryEntry') { + return deserialize<_i35.NoticeHistoryEntry>(data['data']); + } + if (dataClassName == 'NoticeHistoryPage') { + return deserialize<_i36.NoticeHistoryPage>(data['data']); + } + if (dataClassName == 'NoticeSendResult') { + return deserialize<_i37.NoticeSendResult>(data['data']); } if (dataClassName == 'PatientConsentRecord') { - return deserialize<_i14.PatientConsentRecord>(data['data']); + return deserialize<_i38.PatientConsentRecord>(data['data']); } if (dataClassName == 'PatientDataOverview') { - return deserialize<_i15.PatientDataOverview>(data['data']); + return deserialize<_i39.PatientDataOverview>(data['data']); + } + if (dataClassName == 'PatientDataSubjectRequestRecord') { + return deserialize<_i40.PatientDataSubjectRequestRecord>(data['data']); } if (dataClassName == 'PatientRiskEvent') { - return deserialize<_i16.PatientRiskEvent>(data['data']); + return deserialize<_i41.PatientRiskEvent>(data['data']); } if (dataClassName == 'RedAlertResult') { - return deserialize<_i17.RedAlertResult>(data['data']); + return deserialize<_i42.RedAlertResult>(data['data']); } if (dataClassName == 'ServiceHealth') { - return deserialize<_i18.ServiceHealth>(data['data']); + return deserialize<_i43.ServiceHealth>(data['data']); + } + if (dataClassName == 'TermsChangeNotice') { + return deserialize<_i44.TermsChangeNotice>(data['data']); + } + if (dataClassName == 'TermsVersionEntry') { + return deserialize<_i45.TermsVersionEntry>(data['data']); + } + if (dataClassName == 'TotpEnrollmentStart') { + return deserialize<_i46.TotpEnrollmentStart>(data['data']); } if (dataClassName == 'TriageResult') { - return deserialize<_i19.TriageResult>(data['data']); + return deserialize<_i47.TriageResult>(data['data']); + } + if (dataClassName == 'UbsContact') { + return deserialize<_i48.UbsContact>(data['data']); } if (dataClassName == 'VisitSyncEntry') { - return deserialize<_i20.VisitSyncEntry>(data['data']); + return deserialize<_i49.VisitSyncEntry>(data['data']); } if (dataClassName == 'VisitSyncResult') { - return deserialize<_i21.VisitSyncResult>(data['data']); + return deserialize<_i50.VisitSyncResult>(data['data']); + } + if (dataClassName == 'AuditChainCheckpoint') { + return deserialize<_i51.AuditChainCheckpoint>(data['data']); + } + if (dataClassName == 'AuditExport') { + return deserialize<_i52.AuditExport>(data['data']); } if (dataClassName == 'AuditLog') { - return deserialize<_i22.AuditLog>(data['data']); + return deserialize<_i53.AuditLog>(data['data']); } if (dataClassName == 'ConsentLog') { - return deserialize<_i23.ConsentLog>(data['data']); + return deserialize<_i54.ConsentLog>(data['data']); + } + if (dataClassName == 'DataSubjectRequest') { + return deserialize<_i55.DataSubjectRequest>(data['data']); } if (dataClassName == 'EnrollmentToken') { - return deserialize<_i24.EnrollmentToken>(data['data']); + return deserialize<_i56.EnrollmentToken>(data['data']); } if (dataClassName == 'AlertStatus') { - return deserialize<_i25.AlertStatus>(data['data']); + return deserialize<_i57.AlertStatus>(data['data']); } if (dataClassName == 'ArrivalMethod') { - return deserialize<_i26.ArrivalMethod>(data['data']); + return deserialize<_i58.ArrivalMethod>(data['data']); + } + if (dataClassName == 'AuditChainReason') { + return deserialize<_i59.AuditChainReason>(data['data']); + } + if (dataClassName == 'AuditIntegrityState') { + return deserialize<_i60.AuditIntegrityState>(data['data']); + } + if (dataClassName == 'AuditRowSeal') { + return deserialize<_i61.AuditRowSeal>(data['data']); } if (dataClassName == 'ConsentPurpose') { - return deserialize<_i27.ConsentPurpose>(data['data']); + return deserialize<_i62.ConsentPurpose>(data['data']); + } + if (dataClassName == 'DataSubjectRequestStatus') { + return deserialize<_i63.DataSubjectRequestStatus>(data['data']); + } + if (dataClassName == 'DataSubjectRequestType') { + return deserialize<_i64.DataSubjectRequestType>(data['data']); + } + if (dataClassName == 'EscalationReason') { + return deserialize<_i65.EscalationReason>(data['data']); } if (dataClassName == 'RiskLevel') { - return deserialize<_i28.RiskLevel>(data['data']); + return deserialize<_i66.RiskLevel>(data['data']); + } + if (dataClassName == 'ScheduledNoticeStatus') { + return deserialize<_i67.ScheduledNoticeStatus>(data['data']); } if (dataClassName == 'SyncStatus') { - return deserialize<_i29.SyncStatus>(data['data']); + return deserialize<_i68.SyncStatus>(data['data']); } if (dataClassName == 'UserRole') { - return deserialize<_i30.UserRole>(data['data']); + return deserialize<_i69.UserRole>(data['data']); + } + if (dataClassName == 'VisitAuthorship') { + return deserialize<_i70.VisitAuthorship>(data['data']); + } + if (dataClassName == 'AdminInvalidRequestException') { + return deserialize<_i71.AdminInvalidRequestException>(data['data']); } if (dataClassName == 'AlertDispatchUnavailableException') { - return deserialize<_i31.AlertDispatchUnavailableException>(data['data']); + return deserialize<_i72.AlertDispatchUnavailableException>(data['data']); } if (dataClassName == 'AlertPermissionException') { - return deserialize<_i32.AlertPermissionException>(data['data']); + return deserialize<_i73.AlertPermissionException>(data['data']); } if (dataClassName == 'AlertValidationException') { - return deserialize<_i33.AlertValidationException>(data['data']); + return deserialize<_i74.AlertValidationException>(data['data']); } if (dataClassName == 'AuthenticationFailedException') { - return deserialize<_i34.AuthenticationFailedException>(data['data']); + return deserialize<_i75.AuthenticationFailedException>(data['data']); + } + if (dataClassName == 'DataRightsException') { + return deserialize<_i76.DataRightsException>(data['data']); } if (dataClassName == 'EndpointDisabledException') { - return deserialize<_i35.EndpointDisabledException>(data['data']); + return deserialize<_i77.EndpointDisabledException>(data['data']); } if (dataClassName == 'EnrollmentException') { - return deserialize<_i36.EnrollmentException>(data['data']); + return deserialize<_i78.EnrollmentException>(data['data']); + } + if (dataClassName == 'MfaEnrollmentRequiredException') { + return deserialize<_i79.MfaEnrollmentRequiredException>(data['data']); + } + if (dataClassName == 'MfaRequiredException') { + return deserialize<_i80.MfaRequiredException>(data['data']); + } + if (dataClassName == 'NoticeDeliveryException') { + return deserialize<_i81.NoticeDeliveryException>(data['data']); } if (dataClassName == 'OtpRequestException') { - return deserialize<_i37.OtpRequestException>(data['data']); + return deserialize<_i82.OtpRequestException>(data['data']); + } + if (dataClassName == 'SessionExpiredException') { + return deserialize<_i83.SessionExpiredException>(data['data']); } if (dataClassName == 'MicroArea') { - return deserialize<_i38.MicroArea>(data['data']); + return deserialize<_i84.MicroArea>(data['data']); } if (dataClassName == 'OtpChallenge') { - return deserialize<_i39.OtpChallenge>(data['data']); + return deserialize<_i85.OtpChallenge>(data['data']); } if (dataClassName == 'Patient') { - return deserialize<_i40.Patient>(data['data']); + return deserialize<_i86.Patient>(data['data']); + } + if (dataClassName == 'PushToken') { + return deserialize<_i87.PushToken>(data['data']); + } + if (dataClassName == 'ScheduledNotice') { + return deserialize<_i88.ScheduledNotice>(data['data']); + } + if (dataClassName == 'StaffAccount') { + return deserialize<_i89.StaffAccount>(data['data']); + } + if (dataClassName == 'TermsVersion') { + return deserialize<_i90.TermsVersion>(data['data']); } if (dataClassName == 'TriageAnswer') { - return deserialize<_i41.TriageAnswer>(data['data']); + return deserialize<_i91.TriageAnswer>(data['data']); } if (dataClassName == 'TriageSession') { - return deserialize<_i42.TriageSession>(data['data']); + return deserialize<_i92.TriageSession>(data['data']); } if (dataClassName == 'Ubs') { - return deserialize<_i43.Ubs>(data['data']); + return deserialize<_i93.Ubs>(data['data']); } if (dataClassName == 'User') { - return deserialize<_i44.User>(data['data']); + return deserialize<_i94.User>(data['data']); } if (dataClassName == 'UserCredential') { - return deserialize<_i45.UserCredential>(data['data']); + return deserialize<_i95.UserCredential>(data['data']); } if (dataClassName == 'Visit') { - return deserialize<_i46.Visit>(data['data']); + return deserialize<_i96.Visit>(data['data']); } if (dataClassName.startsWith('serverpod.')) { data['className'] = dataClassName.substring(10); @@ -2378,36 +4318,54 @@ class Protocol extends _i1.SerializationManagerServer { switch (t) { case _i3.Acs: return _i3.Acs.t; - case _i4.Alert: - return _i4.Alert.t; - case _i5.AlertDeliveryRecord: - return _i5.AlertDeliveryRecord.t; - case _i6.AlertIdempotencyKey: - return _i6.AlertIdempotencyKey.t; - case _i7.AlertOutboxEntry: - return _i7.AlertOutboxEntry.t; - case _i22.AuditLog: - return _i22.AuditLog.t; - case _i23.ConsentLog: - return _i23.ConsentLog.t; - case _i24.EnrollmentToken: - return _i24.EnrollmentToken.t; - case _i38.MicroArea: - return _i38.MicroArea.t; - case _i39.OtpChallenge: - return _i39.OtpChallenge.t; - case _i40.Patient: - return _i40.Patient.t; - case _i42.TriageSession: - return _i42.TriageSession.t; - case _i43.Ubs: - return _i43.Ubs.t; - case _i44.User: - return _i44.User.t; - case _i45.UserCredential: - return _i45.UserCredential.t; - case _i46.Visit: - return _i46.Visit.t; + case _i4.AcsRefreshToken: + return _i4.AcsRefreshToken.t; + case _i5.AcsUploadToken: + return _i5.AcsUploadToken.t; + case _i6.Alert: + return _i6.Alert.t; + case _i7.AlertDeliveryRecord: + return _i7.AlertDeliveryRecord.t; + case _i8.AlertIdempotencyKey: + return _i8.AlertIdempotencyKey.t; + case _i9.AlertOutboxEntry: + return _i9.AlertOutboxEntry.t; + case _i51.AuditChainCheckpoint: + return _i51.AuditChainCheckpoint.t; + case _i52.AuditExport: + return _i52.AuditExport.t; + case _i53.AuditLog: + return _i53.AuditLog.t; + case _i54.ConsentLog: + return _i54.ConsentLog.t; + case _i55.DataSubjectRequest: + return _i55.DataSubjectRequest.t; + case _i56.EnrollmentToken: + return _i56.EnrollmentToken.t; + case _i84.MicroArea: + return _i84.MicroArea.t; + case _i85.OtpChallenge: + return _i85.OtpChallenge.t; + case _i86.Patient: + return _i86.Patient.t; + case _i87.PushToken: + return _i87.PushToken.t; + case _i88.ScheduledNotice: + return _i88.ScheduledNotice.t; + case _i89.StaffAccount: + return _i89.StaffAccount.t; + case _i90.TermsVersion: + return _i90.TermsVersion.t; + case _i92.TriageSession: + return _i92.TriageSession.t; + case _i93.Ubs: + return _i93.Ubs.t; + case _i94.User: + return _i94.User.t; + case _i95.UserCredential: + return _i95.UserCredential.t; + case _i96.Visit: + return _i96.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 c223e540..626e518f 100644 --- a/backend/sinalacs_server/lib/src/generated/protocol.yaml +++ b/backend/sinalacs_server/lib/src/generated/protocol.yaml @@ -1,3 +1,33 @@ +admin: + - indicators: + - microAreas: + - alerts: + - alertsPulse: + - endMonitoringSession: + - auditLogs: + - verifyAuditChain: + - exportAuditLogs: + - dataSubjectRequests: + - dataSubjectRequest: + - startDataSubjectReview: + - completeDataSubjectRequest: + - rejectDataSubjectRequest: + - acs: + - staff: + - createAcs: + - setAcsMicroArea: + - setAcsActive: + - resetAcsPassword: + - resetAcsMfa: + - resetStaffMfa: + - escalateAlert: + - scheduleNotice: + - cancelScheduledNotice: + - noticeHistory: + - termsVersions: + - draftTermsVersion: + - markTermsReviewed: + - publishTermsVersion: alerts: - createRedAlert: - acknowledge: @@ -5,10 +35,21 @@ alerts: auth: - developmentLogin: - loginInstitutional: + - loginStaff: + - beginStaffTotpEnrollment: + - confirmStaffTotpEnrollment: + - refreshSession: + - logout: + - beginTotpEnrollment: + - confirmTotpEnrollment: - requestOtp: - verifyOtp: +devices: + - registerPushToken: health: - check: +notices: + - sendSegmented: onboarding: - generateEnrollmentToken: - completeEnrollment: @@ -17,8 +58,20 @@ patients: - myChronicConditions: - updateChronicConditions: - myData: + - updateConsent: + - acceptTermsOfUse: + - hasAcceptedCurrentTerms: + - hasGrantedConsent: + - termsChangeNotice: + - requestDataDeletion: + - requestDataCorrection: triage: - evaluate: +ubs: + - myContact: visits: - sync: + - syncLegacy: + - syncDeferred: + - revokeUploadToken: - pull: diff --git a/backend/sinalacs_server/lib/src/generated/push_token.dart b/backend/sinalacs_server/lib/src/generated/push_token.dart new file mode 100644 index 00000000..4112922e --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/push_token.dart @@ -0,0 +1,615 @@ +/* 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; + +/// Token de push (FCM/APNs) do aparelho de um paciente que consentiu com +/// `segmentedPush` (RF14, decisão §3.2). Uma linha por token: se o mesmo token +/// aparece para outro titular, a linha muda de dono, nunca duplica. +/// +/// O token identifica um aparelho, não uma pessoa, mas junto de `userId` liga +/// aparelho a titular — por isso some na revogação do consentimento. +abstract class PushToken + implements _i1.TableRow<_i1.UuidValue?>, _i1.ProtocolSerialization { + PushToken._({ + this.id, + required this.userId, + this.microAreaId, + required this.token, + required this.platform, + required this.createdAt, + required this.updatedAt, + }); + + factory PushToken({ + _i1.UuidValue? id, + required _i1.UuidValue userId, + _i1.UuidValue? microAreaId, + required String token, + required String platform, + required DateTime createdAt, + required DateTime updatedAt, + }) = _PushTokenImpl; + + factory PushToken.fromJson(Map jsonSerialization) { + return PushToken( + id: jsonSerialization['id'] == null + ? null + : _i1.UuidValueJsonExtension.fromJson(jsonSerialization['id']), + userId: _i1.UuidValueJsonExtension.fromJson(jsonSerialization['userId']), + microAreaId: jsonSerialization['microAreaId'] == null + ? null + : _i1.UuidValueJsonExtension.fromJson( + jsonSerialization['microAreaId'], + ), + token: jsonSerialization['token'] as String, + platform: jsonSerialization['platform'] as String, + createdAt: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['createdAt'], + ), + updatedAt: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['updatedAt'], + ), + ); + } + + static final t = PushTokenTable(); + + static const db = PushTokenRepository._(); + + @override + _i1.UuidValue? id; + + _i1.UuidValue userId; + + _i1.UuidValue? microAreaId; + + String token; + + String platform; + + DateTime createdAt; + + DateTime updatedAt; + + @override + _i1.Table<_i1.UuidValue?> get table => t; + + /// Returns a shallow copy of this [PushToken] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + PushToken copyWith({ + _i1.UuidValue? id, + _i1.UuidValue? userId, + _i1.UuidValue? microAreaId, + String? token, + String? platform, + DateTime? createdAt, + DateTime? updatedAt, + }); + @override + Map toJson() { + return { + '__className__': 'PushToken', + if (id != null) 'id': id?.toJson(), + 'userId': userId.toJson(), + if (microAreaId != null) 'microAreaId': microAreaId?.toJson(), + 'token': token, + 'platform': platform, + 'createdAt': createdAt.toJson(), + 'updatedAt': updatedAt.toJson(), + }; + } + + @override + Map toJsonForProtocol() { + return { + '__className__': 'PushToken', + if (id != null) 'id': id?.toJson(), + 'userId': userId.toJson(), + if (microAreaId != null) 'microAreaId': microAreaId?.toJson(), + 'token': token, + 'platform': platform, + 'createdAt': createdAt.toJson(), + 'updatedAt': updatedAt.toJson(), + }; + } + + static PushTokenInclude include() { + return PushTokenInclude._(); + } + + static PushTokenIncludeList includeList({ + _i1.WhereExpressionBuilder? where, + int? limit, + int? offset, + _i1.OrderByBuilder? orderBy, + bool orderDescending = false, + _i1.OrderByListBuilder? orderByList, + PushTokenInclude? include, + }) { + return PushTokenIncludeList._( + where: where, + limit: limit, + offset: offset, + orderBy: orderBy?.call(PushToken.t), + orderDescending: orderDescending, + orderByList: orderByList?.call(PushToken.t), + include: include, + ); + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _PushTokenImpl extends PushToken { + _PushTokenImpl({ + _i1.UuidValue? id, + required _i1.UuidValue userId, + _i1.UuidValue? microAreaId, + required String token, + required String platform, + required DateTime createdAt, + required DateTime updatedAt, + }) : super._( + id: id, + userId: userId, + microAreaId: microAreaId, + token: token, + platform: platform, + createdAt: createdAt, + updatedAt: updatedAt, + ); + + /// Returns a shallow copy of this [PushToken] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + PushToken copyWith({ + Object? id = _Undefined, + _i1.UuidValue? userId, + Object? microAreaId = _Undefined, + String? token, + String? platform, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return PushToken( + id: id is _i1.UuidValue? ? id : this.id, + userId: userId ?? this.userId, + microAreaId: microAreaId is _i1.UuidValue? + ? microAreaId + : this.microAreaId, + token: token ?? this.token, + platform: platform ?? this.platform, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } +} + +class PushTokenUpdateTable extends _i1.UpdateTable { + PushTokenUpdateTable(super.table); + + _i1.ColumnValue<_i1.UuidValue, _i1.UuidValue> userId(_i1.UuidValue value) => + _i1.ColumnValue( + table.userId, + value, + ); + + _i1.ColumnValue<_i1.UuidValue, _i1.UuidValue> microAreaId( + _i1.UuidValue? value, + ) => _i1.ColumnValue( + table.microAreaId, + value, + ); + + _i1.ColumnValue token(String value) => _i1.ColumnValue( + table.token, + value, + ); + + _i1.ColumnValue platform(String value) => _i1.ColumnValue( + table.platform, + value, + ); + + _i1.ColumnValue createdAt(DateTime value) => + _i1.ColumnValue( + table.createdAt, + value, + ); + + _i1.ColumnValue updatedAt(DateTime value) => + _i1.ColumnValue( + table.updatedAt, + value, + ); +} + +class PushTokenTable extends _i1.Table<_i1.UuidValue?> { + PushTokenTable({super.tableRelation}) : super(tableName: 'push_tokens') { + updateTable = PushTokenUpdateTable(this); + userId = _i1.ColumnUuid( + 'userId', + this, + ); + microAreaId = _i1.ColumnUuid( + 'microAreaId', + this, + ); + token = _i1.ColumnString( + 'token', + this, + ); + platform = _i1.ColumnString( + 'platform', + this, + ); + createdAt = _i1.ColumnDateTime( + 'createdAt', + this, + ); + updatedAt = _i1.ColumnDateTime( + 'updatedAt', + this, + ); + } + + late final PushTokenUpdateTable updateTable; + + late final _i1.ColumnUuid userId; + + late final _i1.ColumnUuid microAreaId; + + late final _i1.ColumnString token; + + late final _i1.ColumnString platform; + + late final _i1.ColumnDateTime createdAt; + + late final _i1.ColumnDateTime updatedAt; + + @override + List<_i1.Column> get columns => [ + id, + userId, + microAreaId, + token, + platform, + createdAt, + updatedAt, + ]; +} + +class PushTokenInclude extends _i1.IncludeObject { + PushTokenInclude._(); + + @override + Map get includes => {}; + + @override + _i1.Table<_i1.UuidValue?> get table => PushToken.t; +} + +class PushTokenIncludeList extends _i1.IncludeList { + PushTokenIncludeList._({ + _i1.WhereExpressionBuilder? where, + super.limit, + super.offset, + super.orderBy, + super.orderDescending, + super.orderByList, + super.include, + }) { + super.where = where?.call(PushToken.t); + } + + @override + Map get includes => include?.includes ?? {}; + + @override + _i1.Table<_i1.UuidValue?> get table => PushToken.t; +} + +class PushTokenRepository { + const PushTokenRepository._(); + + /// Returns a list of [PushToken]s matching the given query parameters. + /// + /// Use [where] to specify which items to include in the return value. + /// If none is specified, all items will be returned. + /// + /// To specify the order of the items use [orderBy] or [orderByList] + /// when sorting by multiple columns. + /// + /// The maximum number of items can be set by [limit]. If no limit is set, + /// all items matching the query will be returned. + /// + /// [offset] defines how many items to skip, after which [limit] (or all) + /// items are read from the database. + /// + /// ```dart + /// var persons = await Persons.db.find( + /// session, + /// where: (t) => t.lastName.equals('Jones'), + /// orderBy: (t) => t.firstName, + /// limit: 100, + /// ); + /// ``` + Future> find( + _i1.DatabaseSession session, { + _i1.WhereExpressionBuilder? where, + int? limit, + int? offset, + _i1.OrderByBuilder? orderBy, + bool orderDescending = false, + _i1.OrderByListBuilder? orderByList, + _i1.Transaction? transaction, + _i1.LockMode? lockMode, + _i1.LockBehavior? lockBehavior, + }) async { + return session.db.find( + where: where?.call(PushToken.t), + orderBy: orderBy?.call(PushToken.t), + orderByList: orderByList?.call(PushToken.t), + orderDescending: orderDescending, + limit: limit, + offset: offset, + transaction: transaction, + lockMode: lockMode, + lockBehavior: lockBehavior, + ); + } + + /// Returns the first matching [PushToken] matching the given query parameters. + /// + /// Use [where] to specify which items to include in the return value. + /// If none is specified, all items will be returned. + /// + /// To specify the order use [orderBy] or [orderByList] + /// when sorting by multiple columns. + /// + /// [offset] defines how many items to skip, after which the next one will be picked. + /// + /// ```dart + /// var youngestPerson = await Persons.db.findFirstRow( + /// session, + /// where: (t) => t.lastName.equals('Jones'), + /// orderBy: (t) => t.age, + /// ); + /// ``` + Future findFirstRow( + _i1.DatabaseSession session, { + _i1.WhereExpressionBuilder? where, + int? offset, + _i1.OrderByBuilder? orderBy, + bool orderDescending = false, + _i1.OrderByListBuilder? orderByList, + _i1.Transaction? transaction, + _i1.LockMode? lockMode, + _i1.LockBehavior? lockBehavior, + }) async { + return session.db.findFirstRow( + where: where?.call(PushToken.t), + orderBy: orderBy?.call(PushToken.t), + orderByList: orderByList?.call(PushToken.t), + orderDescending: orderDescending, + offset: offset, + transaction: transaction, + lockMode: lockMode, + lockBehavior: lockBehavior, + ); + } + + /// Finds a single [PushToken] by its [id] or null if no such row exists. + Future findById( + _i1.DatabaseSession session, + _i1.UuidValue id, { + _i1.Transaction? transaction, + _i1.LockMode? lockMode, + _i1.LockBehavior? lockBehavior, + }) async { + return session.db.findById( + id, + transaction: transaction, + lockMode: lockMode, + lockBehavior: lockBehavior, + ); + } + + /// Inserts all [PushToken]s in the list and returns the inserted rows. + /// + /// The returned [PushToken]s will have their `id` fields set. + /// + /// This is an atomic operation, meaning that if one of the rows fails to + /// insert, none of the rows will be inserted. + /// + /// If [ignoreConflicts] is set to `true`, rows that conflict with existing + /// rows are silently skipped, and only the successfully inserted rows are + /// returned. + Future> insert( + _i1.DatabaseSession session, + List rows, { + _i1.Transaction? transaction, + bool ignoreConflicts = false, + }) async { + return session.db.insert( + rows, + transaction: transaction, + ignoreConflicts: ignoreConflicts, + ); + } + + /// Inserts a single [PushToken] and returns the inserted row. + /// + /// The returned [PushToken] will have its `id` field set. + Future insertRow( + _i1.DatabaseSession session, + PushToken row, { + _i1.Transaction? transaction, + }) async { + return session.db.insertRow( + row, + transaction: transaction, + ); + } + + /// Updates all [PushToken]s in the list and returns the updated rows. If + /// [columns] is provided, only those columns will be updated. Defaults to + /// all columns. + /// This is an atomic operation, meaning that if one of the rows fails to + /// update, none of the rows will be updated. + Future> update( + _i1.DatabaseSession session, + List rows, { + _i1.ColumnSelections? columns, + _i1.Transaction? transaction, + }) async { + return session.db.update( + rows, + columns: columns?.call(PushToken.t), + transaction: transaction, + ); + } + + /// Updates a single [PushToken]. The row needs to have its id set. + /// Optionally, a list of [columns] can be provided to only update those + /// columns. Defaults to all columns. + Future updateRow( + _i1.DatabaseSession session, + PushToken row, { + _i1.ColumnSelections? columns, + _i1.Transaction? transaction, + }) async { + return session.db.updateRow( + row, + columns: columns?.call(PushToken.t), + transaction: transaction, + ); + } + + /// Updates a single [PushToken] by its [id] with the specified [columnValues]. + /// Returns the updated row or null if no row with the given id exists. + Future updateById( + _i1.DatabaseSession session, + _i1.UuidValue id, { + required _i1.ColumnValueListBuilder columnValues, + _i1.Transaction? transaction, + }) async { + return session.db.updateById( + id, + columnValues: columnValues(PushToken.t.updateTable), + transaction: transaction, + ); + } + + /// Updates all [PushToken]s matching the [where] expression with the specified [columnValues]. + /// Returns the list of updated rows. + Future> updateWhere( + _i1.DatabaseSession session, { + required _i1.ColumnValueListBuilder columnValues, + required _i1.WhereExpressionBuilder where, + int? limit, + int? offset, + _i1.OrderByBuilder? orderBy, + _i1.OrderByListBuilder? orderByList, + bool orderDescending = false, + _i1.Transaction? transaction, + }) async { + return session.db.updateWhere( + columnValues: columnValues(PushToken.t.updateTable), + where: where(PushToken.t), + limit: limit, + offset: offset, + orderBy: orderBy?.call(PushToken.t), + orderByList: orderByList?.call(PushToken.t), + orderDescending: orderDescending, + transaction: transaction, + ); + } + + /// Deletes all [PushToken]s in the list and returns the deleted rows. + /// This is an atomic operation, meaning that if one of the rows fail to + /// be deleted, none of the rows will be deleted. + Future> delete( + _i1.DatabaseSession session, + List rows, { + _i1.Transaction? transaction, + }) async { + return session.db.delete( + rows, + transaction: transaction, + ); + } + + /// Deletes a single [PushToken]. + Future deleteRow( + _i1.DatabaseSession session, + PushToken row, { + _i1.Transaction? transaction, + }) async { + return session.db.deleteRow( + row, + transaction: transaction, + ); + } + + /// Deletes all rows matching the [where] expression. + Future> deleteWhere( + _i1.DatabaseSession session, { + required _i1.WhereExpressionBuilder where, + _i1.Transaction? transaction, + }) async { + return session.db.deleteWhere( + where: where(PushToken.t), + transaction: transaction, + ); + } + + /// Counts the number of rows matching the [where] expression. If omitted, + /// will return the count of all rows in the table. + Future count( + _i1.DatabaseSession session, { + _i1.WhereExpressionBuilder? where, + int? limit, + _i1.Transaction? transaction, + }) async { + return session.db.count( + where: where?.call(PushToken.t), + limit: limit, + transaction: transaction, + ); + } + + /// Acquires row-level locks on [PushToken] rows matching the [where] expression. + Future lockRows( + _i1.DatabaseSession session, { + required _i1.WhereExpressionBuilder where, + required _i1.LockMode lockMode, + required _i1.Transaction transaction, + _i1.LockBehavior lockBehavior = _i1.LockBehavior.wait, + }) async { + return session.db.lockRows( + where: where(PushToken.t), + lockMode: lockMode, + lockBehavior: lockBehavior, + transaction: transaction, + ); + } +} diff --git a/backend/sinalacs_server/lib/src/generated/scheduled_notice.dart b/backend/sinalacs_server/lib/src/generated/scheduled_notice.dart new file mode 100644 index 00000000..ea7b6cb0 --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/scheduled_notice.dart @@ -0,0 +1,769 @@ +/* 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/scheduled_notice_status.dart' as _i2; + +/// Aviso comunitário agendado por staff (RF14, #46). O texto fica aqui porque o +/// staff o digitou para ser enviado; ele nunca vai para audit_logs. +abstract class ScheduledNotice + implements _i1.TableRow<_i1.UuidValue?>, _i1.ProtocolSerialization { + ScheduledNotice._({ + this.id, + required this.createdBy, + required this.microAreaId, + required this.audience, + required this.title, + required this.message, + required this.scheduledFor, + required this.status, + this.recipients, + this.accepted, + this.failureReason, + required this.createdAt, + this.sentAt, + }); + + factory ScheduledNotice({ + _i1.UuidValue? id, + required _i1.UuidValue createdBy, + required _i1.UuidValue microAreaId, + required String audience, + required String title, + required String message, + required DateTime scheduledFor, + required _i2.ScheduledNoticeStatus status, + int? recipients, + int? accepted, + String? failureReason, + required DateTime createdAt, + DateTime? sentAt, + }) = _ScheduledNoticeImpl; + + factory ScheduledNotice.fromJson(Map jsonSerialization) { + return ScheduledNotice( + id: jsonSerialization['id'] == null + ? null + : _i1.UuidValueJsonExtension.fromJson(jsonSerialization['id']), + createdBy: _i1.UuidValueJsonExtension.fromJson( + jsonSerialization['createdBy'], + ), + microAreaId: _i1.UuidValueJsonExtension.fromJson( + jsonSerialization['microAreaId'], + ), + audience: jsonSerialization['audience'] as String, + title: jsonSerialization['title'] as String, + message: jsonSerialization['message'] as String, + scheduledFor: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['scheduledFor'], + ), + status: _i2.ScheduledNoticeStatus.fromJson( + (jsonSerialization['status'] as String), + ), + recipients: jsonSerialization['recipients'] as int?, + accepted: jsonSerialization['accepted'] as int?, + failureReason: jsonSerialization['failureReason'] as String?, + createdAt: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['createdAt'], + ), + sentAt: jsonSerialization['sentAt'] == null + ? null + : _i1.DateTimeJsonExtension.fromJson(jsonSerialization['sentAt']), + ); + } + + static final t = ScheduledNoticeTable(); + + static const db = ScheduledNoticeRepository._(); + + @override + _i1.UuidValue? id; + + _i1.UuidValue createdBy; + + _i1.UuidValue microAreaId; + + String audience; + + String title; + + String message; + + DateTime scheduledFor; + + _i2.ScheduledNoticeStatus status; + + int? recipients; + + int? accepted; + + String? failureReason; + + DateTime createdAt; + + DateTime? sentAt; + + @override + _i1.Table<_i1.UuidValue?> get table => t; + + /// Returns a shallow copy of this [ScheduledNotice] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + ScheduledNotice copyWith({ + _i1.UuidValue? id, + _i1.UuidValue? createdBy, + _i1.UuidValue? microAreaId, + String? audience, + String? title, + String? message, + DateTime? scheduledFor, + _i2.ScheduledNoticeStatus? status, + int? recipients, + int? accepted, + String? failureReason, + DateTime? createdAt, + DateTime? sentAt, + }); + @override + Map toJson() { + return { + '__className__': 'ScheduledNotice', + if (id != null) 'id': id?.toJson(), + 'createdBy': createdBy.toJson(), + 'microAreaId': microAreaId.toJson(), + 'audience': audience, + 'title': title, + 'message': message, + 'scheduledFor': scheduledFor.toJson(), + 'status': status.toJson(), + if (recipients != null) 'recipients': recipients, + if (accepted != null) 'accepted': accepted, + if (failureReason != null) 'failureReason': failureReason, + 'createdAt': createdAt.toJson(), + if (sentAt != null) 'sentAt': sentAt?.toJson(), + }; + } + + @override + Map toJsonForProtocol() { + return { + '__className__': 'ScheduledNotice', + if (id != null) 'id': id?.toJson(), + 'createdBy': createdBy.toJson(), + 'microAreaId': microAreaId.toJson(), + 'audience': audience, + 'title': title, + 'message': message, + 'scheduledFor': scheduledFor.toJson(), + 'status': status.toJson(), + if (recipients != null) 'recipients': recipients, + if (accepted != null) 'accepted': accepted, + if (failureReason != null) 'failureReason': failureReason, + 'createdAt': createdAt.toJson(), + if (sentAt != null) 'sentAt': sentAt?.toJson(), + }; + } + + static ScheduledNoticeInclude include() { + return ScheduledNoticeInclude._(); + } + + static ScheduledNoticeIncludeList includeList({ + _i1.WhereExpressionBuilder? where, + int? limit, + int? offset, + _i1.OrderByBuilder? orderBy, + bool orderDescending = false, + _i1.OrderByListBuilder? orderByList, + ScheduledNoticeInclude? include, + }) { + return ScheduledNoticeIncludeList._( + where: where, + limit: limit, + offset: offset, + orderBy: orderBy?.call(ScheduledNotice.t), + orderDescending: orderDescending, + orderByList: orderByList?.call(ScheduledNotice.t), + include: include, + ); + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _ScheduledNoticeImpl extends ScheduledNotice { + _ScheduledNoticeImpl({ + _i1.UuidValue? id, + required _i1.UuidValue createdBy, + required _i1.UuidValue microAreaId, + required String audience, + required String title, + required String message, + required DateTime scheduledFor, + required _i2.ScheduledNoticeStatus status, + int? recipients, + int? accepted, + String? failureReason, + required DateTime createdAt, + DateTime? sentAt, + }) : super._( + id: id, + createdBy: createdBy, + microAreaId: microAreaId, + audience: audience, + title: title, + message: message, + scheduledFor: scheduledFor, + status: status, + recipients: recipients, + accepted: accepted, + failureReason: failureReason, + createdAt: createdAt, + sentAt: sentAt, + ); + + /// Returns a shallow copy of this [ScheduledNotice] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + ScheduledNotice copyWith({ + Object? id = _Undefined, + _i1.UuidValue? createdBy, + _i1.UuidValue? microAreaId, + String? audience, + String? title, + String? message, + DateTime? scheduledFor, + _i2.ScheduledNoticeStatus? status, + Object? recipients = _Undefined, + Object? accepted = _Undefined, + Object? failureReason = _Undefined, + DateTime? createdAt, + Object? sentAt = _Undefined, + }) { + return ScheduledNotice( + id: id is _i1.UuidValue? ? id : this.id, + createdBy: createdBy ?? this.createdBy, + microAreaId: microAreaId ?? this.microAreaId, + audience: audience ?? this.audience, + title: title ?? this.title, + message: message ?? this.message, + scheduledFor: scheduledFor ?? this.scheduledFor, + status: status ?? this.status, + recipients: recipients is int? ? recipients : this.recipients, + accepted: accepted is int? ? accepted : this.accepted, + failureReason: failureReason is String? + ? failureReason + : this.failureReason, + createdAt: createdAt ?? this.createdAt, + sentAt: sentAt is DateTime? ? sentAt : this.sentAt, + ); + } +} + +class ScheduledNoticeUpdateTable extends _i1.UpdateTable { + ScheduledNoticeUpdateTable(super.table); + + _i1.ColumnValue<_i1.UuidValue, _i1.UuidValue> createdBy( + _i1.UuidValue value, + ) => _i1.ColumnValue( + table.createdBy, + value, + ); + + _i1.ColumnValue<_i1.UuidValue, _i1.UuidValue> microAreaId( + _i1.UuidValue value, + ) => _i1.ColumnValue( + table.microAreaId, + value, + ); + + _i1.ColumnValue audience(String value) => _i1.ColumnValue( + table.audience, + value, + ); + + _i1.ColumnValue title(String value) => _i1.ColumnValue( + table.title, + value, + ); + + _i1.ColumnValue message(String value) => _i1.ColumnValue( + table.message, + value, + ); + + _i1.ColumnValue scheduledFor(DateTime value) => + _i1.ColumnValue( + table.scheduledFor, + value, + ); + + _i1.ColumnValue<_i2.ScheduledNoticeStatus, _i2.ScheduledNoticeStatus> status( + _i2.ScheduledNoticeStatus value, + ) => _i1.ColumnValue( + table.status, + value, + ); + + _i1.ColumnValue recipients(int? value) => _i1.ColumnValue( + table.recipients, + value, + ); + + _i1.ColumnValue accepted(int? value) => _i1.ColumnValue( + table.accepted, + value, + ); + + _i1.ColumnValue failureReason(String? value) => + _i1.ColumnValue( + table.failureReason, + value, + ); + + _i1.ColumnValue createdAt(DateTime value) => + _i1.ColumnValue( + table.createdAt, + value, + ); + + _i1.ColumnValue sentAt(DateTime? value) => + _i1.ColumnValue( + table.sentAt, + value, + ); +} + +class ScheduledNoticeTable extends _i1.Table<_i1.UuidValue?> { + ScheduledNoticeTable({super.tableRelation}) + : super(tableName: 'scheduled_notices') { + updateTable = ScheduledNoticeUpdateTable(this); + createdBy = _i1.ColumnUuid( + 'createdBy', + this, + ); + microAreaId = _i1.ColumnUuid( + 'microAreaId', + this, + ); + audience = _i1.ColumnString( + 'audience', + this, + ); + title = _i1.ColumnString( + 'title', + this, + ); + message = _i1.ColumnString( + 'message', + this, + ); + scheduledFor = _i1.ColumnDateTime( + 'scheduledFor', + this, + ); + status = _i1.ColumnEnum( + 'status', + this, + _i1.EnumSerialization.byName, + ); + recipients = _i1.ColumnInt( + 'recipients', + this, + ); + accepted = _i1.ColumnInt( + 'accepted', + this, + ); + failureReason = _i1.ColumnString( + 'failureReason', + this, + ); + createdAt = _i1.ColumnDateTime( + 'createdAt', + this, + ); + sentAt = _i1.ColumnDateTime( + 'sentAt', + this, + ); + } + + late final ScheduledNoticeUpdateTable updateTable; + + late final _i1.ColumnUuid createdBy; + + late final _i1.ColumnUuid microAreaId; + + late final _i1.ColumnString audience; + + late final _i1.ColumnString title; + + late final _i1.ColumnString message; + + late final _i1.ColumnDateTime scheduledFor; + + late final _i1.ColumnEnum<_i2.ScheduledNoticeStatus> status; + + late final _i1.ColumnInt recipients; + + late final _i1.ColumnInt accepted; + + late final _i1.ColumnString failureReason; + + late final _i1.ColumnDateTime createdAt; + + late final _i1.ColumnDateTime sentAt; + + @override + List<_i1.Column> get columns => [ + id, + createdBy, + microAreaId, + audience, + title, + message, + scheduledFor, + status, + recipients, + accepted, + failureReason, + createdAt, + sentAt, + ]; +} + +class ScheduledNoticeInclude extends _i1.IncludeObject { + ScheduledNoticeInclude._(); + + @override + Map get includes => {}; + + @override + _i1.Table<_i1.UuidValue?> get table => ScheduledNotice.t; +} + +class ScheduledNoticeIncludeList extends _i1.IncludeList { + ScheduledNoticeIncludeList._({ + _i1.WhereExpressionBuilder? where, + super.limit, + super.offset, + super.orderBy, + super.orderDescending, + super.orderByList, + super.include, + }) { + super.where = where?.call(ScheduledNotice.t); + } + + @override + Map get includes => include?.includes ?? {}; + + @override + _i1.Table<_i1.UuidValue?> get table => ScheduledNotice.t; +} + +class ScheduledNoticeRepository { + const ScheduledNoticeRepository._(); + + /// Returns a list of [ScheduledNotice]s matching the given query parameters. + /// + /// Use [where] to specify which items to include in the return value. + /// If none is specified, all items will be returned. + /// + /// To specify the order of the items use [orderBy] or [orderByList] + /// when sorting by multiple columns. + /// + /// The maximum number of items can be set by [limit]. If no limit is set, + /// all items matching the query will be returned. + /// + /// [offset] defines how many items to skip, after which [limit] (or all) + /// items are read from the database. + /// + /// ```dart + /// var persons = await Persons.db.find( + /// session, + /// where: (t) => t.lastName.equals('Jones'), + /// orderBy: (t) => t.firstName, + /// limit: 100, + /// ); + /// ``` + Future> find( + _i1.DatabaseSession session, { + _i1.WhereExpressionBuilder? where, + int? limit, + int? offset, + _i1.OrderByBuilder? orderBy, + bool orderDescending = false, + _i1.OrderByListBuilder? orderByList, + _i1.Transaction? transaction, + _i1.LockMode? lockMode, + _i1.LockBehavior? lockBehavior, + }) async { + return session.db.find( + where: where?.call(ScheduledNotice.t), + orderBy: orderBy?.call(ScheduledNotice.t), + orderByList: orderByList?.call(ScheduledNotice.t), + orderDescending: orderDescending, + limit: limit, + offset: offset, + transaction: transaction, + lockMode: lockMode, + lockBehavior: lockBehavior, + ); + } + + /// Returns the first matching [ScheduledNotice] matching the given query parameters. + /// + /// Use [where] to specify which items to include in the return value. + /// If none is specified, all items will be returned. + /// + /// To specify the order use [orderBy] or [orderByList] + /// when sorting by multiple columns. + /// + /// [offset] defines how many items to skip, after which the next one will be picked. + /// + /// ```dart + /// var youngestPerson = await Persons.db.findFirstRow( + /// session, + /// where: (t) => t.lastName.equals('Jones'), + /// orderBy: (t) => t.age, + /// ); + /// ``` + Future findFirstRow( + _i1.DatabaseSession session, { + _i1.WhereExpressionBuilder? where, + int? offset, + _i1.OrderByBuilder? orderBy, + bool orderDescending = false, + _i1.OrderByListBuilder? orderByList, + _i1.Transaction? transaction, + _i1.LockMode? lockMode, + _i1.LockBehavior? lockBehavior, + }) async { + return session.db.findFirstRow( + where: where?.call(ScheduledNotice.t), + orderBy: orderBy?.call(ScheduledNotice.t), + orderByList: orderByList?.call(ScheduledNotice.t), + orderDescending: orderDescending, + offset: offset, + transaction: transaction, + lockMode: lockMode, + lockBehavior: lockBehavior, + ); + } + + /// Finds a single [ScheduledNotice] by its [id] or null if no such row exists. + Future findById( + _i1.DatabaseSession session, + _i1.UuidValue id, { + _i1.Transaction? transaction, + _i1.LockMode? lockMode, + _i1.LockBehavior? lockBehavior, + }) async { + return session.db.findById( + id, + transaction: transaction, + lockMode: lockMode, + lockBehavior: lockBehavior, + ); + } + + /// Inserts all [ScheduledNotice]s in the list and returns the inserted rows. + /// + /// The returned [ScheduledNotice]s will have their `id` fields set. + /// + /// This is an atomic operation, meaning that if one of the rows fails to + /// insert, none of the rows will be inserted. + /// + /// If [ignoreConflicts] is set to `true`, rows that conflict with existing + /// rows are silently skipped, and only the successfully inserted rows are + /// returned. + Future> insert( + _i1.DatabaseSession session, + List rows, { + _i1.Transaction? transaction, + bool ignoreConflicts = false, + }) async { + return session.db.insert( + rows, + transaction: transaction, + ignoreConflicts: ignoreConflicts, + ); + } + + /// Inserts a single [ScheduledNotice] and returns the inserted row. + /// + /// The returned [ScheduledNotice] will have its `id` field set. + Future insertRow( + _i1.DatabaseSession session, + ScheduledNotice row, { + _i1.Transaction? transaction, + }) async { + return session.db.insertRow( + row, + transaction: transaction, + ); + } + + /// Updates all [ScheduledNotice]s in the list and returns the updated rows. If + /// [columns] is provided, only those columns will be updated. Defaults to + /// all columns. + /// This is an atomic operation, meaning that if one of the rows fails to + /// update, none of the rows will be updated. + Future> update( + _i1.DatabaseSession session, + List rows, { + _i1.ColumnSelections? columns, + _i1.Transaction? transaction, + }) async { + return session.db.update( + rows, + columns: columns?.call(ScheduledNotice.t), + transaction: transaction, + ); + } + + /// Updates a single [ScheduledNotice]. The row needs to have its id set. + /// Optionally, a list of [columns] can be provided to only update those + /// columns. Defaults to all columns. + Future updateRow( + _i1.DatabaseSession session, + ScheduledNotice row, { + _i1.ColumnSelections? columns, + _i1.Transaction? transaction, + }) async { + return session.db.updateRow( + row, + columns: columns?.call(ScheduledNotice.t), + transaction: transaction, + ); + } + + /// Updates a single [ScheduledNotice] by its [id] with the specified [columnValues]. + /// Returns the updated row or null if no row with the given id exists. + Future updateById( + _i1.DatabaseSession session, + _i1.UuidValue id, { + required _i1.ColumnValueListBuilder + columnValues, + _i1.Transaction? transaction, + }) async { + return session.db.updateById( + id, + columnValues: columnValues(ScheduledNotice.t.updateTable), + transaction: transaction, + ); + } + + /// Updates all [ScheduledNotice]s matching the [where] expression with the specified [columnValues]. + /// Returns the list of updated rows. + Future> updateWhere( + _i1.DatabaseSession session, { + required _i1.ColumnValueListBuilder + columnValues, + required _i1.WhereExpressionBuilder where, + int? limit, + int? offset, + _i1.OrderByBuilder? orderBy, + _i1.OrderByListBuilder? orderByList, + bool orderDescending = false, + _i1.Transaction? transaction, + }) async { + return session.db.updateWhere( + columnValues: columnValues(ScheduledNotice.t.updateTable), + where: where(ScheduledNotice.t), + limit: limit, + offset: offset, + orderBy: orderBy?.call(ScheduledNotice.t), + orderByList: orderByList?.call(ScheduledNotice.t), + orderDescending: orderDescending, + transaction: transaction, + ); + } + + /// Deletes all [ScheduledNotice]s in the list and returns the deleted rows. + /// This is an atomic operation, meaning that if one of the rows fail to + /// be deleted, none of the rows will be deleted. + Future> delete( + _i1.DatabaseSession session, + List rows, { + _i1.Transaction? transaction, + }) async { + return session.db.delete( + rows, + transaction: transaction, + ); + } + + /// Deletes a single [ScheduledNotice]. + Future deleteRow( + _i1.DatabaseSession session, + ScheduledNotice row, { + _i1.Transaction? transaction, + }) async { + return session.db.deleteRow( + row, + transaction: transaction, + ); + } + + /// Deletes all rows matching the [where] expression. + Future> deleteWhere( + _i1.DatabaseSession session, { + required _i1.WhereExpressionBuilder where, + _i1.Transaction? transaction, + }) async { + return session.db.deleteWhere( + where: where(ScheduledNotice.t), + transaction: transaction, + ); + } + + /// Counts the number of rows matching the [where] expression. If omitted, + /// will return the count of all rows in the table. + Future count( + _i1.DatabaseSession session, { + _i1.WhereExpressionBuilder? where, + int? limit, + _i1.Transaction? transaction, + }) async { + return session.db.count( + where: where?.call(ScheduledNotice.t), + limit: limit, + transaction: transaction, + ); + } + + /// Acquires row-level locks on [ScheduledNotice] rows matching the [where] expression. + Future lockRows( + _i1.DatabaseSession session, { + required _i1.WhereExpressionBuilder where, + required _i1.LockMode lockMode, + required _i1.Transaction transaction, + _i1.LockBehavior lockBehavior = _i1.LockBehavior.wait, + }) async { + return session.db.lockRows( + where: where(ScheduledNotice.t), + lockMode: lockMode, + lockBehavior: lockBehavior, + transaction: transaction, + ); + } +} diff --git a/backend/sinalacs_server/lib/src/generated/staff_account.dart b/backend/sinalacs_server/lib/src/generated/staff_account.dart new file mode 100644 index 00000000..5e4005bc --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/staff_account.dart @@ -0,0 +1,661 @@ +/* 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; + +/// Conta de staff do backoffice (coordenador ou administrador). +/// +/// Tabela própria, e não uma coluna em `acs`: staff não é ACS, não tem UBS nem +/// sincronização, e misturar os dois faria `acs.enrollmentId` virar um +/// namespace compartilhado. O id é o UUID do usuário (mesma decisão de `Acs`). +/// O papel mora em `users.role` (`coordinator` | `admin`); a credencial, em +/// `user_credentials`. +abstract class StaffAccount + implements _i1.TableRow<_i1.UuidValue?>, _i1.ProtocolSerialization { + StaffAccount._({ + this.id, + required this.enrollmentId, + required this.active, + this.ubsId, + this.activationCodeHash, + this.activationCodeExpiresAt, + this.activationCodeIssuedBy, + this.activationCodeIssuedAt, + }); + + factory StaffAccount({ + _i1.UuidValue? id, + required String enrollmentId, + required bool active, + _i1.UuidValue? ubsId, + String? activationCodeHash, + DateTime? activationCodeExpiresAt, + String? activationCodeIssuedBy, + DateTime? activationCodeIssuedAt, + }) = _StaffAccountImpl; + + factory StaffAccount.fromJson(Map jsonSerialization) { + return StaffAccount( + id: jsonSerialization['id'] == null + ? null + : _i1.UuidValueJsonExtension.fromJson(jsonSerialization['id']), + enrollmentId: jsonSerialization['enrollmentId'] as String, + active: _i1.BoolJsonExtension.fromJson(jsonSerialization['active']), + ubsId: jsonSerialization['ubsId'] == null + ? null + : _i1.UuidValueJsonExtension.fromJson(jsonSerialization['ubsId']), + activationCodeHash: jsonSerialization['activationCodeHash'] as String?, + activationCodeExpiresAt: + jsonSerialization['activationCodeExpiresAt'] == null + ? null + : _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['activationCodeExpiresAt'], + ), + activationCodeIssuedBy: + jsonSerialization['activationCodeIssuedBy'] as String?, + activationCodeIssuedAt: + jsonSerialization['activationCodeIssuedAt'] == null + ? null + : _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['activationCodeIssuedAt'], + ), + ); + } + + static final t = StaffAccountTable(); + + static const db = StaffAccountRepository._(); + + @override + _i1.UuidValue? id; + + String enrollmentId; + + bool active; + + /// UBS do coordenador; nulo para administrador. Coordenador sem UBS é recusado. + _i1.UuidValue? ubsId; + + String? activationCodeHash; + + DateTime? activationCodeExpiresAt; + + String? activationCodeIssuedBy; + + DateTime? activationCodeIssuedAt; + + @override + _i1.Table<_i1.UuidValue?> get table => t; + + /// Returns a shallow copy of this [StaffAccount] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + StaffAccount copyWith({ + _i1.UuidValue? id, + String? enrollmentId, + bool? active, + _i1.UuidValue? ubsId, + String? activationCodeHash, + DateTime? activationCodeExpiresAt, + String? activationCodeIssuedBy, + DateTime? activationCodeIssuedAt, + }); + @override + Map toJson() { + return { + '__className__': 'StaffAccount', + if (id != null) 'id': id?.toJson(), + 'enrollmentId': enrollmentId, + 'active': active, + if (ubsId != null) 'ubsId': ubsId?.toJson(), + if (activationCodeHash != null) 'activationCodeHash': activationCodeHash, + if (activationCodeExpiresAt != null) + 'activationCodeExpiresAt': activationCodeExpiresAt?.toJson(), + if (activationCodeIssuedBy != null) + 'activationCodeIssuedBy': activationCodeIssuedBy, + if (activationCodeIssuedAt != null) + 'activationCodeIssuedAt': activationCodeIssuedAt?.toJson(), + }; + } + + @override + Map toJsonForProtocol() { + return { + '__className__': 'StaffAccount', + if (id != null) 'id': id?.toJson(), + 'enrollmentId': enrollmentId, + 'active': active, + if (ubsId != null) 'ubsId': ubsId?.toJson(), + if (activationCodeHash != null) 'activationCodeHash': activationCodeHash, + if (activationCodeExpiresAt != null) + 'activationCodeExpiresAt': activationCodeExpiresAt?.toJson(), + if (activationCodeIssuedBy != null) + 'activationCodeIssuedBy': activationCodeIssuedBy, + if (activationCodeIssuedAt != null) + 'activationCodeIssuedAt': activationCodeIssuedAt?.toJson(), + }; + } + + static StaffAccountInclude include() { + return StaffAccountInclude._(); + } + + static StaffAccountIncludeList includeList({ + _i1.WhereExpressionBuilder? where, + int? limit, + int? offset, + _i1.OrderByBuilder? orderBy, + bool orderDescending = false, + _i1.OrderByListBuilder? orderByList, + StaffAccountInclude? include, + }) { + return StaffAccountIncludeList._( + where: where, + limit: limit, + offset: offset, + orderBy: orderBy?.call(StaffAccount.t), + orderDescending: orderDescending, + orderByList: orderByList?.call(StaffAccount.t), + include: include, + ); + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _StaffAccountImpl extends StaffAccount { + _StaffAccountImpl({ + _i1.UuidValue? id, + required String enrollmentId, + required bool active, + _i1.UuidValue? ubsId, + String? activationCodeHash, + DateTime? activationCodeExpiresAt, + String? activationCodeIssuedBy, + DateTime? activationCodeIssuedAt, + }) : super._( + id: id, + enrollmentId: enrollmentId, + active: active, + ubsId: ubsId, + activationCodeHash: activationCodeHash, + activationCodeExpiresAt: activationCodeExpiresAt, + activationCodeIssuedBy: activationCodeIssuedBy, + activationCodeIssuedAt: activationCodeIssuedAt, + ); + + /// Returns a shallow copy of this [StaffAccount] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + StaffAccount copyWith({ + Object? id = _Undefined, + String? enrollmentId, + bool? active, + Object? ubsId = _Undefined, + Object? activationCodeHash = _Undefined, + Object? activationCodeExpiresAt = _Undefined, + Object? activationCodeIssuedBy = _Undefined, + Object? activationCodeIssuedAt = _Undefined, + }) { + return StaffAccount( + id: id is _i1.UuidValue? ? id : this.id, + enrollmentId: enrollmentId ?? this.enrollmentId, + active: active ?? this.active, + ubsId: ubsId is _i1.UuidValue? ? ubsId : this.ubsId, + activationCodeHash: activationCodeHash is String? + ? activationCodeHash + : this.activationCodeHash, + activationCodeExpiresAt: activationCodeExpiresAt is DateTime? + ? activationCodeExpiresAt + : this.activationCodeExpiresAt, + activationCodeIssuedBy: activationCodeIssuedBy is String? + ? activationCodeIssuedBy + : this.activationCodeIssuedBy, + activationCodeIssuedAt: activationCodeIssuedAt is DateTime? + ? activationCodeIssuedAt + : this.activationCodeIssuedAt, + ); + } +} + +class StaffAccountUpdateTable extends _i1.UpdateTable { + StaffAccountUpdateTable(super.table); + + _i1.ColumnValue enrollmentId(String value) => _i1.ColumnValue( + table.enrollmentId, + value, + ); + + _i1.ColumnValue active(bool value) => _i1.ColumnValue( + table.active, + value, + ); + + _i1.ColumnValue<_i1.UuidValue, _i1.UuidValue> ubsId(_i1.UuidValue? value) => + _i1.ColumnValue( + table.ubsId, + value, + ); + + _i1.ColumnValue activationCodeHash(String? value) => + _i1.ColumnValue( + table.activationCodeHash, + value, + ); + + _i1.ColumnValue activationCodeExpiresAt( + DateTime? value, + ) => _i1.ColumnValue( + table.activationCodeExpiresAt, + value, + ); + + _i1.ColumnValue activationCodeIssuedBy(String? value) => + _i1.ColumnValue( + table.activationCodeIssuedBy, + value, + ); + + _i1.ColumnValue activationCodeIssuedAt(DateTime? value) => + _i1.ColumnValue( + table.activationCodeIssuedAt, + value, + ); +} + +class StaffAccountTable extends _i1.Table<_i1.UuidValue?> { + StaffAccountTable({super.tableRelation}) + : super(tableName: 'staff_accounts') { + updateTable = StaffAccountUpdateTable(this); + enrollmentId = _i1.ColumnString( + 'enrollmentId', + this, + ); + active = _i1.ColumnBool( + 'active', + this, + ); + ubsId = _i1.ColumnUuid( + 'ubsId', + this, + ); + activationCodeHash = _i1.ColumnString( + 'activationCodeHash', + this, + ); + activationCodeExpiresAt = _i1.ColumnDateTime( + 'activationCodeExpiresAt', + this, + ); + activationCodeIssuedBy = _i1.ColumnString( + 'activationCodeIssuedBy', + this, + ); + activationCodeIssuedAt = _i1.ColumnDateTime( + 'activationCodeIssuedAt', + this, + ); + } + + late final StaffAccountUpdateTable updateTable; + + late final _i1.ColumnString enrollmentId; + + late final _i1.ColumnBool active; + + /// UBS do coordenador; nulo para administrador. Coordenador sem UBS é recusado. + late final _i1.ColumnUuid ubsId; + + late final _i1.ColumnString activationCodeHash; + + late final _i1.ColumnDateTime activationCodeExpiresAt; + + late final _i1.ColumnString activationCodeIssuedBy; + + late final _i1.ColumnDateTime activationCodeIssuedAt; + + @override + List<_i1.Column> get columns => [ + id, + enrollmentId, + active, + ubsId, + activationCodeHash, + activationCodeExpiresAt, + activationCodeIssuedBy, + activationCodeIssuedAt, + ]; +} + +class StaffAccountInclude extends _i1.IncludeObject { + StaffAccountInclude._(); + + @override + Map get includes => {}; + + @override + _i1.Table<_i1.UuidValue?> get table => StaffAccount.t; +} + +class StaffAccountIncludeList extends _i1.IncludeList { + StaffAccountIncludeList._({ + _i1.WhereExpressionBuilder? where, + super.limit, + super.offset, + super.orderBy, + super.orderDescending, + super.orderByList, + super.include, + }) { + super.where = where?.call(StaffAccount.t); + } + + @override + Map get includes => include?.includes ?? {}; + + @override + _i1.Table<_i1.UuidValue?> get table => StaffAccount.t; +} + +class StaffAccountRepository { + const StaffAccountRepository._(); + + /// Returns a list of [StaffAccount]s matching the given query parameters. + /// + /// Use [where] to specify which items to include in the return value. + /// If none is specified, all items will be returned. + /// + /// To specify the order of the items use [orderBy] or [orderByList] + /// when sorting by multiple columns. + /// + /// The maximum number of items can be set by [limit]. If no limit is set, + /// all items matching the query will be returned. + /// + /// [offset] defines how many items to skip, after which [limit] (or all) + /// items are read from the database. + /// + /// ```dart + /// var persons = await Persons.db.find( + /// session, + /// where: (t) => t.lastName.equals('Jones'), + /// orderBy: (t) => t.firstName, + /// limit: 100, + /// ); + /// ``` + Future> find( + _i1.DatabaseSession session, { + _i1.WhereExpressionBuilder? where, + int? limit, + int? offset, + _i1.OrderByBuilder? orderBy, + bool orderDescending = false, + _i1.OrderByListBuilder? orderByList, + _i1.Transaction? transaction, + _i1.LockMode? lockMode, + _i1.LockBehavior? lockBehavior, + }) async { + return session.db.find( + where: where?.call(StaffAccount.t), + orderBy: orderBy?.call(StaffAccount.t), + orderByList: orderByList?.call(StaffAccount.t), + orderDescending: orderDescending, + limit: limit, + offset: offset, + transaction: transaction, + lockMode: lockMode, + lockBehavior: lockBehavior, + ); + } + + /// Returns the first matching [StaffAccount] matching the given query parameters. + /// + /// Use [where] to specify which items to include in the return value. + /// If none is specified, all items will be returned. + /// + /// To specify the order use [orderBy] or [orderByList] + /// when sorting by multiple columns. + /// + /// [offset] defines how many items to skip, after which the next one will be picked. + /// + /// ```dart + /// var youngestPerson = await Persons.db.findFirstRow( + /// session, + /// where: (t) => t.lastName.equals('Jones'), + /// orderBy: (t) => t.age, + /// ); + /// ``` + Future findFirstRow( + _i1.DatabaseSession session, { + _i1.WhereExpressionBuilder? where, + int? offset, + _i1.OrderByBuilder? orderBy, + bool orderDescending = false, + _i1.OrderByListBuilder? orderByList, + _i1.Transaction? transaction, + _i1.LockMode? lockMode, + _i1.LockBehavior? lockBehavior, + }) async { + return session.db.findFirstRow( + where: where?.call(StaffAccount.t), + orderBy: orderBy?.call(StaffAccount.t), + orderByList: orderByList?.call(StaffAccount.t), + orderDescending: orderDescending, + offset: offset, + transaction: transaction, + lockMode: lockMode, + lockBehavior: lockBehavior, + ); + } + + /// Finds a single [StaffAccount] by its [id] or null if no such row exists. + Future findById( + _i1.DatabaseSession session, + _i1.UuidValue id, { + _i1.Transaction? transaction, + _i1.LockMode? lockMode, + _i1.LockBehavior? lockBehavior, + }) async { + return session.db.findById( + id, + transaction: transaction, + lockMode: lockMode, + lockBehavior: lockBehavior, + ); + } + + /// Inserts all [StaffAccount]s in the list and returns the inserted rows. + /// + /// The returned [StaffAccount]s will have their `id` fields set. + /// + /// This is an atomic operation, meaning that if one of the rows fails to + /// insert, none of the rows will be inserted. + /// + /// If [ignoreConflicts] is set to `true`, rows that conflict with existing + /// rows are silently skipped, and only the successfully inserted rows are + /// returned. + Future> insert( + _i1.DatabaseSession session, + List rows, { + _i1.Transaction? transaction, + bool ignoreConflicts = false, + }) async { + return session.db.insert( + rows, + transaction: transaction, + ignoreConflicts: ignoreConflicts, + ); + } + + /// Inserts a single [StaffAccount] and returns the inserted row. + /// + /// The returned [StaffAccount] will have its `id` field set. + Future insertRow( + _i1.DatabaseSession session, + StaffAccount row, { + _i1.Transaction? transaction, + }) async { + return session.db.insertRow( + row, + transaction: transaction, + ); + } + + /// Updates all [StaffAccount]s in the list and returns the updated rows. If + /// [columns] is provided, only those columns will be updated. Defaults to + /// all columns. + /// This is an atomic operation, meaning that if one of the rows fails to + /// update, none of the rows will be updated. + Future> update( + _i1.DatabaseSession session, + List rows, { + _i1.ColumnSelections? columns, + _i1.Transaction? transaction, + }) async { + return session.db.update( + rows, + columns: columns?.call(StaffAccount.t), + transaction: transaction, + ); + } + + /// Updates a single [StaffAccount]. The row needs to have its id set. + /// Optionally, a list of [columns] can be provided to only update those + /// columns. Defaults to all columns. + Future updateRow( + _i1.DatabaseSession session, + StaffAccount row, { + _i1.ColumnSelections? columns, + _i1.Transaction? transaction, + }) async { + return session.db.updateRow( + row, + columns: columns?.call(StaffAccount.t), + transaction: transaction, + ); + } + + /// Updates a single [StaffAccount] by its [id] with the specified [columnValues]. + /// Returns the updated row or null if no row with the given id exists. + Future updateById( + _i1.DatabaseSession session, + _i1.UuidValue id, { + required _i1.ColumnValueListBuilder columnValues, + _i1.Transaction? transaction, + }) async { + return session.db.updateById( + id, + columnValues: columnValues(StaffAccount.t.updateTable), + transaction: transaction, + ); + } + + /// Updates all [StaffAccount]s matching the [where] expression with the specified [columnValues]. + /// Returns the list of updated rows. + Future> updateWhere( + _i1.DatabaseSession session, { + required _i1.ColumnValueListBuilder columnValues, + required _i1.WhereExpressionBuilder where, + int? limit, + int? offset, + _i1.OrderByBuilder? orderBy, + _i1.OrderByListBuilder? orderByList, + bool orderDescending = false, + _i1.Transaction? transaction, + }) async { + return session.db.updateWhere( + columnValues: columnValues(StaffAccount.t.updateTable), + where: where(StaffAccount.t), + limit: limit, + offset: offset, + orderBy: orderBy?.call(StaffAccount.t), + orderByList: orderByList?.call(StaffAccount.t), + orderDescending: orderDescending, + transaction: transaction, + ); + } + + /// Deletes all [StaffAccount]s in the list and returns the deleted rows. + /// This is an atomic operation, meaning that if one of the rows fail to + /// be deleted, none of the rows will be deleted. + Future> delete( + _i1.DatabaseSession session, + List rows, { + _i1.Transaction? transaction, + }) async { + return session.db.delete( + rows, + transaction: transaction, + ); + } + + /// Deletes a single [StaffAccount]. + Future deleteRow( + _i1.DatabaseSession session, + StaffAccount row, { + _i1.Transaction? transaction, + }) async { + return session.db.deleteRow( + row, + transaction: transaction, + ); + } + + /// Deletes all rows matching the [where] expression. + Future> deleteWhere( + _i1.DatabaseSession session, { + required _i1.WhereExpressionBuilder where, + _i1.Transaction? transaction, + }) async { + return session.db.deleteWhere( + where: where(StaffAccount.t), + transaction: transaction, + ); + } + + /// Counts the number of rows matching the [where] expression. If omitted, + /// will return the count of all rows in the table. + Future count( + _i1.DatabaseSession session, { + _i1.WhereExpressionBuilder? where, + int? limit, + _i1.Transaction? transaction, + }) async { + return session.db.count( + where: where?.call(StaffAccount.t), + limit: limit, + transaction: transaction, + ); + } + + /// Acquires row-level locks on [StaffAccount] rows matching the [where] expression. + Future lockRows( + _i1.DatabaseSession session, { + required _i1.WhereExpressionBuilder where, + required _i1.LockMode lockMode, + required _i1.Transaction transaction, + _i1.LockBehavior lockBehavior = _i1.LockBehavior.wait, + }) async { + return session.db.lockRows( + where: where(StaffAccount.t), + lockMode: lockMode, + lockBehavior: lockBehavior, + transaction: transaction, + ); + } +} diff --git a/backend/sinalacs_server/lib/src/generated/terms_version.dart b/backend/sinalacs_server/lib/src/generated/terms_version.dart new file mode 100644 index 00000000..39c8f2ee --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/terms_version.dart @@ -0,0 +1,711 @@ +/* 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; + +/// Versão dos termos anunciada ao paciente (LGPD-RF18, #46). O TEXTO jurídico +/// não fica aqui: vive no app do paciente. Aqui só o anúncio e a revisão. +abstract class TermsVersion + implements _i1.TableRow<_i1.UuidValue?>, _i1.ProtocolSerialization { + TermsVersion._({ + this.id, + required this.version, + required this.summary, + required this.effectiveFrom, + required this.createdBy, + required this.createdAt, + this.legallyReviewedAt, + this.legallyReviewedBy, + this.publishedAt, + this.publishedBy, + }); + + factory TermsVersion({ + _i1.UuidValue? id, + required String version, + required String summary, + required DateTime effectiveFrom, + required _i1.UuidValue createdBy, + required DateTime createdAt, + DateTime? legallyReviewedAt, + _i1.UuidValue? legallyReviewedBy, + DateTime? publishedAt, + _i1.UuidValue? publishedBy, + }) = _TermsVersionImpl; + + factory TermsVersion.fromJson(Map jsonSerialization) { + return TermsVersion( + id: jsonSerialization['id'] == null + ? null + : _i1.UuidValueJsonExtension.fromJson(jsonSerialization['id']), + version: jsonSerialization['version'] as String, + summary: jsonSerialization['summary'] as String, + effectiveFrom: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['effectiveFrom'], + ), + createdBy: _i1.UuidValueJsonExtension.fromJson( + jsonSerialization['createdBy'], + ), + createdAt: _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['createdAt'], + ), + legallyReviewedAt: jsonSerialization['legallyReviewedAt'] == null + ? null + : _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['legallyReviewedAt'], + ), + legallyReviewedBy: jsonSerialization['legallyReviewedBy'] == null + ? null + : _i1.UuidValueJsonExtension.fromJson( + jsonSerialization['legallyReviewedBy'], + ), + publishedAt: jsonSerialization['publishedAt'] == null + ? null + : _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['publishedAt'], + ), + publishedBy: jsonSerialization['publishedBy'] == null + ? null + : _i1.UuidValueJsonExtension.fromJson( + jsonSerialization['publishedBy'], + ), + ); + } + + static final t = TermsVersionTable(); + + static const db = TermsVersionRepository._(); + + @override + _i1.UuidValue? id; + + String version; + + String summary; + + DateTime effectiveFrom; + + _i1.UuidValue createdBy; + + DateTime createdAt; + + DateTime? legallyReviewedAt; + + _i1.UuidValue? legallyReviewedBy; + + DateTime? publishedAt; + + _i1.UuidValue? publishedBy; + + @override + _i1.Table<_i1.UuidValue?> get table => t; + + /// Returns a shallow copy of this [TermsVersion] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + TermsVersion copyWith({ + _i1.UuidValue? id, + String? version, + String? summary, + DateTime? effectiveFrom, + _i1.UuidValue? createdBy, + DateTime? createdAt, + DateTime? legallyReviewedAt, + _i1.UuidValue? legallyReviewedBy, + DateTime? publishedAt, + _i1.UuidValue? publishedBy, + }); + @override + Map toJson() { + return { + '__className__': 'TermsVersion', + if (id != null) 'id': id?.toJson(), + 'version': version, + 'summary': summary, + 'effectiveFrom': effectiveFrom.toJson(), + 'createdBy': createdBy.toJson(), + 'createdAt': createdAt.toJson(), + if (legallyReviewedAt != null) + 'legallyReviewedAt': legallyReviewedAt?.toJson(), + if (legallyReviewedBy != null) + 'legallyReviewedBy': legallyReviewedBy?.toJson(), + if (publishedAt != null) 'publishedAt': publishedAt?.toJson(), + if (publishedBy != null) 'publishedBy': publishedBy?.toJson(), + }; + } + + @override + Map toJsonForProtocol() { + return { + '__className__': 'TermsVersion', + if (id != null) 'id': id?.toJson(), + 'version': version, + 'summary': summary, + 'effectiveFrom': effectiveFrom.toJson(), + 'createdBy': createdBy.toJson(), + 'createdAt': createdAt.toJson(), + if (legallyReviewedAt != null) + 'legallyReviewedAt': legallyReviewedAt?.toJson(), + if (legallyReviewedBy != null) + 'legallyReviewedBy': legallyReviewedBy?.toJson(), + if (publishedAt != null) 'publishedAt': publishedAt?.toJson(), + if (publishedBy != null) 'publishedBy': publishedBy?.toJson(), + }; + } + + static TermsVersionInclude include() { + return TermsVersionInclude._(); + } + + static TermsVersionIncludeList includeList({ + _i1.WhereExpressionBuilder? where, + int? limit, + int? offset, + _i1.OrderByBuilder? orderBy, + bool orderDescending = false, + _i1.OrderByListBuilder? orderByList, + TermsVersionInclude? include, + }) { + return TermsVersionIncludeList._( + where: where, + limit: limit, + offset: offset, + orderBy: orderBy?.call(TermsVersion.t), + orderDescending: orderDescending, + orderByList: orderByList?.call(TermsVersion.t), + include: include, + ); + } + + @override + String toString() { + return _i1.SerializationManager.encode(this); + } +} + +class _Undefined {} + +class _TermsVersionImpl extends TermsVersion { + _TermsVersionImpl({ + _i1.UuidValue? id, + required String version, + required String summary, + required DateTime effectiveFrom, + required _i1.UuidValue createdBy, + required DateTime createdAt, + DateTime? legallyReviewedAt, + _i1.UuidValue? legallyReviewedBy, + DateTime? publishedAt, + _i1.UuidValue? publishedBy, + }) : super._( + id: id, + version: version, + summary: summary, + effectiveFrom: effectiveFrom, + createdBy: createdBy, + createdAt: createdAt, + legallyReviewedAt: legallyReviewedAt, + legallyReviewedBy: legallyReviewedBy, + publishedAt: publishedAt, + publishedBy: publishedBy, + ); + + /// Returns a shallow copy of this [TermsVersion] + /// with some or all fields replaced by the given arguments. + @_i1.useResult + @override + TermsVersion copyWith({ + Object? id = _Undefined, + String? version, + String? summary, + DateTime? effectiveFrom, + _i1.UuidValue? createdBy, + DateTime? createdAt, + Object? legallyReviewedAt = _Undefined, + Object? legallyReviewedBy = _Undefined, + Object? publishedAt = _Undefined, + Object? publishedBy = _Undefined, + }) { + return TermsVersion( + id: id is _i1.UuidValue? ? id : this.id, + version: version ?? this.version, + summary: summary ?? this.summary, + effectiveFrom: effectiveFrom ?? this.effectiveFrom, + createdBy: createdBy ?? this.createdBy, + createdAt: createdAt ?? this.createdAt, + legallyReviewedAt: legallyReviewedAt is DateTime? + ? legallyReviewedAt + : this.legallyReviewedAt, + legallyReviewedBy: legallyReviewedBy is _i1.UuidValue? + ? legallyReviewedBy + : this.legallyReviewedBy, + publishedAt: publishedAt is DateTime? ? publishedAt : this.publishedAt, + publishedBy: publishedBy is _i1.UuidValue? + ? publishedBy + : this.publishedBy, + ); + } +} + +class TermsVersionUpdateTable extends _i1.UpdateTable { + TermsVersionUpdateTable(super.table); + + _i1.ColumnValue version(String value) => _i1.ColumnValue( + table.version, + value, + ); + + _i1.ColumnValue summary(String value) => _i1.ColumnValue( + table.summary, + value, + ); + + _i1.ColumnValue effectiveFrom(DateTime value) => + _i1.ColumnValue( + table.effectiveFrom, + value, + ); + + _i1.ColumnValue<_i1.UuidValue, _i1.UuidValue> createdBy( + _i1.UuidValue value, + ) => _i1.ColumnValue( + table.createdBy, + value, + ); + + _i1.ColumnValue createdAt(DateTime value) => + _i1.ColumnValue( + table.createdAt, + value, + ); + + _i1.ColumnValue legallyReviewedAt(DateTime? value) => + _i1.ColumnValue( + table.legallyReviewedAt, + value, + ); + + _i1.ColumnValue<_i1.UuidValue, _i1.UuidValue> legallyReviewedBy( + _i1.UuidValue? value, + ) => _i1.ColumnValue( + table.legallyReviewedBy, + value, + ); + + _i1.ColumnValue publishedAt(DateTime? value) => + _i1.ColumnValue( + table.publishedAt, + value, + ); + + _i1.ColumnValue<_i1.UuidValue, _i1.UuidValue> publishedBy( + _i1.UuidValue? value, + ) => _i1.ColumnValue( + table.publishedBy, + value, + ); +} + +class TermsVersionTable extends _i1.Table<_i1.UuidValue?> { + TermsVersionTable({super.tableRelation}) + : super(tableName: 'terms_versions') { + updateTable = TermsVersionUpdateTable(this); + version = _i1.ColumnString( + 'version', + this, + ); + summary = _i1.ColumnString( + 'summary', + this, + ); + effectiveFrom = _i1.ColumnDateTime( + 'effectiveFrom', + this, + ); + createdBy = _i1.ColumnUuid( + 'createdBy', + this, + ); + createdAt = _i1.ColumnDateTime( + 'createdAt', + this, + ); + legallyReviewedAt = _i1.ColumnDateTime( + 'legallyReviewedAt', + this, + ); + legallyReviewedBy = _i1.ColumnUuid( + 'legallyReviewedBy', + this, + ); + publishedAt = _i1.ColumnDateTime( + 'publishedAt', + this, + ); + publishedBy = _i1.ColumnUuid( + 'publishedBy', + this, + ); + } + + late final TermsVersionUpdateTable updateTable; + + late final _i1.ColumnString version; + + late final _i1.ColumnString summary; + + late final _i1.ColumnDateTime effectiveFrom; + + late final _i1.ColumnUuid createdBy; + + late final _i1.ColumnDateTime createdAt; + + late final _i1.ColumnDateTime legallyReviewedAt; + + late final _i1.ColumnUuid legallyReviewedBy; + + late final _i1.ColumnDateTime publishedAt; + + late final _i1.ColumnUuid publishedBy; + + @override + List<_i1.Column> get columns => [ + id, + version, + summary, + effectiveFrom, + createdBy, + createdAt, + legallyReviewedAt, + legallyReviewedBy, + publishedAt, + publishedBy, + ]; +} + +class TermsVersionInclude extends _i1.IncludeObject { + TermsVersionInclude._(); + + @override + Map get includes => {}; + + @override + _i1.Table<_i1.UuidValue?> get table => TermsVersion.t; +} + +class TermsVersionIncludeList extends _i1.IncludeList { + TermsVersionIncludeList._({ + _i1.WhereExpressionBuilder? where, + super.limit, + super.offset, + super.orderBy, + super.orderDescending, + super.orderByList, + super.include, + }) { + super.where = where?.call(TermsVersion.t); + } + + @override + Map get includes => include?.includes ?? {}; + + @override + _i1.Table<_i1.UuidValue?> get table => TermsVersion.t; +} + +class TermsVersionRepository { + const TermsVersionRepository._(); + + /// Returns a list of [TermsVersion]s matching the given query parameters. + /// + /// Use [where] to specify which items to include in the return value. + /// If none is specified, all items will be returned. + /// + /// To specify the order of the items use [orderBy] or [orderByList] + /// when sorting by multiple columns. + /// + /// The maximum number of items can be set by [limit]. If no limit is set, + /// all items matching the query will be returned. + /// + /// [offset] defines how many items to skip, after which [limit] (or all) + /// items are read from the database. + /// + /// ```dart + /// var persons = await Persons.db.find( + /// session, + /// where: (t) => t.lastName.equals('Jones'), + /// orderBy: (t) => t.firstName, + /// limit: 100, + /// ); + /// ``` + Future> find( + _i1.DatabaseSession session, { + _i1.WhereExpressionBuilder? where, + int? limit, + int? offset, + _i1.OrderByBuilder? orderBy, + bool orderDescending = false, + _i1.OrderByListBuilder? orderByList, + _i1.Transaction? transaction, + _i1.LockMode? lockMode, + _i1.LockBehavior? lockBehavior, + }) async { + return session.db.find( + where: where?.call(TermsVersion.t), + orderBy: orderBy?.call(TermsVersion.t), + orderByList: orderByList?.call(TermsVersion.t), + orderDescending: orderDescending, + limit: limit, + offset: offset, + transaction: transaction, + lockMode: lockMode, + lockBehavior: lockBehavior, + ); + } + + /// Returns the first matching [TermsVersion] matching the given query parameters. + /// + /// Use [where] to specify which items to include in the return value. + /// If none is specified, all items will be returned. + /// + /// To specify the order use [orderBy] or [orderByList] + /// when sorting by multiple columns. + /// + /// [offset] defines how many items to skip, after which the next one will be picked. + /// + /// ```dart + /// var youngestPerson = await Persons.db.findFirstRow( + /// session, + /// where: (t) => t.lastName.equals('Jones'), + /// orderBy: (t) => t.age, + /// ); + /// ``` + Future findFirstRow( + _i1.DatabaseSession session, { + _i1.WhereExpressionBuilder? where, + int? offset, + _i1.OrderByBuilder? orderBy, + bool orderDescending = false, + _i1.OrderByListBuilder? orderByList, + _i1.Transaction? transaction, + _i1.LockMode? lockMode, + _i1.LockBehavior? lockBehavior, + }) async { + return session.db.findFirstRow( + where: where?.call(TermsVersion.t), + orderBy: orderBy?.call(TermsVersion.t), + orderByList: orderByList?.call(TermsVersion.t), + orderDescending: orderDescending, + offset: offset, + transaction: transaction, + lockMode: lockMode, + lockBehavior: lockBehavior, + ); + } + + /// Finds a single [TermsVersion] by its [id] or null if no such row exists. + Future findById( + _i1.DatabaseSession session, + _i1.UuidValue id, { + _i1.Transaction? transaction, + _i1.LockMode? lockMode, + _i1.LockBehavior? lockBehavior, + }) async { + return session.db.findById( + id, + transaction: transaction, + lockMode: lockMode, + lockBehavior: lockBehavior, + ); + } + + /// Inserts all [TermsVersion]s in the list and returns the inserted rows. + /// + /// The returned [TermsVersion]s will have their `id` fields set. + /// + /// This is an atomic operation, meaning that if one of the rows fails to + /// insert, none of the rows will be inserted. + /// + /// If [ignoreConflicts] is set to `true`, rows that conflict with existing + /// rows are silently skipped, and only the successfully inserted rows are + /// returned. + Future> insert( + _i1.DatabaseSession session, + List rows, { + _i1.Transaction? transaction, + bool ignoreConflicts = false, + }) async { + return session.db.insert( + rows, + transaction: transaction, + ignoreConflicts: ignoreConflicts, + ); + } + + /// Inserts a single [TermsVersion] and returns the inserted row. + /// + /// The returned [TermsVersion] will have its `id` field set. + Future insertRow( + _i1.DatabaseSession session, + TermsVersion row, { + _i1.Transaction? transaction, + }) async { + return session.db.insertRow( + row, + transaction: transaction, + ); + } + + /// Updates all [TermsVersion]s in the list and returns the updated rows. If + /// [columns] is provided, only those columns will be updated. Defaults to + /// all columns. + /// This is an atomic operation, meaning that if one of the rows fails to + /// update, none of the rows will be updated. + Future> update( + _i1.DatabaseSession session, + List rows, { + _i1.ColumnSelections? columns, + _i1.Transaction? transaction, + }) async { + return session.db.update( + rows, + columns: columns?.call(TermsVersion.t), + transaction: transaction, + ); + } + + /// Updates a single [TermsVersion]. The row needs to have its id set. + /// Optionally, a list of [columns] can be provided to only update those + /// columns. Defaults to all columns. + Future updateRow( + _i1.DatabaseSession session, + TermsVersion row, { + _i1.ColumnSelections? columns, + _i1.Transaction? transaction, + }) async { + return session.db.updateRow( + row, + columns: columns?.call(TermsVersion.t), + transaction: transaction, + ); + } + + /// Updates a single [TermsVersion] by its [id] with the specified [columnValues]. + /// Returns the updated row or null if no row with the given id exists. + Future updateById( + _i1.DatabaseSession session, + _i1.UuidValue id, { + required _i1.ColumnValueListBuilder columnValues, + _i1.Transaction? transaction, + }) async { + return session.db.updateById( + id, + columnValues: columnValues(TermsVersion.t.updateTable), + transaction: transaction, + ); + } + + /// Updates all [TermsVersion]s matching the [where] expression with the specified [columnValues]. + /// Returns the list of updated rows. + Future> updateWhere( + _i1.DatabaseSession session, { + required _i1.ColumnValueListBuilder columnValues, + required _i1.WhereExpressionBuilder where, + int? limit, + int? offset, + _i1.OrderByBuilder? orderBy, + _i1.OrderByListBuilder? orderByList, + bool orderDescending = false, + _i1.Transaction? transaction, + }) async { + return session.db.updateWhere( + columnValues: columnValues(TermsVersion.t.updateTable), + where: where(TermsVersion.t), + limit: limit, + offset: offset, + orderBy: orderBy?.call(TermsVersion.t), + orderByList: orderByList?.call(TermsVersion.t), + orderDescending: orderDescending, + transaction: transaction, + ); + } + + /// Deletes all [TermsVersion]s in the list and returns the deleted rows. + /// This is an atomic operation, meaning that if one of the rows fail to + /// be deleted, none of the rows will be deleted. + Future> delete( + _i1.DatabaseSession session, + List rows, { + _i1.Transaction? transaction, + }) async { + return session.db.delete( + rows, + transaction: transaction, + ); + } + + /// Deletes a single [TermsVersion]. + Future deleteRow( + _i1.DatabaseSession session, + TermsVersion row, { + _i1.Transaction? transaction, + }) async { + return session.db.deleteRow( + row, + transaction: transaction, + ); + } + + /// Deletes all rows matching the [where] expression. + Future> deleteWhere( + _i1.DatabaseSession session, { + required _i1.WhereExpressionBuilder where, + _i1.Transaction? transaction, + }) async { + return session.db.deleteWhere( + where: where(TermsVersion.t), + transaction: transaction, + ); + } + + /// Counts the number of rows matching the [where] expression. If omitted, + /// will return the count of all rows in the table. + Future count( + _i1.DatabaseSession session, { + _i1.WhereExpressionBuilder? where, + int? limit, + _i1.Transaction? transaction, + }) async { + return session.db.count( + where: where?.call(TermsVersion.t), + limit: limit, + transaction: transaction, + ); + } + + /// Acquires row-level locks on [TermsVersion] rows matching the [where] expression. + Future lockRows( + _i1.DatabaseSession session, { + required _i1.WhereExpressionBuilder where, + required _i1.LockMode lockMode, + required _i1.Transaction transaction, + _i1.LockBehavior lockBehavior = _i1.LockBehavior.wait, + }) async { + return session.db.lockRows( + where: where(TermsVersion.t), + lockMode: lockMode, + lockBehavior: lockBehavior, + transaction: transaction, + ); + } +} diff --git a/backend/sinalacs_server/lib/src/generated/ubs.dart b/backend/sinalacs_server/lib/src/generated/ubs.dart index b14a38f7..fc12b833 100644 --- a/backend/sinalacs_server/lib/src/generated/ubs.dart +++ b/backend/sinalacs_server/lib/src/generated/ubs.dart @@ -22,6 +22,7 @@ abstract class Ubs required this.address, required this.city, required this.state, + this.contactPhone, }); factory Ubs({ @@ -30,6 +31,7 @@ abstract class Ubs required String address, required String city, required String state, + String? contactPhone, }) = _UbsImpl; factory Ubs.fromJson(Map jsonSerialization) { @@ -41,6 +43,7 @@ abstract class Ubs address: jsonSerialization['address'] as String, city: jsonSerialization['city'] as String, state: jsonSerialization['state'] as String, + contactPhone: jsonSerialization['contactPhone'] as String?, ); } @@ -59,6 +62,10 @@ abstract class Ubs String state; + /// Telefone de contato da UBS para o ACS escalar um caso (RF13). Dado da + /// unidade, não de pessoa. `null` = ainda não cadastrado: o app avisa, não liga. + String? contactPhone; + @override _i1.Table<_i1.UuidValue?> get table => t; @@ -71,6 +78,7 @@ abstract class Ubs String? address, String? city, String? state, + String? contactPhone, }); @override Map toJson() { @@ -81,6 +89,7 @@ abstract class Ubs 'address': address, 'city': city, 'state': state, + if (contactPhone != null) 'contactPhone': contactPhone, }; } @@ -93,6 +102,7 @@ abstract class Ubs 'address': address, 'city': city, 'state': state, + if (contactPhone != null) 'contactPhone': contactPhone, }; } @@ -135,12 +145,14 @@ class _UbsImpl extends Ubs { required String address, required String city, required String state, + String? contactPhone, }) : super._( id: id, name: name, address: address, city: city, state: state, + contactPhone: contactPhone, ); /// Returns a shallow copy of this [Ubs] @@ -153,6 +165,7 @@ class _UbsImpl extends Ubs { String? address, String? city, String? state, + Object? contactPhone = _Undefined, }) { return Ubs( id: id is _i1.UuidValue? ? id : this.id, @@ -160,6 +173,7 @@ class _UbsImpl extends Ubs { address: address ?? this.address, city: city ?? this.city, state: state ?? this.state, + contactPhone: contactPhone is String? ? contactPhone : this.contactPhone, ); } } @@ -186,6 +200,12 @@ class UbsUpdateTable extends _i1.UpdateTable { table.state, value, ); + + _i1.ColumnValue contactPhone(String? value) => + _i1.ColumnValue( + table.contactPhone, + value, + ); } class UbsTable extends _i1.Table<_i1.UuidValue?> { @@ -207,6 +227,10 @@ class UbsTable extends _i1.Table<_i1.UuidValue?> { 'state', this, ); + contactPhone = _i1.ColumnString( + 'contactPhone', + this, + ); } late final UbsUpdateTable updateTable; @@ -219,6 +243,10 @@ class UbsTable extends _i1.Table<_i1.UuidValue?> { late final _i1.ColumnString state; + /// Telefone de contato da UBS para o ACS escalar um caso (RF13). Dado da + /// unidade, não de pessoa. `null` = ainda não cadastrado: o app avisa, não liga. + late final _i1.ColumnString contactPhone; + @override List<_i1.Column> get columns => [ id, @@ -226,6 +254,7 @@ class UbsTable extends _i1.Table<_i1.UuidValue?> { address, city, state, + contactPhone, ]; } diff --git a/backend/sinalacs_server/lib/src/generated/user_credential.dart b/backend/sinalacs_server/lib/src/generated/user_credential.dart index 74ff6bab..7c82f4f7 100644 --- a/backend/sinalacs_server/lib/src/generated/user_credential.dart +++ b/backend/sinalacs_server/lib/src/generated/user_credential.dart @@ -33,9 +33,14 @@ abstract class UserCredential required this.parallelism, required this.failedAttempts, this.lockedUntil, + int? lockStreak, required this.createdAt, required this.updatedAt, - }); + this.totpSecretEncrypted, + this.totpKeyVersion, + this.totpEnabledAt, + this.totpLastStep, + }) : lockStreak = lockStreak ?? 0; factory UserCredential({ _i1.UuidValue? id, @@ -47,8 +52,13 @@ abstract class UserCredential required int parallelism, required int failedAttempts, DateTime? lockedUntil, + int? lockStreak, required DateTime createdAt, required DateTime updatedAt, + String? totpSecretEncrypted, + int? totpKeyVersion, + DateTime? totpEnabledAt, + int? totpLastStep, }) = _UserCredentialImpl; factory UserCredential.fromJson(Map jsonSerialization) { @@ -68,12 +78,21 @@ abstract class UserCredential : _i1.DateTimeJsonExtension.fromJson( jsonSerialization['lockedUntil'], ), + lockStreak: jsonSerialization['lockStreak'] as int?, createdAt: _i1.DateTimeJsonExtension.fromJson( jsonSerialization['createdAt'], ), updatedAt: _i1.DateTimeJsonExtension.fromJson( jsonSerialization['updatedAt'], ), + totpSecretEncrypted: jsonSerialization['totpSecretEncrypted'] as String?, + totpKeyVersion: jsonSerialization['totpKeyVersion'] as int?, + totpEnabledAt: jsonSerialization['totpEnabledAt'] == null + ? null + : _i1.DateTimeJsonExtension.fromJson( + jsonSerialization['totpEnabledAt'], + ), + totpLastStep: jsonSerialization['totpLastStep'] as int?, ); } @@ -106,10 +125,26 @@ abstract class UserCredential /// `null` = não bloqueado. DateTime? lockedUntil; + /// Rodadas de bloqueio seguidas, sem um login válido no meio. Alimenta o + /// bloqueio progressivo (15 min x 2^lockStreak, teto 24 h). Zera no login válido. + int lockStreak; + DateTime createdAt; DateTime updatedAt; + /// MFA por TOTP (RFC 6238). `null` = sem segredo gravado. O segredo é cifrado + /// (AES-256-GCM, a mesma chave dos dados clínicos); **nunca** em claro. + String? totpSecretEncrypted; + + int? totpKeyVersion; + + /// `null` = enrollment começou e não foi confirmado: a MFA ainda NÃO vale. + DateTime? totpEnabledAt; + + /// Último passo de 30 s aceito. O mesmo código não entra duas vezes (replay). + int? totpLastStep; + @override _i1.Table<_i1.UuidValue?> get table => t; @@ -126,8 +161,13 @@ abstract class UserCredential int? parallelism, int? failedAttempts, DateTime? lockedUntil, + int? lockStreak, DateTime? createdAt, DateTime? updatedAt, + String? totpSecretEncrypted, + int? totpKeyVersion, + DateTime? totpEnabledAt, + int? totpLastStep, }); @override Map toJson() { @@ -142,8 +182,14 @@ abstract class UserCredential 'parallelism': parallelism, 'failedAttempts': failedAttempts, if (lockedUntil != null) 'lockedUntil': lockedUntil?.toJson(), + 'lockStreak': lockStreak, 'createdAt': createdAt.toJson(), 'updatedAt': updatedAt.toJson(), + if (totpSecretEncrypted != null) + 'totpSecretEncrypted': totpSecretEncrypted, + if (totpKeyVersion != null) 'totpKeyVersion': totpKeyVersion, + if (totpEnabledAt != null) 'totpEnabledAt': totpEnabledAt?.toJson(), + if (totpLastStep != null) 'totpLastStep': totpLastStep, }; } @@ -160,8 +206,14 @@ abstract class UserCredential 'parallelism': parallelism, 'failedAttempts': failedAttempts, if (lockedUntil != null) 'lockedUntil': lockedUntil?.toJson(), + 'lockStreak': lockStreak, 'createdAt': createdAt.toJson(), 'updatedAt': updatedAt.toJson(), + if (totpSecretEncrypted != null) + 'totpSecretEncrypted': totpSecretEncrypted, + if (totpKeyVersion != null) 'totpKeyVersion': totpKeyVersion, + if (totpEnabledAt != null) 'totpEnabledAt': totpEnabledAt?.toJson(), + if (totpLastStep != null) 'totpLastStep': totpLastStep, }; } @@ -208,8 +260,13 @@ class _UserCredentialImpl extends UserCredential { required int parallelism, required int failedAttempts, DateTime? lockedUntil, + int? lockStreak, required DateTime createdAt, required DateTime updatedAt, + String? totpSecretEncrypted, + int? totpKeyVersion, + DateTime? totpEnabledAt, + int? totpLastStep, }) : super._( id: id, userId: userId, @@ -220,8 +277,13 @@ class _UserCredentialImpl extends UserCredential { parallelism: parallelism, failedAttempts: failedAttempts, lockedUntil: lockedUntil, + lockStreak: lockStreak, createdAt: createdAt, updatedAt: updatedAt, + totpSecretEncrypted: totpSecretEncrypted, + totpKeyVersion: totpKeyVersion, + totpEnabledAt: totpEnabledAt, + totpLastStep: totpLastStep, ); /// Returns a shallow copy of this [UserCredential] @@ -238,8 +300,13 @@ class _UserCredentialImpl extends UserCredential { int? parallelism, int? failedAttempts, Object? lockedUntil = _Undefined, + int? lockStreak, DateTime? createdAt, DateTime? updatedAt, + Object? totpSecretEncrypted = _Undefined, + Object? totpKeyVersion = _Undefined, + Object? totpEnabledAt = _Undefined, + Object? totpLastStep = _Undefined, }) { return UserCredential( id: id is _i1.UuidValue? ? id : this.id, @@ -251,8 +318,19 @@ class _UserCredentialImpl extends UserCredential { parallelism: parallelism ?? this.parallelism, failedAttempts: failedAttempts ?? this.failedAttempts, lockedUntil: lockedUntil is DateTime? ? lockedUntil : this.lockedUntil, + lockStreak: lockStreak ?? this.lockStreak, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, + totpSecretEncrypted: totpSecretEncrypted is String? + ? totpSecretEncrypted + : this.totpSecretEncrypted, + totpKeyVersion: totpKeyVersion is int? + ? totpKeyVersion + : this.totpKeyVersion, + totpEnabledAt: totpEnabledAt is DateTime? + ? totpEnabledAt + : this.totpEnabledAt, + totpLastStep: totpLastStep is int? ? totpLastStep : this.totpLastStep, ); } } @@ -302,6 +380,11 @@ class UserCredentialUpdateTable extends _i1.UpdateTable { value, ); + _i1.ColumnValue lockStreak(int value) => _i1.ColumnValue( + table.lockStreak, + value, + ); + _i1.ColumnValue createdAt(DateTime value) => _i1.ColumnValue( table.createdAt, @@ -313,6 +396,28 @@ class UserCredentialUpdateTable extends _i1.UpdateTable { table.updatedAt, value, ); + + _i1.ColumnValue totpSecretEncrypted(String? value) => + _i1.ColumnValue( + table.totpSecretEncrypted, + value, + ); + + _i1.ColumnValue totpKeyVersion(int? value) => _i1.ColumnValue( + table.totpKeyVersion, + value, + ); + + _i1.ColumnValue totpEnabledAt(DateTime? value) => + _i1.ColumnValue( + table.totpEnabledAt, + value, + ); + + _i1.ColumnValue totpLastStep(int? value) => _i1.ColumnValue( + table.totpLastStep, + value, + ); } class UserCredentialTable extends _i1.Table<_i1.UuidValue?> { @@ -351,6 +456,11 @@ class UserCredentialTable extends _i1.Table<_i1.UuidValue?> { 'lockedUntil', this, ); + lockStreak = _i1.ColumnInt( + 'lockStreak', + this, + hasDefault: true, + ); createdAt = _i1.ColumnDateTime( 'createdAt', this, @@ -359,6 +469,22 @@ class UserCredentialTable extends _i1.Table<_i1.UuidValue?> { 'updatedAt', this, ); + totpSecretEncrypted = _i1.ColumnString( + 'totpSecretEncrypted', + this, + ); + totpKeyVersion = _i1.ColumnInt( + 'totpKeyVersion', + this, + ); + totpEnabledAt = _i1.ColumnDateTime( + 'totpEnabledAt', + this, + ); + totpLastStep = _i1.ColumnInt( + 'totpLastStep', + this, + ); } late final UserCredentialUpdateTable updateTable; @@ -385,10 +511,26 @@ class UserCredentialTable extends _i1.Table<_i1.UuidValue?> { /// `null` = não bloqueado. late final _i1.ColumnDateTime lockedUntil; + /// Rodadas de bloqueio seguidas, sem um login válido no meio. Alimenta o + /// bloqueio progressivo (15 min x 2^lockStreak, teto 24 h). Zera no login válido. + late final _i1.ColumnInt lockStreak; + late final _i1.ColumnDateTime createdAt; late final _i1.ColumnDateTime updatedAt; + /// MFA por TOTP (RFC 6238). `null` = sem segredo gravado. O segredo é cifrado + /// (AES-256-GCM, a mesma chave dos dados clínicos); **nunca** em claro. + late final _i1.ColumnString totpSecretEncrypted; + + late final _i1.ColumnInt totpKeyVersion; + + /// `null` = enrollment começou e não foi confirmado: a MFA ainda NÃO vale. + late final _i1.ColumnDateTime totpEnabledAt; + + /// Último passo de 30 s aceito. O mesmo código não entra duas vezes (replay). + late final _i1.ColumnInt totpLastStep; + @override List<_i1.Column> get columns => [ id, @@ -400,8 +542,13 @@ class UserCredentialTable extends _i1.Table<_i1.UuidValue?> { parallelism, failedAttempts, lockedUntil, + lockStreak, createdAt, updatedAt, + totpSecretEncrypted, + totpKeyVersion, + totpEnabledAt, + totpLastStep, ]; } diff --git a/backend/sinalacs_server/lib/src/generated/visit.dart b/backend/sinalacs_server/lib/src/generated/visit.dart index 8dab945a..9ef0beb6 100644 --- a/backend/sinalacs_server/lib/src/generated/visit.dart +++ b/backend/sinalacs_server/lib/src/generated/visit.dart @@ -12,9 +12,10 @@ // ignore_for_file: no_leading_underscores_for_library_prefixes import 'package:serverpod/serverpod.dart' as _i1; -import 'enums/arrival_method.dart' as _i2; -import 'enums/risk_level.dart' as _i3; -import 'enums/sync_status.dart' as _i4; +import 'enums/visit_authorship.dart' as _i2; +import 'enums/arrival_method.dart' as _i3; +import 'enums/risk_level.dart' as _i4; +import 'enums/sync_status.dart' as _i5; /// Visita domiciliar. Registrada offline e sincronizada depois. abstract class Visit @@ -22,7 +23,9 @@ abstract class Visit Visit._({ this.id, required this.patientId, - required this.acsId, + this.acsId, + _i2.VisitAuthorship? authorship, + this.originDeviceId, required this.scheduledAt, this.startedAt, this.completedAt, @@ -32,28 +35,31 @@ abstract class Visit String? notesEncrypted, int? notesKeyVersion, required this.syncStatus, - _i2.ArrivalMethod? arrivalMethod, + _i3.ArrivalMethod? arrivalMethod, required this.localId, this.syncAt, required this.version, - }) : notesEncrypted = notesEncrypted ?? '', + }) : authorship = authorship ?? _i2.VisitAuthorship.acs, + notesEncrypted = notesEncrypted ?? '', notesKeyVersion = notesKeyVersion ?? 1, - arrivalMethod = arrivalMethod ?? _i2.ArrivalMethod.manual; + arrivalMethod = arrivalMethod ?? _i3.ArrivalMethod.manual; factory Visit({ _i1.UuidValue? id, required _i1.UuidValue patientId, - required _i1.UuidValue acsId, + _i1.UuidValue? acsId, + _i2.VisitAuthorship? authorship, + String? originDeviceId, required DateTime scheduledAt, DateTime? startedAt, DateTime? completedAt, required String status, - required _i3.RiskLevel riskLevelBefore, - _i3.RiskLevel? riskLevelAfter, + required _i4.RiskLevel riskLevelBefore, + _i4.RiskLevel? riskLevelAfter, String? notesEncrypted, int? notesKeyVersion, - required _i4.SyncStatus syncStatus, - _i2.ArrivalMethod? arrivalMethod, + required _i5.SyncStatus syncStatus, + _i3.ArrivalMethod? arrivalMethod, required _i1.UuidValue localId, DateTime? syncAt, required int version, @@ -67,7 +73,15 @@ abstract class Visit patientId: _i1.UuidValueJsonExtension.fromJson( jsonSerialization['patientId'], ), - acsId: _i1.UuidValueJsonExtension.fromJson(jsonSerialization['acsId']), + acsId: jsonSerialization['acsId'] == null + ? null + : _i1.UuidValueJsonExtension.fromJson(jsonSerialization['acsId']), + authorship: jsonSerialization['authorship'] == null + ? null + : _i2.VisitAuthorship.fromJson( + (jsonSerialization['authorship'] as String), + ), + originDeviceId: jsonSerialization['originDeviceId'] as String?, scheduledAt: _i1.DateTimeJsonExtension.fromJson( jsonSerialization['scheduledAt'], ), @@ -80,22 +94,22 @@ abstract class Visit jsonSerialization['completedAt'], ), status: jsonSerialization['status'] as String, - riskLevelBefore: _i3.RiskLevel.fromJson( + riskLevelBefore: _i4.RiskLevel.fromJson( (jsonSerialization['riskLevelBefore'] as String), ), riskLevelAfter: jsonSerialization['riskLevelAfter'] == null ? null - : _i3.RiskLevel.fromJson( + : _i4.RiskLevel.fromJson( (jsonSerialization['riskLevelAfter'] as String), ), notesEncrypted: jsonSerialization['notesEncrypted'] as String?, notesKeyVersion: jsonSerialization['notesKeyVersion'] as int?, - syncStatus: _i4.SyncStatus.fromJson( + syncStatus: _i5.SyncStatus.fromJson( (jsonSerialization['syncStatus'] as String), ), arrivalMethod: jsonSerialization['arrivalMethod'] == null ? null - : _i2.ArrivalMethod.fromJson( + : _i3.ArrivalMethod.fromJson( (jsonSerialization['arrivalMethod'] as String), ), localId: _i1.UuidValueJsonExtension.fromJson( @@ -117,7 +131,16 @@ abstract class Visit _i1.UuidValue patientId; - _i1.UuidValue acsId; + /// Autor da visita. Nulo SÓ quando `authorship == legacyUnclaimed`. + _i1.UuidValue? acsId; + + /// `legacyUnclaimed` = visita gravada no aparelho antes de existir dono (migração v7): + /// a autoria é desconhecida e `acsId` é nulo. NUNCA preencher `acsId` com quem transportou. + _i2.VisitAuthorship authorship; + + /// Instalação do app de onde veio uma visita legada (`visits.syncLegacy`). + /// Nulo nas visitas com autor ACS. + String? originDeviceId; DateTime scheduledAt; @@ -127,21 +150,21 @@ abstract class Visit String status; - _i3.RiskLevel riskLevelBefore; + _i4.RiskLevel riskLevelBefore; - _i3.RiskLevel? riskLevelAfter; + _i4.RiskLevel? riskLevelAfter; /// JSON de Map, cifrado. Ver patient.spy.yaml para o padrão. String notesEncrypted; int notesKeyVersion; - _i4.SyncStatus syncStatus; + _i5.SyncStatus syncStatus; /// Como o check-in desta visita foi registrado (RF12, decisão §4). Default /// `manual` até a integração nativa de geofencing existir — hoje nenhum /// código produz `geofence`. - _i2.ArrivalMethod arrivalMethod; + _i3.ArrivalMethod arrivalMethod; /// Identificador gerado no dispositivo, usado para deduplicar na sincronização. _i1.UuidValue localId; @@ -160,16 +183,18 @@ abstract class Visit _i1.UuidValue? id, _i1.UuidValue? patientId, _i1.UuidValue? acsId, + _i2.VisitAuthorship? authorship, + String? originDeviceId, DateTime? scheduledAt, DateTime? startedAt, DateTime? completedAt, String? status, - _i3.RiskLevel? riskLevelBefore, - _i3.RiskLevel? riskLevelAfter, + _i4.RiskLevel? riskLevelBefore, + _i4.RiskLevel? riskLevelAfter, String? notesEncrypted, int? notesKeyVersion, - _i4.SyncStatus? syncStatus, - _i2.ArrivalMethod? arrivalMethod, + _i5.SyncStatus? syncStatus, + _i3.ArrivalMethod? arrivalMethod, _i1.UuidValue? localId, DateTime? syncAt, int? version, @@ -180,7 +205,9 @@ abstract class Visit '__className__': 'Visit', if (id != null) 'id': id?.toJson(), 'patientId': patientId.toJson(), - 'acsId': acsId.toJson(), + if (acsId != null) 'acsId': acsId?.toJson(), + 'authorship': authorship.toJson(), + if (originDeviceId != null) 'originDeviceId': originDeviceId, 'scheduledAt': scheduledAt.toJson(), if (startedAt != null) 'startedAt': startedAt?.toJson(), if (completedAt != null) 'completedAt': completedAt?.toJson(), @@ -203,7 +230,9 @@ abstract class Visit '__className__': 'Visit', if (id != null) 'id': id?.toJson(), 'patientId': patientId.toJson(), - 'acsId': acsId.toJson(), + if (acsId != null) 'acsId': acsId?.toJson(), + 'authorship': authorship.toJson(), + if (originDeviceId != null) 'originDeviceId': originDeviceId, 'scheduledAt': scheduledAt.toJson(), if (startedAt != null) 'startedAt': startedAt?.toJson(), if (completedAt != null) 'completedAt': completedAt?.toJson(), @@ -256,17 +285,19 @@ class _VisitImpl extends Visit { _VisitImpl({ _i1.UuidValue? id, required _i1.UuidValue patientId, - required _i1.UuidValue acsId, + _i1.UuidValue? acsId, + _i2.VisitAuthorship? authorship, + String? originDeviceId, required DateTime scheduledAt, DateTime? startedAt, DateTime? completedAt, required String status, - required _i3.RiskLevel riskLevelBefore, - _i3.RiskLevel? riskLevelAfter, + required _i4.RiskLevel riskLevelBefore, + _i4.RiskLevel? riskLevelAfter, String? notesEncrypted, int? notesKeyVersion, - required _i4.SyncStatus syncStatus, - _i2.ArrivalMethod? arrivalMethod, + required _i5.SyncStatus syncStatus, + _i3.ArrivalMethod? arrivalMethod, required _i1.UuidValue localId, DateTime? syncAt, required int version, @@ -274,6 +305,8 @@ class _VisitImpl extends Visit { id: id, patientId: patientId, acsId: acsId, + authorship: authorship, + originDeviceId: originDeviceId, scheduledAt: scheduledAt, startedAt: startedAt, completedAt: completedAt, @@ -296,17 +329,19 @@ class _VisitImpl extends Visit { Visit copyWith({ Object? id = _Undefined, _i1.UuidValue? patientId, - _i1.UuidValue? acsId, + Object? acsId = _Undefined, + _i2.VisitAuthorship? authorship, + Object? originDeviceId = _Undefined, DateTime? scheduledAt, Object? startedAt = _Undefined, Object? completedAt = _Undefined, String? status, - _i3.RiskLevel? riskLevelBefore, + _i4.RiskLevel? riskLevelBefore, Object? riskLevelAfter = _Undefined, String? notesEncrypted, int? notesKeyVersion, - _i4.SyncStatus? syncStatus, - _i2.ArrivalMethod? arrivalMethod, + _i5.SyncStatus? syncStatus, + _i3.ArrivalMethod? arrivalMethod, _i1.UuidValue? localId, Object? syncAt = _Undefined, int? version, @@ -314,13 +349,17 @@ class _VisitImpl extends Visit { return Visit( id: id is _i1.UuidValue? ? id : this.id, patientId: patientId ?? this.patientId, - acsId: acsId ?? this.acsId, + acsId: acsId is _i1.UuidValue? ? acsId : this.acsId, + authorship: authorship ?? this.authorship, + originDeviceId: originDeviceId is String? + ? originDeviceId + : this.originDeviceId, scheduledAt: scheduledAt ?? this.scheduledAt, startedAt: startedAt is DateTime? ? startedAt : this.startedAt, completedAt: completedAt is DateTime? ? completedAt : this.completedAt, status: status ?? this.status, riskLevelBefore: riskLevelBefore ?? this.riskLevelBefore, - riskLevelAfter: riskLevelAfter is _i3.RiskLevel? + riskLevelAfter: riskLevelAfter is _i4.RiskLevel? ? riskLevelAfter : this.riskLevelAfter, notesEncrypted: notesEncrypted ?? this.notesEncrypted, @@ -344,12 +383,25 @@ class VisitUpdateTable extends _i1.UpdateTable { value, ); - _i1.ColumnValue<_i1.UuidValue, _i1.UuidValue> acsId(_i1.UuidValue value) => + _i1.ColumnValue<_i1.UuidValue, _i1.UuidValue> acsId(_i1.UuidValue? value) => _i1.ColumnValue( table.acsId, value, ); + _i1.ColumnValue<_i2.VisitAuthorship, _i2.VisitAuthorship> authorship( + _i2.VisitAuthorship value, + ) => _i1.ColumnValue( + table.authorship, + value, + ); + + _i1.ColumnValue originDeviceId(String? value) => + _i1.ColumnValue( + table.originDeviceId, + value, + ); + _i1.ColumnValue scheduledAt(DateTime value) => _i1.ColumnValue( table.scheduledAt, @@ -373,15 +425,15 @@ class VisitUpdateTable extends _i1.UpdateTable { value, ); - _i1.ColumnValue<_i3.RiskLevel, _i3.RiskLevel> riskLevelBefore( - _i3.RiskLevel value, + _i1.ColumnValue<_i4.RiskLevel, _i4.RiskLevel> riskLevelBefore( + _i4.RiskLevel value, ) => _i1.ColumnValue( table.riskLevelBefore, value, ); - _i1.ColumnValue<_i3.RiskLevel, _i3.RiskLevel> riskLevelAfter( - _i3.RiskLevel? value, + _i1.ColumnValue<_i4.RiskLevel, _i4.RiskLevel> riskLevelAfter( + _i4.RiskLevel? value, ) => _i1.ColumnValue( table.riskLevelAfter, value, @@ -398,15 +450,15 @@ class VisitUpdateTable extends _i1.UpdateTable { value, ); - _i1.ColumnValue<_i4.SyncStatus, _i4.SyncStatus> syncStatus( - _i4.SyncStatus value, + _i1.ColumnValue<_i5.SyncStatus, _i5.SyncStatus> syncStatus( + _i5.SyncStatus value, ) => _i1.ColumnValue( table.syncStatus, value, ); - _i1.ColumnValue<_i2.ArrivalMethod, _i2.ArrivalMethod> arrivalMethod( - _i2.ArrivalMethod value, + _i1.ColumnValue<_i3.ArrivalMethod, _i3.ArrivalMethod> arrivalMethod( + _i3.ArrivalMethod value, ) => _i1.ColumnValue( table.arrivalMethod, value, @@ -441,6 +493,16 @@ class VisitTable extends _i1.Table<_i1.UuidValue?> { 'acsId', this, ); + authorship = _i1.ColumnEnum( + 'authorship', + this, + _i1.EnumSerialization.byName, + hasDefault: true, + ); + originDeviceId = _i1.ColumnString( + 'originDeviceId', + this, + ); scheduledAt = _i1.ColumnDateTime( 'scheduledAt', this, @@ -506,8 +568,17 @@ class VisitTable extends _i1.Table<_i1.UuidValue?> { late final _i1.ColumnUuid patientId; + /// Autor da visita. Nulo SÓ quando `authorship == legacyUnclaimed`. late final _i1.ColumnUuid acsId; + /// `legacyUnclaimed` = visita gravada no aparelho antes de existir dono (migração v7): + /// a autoria é desconhecida e `acsId` é nulo. NUNCA preencher `acsId` com quem transportou. + late final _i1.ColumnEnum<_i2.VisitAuthorship> authorship; + + /// Instalação do app de onde veio uma visita legada (`visits.syncLegacy`). + /// Nulo nas visitas com autor ACS. + late final _i1.ColumnString originDeviceId; + late final _i1.ColumnDateTime scheduledAt; late final _i1.ColumnDateTime startedAt; @@ -516,21 +587,21 @@ class VisitTable extends _i1.Table<_i1.UuidValue?> { late final _i1.ColumnString status; - late final _i1.ColumnEnum<_i3.RiskLevel> riskLevelBefore; + late final _i1.ColumnEnum<_i4.RiskLevel> riskLevelBefore; - late final _i1.ColumnEnum<_i3.RiskLevel> riskLevelAfter; + late final _i1.ColumnEnum<_i4.RiskLevel> riskLevelAfter; /// JSON de Map, cifrado. Ver patient.spy.yaml para o padrão. late final _i1.ColumnString notesEncrypted; late final _i1.ColumnInt notesKeyVersion; - late final _i1.ColumnEnum<_i4.SyncStatus> syncStatus; + late final _i1.ColumnEnum<_i5.SyncStatus> syncStatus; /// Como o check-in desta visita foi registrado (RF12, decisão §4). Default /// `manual` até a integração nativa de geofencing existir — hoje nenhum /// código produz `geofence`. - late final _i1.ColumnEnum<_i2.ArrivalMethod> arrivalMethod; + late final _i1.ColumnEnum<_i3.ArrivalMethod> arrivalMethod; /// Identificador gerado no dispositivo, usado para deduplicar na sincronização. late final _i1.ColumnUuid localId; @@ -544,6 +615,8 @@ class VisitTable extends _i1.Table<_i1.UuidValue?> { id, patientId, acsId, + authorship, + originDeviceId, scheduledAt, startedAt, completedAt, diff --git a/backend/sinalacs_server/lib/src/infrastructure/crypto/health_cipher_totp_vault.dart b/backend/sinalacs_server/lib/src/infrastructure/crypto/health_cipher_totp_vault.dart new file mode 100644 index 00000000..df17a8f1 --- /dev/null +++ b/backend/sinalacs_server/lib/src/infrastructure/crypto/health_cipher_totp_vault.dart @@ -0,0 +1,28 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:sinalacs_server/src/application/auth/totp_secret_vault.dart'; +import 'package:sinalacs_server/src/infrastructure/crypto/health_data_cipher.dart'; + +/// Cofre do segredo TOTP sobre a mesma AES-256-GCM dos dados clínicos +/// (`HEALTH_DATA_ENCRYPTION_KEY`). Uma chave a menos para gerir; o custo é que +/// quem a perde, perde também as MFAs — e a redefinição é manual (decisão D6). +class HealthCipherTotpVault implements TotpSecretVault { + HealthCipherTotpVault(this._cipher); + + final HealthDataCipher _cipher; + + @override + Future seal(Uint8List secret) async { + final v = await _cipher.encrypt(base64Encode(secret)); + return SealedSecret(ciphertextBase64: v.ciphertextBase64, keyVersion: v.keyVersion); + } + + @override + Future open(SealedSecret sealed) async { + final claro = await _cipher.decrypt( + EncryptedValue(ciphertextBase64: sealed.ciphertextBase64, keyVersion: sealed.keyVersion), + ); + return base64Decode(claro); + } +} diff --git a/backend/sinalacs_server/lib/src/infrastructure/crypto/rotating_ip_hasher.dart b/backend/sinalacs_server/lib/src/infrastructure/crypto/rotating_ip_hasher.dart new file mode 100644 index 00000000..ef9d640f --- /dev/null +++ b/backend/sinalacs_server/lib/src/infrastructure/crypto/rotating_ip_hasher.dart @@ -0,0 +1,70 @@ +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; + +/// HMAC-SHA-256 do IP com chave rotativa DIÁRIA (spec/lgpd_data_audit.md §2.1 +/// e §2.6). +/// +/// O espaço de endereçamento público do IPv4 tem 2^32 combinações: SHA-256 +/// puro sobre o IP é reversível por força bruta/rainbow table quase imediata. +/// Aqui o hash é chaveado — o segredo vem de `AUDIT_CHAIN_SECRET`, o mesmo da +/// cadeia de `audit_logs`, que fica FORA do Postgres — e a chave efetiva roda +/// todo dia, em dois passos: +/// +/// 1. deriva a chave do dia: `HMAC(secret, 'sinalacs:ip:v1:')`; +/// 2. hasheia o endereço com ela: `HMAC(chaveDoDia, remoteInfo)`. +/// +/// Consequências deliberadas: +/// - sem o segredo, não há pré-computação possível (rainbow table morre); +/// - dentro do MESMO dia UTC, o mesmo IP produz o mesmo hash — a correlação +/// de incidentes da janela, que a §2.6 pede para preservar, continua +/// possível; +/// - entre dias, o hash muda — o rastro de rede do titular não persiste +/// além da janela de rotação. +/// +/// O prefixo `sinalacs:ip:v1:` é separação de domínio, no mesmo molde de +/// `sinalacs:cpf:v1:`/`sinalacs:otp:v1:` (`HmacCpfHasher`): a derivação de +/// chave não pode colidir com outro protocolo que use o mesmo segredo. +/// `package:crypto` em vez de `package:cryptography` pelo mesmo motivo do +/// hasher de CPF: operação síncrona e barata. +class RotatingIpHasher { + RotatingIpHasher({required String secret, DateTime Function()? clock}) + : _secret = _requireSecret(secret), + _clock = clock ?? DateTime.now; + + final List _secret; + final DateTime Function() _clock; + + static const _domain = 'sinalacs:ip:v1:'; + + /// HMAC-SHA-256 em hexadecimal — mesmo formato do `ipHash` anterior. + String hash(String remoteInfo) { + final dayKey = Hmac(sha256, _secret) + .convert(utf8.encode('$_domain${_dayOf(_clock())}')) + .bytes; + return Hmac(sha256, dayKey).convert(utf8.encode(remoteInfo)).toString(); + } + + static String _dayOf(DateTime now) { + final utc = now.toUtc(); + return '${utc.year.toString().padLeft(4, '0')}-' + '${utc.month.toString().padLeft(2, '0')}-' + '${utc.day.toString().padLeft(2, '0')}'; + } + + /// Segredo vazio é pior que ausente: `Hmac(sha256, [])` produz um hash + /// perfeitamente válido e sem segredo nenhum — a coluna pareceria protegida + /// e não estaria. `AppConfig` já recusa `AUDIT_CHAIN_SECRET` vazio no boot; + /// esta é a rede de baixo, para quem construir o hasher fora da config. + static List _requireSecret(String secret) { + if (secret.trim().isEmpty) { + throw ArgumentError.value( + secret.length, + 'secret', + 'o segredo do hash de IP não pode ser vazio: sem ele o hash é ' + 'reversível por força bruta', + ); + } + return utf8.encode(secret); + } +} diff --git a/backend/sinalacs_server/lib/src/infrastructure/database/alert_overdue_sql.dart b/backend/sinalacs_server/lib/src/infrastructure/database/alert_overdue_sql.dart new file mode 100644 index 00000000..2804e25d --- /dev/null +++ b/backend/sinalacs_server/lib/src/infrastructure/database/alert_overdue_sql.dart @@ -0,0 +1,22 @@ +/// A regra única de "alerta vencido" (#44) em SQL, compartilhada pela leitura +/// do backoffice (`AdminAlert.overdue` e `AdminAlertsPulse.overdueRed`, em +/// `OrmAdminReadStore`) e pelo escalonamento (`OrmAlertEscalationStore`). +/// +/// Os fragmentos supõem o alias `a` para `alerts` e os parâmetros nomeados +/// `@now` (o relógio do servidor) e `@limite` (o prazo em microssegundos, +/// `Duration.inMicroseconds`). A concordância entre os três usos é pinada por +/// test/integration/admin_read_store_test.dart ('acordo: …'). +library; + +/// Prazo vencido: `now − triggeredAt > limite`, **estritamente** e em +/// microssegundos; `triggeredAt` no futuro dá intervalo negativo e nunca vence. +/// O escalonamento usa só este pedaço, porque decide risco, status e +/// escalonamento anterior em Dart, com precedência própria. +const alertDeadlinePassedSql = + '''(@now::timestamp - a."triggeredAt") > (@limite::bigint * interval '1 microsecond')'''; + +/// Vencido para a leitura: só o vermelho pendente ainda não escalonado, com o +/// prazo vencido. Escalonar tira o alerta daqui (mas não da lista nem do +/// filtro `pending`, D3). +const alertOverdueSql = + '''(a."riskLevel" = 'red' AND a."status" = 'pending' AND a."escalatedAt" IS NULL AND $alertDeadlinePassedSql)'''; diff --git a/backend/sinalacs_server/lib/src/infrastructure/database/orm_acs_credential_store.dart b/backend/sinalacs_server/lib/src/infrastructure/database/orm_acs_credential_store.dart index 8870c262..1bba70f3 100644 --- a/backend/sinalacs_server/lib/src/infrastructure/database/orm_acs_credential_store.dart +++ b/backend/sinalacs_server/lib/src/infrastructure/database/orm_acs_credential_store.dart @@ -1,6 +1,7 @@ import 'package:serverpod/serverpod.dart'; import 'package:sinalacs_server/src/application/auth/institutional_auth_service.dart'; import 'package:sinalacs_server/src/application/auth/password_hasher.dart'; +import 'package:sinalacs_server/src/application/auth/totp_secret_vault.dart'; import 'package:sinalacs_server/src/generated/protocol.dart'; /// Implementação sobre o ORM do Serverpod. @@ -31,11 +32,33 @@ import 'package:sinalacs_server/src/generated/protocol.dart'; /// `lockUntil`). Mesmo arranjo de `OrmOnboardingStore.consumeIfValid`, que /// também resolve no `SET`/`WHERE` do Postgres o que o ORM não expressa — o /// ORM não tem `UPDATE ... SET col = col + 1`. -class OrmAcsCredentialStore implements AcsCredentialStore { - OrmAcsCredentialStore({required Session Function() session}) : _session = session; +class OrmAcsCredentialStore implements AcsCredentialStore, TotpStore, StaffActivationStore { + OrmAcsCredentialStore({ + required Session Function() session, + Transaction? transaction, + this.staff = false, + }) : _session = session, + _transaction = transaction; final Session Function() _session; + /// Quando fornecida, **toda** operação deste store participa dela — mesmo + /// arranjo de `OrmRefreshTokenStore`/`OrmUploadTokenStore`. + /// + /// É o que permite a redefinição da MFA do staff (#43) zerar as colunas + /// `totp*` de `user_credentials` e emitir o código em `staff_accounts` na + /// mesma transação, sem que este store conheça o caso de uso: quem abre a + /// transação é o `OrmAdminAccountStore`, que constrói uma instância amarrada + /// a ela. Sem transação, o comportamento é o de sempre (cada instrução + /// autocommita). + final Transaction? _transaction; + + /// `true` = a matrícula é procurada em `staff_accounts` (backoffice); + /// `false` = em `acs`. As duas tabelas têm matrículas independentes, e cada + /// instância só enxerga uma delas: uma matrícula de ACS não existe para o + /// login do staff, e vice-versa. + final bool staff; + @override Future findByEnrollmentId(String enrollmentId) async { final session = _session(); @@ -47,33 +70,60 @@ class OrmAcsCredentialStore implements AcsCredentialStore { // // O caminho é `acs.enrollmentId → acs.id → users.microAreaId` e // `acs.id → user_credentials.userId`: é esta travessia que o teste de - // integração prova contra Postgres, e que um store falso não alcança. - final acs = await Acs.db.findFirstRow( - session, - where: (table) => table.enrollmentId.equals(enrollmentId), - ); - if (acs == null) return null; - - // `acs.id` já é `UuidValue` (o id de `acs` É o UUID do usuário) — não há - // string para converter aqui. - final acsUuid = acs.id!; + // integração prova contra Postgres, e que um store falso não alcança. Com + // [staff], o primeiro passo troca `acs` por `staff_accounts` (índice + // `staff_accounts_enrollment_id_key`, também única). + // + // O id já é `UuidValue` (o id de `acs`/`staff_accounts` É o UUID do + // usuário) — não há string para converter aqui. + final UuidValue acsUuid; + final bool active; + if (staff) { + final conta = await StaffAccount.db.findFirstRow( + session, + where: (table) => table.enrollmentId.equals(enrollmentId), + transaction: _transaction, + ); + if (conta == null) return null; + acsUuid = conta.id!; + active = conta.active; + } else { + final acs = await Acs.db.findFirstRow( + session, + where: (table) => table.enrollmentId.equals(enrollmentId), + transaction: _transaction, + ); + if (acs == null) return null; + acsUuid = acs.id!; + active = acs.active; + } final credential = await UserCredential.db.findFirstRow( session, where: (table) => table.userId.equals(acsUuid), + transaction: _transaction, ); if (credential == null) return null; final user = await User.db.findFirstRow( session, where: (table) => table.id.equals(acsUuid), + transaction: _transaction, ); if (user == null) return null; + // No login do ACS, só usuário de papel `acs` existe: um `admin` (ou + // qualquer outro papel) com linha em `acs` não pode abrir sessão de ACS. A + // resposta é a de matrícula inexistente — nada a revelar. No do staff, o + // papel segue para o serviço, que recusa quem não é coordenador/admin + // depois de a senha conferir (e audita o motivo). + if (!staff && user.role != UserRole.acs) return null; + return AcsCredentialRecord( acsId: acsUuid.uuid, microAreaId: user.microAreaId?.uuid, - active: acs.active, + active: active, + role: user.role, digest: PasswordDigest( hashBase64: credential.passwordHash, saltBase64: credential.passwordSalt, @@ -83,6 +133,106 @@ class OrmAcsCredentialStore implements AcsCredentialStore { ), failedAttempts: credential.failedAttempts, lockedUntil: credential.lockedUntil, + lockStreak: credential.lockStreak, + totp: _totpOf(credential), + ); + } + + /// Estado da MFA como está na linha. Segredo sem versão de chave é linha + /// corrompida: vira erro de servidor, não "sem MFA" — tratar como ausente + /// deixaria entrar só com a senha quem tem MFA ativa. + static TotpEnrollment? _totpOf(UserCredential credential) { + final cifrado = credential.totpSecretEncrypted; + if (cifrado == null) return null; + final versao = credential.totpKeyVersion; + if (versao == null) { + throw StateError('Segredo TOTP sem versão de chave em user_credentials.'); + } + return TotpEnrollment( + sealed: SealedSecret(ciphertextBase64: cifrado, keyVersion: versao), + enabled: credential.totpEnabledAt != null, + lastStep: credential.totpLastStep, + ); + } + + /// Grava um segredo **pendente**: zera `totpEnabledAt` e `totpLastStep`, de + /// modo que a MFA só passa a valer com `enable`. O `WHERE totpEnabledAt IS + /// NULL` recusa sobrescrever uma MFA já ativa mesmo quando o serviço leu a + /// linha antes de uma confirmação concorrente gravar: sem ele, quem só tem a + /// senha poderia trocar o segredo de uma MFA recém-ativada. + @override + Future saveSecret(String acsId, SealedSecret secret, DateTime at) async { + final linhas = await UserCredential.db.updateWhere( + _session(), + columnValues: (t) => [ + t.totpSecretEncrypted(secret.ciphertextBase64), + t.totpKeyVersion(secret.keyVersion), + t.totpEnabledAt(null), + t.totpLastStep(null), + t.updatedAt(at), + ], + where: (t) => t.userId.equals(UuidValue.fromString(acsId)) & t.totpEnabledAt.equals(null), + transaction: _transaction, + ); + return linhas.isNotEmpty; + } + + /// Ativa só se a linha ainda está pendente **com o mesmo segredo** que o + /// código conferiu: duas confirmações concorrentes ativam uma vez só, e um + /// novo início no meio (segredo trocado) não é ativado por um código do + /// segredo antigo. + @override + Future enable(String acsId, SealedSecret pending, int step, DateTime at) async { + final linhas = await UserCredential.db.updateWhere( + _session(), + columnValues: (t) => [t.totpEnabledAt(at), t.totpLastStep(step), t.updatedAt(at)], + where: (t) => + t.userId.equals(UuidValue.fromString(acsId)) & + t.totpEnabledAt.equals(null) & + t.totpSecretEncrypted.equals(pending.ciphertextBase64), + transaction: _transaction, + ); + return linhas.isNotEmpty; + } + + /// Grava o passo aceito e diz se gravou. O `WHERE` só deixa o passo + /// **avançar**; sob READ COMMITTED, a segunda de duas requisições com o + /// mesmo código espera o lock da linha, reavalia o `WHERE` contra o passo + /// que a primeira gravou e não altera nada — devolve `false`, e o serviço + /// recusa a sessão (replay). + @override + Future registerStep(String acsId, int step) async { + final linhas = await UserCredential.db.updateWhere( + _session(), + columnValues: (t) => [t.totpLastStep(step)], + where: (t) => + t.userId.equals(UuidValue.fromString(acsId)) & + (t.totpLastStep.equals(null) | (t.totpLastStep < step)), + transaction: _transaction, + ); + return linhas.isNotEmpty; + } + + /// Apaga o estado da MFA da conta numa única instrução: as quatro colunas + /// `totp*` voltam a NULL e `updatedAt` recebe [at]. + /// + /// É o caminho sancionado da redefinição pela coordenação (#43). Sem `WHERE` + /// sobre o estado: a operação é idempotente por desenho — um ACS sem MFA + /// passa pela mesma instrução sem efeito, e quem decide se o alvo existe (e + /// se está no escopo de quem pediu) é o serviço, que já o validou. + @override + Future clearTotp(String acsId, DateTime at) async { + await UserCredential.db.updateWhere( + _session(), + columnValues: (t) => [ + t.totpSecretEncrypted(null), + t.totpKeyVersion(null), + t.totpEnabledAt(null), + t.totpLastStep(null), + t.updatedAt(at), + ], + where: (t) => t.userId.equals(UuidValue.fromString(acsId)), + transaction: _transaction, ); } @@ -136,6 +286,14 @@ class OrmAcsCredentialStore implements AcsCredentialStore { THEN @lockUntil::timestamp ELSE NULL END, + -- A escalada sobe só quando o bloqueio é APLICADO nesta escrita; o + -- reinício do contador (bloqueio vencido) a preserva. Como o `WHERE` + -- recusa a linha já bloqueada, uma rajada concorrente sobe uma vez. + "lockStreak" = CASE + WHEN @restart THEN "lockStreak" + WHEN "failedAttempts" + 1 >= @maxFailedAttempts THEN "lockStreak" + 1 + ELSE "lockStreak" + END, "updatedAt" = @at WHERE "userId" = @userId::uuid AND ("lockedUntil" IS NULL OR "lockedUntil" <= @at); @@ -147,6 +305,7 @@ class OrmAcsCredentialStore implements AcsCredentialStore { 'at': at, 'userId': acsId, }), + transaction: _transaction, ); } @@ -162,9 +321,10 @@ class OrmAcsCredentialStore implements AcsCredentialStore { // constante — zero) e por isso nem `CASE` nem `RETURNING`. await _session().db.unsafeExecute( 'UPDATE "user_credentials" ' - 'SET "failedAttempts" = 0, "lockedUntil" = NULL, "updatedAt" = @at ' + 'SET "failedAttempts" = 0, "lockedUntil" = NULL, "lockStreak" = 0, "updatedAt" = @at ' 'WHERE "userId" = @userId::uuid;', parameters: QueryParameters.named({'at': at, 'userId': acsId}), + transaction: _transaction, ); } @@ -176,6 +336,7 @@ class OrmAcsCredentialStore implements AcsCredentialStore { final existing = await UserCredential.db.findFirstRow( session, where: (table) => table.userId.equals(userId), + transaction: _transaction, ); if (existing == null) { @@ -193,6 +354,7 @@ class OrmAcsCredentialStore implements AcsCredentialStore { createdAt: at, updatedAt: at, ), + transaction: _transaction, ); return; } @@ -200,6 +362,12 @@ class OrmAcsCredentialStore implements AcsCredentialStore { // Substituir a credencial também zera o estado de bloqueio: a senha nova // não tem relação com as tentativas da antiga, e herdar o contador faria // uma troca de senha nascer a uma falha do bloqueio. + // + // O `lockStreak` sai junto, pela mesma razão: ele é a contagem de rodadas + // de bloqueio **daquela credencial**, e preservá-lo faria o próximo + // bloqueio da senha nova vencer no dobro do tempo por causa das rodadas de + // uma credencial que deixou de existir — uma redefinição de senha pela + // coordenação ficaria mais punitiva que o primeiro bloqueio. existing ..passwordHash = digest.hashBase64 ..passwordSalt = digest.saltBase64 @@ -208,7 +376,52 @@ class OrmAcsCredentialStore implements AcsCredentialStore { ..parallelism = digest.parallelism ..failedAttempts = 0 ..lockedUntil = null + ..lockStreak = 0 ..updatedAt = at; - await UserCredential.db.updateRow(session, existing); + await UserCredential.db.updateRow(session, existing, transaction: _transaction); + } + + @override + Future issue( + String staffId, { + required String codeHash, + required DateTime expiresAt, + required String issuedBy, + required DateTime at, + }) async { + await StaffAccount.db.updateWhere( + _session(), + columnValues: (t) => [ + t.activationCodeHash(codeHash), + t.activationCodeExpiresAt(expiresAt), + t.activationCodeIssuedBy(issuedBy), + t.activationCodeIssuedAt(at), + ], + where: (t) => t.id.equals(UuidValue.fromString(staffId)), + transaction: _transaction, + ); + } + + @override + Future find(String staffId) async { + final c = await StaffAccount.db.findById( + _session(), + UuidValue.fromString(staffId), + transaction: _transaction, + ); + final hash = c?.activationCodeHash; + final expira = c?.activationCodeExpiresAt; + if (hash == null || expira == null) return null; + return StaffActivationRecord(codeHash: hash, expiresAt: expira); + } + + @override + Future clear(String staffId) async { + await StaffAccount.db.updateWhere( + _session(), + columnValues: (t) => [t.activationCodeHash(null), t.activationCodeExpiresAt(null)], + where: (t) => t.id.equals(UuidValue.fromString(staffId)), + transaction: _transaction, + ); } } diff --git a/backend/sinalacs_server/lib/src/infrastructure/database/orm_admin_account_store.dart b/backend/sinalacs_server/lib/src/infrastructure/database/orm_admin_account_store.dart new file mode 100644 index 00000000..ffe5e3da --- /dev/null +++ b/backend/sinalacs_server/lib/src/infrastructure/database/orm_admin_account_store.dart @@ -0,0 +1,490 @@ +import 'package:meta/meta.dart'; +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/admin/admin_account_service.dart'; +import 'package:sinalacs_server/src/application/auth/password_hasher.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_acs_credential_store.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_refresh_token_store.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_upload_token_store.dart'; + +/// Mesmo `Uuid` de `RedAlertService`: o sorteio da chave é o mesmo em todo o +/// servidor. +const _uuid = Uuid(); + +/// Contas do backoffice sobre Postgres (issue #43). +/// +/// SQL direto (`unsafeQuery` com parâmetros nomeados), como `OrmAdminReadStore`: +/// o ORM do Serverpod não expressa a junção `users`/`acs`/`ubs`/`micro_areas`/ +/// `user_credentials` que as listagens precisam. Nada aqui concatena **valor** +/// no texto da consulta; só a presença do filtro de escopo e do filtro por id +/// muda a forma. +/// +/// O escopo do coordenador é `acs."ubsId"` — o mesmo campo que o login usa —, e +/// a listagem inclui quem não tem microárea (`LEFT JOIN`): um ACS recém-cadastrado, +/// ainda sem território, precisa aparecer para poder ser vinculado. +/// +/// `mfaActive` vem da própria consulta (`totpEnabledAt IS NOT NULL`), nunca de +/// coluna calculada: MFA iniciada e não confirmada não é MFA ativa. +class OrmAdminAccountStore implements AdminAccountStore { + OrmAdminAccountStore({ + required Session Function() session, + @visibleForTesting bool debugFailAfterUsersInsert = false, + @visibleForTesting bool debugFailAfterRevocations = false, + @visibleForTesting bool debugFailAfterActivationIssue = false, + }) : _session = session, + _debugFailAfterUsersInsert = debugFailAfterUsersInsert, + _debugFailAfterRevocations = debugFailAfterRevocations, + _debugFailAfterActivationIssue = debugFailAfterActivationIssue; + + final Session Function() _session; + + /// Derruba a transação do cadastro **depois** de `users` e antes de `acs`, + /// para o teste provar que a atomicidade não é teórica: se as três linhas não + /// fossem da mesma transação, sobraria um usuário sem credencial e sem + /// matrícula — um órfão que ninguém consegue nem logar nem apagar pela + /// interface. Mesmo arranjo de + /// `OrmDataSubjectRightsStore._debugFailAfterTokenDelete`. + final bool _debugFailAfterUsersInsert; + + /// Derruba a transação da desativação **depois** das duas revogações: sem a + /// transação única, a flag ficaria gravada e os tokens vigentes — o teste + /// prova que o rollback leva os três `UPDATE` juntos. Mesmo arranjo de + /// [_debugFailAfterUsersInsert]. + final bool _debugFailAfterRevocations; + + /// Derruba a transação da redefinição da MFA do staff **depois** das duas + /// escritas: sem a transação única, a MFA ficaria zerada e o código novo não + /// estaria gravado (conta trancada fora, sem como ativar) — o teste prova que + /// o rollback leva a limpeza e a emissão juntas. Mesmo arranjo de + /// [_debugFailAfterUsersInsert] e [_debugFailAfterRevocations]. + final bool _debugFailAfterActivationIssue; + + /// `@ubs` nulo = sistema inteiro (administrador). + static const _escopoAcs = '(@ubs::uuid IS NULL OR acs."ubsId" = @ubs::uuid)'; + + /// Colunas e junções das duas listagens; o `WHERE`/`ORDER BY` de cada método + /// é o que varia. + static const _selecaoAcs = ''' + SELECT u.id, u.name, acs."enrollmentId", acs."ubsId", ub.name, + m.id, m.name, acs.active, (uc."totpEnabledAt" IS NOT NULL) + FROM acs + JOIN users u ON u.id = acs.id + JOIN ubs ub ON ub.id = acs."ubsId" + LEFT JOIN micro_areas m ON m.id = u."microAreaId" + LEFT JOIN user_credentials uc ON uc."userId" = acs.id'''; + + static const _selecaoStaff = ''' + SELECT u.id, u.name, s."enrollmentId", u.role, ub.name, s.active, + (uc."totpEnabledAt" IS NOT NULL) + FROM staff_accounts s + JOIN users u ON u.id = s.id + LEFT JOIN ubs ub ON ub.id = s."ubsId" + LEFT JOIN user_credentials uc ON uc."userId" = s.id'''; + + @override + Future> acsList(AdminScope scope) async { + final rows = await _session().db.unsafeQuery( + '$_selecaoAcs WHERE $_escopoAcs ORDER BY u.name, u.id', + parameters: QueryParameters.named({'ubs': scope.ubsId}), + ); + return [for (final r in rows) _acs(r)]; + } + + @override + Future acsById(AdminScope scope, String acsId) async { + // Um id que não é UUID nem chega ao banco: o Postgres lançaria erro de + // sintaxe no `::uuid`, e um erro de servidor diria ao chamador que o id é + // inválido — em vez da mesma resposta de "não existe ou não é seu". + if (!_uuidValido(acsId)) return null; + final rows = await _session().db.unsafeQuery( + '$_selecaoAcs WHERE u.id = @id::uuid AND $_escopoAcs', + parameters: QueryParameters.named({'id': acsId, 'ubs': scope.ubsId}), + ); + return rows.isEmpty ? null : _acs(rows.single); + } + + @override + Future> staffList() async { + final rows = await _session().db.unsafeQuery( + '$_selecaoStaff ORDER BY u.name, u.id', + ); + return [for (final r in rows) _staff(r)]; + } + + @override + Future staffById(String staffId) async { + if (!_uuidValido(staffId)) return null; + final rows = await _session().db.unsafeQuery( + '$_selecaoStaff WHERE s.id = @id::uuid', + parameters: QueryParameters.named({'id': staffId}), + ); + return rows.isEmpty ? null : _staff(rows.single); + } + + /// A microárea e a UBS dona dela. Um id que não é UUID devolve `null` pela + /// mesma guarda de [acsById]: a alternativa é o erro de sintaxe do Postgres + /// no `::uuid`, que diria ao chamador que o id é inválido em vez de + /// "microárea não encontrada". + @override + Future<({String? ubsId, String? name})?> microAreaFor( + String microAreaId, + ) async { + if (!_uuidValido(microAreaId)) return null; + final rows = await _session().db.unsafeQuery( + 'SELECT m.name, m."ubsId" FROM micro_areas m WHERE m.id = @id::uuid', + parameters: QueryParameters.named({'id': microAreaId}), + ); + if (rows.isEmpty) return null; + return (name: rows.single[0] as String, ubsId: rows.single[1].toString()); + } + + /// Índice único `acs_enrollment_id_key`. Só a existência interessa: a linha + /// não é lida aqui, e um `SELECT 1` não devolve PII nenhuma. + @override + Future enrollmentIdTaken(String enrollmentId) async { + final rows = await _session().db.unsafeQuery( + 'SELECT 1 FROM acs WHERE "enrollmentId" = @matricula LIMIT 1', + parameters: QueryParameters.named({'matricula': enrollmentId}), + ); + return rows.isNotEmpty; + } + + /// As três linhas do cadastro numa transação — ou todas, ou nenhuma. + /// + /// O id é sorteado aqui e serve às três tabelas: `acs.id` e + /// `user_credentials.userId` são o mesmo UUID de `users.id`, a chave + /// compartilhada que o login do RF07 já atravessa. Sorteado em Dart (e não + /// pelo `DEFAULT gen_random_uuid()` da coluna) porque ele entra também no + /// placeholder do CPF, que é gravado na **mesma** linha — e o índice único + /// de `users.cpfHash` não pode disparar por dois ACS cadastrados no mesmo + /// instante. Mesmo `Uuid` de `RedAlertService`. + /// + /// `cpfHash` recebe um placeholder aleatório único, **nunca** o HMAC de um + /// CPF: `verifyOtp` emite papel de PACIENTE a partir da linha que encontra + /// por `users.cpfHash`, então um CPF na linha do ACS deixaria o login + /// passwordless abrir sessão de paciente com o id e a microárea dele (o + /// mesmo motivo que deixou o ACS semeado fora de `seed_cpf_hashes.dart` — + /// `spec/lgpd_data_audit.md`, nota de seed). O placeholder não é um HMAC + /// (não é hex de 64 caracteres), então nenhum CPF chega até ele. + /// + /// `birthDate` recebe a sentinela 1900-01-01 UTC — a coluna é NOT NULL e não + /// existe data de nascimento de ACS a guardar: nada além do login + /// passwordless lê este campo, e esse login não alcança esta linha. + /// + /// Um 23505 (`unique_violation`) devolve `null` em vez de subir: significa + /// que outra requisição cadastrou a mesma matrícula entre a pré-checagem do + /// serviço e este INSERT, e o resultado para quem chamou é a mesma validação + /// do caso comum. Qualquer outro erro de banco sobe — engolir um erro + /// inesperado aqui viraria "matrícula duplicada" para um defeito de verdade. + @override + Future insertAcs({ + required String name, + required String enrollmentId, + required String microAreaId, + required String ubsId, + required PasswordDigest digest, + required DateTime at, + }) async { + final session = _session(); + final id = UuidValue.fromString(_uuid.v4()); + try { + await session.db.transaction((transaction) async { + await User.db.insertRow( + session, + User( + id: id, + cpfHash: 'acs-sem-cpf-${id.uuid}', + name: name, + birthDate: DateTime.utc(1900, 1, 1), + role: UserRole.acs, + microAreaId: UuidValue.fromString(microAreaId), + createdAt: at, + updatedAt: at, + ), + transaction: transaction, + ); + if (_debugFailAfterUsersInsert) { + throw StateError('falha injetada depois de users (só em teste)'); + } + await Acs.db.insertRow( + session, + Acs( + id: id, + enrollmentId: enrollmentId, + ubsId: UuidValue.fromString(ubsId), + active: true, + ), + transaction: transaction, + ); + await UserCredential.db.insertRow( + session, + UserCredential( + userId: id, + passwordHash: digest.hashBase64, + passwordSalt: digest.saltBase64, + memoryKb: digest.memoryKb, + iterations: digest.iterations, + parallelism: digest.parallelism, + failedAttempts: 0, + lockStreak: 0, + createdAt: at, + updatedAt: at, + ), + transaction: transaction, + ); + }); + } on DatabaseQueryException catch (e) { + // 23505 = unique_violation: a matrícula já existe (corrida). + if (e.code == '23505') return null; + rethrow; + } + return acsById(const AdminScope.system(), id.uuid); + } + + /// O vínculo novo: `acs."ubsId"` (a UBS da microárea-alvo) e + /// `users."microAreaId"` na **mesma transação**. + /// + /// O predicado de escopo vai **dentro** do `WHERE` do `UPDATE`, e não só na + /// pré-checagem do serviço: entre a checagem e a escrita cabe uma outra + /// requisição movendo o mesmo ACS para fora do escopo, e quem decide é a + /// escrita — a única leitura e a única decisão são a da mesma instrução + /// (fecha o TOCTOU). A CTE `alvo` é o alvo `id` já filtrado pelo escopo, e o + /// `RETURNING id` é o que diz se o `UPDATE` alcançou alguma linha: sem linha, + /// nada é movido e nada é escrito em `users`. + /// + /// A UBS gravada é a [ubsId] recebida — derivada da microárea-alvo pelo + /// serviço —, **nunca** `scope.ubsId`: o escopo do administrador é nulo, e + /// `acs."ubsId"` é NOT NULL. + /// + /// A segunda escrita é conferida: o Serverpod não declara a chave + /// estrangeira de `acs.id` para `users.id`, então um `acs` órfão é gravável, + /// e sem esta checagem o vínculo ficaria pela metade (UBS nova no `acs`, + /// microárea velha no `users`). Sem a linha de `users` a transação inteira + /// volta atrás. + @override + Future setAcsMicroArea({ + required String acsId, + required String microAreaId, + required String ubsId, + required AdminScope scope, + required DateTime at, + }) async { + // A mesma guarda de [acsById]: um id fora do formato não pode virar erro de + // sintaxe do Postgres no `::uuid` — o desfecho dele é "não movido". + if (!_uuidValido(acsId) || + !_uuidValido(microAreaId) || + !_uuidValido(ubsId)) { + return null; + } + final session = _session(); + final vinculado = await session.db.transaction((transaction) async { + final alvo = await session.db.unsafeQuery( + ''' + WITH alvo AS ( + SELECT a.id FROM acs a + WHERE a.id = @acs::uuid + AND (@escopo::uuid IS NULL OR a."ubsId" = @escopo::uuid) + ) + UPDATE acs SET "ubsId" = @ubs::uuid + WHERE id IN (SELECT id FROM alvo) + RETURNING id; + ''', + parameters: QueryParameters.named({ + 'acs': acsId, + 'ubs': ubsId, + 'escopo': scope.ubsId, + }), + transaction: transaction, + ); + if (alvo.isEmpty) return false; + + final usuarios = await session.db.unsafeExecute( + 'UPDATE users SET "microAreaId" = @ma::uuid, "updatedAt" = @at ' + 'WHERE id = @acs::uuid;', + parameters: QueryParameters.named({ + 'ma': microAreaId, + 'at': at, + 'acs': acsId, + }), + transaction: transaction, + ); + if (usuarios != 1) { + // Inalcançável com dado íntegro (toda linha de `acs` nasce com a de + // `users`); desfaz os dois UPDATEs em vez de confirmar meio vínculo. + throw StateError( + 'acs sem linha em users: vínculo desfeito (dado inconsistente)', + ); + } + return true; + }); + if (!vinculado) return null; + return acsById(scope, acsId); + } + + /// A flag de acesso e as revogações numa **única** transação. + /// + /// Desativar sem revogar deixaria a sessão do ACS viva até a próxima + /// renovação (o `findAccount` do refresh só recusa lá) — a desativação tem de + /// valer agora, e não no próximo `refreshSession`. Como o `UPDATE` da flag e + /// as duas revogações são da mesma transação, não existe estado observável + /// intermediário: ou o ACS está inativo e sem token vigente, ou nada mudou + /// (é o que a falha injetada de [_debugFailAfterRevocations] prova). + /// + /// As revogações são dos stores de token, amarrados a ESTA transação pelo + /// construtor — o SQL de cada tabela continua sendo de quem é dono dela, e + /// nem `refresh_token_service`/`upload_token_service` nem este arquivo + /// conhecem o `package:serverpod` do outro lado da porta. + /// + /// Reativar não toca em token nenhum: revogado é revogado, e o acesso novo + /// se obtém com um login novo. O predicado de escopo vai **dentro** do + /// `WHERE` (mesma defesa de [setAcsMicroArea]), e um id fora do formato de + /// UUID devolve `false` sem chegar ao `::uuid` do Postgres. + @override + Future setAcsActive({ + required String acsId, + required AdminScope scope, + required bool active, + required DateTime at, + }) async { + if (!_uuidValido(acsId)) return false; + final session = _session(); + return session.db.transaction((transaction) async { + // Sem `RETURNING`: a contagem de linhas já diz se o ACS existe no + // escopo, e um `UPDATE` que regrava o mesmo valor conta igual — é o que + // torna a operação idempotente sem um `SELECT` a mais. + final linhas = await session.db.unsafeExecute( + 'UPDATE acs SET active = @ativo ' + 'WHERE id = @acs::uuid ' + 'AND (@escopo::uuid IS NULL OR "ubsId" = @escopo::uuid);', + parameters: QueryParameters.named({ + 'ativo': active, + 'acs': acsId, + 'escopo': scope.ubsId, + }), + transaction: transaction, + ); + if (linhas != 1) return false; + if (active) return true; + + await OrmRefreshTokenStore( + session: _session, + transaction: transaction, + ).revokeAllForUser(acsId, at); + await OrmUploadTokenStore( + session: _session, + transaction: transaction, + ).revokeAllForUser(acsId, at); + if (_debugFailAfterRevocations) { + throw StateError('falha injetada depois das revogações (só em teste)'); + } + return true; + }); + } + + /// Zera a MFA da conta de equipe e grava o código de ativação novo na + /// **mesma transação**. + /// + /// As duas escritas são do `OrmAcsCredentialStore` amarrado a ESTA transação + /// pelo construtor — mesmo arranjo de [setAcsActive] com os stores de token: + /// o SQL de `user_credentials` e de `staff_accounts` continua sendo de quem é + /// dono da tabela, e nem o serviço nem este arquivo conhecem as colunas + /// `totp*`/`activationCode*`. + /// + /// Juntas porque são um só estado: a conta que perdeu a MFA sem código novo + /// fica trancada fora (o login do staff sempre exige MFA, e sem segredo + /// gravado não há o que ativar), e um código emitido sobre uma MFA ainda viva + /// seria um segundo caminho de ativação ao lado do autenticador que a pessoa + /// já tem. É o que a falha injetada de [_debugFailAfterActivationIssue] prova. + /// + /// A existência e o `active` da conta são lidos **dentro** da transação, e + /// não só na pré-checagem do serviço: entre uma e outra cabe a desativação de + /// outra requisição, e um código emitido para uma conta desativada seria uma + /// credencial de ativação viva para quem não pode entrar. `false` = nada foi + /// escrito — id fora do formato de UUID, conta inexistente ou inativa —, o + /// mesmo desfecho de "não existe". Mesma guarda de [acsById] no `::uuid`. + @override + Future resetStaffMfa({ + required String staffId, + required String codeHash, + required DateTime expiresAt, + required String issuedBy, + required DateTime at, + }) async { + if (!_uuidValido(staffId)) return false; + final session = _session(); + return session.db.transaction((transaction) async { + final conta = await StaffAccount.db.findById( + session, + UuidValue.fromString(staffId), + transaction: transaction, + ); + if (conta == null || !conta.active) return false; + + final credenciais = OrmAcsCredentialStore( + session: _session, + transaction: transaction, + ); + await credenciais.clearTotp(staffId, at); + await credenciais.issue( + staffId, + codeHash: codeHash, + expiresAt: expiresAt, + issuedBy: issuedBy, + at: at, + ); + if (_debugFailAfterActivationIssue) { + throw StateError('falha injetada depois da emissão do código (só em teste)'); + } + return true; + }); + } + + /// Mesma leitura de `OrmAdminReadStore.ubsOf`: `staff_accounts.ubsId` é a + /// única fonte do escopo do coordenador. + @override + Future ubsOf(String staffId) async { + if (!_uuidValido(staffId)) return null; + final conta = await StaffAccount.db.findById( + _session(), + UuidValue.fromString(staffId), + ); + return conta?.ubsId?.toString(); + } + + /// `UuidValue.fromString` **não** valida nada (só embrulha a string); quem + /// valida o formato é `withFormatValidation`. Sem esta guarda, um id fora do + /// formato chega ao `::uuid` e vira erro de sintaxe do Postgres — um 500 no + /// lugar da resposta de "não existe". + static bool _uuidValido(String id) { + try { + UuidValue.withFormatValidation(id); + return true; + } on FormatException { + return false; + } + } + + static AdminAcs _acs(List r) => AdminAcs( + id: r[0].toString(), + name: r[1] as String, + enrollmentId: r[2] as String, + ubsId: r[3].toString(), + ubsName: r[4] as String, + // Nulos enquanto o ACS não tem território (`users.microAreaId`). + microAreaId: r[5]?.toString(), + microAreaName: r[6] as String?, + active: r[7] as bool, + mfaActive: r[8] as bool, + ); + + static AdminStaff _staff(List r) => AdminStaff( + id: r[0].toString(), + name: r[1] as String, + enrollmentId: r[2] as String, + role: UserRole.fromJson(r[3] as String), + // Nulo para o administrador (sistema inteiro) e para o coordenador sem UBS. + ubsName: r[4] as String?, + active: r[5] as bool, + mfaActive: r[6] as bool, + ); +} diff --git a/backend/sinalacs_server/lib/src/infrastructure/database/orm_admin_read_store.dart b/backend/sinalacs_server/lib/src/infrastructure/database/orm_admin_read_store.dart new file mode 100644 index 00000000..2b51af81 --- /dev/null +++ b/backend/sinalacs_server/lib/src/infrastructure/database/orm_admin_read_store.dart @@ -0,0 +1,407 @@ +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/admin/admin_labels.dart'; +import 'package:sinalacs_server/src/application/admin/admin_read_service.dart'; +import 'package:sinalacs_server/src/application/admin/audit_chain_status_service.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/application/audit/audit_row_sealer.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/infrastructure/database/alert_overdue_sql.dart'; + +/// Escapa `\`, `%` e `_` para que o texto do usuário seja literal num `LIKE`/ +/// `ILIKE ... ESCAPE '\'` (#45, D2): `'%'` busca o caractere `%`, não "tudo". +/// O `\` vem primeiro, senão os escapes acrescentados seriam escapados de novo. +String escaparLike(String texto) => + texto.replaceAll(r'\', r'\\').replaceAll('%', r'\%').replaceAll('_', r'\_'); + +/// Leitura do backoffice sobre Postgres. +/// +/// SQL direto (`unsafeQuery` com parâmetros nomeados, como `orm_alert_outbox`): +/// o ORM não expressa agregação com `FILTER` nem junções. Nada aqui concatena +/// valor no texto da consulta; só a presença do filtro de escopo muda a forma. +/// +/// O escopo de UBS passa por `micro_areas.ubsId`. `alerts.microAreaId` é +/// anulável, então a junção é `LEFT`: o administrador conta também o alerta sem +/// microárea; o coordenador, filtrado por `ubsId`, não o enxerga. Na auditoria +/// não há microárea na linha — ela pertence ao **autor** (`users."microAreaId"`, +/// subconsulta de [auditLogs]), e staff não tem microárea: as linhas de staff só +/// o escopo de sistema enxerga. +class OrmAdminReadStore implements AdminReadStore, AuditChainScopeStore { + OrmAdminReadStore({ + required Session Function() session, + this.escalationThreshold = const Duration(seconds: 300), + }) : _session = session; + + final Session Function() _session; + + /// Prazo do alerta vermelho (`AppConfig.redAlertEscalationSeconds`) a partir + /// do qual `AdminAlert.overdue` vale. + final Duration escalationThreshold; + + static const _janelaDoTmrav = Duration(days: 30); + + /// `@ubs` nulo = sistema inteiro. + static const _escopo = '(@ubs::uuid IS NULL OR m."ubsId" = @ubs::uuid)'; + + @override + Future indicators( + AdminScope scope, { + required DateTime now, + }) async { + final rows = await _session().db.unsafeQuery( + ''' + SELECT + count(*) FILTER (WHERE a."riskLevel" = 'red'), + count(*) FILTER (WHERE a."riskLevel" = 'yellow'), + count(*) FILTER (WHERE a."riskLevel" = 'green'), + count(*) FILTER (WHERE a."riskLevel" = 'red' AND a."status" = 'pending'), + count(*) FILTER (WHERE a."riskLevel" = 'red' AND a."status" = 'acknowledged'), + round(avg(extract(epoch FROM (a."acknowledgedAt" - a."triggeredAt"))) + FILTER (WHERE a."riskLevel" = 'red' + AND a."acknowledgedAt" IS NOT NULL + AND a."triggeredAt" >= @since))::int + FROM alerts a + LEFT JOIN micro_areas m ON m.id = a."microAreaId" + WHERE $_escopo + ''', + parameters: QueryParameters.named({ + 'ubs': scope.ubsId, + 'since': now.toUtc().subtract(_janelaDoTmrav), + }), + ); + final r = rows.single; + return AdminIndicators( + red: r[0] as int, + yellow: r[1] as int, + green: r[2] as int, + openRedAlerts: r[3] as int, + acknowledgedRedAlerts: r[4] as int, + tmravSeconds: r[5] as int?, + ); + } + + /// Um `SELECT` só de agregados: nenhuma coluna de alerta sai daqui. A + /// contagem usa o fragmento de `alert_overdue_sql.dart`, o mesmo de + /// [alerts] e do escalonamento. + @override + Future pulse( + AdminScope scope, { + required DateTime now, + required Duration threshold, + }) async { + final rows = await _session().db.unsafeQuery( + ''' + SELECT + count(*) FILTER (WHERE $alertOverdueSql), + max(GREATEST(a."triggeredAt", + COALESCE(a."acknowledgedAt", a."triggeredAt"), + COALESCE(a."escalatedAt", a."triggeredAt"))) + FROM alerts a + LEFT JOIN micro_areas m ON m.id = a."microAreaId" + WHERE $_escopo + ''', + parameters: QueryParameters.named({ + 'ubs': scope.ubsId, + 'now': now.toUtc(), + 'limite': threshold.inMicroseconds, + }), + ); + final r = rows.single; + return ( + overdueRed: r[0] as int, + maxChangedAt: (r[1] as DateTime?)?.toUtc(), + ); + } + + /// Uma linha por microárea: o `id` é a chave do filtro de alertas no app (dois + /// itens com o mesmo valor quebrariam o `DropdownButton`). Com mais de um ACS na + /// área, nomes e matrículas vêm juntos, na ordem da matrícula, e `acsActive` vale + /// se **algum** deles está ativo. + @override + Future> microAreas(AdminScope scope) async { + final rows = await _session().db.unsafeQuery( + ''' + SELECT m.id, m.name, + string_agg(u.name, ', ' ORDER BY acs."enrollmentId"), + string_agg(acs."enrollmentId", ', ' ORDER BY acs."enrollmentId"), + bool_or(acs.active) + FROM micro_areas m + LEFT JOIN users u ON u."microAreaId" = m.id AND u.role = 'acs' + LEFT JOIN acs ON acs.id = u.id + WHERE (@ubs::uuid IS NULL OR m."ubsId" = @ubs::uuid) + GROUP BY m.id, m.name + ORDER BY m.name, m.id + ''', + parameters: QueryParameters.named({'ubs': scope.ubsId}), + ); + return [ + for (final r in rows) + AdminMicroArea( + id: r[0].toString(), + name: r[1] as String, + acsName: (r[2] as String?) ?? 'Sem ACS vinculado', + acsEnrollmentId: (r[3] as String?) ?? '—', + acsActive: (r[4] as bool?) ?? false, + ), + ]; + } + + @override + Future alerts( + AdminScope scope, { + String? microAreaId, + AlertStatus? status, + required int limit, + required int offset, + required DateTime now, + }) async { + final rows = await _session().db.unsafeQuery( + ''' + SELECT a.id, a."patientId", m.name, a."riskLevel", a."status", a."triggeredAt", + a."escalatedAt", + -- Regra única de "vencido" (`alert_overdue_sql.dart`), a mesma de + -- `OrmAlertEscalationStore` e de [pulse]; o acordo é pinado por + -- test/integration/admin_read_store_test.dart ('acordo: overdue…'). + $alertOverdueSql AS vencido + FROM alerts a + LEFT JOIN micro_areas m ON m.id = a."microAreaId" + WHERE $_escopo + AND (@ma::uuid IS NULL OR a."microAreaId" = @ma::uuid) + AND (@status::text IS NULL OR a."status" = @status::text) + -- Vencidos primeiro (#44): um vencido além das primeiras linhas não + -- teria botão "Escalar" em tela nenhuma. Ordena pelo próprio `vencido` + -- (o mesmo fragmento, sem segunda cópia da regra); o desempate + -- `triggeredAt`/`id` mantém a ordem total, estável entre páginas. + ORDER BY vencido DESC, a."triggeredAt" DESC, a.id DESC + LIMIT @limit OFFSET @offset + ''', + parameters: QueryParameters.named({ + 'ubs': scope.ubsId, + 'ma': microAreaId, + 'status': status?.name, + 'now': now.toUtc(), + 'limite': escalationThreshold.inMicroseconds, + // Uma linha a mais só para saber se há próxima página. + 'limit': limit + 1, + 'offset': offset, + }), + ); + final haMais = rows.length > limit; + final pagina = haMais ? rows.sublist(0, limit) : rows; + return AdminAlertPage( + items: [ + for (final r in pagina) + AdminAlert( + id: r[0].toString(), + patientLabel: AdminLabels.patient(r[1].toString()), + microAreaName: (r[2] as String?) ?? 'Sem microárea', + riskLevel: RiskLevel.fromJson(r[3] as String), + status: AlertStatus.fromJson(r[4] as String), + triggeredAt: (r[5] as DateTime).toUtc(), + escalatedAt: (r[6] as DateTime?)?.toUtc(), + overdue: r[7] as bool, + ), + ], + nextOffset: haMais ? offset + limit : null, + ); + } + + /// O escopo filtra o autor da linha, não o recurso auditado: o `IN` junta + /// `users` a `micro_areas` (#43 — o que a #40 havia adiado). `@ubs` nulo + /// (administrador) não filtra nada; ninguém de staff tem microárea, então + /// só o administrador lê as linhas de staff. + /// + /// O [filter] (#45, D1/D2) só acrescenta condições ao `WHERE`, todas por + /// parâmetro nomeado no formato `@p IS NULL OR …`: o texto da consulta é + /// fixo, e o escopo e o `ORDER BY sequence DESC` não mudam com ele. A busca + /// chega escapada por [escaparLike] e casa só o início da matrícula do + /// autor, `resourceType` e `result` — nunca hash, IP nem UUID. + /// + /// Os campos da cadeia (`ipHash`, `previousHash`, `entryHash`, + /// `resourceId`) e o `entryHash` da linha `sequence - 1` (`LEFT JOIN + /// audit_logs p`, pelo índice único de `sequence`) vão só para o + /// [AuditSealInput] interno de cada linha (#45, Task 5): o selo confere o + /// vizinho da CADEIA, não o da página filtrada. A busca não os enxerga. + @override + Future auditLogs( + AdminScope scope, { + AdminAuditFilter? filter, + required int limit, + int? beforeSequence, + }) async { + // Colunas 0–8: o que a tela pode ver. 9–13: só para o selo interno + // (nunca vão para o AdminAuditEntry). + final rows = await _session().db.unsafeQuery( + ''' + SELECT l.id, l.sequence, l."userId", u.role, COALESCE(acs."enrollmentId", s."enrollmentId"), + l."actionType", l."resourceType", l."timestamp", l.result, + l."resourceId", l."ipHash", l."previousHash", l."entryHash", + p."entryHash" + FROM audit_logs l + LEFT JOIN audit_logs p ON p.sequence = l.sequence - 1 + $_juncoesDaAuditoria + $_filtroDaAuditoria + ORDER BY l.sequence DESC + LIMIT @limit + ''', + parameters: QueryParameters.named({ + ..._parametrosDaAuditoria(scope, filter, beforeSequence), + 'limit': limit + 1, + }), + ); + final haMais = rows.length > limit; + final pagina = haMais ? rows.sublist(0, limit) : rows; + final items = [ + for (final r in pagina) + AdminAuditRow( + entry: AdminAuditEntry( + id: r[0].toString(), + sequence: r[1] as int, + userLabel: AdminLabels.user( + role: (r[3] as String?) ?? 'desconhecido', + id: r[2].toString(), + enrollmentId: r[4] as String?, + ), + actionType: r[5] as String, + resourceType: r[6] as String, + timestamp: (r[7] as DateTime).toUtc(), + result: r[8] as String, + ), + seal: AuditSealInput( + row: AuditChainEntry( + fields: AuditChainFields( + sequence: r[1] as int, + previousHash: r[11] as String, + userId: r[2].toString(), + actionType: r[5] as String, + resourceType: r[6] as String, + resourceId: r[9]?.toString(), + timestamp: r[7] as DateTime, + ipHash: r[10] as String, + result: r[8] as String, + ), + entryHash: r[12] as String, + ), + previousEntryHash: r[13] as String?, + ), + actorRole: r[3] as String?, + actorEnrollmentId: r[4] as String?, + ), + ]; + return AdminAuditRowPage( + items: items, + nextBeforeSequence: haMais ? items.last.entry.sequence : null, + ); + } + + /// FROM e WHERE da trilha de auditoria: escopo pelo autor, keyset e as 8 + /// condições do [AdminAuditFilter], todas `@p IS NULL OR …` (#45). Um só + /// texto para a página ([auditLogs]) e para a contagem da exportação + /// ([auditLogsCount]): as duas nunca divergem sobre o que é "o recorte". + /// Usa os aliases `l` (`audit_logs`), `u` (`users`), `acs` e `s` + /// (`staff_accounts`) declarados por [_juncoesDaAuditoria]. + static const _juncoesDaAuditoria = ''' + LEFT JOIN users u ON u.id = l."userId" + LEFT JOIN acs ON acs.id = l."userId" + LEFT JOIN staff_accounts s ON s.id = l."userId"'''; + + static const _filtroDaAuditoria = r'''WHERE (@before::bigint IS NULL OR l.sequence < @before::bigint) + AND (@ubs::uuid IS NULL OR l."userId" IN ( + SELECT u.id FROM users u + JOIN micro_areas m ON m.id = u."microAreaId" + WHERE m."ubsId" = @ubs::uuid)) + AND (@action::text IS NULL OR l."actionType" = @action::text) + AND (@rtype::text IS NULL OR l."resourceType" = @rtype::text) + AND (@result::text IS NULL OR l.result = @result::text) + AND (@from::timestamp IS NULL OR l."timestamp" >= @from::timestamp) + AND (@to::timestamp IS NULL OR l."timestamp" < @to::timestamp) + AND (@role::text IS NULL OR u.role = @role::text) + AND (@enroll::text IS NULL + OR lower(COALESCE(acs."enrollmentId", s."enrollmentId")) + = lower(@enroll::text)) + AND (@q::text IS NULL OR ( + COALESCE(acs."enrollmentId", s."enrollmentId", '') + ILIKE @q::text || '%' ESCAPE '\' + OR l."resourceType" ILIKE '%' || @q::text || '%' ESCAPE '\' + OR l.result ILIKE '%' || @q::text || '%' ESCAPE '\'))'''; + + static Map _parametrosDaAuditoria( + AdminScope scope, + AdminAuditFilter? filter, + int? beforeSequence, + ) { + final busca = filter?.search; + return { + 'before': beforeSequence, + 'ubs': scope.ubsId, + 'action': filter?.actionType, + 'rtype': filter?.resourceType, + 'result': filter?.result, + 'from': filter?.from?.toUtc(), + 'to': filter?.to?.toUtc(), + 'role': filter?.actorRole?.name, + 'enroll': filter?.actorEnrollmentId, + 'q': busca == null ? null : escaparLike(busca), + }; + } + + /// Quantas linhas o recorte de [auditLogs] teria (mesmo escopo, filtro e + /// keyset), **limitado a [cap]**: a contagem para em `cap` linhas em vez de + /// varrer a janela inteira (#45, Task 10: o teto da exportação só precisa + /// saber se passou de `maxRows`). Devolve `min(total, cap)`. + Future auditLogsCount( + AdminScope scope, { + AdminAuditFilter? filter, + int? beforeSequence, + required int cap, + }) async { + if (cap < 1) throw ArgumentError.value(cap, 'cap', 'deve ser >= 1'); + final rows = await _session().db.unsafeQuery( + ''' + SELECT count(*) FROM ( + SELECT 1 + FROM audit_logs l + $_juncoesDaAuditoria + $_filtroDaAuditoria + LIMIT @cap + ) recorte + ''', + parameters: QueryParameters.named({ + ..._parametrosDaAuditoria(scope, filter, beforeSequence), + 'cap': cap, + }), + ); + return rows.single[0] as int; + } + + /// Pertencimento de uma linha de `audit_logs` à UBS (#45, Task 6): a mesma + /// regra do escopo de [auditLogs] — o **autor** tem microárea na UBS. Por + /// igualdade no índice único de `sequence`; só devolve um booleano. + @override + Future auditRowInUbs(int sequence, String ubsId) async { + final rows = await _session().db.unsafeQuery( + ''' + SELECT 1 + FROM audit_logs l + JOIN users u ON u.id = l."userId" + JOIN micro_areas m ON m.id = u."microAreaId" + WHERE l.sequence = @seq::bigint AND m."ubsId" = @ubs::uuid + LIMIT 1 + ''', + parameters: QueryParameters.named({'seq': sequence, 'ubs': ubsId}), + ); + return rows.isNotEmpty; + } + + @override + Future ubsOf(String staffId) async { + final UuidValue id; + try { + id = UuidValue.fromString(staffId); + } on FormatException { + return null; + } + final conta = await StaffAccount.db.findById(_session(), id); + return conta?.ubsId?.toString(); + } +} diff --git a/backend/sinalacs_server/lib/src/infrastructure/database/orm_alert_escalation_store.dart b/backend/sinalacs_server/lib/src/infrastructure/database/orm_alert_escalation_store.dart new file mode 100644 index 00000000..4c5190eb --- /dev/null +++ b/backend/sinalacs_server/lib/src/infrastructure/database/orm_alert_escalation_store.dart @@ -0,0 +1,188 @@ +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/admin/alert_escalation_service.dart'; +import 'package:sinalacs_server/src/application/audit/audit_trail.dart'; +import 'package:sinalacs_server/src/domain/entities/alert_delivery.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/infrastructure/database/alert_overdue_sql.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_alert_outbox.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_data_subject_case_store.dart' + show AuditAppender; + +/// Escalonamento de alerta vermelho (#44) sobre Postgres. +/// +/// Uma transação por chamada: trava a linha do alerta (`SELECT … FOR UPDATE`), +/// relê o estado já sob o lock, decide em SQL e, se vencido, grava as três +/// colunas de escalonamento, enfileira **um** reforço no outbox e audita — as +/// três escritas vivem ou morrem juntas. Status, `acknowledgedAt`, `acsId` e +/// `version` nunca são tocados (D3: o alerta segue pendente para o ACS). +/// +/// Escopo como em `OrmAdminReadStore`: `alerts → micro_areas.ubsId` por junção +/// `LEFT`; o coordenador não enxerga alerta sem microárea, o administrador sim. +/// +/// Ordem dos locks, sempre a mesma: linha do alerta, depois a cadeia de +/// auditoria (tomada dentro de [appendAudit], por último). O ACK +/// (`OrmAlertStore.acknowledge`) pega a linha pelo `UPDATE` e a cadeia depois, +/// na mesma ordem — não há deadlock entre os dois. +class OrmAlertEscalationStore implements AlertEscalationStore { + OrmAlertEscalationStore({required this.session, required this.appendAudit}); + + /// A sessão da requisição, por chamada (padrão dos stores ORM). + final Session Function() session; + + /// Onde o desfecho gravado é auditado, dentro da transação dele. Em produção + /// é `OrmAuditTrail.recordInTransaction`; o teste injeta uma falha por aqui. + final AuditAppender appendAudit; + + /// `UuidValue.fromString` não valida: a string inválida só estoura no + /// Postgres (22P02). Id malformado = alerta inexistente. + static UuidValue? _uuid(String id) => + Uuid.isValidUUID(fromString: id) ? UuidValue.fromString(id) : null; + + @override + Future ubsOf(String staffId) async { + final id = _uuid(staffId); + if (id == null) return null; + final conta = await StaffAccount.db.findById(session(), id); + return conta?.ubsId?.toString(); + } + + @override + Future escalate( + AdminScope scope, { + required String alertId, + required String actorId, + required EscalationReason reason, + required DateTime now, + required Duration threshold, + required AuditEvent Function(EscalationOutcome) auditFor, + }) async { + final id = _uuid(alertId); + if (id == null) return const EscalationAttempt(EscalationOutcome.notFound); + final sessao = session(); + final agora = now.toUtc(); + + return sessao.db.transaction((transaction) async { + // 1. Escopo e lock numa instrução só. `FOR UPDATE OF a`: a microárea fica + // do lado anulável da junção e não pode (nem precisa) ser travada. + // Fora do escopo e inexistente dão a mesma resposta. + final travado = await sessao.db.unsafeQuery( + ''' + SELECT a.id + FROM alerts a + LEFT JOIN micro_areas m ON m.id = a."microAreaId" + WHERE a.id = @id::uuid + AND (@ubs::uuid IS NULL OR m."ubsId" = @ubs::uuid) + FOR UPDATE OF a + ''', + parameters: QueryParameters.named({'id': id.uuid, 'ubs': scope.ubsId}), + transaction: transaction, + ); + if (travado.isEmpty) { + return const EscalationAttempt(EscalationOutcome.notFound); + } + + // 2. Releitura já sob o lock: quem esperou a outra transação enxerga o + // que ela gravou. O prazo é decidido aqui, em SQL, com o `@now` e o + // limite recebidos: vencido só se `now − triggeredAt > limite`, + // estritamente; `triggeredAt` no futuro dá intervalo negativo e nunca + // vence. O fragmento é o de `alert_overdue_sql.dart`, o mesmo de + // `OrmAdminReadStore` (`overdue`, `overdueRed`); a concordância é + // pinada por test/integration/admin_read_store_test.dart ('acordo: …'). + final linha = (await sessao.db.unsafeQuery( + ''' + SELECT a."riskLevel", a.status, a."escalatedAt", + $alertDeadlinePassedSql AS vencido, + a."patientId", a."microAreaId", a."locationHash", + a."locationCell", a."triggeredAt" + FROM alerts a + WHERE a.id = @id::uuid + ''', + parameters: QueryParameters.named({ + 'id': id.uuid, + 'now': agora, + 'limite': threshold.inMicroseconds, + }), + transaction: transaction, + )).single; + + // 3. Precedência fixa (a mesma do store falso do teste do serviço): + // notFound > notRed > notPending > alreadyEscalated > notDue. + if (linha[0] != RiskLevel.red.name) { + return const EscalationAttempt(EscalationOutcome.notRed); + } + if (linha[1] != AlertStatus.pending.name) { + return const EscalationAttempt(EscalationOutcome.notPending); + } + final original = linha[2] as DateTime?; + if (original != null) { + // Repetição: nada muda no alerta nem no outbox; só a linha da trilha, + // com o motivo PEDIDO agora (`already_escalated:`). + await appendAudit( + sessao, + transaction, + auditFor(EscalationOutcome.alreadyEscalated), + ); + return EscalationAttempt( + EscalationOutcome.alreadyEscalated, + escalatedAt: original.toUtc(), + ); + } + if (linha[3] != true) { + return const EscalationAttempt(EscalationOutcome.notDue); + } + + // 4. Só as três colunas de escalonamento; o status fica pendente. + await sessao.db.unsafeExecute( + ''' + UPDATE alerts + SET "escalatedAt" = @now::timestamp, + "escalatedBy" = @por::uuid, + "escalationReason" = @motivo + WHERE id = @id::uuid + ''', + parameters: QueryParameters.named({ + 'id': id.uuid, + 'now': agora, + 'por': actorId, + 'motivo': reason.name, + }), + transaction: transaction, + ); + + // 5. Reforço (D2): uma entrega nova no outbox de sempre, elegível já, + // com o mesmo envelope do alerta mais o marcador `escalated`. Alerta + // sem microárea (só o administrador o vê) não tem tópico de + // território: escalona e audita, sem reforço — não há a quem entregar. + // `alert_outbox` não tem índice único por `alertId`: cada reforço é + // uma linha própria, e a entrega original (publicada ou não) fica + // como está. + final microArea = linha[5]; + if (microArea != null) { + await OrmAlertOutbox( + session: () => sessao, + transaction: transaction, + clock: () => agora, + ).enqueue( + AlertDelivery( + alertId: id.uuid, + patientId: linha[4].toString(), + microAreaId: microArea.toString(), + riskLevel: RiskLevel.red.name, + locationHash: linha[6] as String, + locationCell: linha[7] as String?, + triggeredAt: (linha[8] as DateTime).toUtc(), + escalated: true, + ), + ); + } + + // 6. Por último e na mesma transação: se a trilha falhar, nada fica. + await appendAudit( + sessao, + transaction, + auditFor(EscalationOutcome.escalated), + ); + return EscalationAttempt(EscalationOutcome.escalated, escalatedAt: agora); + }); + } +} diff --git a/backend/sinalacs_server/lib/src/infrastructure/database/orm_alert_store.dart b/backend/sinalacs_server/lib/src/infrastructure/database/orm_alert_store.dart index 60ea7dfe..4c326ae8 100644 --- a/backend/sinalacs_server/lib/src/infrastructure/database/orm_alert_store.dart +++ b/backend/sinalacs_server/lib/src/infrastructure/database/orm_alert_store.dart @@ -1,5 +1,6 @@ import 'package:serverpod/serverpod.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/domain/entities/alert_delivery.dart'; import 'package:sinalacs_server/src/generated/protocol.dart'; @@ -17,10 +18,20 @@ import 'package:sinalacs_server/src/generated/protocol.dart'; class OrmAlertStore implements AlertStore { OrmAlertStore({ required Session Function() session, + required Future Function( + Session session, + Transaction transaction, + AuditEvent event, + ) appendAudit, Transaction? transaction, }) : _session = session, + _appendAudit = appendAudit, _transaction = transaction; + /// Grava a auditoria DENTRO da transação do ACK (fail-closed): em produção é + /// `OrmAuditTrail.recordInTransaction`. + final Future Function(Session, Transaction, AuditEvent) _appendAudit; + final Session Function() _session; final Transaction? _transaction; @@ -96,6 +107,7 @@ class OrmAlertStore implements AlertStore { required String alertId, required String acsId, required String microAreaId, + required AuditEvent audit, }) async { final session = _session(); final alertUuid = UuidValue.fromString(alertId); @@ -122,6 +134,10 @@ class OrmAlertStore implements AlertStore { acknowledgedAt: alert.acknowledgedAt ?? now, status: AlertStatus.acknowledged, ), + // Só as colunas do ACK: a linha foi lida sem lock, e um escalonamento + // (#44) gravado entre a leitura e esta escrita seria apagado por um + // UPDATE da linha inteira com os valores velhos. + columns: (t) => [t.acsId, t.receivedAt, t.acknowledgedAt, t.status], transaction: transaction, ); @@ -144,6 +160,10 @@ class OrmAlertStore implements AlertStore { ); } + // Fail-closed: se a trilha falhar, a transação inteira (status incluso) + // é desfeita. Roda por último, pois o lock da cadeia nunca precede outro. + await _appendAudit(session, transaction, audit); + return true; } 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 index 28bf7fb5..f126cf90 100644 --- 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 @@ -4,34 +4,52 @@ 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. +/// +/// Toda leitura é limitada (#45, G5): `readBatch` por keyset sobre o índice +/// único `audit_logs_sequence_idx`, `entryAt` por igualdade nele. class OrmAuditChainReader implements AuditChainReader { - OrmAuditChainReader({required Session Function() session}) : _session = session; + OrmAuditChainReader({required Session Function() session}) + : _session = session; final Session Function() _session; @override - Future> readInOrder() async { + Future> readBatch({ + required int afterSequence, + required int limit, + }) async { + if (limit < 1) throw ArgumentError.value(limit, 'limit'); final rows = await AuditLog.db.find( _session(), + where: (t) => t.sequence > afterSequence, orderBy: (t) => t.sequence, + limit: limit, ); + return [for (final row in rows) entryOf(row)]; + } - 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, - ), - ]; + @override + Future entryAt(int sequence) async { + final row = await AuditLog.db.findFirstRow( + _session(), + where: (t) => t.sequence.equals(sequence), + ); + return row == null ? null : entryOf(row); } + + /// A linha do ORM na forma que a cadeia conhece. + static AuditChainEntry entryOf(AuditLog row) => 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_checkpoint_store.dart b/backend/sinalacs_server/lib/src/infrastructure/database/orm_audit_checkpoint_store.dart new file mode 100644 index 00000000..4614ae6e --- /dev/null +++ b/backend/sinalacs_server/lib/src/infrastructure/database/orm_audit_checkpoint_store.dart @@ -0,0 +1,76 @@ +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/audit/audit_chain_checkpoint_store.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; + +/// [AuditChainCheckpointStore] sobre `audit_chain_checkpoints` (#45, Task 7, +/// D4). Leitura limitada pelo índice único de `sequence`; escrita só para +/// frente. +/// +/// **Limite conhecido:** esta tabela vive no mesmo banco que `audit_logs`. +/// Quem apaga a cauda da cadeia com escrita direta também pode apagar os +/// checkpoints (não forjar um novo: a assinatura exige `AUDIT_CHAIN_SECRET`), +/// e restaurar um backup inteiro antigo traz cadeia e checkpoints coerentes +/// entre si. O checkpoint reduz o risco do truncamento, não o elimina. +class OrmAuditCheckpointStore implements AuditChainCheckpointStore { + OrmAuditCheckpointStore({required Session Function() session}) + : _session = session; + + final Session Function() _session; + + /// Serializa as gravações de checkpoint entre transações: sem ele, duas + /// verificações concorrentes com cabeças diferentes poderiam ambas passar + /// pelo `NOT EXISTS` e a menor entrar depois da maior. Chave fixa, distinta + /// da chave da cadeia (`OrmAuditTrail._chainLockKey`) e da forma de duas + /// chaves de `lockPerSubject`. + static const _checkpointLockKey = 725100824; + + @override + Future> latest({required int limit}) async { + if (limit < 1) throw ArgumentError.value(limit, 'limit'); + final rows = await AuditChainCheckpoint.db.find( + _session(), + orderBy: (t) => t.sequence, + orderDescending: true, + limit: limit, + ); + return [ + for (final r in rows) + AuditChainCheckpointRecord( + sequence: r.sequence, + entryHash: r.entryHash, + signature: r.signature, + ), + ]; + } + + @override + Future advanceTo(AuditChainCheckpointRecord checkpoint) async { + final session = _session(); + await session.db.transaction((transaction) async { + await session.db.unsafeExecute( + 'SELECT pg_advisory_xact_lock(@key);', + parameters: QueryParameters.named({'key': _checkpointLockKey}), + transaction: transaction, + ); + // Só avança: nenhuma linha com `sequence` >= pode existir. O + // `ON CONFLICT` cobre o que o lock não cobre (ex.: outra escrita fora + // deste store) sem transformar a corrida em erro. + await session.db.unsafeExecute( + 'INSERT INTO audit_chain_checkpoints ' + '("sequence", "entryHash", "signature", "createdAt") ' + 'SELECT @sequence, @entryHash, @signature, @createdAt ' + 'WHERE NOT EXISTS (' + ' SELECT 1 FROM audit_chain_checkpoints WHERE "sequence" >= @sequence' + ') ' + 'ON CONFLICT ("sequence") DO NOTHING;', + parameters: QueryParameters.named({ + 'sequence': checkpoint.sequence, + 'entryHash': checkpoint.entryHash, + 'signature': checkpoint.signature, + 'createdAt': DateTime.now().toUtc(), + }), + transaction: transaction, + ); + }); + } +} diff --git a/backend/sinalacs_server/lib/src/infrastructure/database/orm_audit_export_store.dart b/backend/sinalacs_server/lib/src/infrastructure/database/orm_audit_export_store.dart new file mode 100644 index 00000000..5a3f37d0 --- /dev/null +++ b/backend/sinalacs_server/lib/src/infrastructure/database/orm_audit_export_store.dart @@ -0,0 +1,84 @@ +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/admin/admin_read_service.dart'; +import 'package:sinalacs_server/src/application/admin/audit_export_service.dart'; +import 'package:sinalacs_server/src/application/audit/audit_trail.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_admin_read_store.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_data_subject_case_store.dart' + show AuditAppender; + +/// Exportação da auditoria (#45, Task 10) sobre Postgres. +/// +/// A leitura reusa o SQL da página do backoffice ([OrmAdminReadStore]): mesmo +/// filtro, mesmo selo, escopo de sistema, keyset `sequence < head + 1` — "o +/// recorte" é um só texto para a tela e para o arquivo. A gravação é uma +/// transação: `audit_exports` e o apêndice à cadeia ([AuditAppender], em +/// produção `OrmAuditTrail.recordInTransaction`, que toma o lock da cadeia +/// por último). +class OrmAuditExportStore implements AuditExportStore { + OrmAuditExportStore({ + required Session Function() session, + required this._appendAudit, + }) : _session = session, + _leitura = OrmAdminReadStore(session: session); + + final Session Function() _session; + final AuditAppender _appendAudit; + final OrmAdminReadStore _leitura; + + static const _sistema = AdminScope.system(); + + @override + Future ubsOf(String staffId) => _leitura.ubsOf(staffId); + + @override + Future headSequence() async { + final ultima = await AuditLog.db.findFirstRow( + _session(), + orderBy: (t) => t.sequence, + orderDescending: true, + ); + return ultima?.sequence ?? 0; + } + + @override + Future countUpTo( + AdminAuditFilter filter, { + required int headSequence, + required int cap, + }) => _leitura.auditLogsCount( + _sistema, + filter: filter, + beforeSequence: headSequence + 1, + cap: cap, + ); + + @override + Future> readUpTo( + AdminAuditFilter filter, { + required int headSequence, + required int limit, + }) async { + final pagina = await _leitura.auditLogs( + _sistema, + filter: filter, + limit: limit, + beforeSequence: headSequence + 1, + ); + // A página vem em `sequence DESC`; o arquivo é cronológico. + return pagina.items.reversed.toList(); + } + + @override + Future commitExport(AuditExport registro, AuditEvent evento) { + final session = _session(); + return session.db.transaction((transaction) async { + await AuditExport.db.insertRow( + session, + registro, + transaction: transaction, + ); + await _appendAudit(session, transaction, evento); + }); + } +} 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 index 4f46e6ca..44ad4f19 100644 --- a/backend/sinalacs_server/lib/src/infrastructure/database/orm_audit_trail.dart +++ b/backend/sinalacs_server/lib/src/infrastructure/database/orm_audit_trail.dart @@ -1,8 +1,8 @@ -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'; +import 'package:sinalacs_server/src/infrastructure/crypto/rotating_ip_hasher.dart'; /// Implementação de [AuditTrail] sobre o ORM do Serverpod. /// @@ -11,7 +11,10 @@ import 'package:sinalacs_server/src/generated/protocol.dart'; /// `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. +/// da conexão seria sempre o do proxy, não o do ACS. O valor gravado é um +/// HMAC-SHA-256 com chave rotativa diária derivada de `AUDIT_CHAIN_SECRET` +/// ([RotatingIpHasher]) — SHA-256 puro sobre um IPv4 (2^32 combinações) seria +/// reversível por rainbow table (spec/lgpd_data_audit.md §2.1). /// /// 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 @@ -24,10 +27,12 @@ class OrmAuditTrail extends AuditTrail { required Session Function() session, required String chainSecret, }) : _session = session, - _chain = AuditChain(secret: chainSecret); + _chain = AuditChain(secret: chainSecret), + _ipHasher = RotatingIpHasher(secret: chainSecret); final Session Function() _session; final AuditChain _chain; + final RotatingIpHasher _ipHasher; /// 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. @@ -36,58 +41,74 @@ class OrmAuditTrail extends AuditTrail { @override Future record(AuditEvent event) async { final session = _session(); + await session.db.transaction( + (transaction) => recordInTransaction(session, transaction, event), + ); + } + + /// O apêndice à cadeia dentro de uma transação que já existe, para quem + /// precisa que a linha de auditoria e a mudança que ela descreve vivam ou + /// morram juntas (o atendimento de pedidos do titular, #42): se esta + /// gravação falhar, a transação de quem chamou é desfeita inteira. + /// + /// Toma o lock da cadeia por último: quem chama pode já segurar locks + /// próprios (por pedido, por titular), mas nunca deve pedir outro depois + /// deste — é essa ordem fixa que impede o deadlock. + Future recordInTransaction( + Session session, + Transaction transaction, + AuditEvent event, + ) async { // 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 ipHash = _ipHasher.hash(remoteInfo); 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, - ); + 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 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, + 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, + resourceId: resourceId == null ? null : UuidValue.fromString(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, - ); - }); + sequence: sequence, + previousHash: previousHash, + entryHash: entryHash, + ), + transaction: transaction, + ); } } diff --git a/backend/sinalacs_server/lib/src/infrastructure/database/orm_data_subject_case_store.dart b/backend/sinalacs_server/lib/src/infrastructure/database/orm_data_subject_case_store.dart new file mode 100644 index 00000000..fc8017b6 --- /dev/null +++ b/backend/sinalacs_server/lib/src/infrastructure/database/orm_data_subject_case_store.dart @@ -0,0 +1,436 @@ +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/admin/admin_labels.dart'; +import 'package:sinalacs_server/src/application/admin/admin_read_service.dart'; +import 'package:sinalacs_server/src/application/admin/data_subject_case_service.dart'; +import 'package:sinalacs_server/src/application/audit/audit_trail.dart'; +import 'package:sinalacs_server/src/application/onboarding/consent_signature.dart'; +import 'package:sinalacs_server/src/application/onboarding/onboarding_service.dart' + show ConsentLogEntry, consentPolicyVersion; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/infrastructure/crypto/encrypted_json.dart'; +import 'package:sinalacs_server/src/infrastructure/crypto/health_data_cipher.dart'; +import 'package:sinalacs_server/src/infrastructure/database/removed_account.dart'; +import 'package:sinalacs_server/src/infrastructure/database/signed_consent_log.dart'; +import 'package:sinalacs_server/src/infrastructure/database/subject_lock.dart'; + +/// Grava um evento de auditoria DENTRO de [transaction]. Em produção é +/// `OrmAuditTrail.recordInTransaction`; o teste injeta uma falha por aqui. +typedef AuditAppender = + Future Function( + Session session, + Transaction transaction, + AuditEvent event, + ); + +/// Atendimento de pedidos do titular (#42) sobre Postgres. +/// +/// Leitura em SQL direto (`unsafeQuery` com parâmetros nomeados, como +/// `OrmAdminReadStore`): o escopo de UBS é uma junção +/// `data_subject_requests → users → micro_areas.ubsId`. A microárea mora em +/// `users`, não em `patients`, e por isso o titular sem linha em `patients` +/// continua no escopo. Titular sem microárea só aparece para o administrador. +/// +/// `details` e a nota de resposta são cifrados com o mesmo [HealthDataCipher] +/// de `visits.notes`; a lista nunca os decifra, só o detalhe ([find]). +class OrmDataSubjectCaseStore implements DataSubjectCaseStore { + OrmDataSubjectCaseStore( + this._session, + this._cipher, { + required String chainSecret, + required this.appendAudit, + }) : _signature = ConsentSignature(secret: chainSecret); + + final Session _session; + final HealthDataCipher _cipher; + + /// Assina o `denied` de push da anonimização com o MESMO `AUDIT_CHAIN_SECRET` + /// do painel do titular (`OrmDataSubjectRightsStore`). + final ConsentSignature _signature; + /// Onde a decisão é auditada, dentro da transação dela. + final AuditAppender appendAudit; + + static const rotuloRemovido = 'Titular removido'; + + /// `users.birthDate` é NOT NULL: a data real dá lugar a este marcador fixo. + static final nascimentoRemovido = DateTime.utc(1900, 1, 1); + + /// `@ubs` nulo = sistema inteiro. + static const _base = ''' + FROM data_subject_requests r + JOIN users u ON u.id = r."userId" + LEFT JOIN micro_areas m ON m.id = u."microAreaId" + WHERE (@ubs::uuid IS NULL OR m."ubsId" = @ubs::uuid)'''; + + static const _abertos = { + DataSubjectRequestStatus.open, + DataSubjectRequestStatus.inReview, + }; + + static bool _vencido(DataSubjectRequestStatus s, DateTime dueAt, DateTime now) => + _abertos.contains(s) && now.isAfter(dueAt); + + /// `UuidValue.fromString` não valida: a string inválida só estoura no + /// Postgres (22P02). Id malformado = pedido inexistente. + static UuidValue? _uuid(String id) => + Uuid.isValidUUID(fromString: id) ? UuidValue.fromString(id) : null; + + @override + Future ubsOf(String staffId) async { + final id = _uuid(staffId); + if (id == null) return null; + final conta = await StaffAccount.db.findById(_session, id); + return conta?.ubsId?.toString(); + } + + @override + Future subjectOf(String requestId) async { + final uuid = _uuid(requestId); + if (uuid == null) return null; + final rows = await _session.db.unsafeQuery( + 'SELECT "userId" FROM data_subject_requests WHERE id = @id::uuid', + parameters: QueryParameters.named({'id': uuid.uuid}), + ); + return rows.isEmpty ? null : rows.single[0].toString(); + } + + @override + Future list( + AdminScope scope, { + DataSubjectRequestStatus? status, + required int limit, + required int offset, + required DateTime now, + }) async { + // Prazo mais próximo primeiro: os vencidos sobem ao topo da fila. + final rows = await _session.db.unsafeQuery( + ''' + SELECT r.id, r."userId", r."requestType", r.status, r."createdAt", r."dueAt" + $_base + AND (@status::text IS NULL OR r.status = @status::text) + ORDER BY r."dueAt" ASC, r.id ASC + LIMIT @limit OFFSET @offset + ''', + parameters: QueryParameters.named({ + 'ubs': scope.ubsId, + 'status': status?.name, + // Uma linha a mais só para saber se há próxima página. + 'limit': limit + 1, + 'offset': offset, + }), + ); + final haMais = rows.length > limit; + final pagina = haMais ? rows.sublist(0, limit) : rows; + return AdminDataSubjectRequestPage( + items: [ + for (final r in pagina) + () { + final st = DataSubjectRequestStatus.fromJson(r[3] as String); + final due = (r[5] as DateTime).toUtc(); + return AdminDataSubjectRequest( + id: r[0].toString(), + type: DataSubjectRequestType.fromJson(r[2] as String), + status: st, + createdAt: (r[4] as DateTime).toUtc(), + dueAt: due, + overdue: _vencido(st, due, now), + patientLabel: AdminLabels.patient(r[1].toString()), + ); + }(), + ], + nextOffset: haMais ? offset + limit : null, + ); + } + + @override + Future find( + AdminScope scope, + String id, { + required DateTime now, + }) async { + final uuid = _uuid(id); + if (uuid == null) return null; + final rows = await _session.db.unsafeQuery( + ''' + SELECT r.id, r."userId", r."requestType", r.status, r."createdAt", r."dueAt", + r."detailsEncrypted", r."detailsKeyVersion", + r."resolutionEncrypted", r."resolutionKeyVersion", r."decidedAt" + $_base + AND r.id = @id::uuid + ''', + parameters: QueryParameters.named({'ubs': scope.ubsId, 'id': uuid.uuid}), + ); + if (rows.isEmpty) return null; + final r = rows.single; + final st = DataSubjectRequestStatus.fromJson(r[3] as String); + final due = (r[5] as DateTime).toUtc(); + final resolucaoCifrada = r[8] as String?; + return AdminDataSubjectRequestDetail( + id: r[0].toString(), + type: DataSubjectRequestType.fromJson(r[2] as String), + status: st, + createdAt: (r[4] as DateTime).toUtc(), + dueAt: due, + overdue: _vencido(st, due, now), + patientLabel: AdminLabels.patient(r[1].toString()), + details: await _cipher.decryptJson(r[6] as String, r[7] as int) as String?, + resolution: resolucaoCifrada == null + ? null + : await _cipher.decryptJson(resolucaoCifrada, r[9] as int) as String?, + decidedAt: (r[10] as DateTime?)?.toUtc(), + ); + } + + /// Ordem dos locks, sempre a mesma, para não haver deadlock: + /// 1. pedido (`lockNamespaceDataSubjectCase`) — dois analistas no mesmo pedido; + /// 2. titular (`lockNamespaceDeletion`, o MESMO de + /// `OrmDataSubjectRightsStore.createDeletionRequestIfNoneOpen`) — duas + /// exclusões do mesmo titular decididas ao mesmo tempo, ou o titular + /// abrindo um pedido novo durante a anonimização; + /// 3. push do titular (`lockNamespacePushToken`, o MESMO de + /// `OrmPushTokenStore.registerIfConsented`), só na anonimização — um + /// registro de token em voo termina antes do `DELETE` ou espera e lê o + /// `denied`; + /// 4. cadeia de auditoria, tomado dentro de [appendAudit], por último. + /// O status é relido só depois dos dois primeiros locks. Nenhum outro + /// escritor pega estes locks na ordem inversa: o registro de push pega 3 e o + /// lock por token (4 em `subject_lock.dart`), nunca 1 nem 6. + @override + Future decide( + AdminScope scope, + String id, { + required Set from, + required DataSubjectRequestStatus to, + required String? resolution, + required String decidedBy, + required bool anonymize, + required AuditEvent audit, + required DateTime now, + }) async { + final uuid = _uuid(id); + if (uuid == null) return false; + final decisor = UuidValue.fromString(decidedBy); + final nota = resolution == null ? null : await _cipher.encryptJson(resolution); + final session = _session; + + return session.db.transaction((transaction) async { + await lockPerSubject( + session, + transaction, + namespace: lockNamespaceDataSubjectCase, + key: uuid.uuid, + ); + // Só o dono e o escopo; `userId` não muda, então pode ser lido antes do + // lock do titular. + final dono = await session.db.unsafeQuery( + 'SELECT r."userId" $_base AND r.id = @id::uuid', + parameters: QueryParameters.named({'ubs': scope.ubsId, 'id': uuid.uuid}), + transaction: transaction, + ); + // Inexistente ou fora do escopo: o serviço trata como corrida perdida. + if (dono.isEmpty) return false; + final titular = dono.single[0].toString(); + await lockPerSubject( + session, + transaction, + namespace: lockNamespaceDeletion, + key: titular, + ); + + final pedido = await DataSubjectRequest.db.findById( + session, + uuid, + transaction: transaction, + ); + if (pedido == null || !from.contains(pedido.status)) return false; + + await DataSubjectRequest.db.updateRow( + session, + pedido.copyWith( + status: to, + decidedAt: now, + decidedBy: decisor, + resolutionEncrypted: nota?.ciphertextBase64, + resolutionKeyVersion: nota?.keyVersion, + ), + transaction: transaction, + ); + + var irmaos = const []; + if (anonymize) { + if (pedido.requestType != DataSubjectRequestType.deletion) { + throw StateError('anonimização só atende pedido de exclusão'); + } + irmaos = await _anonimizar( + session, + transaction, + titular: pedido.userId, + pedido: uuid, + decisor: decisor, + now: now, + ); + } + + // Por último e na mesma transação: se a auditoria falhar, nada fica. + await appendAudit(session, transaction, audit); + // Cada pedido irmão fechado junto ganha a própria linha, com a mesma + // forma da decisão principal e o id dele. + for (final irmao in irmaos) { + await appendAudit( + session, + transaction, + AuditEvent( + userId: audit.userId, + actionType: audit.actionType, + resourceType: audit.resourceType, + resourceId: irmao.uuid, + result: 'completed', + ), + ); + } + return true; + }); + } + + /// Exclusão atendida = anonimização (decisão 2 da #42): some a identidade do + /// titular e o que liga aparelho/login a ele; ficam `alerts`, `visits`, + /// `audit_logs` e `consent_logs` (trilha e estatística pseudonimizada, + /// `spec/lgpd_design.md` 5.7). A microárea do usuário fica, para o pedido + /// continuar no escopo do coordenador que o atendeu. + /// + /// Devolve os ids dos outros pedidos de exclusão fechados junto, para a + /// auditoria de cada um. + Future> _anonimizar( + Session session, + Transaction transaction, { + required UuidValue titular, + required UuidValue pedido, + required UuidValue decisor, + required DateTime now, + }) async { + final usuario = await User.db.findById( + session, + titular, + transaction: transaction, + ); + // Pedido de exclusão só nasce do próprio paciente; qualquer outro papel aqui + // é dado inconsistente, e apagar a credencial de um ACS ou do staff seria + // grave. Lançar desfaz a transação inteira. + if (usuario == null || usuario.role != UserRole.patient) { + throw StateError('titular do pedido de exclusão não é paciente'); + } + await User.db.updateRow( + session, + usuario.copyWith( + name: rotuloRemovido, + // Aleatório e nunca derivado do CPF: o mesmo CPF não volta a achar + // esta linha no login, e o índice único segue satisfeito. + cpfHash: '$removedCpfHashPrefix${const Uuid().v4()}', + birthDate: nascimentoRemovido, + updatedAt: now, + ), + transaction: transaction, + ); + // `copyWith` não zera campo anulável; o UPDATE vai em SQL. Titular sem + // linha em `patients` atualiza zero linhas e segue. + await session.db.unsafeExecute( + ''' + UPDATE patients + SET "chronicConditionsEncrypted" = '', "lastLocationHash" = NULL, + "emergencyContactEncrypted" = '' + WHERE id = @id::uuid + ''', + parameters: QueryParameters.named({'id': titular.uuid}), + transaction: transaction, + ); + // Respostas da triagem viram a coluna vazia, que `decryptJson` lê como + // "sem respostas"; a sessão (risco e data) fica para a estatística, como + // os alertas. Apagar a linha perderia esse histórico pseudonimizado. + await session.db.unsafeExecute( + ''' + UPDATE triage_sessions SET "answersEncrypted" = '' + WHERE "patientId" = @id::uuid + ''', + parameters: QueryParameters.named({'id': titular.uuid}), + transaction: transaction, + ); + // Push: a sessão do titular pode seguir viva até o JWT vencer, e + // `registerIfConsented` só olha o consentimento mais recente. Sob o lock + // de push do titular, apaga os tokens e acrescenta um `denied` assinado + // (append-only: as linhas anteriores ficam como estão). Só `segmentedPush`. + await lockPerSubject( + session, + transaction, + namespace: lockNamespacePushToken, + key: titular.uuid, + ); + await PushToken.db.deleteWhere( + session, + where: (t) => t.userId.equals(titular), + transaction: transaction, + ); + final ultimo = await ConsentLog.db.findFirstRow( + session, + where: (t) => + t.userId.equals(titular) & + t.purpose.equals(ConsentPurpose.segmentedPush.name), + orderBy: (t) => t.timestamp, + orderDescending: true, + transaction: transaction, + ); + // Estritamente depois do último: no empate de instante a leitura desempata + // por id aleatório, e o `denied` poderia perder para um `granted`. + final instante = ultimo == null || ultimo.timestamp.isBefore(now) + ? now + : ultimo.timestamp.add(const Duration(microseconds: 1)); + await ConsentLog.db.insertRow( + session, + signedConsentLog( + ConsentLogEntry( + userId: titular.uuid, + purpose: ConsentPurpose.segmentedPush, + action: 'denied', + version: consentPolicyVersion, + timestamp: instante, + ), + signature: _signature, + origin: 'atendimento-exclusao', + ), + transaction: transaction, + ); + await OtpChallenge.db.deleteWhere( + session, + where: (t) => t.userId.equals(titular), + transaction: transaction, + ); + await UserCredential.db.deleteWhere( + session, + where: (t) => t.userId.equals(titular), + transaction: transaction, + ); + // Outros pedidos de exclusão ainda abertos do mesmo titular perderam o + // objeto: fecham juntos, com a mesma decisão. Correções ficam como estão. + // Pelo ORM, e não em SQL, para `decidedAt` ser gravado como o resto da + // tabela. Sob o lock do titular, nenhum outro decide estes pedidos agora. + final outros = await DataSubjectRequest.db.find( + session, + where: (t) => + t.userId.equals(titular) & + t.requestType.equals(DataSubjectRequestType.deletion) & + t.status.inSet(_abertos) & + t.id.notEquals(pedido), + transaction: transaction, + ); + for (final outro in outros) { + await DataSubjectRequest.db.updateRow( + session, + outro.copyWith( + status: DataSubjectRequestStatus.completed, + decidedAt: now, + decidedBy: decisor, + ), + transaction: transaction, + ); + } + return [for (final outro in outros) outro.id!]; + } +} diff --git a/backend/sinalacs_server/lib/src/infrastructure/database/orm_data_subject_rights_store.dart b/backend/sinalacs_server/lib/src/infrastructure/database/orm_data_subject_rights_store.dart new file mode 100644 index 00000000..afdae3eb --- /dev/null +++ b/backend/sinalacs_server/lib/src/infrastructure/database/orm_data_subject_rights_store.dart @@ -0,0 +1,252 @@ +import 'package:meta/meta.dart'; +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/onboarding/consent_signature.dart'; +import 'package:sinalacs_server/src/application/onboarding/onboarding_service.dart' + show ConsentLogEntry; +import 'package:sinalacs_server/src/application/patients/data_subject_rights_service.dart'; +import 'package:sinalacs_server/src/application/patients/patient_data_overview_service.dart' + show ConsentRecordSnapshot, DataSubjectRequestSnapshot; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/infrastructure/crypto/encrypted_json.dart'; +import 'package:sinalacs_server/src/infrastructure/crypto/health_data_cipher.dart'; +import 'package:sinalacs_server/src/infrastructure/database/removed_account.dart'; +import 'package:sinalacs_server/src/infrastructure/database/signed_consent_log.dart'; +import 'package:sinalacs_server/src/infrastructure/database/subject_lock.dart'; + +/// Um pedido lido do banco, com `details` e a nota de resposta (`resolution`, +/// #42) decifrados. Compartilhado com `OrmPatientDataOverviewStore`, que lista +/// os mesmos pedidos em "Meus Dados": quem lê é sempre o próprio titular. +Future dataSubjectRequestSnapshotOf( + DataSubjectRequest row, + HealthDataCipher cipher, +) async { + final resolutionEncrypted = row.resolutionEncrypted; + final resolutionKeyVersion = row.resolutionKeyVersion; + return DataSubjectRequestSnapshot( + id: row.id!.uuid, + type: row.requestType, + status: row.status, + details: await cipher.decryptJson(row.detailsEncrypted, row.detailsKeyVersion) as String?, + createdAt: row.createdAt, + dueAt: row.dueAt, + resolution: resolutionEncrypted == null || resolutionKeyVersion == null + ? null + : await cipher.decryptJson(resolutionEncrypted, resolutionKeyVersion) as String?, + ); +} + +/// Implementação de [DataSubjectRightsStore] sobre o ORM do Serverpod. +/// +/// [chainSecret] é o mesmo `AUDIT_CHAIN_SECRET` que assina `consent_logs` no +/// onboarding (`OrmOnboardingStore`) — a linha sai de [signedConsentLog], a +/// mesma função, para as duas origens não divergirem. +class OrmDataSubjectRightsStore implements DataSubjectRightsStore { + OrmDataSubjectRightsStore({ + required Session Function() session, + required String chainSecret, + required HealthDataCipher cipher, + @visibleForTesting bool debugFailAfterTokenDelete = false, + }) : _session = session, + _signature = ConsentSignature(secret: chainSecret), + _cipher = cipher, + _debugFailAfterTokenDelete = debugFailAfterTokenDelete; + + final Session Function() _session; + final ConsentSignature _signature; + final HealthDataCipher _cipher; + final bool _debugFailAfterTokenDelete; + + /// Sob o lock de push do titular (namespace 3), o mesmo que a anonimização + /// (#42) segura até o commit: um `granted` de `segmentedPush` nunca entra + /// depois do `denied` que ela grava. + @override + Future recordConsent(ConsentLogEntry entry) async { + final session = _session(); + return session.db.transaction((transaction) async { + await lockPerSubject(session, transaction, + namespace: lockNamespacePushToken, key: entry.userId); + await _refuseRemoved(session, transaction, entry.userId); + final row = await ConsentLog.db.insertRow( + session, + signedConsentLog(entry, signature: _signature, origin: 'painel-titular'), + transaction: transaction, + ); + return row.id!.uuid; + }); + } + + /// Conta anonimizada por exclusão atendida (#42): nenhuma escrita nova. + Future _refuseRemoved(Session session, Transaction transaction, String userId) async { + if (await isRemovedAccount(session, UuidValue.fromString(userId), transaction: transaction)) { + throw StateError(removedAccountMessage); + } + } + + /// Trava por titular (a MESMA de `OrmPushTokenStore.registerIfConsented`), apaga + /// os tokens e só então grava o `denied`, tudo numa transação: se qualquer passo + /// falhar, nada fica — nem o consentimento revogado com o token vivo, nem o + /// contrário. + @override + Future recordConsentRevokingPush(ConsentLogEntry entry) async { + final session = _session(); + final userUuid = UuidValue.fromString(entry.userId); + return session.db.transaction((transaction) async { + await lockPerSubject(session, transaction, + namespace: lockNamespacePushToken, key: entry.userId); + await _refuseRemoved(session, transaction, entry.userId); + await PushToken.db.deleteWhere( + session, + where: (t) => t.userId.equals(userUuid), + transaction: transaction, + ); + if (_debugFailAfterTokenDelete) { + throw StateError('falha injetada depois de apagar os tokens (só em teste)'); + } + final row = await ConsentLog.db.insertRow( + session, + signedConsentLog(entry, signature: _signature, origin: 'painel-titular'), + transaction: transaction, + ); + return row.id!.uuid; + }); + } + + @override + Future<({String? id, ConsentRecordSnapshot? existing})> recordConsentUnlessCurrent( + ConsentLogEntry entry, + ) async { + final session = _session(); + final userUuid = UuidValue.fromString(entry.userId); + return session.db.transaction((transaction) async { + await lockPerSubject(session, transaction, + namespace: lockNamespaceTerms, key: '${entry.purpose.name}:${entry.userId}'); + await _refuseRemoved(session, transaction, entry.userId); + final latest = await ConsentLog.db.findFirstRow( + session, + where: (t) => t.userId.equals(userUuid) & t.purpose.equals(entry.purpose.name), + orderByList: (t) => [ + Order(column: t.timestamp, orderDescending: true), + Order(column: t.id, orderDescending: true), + ], + transaction: transaction, + ); + final snapshot = latest == null + ? null + : ConsentRecordSnapshot( + purpose: latest.purpose, + action: latest.action, + version: latest.version, + timestamp: latest.timestamp, + ); + if (isCurrentAcceptance(snapshot, version: entry.version)) { + return (id: null, existing: snapshot); + } + final row = await ConsentLog.db.insertRow( + session, + signedConsentLog(entry, signature: _signature, origin: 'painel-titular'), + transaction: transaction, + ); + return (id: row.id!.uuid, existing: null); + }); + } + + @override + Future latestConsent(String userId, ConsentPurpose purpose) async { + final row = await ConsentLog.db.findFirstRow( + _session(), + where: (t) => t.userId.equals(UuidValue.fromString(userId)) & t.purpose.equals(purpose.name), + orderByList: (t) => [ + Order(column: t.timestamp, orderDescending: true), + Order(column: t.id, orderDescending: true), + ], + ); + return row == null + ? null + : ConsentRecordSnapshot( + purpose: row.purpose, + action: row.action, + version: row.version, + timestamp: row.timestamp, + ); + } + + /// Serializa por titular com `pg_advisory_xact_lock`, o mesmo recurso que + /// `OrmAuditTrail` usa para a cadeia: o Serverpod não declara `WHERE` em + /// índice, então um índice único parcial ("um pedido aberto por titular") + /// não é possível. O lock solta sozinho no fim da transação. + @override + Future<({DataSubjectRequestSnapshot request, bool created})> createDeletionRequestIfNoneOpen({ + required String userId, + required DateTime createdAt, + required DateTime dueAt, + }) async { + final session = _session(); + final userUuid = UuidValue.fromString(userId); + final encrypted = await _cipher.encryptJson(null); + return session.db.transaction((transaction) async { + await lockPerSubject(session, transaction, namespace: lockNamespaceDeletion, key: userId); + await _refuseRemoved(session, transaction, userId); + final open = await DataSubjectRequest.db.findFirstRow( + session, + where: (t) => + t.userId.equals(userUuid) & + t.requestType.equals(DataSubjectRequestType.deletion) & + t.status.equals(DataSubjectRequestStatus.open), + orderBy: (t) => t.createdAt, + orderDescending: true, + transaction: transaction, + ); + if (open != null) { + return (request: await dataSubjectRequestSnapshotOf(open, _cipher), created: false); + } + final row = await DataSubjectRequest.db.insertRow( + session, + DataSubjectRequest( + userId: userUuid, + requestType: DataSubjectRequestType.deletion, + detailsEncrypted: encrypted.ciphertextBase64, + detailsKeyVersion: encrypted.keyVersion, + status: DataSubjectRequestStatus.open, + createdAt: createdAt, + dueAt: dueAt, + ), + transaction: transaction, + ); + return (request: await dataSubjectRequestSnapshotOf(row, _cipher), created: true); + }); + } + + @override + Future createRequest({ + required String userId, + required DataSubjectRequestType type, + required String? details, + required DateTime createdAt, + required DateTime dueAt, + }) async { + // Cifra sempre, inclusive o `null` da exclusão: a coluna vazia fica + // reservada para linha escrita fora do caminho Dart (ver `decryptJson`). + final encrypted = await _cipher.encryptJson(details); + final session = _session(); + // Sob o lock por titular da exclusão (namespace 1), o mesmo que a + // anonimização (#42) toma: um pedido novo nunca nasce depois dela. + return session.db.transaction((transaction) async { + await lockPerSubject(session, transaction, namespace: lockNamespaceDeletion, key: userId); + await _refuseRemoved(session, transaction, userId); + final row = await DataSubjectRequest.db.insertRow( + session, + DataSubjectRequest( + userId: UuidValue.fromString(userId), + requestType: type, + detailsEncrypted: encrypted.ciphertextBase64, + detailsKeyVersion: encrypted.keyVersion, + status: DataSubjectRequestStatus.open, + createdAt: createdAt, + dueAt: dueAt, + ), + transaction: transaction, + ); + return dataSubjectRequestSnapshotOf(row, _cipher); + }); + } +} diff --git a/backend/sinalacs_server/lib/src/infrastructure/database/orm_notice_recipient_store.dart b/backend/sinalacs_server/lib/src/infrastructure/database/orm_notice_recipient_store.dart new file mode 100644 index 00000000..a23fc7fb --- /dev/null +++ b/backend/sinalacs_server/lib/src/infrastructure/database/orm_notice_recipient_store.dart @@ -0,0 +1,63 @@ +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/notices/notice_service.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/infrastructure/push/gorush_client.dart'; + +/// Implementação de [NoticeRecipientStore] sobre o Postgres. +class OrmNoticeRecipientStore implements NoticeRecipientStore { + OrmNoticeRecipientStore({required Session Function() session}) : _session = session; + + final Session Function() _session; + + /// A segmentação acontece aqui, no banco (decisão §3.2). O consentimento + /// decisivo é a linha **mais recente** de `segmentedPush` de cada titular: + /// quem revogou depois de registrar o token fica de fora mesmo que o token + /// ainda esteja na tabela. Empate de `timestamp` decide o `id` maior. `LEFT JOIN` + /// com `patients`: quem tem token e consentimento mas não tem linha clínica + /// recebe o aviso "para todos" e só fica fora do filtro de crônicos. Parâmetros + /// sempre nomeados, nunca interpolados. + @override + Future> consentedTargets({ + required String microAreaId, + required bool chronicOnly, + }) async { + final rows = await _session().db.unsafeQuery( + ''' + SELECT pt."token" AS token, pt."platform" AS platform + FROM push_tokens pt + JOIN users u ON u."id" = pt."userId" + LEFT JOIN patients p ON p."id" = u."id" + WHERE u."microAreaId" = @micro::uuid + AND u."role" = 'patient' + AND (NOT @chronic OR COALESCE(p."isChronic", false)) + AND COALESCE(( + SELECT c."action" FROM consent_logs c + WHERE c."userId" = pt."userId" AND c."purpose" = 'segmentedPush' + ORDER BY c."timestamp" DESC, c."id" DESC LIMIT 1 + ), 'denied') = 'granted' + ORDER BY pt."token" + ''', + parameters: QueryParameters.named({ + 'micro': microAreaId, + 'chronic': chronicOnly, + }), + ); + return [ + for (final row in rows) + PushTarget( + token: row.toColumnMap()['token'] as String, + platform: row.toColumnMap()['platform'] as String, + ), + ]; + } + + @override + Future deleteTokens(List tokens) async { + if (tokens.isEmpty) return 0; + final removed = await PushToken.db.deleteWhere( + _session(), + where: (t) => t.token.inSet(tokens.toSet()), + ); + return removed.length; + } +} diff --git a/backend/sinalacs_server/lib/src/infrastructure/database/orm_onboarding_store.dart b/backend/sinalacs_server/lib/src/infrastructure/database/orm_onboarding_store.dart index 09dc6837..464ae8c1 100644 --- a/backend/sinalacs_server/lib/src/infrastructure/database/orm_onboarding_store.dart +++ b/backend/sinalacs_server/lib/src/infrastructure/database/orm_onboarding_store.dart @@ -2,6 +2,7 @@ import 'package:serverpod/serverpod.dart'; import 'package:sinalacs_server/src/application/onboarding/consent_signature.dart'; import 'package:sinalacs_server/src/application/onboarding/onboarding_service.dart'; import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/infrastructure/database/signed_consent_log.dart'; /// Implementação de [OnboardingStore] sobre o ORM do Serverpod. /// @@ -93,29 +94,9 @@ class OrmOnboardingStore implements OnboardingStore { @override Future recordConsent(ConsentLogEntry entry) async { - final signature = _signature.compute( - userId: entry.userId, - purpose: entry.purpose.name, - action: entry.action, - version: entry.version, - timestamp: entry.timestamp, - ); await ConsentLog.db.insertRow( _session(), - ConsentLog( - userId: UuidValue.fromString(entry.userId), - purpose: entry.purpose.name, - action: entry.action, - version: entry.version, - timestamp: entry.timestamp, - // IP e user agent não se aplicam a este evento de domínio — o - // request HTTP em si já é auditado em audit_logs por outros - // caminhos; aqui os campos exigidos pelo schema ficam com um - // marcador explícito de ausência, não um valor fabricado. - ipHash: 'nao-aplicavel-onboarding', - userAgent: 'nao-aplicavel-onboarding', - signature: signature, - ), + signedConsentLog(entry, signature: _signature, origin: 'onboarding'), transaction: _transaction, ); } diff --git a/backend/sinalacs_server/lib/src/infrastructure/database/orm_patient_data_overview_store.dart b/backend/sinalacs_server/lib/src/infrastructure/database/orm_patient_data_overview_store.dart index 1372f27a..c476bdba 100644 --- a/backend/sinalacs_server/lib/src/infrastructure/database/orm_patient_data_overview_store.dart +++ b/backend/sinalacs_server/lib/src/infrastructure/database/orm_patient_data_overview_store.dart @@ -3,12 +3,16 @@ import 'package:sinalacs_server/src/application/patients/patient_data_overview_s import 'package:sinalacs_server/src/generated/protocol.dart'; import 'package:sinalacs_server/src/infrastructure/crypto/encrypted_json.dart'; import 'package:sinalacs_server/src/infrastructure/crypto/health_data_cipher.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_data_subject_rights_store.dart'; /// Implementação de [PatientDataOverviewStore] sobre o ORM do Serverpod. /// -/// Agrega quatro consultas independentes — `patients`, `users`, -/// `consent_logs` e o histórico de risco de `triage_sessions`/`alerts` — num -/// único snapshot. Só decifra `chronicConditionsEncrypted`; o conteúdo bruto +/// Agrega cinco consultas independentes — `patients`, `users`, +/// `consent_logs`, o histórico de risco de `triage_sessions`/`alerts` e +/// `data_subject_requests` — num único snapshot. Decifra +/// `chronicConditionsEncrypted`, `emergencyContactEncrypted` (PII de +/// terceiro) e o `detailsEncrypted` dos pedidos, texto que o próprio titular +/// escreveu; o conteúdo bruto /// de uma triagem (`answersEncrypted`) e a localização de um alerta nunca /// entram no snapshot, por minimização (spec/lgpd_design.md). class OrmPatientDataOverviewStore implements PatientDataOverviewStore { @@ -37,6 +41,16 @@ class OrmPatientDataOverviewStore implements PatientDataOverviewStore { final chronicConditions = decoded == null ? const [] : (decoded as List).cast(); + // Mesmo padrão de cifragem de chronicConditions (RNF03, INV-04): + // ciphertext vazio = linha nunca escrita por caminho Dart (seed SQL) — e o + // DTO de domínio sempre teve `emergencyContact` como String não-nula, então + // a ausência vira string vazia, não null. + final decodedContact = await _cipher.decryptJson( + patient.emergencyContactEncrypted, + patient.emergencyContactKeyVersion, + ); + final emergencyContact = (decodedContact as String?) ?? ''; + final consentRows = await ConsentLog.db.find( session, where: (t) => t.userId.equals(id), @@ -52,6 +66,15 @@ class OrmPatientDataOverviewStore implements PatientDataOverviewStore { where: (t) => t.patientId.equals(id), orderBy: (t) => t.triggeredAt, ); + final requestRows = await DataSubjectRequest.db.find( + session, + where: (t) => t.userId.equals(id), + orderBy: (t) => t.createdAt, + orderDescending: true, + ); + final requests = [ + for (final row in requestRows) await dataSubjectRequestSnapshotOf(row, _cipher), + ]; final riskHistory = [ for (final row in triageRows) @@ -67,7 +90,7 @@ class OrmPatientDataOverviewStore implements PatientDataOverviewStore { // `createdAt`, por isso o epoch como sentinela óbvio de dado ausente. name: user?.name ?? '', birthDate: user?.birthDate ?? DateTime.utc(1970), - emergencyContact: patient.emergencyContact, + emergencyContact: emergencyContact, isChronic: patient.isChronic, chronicConditions: chronicConditions, consents: [ @@ -80,6 +103,7 @@ class OrmPatientDataOverviewStore implements PatientDataOverviewStore { ), ], riskHistory: riskHistory, + requests: requests, ); } } 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 index 3f5b8fb8..3ab5efdc 100644 --- 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 @@ -1,8 +1,12 @@ import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/patients/data_subject_rights_service.dart' + show removedAccountMessage; import 'package:sinalacs_server/src/application/patients/patient_directory_service.dart'; import 'package:sinalacs_server/src/generated/protocol.dart'; import 'package:sinalacs_server/src/infrastructure/crypto/encrypted_json.dart'; import 'package:sinalacs_server/src/infrastructure/crypto/health_data_cipher.dart'; +import 'package:sinalacs_server/src/infrastructure/database/removed_account.dart'; +import 'package:sinalacs_server/src/infrastructure/database/subject_lock.dart'; /// Implementação de [PatientDirectoryStore] sobre o ORM do Serverpod. /// @@ -102,15 +106,22 @@ class OrmPatientDirectoryStore implements PatientDirectoryStore { }) async { final session = _session(); final id = UuidValue.fromString(patientId); - final patient = await Patient.db.findById(session, id); - if (patient == null) { - throw StateError('Paciente $patientId não encontrado.'); - } - final encrypted = await _cipher.encryptJson(conditions); - patient - ..chronicConditionsEncrypted = encrypted.ciphertextBase64 - ..chronicConditionsKeyVersion = encrypted.keyVersion; - await Patient.db.updateRow(session, patient); + // Sob o lock por titular (namespace 1), o mesmo da anonimização (#42): uma + // conta anonimizada com JWT ainda vivo não regrava dado de saúde. + await session.db.transaction((transaction) async { + await lockPerSubject(session, transaction, namespace: lockNamespaceDeletion, key: patientId); + if (await isRemovedAccount(session, id, transaction: transaction)) { + throw StateError(removedAccountMessage); + } + final patient = await Patient.db.findById(session, id, transaction: transaction); + if (patient == null) { + throw StateError('Paciente $patientId não encontrado.'); + } + patient + ..chronicConditionsEncrypted = encrypted.ciphertextBase64 + ..chronicConditionsKeyVersion = encrypted.keyVersion; + await Patient.db.updateRow(session, patient, transaction: transaction); + }); } } diff --git a/backend/sinalacs_server/lib/src/infrastructure/database/orm_push_token_store.dart b/backend/sinalacs_server/lib/src/infrastructure/database/orm_push_token_store.dart new file mode 100644 index 00000000..0696ada6 --- /dev/null +++ b/backend/sinalacs_server/lib/src/infrastructure/database/orm_push_token_store.dart @@ -0,0 +1,107 @@ +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/patients/push_token_service.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/infrastructure/database/subject_lock.dart'; + +/// Implementação de [PushTokenStore] sobre o ORM do Serverpod. +class OrmPushTokenStore implements PushTokenStore { + OrmPushTokenStore({required Session Function() session}) : _session = session; + + final Session Function() _session; + + /// Duas travas, sempre nesta ordem: por titular (fecha a janela entre ler o + /// consentimento e gravar, contra a revogação) e depois por token (o índice + /// único recusaria o segundo inserto de uma corrida, e aqui o segundo deve + /// virar atualização). Só se espera pela trava do token com a do titular na + /// mão, e quem a segura a solta no fim da própria transação: não há ciclo. + @override + Future registerIfConsented({ + required String userId, + required String? microAreaId, + required String token, + required String platform, + required DateTime now, + }) async { + final session = _session(); + final userUuid = UuidValue.fromString(userId); + final areaUuid = microAreaId == null ? null : UuidValue.fromString(microAreaId); + return session.db.transaction((transaction) async { + await lockPerSubject(session, transaction, namespace: lockNamespacePushToken, key: userId); + await lockPerSubject(session, transaction, namespace: lockNamespacePushTokenRow, key: token); + final consent = await ConsentLog.db.findFirstRow( + session, + where: (t) => + t.userId.equals(userUuid) & t.purpose.equals(ConsentPurpose.segmentedPush.name), + // Desempate por id: dois consentimentos no mesmo instante não podem deixar + // a leitura não determinística. + orderByList: (t) => [ + Order(column: t.timestamp, orderDescending: true), + Order(column: t.id, orderDescending: true), + ], + transaction: transaction, + ); + final existing = await PushToken.db.findFirstRow( + session, + where: (t) => t.token.equals(token), + transaction: transaction, + ); + if (consent == null || consent.action != 'granted') { + if (existing != null && existing.userId != userUuid) { + await PushToken.db.deleteRow(session, existing, transaction: transaction); + } + return const PushRegistrationResult(PushRegistration.refused); + } + final ownerChanged = existing != null && existing.userId != userUuid; + final previousOwnerId = ownerChanged ? existing.userId.uuid : null; + if (existing != null) { + await PushToken.db.updateRow( + session, + existing.copyWith( + userId: userUuid, + microAreaId: areaUuid, + platform: platform, + updatedAt: now, + ), + transaction: transaction, + ); + } else { + await PushToken.db.insertRow( + session, + PushToken( + userId: userUuid, + microAreaId: areaUuid, + token: token, + platform: platform, + createdAt: now, + updatedAt: now, + ), + transaction: transaction, + ); + } + // Teto por titular. Poupa sempre o token que acabou de entrar (um relógio que + // voltou o faria parecer o mais antigo) e apaga por id E titular: se outro + // registro roubou um desses tokens entre a leitura e o apagamento, a linha + // já é de outro e não pode ser levada por esta poda. + final mine = await PushToken.db.find( + session, + where: (t) => t.userId.equals(userUuid) & t.token.notEquals(token), + orderByList: (t) => [ + Order(column: t.updatedAt, orderDescending: true), + Order(column: t.createdAt, orderDescending: true), + ], + transaction: transaction, + ); + final doomed = mine.skip(maxPushTokensPerUser - 1).map((t) => t.id!).toSet(); + if (doomed.isNotEmpty) { + await PushToken.db.deleteWhere( + session, + where: (t) => t.id.inSet(doomed) & t.userId.equals(userUuid), + transaction: transaction, + ); + } + return ownerChanged + ? PushRegistrationResult(PushRegistration.ownerChanged, previousOwnerId: previousOwnerId) + : const PushRegistrationResult(PushRegistration.registered); + }); + } +} diff --git a/backend/sinalacs_server/lib/src/infrastructure/database/orm_refresh_token_store.dart b/backend/sinalacs_server/lib/src/infrastructure/database/orm_refresh_token_store.dart new file mode 100644 index 00000000..f6a6dd0c --- /dev/null +++ b/backend/sinalacs_server/lib/src/infrastructure/database/orm_refresh_token_store.dart @@ -0,0 +1,159 @@ +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/auth/refresh_token_service.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; + +/// Implementação do `RefreshTokenStore` sobre o ORM do Serverpod. +/// +/// Mesmo arranjo de `OrmAcsCredentialStore`: o `Session` vem por chamada e o +/// que o ORM não expressa de forma atômica (escrita condicional com contagem +/// de linhas) é uma única instrução SQL. Só o SHA-256 do token chega aqui; o +/// token em claro nunca é visto por este store. +/// Quando [transaction] é fornecida, todas as escritas participam dela — mesmo +/// arranjo de `OrmAlertStore`: é o que permite a desativação (#43) gravar a +/// flag do ACS e revogar os tokens na **mesma** transação, sem que este store +/// conheça o caso de uso. +class OrmRefreshTokenStore implements RefreshTokenStore { + OrmRefreshTokenStore({ + required Session Function() session, + Transaction? transaction, + }) : _session = session, + _transaction = transaction; + + final Session Function() _session; + final Transaction? _transaction; + + /// Uma instrução: trava as linhas da família (`FOR UPDATE`) e só insere se + /// nenhuma delas está revogada. Se um `revokeFamily` concorrente já travou a + /// linha, o `FOR UPDATE` espera, relê a linha já revogada e recusa — não há + /// janela entre "checar" e "inserir" em que a revogação passe despercebida. + @override + Future insert(RefreshTokenRecord record, String tokenHash) async { + final linhas = await _session().db.unsafeExecute( + ''' + WITH familia AS ( + SELECT "revokedAt" FROM "acs_refresh_tokens" + WHERE "familyId" = @familyId::uuid + FOR UPDATE + ) + INSERT INTO "acs_refresh_tokens" + ("id", "userId", "familyId", "tokenHash", "deviceId", "issuedAt", + "idleExpiresAt", "absoluteExpiresAt", "rotatedAt", "revokedAt") + SELECT @id::uuid, @userId::uuid, @familyId::uuid, @tokenHash::text, + @deviceId::text, @issuedAt::timestamp, @idleExpiresAt::timestamp, + @absoluteExpiresAt::timestamp, NULL, NULL + WHERE NOT EXISTS (SELECT 1 FROM familia WHERE "revokedAt" IS NOT NULL); + ''', + parameters: QueryParameters.named({ + 'id': record.id, + 'userId': record.userId, + 'familyId': record.familyId, + 'tokenHash': tokenHash, + 'deviceId': record.deviceId, + 'issuedAt': record.issuedAt, + 'idleExpiresAt': record.idleExpiresAt, + 'absoluteExpiresAt': record.absoluteExpiresAt, + }), + transaction: _transaction, + ); + return linhas == 1; + } + + @override + Future findByHash(String tokenHash) async { + final row = await AcsRefreshToken.db.findFirstRow( + _session(), + where: (t) => t.tokenHash.equals(tokenHash), + transaction: _transaction, + ); + if (row == null) return null; + return RefreshTokenRecord( + id: row.id!.uuid, + userId: row.userId.uuid, + familyId: row.familyId.uuid, + deviceId: row.deviceId, + issuedAt: row.issuedAt, + idleExpiresAt: row.idleExpiresAt, + absoluteExpiresAt: row.absoluteExpiresAt, + rotatedAt: row.rotatedAt, + revokedAt: row.revokedAt, + ); + } + + @override + Future markRotated(String id, DateTime at) async { + final linhas = await _session().db.unsafeExecute( + 'UPDATE "acs_refresh_tokens" SET "rotatedAt" = @at ' + 'WHERE "id" = @id::uuid AND "rotatedAt" IS NULL AND "revokedAt" IS NULL;', + parameters: QueryParameters.named({'at': at, 'id': id}), + transaction: _transaction, + ); + return linhas == 1; + } + + /// Duas passadas, de propósito. Sob READ COMMITTED a `UPDATE` enxerga só as + /// linhas do seu instantâneo: um `insert` concorrente que travou a família + /// primeiro e confirmou depois do início desta instrução cria um filho que + /// a primeira passada não vê. A segunda passada, com instantâneo novo, o + /// alcança — sem ela, o filho ficaria vigente depois da revogação. (O + /// inverso, `insert` depois da revogação, é barrado pelo próprio `insert`.) + @override + Future revokeFamily(String familyId, DateTime at) async { + for (var passada = 0; passada < 2; passada++) { + await _session().db.unsafeExecute( + 'UPDATE "acs_refresh_tokens" SET "revokedAt" = @at ' + 'WHERE "familyId" = @familyId::uuid AND "revokedAt" IS NULL;', + parameters: QueryParameters.named({'at': at, 'familyId': familyId}), + transaction: _transaction, + ); + } + } + + /// Todas as famílias do usuário — a desativação da conta (#43); o `logout` + /// continua revogando só a própria família. + /// + /// Uma instrução só, e não as duas passadas de [revokeFamily]: aqui a + /// revogação não depende de alcançar um filho inserido depois dela, porque + /// quem impede um filho novo é o próprio `insert` (recusa a família revogada) + /// e a emissão de família nova exige um login, que a conta inativa já barra. + @override + Future revokeAllForUser(String userId, DateTime at) async { + await _session().db.unsafeExecute( + 'UPDATE "acs_refresh_tokens" SET "revokedAt" = @at ' + 'WHERE "userId" = @userId::uuid AND "revokedAt" IS NULL;', + parameters: QueryParameters.named({'at': at, 'userId': userId}), + transaction: _transaction, + ); + } + + /// Só devolve conta de um ACS: o serviço fixa o papel ACS na renovação, então + /// um usuário sem linha em `acs` (paciente, por exemplo) não pode renovar. + @override + Future findAccount(String userId) async { + final session = _session(); + final id = UuidValue.fromString(userId); + final acs = await Acs.db.findFirstRow( + session, + where: (t) => t.id.equals(id), + transaction: _transaction, + ); + if (acs == null) return null; + final user = await User.db.findFirstRow( + session, + where: (t) => t.id.equals(id), + transaction: _transaction, + ); + if (user == null) return null; + return RefreshAccount(active: acs.active, microAreaId: user.microAreaId?.uuid); + } + + @override + Future deleteExpiredFor(String userId, DateTime before) async { + await AcsRefreshToken.db.deleteWhere( + _session(), + where: (t) => + t.userId.equals(UuidValue.fromString(userId)) & + (t.absoluteExpiresAt < before), + transaction: _transaction, + ); + } +} diff --git a/backend/sinalacs_server/lib/src/infrastructure/database/orm_retention_store.dart b/backend/sinalacs_server/lib/src/infrastructure/database/orm_retention_store.dart new file mode 100644 index 00000000..19ff5058 --- /dev/null +++ b/backend/sinalacs_server/lib/src/infrastructure/database/orm_retention_store.dart @@ -0,0 +1,161 @@ +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/retention/expurge_service.dart'; + +/// Implementação de [RetentionStore] sobre SQL cru. +/// +/// O lote de DELETEs não tem equivalente expressivo no ORM (subquery `IN` + +/// `COALESCE`), e o SQL cru mantém contagem + remoção numa ÚNICA transação, +/// sob advisory lock — duas rodadas concorrentes (ou um boot duplicado) não +/// podem contar+deletar ao mesmo tempo. +class OrmRetentionStore implements RetentionStore { + OrmRetentionStore({required Session Function() session}) : _session = session; + + final Session Function() _session; + + /// Chave própria do advisory lock do expurgo — irmã da de `OrmAuditTrail`, + /// mas independente: expurgo e apêndice de auditoria não se serializam. + static const _expurgeLockKey = 725100824; + + /// D7 (#44): alerta vermelho ainda `pending` nunca é expurgado — o prazo de + /// retenção não pode descartar um chamado de urgência sem desfecho. O mesmo + /// predicado vale para o pai e para os filhos (nenhum filho de alerta + /// retido sai, e nenhuma FK quebra). + static const _expurgavel = + '"triggeredAt" < @cutoff ' + 'AND NOT ("riskLevel" = \'red\' AND "status" = \'pending\')'; + + @override + Future purgeDue(RetentionCutoffs cutoffs) async { + final session = _session(); + return session.db.transaction((transaction) async { + await session.db.unsafeExecute( + 'SELECT pg_advisory_xact_lock(@key);', + parameters: QueryParameters.named({'key': _expurgeLockKey}), + transaction: transaction, + ); + + // B3: contagens ANTES dos DELETEs — são o único resíduo do que saiu. + final counts = ExpurgeCounts( + alerts: await _count( + 'SELECT COUNT(*) FROM alerts WHERE $_expurgavel', + cutoffs.alerts, + transaction, + ), + alertDeliveries: await _count( + 'SELECT COUNT(*) FROM alert_deliveries WHERE "alertId" IN ' + '(SELECT id FROM alerts WHERE $_expurgavel)', + cutoffs.alerts, + transaction, + ), + alertIdempotencyKeys: await _count( + 'SELECT COUNT(*) FROM alert_idempotency_keys WHERE "alertId" IN ' + '(SELECT id FROM alerts WHERE $_expurgavel)', + cutoffs.alerts, + transaction, + ), + alertOutbox: await _count( + 'SELECT COUNT(*) FROM alert_outbox WHERE "alertId" IN ' + '(SELECT id FROM alerts WHERE $_expurgavel)', + cutoffs.alerts, + transaction, + ), + visits: await _count( + 'SELECT COUNT(*) FROM visits ' + 'WHERE COALESCE("completedAt", "scheduledAt") < @cutoff', + cutoffs.visits, + transaction, + ), + triageSessions: await _count( + 'SELECT COUNT(*) FROM triage_sessions WHERE "createdAt" < @cutoff', + cutoffs.triageSessions, + transaction, + ), + alertsByRisk: await _alertsByRisk(cutoffs.alerts, transaction), + skippedPendingRed: await _count( + 'SELECT COUNT(*) FROM alerts WHERE "triggeredAt" < @cutoff ' + 'AND "riskLevel" = \'red\' AND "status" = \'pending\'', + cutoffs.alerts, + transaction, + ), + ); + + // Ordem importa: as FKs dos dependentes de alerts são ON DELETE NO + // ACTION, então saem antes do alerta pai. + await _delete( + 'DELETE FROM alert_deliveries WHERE "alertId" IN ' + '(SELECT id FROM alerts WHERE $_expurgavel)', + cutoffs.alerts, + transaction, + ); + await _delete( + 'DELETE FROM alert_idempotency_keys WHERE "alertId" IN ' + '(SELECT id FROM alerts WHERE $_expurgavel)', + cutoffs.alerts, + transaction, + ); + await _delete( + 'DELETE FROM alert_outbox WHERE "alertId" IN ' + '(SELECT id FROM alerts WHERE $_expurgavel)', + cutoffs.alerts, + transaction, + ); + await _delete( + 'DELETE FROM alerts WHERE $_expurgavel', + cutoffs.alerts, + transaction, + ); + // Visitas: `completedAt` quando existe (data real do cuidado), senão + // `scheduledAt` (NOT NULL) — nenhuma linha escapa por NULL. + await _delete( + 'DELETE FROM visits ' + 'WHERE COALESCE("completedAt", "scheduledAt") < @cutoff', + cutoffs.visits, + transaction, + ); + await _delete( + 'DELETE FROM triage_sessions WHERE "createdAt" < @cutoff', + cutoffs.triageSessions, + transaction, + ); + + return counts; + }); + } + + Future _count( + String sql, + DateTime cutoff, + Transaction transaction, + ) async { + final rows = await _session().db.unsafeQuery( + sql, + parameters: QueryParameters.named({'cutoff': cutoff}), + transaction: transaction, + ); + return rows.single.single as int; + } + + Future _delete( + String sql, + DateTime cutoff, + Transaction transaction, + ) => + _session().db.unsafeExecute( + sql, + parameters: QueryParameters.named({'cutoff': cutoff}), + transaction: transaction, + ); + + Future> _alertsByRisk( + DateTime cutoff, + Transaction transaction, + ) async { + final rows = await _session().db.unsafeQuery( + 'SELECT "riskLevel", COUNT(*) FROM alerts ' + 'WHERE $_expurgavel GROUP BY "riskLevel"', + parameters: QueryParameters.named({'cutoff': cutoff}), + transaction: transaction, + ); + return {for (final row in rows) row[0] as String: row[1] as int}; + } +} diff --git a/backend/sinalacs_server/lib/src/infrastructure/database/orm_scheduled_notice_store.dart b/backend/sinalacs_server/lib/src/infrastructure/database/orm_scheduled_notice_store.dart new file mode 100644 index 00000000..b7389239 --- /dev/null +++ b/backend/sinalacs_server/lib/src/infrastructure/database/orm_scheduled_notice_store.dart @@ -0,0 +1,232 @@ +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/notices/notice_scheduling_service.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; + +/// [ScheduledNoticeStore] sobre o Postgres (#46). +/// +/// As transições de estado (`pending -> sending`, cancelamento, varredura) são +/// UMA instrução SQL cada, com parâmetros nomeados, para serem atômicas entre +/// instâncias e entre o laço de disparo e o cancelamento do backoffice. +class OrmScheduledNoticeStore implements ScheduledNoticeStore { + OrmScheduledNoticeStore({required Session Function() session}) : _session = session; + + final Session Function() _session; + + static const _columns = + '"id", "createdBy", "microAreaId", "audience", "title", "message", ' + '"scheduledFor", "status", "recipients", "accepted", "failureReason", ' + '"createdAt", "sentAt"'; + + static const _historyColumns = + 's."id", s."createdBy", s."microAreaId", s."audience", s."title", ' + 's."message", s."scheduledFor", s."status", s."recipients", s."accepted", ' + 's."failureReason", s."createdAt", s."sentAt", ' + 'm."name" AS "microAreaName", sa."enrollmentId" AS "createdByLabel"'; + + @override + Future ubsOfStaff(String userId) async { + final rows = await _session().db.unsafeQuery( + 'SELECT "ubsId" FROM staff_accounts WHERE "id" = @id::uuid', + parameters: QueryParameters.named({'id': userId}), + ); + if (rows.isEmpty) return null; + return rows.first.toColumnMap()['ubsId']?.toString(); + } + + @override + Future ubsOfMicroArea(String microAreaId) async { + final rows = await _session().db.unsafeQuery( + 'SELECT "ubsId" FROM micro_areas WHERE "id" = @id::uuid', + parameters: QueryParameters.named({'id': microAreaId}), + ); + if (rows.isEmpty) return null; + return rows.first.toColumnMap()['ubsId']?.toString(); + } + + @override + Future authorityOf(String userId) async { + final rows = await _session().db.unsafeQuery( + 'SELECT u."role", sa."active", sa."ubsId" FROM users u ' + 'JOIN staff_accounts sa ON sa."id" = u."id" WHERE u."id" = @id::uuid', + parameters: QueryParameters.named({'id': userId}), + ); + if (rows.isEmpty) return null; + final m = rows.first.toColumnMap(); + return StaffAuthority( + // `UserRole` é `serialized: byName`: a coluna guarda o nome. + role: UserRole.fromJson(m['role'] as String), + active: m['active'] as bool, + ubsId: m['ubsId']?.toString(), + ); + } + + @override + Future insert({ + required String createdBy, + required String microAreaId, + required String audience, + required String title, + required String message, + required DateTime scheduledFor, + required DateTime createdAt, + }) async { + final row = await ScheduledNotice.db.insertRow( + _session(), + ScheduledNotice( + createdBy: UuidValue.fromString(createdBy), + microAreaId: UuidValue.fromString(microAreaId), + audience: audience, + title: title, + message: message, + scheduledFor: scheduledFor, + status: ScheduledNoticeStatus.pending, + createdAt: createdAt, + ), + ); + return row.id!.toString(); + } + + @override + Future findById(String id) async { + final rows = await _session().db.unsafeQuery( + 'SELECT $_columns FROM scheduled_notices WHERE "id" = @id::uuid', + parameters: QueryParameters.named({'id': id}), + ); + return rows.isEmpty ? null : _snapshot(rows.first.toColumnMap()); + } + + @override + Future cancelIfPending(String id) async { + final rows = await _session().db.unsafeQuery( + ''' + UPDATE scheduled_notices SET "status" = 'cancelled' + WHERE "id" = @id::uuid AND "status" = 'pending' + RETURNING "id" + ''', + parameters: QueryParameters.named({'id': id}), + ); + return rows.length == 1; + } + + @override + Future> claimDue(DateTime now, {int limit = 20}) async { + final rows = await _session().db.unsafeQuery( + ''' + UPDATE scheduled_notices SET "status" = 'sending' + WHERE "id" IN ( + SELECT "id" FROM scheduled_notices + WHERE "status" = 'pending' AND "scheduledFor" <= @now + ORDER BY "scheduledFor" + LIMIT @lim + FOR UPDATE SKIP LOCKED + ) + RETURNING $_columns + ''', + parameters: QueryParameters.named({'now': now.toUtc(), 'lim': limit}), + ); + final claimed = [for (final r in rows) _snapshot(r.toColumnMap())]; + claimed.sort((a, b) => a.scheduledFor.compareTo(b.scheduledFor)); + return claimed; + } + + @override + Future finish( + String id, { + required ScheduledNoticeStatus status, + int? recipients, + int? accepted, + String? failureReason, + required DateTime at, + }) async { + await _session().db.unsafeExecute( + ''' + UPDATE scheduled_notices + SET "status" = @status::text, "recipients" = @recipients::bigint, + "accepted" = @accepted::bigint, "failureReason" = @reason::text, + "sentAt" = @sentAt::timestamp + WHERE "id" = @id::uuid + ''', + parameters: QueryParameters.named({ + 'id': id, + 'status': status.name, + 'recipients': recipients, + 'accepted': accepted, + 'reason': failureReason, + 'sentAt': status == ScheduledNoticeStatus.sent ? at.toUtc() : null, + }), + ); + } + + @override + Future> markStaleSendingUnknown({ + required DateTime before, + required String reason, + required DateTime at, + }) async { + final rows = await _session().db.unsafeQuery( + ''' + UPDATE scheduled_notices + SET "status" = 'unknown', "failureReason" = @reason + WHERE "status" = 'sending' AND "scheduledFor" < @before + RETURNING "id", "createdBy" + ''', + parameters: QueryParameters.named({'reason': reason, 'before': before.toUtc()}), + ); + return [ + for (final r in rows) + StuckNotice( + id: r.toColumnMap()['id'].toString(), + createdBy: r.toColumnMap()['createdBy'].toString(), + ), + ]; + } + + @override + Future> history({ + String? microAreaId, + String? ubsId, + required int limit, + required int offset, + }) async { + final rows = await _session().db.unsafeQuery( + ''' + SELECT $_historyColumns + FROM scheduled_notices s + JOIN micro_areas m ON m."id" = s."microAreaId" + LEFT JOIN staff_accounts sa ON sa."id" = s."createdBy" + WHERE (@micro::uuid IS NULL OR s."microAreaId" = @micro::uuid) + AND (@ubs::uuid IS NULL OR m."ubsId" = @ubs::uuid) + ORDER BY s."scheduledFor" DESC, s."id" DESC + LIMIT @lim OFFSET @off + ''', + parameters: QueryParameters.named({ + 'micro': microAreaId, + 'ubs': ubsId, + 'lim': limit, + 'off': offset, + }), + ); + return [for (final r in rows) _snapshot(r.toColumnMap())]; + } + + ScheduledNoticeSnapshot _snapshot(Map c) => ScheduledNoticeSnapshot( + id: c['id'].toString(), + createdBy: c['createdBy'].toString(), + microAreaId: c['microAreaId'].toString(), + audience: c['audience'] as String, + title: c['title'] as String, + message: c['message'] as String, + scheduledFor: _utc(c['scheduledFor'] as DateTime), + status: ScheduledNoticeStatus.values.byName(c['status'] as String), + recipients: c['recipients'] as int?, + accepted: c['accepted'] as int?, + failureReason: c['failureReason'] as String?, + createdAt: _utc(c['createdAt'] as DateTime), + sentAt: c['sentAt'] == null ? null : _utc(c['sentAt'] as DateTime), + microAreaName: c['microAreaName'] as String?, + createdByLabel: c['createdByLabel'] as String?, + ); + + static DateTime _utc(DateTime d) => d.isUtc ? d : DateTime.utc( + d.year, d.month, d.day, d.hour, d.minute, d.second, d.millisecond, d.microsecond); +} diff --git a/backend/sinalacs_server/lib/src/infrastructure/database/orm_terms_version_store.dart b/backend/sinalacs_server/lib/src/infrastructure/database/orm_terms_version_store.dart new file mode 100644 index 00000000..bbc6d5ef --- /dev/null +++ b/backend/sinalacs_server/lib/src/infrastructure/database/orm_terms_version_store.dart @@ -0,0 +1,135 @@ +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/patients/terms_publication_service.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; + +/// [TermsVersionStore] sobre o Postgres (#46). +/// +/// Revisar e publicar são UMA instrução SQL cada, condicionada ao estado, para +/// serem atômicas entre instâncias. As colunas são `timestamp without time +/// zone`: tudo entra e sai em UTC. Um id que não é UUID devolve `null`/`false` +/// em vez de estourar o cast do Postgres. +class OrmTermsVersionStore implements TermsVersionStore { + OrmTermsVersionStore({required Session Function() session}) : _session = session; + + final Session Function() _session; + + static const _columns = + '"id", "version", "summary", "effectiveFrom", "createdBy", "createdAt", ' + '"legallyReviewedAt", "legallyReviewedBy", "publishedAt", "publishedBy"'; + + static final _uuid = RegExp( + r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$', + ); + + @override + Future insertDraft({ + required String version, + required String summary, + required DateTime effectiveFrom, + required String createdBy, + required DateTime createdAt, + }) async { + final row = await TermsVersion.db.insertRow( + _session(), + TermsVersion( + version: version, + summary: summary, + effectiveFrom: effectiveFrom.toUtc(), + createdBy: UuidValue.fromString(createdBy), + createdAt: createdAt.toUtc(), + ), + ); + return row.id!.toString(); + } + + @override + Future findById(String id) async { + if (!_uuid.hasMatch(id)) return null; + final rows = await _session().db.unsafeQuery( + 'SELECT $_columns FROM terms_versions WHERE "id" = @id::uuid', + parameters: QueryParameters.named({'id': id}), + ); + return rows.isEmpty ? null : _snapshot(rows.first.toColumnMap()); + } + + @override + Future versionExists(String version) async { + final rows = await _session().db.unsafeQuery( + 'SELECT 1 FROM terms_versions WHERE "version" = @version', + parameters: QueryParameters.named({'version': version}), + ); + return rows.isNotEmpty; + } + + @override + Future markReviewed(String id, {required String reviewedBy, required DateTime at}) async { + if (!_uuid.hasMatch(id) || !_uuid.hasMatch(reviewedBy)) return false; + final rows = await _session().db.unsafeQuery( + ''' + UPDATE terms_versions + SET "legallyReviewedAt" = @at, "legallyReviewedBy" = @by::uuid + WHERE "id" = @id::uuid AND "legallyReviewedAt" IS NULL AND "publishedAt" IS NULL + RETURNING "id" + ''', + parameters: QueryParameters.named({'id': id, 'by': reviewedBy, 'at': at.toUtc()}), + ); + return rows.length == 1; + } + + @override + Future markPublished(String id, {required String publishedBy, required DateTime at}) async { + if (!_uuid.hasMatch(id) || !_uuid.hasMatch(publishedBy)) return false; + final rows = await _session().db.unsafeQuery( + ''' + UPDATE terms_versions + SET "publishedAt" = @at, "publishedBy" = @by::uuid + WHERE "id" = @id::uuid AND "legallyReviewedAt" IS NOT NULL AND "publishedAt" IS NULL + RETURNING "id" + ''', + parameters: QueryParameters.named({'id': id, 'by': publishedBy, 'at': at.toUtc()}), + ); + return rows.length == 1; + } + + @override + Future> list() async { + final rows = await _session().db.unsafeQuery( + 'SELECT $_columns FROM terms_versions ORDER BY "effectiveFrom" DESC, "createdAt" DESC', + ); + return [for (final r in rows) _snapshot(r.toColumnMap())]; + } + + @override + Future currentPublished(DateTime now) async { + final rows = await _session().db.unsafeQuery( + ''' + SELECT $_columns FROM terms_versions + WHERE "publishedAt" IS NOT NULL + AND "publishedAt" <= @now + AND "effectiveFrom" > @now + ORDER BY "publishedAt" DESC, "createdAt" DESC + LIMIT 1 + ''', + parameters: QueryParameters.named({'now': now.toUtc()}), + ); + return rows.isEmpty ? null : _snapshot(rows.first.toColumnMap()); + } + + TermsVersionSnapshot _snapshot(Map m) => TermsVersionSnapshot( + id: m['id'].toString(), + version: m['version'] as String, + summary: m['summary'] as String, + effectiveFrom: _utc(m['effectiveFrom'])!, + createdAt: _utc(m['createdAt'])!, + legallyReviewedAt: _utc(m['legallyReviewedAt']), + legallyReviewedBy: m['legallyReviewedBy']?.toString(), + publishedAt: _utc(m['publishedAt']), + publishedBy: m['publishedBy']?.toString(), + ); + + DateTime? _utc(Object? v) { + if (v == null) return null; + final d = v as DateTime; + return d.isUtc ? d : DateTime.utc(d.year, d.month, d.day, d.hour, d.minute, d.second, d.millisecond, d.microsecond); + } +} diff --git a/backend/sinalacs_server/lib/src/infrastructure/database/orm_triage_session_store.dart b/backend/sinalacs_server/lib/src/infrastructure/database/orm_triage_session_store.dart index cee4d285..085a9576 100644 --- a/backend/sinalacs_server/lib/src/infrastructure/database/orm_triage_session_store.dart +++ b/backend/sinalacs_server/lib/src/infrastructure/database/orm_triage_session_store.dart @@ -3,6 +3,8 @@ import 'package:sinalacs_server/src/application/triage/triage_session_service.da import 'package:sinalacs_server/src/generated/protocol.dart'; import 'package:sinalacs_server/src/infrastructure/crypto/encrypted_json.dart'; import 'package:sinalacs_server/src/infrastructure/crypto/health_data_cipher.dart'; +import 'package:sinalacs_server/src/infrastructure/database/removed_account.dart'; +import 'package:sinalacs_server/src/infrastructure/database/subject_lock.dart'; /// Implementação de [TriageSessionStore] sobre o ORM do Serverpod. /// @@ -28,14 +30,37 @@ class OrmTriageSessionStore implements TriageSessionStore { final HealthDataCipher _cipher; final Transaction? _transaction; + /// Conta anonimizada por exclusão atendida (#42) com JWT ainda vivo: a + /// classificação já foi calculada e volta ao paciente igual (o serviço não + /// depende desta gravação, INV-02), mas nenhuma sessão nem resposta é + /// gravada — devolve `null`, o mesmo caminho de "sem id" que o serviço já + /// trata. Roda sob o lock por titular (namespace 1), o mesmo da + /// anonimização, que zera as respostas das sessões anteriores: uma sessão + /// gravada antes do commit dela é zerada, uma depois não nasce. @override Future insert(TriageSessionRecord session) async { final encrypted = await _cipher.encryptJson( [for (final answer in session.answers) answer.toJson()], ); + final db = _session(); + final external = _transaction; + if (external != null) return _insert(db, external, session, encrypted); + return db.db.transaction((transaction) => _insert(db, transaction, session, encrypted)); + } + Future _insert( + Session db, + Transaction transaction, + TriageSessionRecord session, + EncryptedValue encrypted, + ) async { + await lockPerSubject(db, transaction, + namespace: lockNamespaceDeletion, key: session.patientId.uuid); + if (await isRemovedAccount(db, session.patientId, transaction: transaction)) { + return null; + } final stored = await TriageSession.db.insertRow( - _session(), + db, TriageSession( patientId: session.patientId, answersEncrypted: encrypted.ciphertextBase64, @@ -45,7 +70,7 @@ class OrmTriageSessionStore implements TriageSessionStore { createdAt: session.createdAt, deviceId: session.deviceId, ), - transaction: _transaction, + transaction: transaction, ); return stored.id; } diff --git a/backend/sinalacs_server/lib/src/infrastructure/database/orm_ubs_contact_store.dart b/backend/sinalacs_server/lib/src/infrastructure/database/orm_ubs_contact_store.dart new file mode 100644 index 00000000..bdce030e --- /dev/null +++ b/backend/sinalacs_server/lib/src/infrastructure/database/orm_ubs_contact_store.dart @@ -0,0 +1,18 @@ +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/ubs/ubs_contact_service.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; + +class OrmUbsContactStore implements UbsContactStore { + OrmUbsContactStore(this._session); + + final Session _session; + + @override + Future findForAcs(String acsId) async { + final acs = await Acs.db.findById(_session, UuidValue.fromString(acsId)); + if (acs == null) return null; + final ubs = await Ubs.db.findById(_session, acs.ubsId); + if (ubs == null) return null; + return UbsContactRecord(name: ubs.name, phone: ubs.contactPhone); + } +} diff --git a/backend/sinalacs_server/lib/src/infrastructure/database/orm_upload_token_store.dart b/backend/sinalacs_server/lib/src/infrastructure/database/orm_upload_token_store.dart new file mode 100644 index 00000000..c7ed735c --- /dev/null +++ b/backend/sinalacs_server/lib/src/infrastructure/database/orm_upload_token_store.dart @@ -0,0 +1,148 @@ +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/auth/refresh_token_service.dart'; +import 'package:sinalacs_server/src/application/auth/upload_token_service.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/infrastructure/database/subject_lock.dart'; + +/// Implementação do `UploadTokenStore` sobre o ORM do Serverpod. +/// +/// Mesmo arranjo de `OrmRefreshTokenStore`: o `Session` vem por chamada e só o +/// SHA-256 do token chega aqui — o token em claro nunca é visto por este store. +/// +/// Quando [transaction] é fornecida, todas as escritas participam dela — é o +/// que permite a desativação (#43) revogar o envio diferido na mesma transação +/// da flag do ACS. +class OrmUploadTokenStore implements UploadTokenStore { + OrmUploadTokenStore({ + required Session Function() session, + Transaction? transaction, + }) : _session = session, + _transaction = transaction; + + final Session Function() _session; + final Transaction? _transaction; + + /// Revoga o vigente do par e insere o novo numa transação serializada por + /// (usuário, aparelho) com advisory lock: dois logins concorrentes do mesmo + /// aparelho não deixam dois tokens vigentes (sob READ COMMITTED, o `UPDATE` + /// de um não enxergaria a linha que o outro acabou de inserir). + @override + Future replace(UploadTokenRecord record, String tokenHash) async { + final session = _session(); + await _naTransacao(session, (transaction) async { + await lockPerSubject( + session, + transaction, + namespace: lockNamespaceUploadToken, + key: '${record.userId}|${record.deviceId}', + ); + await session.db.unsafeExecute( + 'UPDATE "acs_upload_tokens" SET "revokedAt" = @at ' + 'WHERE "userId" = @userId::uuid AND "deviceId" = @deviceId::text ' + 'AND "revokedAt" IS NULL;', + parameters: QueryParameters.named({ + 'at': record.issuedAt, + 'userId': record.userId, + 'deviceId': record.deviceId, + }), + transaction: transaction, + ); + await AcsUploadToken.db.insertRow( + session, + AcsUploadToken( + id: UuidValue.fromString(record.id), + userId: UuidValue.fromString(record.userId), + tokenHash: tokenHash, + deviceId: record.deviceId, + issuedAt: record.issuedAt, + expiresAt: record.expiresAt, + ), + transaction: transaction, + ); + }); + } + + /// Executa [body] na transação do construtor, quando ela existe, e numa + /// transação nova do banco quando não existe — mesmo arranjo de + /// `OrmAlertStore.acknowledge`: a instância amarrada a uma transação nunca + /// abre uma segunda, que ficaria fora (e invisível para) a primeira. + Future _naTransacao( + Session session, + Future Function(Transaction transaction) body, + ) { + final existente = _transaction; + return existente != null ? body(existente) : session.db.transaction(body); + } + + @override + Future findByHash(String tokenHash) async { + final row = await AcsUploadToken.db.findFirstRow( + _session(), + where: (t) => t.tokenHash.equals(tokenHash), + transaction: _transaction, + ); + if (row == null) return null; + return UploadTokenRecord( + id: row.id!.uuid, + userId: row.userId.uuid, + deviceId: row.deviceId, + issuedAt: row.issuedAt, + expiresAt: row.expiresAt, + revokedAt: row.revokedAt, + ); + } + + @override + Future revoke(String id, DateTime at) async { + await _session().db.unsafeExecute( + 'UPDATE "acs_upload_tokens" SET "revokedAt" = @at ' + 'WHERE "id" = @id::uuid AND "revokedAt" IS NULL;', + parameters: QueryParameters.named({'at': at, 'id': id}), + transaction: _transaction, + ); + } + + /// Todos os tokens do usuário — a desativação da conta (#43). Um token + /// emitido na janela de uma desativação concorrente já nasce inutilizável: + /// `resolve` relê a conta a cada uso e, inativa, revoga e recusa. + @override + Future revokeAllForUser(String userId, DateTime at) async { + await _session().db.unsafeExecute( + 'UPDATE "acs_upload_tokens" SET "revokedAt" = @at ' + 'WHERE "userId" = @userId::uuid AND "revokedAt" IS NULL;', + parameters: QueryParameters.named({'at': at, 'userId': userId}), + transaction: _transaction, + ); + } + + /// Só conta de ACS (mesma regra de `OrmRefreshTokenStore.findAccount`): um + /// usuário sem linha em `acs` nunca resolve um token de envio. + @override + Future findAccount(String userId) async { + final session = _session(); + final id = UuidValue.fromString(userId); + final acs = await Acs.db.findFirstRow( + session, + where: (t) => t.id.equals(id), + transaction: _transaction, + ); + if (acs == null) return null; + final user = await User.db.findFirstRow( + session, + where: (t) => t.id.equals(id), + transaction: _transaction, + ); + if (user == null) return null; + return RefreshAccount(active: acs.active, microAreaId: user.microAreaId?.uuid); + } + + @override + Future deleteExpiredFor(String userId, DateTime before) async { + await AcsUploadToken.db.deleteWhere( + _session(), + where: (t) => + t.userId.equals(UuidValue.fromString(userId)) & (t.expiresAt < before), + transaction: _transaction, + ); + } +} 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 index 80870929..6c1904d0 100644 --- a/backend/sinalacs_server/lib/src/infrastructure/database/orm_visit_store.dart +++ b/backend/sinalacs_server/lib/src/infrastructure/database/orm_visit_store.dart @@ -110,12 +110,20 @@ class OrmVisitStore implements VisitStore { return records; } + @override + Future isActiveAcs(UuidValue acsId) async { + final acs = await Acs.db.findById(_session(), acsId, transaction: _transaction); + return acs?.active ?? false; + } + Future _toRow(VisitRecord record) async { final encrypted = await _cipher.encryptJson(record.notes); return Visit( id: record.id, patientId: record.patientId, acsId: record.acsId, + authorship: record.authorship, + originDeviceId: record.originDeviceId, scheduledAt: record.scheduledAt, startedAt: record.startedAt, completedAt: record.completedAt, @@ -141,6 +149,8 @@ class OrmVisitStore implements VisitStore { id: row.id, patientId: row.patientId, acsId: row.acsId, + authorship: row.authorship, + originDeviceId: row.originDeviceId, scheduledAt: row.scheduledAt, startedAt: row.startedAt, completedAt: row.completedAt, diff --git a/backend/sinalacs_server/lib/src/infrastructure/database/removed_account.dart b/backend/sinalacs_server/lib/src/infrastructure/database/removed_account.dart new file mode 100644 index 00000000..8c547039 --- /dev/null +++ b/backend/sinalacs_server/lib/src/infrastructure/database/removed_account.dart @@ -0,0 +1,29 @@ +import 'package:serverpod/serverpod.dart'; + +/// Prefixo que a exclusão atendida (#42, `OrmDataSubjectCaseStore`) grava em +/// `users.cpfHash` no lugar do HMAC do CPF: `removed:` + UUID aleatório, que +/// nunca casa com o hash de um CPF de verdade. +const removedCpfHashPrefix = 'removed:'; + +/// `true` se a conta [userId] foi anonimizada por uma exclusão atendida. +/// +/// Existe para o JWT do paciente que ainda vive depois da anonimização (a +/// sessão dura 1 hora): sem esta consulta, ele continuaria abrindo pedidos e +/// gravando consentimentos de uma conta que não tem mais titular. É lida dentro +/// da transação de quem chama, depois do lock por titular, para não correr com +/// a anonimização, que segura os mesmos locks até o commit. +Future isRemovedAccount( + Session session, + UuidValue userId, { + Transaction? transaction, +}) async { + final rows = await session.db.unsafeQuery( + 'SELECT 1 FROM users WHERE id = @id::uuid AND starts_with("cpfHash", @prefixo)', + parameters: QueryParameters.named({ + 'id': userId.uuid, + 'prefixo': removedCpfHashPrefix, + }), + transaction: transaction, + ); + return rows.isNotEmpty; +} 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 426035ca..95f3028b 100644 --- a/backend/sinalacs_server/lib/src/infrastructure/database/seeds/development.sql +++ b/backend/sinalacs_server/lib/src/infrastructure/database/seeds/development.sql @@ -12,6 +12,10 @@ INSERT INTO "ubs" ("id", "name", "address", "city", "state") VALUES ('00000000-0000-4000-8000-000000000004', 'UBS Desenvolvimento', 'Endereço local', 'São Paulo', 'SP') ON CONFLICT ("id") DO NOTHING; +-- Telefone sintético (RF13). O UPDATE cobre bancos de dev criados antes da coluna. +UPDATE "ubs" SET "contactPhone" = '+55 11 5550-0100' +WHERE "id" = '00000000-0000-4000-8000-000000000004' AND "contactPhone" IS NULL; + INSERT INTO "micro_areas" ("id", "name", "ubsId", "geoJsonBoundary") VALUES ('00000000-0000-4000-8000-000000000003', 'Microárea 12', '00000000-0000-4000-8000-000000000004', '{}') ON CONFLICT ("id") DO NOTHING; @@ -29,29 +33,42 @@ VALUES ('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()) + ('00000000-0000-4000-8000-000000000009', 'development-patient-09', 'Paciente de Outra Área', '1970-01-01', 'patient', '00000000-0000-4000-8000-000000000099', NOW(), NOW()), + -- Admin do backoffice (issue #39): papel `admin`, sem microárea. O `cpfHash` é + -- um literal único que nenhum HMAC produz: ele entra por matrícula + senha + + -- TOTP (`auth.loginStaff`), nunca por CPF, e o `cpf-hash-seed` só faz UPDATE + -- nas linhas de paciente que lista, então não o toca. A senha vem de + -- DEV_ADMIN_PASSWORD (`seed_acs_credentials`). Sem TOTP semeado: a MFA do + -- staff é obrigatória e o primeiro acesso passa pela tela de ativação. + ('00000000-0000-4000-8000-000000000090', 'development-admin', 'Admin de desenvolvimento', '1985-01-01', 'admin', NULL, NOW(), NOW()) ON CONFLICT ("id") DO NOTHING; -- id = UUID do usuário paciente. -- --- `chronicConditions` virou `chronicConditionsEncrypted` (AES-256-GCM, RNF03 / --- INV-04). Este arquivo roda por `psql`, FORA do processo Dart, então não tem --- como chamar HealthDataCipher — as linhas entram com ciphertext vazio (que o --- store lê como "sem condições") e `bin/seed_health_data.dart` as completa --- logo depois, no serviço `health-data-seed` do docker-compose.yml. Colar um --- ciphertext literal aqui seria a alternativa, mas duplicaria a lógica de --- cifragem fora do Dart e quebraria em silêncio a cada troca de chave. -INSERT INTO "patients" ("id", "emergencyContact", "isChronic", "chronicConditionsEncrypted", "chronicConditionsKeyVersion") +-- `chronicConditions` e `emergencyContact` viraram pares `*Encrypted`/ +-- `*KeyVersion` (AES-256-GCM, RNF03 / INV-04). Este arquivo roda por `psql`, +-- FORA do processo Dart, então não tem como chamar HealthDataCipher — as +-- linhas entram com ciphertext vazio (que o store lê como "sem condições" e +-- "sem contato") e `bin/seed_health_data.dart` as completa logo depois, no +-- serviço `health-data-seed` do docker-compose.yml. Colar um ciphertext +-- literal aqui seria a alternativa, mas duplicaria a lógica de cifragem fora +-- do Dart e quebraria em silêncio a cada troca de chave. +INSERT INTO "patients" ("id", "emergencyContactEncrypted", "emergencyContactKeyVersion", "isChronic", "chronicConditionsEncrypted", "chronicConditionsKeyVersion") VALUES - ('00000000-0000-4000-8000-000000000001', 'Contato de desenvolvimento', false, '', 1), - ('00000000-0000-4000-8000-000000000005', 'Contato de desenvolvimento', true, '', 1), - ('00000000-0000-4000-8000-000000000006', 'Contato de desenvolvimento', false, '', 1), - ('00000000-0000-4000-8000-000000000007', 'Contato de desenvolvimento', true, '', 1), - ('00000000-0000-4000-8000-000000000008', 'Contato de desenvolvimento', false, '', 1), - ('00000000-0000-4000-8000-000000000009', 'Contato de desenvolvimento', false, '', 1) + ('00000000-0000-4000-8000-000000000001', '', 1, false, '', 1), + ('00000000-0000-4000-8000-000000000005', '', 1, true, '', 1), + ('00000000-0000-4000-8000-000000000006', '', 1, false, '', 1), + ('00000000-0000-4000-8000-000000000007', '', 1, true, '', 1), + ('00000000-0000-4000-8000-000000000008', '', 1, false, '', 1), + ('00000000-0000-4000-8000-000000000009', '', 1, false, '', 1) ON CONFLICT ("id") DO NOTHING; -- id = UUID do usuário ACS. INSERT INTO "acs" ("id", "enrollmentId", "ubsId", "active") VALUES ('00000000-0000-4000-8000-000000000002', 'ACS-001', '00000000-0000-4000-8000-000000000004', true) ON CONFLICT ("id") DO NOTHING; + +-- id = UUID do usuário admin. +INSERT INTO staff_accounts (id, "enrollmentId", active) +VALUES ('00000000-0000-4000-8000-000000000090', 'ADM-001', true) +ON CONFLICT DO NOTHING; diff --git a/backend/sinalacs_server/lib/src/infrastructure/database/signed_consent_log.dart b/backend/sinalacs_server/lib/src/infrastructure/database/signed_consent_log.dart new file mode 100644 index 00000000..5be2728c --- /dev/null +++ b/backend/sinalacs_server/lib/src/infrastructure/database/signed_consent_log.dart @@ -0,0 +1,40 @@ +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/onboarding/consent_signature.dart'; +import 'package:sinalacs_server/src/application/onboarding/onboarding_service.dart' + show ConsentLogEntry; +import 'package:sinalacs_server/src/generated/protocol.dart'; + +/// A linha de `consent_logs` de [entry], assinada por [signature]. +/// +/// Há dois escritores de consentimento — a conclusão do onboarding e o painel +/// "Meus Dados" (LGPD-RF05) — e os dois gravam a mesma forma de linha. Esta +/// função é o único lugar que a define, para que a assinatura e os campos não +/// divirjam entre eles. +/// +/// IP e user agent não se aplicam a nenhum dos dois eventos de domínio — o +/// request HTTP em si já é auditado em `audit_logs` por outros caminhos —, então +/// os campos exigidos pelo schema levam um marcador explícito de ausência com a +/// [origin] do evento, não um valor fabricado. +ConsentLog signedConsentLog( + ConsentLogEntry entry, { + required ConsentSignature signature, + required String origin, +}) { + final marker = 'nao-aplicavel-$origin'; + return ConsentLog( + userId: UuidValue.fromString(entry.userId), + purpose: entry.purpose.name, + action: entry.action, + version: entry.version, + timestamp: entry.timestamp, + ipHash: marker, + userAgent: marker, + signature: signature.compute( + userId: entry.userId, + purpose: entry.purpose.name, + action: entry.action, + version: entry.version, + timestamp: entry.timestamp, + ), + ); +} diff --git a/backend/sinalacs_server/lib/src/infrastructure/database/subject_lock.dart b/backend/sinalacs_server/lib/src/infrastructure/database/subject_lock.dart new file mode 100644 index 00000000..a1ff06d9 --- /dev/null +++ b/backend/sinalacs_server/lib/src/infrastructure/database/subject_lock.dart @@ -0,0 +1,29 @@ +import 'package:serverpod/serverpod.dart'; + +/// Namespaces dos advisory locks por titular. A forma de duas chaves de +/// `pg_advisory_xact_lock(int, int)` não compartilha espaço com a de uma chave +/// (`OrmAuditTrail`), então um `hashtext` nunca colide com a cadeia de auditoria. +const int lockNamespaceDeletion = 1; +const int lockNamespaceTerms = 2; +/// Push: 3 serializa por titular (consentimento × token), 4 por token. +const int lockNamespacePushToken = 3; +const int lockNamespacePushTokenRow = 4; +/// Token de envio diferido: serializa a troca por (usuário, aparelho). +const int lockNamespaceUploadToken = 5; +/// Atendimento de pedido do titular (#42): serializa as decisões por pedido. +const int lockNamespaceDataSubjectCase = 6; + +/// Serializa, dentro de [transaction], quem disputa a mesma [key] no mesmo +/// [namespace]. Solta sozinho no fim da transação. +Future lockPerSubject( + Session session, + Transaction transaction, { + required int namespace, + required String key, +}) async { + await session.db.unsafeExecute( + 'SELECT pg_advisory_xact_lock(@ns::int, hashtext(@key));', + parameters: QueryParameters.named({'ns': namespace, 'key': key}), + transaction: transaction, + ); +} diff --git a/backend/sinalacs_server/lib/src/infrastructure/mqtt/mqtt_alert_dispatcher.dart b/backend/sinalacs_server/lib/src/infrastructure/mqtt/mqtt_alert_dispatcher.dart index 25743719..9a190ad9 100644 --- a/backend/sinalacs_server/lib/src/infrastructure/mqtt/mqtt_alert_dispatcher.dart +++ b/backend/sinalacs_server/lib/src/infrastructure/mqtt/mqtt_alert_dispatcher.dart @@ -2,11 +2,13 @@ import 'dart:async'; import 'dart:io'; import 'dart:math'; +import 'package:meta/meta.dart'; import 'package:mqtt_client/mqtt_client.dart'; import 'package:mqtt_client/mqtt_server_client.dart'; import 'package:sinalacs_server/src/application/alerts/red_alert_service.dart'; import 'package:sinalacs_server/src/config/app_config.dart'; import 'package:sinalacs_server/src/domain/entities/alert_delivery.dart'; +import 'package:sinalacs_server/src/runtime/uncaught_error_policy.dart'; class MqttUnavailableException implements Exception { const MqttUnavailableException(this.message); @@ -18,21 +20,98 @@ class MqttUnavailableException implements Exception { } class MqttAlertDispatcher implements AlertPublisher { - MqttAlertDispatcher({required AppConfig config}) : _config = config; + MqttAlertDispatcher({ + required AppConfig config, + Duration initialBackoff = const Duration(seconds: 2), + Duration maxBackoff = const Duration(seconds: 60), + void Function(Object error)? onFatal, + void Function(String line)? log, + Duration publishAckTimeout = const Duration(seconds: 10), + int keepAliveSeconds = 30, + int pingResponseTimeoutSeconds = 10, + }) : _config = config, + _ackTimeout = publishAckTimeout, + _keepAliveSecs = keepAliveSeconds, + _pingTimeoutSecs = pingResponseTimeoutSeconds, + _initialBackoff = initialBackoff, + // Teto menor que o piso faria o backoff encolher a cada falha. + _maxBackoff = maxBackoff < initialBackoff ? initialBackoff : maxBackoff, + _backoff = initialBackoff, + _onFatal = onFatal ?? _exitFatal, + _log = log ?? _stderrLog; - static const _initialBackoff = Duration(seconds: 2); - static const _maxBackoff = Duration(seconds: 60); + /// Destino padrão das linhas de log (`log` no construtor troca, nos testes). + /// Toda linha leva só tipo e errno, nunca mensagem, pilha ou endereço. + static void _stderrLog(String line) => stderr.writeln(line); + + /// EX_SOFTWARE: falha não prevista no cliente MQTT. O processo sai de forma + /// controlada (o motivo já foi registrado só pelo tipo) e o orquestrador o + /// reinicia, em vez de continuar com o dispatcher num estado desconhecido. + static void _exitFatal(Object error) => exit(70); final AppConfig _config; + final Duration _initialBackoff; + final Duration _maxBackoff; + final void Function(Object error) _onFatal; + final void Function(String line) _log; + + /// Prazo para o PUBACK de uma publicação QoS 1 (H5, #56). Estourado, a + /// publicação falha e a conexão do cliente é dada como perdida. + final Duration _ackTimeout; + + /// PINGREQ a cada [_keepAliveSecs] (o `keepAlivePeriod` do cliente). + final int _keepAliveSecs; + + /// `disconnectOnNoResponsePeriod` do cliente: sem PINGRESP neste prazo + /// depois de um PINGREQ, a biblioteca derruba a conexão e chama + /// `onDisconnected` (H5, #56). Ver [_connectOnce]. + final int _pingTimeoutSecs; + + /// Publicações à espera de PUBACK, por cliente e por id de mensagem. O id + /// é do dispensador do cliente, por isso o mapa vive com ele. + final _pendingAcks = Expando>>('PUBACKs pendentes'); + + /// Zona protegida de cada cliente (ver [_zoneFor]). + final _zones = Expando('zona protegida do cliente MQTT'); + + /// Gancho de teste chamado logo depois de um cliente novo assumir, antes de + /// o anterior ser descartado: a janela em que uma queda já foi perdida. + @visibleForTesting + Future Function()? afterClientSwapForTest; MqttServerClient? _client; StreamSubscription>>? _subscription; Future Function(AlertDeliveryAck ack)? _onAcknowledgement; - Duration _backoff = _initialBackoff; + Duration _backoff; bool _reconnecting = false; bool _closed = false; bool get isConnected => _client?.connectionStatus?.state == MqttConnectionState.connected; + /// Cliente ativo, só para os testes de cliente obsoleto. + @visibleForTesting + MqttServerClient? get clientForTest => _client; + + /// Zona protegida (#56) de uma tentativa de conexão. + /// + /// O `Socket` do cliente MQTT nasce dentro de `client.connect(...)`, e um + /// erro de E/S do socket (EPIPE/ECONNRESET) completa o `Socket.done` do sink + /// de escrita, que nem o mqtt_client nem ninguém escuta. Esse erro é entregue + /// à zona em que o socket foi criado; sem handler ali, ele sobe até a zona + /// raiz e derruba o processo inteiro (provado na Tarefa 6b). Um `try/catch` + /// em volta de `connect`/`publish` não o vê, por isso cada `connect` roda + /// numa zona própria, e o handler sabe de QUAL cliente veio o erro: um erro + /// tardio do socket de um cliente já substituído não derruba o atual. + /// + /// Deriva da zona raiz, não de `Zone.current`: o dispatcher é criado sob + /// demanda e pode nascer dentro da zona de uma requisição do Serverpod, cujos + /// valores não devem viver junto com a conexão MQTT do processo. + Zone _zoneFor(MqttServerClient client) => _zones[client] ??= Zone.root.fork( + specification: ZoneSpecification( + handleUncaughtError: (self, parent, zone, error, stack) => + _onAsyncError(error, stack, client), + ), + ); + Future connect({ Future Function(AlertDeliveryAck ack)? onAcknowledgement, }) async { @@ -40,7 +119,10 @@ class MqttAlertDispatcher implements AlertPublisher { try { await _connectOnce(); } catch (error) { - stderr.writeln('Falha ao conectar ao broker MQTT: $error. Tentando novamente em segundo plano...'); + _log( + 'Falha ao conectar ao broker MQTT (${error.runtimeType}). ' + 'Tentando novamente em segundo plano...', + ); unawaited(_reconnectWithBackoff()); } } @@ -48,36 +130,97 @@ class MqttAlertDispatcher implements AlertPublisher { Future _connectOnce() async { final broker = _parseBroker(_config.mqttBroker); final clientId = 'sinalacs-backend-${DateTime.now().microsecondsSinceEpoch}'; - final client = MqttServerClient.withPort(broker.host, clientId, broker.port) - ..keepAlivePeriod = 30 + late final MqttServerClient client; + client = MqttServerClient.withPort(broker.host, clientId, broker.port) + ..keepAlivePeriod = _keepAliveSecs + // Partição sem FIN/RST (#56, cenário C): sem isto (padrão 0, desligado) + // o cliente seguia `connected` enquanto a rede não voltasse, mesmo + // com o broker já tendo expirado a sessão. A biblioteca manda PINGREQ a + // cada `keepAlivePeriod` e, se o PINGRESP não chega neste prazo, + // destrói o socket e chama `onDisconnected` (o timer nasce dentro de + // `connect`, logo na zona protegida). Pior caso de detecção: + // keepalive + este prazo (30 + 10 = 40 s), antes de o broker desistir + // do cliente (1,5 x keepalive = 45 s). + ..disconnectOnNoResponsePeriod = _pingTimeoutSecs ..secure = _config.mqttUseTls ..autoReconnect = false - ..onDisconnected = _handleDisconnected + // O callback carrega o cliente que o registrou: um aviso tardio de um + // cliente já substituído é ignorado em vez de derrubar o atual (H4). + ..onDisconnected = (() => _handleDisconnected(client)) ..connectionMessage = MqttConnectMessage() .withClientIdentifier(clientId) .withWillQos(MqttQos.atLeastOnce); if (_config.mqttUseTls && _config.mqttCaCertificatePath != null) { - client.securityContext = SecurityContext(withTrustedRoots: false) + final contexto = SecurityContext(withTrustedRoots: false) ..setTrustedCertificates(_config.mqttCaCertificatePath!); + final cert = _config.mqttClientCertificatePath; + final chave = _config.mqttClientKeyPath; + if (cert != null && chave != null) { + contexto + ..useCertificateChain(cert) + ..usePrivateKey(chave); + } + client.securityContext = contexto; } - await client.connect(_config.mqttUsername, _config.mqttPassword); - if (client.connectionStatus?.state != MqttConnectionState.connected) { - throw StateError('Não foi possível conectar ao broker MQTT.'); + try { + // O socket (e o timer do keepalive) nascem aqui: dentro da zona. + await _runProtected( + _zoneFor(client), + () => client.connect(_config.mqttUsername, _config.mqttPassword), + ); + if (client.connectionStatus?.state != MqttConnectionState.connected) { + throw StateError('Não foi possível conectar ao broker MQTT.'); + } + if (_closed) { + throw StateError('Dispatcher MQTT encerrado durante a conexão.'); + } + _zoneFor(client).run(() { + client.subscribe('sinalacs/v1/alerts/+/acks', MqttQos.atLeastOnce); + // PUBACK das publicações QoS 1: a biblioteca só emite em `published` + // quando há ouvinte. O stream fecha quando o cliente desconecta; aí + // as publicações em voo deste cliente falham (nunca ficam penduradas). + client.published?.listen( + (message) => _onPublishAck(client, message), + onDone: () => _failPendingAcks(client), + onError: (Object _) => _failPendingAcks(client), + ); + }); + } catch (_) { + _disconnectQuietly(client); + rethrow; } - client.subscribe('sinalacs/v1/alerts/+/acks', MqttQos.atLeastOnce); - - // A subscription e o cliente anteriores precisam ser descartados antes de - // assumir os novos: sem isso, cada reconexão deixava um listener vivo no - // cliente antigo, e uma tempestade de reconexão passava a disparar o - // callback de ACK em duplicata para a mesma mensagem. - await _teardownCurrentConnection(); - + // O cliente novo assume antes de o anterior ser descartado, sem `await` + // entre a checagem de estado e a troca: assim um `onDisconnected` do novo + // nunca cai numa janela em que ele ainda não é o `_client`. A subscription + // e o cliente anteriores são descartados logo depois: sem isso, cada + // reconexão deixava um listener vivo no cliente antigo, e uma tempestade + // de reconexão disparava o callback de ACK em duplicata. + final previousSubscription = _subscription; + final previousClient = _client; _subscription = client.updates?.listen(_handleMessages); _client = client; _backoff = _initialBackoff; + final gancho = afterClientSwapForTest; + if (gancho != null) await gancho(); + await previousSubscription?.cancel(); + _disconnectQuietly(previousClient); + } + + /// Roda [body] em [zone] (protegida) e devolve o resultado na zona do chamador. + /// + /// Um `Future` que falha dentro de uma zona com handler de erro não cruza + /// para outra zona de erro: `await zone.run(...)` ficaria pendurado para + /// sempre numa falha de `connect` (o erro iria só para o handler). O + /// `Completer` criado aqui fora entrega valor e erro ao chamador. + Future _runProtected(Zone zone, Future Function() body) { + final resultado = Completer(); + zone.run(() { + body().then(resultado.complete, onError: resultado.completeError); + }); + return resultado.future; } Future _handleMessages( @@ -99,9 +242,13 @@ class MqttAlertDispatcher implements AlertPublisher { // (INV-03), por isso a falha é aguardada e registrada. try { await handler(ack); - } catch (error, stackTrace) { - stderr.writeln( - 'Falha ao processar ACK do alerta ${ack.alertId}: $error\n$stackTrace', + } catch (error) { + // Só o id sintético e o TIPO do erro, nunca `$error`/`$stackTrace` + // brutos: um aggregator de log arquiva o stderr sem tratamento, e a + // exceção pode carregar atributos do alerta (VAZ-01). + _log( + 'Falha ao processar ACK do alerta ${ack.alertId} ' + '(${error.runtimeType}).', ); } } @@ -113,38 +260,201 @@ class MqttAlertDispatcher implements AlertPublisher { _subscription = null; _client = null; await previousSubscription?.cancel(); - previousClient?.disconnect(); + _disconnectQuietly(previousClient); + } + + void _disconnectQuietly(MqttServerClient? client) { + if (client == null) return; + _failPendingAcks(client); + try { + client.disconnect(); + } catch (error) { + _log( + 'Falha ao encerrar cliente MQTT antigo (${error.runtimeType}).', + ); + } } - void _handleDisconnected() { - if (_closed || _reconnecting) return; - unawaited(_reconnectWithBackoff()); + void _handleDisconnected(MqttServerClient origem) { + if (_closed || !identical(origem, _client)) return; + unawaited(_markConnectionLost(origem)); + } + + /// A conexão de [client] caiu: descarta-o (para `publish` lançar + /// [MqttUnavailableException] mesmo que a biblioteca ainda o dê por + /// conectado) e agenda a reconexão. Idempotente: a checagem de identidade e + /// a troca de `_client` acontecem sem `await` entre elas, então o + /// `onDisconnected` e o erro de E/S da mesma queda descartam o cliente uma + /// vez só. Não volta cedo por `_reconnecting`: uma queda do cliente recém- + /// conectado, ainda dentro do laço de reconexão, precisa zerar `_client` + /// para o laço ver que a conexão já caiu e tentar de novo. + Future _markConnectionLost(MqttServerClient client) async { + if (_closed || !identical(client, _client)) return; + await _teardownCurrentConnection(); + if (!_reconnecting) unawaited(_reconnectWithBackoff()); + } + + bool _isTransient(Object e) => isTransientIoError(e); + + /// Handler da zona protegida. Registra só o tipo (e o errno de um + /// `SocketException`), nunca a mensagem, a pilha ou o endereço (VAZ-01). + void _onAsyncError(Object error, StackTrace stack, MqttServerClient client) { + if (_closed) { + // Encerrado: nada a reconectar e nenhum motivo para derrubar o processo + // por um erro tardio de um socket que já foi descartado. + _log( + 'Erro tardio no cliente MQTT encerrado ' + '(${error.runtimeType}${_errno(error)}); ignorado.', + ); + return; + } + if (_isTransient(error)) { + _log( + 'Erro de E/S no cliente MQTT (${error.runtimeType}${_errno(error)}); ' + 'reconectando.', + ); + // Só derruba a conexão se o erro veio do cliente atual (identidade + // checada em _markConnectionLost). + unawaited(_markConnectionLost(client)); + return; + } + _log('Erro fatal no cliente MQTT (${error.runtimeType}).'); + _onFatal(error); + } + + static String _errno(Object e) { + if (e is SocketException) { + final code = e.osError?.errorCode; + if (code != null) return ', errno=$code'; + } + return ''; + } + + /// Entrega [error] à zona protegida como erro assíncrono não tratado. Prova + /// a política do handler, não o comportamento de um socket real (esse é o + /// teste de RST do broker falso e os cenários no Docker). + /// + /// [from] escolhe a zona de um cliente específico (padrão: o atual). + @visibleForTesting + Future simulateAsyncErrorForTest( + Object error, { + MqttServerClient? from, + }) async { + final client = from ?? _client; + if (client == null) throw StateError('sem cliente MQTT para simular erro'); + _zoneFor(client).run(() { + Future.error(error); + }); + await Future.delayed(Duration.zero); } Future _reconnectWithBackoff() async { + if (_reconnecting || _closed) return; _reconnecting = true; - while (!_closed) { - await Future.delayed(_backoff); - try { - await _connectOnce(); - break; - } catch (error) { - stderr.writeln('Falha ao reconectar ao broker MQTT: $error.'); - _backoff = Duration(seconds: min(_backoff.inSeconds * 2, _maxBackoff.inSeconds)); + try { + while (!_closed) { + await Future.delayed(_backoff); + if (_closed) break; + try { + await _connectOnce(); + // A conexão nova pode ter caído na própria troca de cliente + // (_markConnectionLost zera `_client`); nesse caso, tenta de novo + // em vez de sair do laço com um cliente morto. + if (_client != null && isConnected) break; + _log('Conexão MQTT caiu logo após reconectar; tentando de novo.'); + } catch (error) { + _log( + 'Falha ao reconectar ao broker MQTT (${error.runtimeType}).', + ); + _backoff = Duration( + microseconds: min( + _backoff.inMicroseconds * 2, + _maxBackoff.inMicroseconds, + ), + ); + } } + } finally { + _reconnecting = false; } - _reconnecting = false; } + /// Publica [alert] com QoS 1 e completa só quando o broker confirma com + /// PUBACK (H5, #56): antes, `publish` devolvia assim que a mensagem ia para + /// o buffer do socket e o outbox a dava por entregue mesmo com a conexão + /// morta. + /// + /// Falha com [MqttUnavailableException] quando não há conexão, quando a + /// conexão cai antes do PUBACK ou quando ele não chega em + /// `publishAckTimeout`. No tempo limite a conexão é dada como perdida (a + /// reconexão começa e as publicações seguintes falham na hora, sem esperar + /// outro prazo inteiro). Entrega é pelo menos uma vez: uma mensagem que o + /// broker recebeu sem o PUBACK chegar volta a ser publicada pelo outbox. @override - void publish(AlertDelivery alert) { + Future publish(AlertDelivery alert) async { final client = _client; if (client == null || client.connectionStatus?.state != MqttConnectionState.connected) { throw const MqttUnavailableException('O dispatcher MQTT não está conectado.'); } final payload = MqttClientPayloadBuilder()..addString(alert.toJson()); - client.publishMessage(alert.topic, MqttQos.atLeastOnce, payload.payload!); + // O Completer nasce na zona do chamador: o erro precisa chegar a quem + // aguarda, e um Future com erro não cruza para outra zona de erro. + final confirmacao = Completer(); + final pendentes = _pendingAcks[client] ??= >{}; + // A escrita vai para o socket nascido na zona protegida. `publishMessage` + // é síncrono até a escrita, e o PUBACK só é processado num turno + // seguinte do event loop: registrar o id logo depois não perde a + // confirmação. + final messageId = _zoneFor(client).run( + () => client.publishMessage( + alert.topic, + MqttQos.atLeastOnce, + payload.payload!, + ), + ); + pendentes[messageId] = confirmacao; + + final prazo = Timer(_ackTimeout, () { + if (!identical(pendentes[messageId], confirmacao)) return; + pendentes.remove(messageId); + _log( + 'PUBACK não recebido em ${_ackTimeout.inMilliseconds} ms; ' + 'conexão MQTT dada como perdida.', + ); + confirmacao.completeError( + const MqttUnavailableException('O broker MQTT não confirmou a publicação no prazo.'), + ); + unawaited(_markConnectionLost(client)); + }); + try { + await confirmacao.future; + } finally { + prazo.cancel(); + } + } + + void _onPublishAck(MqttServerClient client, MqttPublishMessage message) { + final id = message.variableHeader?.messageIdentifier; + if (id == null) return; + final confirmacao = _pendingAcks[client]?.remove(id); + if (confirmacao != null && !confirmacao.isCompleted) confirmacao.complete(); + } + + /// A conexão de [client] acabou: nenhuma publicação dele terá PUBACK. Falha + /// todas, para o outbox registrar a falha e reentregar depois. + void _failPendingAcks(MqttServerClient client) { + final pendentes = _pendingAcks[client]; + if (pendentes == null || pendentes.isEmpty) return; + final emVoo = List.of(pendentes.values); + pendentes.clear(); + for (final confirmacao in emVoo) { + if (!confirmacao.isCompleted) { + confirmacao.completeError( + const MqttUnavailableException('A conexão MQTT caiu antes da confirmação da publicação.'), + ); + } + } } Future close() async { diff --git a/backend/sinalacs_server/lib/src/infrastructure/push/data_subject_push_notifier.dart b/backend/sinalacs_server/lib/src/infrastructure/push/data_subject_push_notifier.dart new file mode 100644 index 00000000..a20c360c --- /dev/null +++ b/backend/sinalacs_server/lib/src/infrastructure/push/data_subject_push_notifier.dart @@ -0,0 +1,87 @@ +import 'dart:io'; + +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/admin/data_subject_case_service.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/infrastructure/push/gorush_client.dart'; + +/// Aparelhos de UM titular que ainda consentem com push. Interface à parte do +/// ORM para o notificador ser testável sem banco. +abstract interface class DataSubjectPushTargets { + /// Tokens do [userId] só se a linha **mais recente** de `segmentedPush` em + /// `consent_logs` é `granted`; sem consentimento ou sem token, lista vazia. + Future> consentedTargetsOf(String userId); +} + +class OrmDataSubjectPushTargets implements DataSubjectPushTargets { + OrmDataSubjectPushTargets({required Session Function() session}) : _session = session; + + final Session Function() _session; + + @override + Future> consentedTargetsOf(String userId) async { + if (!Uuid.isValidUUID(fromString: userId)) return const []; + final rows = await _session().db.unsafeQuery( + ''' + SELECT pt."token" AS token, pt."platform" AS platform + FROM push_tokens pt + WHERE pt."userId" = @user::uuid + AND COALESCE(( + SELECT c."action" FROM consent_logs c + WHERE c."userId" = pt."userId" AND c."purpose" = 'segmentedPush' + ORDER BY c."timestamp" DESC, c."id" DESC LIMIT 1 + ), 'denied') = 'granted' + ORDER BY pt."token" + ''', + parameters: QueryParameters.named({'user': userId}), + ); + return [ + for (final row in rows) + PushTarget( + token: row.toColumnMap()['token'] as String, + platform: row.toColumnMap()['platform'] as String, + ), + ]; + } +} + +/// Avisa o titular, por push, que o pedido dele foi respondido. O texto é fixo +/// e genérico: nem a nota, nem o tipo do pedido, nem o resultado saem do +/// servidor; o detalhe fica em "Meus dados", atrás do login. Sem remetente +/// (`GORUSH_URL` ausente) é no-op. Qualquer falha é engolida: a decisão já foi +/// gravada e o aviso é melhor esforço. +class GorushDataSubjectNotifier implements DataSubjectNotifier { + GorushDataSubjectNotifier({ + required DataSubjectPushTargets targets, + required PushSender? sender, + }) : _targets = targets, + _sender = sender; + + final DataSubjectPushTargets _targets; + final PushSender? _sender; + + static const title = 'Seu pedido foi respondido'; + static const body = 'Abra o SinalACS e veja a resposta em Meus dados.'; + static const screen = 'my_data'; + + @override + Future decided(String userId, DataSubjectRequestStatus status) async { + final sender = _sender; + if (sender == null) return; + if (status != DataSubjectRequestStatus.completed && + status != DataSubjectRequestStatus.rejected) { + return; + } + try { + final alvos = await _targets.consentedTargetsOf(userId); + if (alvos.isEmpty) return; + await sender.send( + const PushMessage(title: title, body: body, data: {'screen': screen}), + alvos, + ); + } catch (e) { + // Só o tipo: a mensagem do erro poderia citar token ou corpo. + stderr.writeln('push ao titular falhou: ${e.runtimeType}'); + } + } +} diff --git a/backend/sinalacs_server/lib/src/infrastructure/push/gorush_client.dart b/backend/sinalacs_server/lib/src/infrastructure/push/gorush_client.dart new file mode 100644 index 00000000..f47aa820 --- /dev/null +++ b/backend/sinalacs_server/lib/src/infrastructure/push/gorush_client.dart @@ -0,0 +1,189 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +/// Um aparelho a notificar: o token do provedor e a plataforma. +class PushTarget { + const PushTarget({required this.token, required this.platform}); + + final String token; + + /// `android` ou `ios`, o mesmo vocabulário de `push_tokens.platform`. + final String platform; +} + +/// O que vai na notificação. Nunca carrega dado de paciente: só texto do aviso e +/// a tela que o app deve abrir (minimização, decisão §3.2). +class PushMessage { + const PushMessage({required this.title, required this.body, this.data = const {}}); + + final String title; + final String body; + final Map data; +} + +/// Desfecho de um envio: quantas notificações o Gorush aceitou e quais tokens o +/// provedor declarou inválidos (candidatos a apagar). +class PushSendReport { + const PushSendReport({required this.accepted, required this.invalidTokens}); + + final int accepted; + final List invalidTokens; +} + +/// O relé de push não respondeu como esperado (fora do ar, lento, 5xx). A +/// mensagem nunca inclui tokens nem o corpo enviado. +class PushGatewayException implements Exception { + const PushGatewayException(this.message, {this.outcomeUnknown = false}); + + final String message; + + /// `true` quando o pedido saiu mas a resposta não voltou a tempo: o Gorush + /// pode ter entregue o aviso a parte dos aparelhos. Reenviar às cegas duplica + /// o aviso; quem chama deve dizer isso à pessoa. + final bool outcomeUnknown; + + @override + String toString() => 'PushGatewayException: $message'; +} + +abstract interface class PushSender { + Future send(PushMessage message, List targets); +} + +/// Cliente HTTP do Gorush (`POST /api/push`). Usa `dart:io`: o backend não tem o +/// pacote `http`, e uma chamada só não justifica a dependência. +class GorushClient implements PushSender { + GorushClient({ + required String baseUrl, + required Duration timeout, + this.connectTimeout = const Duration(seconds: 2), + HttpClient? httpClient, + }) : _baseUrl = baseUrl, + _timeout = timeout, + _http = httpClient ?? HttpClient(); + + final String _baseUrl; + final Duration _timeout; + + /// Teto da fase de CONEXÃO. Estourar aqui significa que nada foi enviado (com o + /// container do Gorush parado o nome nem resolve e a chamada fica pendurada), então + /// é "inacessível, tente de novo" — e não "resultado desconhecido", que só vale + /// depois que o pedido saiu e a resposta não voltou. + final Duration connectTimeout; + final HttpClient _http; + + /// Encerra as conexões. Um `send` depois disso falha com [PushGatewayException]. + void close() => _http.close(force: true); + + /// Códigos de plataforma do Gorush. + static const _ios = 1; + static const _android = 2; + + /// Erros que dizem que o TOKEN morreu. `MismatchSenderId` fica de fora de + /// propósito: é a conta de serviço do servidor errada, e apagar por causa dele + /// esvaziaria `push_tokens` da microárea inteira num único envio. + static const _invalidTokenErrors = [ + 'notregistered', + 'unregistered', + 'invalidregistration', + 'requested entity was not found', // FCM v1: UNREGISTERED + // FCM v1: INVALID_ARGUMENT sobre o token (texto medido contra o FCM real) + 'not a valid fcm registration token', + 'baddevicetoken', + 'devicetokennotfortopic', + ]; + + @override + Future send(PushMessage message, List targets) async { + if (targets.isEmpty) return const PushSendReport(accepted: 0, invalidTokens: []); + + final byPlatform = >{}; + for (final t in targets) { + byPlatform.putIfAbsent(t.platform == 'ios' ? _ios : _android, () => []).add(t.token); + } + final body = jsonEncode({ + 'notifications': [ + for (final entry in byPlatform.entries) + { + 'tokens': entry.value, + 'platform': entry.key, + 'title': message.title, + 'message': message.body, + 'data': message.data, + }, + ], + }); + + try { + return await _post(body, targets.length, {for (final t in targets) t.token}) + .timeout(_timeout); + } on PushGatewayException { + rethrow; + } on TimeoutException { + throw const PushGatewayException( + 'O Gorush não respondeu a tempo.', + outcomeUnknown: true, + ); + } on SocketException { + throw const PushGatewayException('O Gorush está inacessível.'); + } on HttpException { + throw const PushGatewayException('Falha de HTTP ao falar com o Gorush.'); + } on FormatException { + // A resposta era 2xx: o Gorush pode ter entregue. Não é "tente de novo". + throw const PushGatewayException( + 'O Gorush respondeu algo ilegível.', + outcomeUnknown: true, + ); + } on StateError { + throw const PushGatewayException('O cliente do Gorush foi encerrado.'); + } + } + + Future _post(String body, int total, Set sent) async { + final HttpClientRequest request; + try { + request = await _http.postUrl(Uri.parse('$_baseUrl/api/push')).timeout(connectTimeout); + } on TimeoutException { + // Nada saiu: é o mesmo desfecho de uma conexão recusada. + throw const PushGatewayException('O Gorush está inacessível.'); + } + request.headers.contentType = ContentType.json; + request.write(body); + final response = await request.close(); + final raw = await utf8.decoder.bind(response).join(); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw PushGatewayException('O Gorush respondeu com status ${response.statusCode}.'); + } + final decoded = raw.isEmpty ? {} : jsonDecode(raw); + if (decoded is! Map) { + throw const PushGatewayException( + 'O Gorush respondeu algo ilegível.', + outcomeUnknown: true, + ); + } + final json = decoded; + final logs = json['logs'] is List ? json['logs'] as List : const []; + final invalid = []; + var failed = 0; + for (final log in logs.whereType>()) { + if (log['type'] != 'failed-push') continue; + failed++; + final error = '${log['error']}'.toLowerCase(); + final token = log['token']; + // Só devolve para a poda um token que NÓS enviamos: com `log.hide_token` do + // Gorush ligado ele volta mascarado (`***…xx`), e apagar por um valor que não + // reconhecemos seria apagar às cegas. + if (token is String && sent.contains(token) && _invalidTokenErrors.any(error.contains)) { + invalid.add(token); + } + } + // `counts` do Gorush não é confiável como "aceitos" (conta notificações + // enfileiradas, não entregas): o número que vai para o ACS é o total menos as + // falhas que o próprio Gorush relatou. + return PushSendReport( + accepted: (total - failed).clamp(0, total), + invalidTokens: invalid, + ); + } +} diff --git a/backend/sinalacs_server/lib/src/infrastructure/testing/e2e_fixtures.dart b/backend/sinalacs_server/lib/src/infrastructure/testing/e2e_fixtures.dart new file mode 100644 index 00000000..42d5ab2b --- /dev/null +++ b/backend/sinalacs_server/lib/src/infrastructure/testing/e2e_fixtures.dart @@ -0,0 +1,371 @@ +import 'dart:io'; +import 'dart:math'; +import 'package:sinalacs_server/src/application/auth/staff_activation_code.dart'; + +/// Dados SINTÉTICOS de uma execução de e2e. Nada aqui é estável entre +/// execuções: UUIDs e CPFs mudam a cada chamada (quem precisa deles lê o +/// manifesto). Nunca um dado real (LGPD). +class E2ePatient { + const E2ePatient({ + required this.id, + required this.role, + required this.name, + required this.cpf, + required this.birthDate, + required this.chronic, + required this.microAreaId, + }); + + final String id; + + /// `main` e `chronic` (jornada pela tela), `api` (testes sem tela), `push` + /// (testes de push) ou `outsider` (outra microárea). + final String role; + final String name; + + /// Só dígitos; nunca em log nem em mensagem de erro. + final String cpf; + + /// AAAA-MM-DD. + final String birthDate; + final bool chronic; + final String microAreaId; + + Map toJson() => { + 'id': id, + 'role': role, + 'name': name, + 'cpf': cpf, + 'birthDate': birthDate, + 'chronic': chronic, + 'microAreaId': microAreaId, + }; + + factory E2ePatient.fromJson(Map j) => E2ePatient( + id: j['id']! as String, + role: j['role']! as String, + name: j['name']! as String, + cpf: j['cpf']! as String, + birthDate: j['birthDate']! as String, + chronic: j['chronic']! as bool, + microAreaId: j['microAreaId']! as String, + ); + + @override + String toString() => 'E2ePatient($role)'; +} + +class E2eAcs { + const E2eAcs({required this.id, required this.matricula, required this.password}); + + final String id; + final String matricula; + final String password; + + Map toJson() => {'id': id, 'matricula': matricula, 'password': password}; + + factory E2eAcs.fromJson(Map j) => E2eAcs( + id: j['id']! as String, + matricula: j['matricula']! as String, + password: j['password']! as String, + ); + + @override + String toString() => 'E2eAcs(${id.substring(0, 8)}…)'; +} + +/// Conta do backoffice (`staff_accounts`) SEM TOTP ativado: o e2e faz a +/// ativação da MFA pela tela. Matrícula e senha novas a cada execução. +/// +/// A MESMA forma serve ao administrador e ao coordenador (#43): a única +/// diferença entre os dois está no seed — o papel em `users` e a UBS do +/// `staff_accounts` —, não no que o manifesto e o relé precisam entregar. +class E2eStaff { + const E2eStaff({ + required this.id, + required this.matricula, + required this.password, + required this.activationCode, + }); + + final String id; + final String matricula; + final String password; + + /// Código de ativação de uso único (#48), em claro só no manifesto 0600; o seed + /// grava o hash. Sem ele, a ativação da MFA pela tela é recusada. + final String activationCode; + + Map toJson() => + {'id': id, 'matricula': matricula, 'password': password, 'activationCode': activationCode}; + + factory E2eStaff.fromJson(Map j) => E2eStaff( + id: j['id']! as String, + matricula: j['matricula']! as String, + password: j['password']! as String, + activationCode: j['activationCode']! as String, + ); + + @override + String toString() => 'E2eStaff(${id.substring(0, 8)}…)'; +} + +/// Prazo do alerta vermelho na stack de e2e (`RED_ALERT_ESCALATION_SECONDS` em +/// `docker-compose.e2e.yml`): o mínimo que o `AppConfig` aceita, para o e2e do +/// escalonamento (#44) não esperar os 5 min do padrão. +const e2eEscalationThresholdSeconds = 30; + +/// Os dois alertas vermelhos do e2e do escalonamento (#44), ambos do paciente +/// `main`, na microárea das fixtures (UBS do coordenador): +/// +/// * [overdueId] é semeado com `E2E_SEED_ALERT_ESCALATION=1`, disparado há +/// [overdueAge]: já vencido quando a tela abre, com o botão desde o início. +/// * [freshId] **não** é semeado: o script o insere no meio do teste, quando a +/// tela de Alertas já está aberta, com `triggeredAt` [freshLead] à frente do +/// relógio do banco. É o que deixa a janela "ainda no prazo" maior que um +/// pulso (30 s) — com o prazo de 30 s e o disparo em `NOW()`, a primeira +/// recarga podia já trazê-lo vencido e o "antes não há botão" ficaria sem +/// prova. O instante futuro é um arranjo SINTÉTICO do teste. +class E2eEscalationAlerts { + const E2eEscalationAlerts({required this.overdueId, required this.freshId}); + + final String overdueId; + final String freshId; + + static const overdueAge = Duration(minutes: 10); + static const freshLead = Duration(seconds: 20); + + Map toJson() => {'overdueId': overdueId, 'freshId': freshId}; + + factory E2eEscalationAlerts.fromJson(Map j) => + E2eEscalationAlerts(overdueId: j['overdueId']! as String, freshId: j['freshId']! as String); +} + +class E2eFixtures { + const E2eFixtures({ + required this.ubsId, + required this.microAreaId, + required this.otherMicroAreaId, + required this.acs, + required this.secondAcs, + required this.staff, + required this.coordinator, + required this.adminAlertIds, + required this.escalationAlerts, + required this.patients, + }); + + final String ubsId; + final String microAreaId; + final String otherMicroAreaId; + final E2eAcs acs; + + /// Segundo ACS, na MESMA microárea de [acs]: prova no aparelho que a fila + /// offline é por dono (B não vê nem envia as visitas de A) e que o envio + /// diferido sobe as visitas de A com a autoria de A. + final E2eAcs secondAcs; + + /// Administrador do backoffice (sem MFA ativada), para o e2e do app admin. + final E2eStaff staff; + + /// Coordenador do backoffice (#43), também sem MFA ativada. O escopo dele é a + /// UBS destas fixtures — a mesma que contém as duas microáreas —, e é isso + /// que o e2e da gestão de contas prova dos dois lados: ele enxerga os ACS da + /// UBS e não enxerga a equipe do backoffice. + final E2eStaff coordinator; + + /// Ids dos 3 alertas semeados para o painel do admin (#40): um vermelho + /// pendente, um vermelho reconhecido 60 s depois do disparo e um verde + /// pendente, todos na microárea das fixtures. O painel deve mostrar Vermelho 2, + /// Verde 1, 1 aberto, 1 reconhecido e TMRAV 60s. + final List adminAlertIds; + + /// Alertas do e2e do escalonamento (#44); ver [E2eEscalationAlerts]. + final E2eEscalationAlerts escalationAlerts; + final List patients; + + E2ePatient byRole(String role) => patients.singleWhere((p) => p.role == role); + + Map toJson() => { + 'ubsId': ubsId, + 'microAreaId': microAreaId, + 'otherMicroAreaId': otherMicroAreaId, + 'acs': acs.toJson(), + 'acsB': secondAcs.toJson(), + 'staff': staff.toJson(), + 'coordinator': coordinator.toJson(), + 'adminAlertIds': adminAlertIds, + 'escalationAlerts': escalationAlerts.toJson(), + 'patients': [for (final p in patients) p.toJson()], + }; + + factory E2eFixtures.fromJson(Map j) => E2eFixtures( + ubsId: j['ubsId']! as String, + microAreaId: j['microAreaId']! as String, + otherMicroAreaId: j['otherMicroAreaId']! as String, + acs: E2eAcs.fromJson((j['acs']! as Map).cast()), + secondAcs: E2eAcs.fromJson((j['acsB']! as Map).cast()), + staff: E2eStaff.fromJson((j['staff']! as Map).cast()), + coordinator: E2eStaff.fromJson((j['coordinator']! as Map).cast()), + adminAlertIds: [for (final id in (j['adminAlertIds']! as List)) id as String], + escalationAlerts: E2eEscalationAlerts.fromJson((j['escalationAlerts']! as Map).cast()), + patients: [ + for (final p in (j['patients']! as List)) E2ePatient.fromJson((p as Map).cast()), + ], + ); + + @override + String toString() => 'E2eFixtures(${patients.length} pacientes)'; +} + +String generateUuidV4(Random random) { + String hex(int n) => List.generate(n, (_) => random.nextInt(16).toRadixString(16)).join(); + final variant = '89ab'[random.nextInt(4)]; + return '${hex(8)}-${hex(4)}-4${hex(3)}-$variant${hex(3)}-${hex(12)}'; +} + +/// 11 dígitos com os dois dígitos verificadores corretos e sem todos iguais. +String generateValidCpfDigits(Random random) { + int dv(List base) { + var sum = 0; + for (var i = 0; i < base.length; i++) { + sum += base[i] * (base.length + 1 - i); + } + final r = (sum * 10) % 11; + return r == 10 ? 0 : r; + } + + while (true) { + final d = List.generate(9, (_) => random.nextInt(10)); + if (d.toSet().length == 1) continue; + final d1 = dv(d); + final d2 = dv([...d, d1]); + return [...d, d1, d2].join(); + } +} + +String _birthDate(Random random) { + final year = 1950 + random.nextInt(50); + final month = (1 + random.nextInt(12)).toString().padLeft(2, '0'); + final day = (1 + random.nextInt(28)).toString().padLeft(2, '0'); + return '$year-$month-$day'; +} + +String _password(Random random) { + const alphabet = 'abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + return List.generate(20, (_) => alphabet[random.nextInt(alphabet.length)]).join(); +} + +E2eFixtures generateE2eFixtures(Random random) { + final microAreaId = generateUuidV4(random); + final otherMicroAreaId = generateUuidV4(random); + final usedCpfs = {}; + E2ePatient patient(String role, String name, {required bool chronic, required String microAreaId}) { + String cpf; + do { + cpf = generateValidCpfDigits(random); + } while (!usedCpfs.add(cpf)); + return E2ePatient( + id: generateUuidV4(random), + role: role, + name: name, + cpf: cpf, + birthDate: _birthDate(random), + chronic: chronic, + microAreaId: microAreaId, + ); + } + + final acs = E2eAcs( + id: generateUuidV4(random), + matricula: 'E2E-${1000 + random.nextInt(9000)}', + password: _password(random), + ); + String matriculaB; + do { + matriculaB = 'E2E-${1000 + random.nextInt(9000)}'; + } while (matriculaB == acs.matricula); + + return E2eFixtures( + ubsId: generateUuidV4(random), + microAreaId: microAreaId, + otherMicroAreaId: otherMicroAreaId, + acs: acs, + secondAcs: E2eAcs( + id: generateUuidV4(random), + matricula: matriculaB, + password: _password(random), + ), + staff: E2eStaff( + id: generateUuidV4(random), + matricula: 'E2E-ADM-${1000 + random.nextInt(9000)}', + password: _password(random), + activationCode: StaffActivationCode.generate(random), + ), + // Prefixo próprio: as duas matrículas de staff nascem de sorteios + // independentes e `staff_accounts.enrollmentId` é único — dois prefixos + // diferentes não colidem nem entre si nem com a matrícula que o e2e cria + // pela tela. + coordinator: E2eStaff( + id: generateUuidV4(random), + matricula: 'E2E-COORD-${1000 + random.nextInt(9000)}', + password: _password(random), + activationCode: StaffActivationCode.generate(random), + ), + adminAlertIds: [for (var i = 0; i < 3; i++) generateUuidV4(random)], + escalationAlerts: E2eEscalationAlerts(overdueId: generateUuidV4(random), freshId: generateUuidV4(random)), + patients: [ + patient('main', 'Paciente E2E Principal', chronic: false, microAreaId: microAreaId), + patient('chronic', 'Paciente E2E Crônico', chronic: true, microAreaId: microAreaId), + // Um paciente por consumidor: o OTP impõe 60 s entre dois pedidos do MESMO + // paciente, então testes que rodam em sequência não podem dividir um. + patient('api', 'Paciente E2E API', chronic: false, microAreaId: microAreaId), + patient('push', 'Paciente E2E Push', chronic: false, microAreaId: microAreaId), + patient('outsider', 'Paciente E2E Outra Área', chronic: false, microAreaId: otherMicroAreaId), + ], + ); +} + +/// Por que o seed de e2e deve RECUSAR rodar neste ambiente, ou `null` se pode. +/// +/// Só escreve no banco `sinalacs_e2e` do `postgres-test` local (publicado em +/// `localhost:9090`): o nome sozinho não basta, porque um banco de mesmo nome em +/// outro servidor passaria. Exigir host local e a porta do `postgres-test` fecha +/// esse caminho (o banco de desenvolvimento fica em `postgres:5432`). +String? e2eSeedRefusal(Map env) { + if ((env['APP_ENV'] ?? 'development') != 'development') { + return 'APP_ENV=${env['APP_ENV']}; este seed é só de desenvolvimento.'; + } + if (env['SERVERPOD_DATABASE_NAME'] != 'sinalacs_e2e') { + return 'este seed só escreve no banco sinalacs_e2e.'; + } + final host = (env['SERVERPOD_DATABASE_HOST'] ?? 'localhost').toLowerCase(); + if (host != 'localhost' && host != '127.0.0.1') { + return 'o host do banco deve ser local (localhost ou 127.0.0.1), e não "$host".'; + } + if ((env['SERVERPOD_DATABASE_PORT'] ?? '9090') != '9090') { + return 'a porta do banco deve ser a 9090 do postgres-test.'; + } + if ((env['SERVERPOD_DATABASE_PASSWORD'] ?? '').isEmpty) { + return 'SERVERPOD_DATABASE_PASSWORD não definida.'; + } + return null; +} + +/// Grava o manifesto já PRIVADO: o diretório em 0700 e o arquivo em 0600 ANTES de +/// receber o conteúdo (criar com o umask e só depois restringir deixaria uma +/// janela em que a senha do ACS é legível por outros usuários da máquina). +void writeManifestPrivately(File file, String content) { + file.parent.createSync(recursive: true); + _chmod('700', file.parent.path); + if (!file.existsSync()) file.createSync(); + _chmod('600', file.path); + file.writeAsStringSync(content); +} + +void _chmod(String mode, String path) { + final result = Process.runSync('chmod', [mode, path]); + if (result.exitCode != 0) { + throw StateError('chmod $mode falhou em $path: ${result.stderr}'.trim()); + } +} diff --git a/backend/sinalacs_server/lib/src/models/acs_refresh_token.spy.yaml b/backend/sinalacs_server/lib/src/models/acs_refresh_token.spy.yaml new file mode 100644 index 00000000..999c418c --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/acs_refresh_token.spy.yaml @@ -0,0 +1,34 @@ +### Refresh token do ACS (LGPD-RT06). Uma linha por token emitido; os tokens de +### um mesmo login formam uma *família* (`familyId`) e a rotação encadeia +### dentro dela. +### +### **Nunca** o token: só o SHA-256 dele em hex. O token é 256 bits aleatórios, +### então um hash simples basta (não há senha humana para adivinhar). +class: AcsRefreshToken +table: acs_refresh_tokens +fields: + id: UuidValue?, defaultPersist=random + userId: UuidValue, relation(parent=users) + ### Todos os tokens nascidos do mesmo login por senha+TOTP. + familyId: UuidValue + tokenHash: String + ### Aparelho que recebeu o token. Token apresentado por outro aparelho + ### revoga a família. + deviceId: String + issuedAt: DateTime + ### Janela ociosa: renovada a cada rotação, nunca além de `absoluteExpiresAt`. + idleExpiresAt: DateTime + ### Teto do turno, fixado no login por senha+TOTP e herdado pelos filhos. + absoluteExpiresAt: DateTime + ### `null` = ainda não usado. Preenchido na rotação. + rotatedAt: DateTime? + ### `null` = vigente. Preenchido na revogação da família ou no logout. + revokedAt: DateTime? +indexes: + acs_refresh_tokens_hash_key: + fields: tokenHash + unique: true + acs_refresh_tokens_family_idx: + fields: familyId + acs_refresh_tokens_user_idx: + fields: userId diff --git a/backend/sinalacs_server/lib/src/models/acs_upload_token.spy.yaml b/backend/sinalacs_server/lib/src/models/acs_upload_token.spy.yaml new file mode 100644 index 00000000..c240dfd4 --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/acs_upload_token.spy.yaml @@ -0,0 +1,29 @@ +### Token de envio diferido do ACS (D7 do plano 2026-10-03). Deixa o aparelho +### subir as visitas pendentes de um ACS que já saiu — com a autoria DELE — +### sem sessão e sem nenhum outro poder: o único uso é `visits.syncDeferred` +### (e `visits.revokeUploadToken`). Um vigente por (usuário, aparelho): a +### emissão revoga o anterior. Validade de 7 dias, sem rotação. +### +### **Nunca** o token: só o SHA-256 dele em hex. O token é 256 bits aleatórios, +### então um hash simples basta (não há senha humana para adivinhar). +class: AcsUploadToken +table: acs_upload_tokens +fields: + id: UuidValue?, defaultPersist=random + userId: UuidValue, relation(parent=users) + tokenHash: String + ### Instalação que recebeu o token. Token apresentado por outro aparelho é + ### recusado e revogado. + deviceId: String + issuedAt: DateTime + ### Teto fixo (`issuedAt` + 7 dias); o uso não o estende. + expiresAt: DateTime + ### `null` = vigente. Preenchido na revogação (pedido do app, novo login no + ### mesmo aparelho, aparelho divergente ou conta inativa/sem microárea). + revokedAt: DateTime? +indexes: + acs_upload_tokens_hash_key: + fields: tokenHash + unique: true + acs_upload_tokens_user_device_idx: + fields: userId, deviceId diff --git a/backend/sinalacs_server/lib/src/models/alert.spy.yaml b/backend/sinalacs_server/lib/src/models/alert.spy.yaml index eeaaaf4d..71ed47fb 100644 --- a/backend/sinalacs_server/lib/src/models/alert.spy.yaml +++ b/backend/sinalacs_server/lib/src/models/alert.spy.yaml @@ -24,6 +24,10 @@ fields: deviceId: String retryCount: int version: int + ### Escalonamento pelo backoffice (#44). Anuláveis: alerta não escalonado. + escalatedAt: DateTime? + escalatedBy: UuidValue? + escalationReason: EscalationReason? indexes: alerts_micro_area_status_idx: fields: microAreaId, status, triggeredAt diff --git a/backend/sinalacs_server/lib/src/models/api/admin_acs.spy.yaml b/backend/sinalacs_server/lib/src/models/api/admin_acs.spy.yaml new file mode 100644 index 00000000..c1702a3e --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/api/admin_acs.spy.yaml @@ -0,0 +1,19 @@ +### ACS no backoffice (#43): identificação profissional, território e estado de +### acesso. Nunca inclui CPF, senha ou contato. +### +### `mfaActive` é `user_credentials.totpEnabledAt IS NOT NULL` — MFA **ativa**, +### não apenas iniciada: um enrollment pendente (`totpEnabledAt` nulo) não vale +### como proteção e a tela precisa poder dizer isso. +class: AdminAcs +fields: + id: String + name: String + enrollmentId: String + ubsId: String + ubsName: String + ### Nulos enquanto o ACS não tem território (`users.microAreaId`). A listagem + ### inclui quem não tem microárea, para poder vinculá-lo. + microAreaId: String? + microAreaName: String? + active: bool + mfaActive: bool diff --git a/backend/sinalacs_server/lib/src/models/api/admin_acs_creation_result.spy.yaml b/backend/sinalacs_server/lib/src/models/api/admin_acs_creation_result.spy.yaml new file mode 100644 index 00000000..7a71016d --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/api/admin_acs_creation_result.spy.yaml @@ -0,0 +1,11 @@ +### ACS recém-cadastrado e a senha inicial gerada (issue #43). +### +### A senha existe **só** aqui e na resposta de `admin.createAcs`: não há coluna, +### log nem linha de auditoria que a guarde — o servidor persiste apenas o hash +### Argon2id em `user_credentials`. A trilha registra `created` com o id do ACS e +### nunca a credencial. Quem cadastra mostra uma vez; perdida a senha, o caminho +### é redefinir, não recuperar (operação seguinte da mesma issue). +class: AdminAcsCreationResult +fields: + acs: AdminAcs + initialPassword: String diff --git a/backend/sinalacs_server/lib/src/models/api/admin_alert.spy.yaml b/backend/sinalacs_server/lib/src/models/api/admin_alert.spy.yaml new file mode 100644 index 00000000..b7ca51b7 --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/api/admin_alert.spy.yaml @@ -0,0 +1,13 @@ +### Alerta para o backoffice. `patientLabel` é `#` + 4 hex do UUID: o backoffice +### identifica, não qualifica (spec/lgpd_design.md). Nunca nome, CPF ou contato. +class: AdminAlert +fields: + id: String + patientLabel: String + microAreaName: String + riskLevel: RiskLevel + status: AlertStatus + triggeredAt: DateTime + escalatedAt: DateTime? + ### Sem reconhecimento além do prazo configurado; calculado na leitura. + overdue: bool diff --git a/backend/sinalacs_server/lib/src/models/api/admin_alert_page.spy.yaml b/backend/sinalacs_server/lib/src/models/api/admin_alert_page.spy.yaml new file mode 100644 index 00000000..90cb3e84 --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/api/admin_alert_page.spy.yaml @@ -0,0 +1,5 @@ +### Página de alertas. `nextOffset` nulo = não há mais páginas. +class: AdminAlertPage +fields: + items: List + nextOffset: int? diff --git a/backend/sinalacs_server/lib/src/models/api/admin_alerts_pulse.spy.yaml b/backend/sinalacs_server/lib/src/models/api/admin_alerts_pulse.spy.yaml new file mode 100644 index 00000000..01b1c4b1 --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/api/admin_alerts_pulse.spy.yaml @@ -0,0 +1,13 @@ +### Pulso da monitoração do backoffice (issue #44, D5): o que o painel consulta +### a cada 30 s. Sem identidade nenhuma — nem o rótulo do paciente: só contagens +### e instantes. O dashboard se atualiza por aqui (`indicators` são os mesmos de +### `admin.indicators`, no mesmo escopo); a lista só é relida por `admin.alerts` +### quando `maxChangedAt` muda. `overdueRed` segue a regra de `AdminAlert.overdue`. +### `maxChangedAt` é nulo quando o escopo não tem alerta; `serverNow` é o +### relógio do servidor, nunca o do aparelho. +class: AdminAlertsPulse +fields: + indicators: AdminIndicators + overdueRed: int + maxChangedAt: DateTime? + serverNow: DateTime diff --git a/backend/sinalacs_server/lib/src/models/api/admin_audit_chain_status.spy.yaml b/backend/sinalacs_server/lib/src/models/api/admin_audit_chain_status.spy.yaml new file mode 100644 index 00000000..59912e17 --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/api/admin_audit_chain_status.spy.yaml @@ -0,0 +1,24 @@ +### Resultado de `admin.verifyAuditChain` (#45, D3/D9). Nunca carrega hash, +### segredo nem conteúdo de linha. +### - `state`: só `verified` é verde; qualquer outro, nunca. +### - `checkedRows`: linhas conferidas antes da quebra (ou o total). Só para o +### administrador; nulo para o coordenador (não infere volume de outra UBS). +### - `headSequence`: última `sequence` da cadeia íntegra. Só administrador e +### só com `verified`. +### - `firstBrokenSequence`: onde a cadeia quebrou. Para o coordenador, só se +### a linha quebrada for de um autor da UBS dele; senão nulo. +### - `brokenInScope`: com `broken`, se a quebra é visível no escopo de quem +### chama (sempre `true` para o administrador); nulo nos outros estados ou se +### o pertencimento não pôde ser apurado. +### - `reasonCode`: motivo fechado; o texto livre do verificador não sai do +### servidor. Para o coordenador, quebra fora do escopo vem sem motivo. +### - `verifiedAt`: quando a verificação servida terminou (cache de 60 s). +class: AdminAuditChainStatus +fields: + state: AuditIntegrityState + checkedRows: int? + headSequence: int? + firstBrokenSequence: int? + brokenInScope: bool? + reasonCode: AuditChainReason? + verifiedAt: DateTime diff --git a/backend/sinalacs_server/lib/src/models/api/admin_audit_entry.spy.yaml b/backend/sinalacs_server/lib/src/models/api/admin_audit_entry.spy.yaml new file mode 100644 index 00000000..d3b711fb --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/api/admin_audit_entry.spy.yaml @@ -0,0 +1,14 @@ +### Linha de auditoria para o backoffice. Sem `ipHash`, `previousHash` nem +### `entryHash`: a cadeia de hash é do servidor, não da tela. +class: AdminAuditEntry +fields: + id: String + sequence: int + userLabel: String + actionType: String + resourceType: String + timestamp: DateTime + result: String + ### Selo da linha (#45, D3), calculado no servidor. O padrão é + ### `unverifiable` — nunca `verified` por omissão. + integrity: AuditRowSeal, default=unverifiable diff --git a/backend/sinalacs_server/lib/src/models/api/admin_audit_export.spy.yaml b/backend/sinalacs_server/lib/src/models/api/admin_audit_export.spy.yaml new file mode 100644 index 00000000..2167e0b7 --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/api/admin_audit_export.spy.yaml @@ -0,0 +1,24 @@ +### Resultado de `admin.exportAuditLogs` (#45, D5–D8). Só o administrador +### exporta. O arquivo é o CSV de `AuditCsv` (colunas fixas de D6: nunca +### `userId`, `resourceId`, `ipHash`, `previousHash`, `entryHash` nem segredo). +### - `fileName`: `auditoria__.csv` em UTC compacto; sem dado pessoal. +### - `csv`: o conteúdo (UTF-8, RFC 4180, CRLF), gerado em memória no servidor. +### - `rowCount`, `firstSequence`, `lastSequence`: o recorte exportado (as duas +### sequences são nulas com recorte vazio). O próprio evento `export` fica +### sempre FORA do arquivo (`sequence` maior que `lastSequence`). +### - `contentSha256`: SHA-256 (hex minúsculo) dos bytes UTF-8 de `csv`, o mesmo +### gravado em `audit_exports`. +### - `chainState`: estado da verificação global da cadeia no momento da +### exportação (cache de 60 s); qualquer coisa diferente de `verified` é +### registrada e devolvida como está. +### - `generatedAt`: instante da exportação (UTC), o `requestedAt` gravado. +class: AdminAuditExport +fields: + fileName: String + csv: String + rowCount: int + firstSequence: int? + lastSequence: int? + contentSha256: String + chainState: AuditIntegrityState + generatedAt: DateTime diff --git a/backend/sinalacs_server/lib/src/models/api/admin_audit_filter.spy.yaml b/backend/sinalacs_server/lib/src/models/api/admin_audit_filter.spy.yaml new file mode 100644 index 00000000..3d2b108f --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/api/admin_audit_filter.spy.yaml @@ -0,0 +1,26 @@ +### Filtro da trilha de auditoria do backoffice (#45, D1/D2/D10). Todos os +### campos são opcionais e combinados por AND; nulo = sem restrição. Nunca +### alarga o escopo: o coordenador continua vendo só os autores da UBS dele. +### +### Validação no `AdminReadService` (antes de auditar a leitura): +### - `actorEnrollmentId`: matrícula do autor (ACS ou staff), igualdade sem +### caixa, 1 a 32 caracteres. Paciente não é filtrável por identificador. +### - `actionType`: `read`, `write`, `login` ou `export`. +### - `resourceType`, `result`: `^[a-z0-9_:]{1,64}$`, igualdade (o `:` e os +### dígitos cobrem resultados como `escalated:no_ack_timeout`, da #44). +### - `from`/`to`: UTC; `from` inclusivo, `to` exclusivo, `from < to` e janela +### de no máximo 366 dias quando os dois vêm. +### - `search`: 2 a 64 caracteres; casa só o início da matrícula do autor, +### `resourceType` e `result` (sem caixa, curingas escapados). Nunca hash, +### IP, UUID ou nome. +### Não há filtro por `resourceId` (D10: pode ser id de paciente). +class: AdminAuditFilter +fields: + actorEnrollmentId: String? + actorRole: UserRole? + actionType: String? + resourceType: String? + result: String? + from: DateTime? + to: DateTime? + search: String? diff --git a/backend/sinalacs_server/lib/src/models/api/admin_audit_page.spy.yaml b/backend/sinalacs_server/lib/src/models/api/admin_audit_page.spy.yaml new file mode 100644 index 00000000..6353f85f --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/api/admin_audit_page.spy.yaml @@ -0,0 +1,6 @@ +### Página de auditoria por keyset: `nextBeforeSequence` é o `sequence` da última +### linha devolvida (o log só cresce, então `offset` repetiria linhas). +class: AdminAuditPage +fields: + items: List + nextBeforeSequence: int? diff --git a/backend/sinalacs_server/lib/src/models/api/admin_data_subject_request.spy.yaml b/backend/sinalacs_server/lib/src/models/api/admin_data_subject_request.spy.yaml new file mode 100644 index 00000000..fa333ff3 --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/api/admin_data_subject_request.spy.yaml @@ -0,0 +1,12 @@ +### Linha da lista de pedidos do titular no backoffice. Sem nome nem CPF: +### `patientLabel` é um rótulo curto (`#A18F`) derivado do id do titular. +class: AdminDataSubjectRequest +fields: + id: String + type: DataSubjectRequestType + status: DataSubjectRequestStatus + createdAt: DateTime + dueAt: DateTime + ### Vencido: status `open`/`inReview` e agora > `dueAt`. + overdue: bool + patientLabel: String diff --git a/backend/sinalacs_server/lib/src/models/api/admin_data_subject_request_detail.spy.yaml b/backend/sinalacs_server/lib/src/models/api/admin_data_subject_request_detail.spy.yaml new file mode 100644 index 00000000..10c8d7ad --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/api/admin_data_subject_request_detail.spy.yaml @@ -0,0 +1,14 @@ +### Detalhe de um pedido do titular. `details` e `resolution` são decifrados +### só aqui (nunca na lista) e a leitura é auditada antes do dado. +class: AdminDataSubjectRequestDetail +fields: + id: String + type: DataSubjectRequestType + status: DataSubjectRequestStatus + createdAt: DateTime + dueAt: DateTime + overdue: bool + patientLabel: String + details: String? + resolution: String? + decidedAt: DateTime? diff --git a/backend/sinalacs_server/lib/src/models/api/admin_data_subject_request_page.spy.yaml b/backend/sinalacs_server/lib/src/models/api/admin_data_subject_request_page.spy.yaml new file mode 100644 index 00000000..6bd86c26 --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/api/admin_data_subject_request_page.spy.yaml @@ -0,0 +1,5 @@ +### Página de pedidos do titular. `nextOffset` nulo = não há mais páginas. +class: AdminDataSubjectRequestPage +fields: + items: List + nextOffset: int? diff --git a/backend/sinalacs_server/lib/src/models/api/admin_escalation_result.spy.yaml b/backend/sinalacs_server/lib/src/models/api/admin_escalation_result.spy.yaml new file mode 100644 index 00000000..2e53a69d --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/api/admin_escalation_result.spy.yaml @@ -0,0 +1,7 @@ +### Resultado de escalonar um alerta pelo backoffice. `alreadyEscalated` torna +### a operação idempotente: repetir o pedido não duplica o escalonamento. +class: AdminEscalationResult +fields: + alertId: String + escalatedAt: DateTime + alreadyEscalated: bool diff --git a/backend/sinalacs_server/lib/src/models/api/admin_indicators.spy.yaml b/backend/sinalacs_server/lib/src/models/api/admin_indicators.spy.yaml new file mode 100644 index 00000000..e4af56af --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/api/admin_indicators.spy.yaml @@ -0,0 +1,12 @@ +### Indicadores do painel do backoffice (issue #40). Contagens por risco e o +### TMRAV, no escopo do chamador (sistema inteiro para o administrador, a UBS +### para o coordenador). `tmravSeconds` é nulo quando não há alerta vermelho +### reconhecido na janela: sem amostra não há média, e zero seria mentira. +class: AdminIndicators +fields: + red: int + yellow: int + green: int + openRedAlerts: int + acknowledgedRedAlerts: int + tmravSeconds: int? diff --git a/backend/sinalacs_server/lib/src/models/api/admin_micro_area.spy.yaml b/backend/sinalacs_server/lib/src/models/api/admin_micro_area.spy.yaml new file mode 100644 index 00000000..c8aaa1cf --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/api/admin_micro_area.spy.yaml @@ -0,0 +1,8 @@ +### Microárea e o ACS vinculado, para a listagem somente leitura do backoffice. +class: AdminMicroArea +fields: + id: String + name: String + acsName: String + acsEnrollmentId: String + acsActive: bool diff --git a/backend/sinalacs_server/lib/src/models/api/admin_password_reset_result.spy.yaml b/backend/sinalacs_server/lib/src/models/api/admin_password_reset_result.spy.yaml new file mode 100644 index 00000000..fcacf2a8 --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/api/admin_password_reset_result.spy.yaml @@ -0,0 +1,11 @@ +### Senha nova do ACS redefinida pelo backoffice (issue #43). +### +### Existe **só** nesta resposta: não há coluna, log nem linha de auditoria que +### a guarde — o servidor persiste apenas o hash Argon2id em `user_credentials`. +### A senha é sorteada pelo servidor (o operador não a escolhe), pelo mesmo +### motivo da senha inicial: uma senha escolhida por quem tem pressa vira a +### credencial do RF07. Quem redefine mostra uma vez; perdida de novo, o caminho +### é redefinir outra vez, nunca recuperar. +class: AdminPasswordResetResult +fields: + newPassword: String diff --git a/backend/sinalacs_server/lib/src/models/api/admin_staff.spy.yaml b/backend/sinalacs_server/lib/src/models/api/admin_staff.spy.yaml new file mode 100644 index 00000000..d9b4a19e --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/api/admin_staff.spy.yaml @@ -0,0 +1,15 @@ +### Conta de equipe do backoffice (#43): identificação profissional, papel, +### UBS e estado de acesso. Nunca inclui CPF, senha ou contato. +### +### `ubsName` é nulo para o administrador (vê o sistema inteiro) e para o +### coordenador ainda sem UBS — que é recusado em tudo até ter uma. +### `mfaActive` tem a mesma leitura de `AdminAcs`: `totpEnabledAt` preenchido. +class: AdminStaff +fields: + id: String + name: String + enrollmentId: String + role: UserRole + ubsName: String? + active: bool + mfaActive: bool diff --git a/backend/sinalacs_server/lib/src/models/api/admin_staff_mfa_reset_result.spy.yaml b/backend/sinalacs_server/lib/src/models/api/admin_staff_mfa_reset_result.spy.yaml new file mode 100644 index 00000000..d2c7ec51 --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/api/admin_staff_mfa_reset_result.spy.yaml @@ -0,0 +1,17 @@ +### Redefinição da MFA de uma conta de equipe pelo backoffice (issue #43), com +### o código de ativação novo emitido **pela tela** — o deferimento da #48. +### +### Fecha a pendência registrada no `PROGRESS.md` (L665): até aqui só a CLI de +### operador `bin/issue_staff_activation_code.dart` emitia, e a emissão não +### entrava em `audit_logs`. Agora a operação faz as duas coisas numa transação +### só — zera as quatro colunas `totp*` da conta e grava +### `activationCodeHash/ExpiresAt/IssuedBy/IssuedAt` — e a linha +### `admin_staff`/`mfa_reset` com o id do alvo fica na trilha. +### +### O código existe **só** nesta resposta: o servidor guarda apenas o SHA-256 +### (`StaffActivationCode.hash`), como no `#48`. Quem redefine mostra uma vez; +### perdido de novo, o caminho é redefinir outra vez, nunca recuperar. +class: AdminStaffMfaResetResult +fields: + activationCode: String + activationCodeExpiresAt: DateTime diff --git a/backend/sinalacs_server/lib/src/models/api/development_login_result.spy.yaml b/backend/sinalacs_server/lib/src/models/api/development_login_result.spy.yaml index bca1408f..add59ba1 100644 --- a/backend/sinalacs_server/lib/src/models/api/development_login_result.spy.yaml +++ b/backend/sinalacs_server/lib/src/models/api/development_login_result.spy.yaml @@ -3,3 +3,11 @@ class: DevelopmentLoginResult fields: accessToken: String tokenType: String + ### Só no `loginInstitutional` e no `refreshSession`. `null` nos demais + ### emissores (paciente, `developmentLogin`). + refreshToken: String? + ### Token de envio diferido (D7): só no `loginInstitutional` com `deviceId` + ### real. Serve apenas a `visits.syncDeferred`/`visits.revokeUploadToken`. + ### `null` em todos os outros emissores (paciente, `developmentLogin`, + ### `refreshSession`). + uploadToken: String? diff --git a/backend/sinalacs_server/lib/src/models/api/notice_history_entry.spy.yaml b/backend/sinalacs_server/lib/src/models/api/notice_history_entry.spy.yaml new file mode 100644 index 00000000..6fce564e --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/api/notice_history_entry.spy.yaml @@ -0,0 +1,20 @@ +### Linha do histórico de avisos agendados (#46). Deliberadamente SEM o texto +### da mensagem: o backoffice lista título, alvo e resultado. +### +### `createdByLabel` é a matrícula de quem agendou ("—" se a conta não tem). +### `recipients`/`accepted` só existem depois do envio; `failureReason` só em +### `failed`/`unknown`. +class: NoticeHistoryEntry +fields: + id: String + microAreaId: String + microAreaName: String + audience: String + title: String + status: ScheduledNoticeStatus + scheduledFor: DateTime + sentAt: DateTime? + recipients: int? + accepted: int? + failureReason: String? + createdByLabel: String diff --git a/backend/sinalacs_server/lib/src/models/api/notice_history_page.spy.yaml b/backend/sinalacs_server/lib/src/models/api/notice_history_page.spy.yaml new file mode 100644 index 00000000..bb658bb5 --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/api/notice_history_page.spy.yaml @@ -0,0 +1,4 @@ +### Página do histórico de avisos agendados (#46), mais recentes primeiro. +class: NoticeHistoryPage +fields: + entries: List diff --git a/backend/sinalacs_server/lib/src/models/api/notice_send_result.spy.yaml b/backend/sinalacs_server/lib/src/models/api/notice_send_result.spy.yaml new file mode 100644 index 00000000..75271fa5 --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/api/notice_send_result.spy.yaml @@ -0,0 +1,7 @@ +### Resultado de `notices.sendSegmented` (RF14): quantos pacientes da microárea +### consentiram em receber o aviso e quantas notificações o relé aceitou. +### Só contagens — nunca a lista de destinatários. +class: NoticeSendResult +fields: + recipients: int + accepted: int diff --git a/backend/sinalacs_server/lib/src/models/api/patient_data_overview.spy.yaml b/backend/sinalacs_server/lib/src/models/api/patient_data_overview.spy.yaml index b150da2a..791ac84e 100644 --- a/backend/sinalacs_server/lib/src/models/api/patient_data_overview.spy.yaml +++ b/backend/sinalacs_server/lib/src/models/api/patient_data_overview.spy.yaml @@ -13,3 +13,4 @@ fields: chronicConditions: List consents: List riskHistory: List + requests: List diff --git a/backend/sinalacs_server/lib/src/models/api/patient_data_subject_request_record.spy.yaml b/backend/sinalacs_server/lib/src/models/api/patient_data_subject_request_record.spy.yaml new file mode 100644 index 00000000..e7fa9100 --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/api/patient_data_subject_request_record.spy.yaml @@ -0,0 +1,11 @@ +### Um pedido do próprio titular, para o painel "Meus Dados". `details` já +### decifrado — é texto que o próprio titular escreveu (direito de acesso). +class: PatientDataSubjectRequestRecord +fields: + type: DataSubjectRequestType + status: DataSubjectRequestStatus + details: String? + createdAt: DateTime + dueAt: DateTime + ### Nota de resposta do backoffice, já decifrada; nula até a decisão. + resolution: String? diff --git a/backend/sinalacs_server/lib/src/models/api/terms_change_notice.spy.yaml b/backend/sinalacs_server/lib/src/models/api/terms_change_notice.spy.yaml new file mode 100644 index 00000000..bc3cfef1 --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/api/terms_change_notice.spy.yaml @@ -0,0 +1,8 @@ +### Aviso de mudança dos termos (LGPD-RF18): a versão que passa a valer, a data +### de vigência e um resumo do que muda. Publicado com pelo menos 15 dias de +### antecedência (`TermsChangeSchedule`). Só texto público; nada do titular. +class: TermsChangeNotice +fields: + version: String + effectiveFrom: DateTime + summary: String diff --git a/backend/sinalacs_server/lib/src/models/api/terms_version_entry.spy.yaml b/backend/sinalacs_server/lib/src/models/api/terms_version_entry.spy.yaml new file mode 100644 index 00000000..dc2512d1 --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/api/terms_version_entry.spy.yaml @@ -0,0 +1,11 @@ +### Versão dos termos de uso no backoffice (#46, LGPD-RF18). Sem os campos de +### quem revisou/publicou: o admin vê quando, não a identidade. +class: TermsVersionEntry +fields: + id: String + version: String + summary: String + effectiveFrom: DateTime + createdAt: DateTime + legallyReviewedAt: DateTime? + publishedAt: DateTime? diff --git a/backend/sinalacs_server/lib/src/models/api/totp_enrollment_start.spy.yaml b/backend/sinalacs_server/lib/src/models/api/totp_enrollment_start.spy.yaml new file mode 100644 index 00000000..450bb2e1 --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/api/totp_enrollment_start.spy.yaml @@ -0,0 +1,6 @@ +### Início da ativação da MFA: o segredo para o autenticador (base32) e a URI +### `otpauth://` que vira o QR. Volta só nesta resposta; nunca é gravado em claro. +class: TotpEnrollmentStart +fields: + secretBase32: String + otpauthUri: String diff --git a/backend/sinalacs_server/lib/src/models/api/ubs_contact.spy.yaml b/backend/sinalacs_server/lib/src/models/api/ubs_contact.spy.yaml new file mode 100644 index 00000000..7e96aca7 --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/api/ubs_contact.spy.yaml @@ -0,0 +1,6 @@ +### Contato da UBS do ACS autenticado (RF13). Só o que o botão "Ligar para a UBS" +### precisa: o nome para a mensagem e o telefone para o discador. +class: UbsContact +fields: + name: String + phone: String? diff --git a/backend/sinalacs_server/lib/src/models/audit_chain_checkpoint.spy.yaml b/backend/sinalacs_server/lib/src/models/audit_chain_checkpoint.spy.yaml new file mode 100644 index 00000000..1cdf58ab --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/audit_chain_checkpoint.spy.yaml @@ -0,0 +1,14 @@ +### Ponto de verificação da cadeia de `audit_logs` (#45): `entryHash` na +### posição `sequence`, assinado, para detectar truncamento do final da cadeia. +class: AuditChainCheckpoint +table: audit_chain_checkpoints +fields: + id: UuidValue?, defaultPersist=random + sequence: int + entryHash: String + signature: String + createdAt: DateTime +indexes: + audit_chain_checkpoints_sequence_idx: + fields: sequence + unique: true diff --git a/backend/sinalacs_server/lib/src/models/audit_export.spy.yaml b/backend/sinalacs_server/lib/src/models/audit_export.spy.yaml new file mode 100644 index 00000000..019c9d77 --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/audit_export.spy.yaml @@ -0,0 +1,27 @@ +### Registro de cada exportação da trilha de auditoria (#45). Append-only: +### guarda quem exportou, os filtros, o intervalo de `sequence` coberto e o +### SHA-256 do conteúdo, para que a própria exportação seja auditável. +class: AuditExport +table: audit_exports +fields: + id: UuidValue?, defaultPersist=random + requestedBy: UuidValue, relation(parent=users) + requestedAt: DateTime + ### Só os parâmetros do filtro (`AdminAuditFilter.toJson`, chaves + ### `from`/`to` e as opcionais), nunca linhas exportadas. LGPD: guarda o + ### `search` e o `actorEnrollmentId` digitados pelo administrador — texto + ### livre de até 64 caracteres que casa só com matrícula do autor, + ### `resourceType` e `result` (nunca nome, CPF, UUID ou hash), mas que o + ### operador pode preencher com qualquer coisa: tratar como dado pessoal + ### do staff (identificação profissional) e não ecoar em log. + filtersJson: String + format: String + rowCount: int + firstSequence: int? + lastSequence: int? + contentSha256: String + ### Resultado da verificação da cadeia no momento da exportação. + chainState: String +indexes: + audit_exports_requested_at_idx: + fields: requestedAt 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 8518187b..a1369ea7 100644 --- a/backend/sinalacs_server/lib/src/models/audit_log.spy.yaml +++ b/backend/sinalacs_server/lib/src/models/audit_log.spy.yaml @@ -33,3 +33,11 @@ indexes: audit_logs_sequence_idx: fields: sequence unique: true + ### Consulta do backoffice (#45): filtro por período e ordenação por tempo. + ### Medido com 50 mil linhas: Seq Scan + Sort (5 ms) -> Index Scan (0,06 ms). + audit_logs_timestamp_idx: + fields: timestamp + ### Filtro por ator, paginado por `sequence`. Para ator pouco frequente o + ### plano passa de Seq Scan para Bitmap Index Scan (1,8 ms -> 0,06 ms). + audit_logs_user_sequence_idx: + fields: userId, sequence diff --git a/backend/sinalacs_server/lib/src/models/data_subject_request.spy.yaml b/backend/sinalacs_server/lib/src/models/data_subject_request.spy.yaml new file mode 100644 index 00000000..c68569d5 --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/data_subject_request.spy.yaml @@ -0,0 +1,28 @@ +### Pedido do titular sobre os próprios dados (LGPD-RF08): exclusão ou +### correção, com prazo de resposta de 15 dias (spec/lgpd_design.md 596-597). +### +### `details` é texto livre do titular e pode citar condição de saúde — por +### isso é cifrado na aplicação (AES-256-GCM), pelo mesmo motivo e com o +### mesmo `HealthDataCipher` de `visits.notes` (RNF03/INV-04). Num pedido de +### exclusão guarda o JSON `null` cifrado. +class: DataSubjectRequest +table: data_subject_requests +fields: + id: UuidValue?, defaultPersist=random + userId: UuidValue, relation(parent=users) + requestType: DataSubjectRequestType + detailsEncrypted: String + detailsKeyVersion: int + status: DataSubjectRequestStatus + createdAt: DateTime + ### Prazo de resposta: `createdAt` + 15 dias. + dueAt: DateTime + ### Decisão do backoffice (#42); nulos enquanto o pedido está `open`. + decidedAt: DateTime? + decidedBy: UuidValue?, relation(parent=users) + ### Nota de resposta/motivo da recusa: texto livre, cifrado como `details`. + resolutionEncrypted: String? + resolutionKeyVersion: int? +indexes: + data_subject_requests_user_id_type_idx: + fields: userId, requestType diff --git a/backend/sinalacs_server/lib/src/models/enums/audit_chain_reason.spy.yaml b/backend/sinalacs_server/lib/src/models/enums/audit_chain_reason.spy.yaml new file mode 100644 index 00000000..895ce16a --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/enums/audit_chain_reason.spy.yaml @@ -0,0 +1,23 @@ +### Motivo fechado de uma verificação da cadeia de `audit_logs` que não deu +### `ok` (#45, D9). Substitui, para o cliente, o texto livre `reason` do +### verificador: nunca vai hash, segredo nem conteúdo de linha. +### `hashMismatch` = HMAC da linha não confere; `linkBroken` = `previousHash` +### não aponta para a anterior; `sequenceGap` = `sequence` descontínua; +### `tailTruncated` = cadeia termina antes do último checkpoint válido, ou o +### `entryHash` naquela `sequence` diverge do checkpoint (Task 7); +### `checkpointInvalid` = cadeia íntegra, mas há checkpoint com assinatura +### inválida — ignorado e sinalizado, nunca aceito (Task 7; estado inconclusive); +### `timeBudget` = orçamento de tempo esgotado antes do fim (resultado parcial, +### nunca íntegro); `databaseError` = falha ao ler; `secretUnavailable` = +### segredo da cadeia ausente. +enum: AuditChainReason +serialized: byName +values: + - hashMismatch + - linkBroken + - sequenceGap + - tailTruncated + - checkpointInvalid + - timeBudget + - databaseError + - secretUnavailable diff --git a/backend/sinalacs_server/lib/src/models/enums/audit_integrity_state.spy.yaml b/backend/sinalacs_server/lib/src/models/enums/audit_integrity_state.spy.yaml new file mode 100644 index 00000000..b7c016c4 --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/enums/audit_integrity_state.spy.yaml @@ -0,0 +1,14 @@ +### Estado global da cadeia de `audit_logs` (#45, D3), calculado no servidor +### por `admin.verifyAuditChain`. **Só `verified` é verde.** `broken` = a +### verificação achou uma quebra (pode ser adulteração ou segredo trocado: o +### painel diz "falha", nunca "adulteração" por conta própria); +### `unavailable` = não deu para verificar (banco, tempo de leitura ou segredo +### ausente); `inconclusive` = nada a atestar (cadeia vazia) ou verificação +### parcial (orçamento de tempo esgotado). +enum: AuditIntegrityState +serialized: byName +values: + - verified + - broken + - unavailable + - inconclusive diff --git a/backend/sinalacs_server/lib/src/models/enums/audit_row_seal.spy.yaml b/backend/sinalacs_server/lib/src/models/enums/audit_row_seal.spy.yaml new file mode 100644 index 00000000..cf593ec4 --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/enums/audit_row_seal.spy.yaml @@ -0,0 +1,11 @@ +### Selo de integridade de UMA linha de `audit_logs` (#45, D3), calculado no +### servidor com a mesma `AuditChain` que grava a trilha. Só `verified` é verde: +### `invalid` = o HMAC da linha ou o elo com a `sequence - 1` não confere; +### `unverifiable` = a linha `sequence - 1` não existe no banco (o elo não pôde +### ser conferido). Nunca carrega hash nem segredo. +enum: AuditRowSeal +serialized: byName +values: + - verified + - invalid + - unverifiable diff --git a/backend/sinalacs_server/lib/src/models/enums/consent_purpose.spy.yaml b/backend/sinalacs_server/lib/src/models/enums/consent_purpose.spy.yaml index 7b0f78a6..6d388ef2 100644 --- a/backend/sinalacs_server/lib/src/models/enums/consent_purpose.spy.yaml +++ b/backend/sinalacs_server/lib/src/models/enums/consent_purpose.spy.yaml @@ -1,10 +1,14 @@ -### As três finalidades de consentimento do onboarding (decisão §2.2 de +### As finalidades de consentimento do onboarding (decisão §2.2 de ### docs/superpowers/specs/2026-09-16-decisoes-produto-pos-validacao.md). -### `healthDataProcessing` é obrigatória para usar o app; as outras duas -### podem ser recusadas sem impedir o restante do fluxo. +### `healthDataProcessing` e `termsOfUse` são obrigatórias para usar o app; +### as outras duas podem ser recusadas sem impedir o restante do fluxo. +### `termsOfUse` registra o aceite do Termo de Uso e da Política de +### Privacidade na versão `consentPolicyVersion` (LGPD-RF18/RF19) — não é +### consentimento revogável pelo painel, é a condição de uso do app. enum: ConsentPurpose serialized: byName values: - healthDataProcessing - localReminders - segmentedPush + - termsOfUse diff --git a/backend/sinalacs_server/lib/src/models/enums/data_subject_request_status.spy.yaml b/backend/sinalacs_server/lib/src/models/enums/data_subject_request_status.spy.yaml new file mode 100644 index 00000000..9e3099d4 --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/enums/data_subject_request_status.spy.yaml @@ -0,0 +1,10 @@ +### Situação de um pedido do titular. `open` é escrito pelo titular; `inReview`, +### `completed` e `rejected` pelo backoffice (coordenador/admin, #42). +### `completed` e `rejected` são finais. +enum: DataSubjectRequestStatus +serialized: byName +values: + - open + - inReview + - completed + - rejected diff --git a/backend/sinalacs_server/lib/src/models/enums/data_subject_request_type.spy.yaml b/backend/sinalacs_server/lib/src/models/enums/data_subject_request_type.spy.yaml new file mode 100644 index 00000000..4cea0d30 --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/enums/data_subject_request_type.spy.yaml @@ -0,0 +1,7 @@ +### Tipo de pedido do titular sobre os próprios dados (LGPD-RF08, Art. 18): +### exclusão/anonimização ou correção. Ver spec/lgpd_design.md linhas 583-597. +enum: DataSubjectRequestType +serialized: byName +values: + - deletion + - correction diff --git a/backend/sinalacs_server/lib/src/models/enums/escalation_reason.spy.yaml b/backend/sinalacs_server/lib/src/models/enums/escalation_reason.spy.yaml new file mode 100644 index 00000000..3f16618b --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/enums/escalation_reason.spy.yaml @@ -0,0 +1,8 @@ +### Motivo do escalonamento de um alerta (#44). Conjunto fechado, sem texto +### livre: texto livre num alerta de saúde é dado pessoal sensível (LGPD). +enum: EscalationReason +serialized: byName +values: + - noAckTimeout + - acsUnreachable + - patientRecontact diff --git a/backend/sinalacs_server/lib/src/models/enums/scheduled_notice_status.spy.yaml b/backend/sinalacs_server/lib/src/models/enums/scheduled_notice_status.spy.yaml new file mode 100644 index 00000000..baad0974 --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/enums/scheduled_notice_status.spy.yaml @@ -0,0 +1,10 @@ +### Ciclo de um aviso agendado. `sending` é a reserva atômica anti-duplicidade. +enum: ScheduledNoticeStatus +serialized: byName +values: + - pending + - sending + - sent + - unknown + - failed + - cancelled diff --git a/backend/sinalacs_server/lib/src/models/enums/visit_authorship.spy.yaml b/backend/sinalacs_server/lib/src/models/enums/visit_authorship.spy.yaml new file mode 100644 index 00000000..21c45038 --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/enums/visit_authorship.spy.yaml @@ -0,0 +1,11 @@ +### Quem é o autor de uma visita (D4 do plano 2026-10-03). +### +### `acs`: o ACS da sessão que a enviou por `visits.sync` — `visits.acsId` é +### ele. `legacyUnclaimed`: visita gravada no aparelho antes de existir dono por +### visita (migração v7 do banco local do app); a autoria é DESCONHECIDA e +### `visits.acsId` fica nulo. Só `visits.syncLegacy` grava este valor. +enum: VisitAuthorship +serialized: byName +values: + - acs + - legacyUnclaimed diff --git a/backend/sinalacs_server/lib/src/models/exceptions/admin_invalid_request_exception.spy.yaml b/backend/sinalacs_server/lib/src/models/exceptions/admin_invalid_request_exception.spy.yaml new file mode 100644 index 00000000..bd82a553 --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/exceptions/admin_invalid_request_exception.spy.yaml @@ -0,0 +1,5 @@ +### Parâmetro do backoffice fora do intervalo (por exemplo, `limit` de paginação). +### Não é erro de permissão. +exception: AdminInvalidRequestException +fields: + message: String diff --git a/backend/sinalacs_server/lib/src/models/exceptions/data_rights_exception.spy.yaml b/backend/sinalacs_server/lib/src/models/exceptions/data_rights_exception.spy.yaml new file mode 100644 index 00000000..866b5a6c --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/exceptions/data_rights_exception.spy.yaml @@ -0,0 +1,6 @@ +### Recusa de negócio de uma operação do titular sobre os próprios dados +### (consentimento obrigatório, texto de correção vazio ou longo demais). +### Mensagem pronta para a pessoa ler; nunca cita dado do paciente. +exception: DataRightsException +fields: + message: String diff --git a/backend/sinalacs_server/lib/src/models/exceptions/mfa_enrollment_required_exception.spy.yaml b/backend/sinalacs_server/lib/src/models/exceptions/mfa_enrollment_required_exception.spy.yaml new file mode 100644 index 00000000..f3264a6f --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/exceptions/mfa_enrollment_required_exception.spy.yaml @@ -0,0 +1,5 @@ +### O servidor exige MFA e este ACS ainda não a ativou: a pessoa precisa +### ativar (auth.beginTotpEnrollment) antes de entrar. +exception: MfaEnrollmentRequiredException +fields: + message: String diff --git a/backend/sinalacs_server/lib/src/models/exceptions/mfa_required_exception.spy.yaml b/backend/sinalacs_server/lib/src/models/exceptions/mfa_required_exception.spy.yaml new file mode 100644 index 00000000..e616e68f --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/exceptions/mfa_required_exception.spy.yaml @@ -0,0 +1,5 @@ +### O ACS tem MFA ativa, a senha conferiu e faltou o código do autenticador. +### Só é lançada DEPOIS de a senha estar certa: não revela matrícula nem senha. +exception: MfaRequiredException +fields: + message: String diff --git a/backend/sinalacs_server/lib/src/models/exceptions/notice_delivery_exception.spy.yaml b/backend/sinalacs_server/lib/src/models/exceptions/notice_delivery_exception.spy.yaml new file mode 100644 index 00000000..14096462 --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/exceptions/notice_delivery_exception.spy.yaml @@ -0,0 +1,6 @@ +### O aviso comunitário não pôde ser entregue agora: o relé de push está fora do +### ar, lento, ou o envio não está configurado neste ambiente. Mensagem pronta +### para o ACS ler; nunca cita token, destinatário nem o texto enviado. +exception: NoticeDeliveryException +fields: + message: String diff --git a/backend/sinalacs_server/lib/src/models/exceptions/session_expired_exception.spy.yaml b/backend/sinalacs_server/lib/src/models/exceptions/session_expired_exception.spy.yaml new file mode 100644 index 00000000..821aa888 --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/exceptions/session_expired_exception.spy.yaml @@ -0,0 +1,6 @@ +### O refresh token não vale mais (expirado, revogado, reusado, de outro +### aparelho ou de conta desativada). Mensagem única de propósito: o motivo +### real fica só em audit_logs. +exception: SessionExpiredException +fields: + message: String diff --git a/backend/sinalacs_server/lib/src/models/patient.spy.yaml b/backend/sinalacs_server/lib/src/models/patient.spy.yaml index 6f8a9d52..640512a0 100644 --- a/backend/sinalacs_server/lib/src/models/patient.spy.yaml +++ b/backend/sinalacs_server/lib/src/models/patient.spy.yaml @@ -10,7 +10,16 @@ class: Patient table: patients fields: id: UuidValue?, defaultPersist=random - emergencyContact: String + ### Contato de emergência do paciente (PII de terceiro: nome/telefone), + ### cifrado em AES-256-GCM (RNF03, INV-04) — mesmo padrão de + ### chronicConditions. Antes era `emergencyContact` em texto claro; agora o + ### par `emergencyContactEncrypted`/`emergencyContactKeyVersion` guarda o + ### ciphertext, cifrado e decifrado na borda do repositório ORM + ### (OrmPatientDirectoryStore), nunca em application/. + emergencyContactEncrypted: String, default='' + ### Versão da chave que cifrou o campo acima, para permitir rotação futura + ### sem reescrever todas as linhas de uma vez. + emergencyContactKeyVersion: int, default=1 isChronic: bool ### JSON de List, cifrado em AES-256-GCM (RNF03, INV-04). Decifrado ### e cifrado na borda do repositório (OrmPatientDirectoryStore), nunca em diff --git a/backend/sinalacs_server/lib/src/models/push_token.spy.yaml b/backend/sinalacs_server/lib/src/models/push_token.spy.yaml new file mode 100644 index 00000000..9b861e48 --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/push_token.spy.yaml @@ -0,0 +1,22 @@ +### Token de push (FCM/APNs) do aparelho de um paciente que consentiu com +### `segmentedPush` (RF14, decisão §3.2). Uma linha por token: se o mesmo token +### aparece para outro titular, a linha muda de dono, nunca duplica. +### +### O token identifica um aparelho, não uma pessoa, mas junto de `userId` liga +### aparelho a titular — por isso some na revogação do consentimento. +class: PushToken +table: push_tokens +fields: + id: UuidValue?, defaultPersist=random + userId: UuidValue, relation(parent=users) + microAreaId: UuidValue? + token: String + platform: String + createdAt: DateTime + updatedAt: DateTime +indexes: + push_tokens_token_key: + fields: token + unique: true + push_tokens_user_id_idx: + fields: userId diff --git a/backend/sinalacs_server/lib/src/models/scheduled_notice.spy.yaml b/backend/sinalacs_server/lib/src/models/scheduled_notice.spy.yaml new file mode 100644 index 00000000..1ef10ec0 --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/scheduled_notice.spy.yaml @@ -0,0 +1,23 @@ +### Aviso comunitário agendado por staff (RF14, #46). O texto fica aqui porque o +### staff o digitou para ser enviado; ele nunca vai para audit_logs. +class: ScheduledNotice +table: scheduled_notices +fields: + id: UuidValue?, defaultPersist=random + createdBy: UuidValue, relation(parent=users) + microAreaId: UuidValue, relation(parent=micro_areas) + audience: String + title: String + message: String + scheduledFor: DateTime + status: ScheduledNoticeStatus + recipients: int? + accepted: int? + failureReason: String? + createdAt: DateTime + sentAt: DateTime? +indexes: + scheduled_notices_due_idx: + fields: status, scheduledFor + scheduled_notices_area_idx: + fields: microAreaId, createdAt diff --git a/backend/sinalacs_server/lib/src/models/staff_account.spy.yaml b/backend/sinalacs_server/lib/src/models/staff_account.spy.yaml new file mode 100644 index 00000000..a4542ed0 --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/staff_account.spy.yaml @@ -0,0 +1,23 @@ +### Conta de staff do backoffice (coordenador ou administrador). +### +### Tabela própria, e não uma coluna em `acs`: staff não é ACS, não tem UBS nem +### sincronização, e misturar os dois faria `acs.enrollmentId` virar um +### namespace compartilhado. O id é o UUID do usuário (mesma decisão de `Acs`). +### O papel mora em `users.role` (`coordinator` | `admin`); a credencial, em +### `user_credentials`. +class: StaffAccount +table: staff_accounts +fields: + id: UuidValue?, defaultPersist=random + enrollmentId: String + active: bool + ### UBS do coordenador; nulo para administrador. Coordenador sem UBS é recusado. + ubsId: UuidValue? + activationCodeHash: String? + activationCodeExpiresAt: DateTime? + activationCodeIssuedBy: String? + activationCodeIssuedAt: DateTime? +indexes: + staff_accounts_enrollment_id_key: + fields: enrollmentId + unique: true diff --git a/backend/sinalacs_server/lib/src/models/terms_version.spy.yaml b/backend/sinalacs_server/lib/src/models/terms_version.spy.yaml new file mode 100644 index 00000000..242fe885 --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/terms_version.spy.yaml @@ -0,0 +1,19 @@ +### Versão dos termos anunciada ao paciente (LGPD-RF18, #46). O TEXTO jurídico +### não fica aqui: vive no app do paciente. Aqui só o anúncio e a revisão. +class: TermsVersion +table: terms_versions +fields: + id: UuidValue?, defaultPersist=random + version: String + summary: String + effectiveFrom: DateTime + createdBy: UuidValue, relation(parent=users) + createdAt: DateTime + legallyReviewedAt: DateTime? + legallyReviewedBy: UuidValue?, relation(parent=users) + publishedAt: DateTime? + publishedBy: UuidValue?, relation(parent=users) +indexes: + terms_versions_version_key: + fields: version + unique: true diff --git a/backend/sinalacs_server/lib/src/models/ubs.spy.yaml b/backend/sinalacs_server/lib/src/models/ubs.spy.yaml index 44f82504..54a68359 100644 --- a/backend/sinalacs_server/lib/src/models/ubs.spy.yaml +++ b/backend/sinalacs_server/lib/src/models/ubs.spy.yaml @@ -7,3 +7,6 @@ fields: address: String city: String state: String + ### Telefone de contato da UBS para o ACS escalar um caso (RF13). Dado da + ### unidade, não de pessoa. `null` = ainda não cadastrado: o app avisa, não liga. + contactPhone: String? diff --git a/backend/sinalacs_server/lib/src/models/user_credential.spy.yaml b/backend/sinalacs_server/lib/src/models/user_credential.spy.yaml index ea1471d2..aa7b26de 100644 --- a/backend/sinalacs_server/lib/src/models/user_credential.spy.yaml +++ b/backend/sinalacs_server/lib/src/models/user_credential.spy.yaml @@ -24,8 +24,19 @@ fields: failedAttempts: int ### `null` = não bloqueado. lockedUntil: DateTime? + ### Rodadas de bloqueio seguidas, sem um login válido no meio. Alimenta o + ### bloqueio progressivo (15 min x 2^lockStreak, teto 24 h). Zera no login válido. + lockStreak: int, default=0 createdAt: DateTime updatedAt: DateTime + ### MFA por TOTP (RFC 6238). `null` = sem segredo gravado. O segredo é cifrado + ### (AES-256-GCM, a mesma chave dos dados clínicos); **nunca** em claro. + totpSecretEncrypted: String? + totpKeyVersion: int? + ### `null` = enrollment começou e não foi confirmado: a MFA ainda NÃO vale. + totpEnabledAt: DateTime? + ### Último passo de 30 s aceito. O mesmo código não entra duas vezes (replay). + totpLastStep: int? indexes: user_credentials_user_id_key: fields: userId diff --git a/backend/sinalacs_server/lib/src/models/visit.spy.yaml b/backend/sinalacs_server/lib/src/models/visit.spy.yaml index ad39e309..3cf8c471 100644 --- a/backend/sinalacs_server/lib/src/models/visit.spy.yaml +++ b/backend/sinalacs_server/lib/src/models/visit.spy.yaml @@ -4,7 +4,14 @@ table: visits fields: id: UuidValue?, defaultPersist=random patientId: UuidValue, relation(parent=patients) - acsId: UuidValue, relation(parent=acs) + ### Autor da visita. Nulo SÓ quando `authorship == legacyUnclaimed`. + acsId: UuidValue?, relation(parent=acs) + ### `legacyUnclaimed` = visita gravada no aparelho antes de existir dono (migração v7): + ### a autoria é desconhecida e `acsId` é nulo. NUNCA preencher `acsId` com quem transportou. + authorship: VisitAuthorship, default=acs + ### Instalação do app de onde veio uma visita legada (`visits.syncLegacy`). + ### Nulo nas visitas com autor ACS. + originDeviceId: String? scheduledAt: DateTime startedAt: DateTime? completedAt: DateTime? diff --git a/backend/sinalacs_server/lib/src/ops/staff_activation_issuer.dart b/backend/sinalacs_server/lib/src/ops/staff_activation_issuer.dart new file mode 100644 index 00000000..172f4541 --- /dev/null +++ b/backend/sinalacs_server/lib/src/ops/staff_activation_issuer.dart @@ -0,0 +1,54 @@ +import 'package:postgres/postgres.dart' as pg; +import 'package:sinalacs_server/src/application/auth/staff_activation_code.dart'; + +enum IssueStatus { issued, notFound } + +class IssueResult { + const IssueResult._(this.status, this.code, this.expiresAt); + + const IssueResult.notFound() : this._(IssueStatus.notFound, null, null); + + const IssueResult.issued(String code, DateTime expiresAt) : this._(IssueStatus.issued, code, expiresAt); + + final IssueStatus status; + + /// O código em claro. Só existe aqui, na saída da CLI: o banco guarda o hash. + final String? code; + final DateTime? expiresAt; +} + +/// Emite o código de ativação da MFA de uma conta de staff (#48). +/// +/// Matrícula inexistente e conta inativa dão o mesmo `notFound`: a CLI não +/// serve para sondar quais matrículas existem. Um código novo substitui o +/// anterior. A emissão fica registrada em `activationCodeIssuedBy`/`IssuedAt` +/// (a trilha `audit_logs` exige a cadeia de auditoria do servidor, que a CLI +/// não carrega). +Future issueStaffActivationCode( + pg.Session db, { + required String matricula, + required String issuedBy, + required Duration validity, + DateTime? now, +}) async { + final quem = issuedBy.trim(); + if (quem.isEmpty) throw ArgumentError('informe quem está emitindo o código (--issued-by)'); + final agora = (now ?? DateTime.now()).toUtc(); + + final conta = await db.execute( + pg.Sql.named('SELECT "id","active" FROM "staff_accounts" WHERE "enrollmentId" = @m'), + parameters: {'m': matricula.trim()}, + ); + if (conta.isEmpty || conta.single[1] != true) return const IssueResult.notFound(); + + final codigo = StaffActivationCode.generate(); + final expira = agora.add(validity); + await db.execute( + pg.Sql.named( + 'UPDATE "staff_accounts" SET "activationCodeHash" = @h, "activationCodeExpiresAt" = @e, ' + '"activationCodeIssuedBy" = @by, "activationCodeIssuedAt" = @at WHERE "id" = @id', + ), + parameters: {'h': StaffActivationCode.hash(codigo), 'e': expira, 'by': quem, 'at': agora, 'id': conta.single[0]}, + ); + return IssueResult.issued(codigo, expira); +} diff --git a/backend/sinalacs_server/lib/src/runtime/alert_runtime.dart b/backend/sinalacs_server/lib/src/runtime/alert_runtime.dart index 09665e18..20c84e24 100644 --- a/backend/sinalacs_server/lib/src/runtime/alert_runtime.dart +++ b/backend/sinalacs_server/lib/src/runtime/alert_runtime.dart @@ -1,3 +1,21 @@ +import 'package:sinalacs_server/src/application/audit/audit_chain.dart'; +import 'package:sinalacs_server/src/application/admin/admin_account_service.dart'; +import 'package:sinalacs_server/src/application/admin/admin_monitoring_service.dart'; +import 'package:sinalacs_server/src/application/admin/admin_read_service.dart'; +import 'package:sinalacs_server/src/application/admin/alert_escalation_service.dart'; +import 'package:sinalacs_server/src/application/admin/audit_chain_status_service.dart'; +import 'package:sinalacs_server/src/application/admin/audit_export_service.dart'; +import 'package:sinalacs_server/src/application/admin/data_subject_case_service.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_data_subject_case_store.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_admin_account_store.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_admin_read_store.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_audit_checkpoint_store.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_audit_export_store.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_alert_escalation_store.dart'; +import 'package:sinalacs_server/src/application/notices/notice_scheduling_service.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_scheduled_notice_store.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_terms_version_store.dart'; +import 'package:sinalacs_server/src/application/patients/terms_publication_service.dart'; import 'package:meta/meta.dart'; import 'package:serverpod/serverpod.dart'; import 'package:sinalacs_server/src/application/alerts/alert_outbox_dispatcher.dart'; @@ -6,27 +24,43 @@ 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/auth/cpf_hasher.dart'; import 'package:sinalacs_server/src/application/auth/institutional_auth_service.dart'; +import 'package:sinalacs_server/src/application/auth/refresh_token_service.dart'; +import 'package:sinalacs_server/src/application/auth/upload_token_service.dart'; import 'package:sinalacs_server/src/application/auth/password_hasher.dart'; import 'package:sinalacs_server/src/application/auth/passwordless_auth_service.dart'; import 'package:sinalacs_server/src/application/auth/sms_gateway.dart'; import 'package:sinalacs_server/src/application/onboarding/onboarding_service.dart'; +import 'package:sinalacs_server/src/application/patients/data_subject_rights_service.dart'; import 'package:sinalacs_server/src/application/patients/patient_data_overview_service.dart'; import 'package:sinalacs_server/src/application/patients/patient_directory_service.dart'; import 'package:sinalacs_server/src/application/triage/triage_session_service.dart'; +import 'package:sinalacs_server/src/application/ubs/ubs_contact_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/crypto/argon2_password_hasher.dart'; +import 'package:sinalacs_server/src/infrastructure/crypto/health_cipher_totp_vault.dart'; import 'package:sinalacs_server/src/infrastructure/crypto/health_data_cipher.dart'; import 'package:sinalacs_server/src/infrastructure/crypto/hmac_cpf_hasher.dart'; import 'package:sinalacs_server/src/infrastructure/database/orm_acs_credential_store.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_refresh_token_store.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_upload_token_store.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_chain_reader.dart'; import 'package:sinalacs_server/src/infrastructure/database/orm_audit_trail.dart'; import 'package:sinalacs_server/src/infrastructure/database/orm_onboarding_store.dart'; import 'package:sinalacs_server/src/infrastructure/database/orm_otp_challenge_store.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_data_subject_rights_store.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_notice_recipient_store.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_push_token_store.dart'; +import 'package:sinalacs_server/src/infrastructure/push/data_subject_push_notifier.dart'; +import 'package:sinalacs_server/src/infrastructure/push/gorush_client.dart'; +import 'package:sinalacs_server/src/application/notices/notice_service.dart'; +import 'package:sinalacs_server/src/application/patients/push_token_service.dart'; import 'package:sinalacs_server/src/infrastructure/database/orm_patient_data_overview_store.dart'; import 'package:sinalacs_server/src/infrastructure/database/orm_patient_directory_store.dart'; import 'package:sinalacs_server/src/infrastructure/database/orm_triage_session_store.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_ubs_contact_store.dart'; import 'package:sinalacs_server/src/infrastructure/database/orm_visit_store.dart'; import 'package:sinalacs_server/src/infrastructure/mqtt/mqtt_alert_dispatcher.dart'; @@ -44,6 +78,12 @@ class AlertRuntime { static final AlertRuntime instance = AlertRuntime._(); AppConfig? _config; + PushSender? Function()? _noticeSenderOverride; + + /// Um único [GorushClient] por processo (e por URL): um `HttpClient` novo por + /// requisição nunca era fechado. + GorushClient? _gorush; + String? _gorushUrl; MqttAlertDispatcher? _dispatcher; DevelopmentAuthService? _auth; HealthDataCipher? _healthDataCipher; @@ -116,6 +156,13 @@ class AlertRuntime { bool get isMqttConnected => _dispatcher?.isConnected ?? false; + /// Troca o relé de push dos avisos comunitários (RF14) por um duplo de teste. + /// `null` volta a montar o [GorushClient] a partir de `GORUSH_URL`. + @visibleForTesting + void overrideNoticeSender(PushSender? Function()? factory) { + _noticeSenderOverride = factory; + } + /// Substitui a configuração lida do ambiente. /// /// Existe para os testes de integração, que precisam exercitar o caminho com @@ -124,6 +171,9 @@ class AlertRuntime { @visibleForTesting void overrideConfig(AppConfig? value) { _config = value; + _gorush?.close(); + _gorush = null; + _gorushUrl = null; // O serviço de auth deriva do segredo, então precisa ser reconstruído. _auth = null; // Idem para a cifra: ela guarda a chave AES derivada de @@ -158,7 +208,14 @@ class AlertRuntime { /// publicação no broker falhar. RedAlertService serviceFor(Session session, {Transaction? transaction}) => RedAlertService( - store: OrmAlertStore(session: () => session, transaction: transaction), + store: OrmAlertStore( + session: () => session, + transaction: transaction, + appendAudit: OrmAuditTrail( + session: () => session, + chainSecret: config.auditChainSecret, + ).recordInTransaction, + ), outbox: OrmAlertOutbox(session: () => session, transaction: transaction), ); @@ -179,6 +236,165 @@ class AlertRuntime { audit: auditTrailFor(session), ); + /// Leitura do backoffice (#40), para uma requisição. A trilha é a da + /// requisição: cada leitura do staff vira uma linha encadeada em `audit_logs`. + AdminReadService adminReadServiceFor(Session session) => AdminReadService( + store: OrmAdminReadStore( + session: () => session, + escalationThreshold: Duration(seconds: config.redAlertEscalationSeconds), + ), + audit: auditTrailFor(session), + auditChain: AuditChain(secret: config.auditChainSecret), + ); + + /// Single-flight e cache de 60 s da verificação global da cadeia (#45, D9). + /// Estado do PROCESSO, como [_monitoringSessions]. + final AuditChainStatusCache _auditChainStatusCache = AuditChainStatusCache(); + + /// Esvazia o cache e o single-flight da verificação da cadeia. Existe para + /// os testes: o harness desfaz o banco entre os casos, não o processo. + @visibleForTesting + void resetAuditChainStatusCache() => _auditChainStatusCache.reset(); + + /// `admin.verifyAuditChain` (#45, Task 6), para uma requisição: reader, + /// store, checkpoints de cauda (Task 7) e trilha da chamada; cache e + /// single-flight do processo; o mesmo `AUDIT_CHAIN_SECRET` que grava a + /// trilha e assina os checkpoints. + AuditChainStatusService auditChainStatusServiceFor(Session session) => + AuditChainStatusService( + store: OrmAdminReadStore(session: () => session), + audit: auditTrailFor(session), + reader: OrmAuditChainReader(session: () => session), + secret: config.auditChainSecret, + checkpoints: OrmAuditCheckpointStore(session: () => session), + // Só o tipo do erro: nada de hash, assinatura ou SQL no log. + onCheckpointWriteFailure: (erro) => session.log( + 'Falha ao gravar checkpoint da cadeia de auditoria ' + '(${erro.runtimeType}); a verificação segue verified.', + level: LogLevel.warning, + ), + cache: _auditChainStatusCache, + ); + + /// `admin.exportAuditLogs` (#45, Task 10), para uma requisição. A + /// exportação e o evento `export` gravam na MESMA transação + /// (`OrmAuditTrail.recordInTransaction`, mesmo `AUDIT_CHAIN_SECRET`); as + /// recusas e falhas usam a trilha comum. O estado da cadeia vem da + /// verificação global do processo (cache de 60 s), sem uma segunda linha + /// de auditoria. A falha vai para o log só com o TIPO do erro: nunca + /// mensagem do banco, linha ou trecho do CSV. + AuditExportService auditExportServiceFor(Session session) => + AuditExportService( + store: OrmAuditExportStore( + session: () => session, + appendAudit: OrmAuditTrail( + session: () => session, + chainSecret: config.auditChainSecret, + ).recordInTransaction, + ), + audit: auditTrailFor(session), + auditChain: AuditChain(secret: config.auditChainSecret), + chainState: (inicio) => + auditChainStatusServiceFor(session).currentState(notBefore: inicio), + onFailure: (erro) => session.log( + 'Falha na exportação da auditoria (${erro.runtimeType}).', + level: LogLevel.warning, + ), + ); + + /// Sessões de monitoramento do backoffice (#44, D5): `userId → último + /// pulso`. Estado do PROCESSO, como o dispatcher MQTT — os endpoints são + /// construídos por requisição e perderiam o mapa. Instância única do + /// backend (PRD); um reinício só abre sessões novas (START de novo). + final Map _monitoringSessions = {}; + + /// Esvazia o mapa de sessões de monitoramento. Existe para os testes: o + /// harness desfaz o banco entre os casos, mas não o estado do processo. + @visibleForTesting + void resetMonitoringSessions() => _monitoringSessions.clear(); + + /// Pulso e sessão de monitoramento do backoffice (#44, D5), para uma + /// requisição: o store e a trilha são os da chamada; o mapa de sessões, o + /// do processo. O prazo é o mesmo de [adminReadServiceFor] e do + /// escalonamento. + AdminMonitoringService adminMonitoringServiceFor(Session session) { + final limite = Duration(seconds: config.redAlertEscalationSeconds); + return AdminMonitoringService( + store: OrmAdminReadStore( + session: () => session, + escalationThreshold: limite, + ), + audit: auditTrailFor(session), + sessions: _monitoringSessions, + threshold: limite, + ); + } + + /// Atendimento de pedidos do titular no backoffice (#42), para uma + /// requisição. A decisão é auditada DENTRO da transação do store + /// (`recordInTransaction`), com o mesmo `AUDIT_CHAIN_SECRET` da trilha; o + /// mesmo segredo assina o `denied` de push da anonimização, como no painel do + /// titular (`OrmDataSubjectRightsStore`). Leituras e recusas usam a trilha + /// comum da requisição. + DataSubjectCaseService dataSubjectCaseServiceFor(Session session) => + DataSubjectCaseService( + store: OrmDataSubjectCaseStore( + session, + healthDataCipher, + chainSecret: config.auditChainSecret, + appendAudit: OrmAuditTrail( + session: () => session, + chainSecret: config.auditChainSecret, + ).recordInTransaction, + ), + audit: auditTrailFor(session), + notifier: _dataSubjectNotifierFor(session), + ); + /// Gestão de contas do backoffice (#43), para uma requisição. Mesmo arranjo + /// de [adminReadServiceFor]: os stores são amarrados à sessão da chamada e a + /// trilha é a da requisição — cada operação vira uma linha encadeada em + /// `audit_logs`. A credencial do ACS e o estado da MFA vivem na mesma tabela + /// (`user_credentials`), por isso um store só serve às duas portas; o código + /// de ativação é do staff (`staff_accounts`), daí a segunda instância. + /// + /// Não há porta de refresh token nem de envio diferido aqui: a desativação + /// revoga as duas **dentro da transação do `OrmAdminAccountStore`**, para a + /// flag e as revogações serem um só estado (ver `setAcsActive`), e as demais + /// operações da #43 não tocam em token. + AdminAccountService adminAccountServiceFor(Session session) { + final credenciais = OrmAcsCredentialStore(session: () => session); + return AdminAccountService( + store: OrmAdminAccountStore(session: () => session), + credentials: credenciais, + totpStore: credenciais, + activationStore: OrmAcsCredentialStore(session: () => session, staff: true), + hasher: passwordHasher, + audit: auditTrailFor(session), + ); + } + + /// Escalonamento de alerta vermelho pelo backoffice (#44), para uma + /// requisição. O escalonamento gravado é auditado DENTRO da transação do + /// store (`recordInTransaction`), junto do `UPDATE` e do reforço no outbox; + /// as recusas usam a trilha comum da requisição. O reforço não é publicado + /// aqui: a varredura periódica do outbox (`server.dart`) o entrega. + AlertEscalationService alertEscalationServiceFor(Session session) => + AlertEscalationService( + store: OrmAlertEscalationStore( + session: () => session, + appendAudit: OrmAuditTrail( + session: () => session, + chainSecret: config.auditChainSecret, + ).recordInTransaction, + ), + audit: auditTrailFor(session), + threshold: Duration(seconds: config.redAlertEscalationSeconds), + ); + + /// Contato da UBS do ACS (RF13), para uma requisição. + UbsContactService ubsContactServiceFor(Session session) => + UbsContactService(store: OrmUbsContactStore(session)); + /// Constrói o diretório de pacientes da microárea para uma requisição. PatientDirectoryService patientDirectoryServiceFor(Session session) => PatientDirectoryService( @@ -199,6 +415,90 @@ class AlertRuntime { audit: auditTrailFor(session), ); + /// Constrói o serviço de direitos do titular (LGPD-RF05/RF08) para uma + /// requisição. + DataSubjectRightsService dataSubjectRightsServiceFor(Session session) => + DataSubjectRightsService( + store: OrmDataSubjectRightsStore( + session: () => session, + chainSecret: config.auditChainSecret, + cipher: healthDataCipher, + ), + audit: auditTrailFor(session), + termsVersionStore: OrmTermsVersionStore(session: () => session), + ); + + /// Aviso push ao titular; mesmo remetente do aviso comunitário. Sem relé, + /// sem notificador. + DataSubjectNotifier? _dataSubjectNotifierFor(Session session) { + final override = _noticeSenderOverride; + final url = config.gorushUrl; + final sender = override != null + ? override() + : (url == null ? null : _gorushClientFor(url)); + if (sender == null) return null; + return GorushDataSubjectNotifier( + targets: OrmDataSubjectPushTargets(session: () => session), + sender: sender, + ); + } + + /// Aviso comunitário do ACS (RF14). Sem `GORUSH_URL` o serviço nasce sem relé e + /// recusa o envio com uma mensagem clara. + NoticeService noticeServiceFor(Session session) { + final override = _noticeSenderOverride; + final url = config.gorushUrl; + return NoticeService( + store: OrmNoticeRecipientStore(session: () => session), + sender: override != null + ? override() + : (url == null ? null : _gorushClientFor(url)), + audit: auditTrailFor(session), + ); + } + + /// Agendamento e disparo de avisos comunitários (RF14, #46). O envio usa o + /// mesmo [NoticeService] de [noticeServiceFor], então a auditoria do disparo + /// é a do envio imediato. [clock] existe para os testes. + NoticeSchedulingService noticeSchedulingServiceFor( + Session session, { + DateTime Function()? clock, + }) => + NoticeSchedulingService( + store: OrmScheduledNoticeStore(session: () => session), + audit: auditTrailFor(session), + sendNow: noticeServiceFor(session).sendToMicroArea, + clock: clock, + ); + + /// Rascunho, revisão jurídica e publicação dos termos (LGPD-RF18, #46). + /// [clock] existe para os testes. + TermsPublicationService termsPublicationServiceFor( + Session session, { + DateTime Function()? clock, + }) => + TermsPublicationService( + store: OrmTermsVersionStore(session: () => session), + audit: auditTrailFor(session), + clock: clock, + ); + + GorushClient _gorushClientFor(String url) { + if (_gorush == null || _gorushUrl != url) { + _gorush?.close(); + _gorush = GorushClient(baseUrl: url, timeout: config.gorushTimeout); + _gorushUrl = url; + } + return _gorush!; + } + + /// Registro do aparelho para avisos segmentados (RF14). + PushTokenService pushTokenServiceFor(Session session) => + PushTokenService( + store: OrmPushTokenStore(session: () => session), + audit: auditTrailFor(session), + ); + /// Constrói o serviço de triagem persistida para uma requisição. TriageSessionService triageSessionServiceFor(Session session) => TriageSessionService( @@ -224,18 +524,54 @@ class AlertRuntime { ), ); + /// Refresh token do ACS (LGPD-RT06): store no ORM da sessão da chamada e a + /// trilha de auditoria da requisição. + RefreshTokenService refreshTokenServiceFor(Session session) => + RefreshTokenService( + store: OrmRefreshTokenStore(session: () => session), + audit: auditTrailFor(session), + ); + + /// Token de envio diferido do ACS (D7 do plano 2026-10-03): mesmo arranjo do + /// refresh token, store no ORM da sessão e trilha da requisição. + UploadTokenService uploadTokenServiceFor(Session session) => + UploadTokenService( + store: OrmUploadTokenStore(session: () => session), + audit: auditTrailFor(session), + ); + /// Serviço de login institucional para uma requisição. /// /// Mesmo arranjo dos outros `*ServiceFor`: o store recebe a sessão por /// chamada e a trilha de auditoria é a da requisição, para que cada /// tentativa (granted, denied_credentials, denied_locked…) vire uma linha /// encadeada em `audit_logs`. - InstitutionalAuthService institutionalAuthServiceFor(Session session) => - InstitutionalAuthService( - store: OrmAcsCredentialStore(session: () => session), - hasher: passwordHasher, - audit: auditTrailFor(session), - ); + InstitutionalAuthService institutionalAuthServiceFor(Session session) { + final store = OrmAcsCredentialStore(session: () => session); + return InstitutionalAuthService( + store: store, + hasher: passwordHasher, + audit: auditTrailFor(session), + totpStore: store, + vault: HealthCipherTotpVault(healthDataCipher), + requireMfa: config.requireAcsMfa, + ); + } + + /// Login do backoffice: mesma regra do ACS, outra tabela de matrículas, MFA + /// sempre obrigatória e token sem microárea. + InstitutionalAuthService staffAuthServiceFor(Session session) { + final store = OrmAcsCredentialStore(session: () => session, staff: true); + return InstitutionalAuthService( + store: store, + hasher: passwordHasher, + audit: auditTrailFor(session), + totpStore: store, + activationStore: store, + vault: HealthCipherTotpVault(healthDataCipher), + audience: CredentialAudience.staff, + ); + } /// Serviço de login passwordless do paciente (RF01) para uma requisição. /// diff --git a/backend/sinalacs_server/lib/src/runtime/notice_dispatch_loop.dart b/backend/sinalacs_server/lib/src/runtime/notice_dispatch_loop.dart new file mode 100644 index 00000000..7181a0d0 --- /dev/null +++ b/backend/sinalacs_server/lib/src/runtime/notice_dispatch_loop.dart @@ -0,0 +1,75 @@ +import 'dart:async'; + +/// Intervalo padrão do laço de disparo dos avisos agendados (#46). +const Duration defaultNoticeDispatchInterval = Duration(seconds: 30); + +/// Laço periódico que dispara os avisos agendados vencidos. +/// +/// Nunca morre por erro: uma falha em [tick] (ex.: banco fora) é registrada por +/// [onError] — que recebe só o erro, jamais texto de aviso — e o laço segue no +/// próximo ciclo. Não sobrepõe ciclos (um tick lento não empilha outro). +/// [startup] roda uma vez antes do primeiro ciclo (varredura de partida). +/// [timerFactory] existe para os testes injetarem um relógio. +class NoticeDispatchLoop { + NoticeDispatchLoop({ + required Future Function() tick, + Future Function()? startup, + void Function(Object error)? onError, + this.interval = defaultNoticeDispatchInterval, + Timer Function(Duration, void Function(Timer))? timerFactory, + }) : _tick = tick, + _startup = startup, + _onError = onError, + _timerFactory = timerFactory ?? Timer.periodic; + + final Future Function() _tick; + final Future Function()? _startup; + final void Function(Object error)? _onError; + final Timer Function(Duration, void Function(Timer)) _timerFactory; + final Duration interval; + + Timer? _timer; + bool _running = false; + bool _stopped = false; + Future? _startupDone; + + bool get isStarted => _timer != null; + + void start() { + if (_timer != null || _stopped) return; + _startupDone = _runStartup(); + _timer = _timerFactory(interval, (_) => unawaited(runOnce())); + } + + Future _runStartup() async { + final startup = _startup; + if (startup == null) return; + try { + await startup(); + } catch (e) { + _onError?.call(e); + } + } + + /// Um ciclo (público para os testes). Devolve quantos avisos processou. + Future runOnce() async { + if (_running || _stopped) return 0; + _running = true; + try { + await _startupDone; + return await _tick(); + } catch (e) { + _onError?.call(e); + return 0; + } finally { + _running = false; + } + } + + /// Encerra o laço (shutdown); ciclo em andamento termina sozinho. + void stop() { + _stopped = true; + _timer?.cancel(); + _timer = null; + } +} diff --git a/backend/sinalacs_server/lib/src/runtime/uncaught_error_policy.dart b/backend/sinalacs_server/lib/src/runtime/uncaught_error_policy.dart new file mode 100644 index 00000000..d58aaded --- /dev/null +++ b/backend/sinalacs_server/lib/src/runtime/uncaught_error_policy.dart @@ -0,0 +1,50 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:mqtt_client/mqtt_client.dart'; + +/// Política pura de classificação de erros assíncronos sem dono (#56). +/// +/// Um erro de E/S transitório é o que uma queda de rede ou do broker produz +/// (socket reiniciado, handshake TLS interrompido, tempo esgotado, cliente MQTT +/// sem conexão). Reutilizada pelo dispatcher MQTT e pela zona global de +/// `bin/main.dart`, para a lista não existir em dois lugares. +bool isTransientIoError(Object e) => + e is SocketException || + e is TlsException || // inclui HandshakeException + e is TimeoutException || + e is ConnectionException || + e is NoConnectionException || + e is StateError && e.message == 'socket is null'; + +/// Texto seguro para log: só o tipo e, para `SocketException`, o errno. Nunca +/// a mensagem, o endereço ou a pilha (VAZ-01). +String describeErrorForLog(Object e) { + if (e is SocketException) { + final code = e.osError?.errorCode; + if (code != null) return '${e.runtimeType}, errno=$code'; + } + return '${e.runtimeType}'; +} + +/// Código de saída do processo quando a zona global recebe um erro que não é +/// de E/S transitória (estado possivelmente inconsistente; o restart assume). +const uncaughtFatalExitCode = 70; + +/// Cria o handler da zona global. [log] e [exitFn] são injetáveis para teste. +void Function(Object, StackTrace) buildUncaughtErrorHandler({ + required void Function(String) log, + required void Function(int) exitFn, +}) { + return (Object error, StackTrace _) { + if (isTransientIoError(error)) { + log( + 'Erro de E/S sem dono (${describeErrorForLog(error)}); ' + 'processo mantido.', + ); + return; + } + log('Erro assíncrono não tratado (${error.runtimeType}); encerrando.'); + exitFn(uncaughtFatalExitCode); + }; +} diff --git a/backend/sinalacs_server/migrations/20260929005339393/definition.json b/backend/sinalacs_server/migrations/20260929005339393/definition.json new file mode 100644 index 00000000..48a85f1c --- /dev/null +++ b/backend/sinalacs_server/migrations/20260929005339393/definition.json @@ -0,0 +1,3296 @@ +{ + "__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 + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "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": "locationCell", + "columnType": 0, + "isNullable": true, + "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": "data_subject_requests", + "dartName": "DataSubjectRequest", + "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": "requestType", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestType" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsEncrypted", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsKeyVersion", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "dueAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "data_subject_requests_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_user_id_type_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "requestType" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "enrollment_tokens", + "dartName": "EnrollmentToken", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdByAcsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_1", + "columns": [ + "createdByAcsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_token_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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": "otp_challenges", + "dartName": "OtpChallenge", + "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": "codeHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "attempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "otp_challenges_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_user_id_created_at_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "createdAt" + } + ], + "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": "chronicConditionsEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditionsKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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": "answersEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "answersKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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": "user_credentials", + "dartName": "UserCredential", + "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": "passwordHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "passwordSalt", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "memoryKb", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "iterations", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "parallelism", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "failedAttempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockedUntil", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "user_credentials_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_user_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "cpfHash" + } + ], + "type": "btree", + "isUnique": true, + "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": "notesEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "notesKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "syncStatus", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:SyncStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "arrivalMethod", + "columnType": 0, + "isNullable": false, + "columnDefault": "'manual'::text", + "dartType": "protocol:ArrivalMethod" + }, + { + "__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": "20260929005339393" + }, + { + "__className__": "serverpod.DatabaseMigrationVersion", + "module": "serverpod", + "version": "20260129180959368" + } + ], + "migrationApiVersion": 1 +} \ No newline at end of file diff --git a/backend/sinalacs_server/migrations/20260929005339393/definition.sql b/backend/sinalacs_server/migrations/20260929005339393/definition.sql new file mode 100644 index 00000000..bc44f5d4 --- /dev/null +++ b/backend/sinalacs_server/migrations/20260929005339393/definition.sql @@ -0,0 +1,687 @@ +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"); +CREATE UNIQUE INDEX "acs_enrollment_id_key" ON "acs" USING btree ("enrollmentId"); + +-- +-- 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, + "locationCell" text, + "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 DataSubjectRequest as table data_subject_requests +-- +CREATE TABLE "data_subject_requests" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "requestType" text NOT NULL, + "detailsEncrypted" text NOT NULL, + "detailsKeyVersion" bigint NOT NULL, + "status" text NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "dueAt" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE INDEX "data_subject_requests_user_id_type_idx" ON "data_subject_requests" USING btree ("userId", "requestType"); + +-- +-- Class EnrollmentToken as table enrollment_tokens +-- +CREATE TABLE "enrollment_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "tokenHash" text NOT NULL, + "patientId" uuid NOT NULL, + "microAreaId" uuid NOT NULL, + "createdByAcsId" uuid NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "expiresAt" timestamp without time zone NOT NULL, + "consumedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "enrollment_tokens_token_hash_key" ON "enrollment_tokens" USING btree ("tokenHash"); + +-- +-- 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 OtpChallenge as table otp_challenges +-- +CREATE TABLE "otp_challenges" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "codeHash" text NOT NULL, + "attempts" bigint NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "expiresAt" timestamp without time zone NOT NULL, + "consumedAt" timestamp without time zone +); + +-- Indexes +CREATE INDEX "otp_challenges_user_id_created_at_idx" ON "otp_challenges" USING btree ("userId", "createdAt"); + +-- +-- Class Patient as table patients +-- +CREATE TABLE "patients" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "emergencyContact" text NOT NULL, + "isChronic" boolean NOT NULL, + "chronicConditionsEncrypted" text NOT NULL DEFAULT ''::text, + "chronicConditionsKeyVersion" bigint NOT NULL DEFAULT 1, + "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, + "answersEncrypted" text NOT NULL DEFAULT ''::text, + "answersKeyVersion" bigint NOT NULL DEFAULT 1, + "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 UserCredential as table user_credentials +-- +CREATE TABLE "user_credentials" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "passwordHash" text NOT NULL, + "passwordSalt" text NOT NULL, + "memoryKb" bigint NOT NULL, + "iterations" bigint NOT NULL, + "parallelism" bigint NOT NULL, + "failedAttempts" bigint NOT NULL, + "lockedUntil" timestamp without time zone, + "createdAt" timestamp without time zone NOT NULL, + "updatedAt" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "user_credentials_user_id_key" ON "user_credentials" USING btree ("userId"); + +-- +-- 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 UNIQUE INDEX "users_cpf_hash_key" 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, + "notesEncrypted" text NOT NULL DEFAULT ''::text, + "notesKeyVersion" bigint NOT NULL DEFAULT 1, + "syncStatus" text NOT NULL, + "arrivalMethod" text NOT NULL DEFAULT 'manual'::text, + "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 "data_subject_requests" table +-- +ALTER TABLE ONLY "data_subject_requests" + ADD CONSTRAINT "data_subject_requests_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "enrollment_tokens" table +-- +ALTER TABLE ONLY "enrollment_tokens" + ADD CONSTRAINT "enrollment_tokens_fk_0" + FOREIGN KEY("patientId") + REFERENCES "patients"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; +ALTER TABLE ONLY "enrollment_tokens" + ADD CONSTRAINT "enrollment_tokens_fk_1" + FOREIGN KEY("createdByAcsId") + REFERENCES "acs"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "otp_challenges" table +-- +ALTER TABLE ONLY "otp_challenges" + ADD CONSTRAINT "otp_challenges_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 "user_credentials" table +-- +ALTER TABLE ONLY "user_credentials" + ADD CONSTRAINT "user_credentials_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("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', '20260929005339393', now()) + ON CONFLICT ("module") + DO UPDATE SET "version" = '20260929005339393', "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/20260929005339393/definition_project.json b/backend/sinalacs_server/migrations/20260929005339393/definition_project.json new file mode 100644 index 00000000..c0b5247b --- /dev/null +++ b/backend/sinalacs_server/migrations/20260929005339393/definition_project.json @@ -0,0 +1,1960 @@ +{ + "__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 + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "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": "locationCell", + "columnType": 0, + "isNullable": true, + "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": "data_subject_requests", + "dartName": "DataSubjectRequest", + "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": "requestType", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestType" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsEncrypted", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsKeyVersion", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "dueAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "data_subject_requests_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_user_id_type_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "requestType" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "enrollment_tokens", + "dartName": "EnrollmentToken", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdByAcsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_1", + "columns": [ + "createdByAcsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_token_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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": "otp_challenges", + "dartName": "OtpChallenge", + "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": "codeHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "attempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "otp_challenges_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_user_id_created_at_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "createdAt" + } + ], + "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": "chronicConditionsEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditionsKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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": "answersEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "answersKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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": "user_credentials", + "dartName": "UserCredential", + "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": "passwordHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "passwordSalt", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "memoryKb", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "iterations", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "parallelism", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "failedAttempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockedUntil", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "user_credentials_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_user_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "cpfHash" + } + ], + "type": "btree", + "isUnique": true, + "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": "notesEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "notesKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "syncStatus", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:SyncStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "arrivalMethod", + "columnType": 0, + "isNullable": false, + "columnDefault": "'manual'::text", + "dartType": "protocol:ArrivalMethod" + }, + { + "__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/20260929005339393/migration.json b/backend/sinalacs_server/migrations/20260929005339393/migration.json new file mode 100644 index 00000000..f990c1a3 --- /dev/null +++ b/backend/sinalacs_server/migrations/20260929005339393/migration.json @@ -0,0 +1,129 @@ +{ + "__className__": "serverpod.DatabaseMigration", + "actions": [ + { + "__className__": "serverpod.DatabaseMigrationAction", + "type": "createTable", + "createTable": { + "__className__": "serverpod.TableDefinition", + "name": "data_subject_requests", + "dartName": "DataSubjectRequest", + "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": "requestType", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestType" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsEncrypted", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsKeyVersion", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "dueAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "data_subject_requests_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_user_id_type_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "requestType" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + } + } + ], + "warnings": [], + "migrationApiVersion": 1 +} \ No newline at end of file diff --git a/backend/sinalacs_server/migrations/20260929005339393/migration.sql b/backend/sinalacs_server/migrations/20260929005339393/migration.sql new file mode 100644 index 00000000..b20e1bfb --- /dev/null +++ b/backend/sinalacs_server/migrations/20260929005339393/migration.sql @@ -0,0 +1,48 @@ +BEGIN; + +-- +-- ACTION CREATE TABLE +-- +CREATE TABLE "data_subject_requests" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "requestType" text NOT NULL, + "detailsEncrypted" text NOT NULL, + "detailsKeyVersion" bigint NOT NULL, + "status" text NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "dueAt" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE INDEX "data_subject_requests_user_id_type_idx" ON "data_subject_requests" USING btree ("userId", "requestType"); + +-- +-- ACTION CREATE FOREIGN KEY +-- +ALTER TABLE ONLY "data_subject_requests" + ADD CONSTRAINT "data_subject_requests_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', '20260929005339393', now()) + ON CONFLICT ("module") + DO UPDATE SET "version" = '20260929005339393', "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/20260929192823862/definition.json b/backend/sinalacs_server/migrations/20260929192823862/definition.json new file mode 100644 index 00000000..e4004477 --- /dev/null +++ b/backend/sinalacs_server/migrations/20260929192823862/definition.json @@ -0,0 +1,3416 @@ +{ + "__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 + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "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": "locationCell", + "columnType": 0, + "isNullable": true, + "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": "data_subject_requests", + "dartName": "DataSubjectRequest", + "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": "requestType", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestType" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsEncrypted", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsKeyVersion", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "dueAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "data_subject_requests_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_user_id_type_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "requestType" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "enrollment_tokens", + "dartName": "EnrollmentToken", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdByAcsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_1", + "columns": [ + "createdByAcsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_token_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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": "otp_challenges", + "dartName": "OtpChallenge", + "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": "codeHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "attempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "otp_challenges_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_user_id_created_at_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "createdAt" + } + ], + "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": "chronicConditionsEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditionsKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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": "push_tokens", + "dartName": "PushToken", + "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": "microAreaId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "token", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "platform", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "push_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_token_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "token" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_user_id_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "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": "answersEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "answersKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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": "user_credentials", + "dartName": "UserCredential", + "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": "passwordHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "passwordSalt", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "memoryKb", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "iterations", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "parallelism", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "failedAttempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockedUntil", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "user_credentials_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_user_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "cpfHash" + } + ], + "type": "btree", + "isUnique": true, + "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": "notesEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "notesKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "syncStatus", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:SyncStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "arrivalMethod", + "columnType": 0, + "isNullable": false, + "columnDefault": "'manual'::text", + "dartType": "protocol:ArrivalMethod" + }, + { + "__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": "20260929192823862" + }, + { + "__className__": "serverpod.DatabaseMigrationVersion", + "module": "serverpod", + "version": "20260129180959368" + } + ], + "migrationApiVersion": 1 +} \ No newline at end of file diff --git a/backend/sinalacs_server/migrations/20260929192823862/definition.sql b/backend/sinalacs_server/migrations/20260929192823862/definition.sql new file mode 100644 index 00000000..e4b759e5 --- /dev/null +++ b/backend/sinalacs_server/migrations/20260929192823862/definition.sql @@ -0,0 +1,714 @@ +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"); +CREATE UNIQUE INDEX "acs_enrollment_id_key" ON "acs" USING btree ("enrollmentId"); + +-- +-- 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, + "locationCell" text, + "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 DataSubjectRequest as table data_subject_requests +-- +CREATE TABLE "data_subject_requests" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "requestType" text NOT NULL, + "detailsEncrypted" text NOT NULL, + "detailsKeyVersion" bigint NOT NULL, + "status" text NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "dueAt" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE INDEX "data_subject_requests_user_id_type_idx" ON "data_subject_requests" USING btree ("userId", "requestType"); + +-- +-- Class EnrollmentToken as table enrollment_tokens +-- +CREATE TABLE "enrollment_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "tokenHash" text NOT NULL, + "patientId" uuid NOT NULL, + "microAreaId" uuid NOT NULL, + "createdByAcsId" uuid NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "expiresAt" timestamp without time zone NOT NULL, + "consumedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "enrollment_tokens_token_hash_key" ON "enrollment_tokens" USING btree ("tokenHash"); + +-- +-- 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 OtpChallenge as table otp_challenges +-- +CREATE TABLE "otp_challenges" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "codeHash" text NOT NULL, + "attempts" bigint NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "expiresAt" timestamp without time zone NOT NULL, + "consumedAt" timestamp without time zone +); + +-- Indexes +CREATE INDEX "otp_challenges_user_id_created_at_idx" ON "otp_challenges" USING btree ("userId", "createdAt"); + +-- +-- Class Patient as table patients +-- +CREATE TABLE "patients" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "emergencyContact" text NOT NULL, + "isChronic" boolean NOT NULL, + "chronicConditionsEncrypted" text NOT NULL DEFAULT ''::text, + "chronicConditionsKeyVersion" bigint NOT NULL DEFAULT 1, + "lastLocationHash" text, + "lastTriageAt" timestamp without time zone +); + +-- +-- Class PushToken as table push_tokens +-- +CREATE TABLE "push_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "microAreaId" uuid, + "token" text NOT NULL, + "platform" text NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "updatedAt" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "push_tokens_token_key" ON "push_tokens" USING btree ("token"); +CREATE INDEX "push_tokens_user_id_idx" ON "push_tokens" USING btree ("userId"); + +-- +-- Class TriageSession as table triage_sessions +-- +CREATE TABLE "triage_sessions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "patientId" uuid NOT NULL, + "answersEncrypted" text NOT NULL DEFAULT ''::text, + "answersKeyVersion" bigint NOT NULL DEFAULT 1, + "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 UserCredential as table user_credentials +-- +CREATE TABLE "user_credentials" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "passwordHash" text NOT NULL, + "passwordSalt" text NOT NULL, + "memoryKb" bigint NOT NULL, + "iterations" bigint NOT NULL, + "parallelism" bigint NOT NULL, + "failedAttempts" bigint NOT NULL, + "lockedUntil" timestamp without time zone, + "createdAt" timestamp without time zone NOT NULL, + "updatedAt" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "user_credentials_user_id_key" ON "user_credentials" USING btree ("userId"); + +-- +-- 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 UNIQUE INDEX "users_cpf_hash_key" 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, + "notesEncrypted" text NOT NULL DEFAULT ''::text, + "notesKeyVersion" bigint NOT NULL DEFAULT 1, + "syncStatus" text NOT NULL, + "arrivalMethod" text NOT NULL DEFAULT 'manual'::text, + "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 "data_subject_requests" table +-- +ALTER TABLE ONLY "data_subject_requests" + ADD CONSTRAINT "data_subject_requests_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "enrollment_tokens" table +-- +ALTER TABLE ONLY "enrollment_tokens" + ADD CONSTRAINT "enrollment_tokens_fk_0" + FOREIGN KEY("patientId") + REFERENCES "patients"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; +ALTER TABLE ONLY "enrollment_tokens" + ADD CONSTRAINT "enrollment_tokens_fk_1" + FOREIGN KEY("createdByAcsId") + REFERENCES "acs"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "otp_challenges" table +-- +ALTER TABLE ONLY "otp_challenges" + ADD CONSTRAINT "otp_challenges_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "push_tokens" table +-- +ALTER TABLE ONLY "push_tokens" + ADD CONSTRAINT "push_tokens_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 "user_credentials" table +-- +ALTER TABLE ONLY "user_credentials" + ADD CONSTRAINT "user_credentials_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("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', '20260929192823862', now()) + ON CONFLICT ("module") + DO UPDATE SET "version" = '20260929192823862', "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/20260929192823862/definition_project.json b/backend/sinalacs_server/migrations/20260929192823862/definition_project.json new file mode 100644 index 00000000..b551d5b5 --- /dev/null +++ b/backend/sinalacs_server/migrations/20260929192823862/definition_project.json @@ -0,0 +1,2080 @@ +{ + "__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 + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "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": "locationCell", + "columnType": 0, + "isNullable": true, + "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": "data_subject_requests", + "dartName": "DataSubjectRequest", + "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": "requestType", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestType" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsEncrypted", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsKeyVersion", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "dueAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "data_subject_requests_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_user_id_type_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "requestType" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "enrollment_tokens", + "dartName": "EnrollmentToken", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdByAcsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_1", + "columns": [ + "createdByAcsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_token_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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": "otp_challenges", + "dartName": "OtpChallenge", + "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": "codeHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "attempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "otp_challenges_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_user_id_created_at_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "createdAt" + } + ], + "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": "chronicConditionsEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditionsKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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": "push_tokens", + "dartName": "PushToken", + "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": "microAreaId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "token", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "platform", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "push_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_token_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "token" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_user_id_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "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": "answersEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "answersKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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": "user_credentials", + "dartName": "UserCredential", + "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": "passwordHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "passwordSalt", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "memoryKb", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "iterations", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "parallelism", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "failedAttempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockedUntil", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "user_credentials_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_user_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "cpfHash" + } + ], + "type": "btree", + "isUnique": true, + "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": "notesEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "notesKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "syncStatus", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:SyncStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "arrivalMethod", + "columnType": 0, + "isNullable": false, + "columnDefault": "'manual'::text", + "dartType": "protocol:ArrivalMethod" + }, + { + "__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/20260929192823862/migration.json b/backend/sinalacs_server/migrations/20260929192823862/migration.json new file mode 100644 index 00000000..44888e39 --- /dev/null +++ b/backend/sinalacs_server/migrations/20260929192823862/migration.json @@ -0,0 +1,131 @@ +{ + "__className__": "serverpod.DatabaseMigration", + "actions": [ + { + "__className__": "serverpod.DatabaseMigrationAction", + "type": "createTable", + "createTable": { + "__className__": "serverpod.TableDefinition", + "name": "push_tokens", + "dartName": "PushToken", + "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": "microAreaId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "token", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "platform", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "push_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_token_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "token" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_user_id_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + } + } + ], + "warnings": [], + "migrationApiVersion": 1 +} \ No newline at end of file diff --git a/backend/sinalacs_server/migrations/20260929192823862/migration.sql b/backend/sinalacs_server/migrations/20260929192823862/migration.sql new file mode 100644 index 00000000..19b4d973 --- /dev/null +++ b/backend/sinalacs_server/migrations/20260929192823862/migration.sql @@ -0,0 +1,48 @@ +BEGIN; + +-- +-- ACTION CREATE TABLE +-- +CREATE TABLE "push_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "microAreaId" uuid, + "token" text NOT NULL, + "platform" text NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "updatedAt" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "push_tokens_token_key" ON "push_tokens" USING btree ("token"); +CREATE INDEX "push_tokens_user_id_idx" ON "push_tokens" USING btree ("userId"); + +-- +-- ACTION CREATE FOREIGN KEY +-- +ALTER TABLE ONLY "push_tokens" + ADD CONSTRAINT "push_tokens_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', '20260929192823862', now()) + ON CONFLICT ("module") + DO UPDATE SET "version" = '20260929192823862', "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/20261002153125316/definition.json b/backend/sinalacs_server/migrations/20261002153125316/definition.json new file mode 100644 index 00000000..acdb3840 --- /dev/null +++ b/backend/sinalacs_server/migrations/20261002153125316/definition.json @@ -0,0 +1,3423 @@ +{ + "__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 + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "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": "locationCell", + "columnType": 0, + "isNullable": true, + "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": "data_subject_requests", + "dartName": "DataSubjectRequest", + "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": "requestType", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestType" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsEncrypted", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsKeyVersion", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "dueAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "data_subject_requests_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_user_id_type_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "requestType" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "enrollment_tokens", + "dartName": "EnrollmentToken", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdByAcsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_1", + "columns": [ + "createdByAcsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_token_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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": "otp_challenges", + "dartName": "OtpChallenge", + "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": "codeHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "attempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "otp_challenges_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_user_id_created_at_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "createdAt" + } + ], + "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": "chronicConditionsEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditionsKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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": "push_tokens", + "dartName": "PushToken", + "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": "microAreaId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "token", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "platform", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "push_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_token_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "token" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_user_id_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "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": "answersEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "answersKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "contactPhone", + "columnType": 0, + "isNullable": true, + "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": "user_credentials", + "dartName": "UserCredential", + "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": "passwordHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "passwordSalt", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "memoryKb", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "iterations", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "parallelism", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "failedAttempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockedUntil", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "user_credentials_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_user_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "cpfHash" + } + ], + "type": "btree", + "isUnique": true, + "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": "notesEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "notesKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "syncStatus", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:SyncStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "arrivalMethod", + "columnType": 0, + "isNullable": false, + "columnDefault": "'manual'::text", + "dartType": "protocol:ArrivalMethod" + }, + { + "__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": "20261002153125316" + }, + { + "__className__": "serverpod.DatabaseMigrationVersion", + "module": "serverpod", + "version": "20260129180959368" + } + ], + "migrationApiVersion": 1 +} \ No newline at end of file diff --git a/backend/sinalacs_server/migrations/20261002153125316/definition.sql b/backend/sinalacs_server/migrations/20261002153125316/definition.sql new file mode 100644 index 00000000..8b3dfef9 --- /dev/null +++ b/backend/sinalacs_server/migrations/20261002153125316/definition.sql @@ -0,0 +1,715 @@ +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"); +CREATE UNIQUE INDEX "acs_enrollment_id_key" ON "acs" USING btree ("enrollmentId"); + +-- +-- 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, + "locationCell" text, + "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 DataSubjectRequest as table data_subject_requests +-- +CREATE TABLE "data_subject_requests" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "requestType" text NOT NULL, + "detailsEncrypted" text NOT NULL, + "detailsKeyVersion" bigint NOT NULL, + "status" text NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "dueAt" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE INDEX "data_subject_requests_user_id_type_idx" ON "data_subject_requests" USING btree ("userId", "requestType"); + +-- +-- Class EnrollmentToken as table enrollment_tokens +-- +CREATE TABLE "enrollment_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "tokenHash" text NOT NULL, + "patientId" uuid NOT NULL, + "microAreaId" uuid NOT NULL, + "createdByAcsId" uuid NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "expiresAt" timestamp without time zone NOT NULL, + "consumedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "enrollment_tokens_token_hash_key" ON "enrollment_tokens" USING btree ("tokenHash"); + +-- +-- 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 OtpChallenge as table otp_challenges +-- +CREATE TABLE "otp_challenges" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "codeHash" text NOT NULL, + "attempts" bigint NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "expiresAt" timestamp without time zone NOT NULL, + "consumedAt" timestamp without time zone +); + +-- Indexes +CREATE INDEX "otp_challenges_user_id_created_at_idx" ON "otp_challenges" USING btree ("userId", "createdAt"); + +-- +-- Class Patient as table patients +-- +CREATE TABLE "patients" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "emergencyContact" text NOT NULL, + "isChronic" boolean NOT NULL, + "chronicConditionsEncrypted" text NOT NULL DEFAULT ''::text, + "chronicConditionsKeyVersion" bigint NOT NULL DEFAULT 1, + "lastLocationHash" text, + "lastTriageAt" timestamp without time zone +); + +-- +-- Class PushToken as table push_tokens +-- +CREATE TABLE "push_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "microAreaId" uuid, + "token" text NOT NULL, + "platform" text NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "updatedAt" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "push_tokens_token_key" ON "push_tokens" USING btree ("token"); +CREATE INDEX "push_tokens_user_id_idx" ON "push_tokens" USING btree ("userId"); + +-- +-- Class TriageSession as table triage_sessions +-- +CREATE TABLE "triage_sessions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "patientId" uuid NOT NULL, + "answersEncrypted" text NOT NULL DEFAULT ''::text, + "answersKeyVersion" bigint NOT NULL DEFAULT 1, + "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, + "contactPhone" text +); + +-- +-- Class UserCredential as table user_credentials +-- +CREATE TABLE "user_credentials" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "passwordHash" text NOT NULL, + "passwordSalt" text NOT NULL, + "memoryKb" bigint NOT NULL, + "iterations" bigint NOT NULL, + "parallelism" bigint NOT NULL, + "failedAttempts" bigint NOT NULL, + "lockedUntil" timestamp without time zone, + "createdAt" timestamp without time zone NOT NULL, + "updatedAt" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "user_credentials_user_id_key" ON "user_credentials" USING btree ("userId"); + +-- +-- 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 UNIQUE INDEX "users_cpf_hash_key" 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, + "notesEncrypted" text NOT NULL DEFAULT ''::text, + "notesKeyVersion" bigint NOT NULL DEFAULT 1, + "syncStatus" text NOT NULL, + "arrivalMethod" text NOT NULL DEFAULT 'manual'::text, + "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 "data_subject_requests" table +-- +ALTER TABLE ONLY "data_subject_requests" + ADD CONSTRAINT "data_subject_requests_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "enrollment_tokens" table +-- +ALTER TABLE ONLY "enrollment_tokens" + ADD CONSTRAINT "enrollment_tokens_fk_0" + FOREIGN KEY("patientId") + REFERENCES "patients"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; +ALTER TABLE ONLY "enrollment_tokens" + ADD CONSTRAINT "enrollment_tokens_fk_1" + FOREIGN KEY("createdByAcsId") + REFERENCES "acs"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "otp_challenges" table +-- +ALTER TABLE ONLY "otp_challenges" + ADD CONSTRAINT "otp_challenges_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "push_tokens" table +-- +ALTER TABLE ONLY "push_tokens" + ADD CONSTRAINT "push_tokens_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 "user_credentials" table +-- +ALTER TABLE ONLY "user_credentials" + ADD CONSTRAINT "user_credentials_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("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', '20261002153125316', now()) + ON CONFLICT ("module") + DO UPDATE SET "version" = '20261002153125316', "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/20261002153125316/definition_project.json b/backend/sinalacs_server/migrations/20261002153125316/definition_project.json new file mode 100644 index 00000000..96378926 --- /dev/null +++ b/backend/sinalacs_server/migrations/20261002153125316/definition_project.json @@ -0,0 +1,2087 @@ +{ + "__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 + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "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": "locationCell", + "columnType": 0, + "isNullable": true, + "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": "data_subject_requests", + "dartName": "DataSubjectRequest", + "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": "requestType", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestType" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsEncrypted", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsKeyVersion", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "dueAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "data_subject_requests_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_user_id_type_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "requestType" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "enrollment_tokens", + "dartName": "EnrollmentToken", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdByAcsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_1", + "columns": [ + "createdByAcsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_token_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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": "otp_challenges", + "dartName": "OtpChallenge", + "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": "codeHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "attempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "otp_challenges_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_user_id_created_at_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "createdAt" + } + ], + "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": "chronicConditionsEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditionsKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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": "push_tokens", + "dartName": "PushToken", + "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": "microAreaId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "token", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "platform", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "push_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_token_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "token" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_user_id_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "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": "answersEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "answersKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "contactPhone", + "columnType": 0, + "isNullable": true, + "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": "user_credentials", + "dartName": "UserCredential", + "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": "passwordHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "passwordSalt", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "memoryKb", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "iterations", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "parallelism", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "failedAttempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockedUntil", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "user_credentials_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_user_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "cpfHash" + } + ], + "type": "btree", + "isUnique": true, + "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": "notesEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "notesKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "syncStatus", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:SyncStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "arrivalMethod", + "columnType": 0, + "isNullable": false, + "columnDefault": "'manual'::text", + "dartType": "protocol:ArrivalMethod" + }, + { + "__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/20261002153125316/migration.json b/backend/sinalacs_server/migrations/20261002153125316/migration.json new file mode 100644 index 00000000..156b3b4e --- /dev/null +++ b/backend/sinalacs_server/migrations/20261002153125316/migration.json @@ -0,0 +1,32 @@ +{ + "__className__": "serverpod.DatabaseMigration", + "actions": [ + { + "__className__": "serverpod.DatabaseMigrationAction", + "type": "alterTable", + "alterTable": { + "__className__": "serverpod.TableMigration", + "name": "ubs", + "schema": "public", + "addColumns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "contactPhone", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + } + ], + "deleteColumns": [], + "modifyColumns": [], + "addIndexes": [], + "deleteIndexes": [], + "addForeignKeys": [], + "deleteForeignKeys": [], + "warnings": [] + } + } + ], + "warnings": [], + "migrationApiVersion": 1 +} \ No newline at end of file diff --git a/backend/sinalacs_server/migrations/20261002153125316/migration.sql b/backend/sinalacs_server/migrations/20261002153125316/migration.sql new file mode 100644 index 00000000..87de6e6a --- /dev/null +++ b/backend/sinalacs_server/migrations/20261002153125316/migration.sql @@ -0,0 +1,25 @@ +BEGIN; + +-- +-- ACTION ALTER TABLE +-- +ALTER TABLE "ubs" ADD COLUMN "contactPhone" text; + +-- +-- MIGRATION VERSION FOR sinalacs +-- +INSERT INTO "serverpod_migrations" ("module", "version", "timestamp") + VALUES ('sinalacs', '20261002153125316', now()) + ON CONFLICT ("module") + DO UPDATE SET "version" = '20261002153125316', "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/20261002161303717/definition.json b/backend/sinalacs_server/migrations/20261002161303717/definition.json new file mode 100644 index 00000000..6ad48b40 --- /dev/null +++ b/backend/sinalacs_server/migrations/20261002161303717/definition.json @@ -0,0 +1,3451 @@ +{ + "__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 + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "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": "locationCell", + "columnType": 0, + "isNullable": true, + "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": "data_subject_requests", + "dartName": "DataSubjectRequest", + "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": "requestType", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestType" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsEncrypted", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsKeyVersion", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "dueAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "data_subject_requests_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_user_id_type_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "requestType" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "enrollment_tokens", + "dartName": "EnrollmentToken", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdByAcsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_1", + "columns": [ + "createdByAcsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_token_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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": "otp_challenges", + "dartName": "OtpChallenge", + "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": "codeHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "attempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "otp_challenges_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_user_id_created_at_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "createdAt" + } + ], + "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": "chronicConditionsEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditionsKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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": "push_tokens", + "dartName": "PushToken", + "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": "microAreaId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "token", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "platform", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "push_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_token_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "token" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_user_id_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "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": "answersEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "answersKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "contactPhone", + "columnType": 0, + "isNullable": true, + "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": "user_credentials", + "dartName": "UserCredential", + "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": "passwordHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "passwordSalt", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "memoryKb", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "iterations", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "parallelism", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "failedAttempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockedUntil", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpSecretEncrypted", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpKeyVersion", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpEnabledAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpLastStep", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "user_credentials_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_user_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "cpfHash" + } + ], + "type": "btree", + "isUnique": true, + "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": "notesEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "notesKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "syncStatus", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:SyncStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "arrivalMethod", + "columnType": 0, + "isNullable": false, + "columnDefault": "'manual'::text", + "dartType": "protocol:ArrivalMethod" + }, + { + "__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": "20261002161303717" + }, + { + "__className__": "serverpod.DatabaseMigrationVersion", + "module": "serverpod", + "version": "20260129180959368" + } + ], + "migrationApiVersion": 1 +} \ No newline at end of file diff --git a/backend/sinalacs_server/migrations/20261002161303717/definition.sql b/backend/sinalacs_server/migrations/20261002161303717/definition.sql new file mode 100644 index 00000000..f23deef3 --- /dev/null +++ b/backend/sinalacs_server/migrations/20261002161303717/definition.sql @@ -0,0 +1,719 @@ +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"); +CREATE UNIQUE INDEX "acs_enrollment_id_key" ON "acs" USING btree ("enrollmentId"); + +-- +-- 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, + "locationCell" text, + "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 DataSubjectRequest as table data_subject_requests +-- +CREATE TABLE "data_subject_requests" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "requestType" text NOT NULL, + "detailsEncrypted" text NOT NULL, + "detailsKeyVersion" bigint NOT NULL, + "status" text NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "dueAt" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE INDEX "data_subject_requests_user_id_type_idx" ON "data_subject_requests" USING btree ("userId", "requestType"); + +-- +-- Class EnrollmentToken as table enrollment_tokens +-- +CREATE TABLE "enrollment_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "tokenHash" text NOT NULL, + "patientId" uuid NOT NULL, + "microAreaId" uuid NOT NULL, + "createdByAcsId" uuid NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "expiresAt" timestamp without time zone NOT NULL, + "consumedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "enrollment_tokens_token_hash_key" ON "enrollment_tokens" USING btree ("tokenHash"); + +-- +-- 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 OtpChallenge as table otp_challenges +-- +CREATE TABLE "otp_challenges" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "codeHash" text NOT NULL, + "attempts" bigint NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "expiresAt" timestamp without time zone NOT NULL, + "consumedAt" timestamp without time zone +); + +-- Indexes +CREATE INDEX "otp_challenges_user_id_created_at_idx" ON "otp_challenges" USING btree ("userId", "createdAt"); + +-- +-- Class Patient as table patients +-- +CREATE TABLE "patients" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "emergencyContact" text NOT NULL, + "isChronic" boolean NOT NULL, + "chronicConditionsEncrypted" text NOT NULL DEFAULT ''::text, + "chronicConditionsKeyVersion" bigint NOT NULL DEFAULT 1, + "lastLocationHash" text, + "lastTriageAt" timestamp without time zone +); + +-- +-- Class PushToken as table push_tokens +-- +CREATE TABLE "push_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "microAreaId" uuid, + "token" text NOT NULL, + "platform" text NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "updatedAt" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "push_tokens_token_key" ON "push_tokens" USING btree ("token"); +CREATE INDEX "push_tokens_user_id_idx" ON "push_tokens" USING btree ("userId"); + +-- +-- Class TriageSession as table triage_sessions +-- +CREATE TABLE "triage_sessions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "patientId" uuid NOT NULL, + "answersEncrypted" text NOT NULL DEFAULT ''::text, + "answersKeyVersion" bigint NOT NULL DEFAULT 1, + "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, + "contactPhone" text +); + +-- +-- Class UserCredential as table user_credentials +-- +CREATE TABLE "user_credentials" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "passwordHash" text NOT NULL, + "passwordSalt" text NOT NULL, + "memoryKb" bigint NOT NULL, + "iterations" bigint NOT NULL, + "parallelism" bigint NOT NULL, + "failedAttempts" bigint NOT NULL, + "lockedUntil" timestamp without time zone, + "createdAt" timestamp without time zone NOT NULL, + "updatedAt" timestamp without time zone NOT NULL, + "totpSecretEncrypted" text, + "totpKeyVersion" bigint, + "totpEnabledAt" timestamp without time zone, + "totpLastStep" bigint +); + +-- Indexes +CREATE UNIQUE INDEX "user_credentials_user_id_key" ON "user_credentials" USING btree ("userId"); + +-- +-- 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 UNIQUE INDEX "users_cpf_hash_key" 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, + "notesEncrypted" text NOT NULL DEFAULT ''::text, + "notesKeyVersion" bigint NOT NULL DEFAULT 1, + "syncStatus" text NOT NULL, + "arrivalMethod" text NOT NULL DEFAULT 'manual'::text, + "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 "data_subject_requests" table +-- +ALTER TABLE ONLY "data_subject_requests" + ADD CONSTRAINT "data_subject_requests_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "enrollment_tokens" table +-- +ALTER TABLE ONLY "enrollment_tokens" + ADD CONSTRAINT "enrollment_tokens_fk_0" + FOREIGN KEY("patientId") + REFERENCES "patients"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; +ALTER TABLE ONLY "enrollment_tokens" + ADD CONSTRAINT "enrollment_tokens_fk_1" + FOREIGN KEY("createdByAcsId") + REFERENCES "acs"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "otp_challenges" table +-- +ALTER TABLE ONLY "otp_challenges" + ADD CONSTRAINT "otp_challenges_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "push_tokens" table +-- +ALTER TABLE ONLY "push_tokens" + ADD CONSTRAINT "push_tokens_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 "user_credentials" table +-- +ALTER TABLE ONLY "user_credentials" + ADD CONSTRAINT "user_credentials_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("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', '20261002161303717', now()) + ON CONFLICT ("module") + DO UPDATE SET "version" = '20261002161303717', "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/20261002161303717/definition_project.json b/backend/sinalacs_server/migrations/20261002161303717/definition_project.json new file mode 100644 index 00000000..2425dfe1 --- /dev/null +++ b/backend/sinalacs_server/migrations/20261002161303717/definition_project.json @@ -0,0 +1,2115 @@ +{ + "__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 + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "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": "locationCell", + "columnType": 0, + "isNullable": true, + "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": "data_subject_requests", + "dartName": "DataSubjectRequest", + "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": "requestType", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestType" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsEncrypted", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsKeyVersion", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "dueAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "data_subject_requests_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_user_id_type_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "requestType" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "enrollment_tokens", + "dartName": "EnrollmentToken", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdByAcsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_1", + "columns": [ + "createdByAcsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_token_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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": "otp_challenges", + "dartName": "OtpChallenge", + "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": "codeHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "attempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "otp_challenges_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_user_id_created_at_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "createdAt" + } + ], + "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": "chronicConditionsEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditionsKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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": "push_tokens", + "dartName": "PushToken", + "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": "microAreaId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "token", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "platform", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "push_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_token_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "token" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_user_id_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "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": "answersEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "answersKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "contactPhone", + "columnType": 0, + "isNullable": true, + "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": "user_credentials", + "dartName": "UserCredential", + "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": "passwordHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "passwordSalt", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "memoryKb", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "iterations", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "parallelism", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "failedAttempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockedUntil", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpSecretEncrypted", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpKeyVersion", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpEnabledAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpLastStep", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "user_credentials_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_user_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "cpfHash" + } + ], + "type": "btree", + "isUnique": true, + "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": "notesEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "notesKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "syncStatus", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:SyncStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "arrivalMethod", + "columnType": 0, + "isNullable": false, + "columnDefault": "'manual'::text", + "dartType": "protocol:ArrivalMethod" + }, + { + "__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/20261002161303717/migration.json b/backend/sinalacs_server/migrations/20261002161303717/migration.json new file mode 100644 index 00000000..82f47963 --- /dev/null +++ b/backend/sinalacs_server/migrations/20261002161303717/migration.json @@ -0,0 +1,53 @@ +{ + "__className__": "serverpod.DatabaseMigration", + "actions": [ + { + "__className__": "serverpod.DatabaseMigrationAction", + "type": "alterTable", + "alterTable": { + "__className__": "serverpod.TableMigration", + "name": "user_credentials", + "schema": "public", + "addColumns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpSecretEncrypted", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpKeyVersion", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpEnabledAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpLastStep", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + } + ], + "deleteColumns": [], + "modifyColumns": [], + "addIndexes": [], + "deleteIndexes": [], + "addForeignKeys": [], + "deleteForeignKeys": [], + "warnings": [] + } + } + ], + "warnings": [], + "migrationApiVersion": 1 +} \ No newline at end of file diff --git a/backend/sinalacs_server/migrations/20261002161303717/migration.sql b/backend/sinalacs_server/migrations/20261002161303717/migration.sql new file mode 100644 index 00000000..11d60eec --- /dev/null +++ b/backend/sinalacs_server/migrations/20261002161303717/migration.sql @@ -0,0 +1,28 @@ +BEGIN; + +-- +-- ACTION ALTER TABLE +-- +ALTER TABLE "user_credentials" ADD COLUMN "totpSecretEncrypted" text; +ALTER TABLE "user_credentials" ADD COLUMN "totpKeyVersion" bigint; +ALTER TABLE "user_credentials" ADD COLUMN "totpEnabledAt" timestamp without time zone; +ALTER TABLE "user_credentials" ADD COLUMN "totpLastStep" bigint; + +-- +-- MIGRATION VERSION FOR sinalacs +-- +INSERT INTO "serverpod_migrations" ("module", "version", "timestamp") + VALUES ('sinalacs', '20261002161303717', now()) + ON CONFLICT ("module") + DO UPDATE SET "version" = '20261002161303717', "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/20261003151950052/definition.json b/backend/sinalacs_server/migrations/20261003151950052/definition.json new file mode 100644 index 00000000..c9be74ee --- /dev/null +++ b/backend/sinalacs_server/migrations/20261003151950052/definition.json @@ -0,0 +1,3606 @@ +{ + "__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 + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "acs_refresh_tokens", + "dartName": "AcsRefreshToken", + "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": "familyId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "issuedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "idleExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "absoluteExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "rotatedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "revokedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "acs_refresh_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_family_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "familyId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_user_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "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": "locationCell", + "columnType": 0, + "isNullable": true, + "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": "data_subject_requests", + "dartName": "DataSubjectRequest", + "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": "requestType", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestType" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsEncrypted", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsKeyVersion", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "dueAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "data_subject_requests_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_user_id_type_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "requestType" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "enrollment_tokens", + "dartName": "EnrollmentToken", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdByAcsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_1", + "columns": [ + "createdByAcsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_token_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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": "otp_challenges", + "dartName": "OtpChallenge", + "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": "codeHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "attempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "otp_challenges_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_user_id_created_at_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "createdAt" + } + ], + "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": "chronicConditionsEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditionsKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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": "push_tokens", + "dartName": "PushToken", + "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": "microAreaId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "token", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "platform", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "push_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_token_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "token" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_user_id_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "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": "answersEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "answersKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "contactPhone", + "columnType": 0, + "isNullable": true, + "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": "user_credentials", + "dartName": "UserCredential", + "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": "passwordHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "passwordSalt", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "memoryKb", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "iterations", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "parallelism", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "failedAttempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockedUntil", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpSecretEncrypted", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpKeyVersion", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpEnabledAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpLastStep", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "user_credentials_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_user_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "cpfHash" + } + ], + "type": "btree", + "isUnique": true, + "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": "notesEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "notesKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "syncStatus", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:SyncStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "arrivalMethod", + "columnType": 0, + "isNullable": false, + "columnDefault": "'manual'::text", + "dartType": "protocol:ArrivalMethod" + }, + { + "__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": "20261003151950052" + }, + { + "__className__": "serverpod.DatabaseMigrationVersion", + "module": "serverpod", + "version": "20260129180959368" + } + ], + "migrationApiVersion": 1 +} \ No newline at end of file diff --git a/backend/sinalacs_server/migrations/20261003151950052/definition.sql b/backend/sinalacs_server/migrations/20261003151950052/definition.sql new file mode 100644 index 00000000..c7d9fe09 --- /dev/null +++ b/backend/sinalacs_server/migrations/20261003151950052/definition.sql @@ -0,0 +1,750 @@ +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"); +CREATE UNIQUE INDEX "acs_enrollment_id_key" ON "acs" USING btree ("enrollmentId"); + +-- +-- Class AcsRefreshToken as table acs_refresh_tokens +-- +CREATE TABLE "acs_refresh_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "familyId" uuid NOT NULL, + "tokenHash" text NOT NULL, + "deviceId" text NOT NULL, + "issuedAt" timestamp without time zone NOT NULL, + "idleExpiresAt" timestamp without time zone NOT NULL, + "absoluteExpiresAt" timestamp without time zone NOT NULL, + "rotatedAt" timestamp without time zone, + "revokedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "acs_refresh_tokens_hash_key" ON "acs_refresh_tokens" USING btree ("tokenHash"); +CREATE INDEX "acs_refresh_tokens_family_idx" ON "acs_refresh_tokens" USING btree ("familyId"); +CREATE INDEX "acs_refresh_tokens_user_idx" ON "acs_refresh_tokens" USING btree ("userId"); + +-- +-- 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, + "locationCell" text, + "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 DataSubjectRequest as table data_subject_requests +-- +CREATE TABLE "data_subject_requests" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "requestType" text NOT NULL, + "detailsEncrypted" text NOT NULL, + "detailsKeyVersion" bigint NOT NULL, + "status" text NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "dueAt" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE INDEX "data_subject_requests_user_id_type_idx" ON "data_subject_requests" USING btree ("userId", "requestType"); + +-- +-- Class EnrollmentToken as table enrollment_tokens +-- +CREATE TABLE "enrollment_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "tokenHash" text NOT NULL, + "patientId" uuid NOT NULL, + "microAreaId" uuid NOT NULL, + "createdByAcsId" uuid NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "expiresAt" timestamp without time zone NOT NULL, + "consumedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "enrollment_tokens_token_hash_key" ON "enrollment_tokens" USING btree ("tokenHash"); + +-- +-- 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 OtpChallenge as table otp_challenges +-- +CREATE TABLE "otp_challenges" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "codeHash" text NOT NULL, + "attempts" bigint NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "expiresAt" timestamp without time zone NOT NULL, + "consumedAt" timestamp without time zone +); + +-- Indexes +CREATE INDEX "otp_challenges_user_id_created_at_idx" ON "otp_challenges" USING btree ("userId", "createdAt"); + +-- +-- Class Patient as table patients +-- +CREATE TABLE "patients" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "emergencyContact" text NOT NULL, + "isChronic" boolean NOT NULL, + "chronicConditionsEncrypted" text NOT NULL DEFAULT ''::text, + "chronicConditionsKeyVersion" bigint NOT NULL DEFAULT 1, + "lastLocationHash" text, + "lastTriageAt" timestamp without time zone +); + +-- +-- Class PushToken as table push_tokens +-- +CREATE TABLE "push_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "microAreaId" uuid, + "token" text NOT NULL, + "platform" text NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "updatedAt" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "push_tokens_token_key" ON "push_tokens" USING btree ("token"); +CREATE INDEX "push_tokens_user_id_idx" ON "push_tokens" USING btree ("userId"); + +-- +-- Class TriageSession as table triage_sessions +-- +CREATE TABLE "triage_sessions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "patientId" uuid NOT NULL, + "answersEncrypted" text NOT NULL DEFAULT ''::text, + "answersKeyVersion" bigint NOT NULL DEFAULT 1, + "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, + "contactPhone" text +); + +-- +-- Class UserCredential as table user_credentials +-- +CREATE TABLE "user_credentials" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "passwordHash" text NOT NULL, + "passwordSalt" text NOT NULL, + "memoryKb" bigint NOT NULL, + "iterations" bigint NOT NULL, + "parallelism" bigint NOT NULL, + "failedAttempts" bigint NOT NULL, + "lockedUntil" timestamp without time zone, + "createdAt" timestamp without time zone NOT NULL, + "updatedAt" timestamp without time zone NOT NULL, + "totpSecretEncrypted" text, + "totpKeyVersion" bigint, + "totpEnabledAt" timestamp without time zone, + "totpLastStep" bigint +); + +-- Indexes +CREATE UNIQUE INDEX "user_credentials_user_id_key" ON "user_credentials" USING btree ("userId"); + +-- +-- 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 UNIQUE INDEX "users_cpf_hash_key" 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, + "notesEncrypted" text NOT NULL DEFAULT ''::text, + "notesKeyVersion" bigint NOT NULL DEFAULT 1, + "syncStatus" text NOT NULL, + "arrivalMethod" text NOT NULL DEFAULT 'manual'::text, + "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 "acs_refresh_tokens" table +-- +ALTER TABLE ONLY "acs_refresh_tokens" + ADD CONSTRAINT "acs_refresh_tokens_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- 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 "data_subject_requests" table +-- +ALTER TABLE ONLY "data_subject_requests" + ADD CONSTRAINT "data_subject_requests_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "enrollment_tokens" table +-- +ALTER TABLE ONLY "enrollment_tokens" + ADD CONSTRAINT "enrollment_tokens_fk_0" + FOREIGN KEY("patientId") + REFERENCES "patients"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; +ALTER TABLE ONLY "enrollment_tokens" + ADD CONSTRAINT "enrollment_tokens_fk_1" + FOREIGN KEY("createdByAcsId") + REFERENCES "acs"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "otp_challenges" table +-- +ALTER TABLE ONLY "otp_challenges" + ADD CONSTRAINT "otp_challenges_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "push_tokens" table +-- +ALTER TABLE ONLY "push_tokens" + ADD CONSTRAINT "push_tokens_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 "user_credentials" table +-- +ALTER TABLE ONLY "user_credentials" + ADD CONSTRAINT "user_credentials_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("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', '20261003151950052', now()) + ON CONFLICT ("module") + DO UPDATE SET "version" = '20261003151950052', "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/20261003151950052/definition_project.json b/backend/sinalacs_server/migrations/20261003151950052/definition_project.json new file mode 100644 index 00000000..048f4c27 --- /dev/null +++ b/backend/sinalacs_server/migrations/20261003151950052/definition_project.json @@ -0,0 +1,2270 @@ +{ + "__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 + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "acs_refresh_tokens", + "dartName": "AcsRefreshToken", + "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": "familyId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "issuedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "idleExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "absoluteExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "rotatedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "revokedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "acs_refresh_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_family_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "familyId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_user_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "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": "locationCell", + "columnType": 0, + "isNullable": true, + "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": "data_subject_requests", + "dartName": "DataSubjectRequest", + "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": "requestType", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestType" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsEncrypted", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsKeyVersion", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "dueAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "data_subject_requests_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_user_id_type_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "requestType" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "enrollment_tokens", + "dartName": "EnrollmentToken", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdByAcsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_1", + "columns": [ + "createdByAcsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_token_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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": "otp_challenges", + "dartName": "OtpChallenge", + "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": "codeHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "attempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "otp_challenges_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_user_id_created_at_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "createdAt" + } + ], + "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": "chronicConditionsEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditionsKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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": "push_tokens", + "dartName": "PushToken", + "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": "microAreaId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "token", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "platform", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "push_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_token_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "token" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_user_id_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "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": "answersEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "answersKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "contactPhone", + "columnType": 0, + "isNullable": true, + "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": "user_credentials", + "dartName": "UserCredential", + "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": "passwordHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "passwordSalt", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "memoryKb", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "iterations", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "parallelism", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "failedAttempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockedUntil", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpSecretEncrypted", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpKeyVersion", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpEnabledAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpLastStep", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "user_credentials_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_user_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "cpfHash" + } + ], + "type": "btree", + "isUnique": true, + "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": "notesEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "notesKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "syncStatus", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:SyncStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "arrivalMethod", + "columnType": 0, + "isNullable": false, + "columnDefault": "'manual'::text", + "dartType": "protocol:ArrivalMethod" + }, + { + "__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/20261003151950052/migration.json b/backend/sinalacs_server/migrations/20261003151950052/migration.json new file mode 100644 index 00000000..e4c08cd0 --- /dev/null +++ b/backend/sinalacs_server/migrations/20261003151950052/migration.json @@ -0,0 +1,166 @@ +{ + "__className__": "serverpod.DatabaseMigration", + "actions": [ + { + "__className__": "serverpod.DatabaseMigrationAction", + "type": "createTable", + "createTable": { + "__className__": "serverpod.TableDefinition", + "name": "acs_refresh_tokens", + "dartName": "AcsRefreshToken", + "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": "familyId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "issuedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "idleExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "absoluteExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "rotatedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "revokedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "acs_refresh_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_family_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "familyId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_user_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + } + } + ], + "warnings": [], + "migrationApiVersion": 1 +} \ No newline at end of file diff --git a/backend/sinalacs_server/migrations/20261003151950052/migration.sql b/backend/sinalacs_server/migrations/20261003151950052/migration.sql new file mode 100644 index 00000000..d2c9f927 --- /dev/null +++ b/backend/sinalacs_server/migrations/20261003151950052/migration.sql @@ -0,0 +1,52 @@ +BEGIN; + +-- +-- ACTION CREATE TABLE +-- +CREATE TABLE "acs_refresh_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "familyId" uuid NOT NULL, + "tokenHash" text NOT NULL, + "deviceId" text NOT NULL, + "issuedAt" timestamp without time zone NOT NULL, + "idleExpiresAt" timestamp without time zone NOT NULL, + "absoluteExpiresAt" timestamp without time zone NOT NULL, + "rotatedAt" timestamp without time zone, + "revokedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "acs_refresh_tokens_hash_key" ON "acs_refresh_tokens" USING btree ("tokenHash"); +CREATE INDEX "acs_refresh_tokens_family_idx" ON "acs_refresh_tokens" USING btree ("familyId"); +CREATE INDEX "acs_refresh_tokens_user_idx" ON "acs_refresh_tokens" USING btree ("userId"); + +-- +-- ACTION CREATE FOREIGN KEY +-- +ALTER TABLE ONLY "acs_refresh_tokens" + ADD CONSTRAINT "acs_refresh_tokens_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', '20261003151950052', now()) + ON CONFLICT ("module") + DO UPDATE SET "version" = '20261003151950052', "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/20261004013108186/definition.json b/backend/sinalacs_server/migrations/20261004013108186/definition.json new file mode 100644 index 00000000..afcd979c --- /dev/null +++ b/backend/sinalacs_server/migrations/20261004013108186/definition.json @@ -0,0 +1,3621 @@ +{ + "__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 + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "acs_refresh_tokens", + "dartName": "AcsRefreshToken", + "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": "familyId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "issuedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "idleExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "absoluteExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "rotatedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "revokedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "acs_refresh_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_family_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "familyId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_user_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "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": "locationCell", + "columnType": 0, + "isNullable": true, + "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": "data_subject_requests", + "dartName": "DataSubjectRequest", + "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": "requestType", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestType" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsEncrypted", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsKeyVersion", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "dueAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "data_subject_requests_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_user_id_type_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "requestType" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "enrollment_tokens", + "dartName": "EnrollmentToken", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdByAcsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_1", + "columns": [ + "createdByAcsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_token_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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": "otp_challenges", + "dartName": "OtpChallenge", + "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": "codeHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "attempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "otp_challenges_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_user_id_created_at_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "createdAt" + } + ], + "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": "chronicConditionsEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditionsKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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": "push_tokens", + "dartName": "PushToken", + "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": "microAreaId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "token", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "platform", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "push_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_token_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "token" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_user_id_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "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": "answersEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "answersKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "contactPhone", + "columnType": 0, + "isNullable": true, + "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": "user_credentials", + "dartName": "UserCredential", + "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": "passwordHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "passwordSalt", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "memoryKb", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "iterations", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "parallelism", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "failedAttempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockedUntil", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpSecretEncrypted", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpKeyVersion", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpEnabledAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpLastStep", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "user_credentials_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_user_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "cpfHash" + } + ], + "type": "btree", + "isUnique": true, + "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": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "authorship", + "columnType": 0, + "isNullable": false, + "columnDefault": "'acs'::text", + "dartType": "protocol:VisitAuthorship" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "originDeviceId", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__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": "notesEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "notesKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "syncStatus", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:SyncStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "arrivalMethod", + "columnType": 0, + "isNullable": false, + "columnDefault": "'manual'::text", + "dartType": "protocol:ArrivalMethod" + }, + { + "__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": "20261004013108186" + }, + { + "__className__": "serverpod.DatabaseMigrationVersion", + "module": "serverpod", + "version": "20260129180959368" + } + ], + "migrationApiVersion": 1 +} \ No newline at end of file diff --git a/backend/sinalacs_server/migrations/20261004013108186/definition.sql b/backend/sinalacs_server/migrations/20261004013108186/definition.sql new file mode 100644 index 00000000..6567dfdf --- /dev/null +++ b/backend/sinalacs_server/migrations/20261004013108186/definition.sql @@ -0,0 +1,752 @@ +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"); +CREATE UNIQUE INDEX "acs_enrollment_id_key" ON "acs" USING btree ("enrollmentId"); + +-- +-- Class AcsRefreshToken as table acs_refresh_tokens +-- +CREATE TABLE "acs_refresh_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "familyId" uuid NOT NULL, + "tokenHash" text NOT NULL, + "deviceId" text NOT NULL, + "issuedAt" timestamp without time zone NOT NULL, + "idleExpiresAt" timestamp without time zone NOT NULL, + "absoluteExpiresAt" timestamp without time zone NOT NULL, + "rotatedAt" timestamp without time zone, + "revokedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "acs_refresh_tokens_hash_key" ON "acs_refresh_tokens" USING btree ("tokenHash"); +CREATE INDEX "acs_refresh_tokens_family_idx" ON "acs_refresh_tokens" USING btree ("familyId"); +CREATE INDEX "acs_refresh_tokens_user_idx" ON "acs_refresh_tokens" USING btree ("userId"); + +-- +-- 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, + "locationCell" text, + "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 DataSubjectRequest as table data_subject_requests +-- +CREATE TABLE "data_subject_requests" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "requestType" text NOT NULL, + "detailsEncrypted" text NOT NULL, + "detailsKeyVersion" bigint NOT NULL, + "status" text NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "dueAt" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE INDEX "data_subject_requests_user_id_type_idx" ON "data_subject_requests" USING btree ("userId", "requestType"); + +-- +-- Class EnrollmentToken as table enrollment_tokens +-- +CREATE TABLE "enrollment_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "tokenHash" text NOT NULL, + "patientId" uuid NOT NULL, + "microAreaId" uuid NOT NULL, + "createdByAcsId" uuid NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "expiresAt" timestamp without time zone NOT NULL, + "consumedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "enrollment_tokens_token_hash_key" ON "enrollment_tokens" USING btree ("tokenHash"); + +-- +-- 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 OtpChallenge as table otp_challenges +-- +CREATE TABLE "otp_challenges" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "codeHash" text NOT NULL, + "attempts" bigint NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "expiresAt" timestamp without time zone NOT NULL, + "consumedAt" timestamp without time zone +); + +-- Indexes +CREATE INDEX "otp_challenges_user_id_created_at_idx" ON "otp_challenges" USING btree ("userId", "createdAt"); + +-- +-- Class Patient as table patients +-- +CREATE TABLE "patients" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "emergencyContact" text NOT NULL, + "isChronic" boolean NOT NULL, + "chronicConditionsEncrypted" text NOT NULL DEFAULT ''::text, + "chronicConditionsKeyVersion" bigint NOT NULL DEFAULT 1, + "lastLocationHash" text, + "lastTriageAt" timestamp without time zone +); + +-- +-- Class PushToken as table push_tokens +-- +CREATE TABLE "push_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "microAreaId" uuid, + "token" text NOT NULL, + "platform" text NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "updatedAt" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "push_tokens_token_key" ON "push_tokens" USING btree ("token"); +CREATE INDEX "push_tokens_user_id_idx" ON "push_tokens" USING btree ("userId"); + +-- +-- Class TriageSession as table triage_sessions +-- +CREATE TABLE "triage_sessions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "patientId" uuid NOT NULL, + "answersEncrypted" text NOT NULL DEFAULT ''::text, + "answersKeyVersion" bigint NOT NULL DEFAULT 1, + "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, + "contactPhone" text +); + +-- +-- Class UserCredential as table user_credentials +-- +CREATE TABLE "user_credentials" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "passwordHash" text NOT NULL, + "passwordSalt" text NOT NULL, + "memoryKb" bigint NOT NULL, + "iterations" bigint NOT NULL, + "parallelism" bigint NOT NULL, + "failedAttempts" bigint NOT NULL, + "lockedUntil" timestamp without time zone, + "createdAt" timestamp without time zone NOT NULL, + "updatedAt" timestamp without time zone NOT NULL, + "totpSecretEncrypted" text, + "totpKeyVersion" bigint, + "totpEnabledAt" timestamp without time zone, + "totpLastStep" bigint +); + +-- Indexes +CREATE UNIQUE INDEX "user_credentials_user_id_key" ON "user_credentials" USING btree ("userId"); + +-- +-- 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 UNIQUE INDEX "users_cpf_hash_key" 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, + "authorship" text NOT NULL DEFAULT 'acs'::text, + "originDeviceId" text, + "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, + "notesEncrypted" text NOT NULL DEFAULT ''::text, + "notesKeyVersion" bigint NOT NULL DEFAULT 1, + "syncStatus" text NOT NULL, + "arrivalMethod" text NOT NULL DEFAULT 'manual'::text, + "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 "acs_refresh_tokens" table +-- +ALTER TABLE ONLY "acs_refresh_tokens" + ADD CONSTRAINT "acs_refresh_tokens_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- 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 "data_subject_requests" table +-- +ALTER TABLE ONLY "data_subject_requests" + ADD CONSTRAINT "data_subject_requests_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "enrollment_tokens" table +-- +ALTER TABLE ONLY "enrollment_tokens" + ADD CONSTRAINT "enrollment_tokens_fk_0" + FOREIGN KEY("patientId") + REFERENCES "patients"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; +ALTER TABLE ONLY "enrollment_tokens" + ADD CONSTRAINT "enrollment_tokens_fk_1" + FOREIGN KEY("createdByAcsId") + REFERENCES "acs"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "otp_challenges" table +-- +ALTER TABLE ONLY "otp_challenges" + ADD CONSTRAINT "otp_challenges_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "push_tokens" table +-- +ALTER TABLE ONLY "push_tokens" + ADD CONSTRAINT "push_tokens_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 "user_credentials" table +-- +ALTER TABLE ONLY "user_credentials" + ADD CONSTRAINT "user_credentials_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("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', '20261004013108186', now()) + ON CONFLICT ("module") + DO UPDATE SET "version" = '20261004013108186', "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/20261004013108186/definition_project.json b/backend/sinalacs_server/migrations/20261004013108186/definition_project.json new file mode 100644 index 00000000..07d324c9 --- /dev/null +++ b/backend/sinalacs_server/migrations/20261004013108186/definition_project.json @@ -0,0 +1,2285 @@ +{ + "__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 + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "acs_refresh_tokens", + "dartName": "AcsRefreshToken", + "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": "familyId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "issuedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "idleExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "absoluteExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "rotatedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "revokedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "acs_refresh_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_family_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "familyId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_user_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "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": "locationCell", + "columnType": 0, + "isNullable": true, + "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": "data_subject_requests", + "dartName": "DataSubjectRequest", + "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": "requestType", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestType" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsEncrypted", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsKeyVersion", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "dueAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "data_subject_requests_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_user_id_type_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "requestType" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "enrollment_tokens", + "dartName": "EnrollmentToken", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdByAcsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_1", + "columns": [ + "createdByAcsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_token_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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": "otp_challenges", + "dartName": "OtpChallenge", + "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": "codeHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "attempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "otp_challenges_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_user_id_created_at_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "createdAt" + } + ], + "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": "chronicConditionsEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditionsKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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": "push_tokens", + "dartName": "PushToken", + "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": "microAreaId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "token", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "platform", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "push_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_token_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "token" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_user_id_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "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": "answersEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "answersKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "contactPhone", + "columnType": 0, + "isNullable": true, + "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": "user_credentials", + "dartName": "UserCredential", + "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": "passwordHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "passwordSalt", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "memoryKb", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "iterations", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "parallelism", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "failedAttempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockedUntil", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpSecretEncrypted", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpKeyVersion", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpEnabledAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpLastStep", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "user_credentials_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_user_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "cpfHash" + } + ], + "type": "btree", + "isUnique": true, + "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": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "authorship", + "columnType": 0, + "isNullable": false, + "columnDefault": "'acs'::text", + "dartType": "protocol:VisitAuthorship" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "originDeviceId", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__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": "notesEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "notesKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "syncStatus", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:SyncStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "arrivalMethod", + "columnType": 0, + "isNullable": false, + "columnDefault": "'manual'::text", + "dartType": "protocol:ArrivalMethod" + }, + { + "__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/20261004013108186/migration.json b/backend/sinalacs_server/migrations/20261004013108186/migration.json new file mode 100644 index 00000000..0f1de29e --- /dev/null +++ b/backend/sinalacs_server/migrations/20261004013108186/migration.json @@ -0,0 +1,48 @@ +{ + "__className__": "serverpod.DatabaseMigration", + "actions": [ + { + "__className__": "serverpod.DatabaseMigrationAction", + "type": "alterTable", + "alterTable": { + "__className__": "serverpod.TableMigration", + "name": "visits", + "schema": "public", + "addColumns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "authorship", + "columnType": 0, + "isNullable": false, + "columnDefault": "'acs'::text", + "dartType": "protocol:VisitAuthorship" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "originDeviceId", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + } + ], + "deleteColumns": [], + "modifyColumns": [ + { + "__className__": "serverpod.ColumnMigration", + "columnName": "acsId", + "addNullable": true, + "removeNullable": false, + "changeDefault": false + } + ], + "addIndexes": [], + "deleteIndexes": [], + "addForeignKeys": [], + "deleteForeignKeys": [], + "warnings": [] + } + } + ], + "warnings": [], + "migrationApiVersion": 1 +} \ No newline at end of file diff --git a/backend/sinalacs_server/migrations/20261004013108186/migration.sql b/backend/sinalacs_server/migrations/20261004013108186/migration.sql new file mode 100644 index 00000000..5f6818df --- /dev/null +++ b/backend/sinalacs_server/migrations/20261004013108186/migration.sql @@ -0,0 +1,27 @@ +BEGIN; + +-- +-- ACTION ALTER TABLE +-- +ALTER TABLE "visits" ADD COLUMN "authorship" text NOT NULL DEFAULT 'acs'::text; +ALTER TABLE "visits" ADD COLUMN "originDeviceId" text; +ALTER TABLE "visits" ALTER COLUMN "acsId" DROP NOT NULL; + +-- +-- MIGRATION VERSION FOR sinalacs +-- +INSERT INTO "serverpod_migrations" ("module", "version", "timestamp") + VALUES ('sinalacs', '20261004013108186', now()) + ON CONFLICT ("module") + DO UPDATE SET "version" = '20261004013108186', "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/20261004014924364/definition.json b/backend/sinalacs_server/migrations/20261004014924364/definition.json new file mode 100644 index 00000000..640bc1e7 --- /dev/null +++ b/backend/sinalacs_server/migrations/20261004014924364/definition.json @@ -0,0 +1,3746 @@ +{ + "__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 + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "acs_refresh_tokens", + "dartName": "AcsRefreshToken", + "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": "familyId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "issuedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "idleExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "absoluteExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "rotatedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "revokedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "acs_refresh_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_family_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "familyId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_user_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "acs_upload_tokens", + "dartName": "AcsUploadToken", + "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": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "issuedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "revokedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "acs_upload_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_user_device_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "deviceId" + } + ], + "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": "locationCell", + "columnType": 0, + "isNullable": true, + "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": "data_subject_requests", + "dartName": "DataSubjectRequest", + "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": "requestType", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestType" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsEncrypted", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsKeyVersion", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "dueAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "data_subject_requests_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_user_id_type_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "requestType" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "enrollment_tokens", + "dartName": "EnrollmentToken", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdByAcsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_1", + "columns": [ + "createdByAcsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_token_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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": "otp_challenges", + "dartName": "OtpChallenge", + "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": "codeHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "attempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "otp_challenges_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_user_id_created_at_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "createdAt" + } + ], + "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": "chronicConditionsEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditionsKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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": "push_tokens", + "dartName": "PushToken", + "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": "microAreaId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "token", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "platform", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "push_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_token_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "token" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_user_id_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "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": "answersEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "answersKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "contactPhone", + "columnType": 0, + "isNullable": true, + "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": "user_credentials", + "dartName": "UserCredential", + "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": "passwordHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "passwordSalt", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "memoryKb", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "iterations", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "parallelism", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "failedAttempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockedUntil", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpSecretEncrypted", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpKeyVersion", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpEnabledAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpLastStep", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "user_credentials_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_user_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "cpfHash" + } + ], + "type": "btree", + "isUnique": true, + "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": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "authorship", + "columnType": 0, + "isNullable": false, + "columnDefault": "'acs'::text", + "dartType": "protocol:VisitAuthorship" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "originDeviceId", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__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": "notesEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "notesKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "syncStatus", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:SyncStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "arrivalMethod", + "columnType": 0, + "isNullable": false, + "columnDefault": "'manual'::text", + "dartType": "protocol:ArrivalMethod" + }, + { + "__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": "20261004014924364" + }, + { + "__className__": "serverpod.DatabaseMigrationVersion", + "module": "serverpod", + "version": "20260129180959368" + } + ], + "migrationApiVersion": 1 +} \ No newline at end of file diff --git a/backend/sinalacs_server/migrations/20261004014924364/definition.sql b/backend/sinalacs_server/migrations/20261004014924364/definition.sql new file mode 100644 index 00000000..676ca5ca --- /dev/null +++ b/backend/sinalacs_server/migrations/20261004014924364/definition.sql @@ -0,0 +1,779 @@ +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"); +CREATE UNIQUE INDEX "acs_enrollment_id_key" ON "acs" USING btree ("enrollmentId"); + +-- +-- Class AcsRefreshToken as table acs_refresh_tokens +-- +CREATE TABLE "acs_refresh_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "familyId" uuid NOT NULL, + "tokenHash" text NOT NULL, + "deviceId" text NOT NULL, + "issuedAt" timestamp without time zone NOT NULL, + "idleExpiresAt" timestamp without time zone NOT NULL, + "absoluteExpiresAt" timestamp without time zone NOT NULL, + "rotatedAt" timestamp without time zone, + "revokedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "acs_refresh_tokens_hash_key" ON "acs_refresh_tokens" USING btree ("tokenHash"); +CREATE INDEX "acs_refresh_tokens_family_idx" ON "acs_refresh_tokens" USING btree ("familyId"); +CREATE INDEX "acs_refresh_tokens_user_idx" ON "acs_refresh_tokens" USING btree ("userId"); + +-- +-- Class AcsUploadToken as table acs_upload_tokens +-- +CREATE TABLE "acs_upload_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "tokenHash" text NOT NULL, + "deviceId" text NOT NULL, + "issuedAt" timestamp without time zone NOT NULL, + "expiresAt" timestamp without time zone NOT NULL, + "revokedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "acs_upload_tokens_hash_key" ON "acs_upload_tokens" USING btree ("tokenHash"); +CREATE INDEX "acs_upload_tokens_user_device_idx" ON "acs_upload_tokens" USING btree ("userId", "deviceId"); + +-- +-- 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, + "locationCell" text, + "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 DataSubjectRequest as table data_subject_requests +-- +CREATE TABLE "data_subject_requests" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "requestType" text NOT NULL, + "detailsEncrypted" text NOT NULL, + "detailsKeyVersion" bigint NOT NULL, + "status" text NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "dueAt" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE INDEX "data_subject_requests_user_id_type_idx" ON "data_subject_requests" USING btree ("userId", "requestType"); + +-- +-- Class EnrollmentToken as table enrollment_tokens +-- +CREATE TABLE "enrollment_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "tokenHash" text NOT NULL, + "patientId" uuid NOT NULL, + "microAreaId" uuid NOT NULL, + "createdByAcsId" uuid NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "expiresAt" timestamp without time zone NOT NULL, + "consumedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "enrollment_tokens_token_hash_key" ON "enrollment_tokens" USING btree ("tokenHash"); + +-- +-- 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 OtpChallenge as table otp_challenges +-- +CREATE TABLE "otp_challenges" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "codeHash" text NOT NULL, + "attempts" bigint NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "expiresAt" timestamp without time zone NOT NULL, + "consumedAt" timestamp without time zone +); + +-- Indexes +CREATE INDEX "otp_challenges_user_id_created_at_idx" ON "otp_challenges" USING btree ("userId", "createdAt"); + +-- +-- Class Patient as table patients +-- +CREATE TABLE "patients" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "emergencyContact" text NOT NULL, + "isChronic" boolean NOT NULL, + "chronicConditionsEncrypted" text NOT NULL DEFAULT ''::text, + "chronicConditionsKeyVersion" bigint NOT NULL DEFAULT 1, + "lastLocationHash" text, + "lastTriageAt" timestamp without time zone +); + +-- +-- Class PushToken as table push_tokens +-- +CREATE TABLE "push_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "microAreaId" uuid, + "token" text NOT NULL, + "platform" text NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "updatedAt" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "push_tokens_token_key" ON "push_tokens" USING btree ("token"); +CREATE INDEX "push_tokens_user_id_idx" ON "push_tokens" USING btree ("userId"); + +-- +-- Class TriageSession as table triage_sessions +-- +CREATE TABLE "triage_sessions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "patientId" uuid NOT NULL, + "answersEncrypted" text NOT NULL DEFAULT ''::text, + "answersKeyVersion" bigint NOT NULL DEFAULT 1, + "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, + "contactPhone" text +); + +-- +-- Class UserCredential as table user_credentials +-- +CREATE TABLE "user_credentials" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "passwordHash" text NOT NULL, + "passwordSalt" text NOT NULL, + "memoryKb" bigint NOT NULL, + "iterations" bigint NOT NULL, + "parallelism" bigint NOT NULL, + "failedAttempts" bigint NOT NULL, + "lockedUntil" timestamp without time zone, + "createdAt" timestamp without time zone NOT NULL, + "updatedAt" timestamp without time zone NOT NULL, + "totpSecretEncrypted" text, + "totpKeyVersion" bigint, + "totpEnabledAt" timestamp without time zone, + "totpLastStep" bigint +); + +-- Indexes +CREATE UNIQUE INDEX "user_credentials_user_id_key" ON "user_credentials" USING btree ("userId"); + +-- +-- 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 UNIQUE INDEX "users_cpf_hash_key" 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, + "authorship" text NOT NULL DEFAULT 'acs'::text, + "originDeviceId" text, + "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, + "notesEncrypted" text NOT NULL DEFAULT ''::text, + "notesKeyVersion" bigint NOT NULL DEFAULT 1, + "syncStatus" text NOT NULL, + "arrivalMethod" text NOT NULL DEFAULT 'manual'::text, + "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 "acs_refresh_tokens" table +-- +ALTER TABLE ONLY "acs_refresh_tokens" + ADD CONSTRAINT "acs_refresh_tokens_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "acs_upload_tokens" table +-- +ALTER TABLE ONLY "acs_upload_tokens" + ADD CONSTRAINT "acs_upload_tokens_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- 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 "data_subject_requests" table +-- +ALTER TABLE ONLY "data_subject_requests" + ADD CONSTRAINT "data_subject_requests_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "enrollment_tokens" table +-- +ALTER TABLE ONLY "enrollment_tokens" + ADD CONSTRAINT "enrollment_tokens_fk_0" + FOREIGN KEY("patientId") + REFERENCES "patients"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; +ALTER TABLE ONLY "enrollment_tokens" + ADD CONSTRAINT "enrollment_tokens_fk_1" + FOREIGN KEY("createdByAcsId") + REFERENCES "acs"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "otp_challenges" table +-- +ALTER TABLE ONLY "otp_challenges" + ADD CONSTRAINT "otp_challenges_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "push_tokens" table +-- +ALTER TABLE ONLY "push_tokens" + ADD CONSTRAINT "push_tokens_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 "user_credentials" table +-- +ALTER TABLE ONLY "user_credentials" + ADD CONSTRAINT "user_credentials_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("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', '20261004014924364', now()) + ON CONFLICT ("module") + DO UPDATE SET "version" = '20261004014924364', "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/20261004014924364/definition_project.json b/backend/sinalacs_server/migrations/20261004014924364/definition_project.json new file mode 100644 index 00000000..b9f68039 --- /dev/null +++ b/backend/sinalacs_server/migrations/20261004014924364/definition_project.json @@ -0,0 +1,2410 @@ +{ + "__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 + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "acs_refresh_tokens", + "dartName": "AcsRefreshToken", + "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": "familyId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "issuedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "idleExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "absoluteExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "rotatedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "revokedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "acs_refresh_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_family_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "familyId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_user_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "acs_upload_tokens", + "dartName": "AcsUploadToken", + "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": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "issuedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "revokedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "acs_upload_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_user_device_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "deviceId" + } + ], + "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": "locationCell", + "columnType": 0, + "isNullable": true, + "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": "data_subject_requests", + "dartName": "DataSubjectRequest", + "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": "requestType", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestType" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsEncrypted", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsKeyVersion", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "dueAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "data_subject_requests_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_user_id_type_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "requestType" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "enrollment_tokens", + "dartName": "EnrollmentToken", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdByAcsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_1", + "columns": [ + "createdByAcsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_token_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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": "otp_challenges", + "dartName": "OtpChallenge", + "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": "codeHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "attempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "otp_challenges_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_user_id_created_at_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "createdAt" + } + ], + "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": "chronicConditionsEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditionsKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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": "push_tokens", + "dartName": "PushToken", + "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": "microAreaId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "token", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "platform", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "push_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_token_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "token" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_user_id_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "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": "answersEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "answersKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "contactPhone", + "columnType": 0, + "isNullable": true, + "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": "user_credentials", + "dartName": "UserCredential", + "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": "passwordHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "passwordSalt", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "memoryKb", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "iterations", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "parallelism", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "failedAttempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockedUntil", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpSecretEncrypted", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpKeyVersion", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpEnabledAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpLastStep", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "user_credentials_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_user_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "cpfHash" + } + ], + "type": "btree", + "isUnique": true, + "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": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "authorship", + "columnType": 0, + "isNullable": false, + "columnDefault": "'acs'::text", + "dartType": "protocol:VisitAuthorship" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "originDeviceId", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__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": "notesEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "notesKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "syncStatus", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:SyncStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "arrivalMethod", + "columnType": 0, + "isNullable": false, + "columnDefault": "'manual'::text", + "dartType": "protocol:ArrivalMethod" + }, + { + "__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/20261004014924364/migration.json b/backend/sinalacs_server/migrations/20261004014924364/migration.json new file mode 100644 index 00000000..ee58c5fb --- /dev/null +++ b/backend/sinalacs_server/migrations/20261004014924364/migration.json @@ -0,0 +1,136 @@ +{ + "__className__": "serverpod.DatabaseMigration", + "actions": [ + { + "__className__": "serverpod.DatabaseMigrationAction", + "type": "createTable", + "createTable": { + "__className__": "serverpod.TableDefinition", + "name": "acs_upload_tokens", + "dartName": "AcsUploadToken", + "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": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "issuedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "revokedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "acs_upload_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_user_device_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "deviceId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + } + } + ], + "warnings": [], + "migrationApiVersion": 1 +} \ No newline at end of file diff --git a/backend/sinalacs_server/migrations/20261004014924364/migration.sql b/backend/sinalacs_server/migrations/20261004014924364/migration.sql new file mode 100644 index 00000000..fb93dd5c --- /dev/null +++ b/backend/sinalacs_server/migrations/20261004014924364/migration.sql @@ -0,0 +1,48 @@ +BEGIN; + +-- +-- ACTION CREATE TABLE +-- +CREATE TABLE "acs_upload_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "tokenHash" text NOT NULL, + "deviceId" text NOT NULL, + "issuedAt" timestamp without time zone NOT NULL, + "expiresAt" timestamp without time zone NOT NULL, + "revokedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "acs_upload_tokens_hash_key" ON "acs_upload_tokens" USING btree ("tokenHash"); +CREATE INDEX "acs_upload_tokens_user_device_idx" ON "acs_upload_tokens" USING btree ("userId", "deviceId"); + +-- +-- ACTION CREATE FOREIGN KEY +-- +ALTER TABLE ONLY "acs_upload_tokens" + ADD CONSTRAINT "acs_upload_tokens_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', '20261004014924364', now()) + ON CONFLICT ("module") + DO UPDATE SET "version" = '20261004014924364', "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/20261005204222939/definition.json b/backend/sinalacs_server/migrations/20261005204222939/definition.json new file mode 100644 index 00000000..888d57e5 --- /dev/null +++ b/backend/sinalacs_server/migrations/20261005204222939/definition.json @@ -0,0 +1,3754 @@ +{ + "__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 + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "acs_refresh_tokens", + "dartName": "AcsRefreshToken", + "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": "familyId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "issuedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "idleExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "absoluteExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "rotatedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "revokedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "acs_refresh_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_family_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "familyId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_user_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "acs_upload_tokens", + "dartName": "AcsUploadToken", + "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": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "issuedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "revokedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "acs_upload_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_user_device_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "deviceId" + } + ], + "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": "locationCell", + "columnType": 0, + "isNullable": true, + "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": "data_subject_requests", + "dartName": "DataSubjectRequest", + "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": "requestType", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestType" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsEncrypted", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsKeyVersion", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "dueAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "data_subject_requests_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_user_id_type_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "requestType" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "enrollment_tokens", + "dartName": "EnrollmentToken", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdByAcsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_1", + "columns": [ + "createdByAcsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_token_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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": "otp_challenges", + "dartName": "OtpChallenge", + "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": "codeHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "attempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "otp_challenges_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_user_id_created_at_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "createdAt" + } + ], + "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": "chronicConditionsEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditionsKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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": "push_tokens", + "dartName": "PushToken", + "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": "microAreaId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "token", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "platform", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "push_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_token_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "token" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_user_id_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "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": "answersEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "answersKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "contactPhone", + "columnType": 0, + "isNullable": true, + "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": "user_credentials", + "dartName": "UserCredential", + "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": "passwordHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "passwordSalt", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "memoryKb", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "iterations", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "parallelism", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "failedAttempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockedUntil", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockStreak", + "columnType": 6, + "isNullable": false, + "columnDefault": "0", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpSecretEncrypted", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpKeyVersion", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpEnabledAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpLastStep", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "user_credentials_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_user_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "cpfHash" + } + ], + "type": "btree", + "isUnique": true, + "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": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "authorship", + "columnType": 0, + "isNullable": false, + "columnDefault": "'acs'::text", + "dartType": "protocol:VisitAuthorship" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "originDeviceId", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__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": "notesEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "notesKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "syncStatus", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:SyncStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "arrivalMethod", + "columnType": 0, + "isNullable": false, + "columnDefault": "'manual'::text", + "dartType": "protocol:ArrivalMethod" + }, + { + "__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": "20261005204222939" + }, + { + "__className__": "serverpod.DatabaseMigrationVersion", + "module": "serverpod", + "version": "20260129180959368" + } + ], + "migrationApiVersion": 1 +} \ No newline at end of file diff --git a/backend/sinalacs_server/migrations/20261005204222939/definition.sql b/backend/sinalacs_server/migrations/20261005204222939/definition.sql new file mode 100644 index 00000000..ac74ffa6 --- /dev/null +++ b/backend/sinalacs_server/migrations/20261005204222939/definition.sql @@ -0,0 +1,780 @@ +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"); +CREATE UNIQUE INDEX "acs_enrollment_id_key" ON "acs" USING btree ("enrollmentId"); + +-- +-- Class AcsRefreshToken as table acs_refresh_tokens +-- +CREATE TABLE "acs_refresh_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "familyId" uuid NOT NULL, + "tokenHash" text NOT NULL, + "deviceId" text NOT NULL, + "issuedAt" timestamp without time zone NOT NULL, + "idleExpiresAt" timestamp without time zone NOT NULL, + "absoluteExpiresAt" timestamp without time zone NOT NULL, + "rotatedAt" timestamp without time zone, + "revokedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "acs_refresh_tokens_hash_key" ON "acs_refresh_tokens" USING btree ("tokenHash"); +CREATE INDEX "acs_refresh_tokens_family_idx" ON "acs_refresh_tokens" USING btree ("familyId"); +CREATE INDEX "acs_refresh_tokens_user_idx" ON "acs_refresh_tokens" USING btree ("userId"); + +-- +-- Class AcsUploadToken as table acs_upload_tokens +-- +CREATE TABLE "acs_upload_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "tokenHash" text NOT NULL, + "deviceId" text NOT NULL, + "issuedAt" timestamp without time zone NOT NULL, + "expiresAt" timestamp without time zone NOT NULL, + "revokedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "acs_upload_tokens_hash_key" ON "acs_upload_tokens" USING btree ("tokenHash"); +CREATE INDEX "acs_upload_tokens_user_device_idx" ON "acs_upload_tokens" USING btree ("userId", "deviceId"); + +-- +-- 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, + "locationCell" text, + "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 DataSubjectRequest as table data_subject_requests +-- +CREATE TABLE "data_subject_requests" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "requestType" text NOT NULL, + "detailsEncrypted" text NOT NULL, + "detailsKeyVersion" bigint NOT NULL, + "status" text NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "dueAt" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE INDEX "data_subject_requests_user_id_type_idx" ON "data_subject_requests" USING btree ("userId", "requestType"); + +-- +-- Class EnrollmentToken as table enrollment_tokens +-- +CREATE TABLE "enrollment_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "tokenHash" text NOT NULL, + "patientId" uuid NOT NULL, + "microAreaId" uuid NOT NULL, + "createdByAcsId" uuid NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "expiresAt" timestamp without time zone NOT NULL, + "consumedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "enrollment_tokens_token_hash_key" ON "enrollment_tokens" USING btree ("tokenHash"); + +-- +-- 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 OtpChallenge as table otp_challenges +-- +CREATE TABLE "otp_challenges" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "codeHash" text NOT NULL, + "attempts" bigint NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "expiresAt" timestamp without time zone NOT NULL, + "consumedAt" timestamp without time zone +); + +-- Indexes +CREATE INDEX "otp_challenges_user_id_created_at_idx" ON "otp_challenges" USING btree ("userId", "createdAt"); + +-- +-- Class Patient as table patients +-- +CREATE TABLE "patients" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "emergencyContact" text NOT NULL, + "isChronic" boolean NOT NULL, + "chronicConditionsEncrypted" text NOT NULL DEFAULT ''::text, + "chronicConditionsKeyVersion" bigint NOT NULL DEFAULT 1, + "lastLocationHash" text, + "lastTriageAt" timestamp without time zone +); + +-- +-- Class PushToken as table push_tokens +-- +CREATE TABLE "push_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "microAreaId" uuid, + "token" text NOT NULL, + "platform" text NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "updatedAt" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "push_tokens_token_key" ON "push_tokens" USING btree ("token"); +CREATE INDEX "push_tokens_user_id_idx" ON "push_tokens" USING btree ("userId"); + +-- +-- Class TriageSession as table triage_sessions +-- +CREATE TABLE "triage_sessions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "patientId" uuid NOT NULL, + "answersEncrypted" text NOT NULL DEFAULT ''::text, + "answersKeyVersion" bigint NOT NULL DEFAULT 1, + "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, + "contactPhone" text +); + +-- +-- Class UserCredential as table user_credentials +-- +CREATE TABLE "user_credentials" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "passwordHash" text NOT NULL, + "passwordSalt" text NOT NULL, + "memoryKb" bigint NOT NULL, + "iterations" bigint NOT NULL, + "parallelism" bigint NOT NULL, + "failedAttempts" bigint NOT NULL, + "lockedUntil" timestamp without time zone, + "lockStreak" bigint NOT NULL DEFAULT 0, + "createdAt" timestamp without time zone NOT NULL, + "updatedAt" timestamp without time zone NOT NULL, + "totpSecretEncrypted" text, + "totpKeyVersion" bigint, + "totpEnabledAt" timestamp without time zone, + "totpLastStep" bigint +); + +-- Indexes +CREATE UNIQUE INDEX "user_credentials_user_id_key" ON "user_credentials" USING btree ("userId"); + +-- +-- 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 UNIQUE INDEX "users_cpf_hash_key" 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, + "authorship" text NOT NULL DEFAULT 'acs'::text, + "originDeviceId" text, + "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, + "notesEncrypted" text NOT NULL DEFAULT ''::text, + "notesKeyVersion" bigint NOT NULL DEFAULT 1, + "syncStatus" text NOT NULL, + "arrivalMethod" text NOT NULL DEFAULT 'manual'::text, + "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 "acs_refresh_tokens" table +-- +ALTER TABLE ONLY "acs_refresh_tokens" + ADD CONSTRAINT "acs_refresh_tokens_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "acs_upload_tokens" table +-- +ALTER TABLE ONLY "acs_upload_tokens" + ADD CONSTRAINT "acs_upload_tokens_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- 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 "data_subject_requests" table +-- +ALTER TABLE ONLY "data_subject_requests" + ADD CONSTRAINT "data_subject_requests_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "enrollment_tokens" table +-- +ALTER TABLE ONLY "enrollment_tokens" + ADD CONSTRAINT "enrollment_tokens_fk_0" + FOREIGN KEY("patientId") + REFERENCES "patients"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; +ALTER TABLE ONLY "enrollment_tokens" + ADD CONSTRAINT "enrollment_tokens_fk_1" + FOREIGN KEY("createdByAcsId") + REFERENCES "acs"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "otp_challenges" table +-- +ALTER TABLE ONLY "otp_challenges" + ADD CONSTRAINT "otp_challenges_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "push_tokens" table +-- +ALTER TABLE ONLY "push_tokens" + ADD CONSTRAINT "push_tokens_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 "user_credentials" table +-- +ALTER TABLE ONLY "user_credentials" + ADD CONSTRAINT "user_credentials_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("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', '20261005204222939', now()) + ON CONFLICT ("module") + DO UPDATE SET "version" = '20261005204222939', "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/20261005204222939/definition_project.json b/backend/sinalacs_server/migrations/20261005204222939/definition_project.json new file mode 100644 index 00000000..3724a725 --- /dev/null +++ b/backend/sinalacs_server/migrations/20261005204222939/definition_project.json @@ -0,0 +1,2418 @@ +{ + "__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 + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "acs_refresh_tokens", + "dartName": "AcsRefreshToken", + "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": "familyId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "issuedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "idleExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "absoluteExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "rotatedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "revokedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "acs_refresh_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_family_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "familyId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_user_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "acs_upload_tokens", + "dartName": "AcsUploadToken", + "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": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "issuedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "revokedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "acs_upload_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_user_device_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "deviceId" + } + ], + "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": "locationCell", + "columnType": 0, + "isNullable": true, + "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": "data_subject_requests", + "dartName": "DataSubjectRequest", + "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": "requestType", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestType" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsEncrypted", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsKeyVersion", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "dueAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "data_subject_requests_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_user_id_type_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "requestType" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "enrollment_tokens", + "dartName": "EnrollmentToken", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdByAcsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_1", + "columns": [ + "createdByAcsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_token_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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": "otp_challenges", + "dartName": "OtpChallenge", + "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": "codeHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "attempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "otp_challenges_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_user_id_created_at_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "createdAt" + } + ], + "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": "chronicConditionsEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditionsKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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": "push_tokens", + "dartName": "PushToken", + "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": "microAreaId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "token", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "platform", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "push_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_token_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "token" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_user_id_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "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": "answersEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "answersKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "contactPhone", + "columnType": 0, + "isNullable": true, + "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": "user_credentials", + "dartName": "UserCredential", + "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": "passwordHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "passwordSalt", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "memoryKb", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "iterations", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "parallelism", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "failedAttempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockedUntil", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockStreak", + "columnType": 6, + "isNullable": false, + "columnDefault": "0", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpSecretEncrypted", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpKeyVersion", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpEnabledAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpLastStep", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "user_credentials_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_user_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "cpfHash" + } + ], + "type": "btree", + "isUnique": true, + "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": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "authorship", + "columnType": 0, + "isNullable": false, + "columnDefault": "'acs'::text", + "dartType": "protocol:VisitAuthorship" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "originDeviceId", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__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": "notesEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "notesKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "syncStatus", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:SyncStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "arrivalMethod", + "columnType": 0, + "isNullable": false, + "columnDefault": "'manual'::text", + "dartType": "protocol:ArrivalMethod" + }, + { + "__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/20261005204222939/migration.json b/backend/sinalacs_server/migrations/20261005204222939/migration.json new file mode 100644 index 00000000..e7bd6e08 --- /dev/null +++ b/backend/sinalacs_server/migrations/20261005204222939/migration.json @@ -0,0 +1,33 @@ +{ + "__className__": "serverpod.DatabaseMigration", + "actions": [ + { + "__className__": "serverpod.DatabaseMigrationAction", + "type": "alterTable", + "alterTable": { + "__className__": "serverpod.TableMigration", + "name": "user_credentials", + "schema": "public", + "addColumns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockStreak", + "columnType": 6, + "isNullable": false, + "columnDefault": "0", + "dartType": "int" + } + ], + "deleteColumns": [], + "modifyColumns": [], + "addIndexes": [], + "deleteIndexes": [], + "addForeignKeys": [], + "deleteForeignKeys": [], + "warnings": [] + } + } + ], + "warnings": [], + "migrationApiVersion": 1 +} \ No newline at end of file diff --git a/backend/sinalacs_server/migrations/20261005204222939/migration.sql b/backend/sinalacs_server/migrations/20261005204222939/migration.sql new file mode 100644 index 00000000..03244b1e --- /dev/null +++ b/backend/sinalacs_server/migrations/20261005204222939/migration.sql @@ -0,0 +1,25 @@ +BEGIN; + +-- +-- ACTION ALTER TABLE +-- +ALTER TABLE "user_credentials" ADD COLUMN "lockStreak" bigint NOT NULL DEFAULT 0; + +-- +-- MIGRATION VERSION FOR sinalacs +-- +INSERT INTO "serverpod_migrations" ("module", "version", "timestamp") + VALUES ('sinalacs', '20261005204222939', now()) + ON CONFLICT ("module") + DO UPDATE SET "version" = '20261005204222939', "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/20261006195027882/definition.json b/backend/sinalacs_server/migrations/20261006195027882/definition.json new file mode 100644 index 00000000..5e91c292 --- /dev/null +++ b/backend/sinalacs_server/migrations/20261006195027882/definition.json @@ -0,0 +1,3817 @@ +{ + "__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 + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "acs_refresh_tokens", + "dartName": "AcsRefreshToken", + "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": "familyId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "issuedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "idleExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "absoluteExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "rotatedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "revokedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "acs_refresh_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_family_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "familyId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_user_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "acs_upload_tokens", + "dartName": "AcsUploadToken", + "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": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "issuedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "revokedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "acs_upload_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_user_device_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "deviceId" + } + ], + "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": "locationCell", + "columnType": 0, + "isNullable": true, + "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": "data_subject_requests", + "dartName": "DataSubjectRequest", + "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": "requestType", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestType" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsEncrypted", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsKeyVersion", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "dueAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "data_subject_requests_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_user_id_type_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "requestType" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "enrollment_tokens", + "dartName": "EnrollmentToken", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdByAcsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_1", + "columns": [ + "createdByAcsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_token_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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": "otp_challenges", + "dartName": "OtpChallenge", + "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": "codeHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "attempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "otp_challenges_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_user_id_created_at_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "createdAt" + } + ], + "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": "chronicConditionsEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditionsKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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": "push_tokens", + "dartName": "PushToken", + "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": "microAreaId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "token", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "platform", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "push_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_token_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "token" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_user_id_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "staff_accounts", + "dartName": "StaffAccount", + "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": "active", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "staff_accounts_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "staff_accounts_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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": "answersEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "answersKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "contactPhone", + "columnType": 0, + "isNullable": true, + "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": "user_credentials", + "dartName": "UserCredential", + "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": "passwordHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "passwordSalt", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "memoryKb", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "iterations", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "parallelism", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "failedAttempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockedUntil", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockStreak", + "columnType": 6, + "isNullable": false, + "columnDefault": "0", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpSecretEncrypted", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpKeyVersion", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpEnabledAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpLastStep", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "user_credentials_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_user_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "cpfHash" + } + ], + "type": "btree", + "isUnique": true, + "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": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "authorship", + "columnType": 0, + "isNullable": false, + "columnDefault": "'acs'::text", + "dartType": "protocol:VisitAuthorship" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "originDeviceId", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__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": "notesEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "notesKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "syncStatus", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:SyncStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "arrivalMethod", + "columnType": 0, + "isNullable": false, + "columnDefault": "'manual'::text", + "dartType": "protocol:ArrivalMethod" + }, + { + "__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": "20261006195027882" + }, + { + "__className__": "serverpod.DatabaseMigrationVersion", + "module": "serverpod", + "version": "20260129180959368" + } + ], + "migrationApiVersion": 1 +} \ No newline at end of file diff --git a/backend/sinalacs_server/migrations/20261006195027882/definition.sql b/backend/sinalacs_server/migrations/20261006195027882/definition.sql new file mode 100644 index 00000000..2bac8caf --- /dev/null +++ b/backend/sinalacs_server/migrations/20261006195027882/definition.sql @@ -0,0 +1,792 @@ +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"); +CREATE UNIQUE INDEX "acs_enrollment_id_key" ON "acs" USING btree ("enrollmentId"); + +-- +-- Class AcsRefreshToken as table acs_refresh_tokens +-- +CREATE TABLE "acs_refresh_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "familyId" uuid NOT NULL, + "tokenHash" text NOT NULL, + "deviceId" text NOT NULL, + "issuedAt" timestamp without time zone NOT NULL, + "idleExpiresAt" timestamp without time zone NOT NULL, + "absoluteExpiresAt" timestamp without time zone NOT NULL, + "rotatedAt" timestamp without time zone, + "revokedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "acs_refresh_tokens_hash_key" ON "acs_refresh_tokens" USING btree ("tokenHash"); +CREATE INDEX "acs_refresh_tokens_family_idx" ON "acs_refresh_tokens" USING btree ("familyId"); +CREATE INDEX "acs_refresh_tokens_user_idx" ON "acs_refresh_tokens" USING btree ("userId"); + +-- +-- Class AcsUploadToken as table acs_upload_tokens +-- +CREATE TABLE "acs_upload_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "tokenHash" text NOT NULL, + "deviceId" text NOT NULL, + "issuedAt" timestamp without time zone NOT NULL, + "expiresAt" timestamp without time zone NOT NULL, + "revokedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "acs_upload_tokens_hash_key" ON "acs_upload_tokens" USING btree ("tokenHash"); +CREATE INDEX "acs_upload_tokens_user_device_idx" ON "acs_upload_tokens" USING btree ("userId", "deviceId"); + +-- +-- 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, + "locationCell" text, + "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 DataSubjectRequest as table data_subject_requests +-- +CREATE TABLE "data_subject_requests" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "requestType" text NOT NULL, + "detailsEncrypted" text NOT NULL, + "detailsKeyVersion" bigint NOT NULL, + "status" text NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "dueAt" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE INDEX "data_subject_requests_user_id_type_idx" ON "data_subject_requests" USING btree ("userId", "requestType"); + +-- +-- Class EnrollmentToken as table enrollment_tokens +-- +CREATE TABLE "enrollment_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "tokenHash" text NOT NULL, + "patientId" uuid NOT NULL, + "microAreaId" uuid NOT NULL, + "createdByAcsId" uuid NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "expiresAt" timestamp without time zone NOT NULL, + "consumedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "enrollment_tokens_token_hash_key" ON "enrollment_tokens" USING btree ("tokenHash"); + +-- +-- 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 OtpChallenge as table otp_challenges +-- +CREATE TABLE "otp_challenges" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "codeHash" text NOT NULL, + "attempts" bigint NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "expiresAt" timestamp without time zone NOT NULL, + "consumedAt" timestamp without time zone +); + +-- Indexes +CREATE INDEX "otp_challenges_user_id_created_at_idx" ON "otp_challenges" USING btree ("userId", "createdAt"); + +-- +-- Class Patient as table patients +-- +CREATE TABLE "patients" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "emergencyContact" text NOT NULL, + "isChronic" boolean NOT NULL, + "chronicConditionsEncrypted" text NOT NULL DEFAULT ''::text, + "chronicConditionsKeyVersion" bigint NOT NULL DEFAULT 1, + "lastLocationHash" text, + "lastTriageAt" timestamp without time zone +); + +-- +-- Class PushToken as table push_tokens +-- +CREATE TABLE "push_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "microAreaId" uuid, + "token" text NOT NULL, + "platform" text NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "updatedAt" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "push_tokens_token_key" ON "push_tokens" USING btree ("token"); +CREATE INDEX "push_tokens_user_id_idx" ON "push_tokens" USING btree ("userId"); + +-- +-- Class StaffAccount as table staff_accounts +-- +CREATE TABLE "staff_accounts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "enrollmentId" text NOT NULL, + "active" boolean NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "staff_accounts_enrollment_id_key" ON "staff_accounts" USING btree ("enrollmentId"); + +-- +-- Class TriageSession as table triage_sessions +-- +CREATE TABLE "triage_sessions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "patientId" uuid NOT NULL, + "answersEncrypted" text NOT NULL DEFAULT ''::text, + "answersKeyVersion" bigint NOT NULL DEFAULT 1, + "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, + "contactPhone" text +); + +-- +-- Class UserCredential as table user_credentials +-- +CREATE TABLE "user_credentials" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "passwordHash" text NOT NULL, + "passwordSalt" text NOT NULL, + "memoryKb" bigint NOT NULL, + "iterations" bigint NOT NULL, + "parallelism" bigint NOT NULL, + "failedAttempts" bigint NOT NULL, + "lockedUntil" timestamp without time zone, + "lockStreak" bigint NOT NULL DEFAULT 0, + "createdAt" timestamp without time zone NOT NULL, + "updatedAt" timestamp without time zone NOT NULL, + "totpSecretEncrypted" text, + "totpKeyVersion" bigint, + "totpEnabledAt" timestamp without time zone, + "totpLastStep" bigint +); + +-- Indexes +CREATE UNIQUE INDEX "user_credentials_user_id_key" ON "user_credentials" USING btree ("userId"); + +-- +-- 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 UNIQUE INDEX "users_cpf_hash_key" 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, + "authorship" text NOT NULL DEFAULT 'acs'::text, + "originDeviceId" text, + "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, + "notesEncrypted" text NOT NULL DEFAULT ''::text, + "notesKeyVersion" bigint NOT NULL DEFAULT 1, + "syncStatus" text NOT NULL, + "arrivalMethod" text NOT NULL DEFAULT 'manual'::text, + "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 "acs_refresh_tokens" table +-- +ALTER TABLE ONLY "acs_refresh_tokens" + ADD CONSTRAINT "acs_refresh_tokens_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "acs_upload_tokens" table +-- +ALTER TABLE ONLY "acs_upload_tokens" + ADD CONSTRAINT "acs_upload_tokens_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- 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 "data_subject_requests" table +-- +ALTER TABLE ONLY "data_subject_requests" + ADD CONSTRAINT "data_subject_requests_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "enrollment_tokens" table +-- +ALTER TABLE ONLY "enrollment_tokens" + ADD CONSTRAINT "enrollment_tokens_fk_0" + FOREIGN KEY("patientId") + REFERENCES "patients"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; +ALTER TABLE ONLY "enrollment_tokens" + ADD CONSTRAINT "enrollment_tokens_fk_1" + FOREIGN KEY("createdByAcsId") + REFERENCES "acs"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "otp_challenges" table +-- +ALTER TABLE ONLY "otp_challenges" + ADD CONSTRAINT "otp_challenges_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "push_tokens" table +-- +ALTER TABLE ONLY "push_tokens" + ADD CONSTRAINT "push_tokens_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 "user_credentials" table +-- +ALTER TABLE ONLY "user_credentials" + ADD CONSTRAINT "user_credentials_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("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', '20261006195027882', now()) + ON CONFLICT ("module") + DO UPDATE SET "version" = '20261006195027882', "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/20261006195027882/definition_project.json b/backend/sinalacs_server/migrations/20261006195027882/definition_project.json new file mode 100644 index 00000000..57f31f43 --- /dev/null +++ b/backend/sinalacs_server/migrations/20261006195027882/definition_project.json @@ -0,0 +1,2481 @@ +{ + "__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 + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "acs_refresh_tokens", + "dartName": "AcsRefreshToken", + "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": "familyId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "issuedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "idleExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "absoluteExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "rotatedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "revokedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "acs_refresh_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_family_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "familyId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_user_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "acs_upload_tokens", + "dartName": "AcsUploadToken", + "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": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "issuedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "revokedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "acs_upload_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_user_device_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "deviceId" + } + ], + "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": "locationCell", + "columnType": 0, + "isNullable": true, + "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": "data_subject_requests", + "dartName": "DataSubjectRequest", + "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": "requestType", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestType" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsEncrypted", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsKeyVersion", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "dueAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "data_subject_requests_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_user_id_type_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "requestType" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "enrollment_tokens", + "dartName": "EnrollmentToken", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdByAcsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_1", + "columns": [ + "createdByAcsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_token_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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": "otp_challenges", + "dartName": "OtpChallenge", + "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": "codeHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "attempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "otp_challenges_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_user_id_created_at_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "createdAt" + } + ], + "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": "chronicConditionsEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditionsKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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": "push_tokens", + "dartName": "PushToken", + "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": "microAreaId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "token", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "platform", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "push_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_token_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "token" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_user_id_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "staff_accounts", + "dartName": "StaffAccount", + "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": "active", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "staff_accounts_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "staff_accounts_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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": "answersEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "answersKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "contactPhone", + "columnType": 0, + "isNullable": true, + "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": "user_credentials", + "dartName": "UserCredential", + "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": "passwordHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "passwordSalt", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "memoryKb", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "iterations", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "parallelism", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "failedAttempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockedUntil", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockStreak", + "columnType": 6, + "isNullable": false, + "columnDefault": "0", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpSecretEncrypted", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpKeyVersion", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpEnabledAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpLastStep", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "user_credentials_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_user_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "cpfHash" + } + ], + "type": "btree", + "isUnique": true, + "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": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "authorship", + "columnType": 0, + "isNullable": false, + "columnDefault": "'acs'::text", + "dartType": "protocol:VisitAuthorship" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "originDeviceId", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__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": "notesEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "notesKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "syncStatus", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:SyncStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "arrivalMethod", + "columnType": 0, + "isNullable": false, + "columnDefault": "'manual'::text", + "dartType": "protocol:ArrivalMethod" + }, + { + "__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/20261006195027882/migration.json b/backend/sinalacs_server/migrations/20261006195027882/migration.json new file mode 100644 index 00000000..0c32e8dd --- /dev/null +++ b/backend/sinalacs_server/migrations/20261006195027882/migration.json @@ -0,0 +1,74 @@ +{ + "__className__": "serverpod.DatabaseMigration", + "actions": [ + { + "__className__": "serverpod.DatabaseMigrationAction", + "type": "createTable", + "createTable": { + "__className__": "serverpod.TableDefinition", + "name": "staff_accounts", + "dartName": "StaffAccount", + "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": "active", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "staff_accounts_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "staff_accounts_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + } + } + ], + "warnings": [], + "migrationApiVersion": 1 +} \ No newline at end of file diff --git a/backend/sinalacs_server/migrations/20261006195027882/migration.sql b/backend/sinalacs_server/migrations/20261006195027882/migration.sql new file mode 100644 index 00000000..7b7c34bf --- /dev/null +++ b/backend/sinalacs_server/migrations/20261006195027882/migration.sql @@ -0,0 +1,33 @@ +BEGIN; + +-- +-- ACTION CREATE TABLE +-- +CREATE TABLE "staff_accounts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "enrollmentId" text NOT NULL, + "active" boolean NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "staff_accounts_enrollment_id_key" ON "staff_accounts" USING btree ("enrollmentId"); + + +-- +-- MIGRATION VERSION FOR sinalacs +-- +INSERT INTO "serverpod_migrations" ("module", "version", "timestamp") + VALUES ('sinalacs', '20261006195027882', now()) + ON CONFLICT ("module") + DO UPDATE SET "version" = '20261006195027882', "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/20261007153247428/definition.json b/backend/sinalacs_server/migrations/20261007153247428/definition.json new file mode 100644 index 00000000..56d3c13d --- /dev/null +++ b/backend/sinalacs_server/migrations/20261007153247428/definition.json @@ -0,0 +1,3845 @@ +{ + "__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 + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "acs_refresh_tokens", + "dartName": "AcsRefreshToken", + "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": "familyId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "issuedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "idleExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "absoluteExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "rotatedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "revokedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "acs_refresh_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_family_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "familyId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_user_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "acs_upload_tokens", + "dartName": "AcsUploadToken", + "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": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "issuedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "revokedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "acs_upload_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_user_device_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "deviceId" + } + ], + "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": "locationCell", + "columnType": 0, + "isNullable": true, + "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": "data_subject_requests", + "dartName": "DataSubjectRequest", + "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": "requestType", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestType" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsEncrypted", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsKeyVersion", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "dueAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "data_subject_requests_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_user_id_type_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "requestType" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "enrollment_tokens", + "dartName": "EnrollmentToken", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdByAcsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_1", + "columns": [ + "createdByAcsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_token_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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": "otp_challenges", + "dartName": "OtpChallenge", + "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": "codeHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "attempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "otp_challenges_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_user_id_created_at_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "createdAt" + } + ], + "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": "chronicConditionsEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditionsKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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": "push_tokens", + "dartName": "PushToken", + "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": "microAreaId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "token", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "platform", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "push_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_token_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "token" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_user_id_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "staff_accounts", + "dartName": "StaffAccount", + "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": "active", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeHash", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeExpiresAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeIssuedBy", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeIssuedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "staff_accounts_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "staff_accounts_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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": "answersEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "answersKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "contactPhone", + "columnType": 0, + "isNullable": true, + "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": "user_credentials", + "dartName": "UserCredential", + "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": "passwordHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "passwordSalt", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "memoryKb", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "iterations", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "parallelism", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "failedAttempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockedUntil", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockStreak", + "columnType": 6, + "isNullable": false, + "columnDefault": "0", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpSecretEncrypted", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpKeyVersion", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpEnabledAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpLastStep", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "user_credentials_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_user_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "cpfHash" + } + ], + "type": "btree", + "isUnique": true, + "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": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "authorship", + "columnType": 0, + "isNullable": false, + "columnDefault": "'acs'::text", + "dartType": "protocol:VisitAuthorship" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "originDeviceId", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__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": "notesEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "notesKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "syncStatus", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:SyncStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "arrivalMethod", + "columnType": 0, + "isNullable": false, + "columnDefault": "'manual'::text", + "dartType": "protocol:ArrivalMethod" + }, + { + "__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": "20261007153247428" + }, + { + "__className__": "serverpod.DatabaseMigrationVersion", + "module": "serverpod", + "version": "20260129180959368" + } + ], + "migrationApiVersion": 1 +} \ No newline at end of file diff --git a/backend/sinalacs_server/migrations/20261007153247428/definition.sql b/backend/sinalacs_server/migrations/20261007153247428/definition.sql new file mode 100644 index 00000000..45804000 --- /dev/null +++ b/backend/sinalacs_server/migrations/20261007153247428/definition.sql @@ -0,0 +1,796 @@ +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"); +CREATE UNIQUE INDEX "acs_enrollment_id_key" ON "acs" USING btree ("enrollmentId"); + +-- +-- Class AcsRefreshToken as table acs_refresh_tokens +-- +CREATE TABLE "acs_refresh_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "familyId" uuid NOT NULL, + "tokenHash" text NOT NULL, + "deviceId" text NOT NULL, + "issuedAt" timestamp without time zone NOT NULL, + "idleExpiresAt" timestamp without time zone NOT NULL, + "absoluteExpiresAt" timestamp without time zone NOT NULL, + "rotatedAt" timestamp without time zone, + "revokedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "acs_refresh_tokens_hash_key" ON "acs_refresh_tokens" USING btree ("tokenHash"); +CREATE INDEX "acs_refresh_tokens_family_idx" ON "acs_refresh_tokens" USING btree ("familyId"); +CREATE INDEX "acs_refresh_tokens_user_idx" ON "acs_refresh_tokens" USING btree ("userId"); + +-- +-- Class AcsUploadToken as table acs_upload_tokens +-- +CREATE TABLE "acs_upload_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "tokenHash" text NOT NULL, + "deviceId" text NOT NULL, + "issuedAt" timestamp without time zone NOT NULL, + "expiresAt" timestamp without time zone NOT NULL, + "revokedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "acs_upload_tokens_hash_key" ON "acs_upload_tokens" USING btree ("tokenHash"); +CREATE INDEX "acs_upload_tokens_user_device_idx" ON "acs_upload_tokens" USING btree ("userId", "deviceId"); + +-- +-- 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, + "locationCell" text, + "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 DataSubjectRequest as table data_subject_requests +-- +CREATE TABLE "data_subject_requests" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "requestType" text NOT NULL, + "detailsEncrypted" text NOT NULL, + "detailsKeyVersion" bigint NOT NULL, + "status" text NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "dueAt" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE INDEX "data_subject_requests_user_id_type_idx" ON "data_subject_requests" USING btree ("userId", "requestType"); + +-- +-- Class EnrollmentToken as table enrollment_tokens +-- +CREATE TABLE "enrollment_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "tokenHash" text NOT NULL, + "patientId" uuid NOT NULL, + "microAreaId" uuid NOT NULL, + "createdByAcsId" uuid NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "expiresAt" timestamp without time zone NOT NULL, + "consumedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "enrollment_tokens_token_hash_key" ON "enrollment_tokens" USING btree ("tokenHash"); + +-- +-- 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 OtpChallenge as table otp_challenges +-- +CREATE TABLE "otp_challenges" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "codeHash" text NOT NULL, + "attempts" bigint NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "expiresAt" timestamp without time zone NOT NULL, + "consumedAt" timestamp without time zone +); + +-- Indexes +CREATE INDEX "otp_challenges_user_id_created_at_idx" ON "otp_challenges" USING btree ("userId", "createdAt"); + +-- +-- Class Patient as table patients +-- +CREATE TABLE "patients" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "emergencyContact" text NOT NULL, + "isChronic" boolean NOT NULL, + "chronicConditionsEncrypted" text NOT NULL DEFAULT ''::text, + "chronicConditionsKeyVersion" bigint NOT NULL DEFAULT 1, + "lastLocationHash" text, + "lastTriageAt" timestamp without time zone +); + +-- +-- Class PushToken as table push_tokens +-- +CREATE TABLE "push_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "microAreaId" uuid, + "token" text NOT NULL, + "platform" text NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "updatedAt" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "push_tokens_token_key" ON "push_tokens" USING btree ("token"); +CREATE INDEX "push_tokens_user_id_idx" ON "push_tokens" USING btree ("userId"); + +-- +-- Class StaffAccount as table staff_accounts +-- +CREATE TABLE "staff_accounts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "enrollmentId" text NOT NULL, + "active" boolean NOT NULL, + "activationCodeHash" text, + "activationCodeExpiresAt" timestamp without time zone, + "activationCodeIssuedBy" text, + "activationCodeIssuedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "staff_accounts_enrollment_id_key" ON "staff_accounts" USING btree ("enrollmentId"); + +-- +-- Class TriageSession as table triage_sessions +-- +CREATE TABLE "triage_sessions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "patientId" uuid NOT NULL, + "answersEncrypted" text NOT NULL DEFAULT ''::text, + "answersKeyVersion" bigint NOT NULL DEFAULT 1, + "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, + "contactPhone" text +); + +-- +-- Class UserCredential as table user_credentials +-- +CREATE TABLE "user_credentials" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "passwordHash" text NOT NULL, + "passwordSalt" text NOT NULL, + "memoryKb" bigint NOT NULL, + "iterations" bigint NOT NULL, + "parallelism" bigint NOT NULL, + "failedAttempts" bigint NOT NULL, + "lockedUntil" timestamp without time zone, + "lockStreak" bigint NOT NULL DEFAULT 0, + "createdAt" timestamp without time zone NOT NULL, + "updatedAt" timestamp without time zone NOT NULL, + "totpSecretEncrypted" text, + "totpKeyVersion" bigint, + "totpEnabledAt" timestamp without time zone, + "totpLastStep" bigint +); + +-- Indexes +CREATE UNIQUE INDEX "user_credentials_user_id_key" ON "user_credentials" USING btree ("userId"); + +-- +-- 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 UNIQUE INDEX "users_cpf_hash_key" 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, + "authorship" text NOT NULL DEFAULT 'acs'::text, + "originDeviceId" text, + "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, + "notesEncrypted" text NOT NULL DEFAULT ''::text, + "notesKeyVersion" bigint NOT NULL DEFAULT 1, + "syncStatus" text NOT NULL, + "arrivalMethod" text NOT NULL DEFAULT 'manual'::text, + "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 "acs_refresh_tokens" table +-- +ALTER TABLE ONLY "acs_refresh_tokens" + ADD CONSTRAINT "acs_refresh_tokens_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "acs_upload_tokens" table +-- +ALTER TABLE ONLY "acs_upload_tokens" + ADD CONSTRAINT "acs_upload_tokens_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- 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 "data_subject_requests" table +-- +ALTER TABLE ONLY "data_subject_requests" + ADD CONSTRAINT "data_subject_requests_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "enrollment_tokens" table +-- +ALTER TABLE ONLY "enrollment_tokens" + ADD CONSTRAINT "enrollment_tokens_fk_0" + FOREIGN KEY("patientId") + REFERENCES "patients"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; +ALTER TABLE ONLY "enrollment_tokens" + ADD CONSTRAINT "enrollment_tokens_fk_1" + FOREIGN KEY("createdByAcsId") + REFERENCES "acs"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "otp_challenges" table +-- +ALTER TABLE ONLY "otp_challenges" + ADD CONSTRAINT "otp_challenges_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "push_tokens" table +-- +ALTER TABLE ONLY "push_tokens" + ADD CONSTRAINT "push_tokens_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 "user_credentials" table +-- +ALTER TABLE ONLY "user_credentials" + ADD CONSTRAINT "user_credentials_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("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', '20261007153247428', now()) + ON CONFLICT ("module") + DO UPDATE SET "version" = '20261007153247428', "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/20261007153247428/definition_project.json b/backend/sinalacs_server/migrations/20261007153247428/definition_project.json new file mode 100644 index 00000000..74a4bd68 --- /dev/null +++ b/backend/sinalacs_server/migrations/20261007153247428/definition_project.json @@ -0,0 +1,2509 @@ +{ + "__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 + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "acs_refresh_tokens", + "dartName": "AcsRefreshToken", + "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": "familyId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "issuedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "idleExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "absoluteExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "rotatedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "revokedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "acs_refresh_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_family_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "familyId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_user_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "acs_upload_tokens", + "dartName": "AcsUploadToken", + "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": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "issuedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "revokedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "acs_upload_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_user_device_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "deviceId" + } + ], + "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": "locationCell", + "columnType": 0, + "isNullable": true, + "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": "data_subject_requests", + "dartName": "DataSubjectRequest", + "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": "requestType", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestType" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsEncrypted", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsKeyVersion", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "dueAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "data_subject_requests_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_user_id_type_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "requestType" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "enrollment_tokens", + "dartName": "EnrollmentToken", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdByAcsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_1", + "columns": [ + "createdByAcsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_token_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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": "otp_challenges", + "dartName": "OtpChallenge", + "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": "codeHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "attempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "otp_challenges_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_user_id_created_at_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "createdAt" + } + ], + "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": "chronicConditionsEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditionsKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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": "push_tokens", + "dartName": "PushToken", + "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": "microAreaId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "token", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "platform", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "push_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_token_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "token" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_user_id_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "staff_accounts", + "dartName": "StaffAccount", + "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": "active", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeHash", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeExpiresAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeIssuedBy", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeIssuedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "staff_accounts_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "staff_accounts_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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": "answersEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "answersKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "contactPhone", + "columnType": 0, + "isNullable": true, + "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": "user_credentials", + "dartName": "UserCredential", + "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": "passwordHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "passwordSalt", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "memoryKb", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "iterations", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "parallelism", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "failedAttempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockedUntil", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockStreak", + "columnType": 6, + "isNullable": false, + "columnDefault": "0", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpSecretEncrypted", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpKeyVersion", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpEnabledAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpLastStep", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "user_credentials_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_user_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "cpfHash" + } + ], + "type": "btree", + "isUnique": true, + "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": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "authorship", + "columnType": 0, + "isNullable": false, + "columnDefault": "'acs'::text", + "dartType": "protocol:VisitAuthorship" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "originDeviceId", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__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": "notesEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "notesKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "syncStatus", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:SyncStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "arrivalMethod", + "columnType": 0, + "isNullable": false, + "columnDefault": "'manual'::text", + "dartType": "protocol:ArrivalMethod" + }, + { + "__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/20261007153247428/migration.json b/backend/sinalacs_server/migrations/20261007153247428/migration.json new file mode 100644 index 00000000..eda33521 --- /dev/null +++ b/backend/sinalacs_server/migrations/20261007153247428/migration.json @@ -0,0 +1,53 @@ +{ + "__className__": "serverpod.DatabaseMigration", + "actions": [ + { + "__className__": "serverpod.DatabaseMigrationAction", + "type": "alterTable", + "alterTable": { + "__className__": "serverpod.TableMigration", + "name": "staff_accounts", + "schema": "public", + "addColumns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeHash", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeExpiresAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeIssuedBy", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeIssuedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "deleteColumns": [], + "modifyColumns": [], + "addIndexes": [], + "deleteIndexes": [], + "addForeignKeys": [], + "deleteForeignKeys": [], + "warnings": [] + } + } + ], + "warnings": [], + "migrationApiVersion": 1 +} \ No newline at end of file diff --git a/backend/sinalacs_server/migrations/20261007153247428/migration.sql b/backend/sinalacs_server/migrations/20261007153247428/migration.sql new file mode 100644 index 00000000..6219777e --- /dev/null +++ b/backend/sinalacs_server/migrations/20261007153247428/migration.sql @@ -0,0 +1,28 @@ +BEGIN; + +-- +-- ACTION ALTER TABLE +-- +ALTER TABLE "staff_accounts" ADD COLUMN "activationCodeHash" text; +ALTER TABLE "staff_accounts" ADD COLUMN "activationCodeExpiresAt" timestamp without time zone; +ALTER TABLE "staff_accounts" ADD COLUMN "activationCodeIssuedBy" text; +ALTER TABLE "staff_accounts" ADD COLUMN "activationCodeIssuedAt" timestamp without time zone; + +-- +-- MIGRATION VERSION FOR sinalacs +-- +INSERT INTO "serverpod_migrations" ("module", "version", "timestamp") + VALUES ('sinalacs', '20261007153247428', now()) + ON CONFLICT ("module") + DO UPDATE SET "version" = '20261007153247428', "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/20261007191803293/definition.json b/backend/sinalacs_server/migrations/20261007191803293/definition.json new file mode 100644 index 00000000..5c17346c --- /dev/null +++ b/backend/sinalacs_server/migrations/20261007191803293/definition.json @@ -0,0 +1,3852 @@ +{ + "__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 + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "acs_refresh_tokens", + "dartName": "AcsRefreshToken", + "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": "familyId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "issuedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "idleExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "absoluteExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "rotatedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "revokedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "acs_refresh_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_family_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "familyId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_user_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "acs_upload_tokens", + "dartName": "AcsUploadToken", + "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": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "issuedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "revokedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "acs_upload_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_user_device_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "deviceId" + } + ], + "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": "locationCell", + "columnType": 0, + "isNullable": true, + "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": "data_subject_requests", + "dartName": "DataSubjectRequest", + "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": "requestType", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestType" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsEncrypted", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsKeyVersion", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "dueAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "data_subject_requests_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_user_id_type_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "requestType" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "enrollment_tokens", + "dartName": "EnrollmentToken", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdByAcsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_1", + "columns": [ + "createdByAcsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_token_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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": "otp_challenges", + "dartName": "OtpChallenge", + "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": "codeHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "attempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "otp_challenges_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_user_id_created_at_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "createdAt" + } + ], + "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": "chronicConditionsEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditionsKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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": "push_tokens", + "dartName": "PushToken", + "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": "microAreaId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "token", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "platform", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "push_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_token_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "token" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_user_id_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "staff_accounts", + "dartName": "StaffAccount", + "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": "active", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "ubsId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeHash", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeExpiresAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeIssuedBy", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeIssuedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "staff_accounts_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "staff_accounts_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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": "answersEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "answersKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "contactPhone", + "columnType": 0, + "isNullable": true, + "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": "user_credentials", + "dartName": "UserCredential", + "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": "passwordHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "passwordSalt", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "memoryKb", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "iterations", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "parallelism", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "failedAttempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockedUntil", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockStreak", + "columnType": 6, + "isNullable": false, + "columnDefault": "0", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpSecretEncrypted", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpKeyVersion", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpEnabledAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpLastStep", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "user_credentials_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_user_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "cpfHash" + } + ], + "type": "btree", + "isUnique": true, + "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": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "authorship", + "columnType": 0, + "isNullable": false, + "columnDefault": "'acs'::text", + "dartType": "protocol:VisitAuthorship" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "originDeviceId", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__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": "notesEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "notesKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "syncStatus", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:SyncStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "arrivalMethod", + "columnType": 0, + "isNullable": false, + "columnDefault": "'manual'::text", + "dartType": "protocol:ArrivalMethod" + }, + { + "__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": "20261007191803293" + }, + { + "__className__": "serverpod.DatabaseMigrationVersion", + "module": "serverpod", + "version": "20260129180959368" + } + ], + "migrationApiVersion": 1 +} \ No newline at end of file diff --git a/backend/sinalacs_server/migrations/20261007191803293/definition.sql b/backend/sinalacs_server/migrations/20261007191803293/definition.sql new file mode 100644 index 00000000..e2d49aa3 --- /dev/null +++ b/backend/sinalacs_server/migrations/20261007191803293/definition.sql @@ -0,0 +1,797 @@ +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"); +CREATE UNIQUE INDEX "acs_enrollment_id_key" ON "acs" USING btree ("enrollmentId"); + +-- +-- Class AcsRefreshToken as table acs_refresh_tokens +-- +CREATE TABLE "acs_refresh_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "familyId" uuid NOT NULL, + "tokenHash" text NOT NULL, + "deviceId" text NOT NULL, + "issuedAt" timestamp without time zone NOT NULL, + "idleExpiresAt" timestamp without time zone NOT NULL, + "absoluteExpiresAt" timestamp without time zone NOT NULL, + "rotatedAt" timestamp without time zone, + "revokedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "acs_refresh_tokens_hash_key" ON "acs_refresh_tokens" USING btree ("tokenHash"); +CREATE INDEX "acs_refresh_tokens_family_idx" ON "acs_refresh_tokens" USING btree ("familyId"); +CREATE INDEX "acs_refresh_tokens_user_idx" ON "acs_refresh_tokens" USING btree ("userId"); + +-- +-- Class AcsUploadToken as table acs_upload_tokens +-- +CREATE TABLE "acs_upload_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "tokenHash" text NOT NULL, + "deviceId" text NOT NULL, + "issuedAt" timestamp without time zone NOT NULL, + "expiresAt" timestamp without time zone NOT NULL, + "revokedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "acs_upload_tokens_hash_key" ON "acs_upload_tokens" USING btree ("tokenHash"); +CREATE INDEX "acs_upload_tokens_user_device_idx" ON "acs_upload_tokens" USING btree ("userId", "deviceId"); + +-- +-- 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, + "locationCell" text, + "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 DataSubjectRequest as table data_subject_requests +-- +CREATE TABLE "data_subject_requests" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "requestType" text NOT NULL, + "detailsEncrypted" text NOT NULL, + "detailsKeyVersion" bigint NOT NULL, + "status" text NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "dueAt" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE INDEX "data_subject_requests_user_id_type_idx" ON "data_subject_requests" USING btree ("userId", "requestType"); + +-- +-- Class EnrollmentToken as table enrollment_tokens +-- +CREATE TABLE "enrollment_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "tokenHash" text NOT NULL, + "patientId" uuid NOT NULL, + "microAreaId" uuid NOT NULL, + "createdByAcsId" uuid NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "expiresAt" timestamp without time zone NOT NULL, + "consumedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "enrollment_tokens_token_hash_key" ON "enrollment_tokens" USING btree ("tokenHash"); + +-- +-- 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 OtpChallenge as table otp_challenges +-- +CREATE TABLE "otp_challenges" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "codeHash" text NOT NULL, + "attempts" bigint NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "expiresAt" timestamp without time zone NOT NULL, + "consumedAt" timestamp without time zone +); + +-- Indexes +CREATE INDEX "otp_challenges_user_id_created_at_idx" ON "otp_challenges" USING btree ("userId", "createdAt"); + +-- +-- Class Patient as table patients +-- +CREATE TABLE "patients" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "emergencyContact" text NOT NULL, + "isChronic" boolean NOT NULL, + "chronicConditionsEncrypted" text NOT NULL DEFAULT ''::text, + "chronicConditionsKeyVersion" bigint NOT NULL DEFAULT 1, + "lastLocationHash" text, + "lastTriageAt" timestamp without time zone +); + +-- +-- Class PushToken as table push_tokens +-- +CREATE TABLE "push_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "microAreaId" uuid, + "token" text NOT NULL, + "platform" text NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "updatedAt" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "push_tokens_token_key" ON "push_tokens" USING btree ("token"); +CREATE INDEX "push_tokens_user_id_idx" ON "push_tokens" USING btree ("userId"); + +-- +-- Class StaffAccount as table staff_accounts +-- +CREATE TABLE "staff_accounts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "enrollmentId" text NOT NULL, + "active" boolean NOT NULL, + "ubsId" uuid, + "activationCodeHash" text, + "activationCodeExpiresAt" timestamp without time zone, + "activationCodeIssuedBy" text, + "activationCodeIssuedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "staff_accounts_enrollment_id_key" ON "staff_accounts" USING btree ("enrollmentId"); + +-- +-- Class TriageSession as table triage_sessions +-- +CREATE TABLE "triage_sessions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "patientId" uuid NOT NULL, + "answersEncrypted" text NOT NULL DEFAULT ''::text, + "answersKeyVersion" bigint NOT NULL DEFAULT 1, + "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, + "contactPhone" text +); + +-- +-- Class UserCredential as table user_credentials +-- +CREATE TABLE "user_credentials" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "passwordHash" text NOT NULL, + "passwordSalt" text NOT NULL, + "memoryKb" bigint NOT NULL, + "iterations" bigint NOT NULL, + "parallelism" bigint NOT NULL, + "failedAttempts" bigint NOT NULL, + "lockedUntil" timestamp without time zone, + "lockStreak" bigint NOT NULL DEFAULT 0, + "createdAt" timestamp without time zone NOT NULL, + "updatedAt" timestamp without time zone NOT NULL, + "totpSecretEncrypted" text, + "totpKeyVersion" bigint, + "totpEnabledAt" timestamp without time zone, + "totpLastStep" bigint +); + +-- Indexes +CREATE UNIQUE INDEX "user_credentials_user_id_key" ON "user_credentials" USING btree ("userId"); + +-- +-- 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 UNIQUE INDEX "users_cpf_hash_key" 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, + "authorship" text NOT NULL DEFAULT 'acs'::text, + "originDeviceId" text, + "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, + "notesEncrypted" text NOT NULL DEFAULT ''::text, + "notesKeyVersion" bigint NOT NULL DEFAULT 1, + "syncStatus" text NOT NULL, + "arrivalMethod" text NOT NULL DEFAULT 'manual'::text, + "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 "acs_refresh_tokens" table +-- +ALTER TABLE ONLY "acs_refresh_tokens" + ADD CONSTRAINT "acs_refresh_tokens_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "acs_upload_tokens" table +-- +ALTER TABLE ONLY "acs_upload_tokens" + ADD CONSTRAINT "acs_upload_tokens_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- 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 "data_subject_requests" table +-- +ALTER TABLE ONLY "data_subject_requests" + ADD CONSTRAINT "data_subject_requests_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "enrollment_tokens" table +-- +ALTER TABLE ONLY "enrollment_tokens" + ADD CONSTRAINT "enrollment_tokens_fk_0" + FOREIGN KEY("patientId") + REFERENCES "patients"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; +ALTER TABLE ONLY "enrollment_tokens" + ADD CONSTRAINT "enrollment_tokens_fk_1" + FOREIGN KEY("createdByAcsId") + REFERENCES "acs"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "otp_challenges" table +-- +ALTER TABLE ONLY "otp_challenges" + ADD CONSTRAINT "otp_challenges_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "push_tokens" table +-- +ALTER TABLE ONLY "push_tokens" + ADD CONSTRAINT "push_tokens_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 "user_credentials" table +-- +ALTER TABLE ONLY "user_credentials" + ADD CONSTRAINT "user_credentials_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("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', '20261007191803293', now()) + ON CONFLICT ("module") + DO UPDATE SET "version" = '20261007191803293', "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/20261007191803293/definition_project.json b/backend/sinalacs_server/migrations/20261007191803293/definition_project.json new file mode 100644 index 00000000..c4fbca7d --- /dev/null +++ b/backend/sinalacs_server/migrations/20261007191803293/definition_project.json @@ -0,0 +1,2516 @@ +{ + "__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 + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "acs_refresh_tokens", + "dartName": "AcsRefreshToken", + "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": "familyId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "issuedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "idleExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "absoluteExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "rotatedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "revokedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "acs_refresh_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_family_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "familyId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_user_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "acs_upload_tokens", + "dartName": "AcsUploadToken", + "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": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "issuedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "revokedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "acs_upload_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_user_device_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "deviceId" + } + ], + "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": "locationCell", + "columnType": 0, + "isNullable": true, + "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": "data_subject_requests", + "dartName": "DataSubjectRequest", + "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": "requestType", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestType" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsEncrypted", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsKeyVersion", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "dueAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "data_subject_requests_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_user_id_type_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "requestType" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "enrollment_tokens", + "dartName": "EnrollmentToken", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdByAcsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_1", + "columns": [ + "createdByAcsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_token_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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": "otp_challenges", + "dartName": "OtpChallenge", + "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": "codeHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "attempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "otp_challenges_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_user_id_created_at_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "createdAt" + } + ], + "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": "chronicConditionsEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditionsKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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": "push_tokens", + "dartName": "PushToken", + "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": "microAreaId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "token", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "platform", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "push_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_token_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "token" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_user_id_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "staff_accounts", + "dartName": "StaffAccount", + "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": "active", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "ubsId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeHash", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeExpiresAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeIssuedBy", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeIssuedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "staff_accounts_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "staff_accounts_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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": "answersEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "answersKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "contactPhone", + "columnType": 0, + "isNullable": true, + "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": "user_credentials", + "dartName": "UserCredential", + "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": "passwordHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "passwordSalt", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "memoryKb", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "iterations", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "parallelism", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "failedAttempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockedUntil", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockStreak", + "columnType": 6, + "isNullable": false, + "columnDefault": "0", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpSecretEncrypted", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpKeyVersion", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpEnabledAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpLastStep", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "user_credentials_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_user_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "cpfHash" + } + ], + "type": "btree", + "isUnique": true, + "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": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "authorship", + "columnType": 0, + "isNullable": false, + "columnDefault": "'acs'::text", + "dartType": "protocol:VisitAuthorship" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "originDeviceId", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__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": "notesEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "notesKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "syncStatus", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:SyncStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "arrivalMethod", + "columnType": 0, + "isNullable": false, + "columnDefault": "'manual'::text", + "dartType": "protocol:ArrivalMethod" + }, + { + "__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/20261007191803293/migration.json b/backend/sinalacs_server/migrations/20261007191803293/migration.json new file mode 100644 index 00000000..7f85469b --- /dev/null +++ b/backend/sinalacs_server/migrations/20261007191803293/migration.json @@ -0,0 +1,32 @@ +{ + "__className__": "serverpod.DatabaseMigration", + "actions": [ + { + "__className__": "serverpod.DatabaseMigrationAction", + "type": "alterTable", + "alterTable": { + "__className__": "serverpod.TableMigration", + "name": "staff_accounts", + "schema": "public", + "addColumns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "ubsId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + } + ], + "deleteColumns": [], + "modifyColumns": [], + "addIndexes": [], + "deleteIndexes": [], + "addForeignKeys": [], + "deleteForeignKeys": [], + "warnings": [] + } + } + ], + "warnings": [], + "migrationApiVersion": 1 +} \ No newline at end of file diff --git a/backend/sinalacs_server/migrations/20261007191803293/migration.sql b/backend/sinalacs_server/migrations/20261007191803293/migration.sql new file mode 100644 index 00000000..b01eb428 --- /dev/null +++ b/backend/sinalacs_server/migrations/20261007191803293/migration.sql @@ -0,0 +1,25 @@ +BEGIN; + +-- +-- ACTION ALTER TABLE +-- +ALTER TABLE "staff_accounts" ADD COLUMN "ubsId" uuid; + +-- +-- MIGRATION VERSION FOR sinalacs +-- +INSERT INTO "serverpod_migrations" ("module", "version", "timestamp") + VALUES ('sinalacs', '20261007191803293', now()) + ON CONFLICT ("module") + DO UPDATE SET "version" = '20261007191803293', "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/20261008190852100/definition.json b/backend/sinalacs_server/migrations/20261008190852100/definition.json new file mode 100644 index 00000000..7d8b1bdf --- /dev/null +++ b/backend/sinalacs_server/migrations/20261008190852100/definition.json @@ -0,0 +1,3894 @@ +{ + "__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 + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "acs_refresh_tokens", + "dartName": "AcsRefreshToken", + "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": "familyId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "issuedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "idleExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "absoluteExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "rotatedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "revokedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "acs_refresh_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_family_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "familyId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_user_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "acs_upload_tokens", + "dartName": "AcsUploadToken", + "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": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "issuedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "revokedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "acs_upload_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_user_device_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "deviceId" + } + ], + "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": "locationCell", + "columnType": 0, + "isNullable": true, + "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": "data_subject_requests", + "dartName": "DataSubjectRequest", + "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": "requestType", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestType" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsEncrypted", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsKeyVersion", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "dueAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "decidedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "decidedBy", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resolutionEncrypted", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resolutionKeyVersion", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "data_subject_requests_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "data_subject_requests_fk_1", + "columns": [ + "decidedBy" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_user_id_type_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "requestType" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "enrollment_tokens", + "dartName": "EnrollmentToken", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdByAcsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_1", + "columns": [ + "createdByAcsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_token_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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": "otp_challenges", + "dartName": "OtpChallenge", + "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": "codeHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "attempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "otp_challenges_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_user_id_created_at_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "createdAt" + } + ], + "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": "chronicConditionsEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditionsKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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": "push_tokens", + "dartName": "PushToken", + "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": "microAreaId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "token", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "platform", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "push_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_token_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "token" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_user_id_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "staff_accounts", + "dartName": "StaffAccount", + "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": "active", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "ubsId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeHash", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeExpiresAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeIssuedBy", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeIssuedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "staff_accounts_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "staff_accounts_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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": "answersEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "answersKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "contactPhone", + "columnType": 0, + "isNullable": true, + "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": "user_credentials", + "dartName": "UserCredential", + "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": "passwordHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "passwordSalt", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "memoryKb", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "iterations", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "parallelism", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "failedAttempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockedUntil", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockStreak", + "columnType": 6, + "isNullable": false, + "columnDefault": "0", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpSecretEncrypted", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpKeyVersion", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpEnabledAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpLastStep", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "user_credentials_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_user_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "cpfHash" + } + ], + "type": "btree", + "isUnique": true, + "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": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "authorship", + "columnType": 0, + "isNullable": false, + "columnDefault": "'acs'::text", + "dartType": "protocol:VisitAuthorship" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "originDeviceId", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__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": "notesEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "notesKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "syncStatus", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:SyncStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "arrivalMethod", + "columnType": 0, + "isNullable": false, + "columnDefault": "'manual'::text", + "dartType": "protocol:ArrivalMethod" + }, + { + "__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": "20261008190852100" + }, + { + "__className__": "serverpod.DatabaseMigrationVersion", + "module": "serverpod", + "version": "20260129180959368" + } + ], + "migrationApiVersion": 1 +} \ No newline at end of file diff --git a/backend/sinalacs_server/migrations/20261008190852100/definition.sql b/backend/sinalacs_server/migrations/20261008190852100/definition.sql new file mode 100644 index 00000000..92c26388 --- /dev/null +++ b/backend/sinalacs_server/migrations/20261008190852100/definition.sql @@ -0,0 +1,807 @@ +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"); +CREATE UNIQUE INDEX "acs_enrollment_id_key" ON "acs" USING btree ("enrollmentId"); + +-- +-- Class AcsRefreshToken as table acs_refresh_tokens +-- +CREATE TABLE "acs_refresh_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "familyId" uuid NOT NULL, + "tokenHash" text NOT NULL, + "deviceId" text NOT NULL, + "issuedAt" timestamp without time zone NOT NULL, + "idleExpiresAt" timestamp without time zone NOT NULL, + "absoluteExpiresAt" timestamp without time zone NOT NULL, + "rotatedAt" timestamp without time zone, + "revokedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "acs_refresh_tokens_hash_key" ON "acs_refresh_tokens" USING btree ("tokenHash"); +CREATE INDEX "acs_refresh_tokens_family_idx" ON "acs_refresh_tokens" USING btree ("familyId"); +CREATE INDEX "acs_refresh_tokens_user_idx" ON "acs_refresh_tokens" USING btree ("userId"); + +-- +-- Class AcsUploadToken as table acs_upload_tokens +-- +CREATE TABLE "acs_upload_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "tokenHash" text NOT NULL, + "deviceId" text NOT NULL, + "issuedAt" timestamp without time zone NOT NULL, + "expiresAt" timestamp without time zone NOT NULL, + "revokedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "acs_upload_tokens_hash_key" ON "acs_upload_tokens" USING btree ("tokenHash"); +CREATE INDEX "acs_upload_tokens_user_device_idx" ON "acs_upload_tokens" USING btree ("userId", "deviceId"); + +-- +-- 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, + "locationCell" text, + "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 DataSubjectRequest as table data_subject_requests +-- +CREATE TABLE "data_subject_requests" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "requestType" text NOT NULL, + "detailsEncrypted" text NOT NULL, + "detailsKeyVersion" bigint NOT NULL, + "status" text NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "dueAt" timestamp without time zone NOT NULL, + "decidedAt" timestamp without time zone, + "decidedBy" uuid, + "resolutionEncrypted" text, + "resolutionKeyVersion" bigint +); + +-- Indexes +CREATE INDEX "data_subject_requests_user_id_type_idx" ON "data_subject_requests" USING btree ("userId", "requestType"); + +-- +-- Class EnrollmentToken as table enrollment_tokens +-- +CREATE TABLE "enrollment_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "tokenHash" text NOT NULL, + "patientId" uuid NOT NULL, + "microAreaId" uuid NOT NULL, + "createdByAcsId" uuid NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "expiresAt" timestamp without time zone NOT NULL, + "consumedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "enrollment_tokens_token_hash_key" ON "enrollment_tokens" USING btree ("tokenHash"); + +-- +-- 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 OtpChallenge as table otp_challenges +-- +CREATE TABLE "otp_challenges" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "codeHash" text NOT NULL, + "attempts" bigint NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "expiresAt" timestamp without time zone NOT NULL, + "consumedAt" timestamp without time zone +); + +-- Indexes +CREATE INDEX "otp_challenges_user_id_created_at_idx" ON "otp_challenges" USING btree ("userId", "createdAt"); + +-- +-- Class Patient as table patients +-- +CREATE TABLE "patients" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "emergencyContact" text NOT NULL, + "isChronic" boolean NOT NULL, + "chronicConditionsEncrypted" text NOT NULL DEFAULT ''::text, + "chronicConditionsKeyVersion" bigint NOT NULL DEFAULT 1, + "lastLocationHash" text, + "lastTriageAt" timestamp without time zone +); + +-- +-- Class PushToken as table push_tokens +-- +CREATE TABLE "push_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "microAreaId" uuid, + "token" text NOT NULL, + "platform" text NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "updatedAt" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "push_tokens_token_key" ON "push_tokens" USING btree ("token"); +CREATE INDEX "push_tokens_user_id_idx" ON "push_tokens" USING btree ("userId"); + +-- +-- Class StaffAccount as table staff_accounts +-- +CREATE TABLE "staff_accounts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "enrollmentId" text NOT NULL, + "active" boolean NOT NULL, + "ubsId" uuid, + "activationCodeHash" text, + "activationCodeExpiresAt" timestamp without time zone, + "activationCodeIssuedBy" text, + "activationCodeIssuedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "staff_accounts_enrollment_id_key" ON "staff_accounts" USING btree ("enrollmentId"); + +-- +-- Class TriageSession as table triage_sessions +-- +CREATE TABLE "triage_sessions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "patientId" uuid NOT NULL, + "answersEncrypted" text NOT NULL DEFAULT ''::text, + "answersKeyVersion" bigint NOT NULL DEFAULT 1, + "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, + "contactPhone" text +); + +-- +-- Class UserCredential as table user_credentials +-- +CREATE TABLE "user_credentials" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "passwordHash" text NOT NULL, + "passwordSalt" text NOT NULL, + "memoryKb" bigint NOT NULL, + "iterations" bigint NOT NULL, + "parallelism" bigint NOT NULL, + "failedAttempts" bigint NOT NULL, + "lockedUntil" timestamp without time zone, + "lockStreak" bigint NOT NULL DEFAULT 0, + "createdAt" timestamp without time zone NOT NULL, + "updatedAt" timestamp without time zone NOT NULL, + "totpSecretEncrypted" text, + "totpKeyVersion" bigint, + "totpEnabledAt" timestamp without time zone, + "totpLastStep" bigint +); + +-- Indexes +CREATE UNIQUE INDEX "user_credentials_user_id_key" ON "user_credentials" USING btree ("userId"); + +-- +-- 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 UNIQUE INDEX "users_cpf_hash_key" 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, + "authorship" text NOT NULL DEFAULT 'acs'::text, + "originDeviceId" text, + "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, + "notesEncrypted" text NOT NULL DEFAULT ''::text, + "notesKeyVersion" bigint NOT NULL DEFAULT 1, + "syncStatus" text NOT NULL, + "arrivalMethod" text NOT NULL DEFAULT 'manual'::text, + "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 "acs_refresh_tokens" table +-- +ALTER TABLE ONLY "acs_refresh_tokens" + ADD CONSTRAINT "acs_refresh_tokens_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "acs_upload_tokens" table +-- +ALTER TABLE ONLY "acs_upload_tokens" + ADD CONSTRAINT "acs_upload_tokens_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- 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 "data_subject_requests" table +-- +ALTER TABLE ONLY "data_subject_requests" + ADD CONSTRAINT "data_subject_requests_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; +ALTER TABLE ONLY "data_subject_requests" + ADD CONSTRAINT "data_subject_requests_fk_1" + FOREIGN KEY("decidedBy") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "enrollment_tokens" table +-- +ALTER TABLE ONLY "enrollment_tokens" + ADD CONSTRAINT "enrollment_tokens_fk_0" + FOREIGN KEY("patientId") + REFERENCES "patients"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; +ALTER TABLE ONLY "enrollment_tokens" + ADD CONSTRAINT "enrollment_tokens_fk_1" + FOREIGN KEY("createdByAcsId") + REFERENCES "acs"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "otp_challenges" table +-- +ALTER TABLE ONLY "otp_challenges" + ADD CONSTRAINT "otp_challenges_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "push_tokens" table +-- +ALTER TABLE ONLY "push_tokens" + ADD CONSTRAINT "push_tokens_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 "user_credentials" table +-- +ALTER TABLE ONLY "user_credentials" + ADD CONSTRAINT "user_credentials_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("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', '20261008190852100', now()) + ON CONFLICT ("module") + DO UPDATE SET "version" = '20261008190852100', "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/20261008190852100/definition_project.json b/backend/sinalacs_server/migrations/20261008190852100/definition_project.json new file mode 100644 index 00000000..8c90cb03 --- /dev/null +++ b/backend/sinalacs_server/migrations/20261008190852100/definition_project.json @@ -0,0 +1,2558 @@ +{ + "__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 + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "acs_refresh_tokens", + "dartName": "AcsRefreshToken", + "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": "familyId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "issuedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "idleExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "absoluteExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "rotatedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "revokedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "acs_refresh_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_family_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "familyId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_user_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "acs_upload_tokens", + "dartName": "AcsUploadToken", + "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": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "issuedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "revokedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "acs_upload_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_user_device_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "deviceId" + } + ], + "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": "locationCell", + "columnType": 0, + "isNullable": true, + "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": "data_subject_requests", + "dartName": "DataSubjectRequest", + "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": "requestType", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestType" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsEncrypted", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsKeyVersion", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "dueAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "decidedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "decidedBy", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resolutionEncrypted", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resolutionKeyVersion", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "data_subject_requests_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "data_subject_requests_fk_1", + "columns": [ + "decidedBy" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_user_id_type_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "requestType" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "enrollment_tokens", + "dartName": "EnrollmentToken", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdByAcsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_1", + "columns": [ + "createdByAcsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_token_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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": "otp_challenges", + "dartName": "OtpChallenge", + "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": "codeHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "attempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "otp_challenges_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_user_id_created_at_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "createdAt" + } + ], + "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": "chronicConditionsEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditionsKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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": "push_tokens", + "dartName": "PushToken", + "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": "microAreaId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "token", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "platform", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "push_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_token_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "token" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_user_id_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "staff_accounts", + "dartName": "StaffAccount", + "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": "active", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "ubsId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeHash", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeExpiresAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeIssuedBy", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeIssuedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "staff_accounts_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "staff_accounts_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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": "answersEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "answersKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "contactPhone", + "columnType": 0, + "isNullable": true, + "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": "user_credentials", + "dartName": "UserCredential", + "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": "passwordHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "passwordSalt", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "memoryKb", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "iterations", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "parallelism", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "failedAttempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockedUntil", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockStreak", + "columnType": 6, + "isNullable": false, + "columnDefault": "0", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpSecretEncrypted", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpKeyVersion", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpEnabledAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpLastStep", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "user_credentials_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_user_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "cpfHash" + } + ], + "type": "btree", + "isUnique": true, + "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": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "authorship", + "columnType": 0, + "isNullable": false, + "columnDefault": "'acs'::text", + "dartType": "protocol:VisitAuthorship" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "originDeviceId", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__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": "notesEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "notesKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "syncStatus", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:SyncStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "arrivalMethod", + "columnType": 0, + "isNullable": false, + "columnDefault": "'manual'::text", + "dartType": "protocol:ArrivalMethod" + }, + { + "__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/20261008190852100/migration.json b/backend/sinalacs_server/migrations/20261008190852100/migration.json new file mode 100644 index 00000000..c778901c --- /dev/null +++ b/backend/sinalacs_server/migrations/20261008190852100/migration.json @@ -0,0 +1,68 @@ +{ + "__className__": "serverpod.DatabaseMigration", + "actions": [ + { + "__className__": "serverpod.DatabaseMigrationAction", + "type": "alterTable", + "alterTable": { + "__className__": "serverpod.TableMigration", + "name": "data_subject_requests", + "schema": "public", + "addColumns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "decidedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "decidedBy", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resolutionEncrypted", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resolutionKeyVersion", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + } + ], + "deleteColumns": [], + "modifyColumns": [], + "addIndexes": [], + "deleteIndexes": [], + "addForeignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "data_subject_requests_fk_1", + "columns": [ + "decidedBy" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "deleteForeignKeys": [], + "warnings": [] + } + } + ], + "warnings": [], + "migrationApiVersion": 1 +} \ No newline at end of file diff --git a/backend/sinalacs_server/migrations/20261008190852100/migration.sql b/backend/sinalacs_server/migrations/20261008190852100/migration.sql new file mode 100644 index 00000000..c569937f --- /dev/null +++ b/backend/sinalacs_server/migrations/20261008190852100/migration.sql @@ -0,0 +1,37 @@ +BEGIN; + +-- +-- ACTION ALTER TABLE +-- +ALTER TABLE "data_subject_requests" ADD COLUMN "decidedAt" timestamp without time zone; +ALTER TABLE "data_subject_requests" ADD COLUMN "decidedBy" uuid; +ALTER TABLE "data_subject_requests" ADD COLUMN "resolutionEncrypted" text; +ALTER TABLE "data_subject_requests" ADD COLUMN "resolutionKeyVersion" bigint; +-- +-- ACTION CREATE FOREIGN KEY +-- +ALTER TABLE ONLY "data_subject_requests" + ADD CONSTRAINT "data_subject_requests_fk_1" + FOREIGN KEY("decidedBy") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- MIGRATION VERSION FOR sinalacs +-- +INSERT INTO "serverpod_migrations" ("module", "version", "timestamp") + VALUES ('sinalacs', '20261008190852100', now()) + ON CONFLICT ("module") + DO UPDATE SET "version" = '20261008190852100', "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/20261011174930924/definition.json b/backend/sinalacs_server/migrations/20261011174930924/definition.json new file mode 100644 index 00000000..56ee2881 --- /dev/null +++ b/backend/sinalacs_server/migrations/20261011174930924/definition.json @@ -0,0 +1,3903 @@ +{ + "__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 + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "acs_refresh_tokens", + "dartName": "AcsRefreshToken", + "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": "familyId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "issuedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "idleExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "absoluteExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "rotatedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "revokedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "acs_refresh_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_family_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "familyId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_user_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "acs_upload_tokens", + "dartName": "AcsUploadToken", + "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": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "issuedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "revokedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "acs_upload_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_user_device_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "deviceId" + } + ], + "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": "locationCell", + "columnType": 0, + "isNullable": true, + "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": "data_subject_requests", + "dartName": "DataSubjectRequest", + "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": "requestType", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestType" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsEncrypted", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsKeyVersion", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "dueAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "decidedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "decidedBy", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resolutionEncrypted", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resolutionKeyVersion", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "data_subject_requests_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "data_subject_requests_fk_1", + "columns": [ + "decidedBy" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_user_id_type_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "requestType" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "enrollment_tokens", + "dartName": "EnrollmentToken", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdByAcsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_1", + "columns": [ + "createdByAcsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_token_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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": "otp_challenges", + "dartName": "OtpChallenge", + "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": "codeHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "attempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "otp_challenges_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_user_id_created_at_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "createdAt" + } + ], + "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": "emergencyContactEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "emergencyContactKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "isChronic", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditionsEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditionsKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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": "push_tokens", + "dartName": "PushToken", + "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": "microAreaId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "token", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "platform", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "push_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_token_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "token" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_user_id_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "staff_accounts", + "dartName": "StaffAccount", + "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": "active", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "ubsId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeHash", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeExpiresAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeIssuedBy", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeIssuedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "staff_accounts_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "staff_accounts_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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": "answersEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "answersKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "contactPhone", + "columnType": 0, + "isNullable": true, + "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": "user_credentials", + "dartName": "UserCredential", + "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": "passwordHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "passwordSalt", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "memoryKb", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "iterations", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "parallelism", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "failedAttempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockedUntil", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockStreak", + "columnType": 6, + "isNullable": false, + "columnDefault": "0", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpSecretEncrypted", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpKeyVersion", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpEnabledAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpLastStep", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "user_credentials_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_user_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "cpfHash" + } + ], + "type": "btree", + "isUnique": true, + "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": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "authorship", + "columnType": 0, + "isNullable": false, + "columnDefault": "'acs'::text", + "dartType": "protocol:VisitAuthorship" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "originDeviceId", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__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": "notesEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "notesKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "syncStatus", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:SyncStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "arrivalMethod", + "columnType": 0, + "isNullable": false, + "columnDefault": "'manual'::text", + "dartType": "protocol:ArrivalMethod" + }, + { + "__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": "20261011174930924" + }, + { + "__className__": "serverpod.DatabaseMigrationVersion", + "module": "serverpod", + "version": "20260129180959368" + } + ], + "migrationApiVersion": 1 +} \ No newline at end of file diff --git a/backend/sinalacs_server/migrations/20261011174930924/definition.sql b/backend/sinalacs_server/migrations/20261011174930924/definition.sql new file mode 100644 index 00000000..2f3315dd --- /dev/null +++ b/backend/sinalacs_server/migrations/20261011174930924/definition.sql @@ -0,0 +1,808 @@ +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"); +CREATE UNIQUE INDEX "acs_enrollment_id_key" ON "acs" USING btree ("enrollmentId"); + +-- +-- Class AcsRefreshToken as table acs_refresh_tokens +-- +CREATE TABLE "acs_refresh_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "familyId" uuid NOT NULL, + "tokenHash" text NOT NULL, + "deviceId" text NOT NULL, + "issuedAt" timestamp without time zone NOT NULL, + "idleExpiresAt" timestamp without time zone NOT NULL, + "absoluteExpiresAt" timestamp without time zone NOT NULL, + "rotatedAt" timestamp without time zone, + "revokedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "acs_refresh_tokens_hash_key" ON "acs_refresh_tokens" USING btree ("tokenHash"); +CREATE INDEX "acs_refresh_tokens_family_idx" ON "acs_refresh_tokens" USING btree ("familyId"); +CREATE INDEX "acs_refresh_tokens_user_idx" ON "acs_refresh_tokens" USING btree ("userId"); + +-- +-- Class AcsUploadToken as table acs_upload_tokens +-- +CREATE TABLE "acs_upload_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "tokenHash" text NOT NULL, + "deviceId" text NOT NULL, + "issuedAt" timestamp without time zone NOT NULL, + "expiresAt" timestamp without time zone NOT NULL, + "revokedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "acs_upload_tokens_hash_key" ON "acs_upload_tokens" USING btree ("tokenHash"); +CREATE INDEX "acs_upload_tokens_user_device_idx" ON "acs_upload_tokens" USING btree ("userId", "deviceId"); + +-- +-- 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, + "locationCell" text, + "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 DataSubjectRequest as table data_subject_requests +-- +CREATE TABLE "data_subject_requests" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "requestType" text NOT NULL, + "detailsEncrypted" text NOT NULL, + "detailsKeyVersion" bigint NOT NULL, + "status" text NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "dueAt" timestamp without time zone NOT NULL, + "decidedAt" timestamp without time zone, + "decidedBy" uuid, + "resolutionEncrypted" text, + "resolutionKeyVersion" bigint +); + +-- Indexes +CREATE INDEX "data_subject_requests_user_id_type_idx" ON "data_subject_requests" USING btree ("userId", "requestType"); + +-- +-- Class EnrollmentToken as table enrollment_tokens +-- +CREATE TABLE "enrollment_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "tokenHash" text NOT NULL, + "patientId" uuid NOT NULL, + "microAreaId" uuid NOT NULL, + "createdByAcsId" uuid NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "expiresAt" timestamp without time zone NOT NULL, + "consumedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "enrollment_tokens_token_hash_key" ON "enrollment_tokens" USING btree ("tokenHash"); + +-- +-- 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 OtpChallenge as table otp_challenges +-- +CREATE TABLE "otp_challenges" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "codeHash" text NOT NULL, + "attempts" bigint NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "expiresAt" timestamp without time zone NOT NULL, + "consumedAt" timestamp without time zone +); + +-- Indexes +CREATE INDEX "otp_challenges_user_id_created_at_idx" ON "otp_challenges" USING btree ("userId", "createdAt"); + +-- +-- Class Patient as table patients +-- +CREATE TABLE "patients" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "emergencyContactEncrypted" text NOT NULL DEFAULT ''::text, + "emergencyContactKeyVersion" bigint NOT NULL DEFAULT 1, + "isChronic" boolean NOT NULL, + "chronicConditionsEncrypted" text NOT NULL DEFAULT ''::text, + "chronicConditionsKeyVersion" bigint NOT NULL DEFAULT 1, + "lastLocationHash" text, + "lastTriageAt" timestamp without time zone +); + +-- +-- Class PushToken as table push_tokens +-- +CREATE TABLE "push_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "microAreaId" uuid, + "token" text NOT NULL, + "platform" text NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "updatedAt" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "push_tokens_token_key" ON "push_tokens" USING btree ("token"); +CREATE INDEX "push_tokens_user_id_idx" ON "push_tokens" USING btree ("userId"); + +-- +-- Class StaffAccount as table staff_accounts +-- +CREATE TABLE "staff_accounts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "enrollmentId" text NOT NULL, + "active" boolean NOT NULL, + "ubsId" uuid, + "activationCodeHash" text, + "activationCodeExpiresAt" timestamp without time zone, + "activationCodeIssuedBy" text, + "activationCodeIssuedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "staff_accounts_enrollment_id_key" ON "staff_accounts" USING btree ("enrollmentId"); + +-- +-- Class TriageSession as table triage_sessions +-- +CREATE TABLE "triage_sessions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "patientId" uuid NOT NULL, + "answersEncrypted" text NOT NULL DEFAULT ''::text, + "answersKeyVersion" bigint NOT NULL DEFAULT 1, + "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, + "contactPhone" text +); + +-- +-- Class UserCredential as table user_credentials +-- +CREATE TABLE "user_credentials" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "passwordHash" text NOT NULL, + "passwordSalt" text NOT NULL, + "memoryKb" bigint NOT NULL, + "iterations" bigint NOT NULL, + "parallelism" bigint NOT NULL, + "failedAttempts" bigint NOT NULL, + "lockedUntil" timestamp without time zone, + "lockStreak" bigint NOT NULL DEFAULT 0, + "createdAt" timestamp without time zone NOT NULL, + "updatedAt" timestamp without time zone NOT NULL, + "totpSecretEncrypted" text, + "totpKeyVersion" bigint, + "totpEnabledAt" timestamp without time zone, + "totpLastStep" bigint +); + +-- Indexes +CREATE UNIQUE INDEX "user_credentials_user_id_key" ON "user_credentials" USING btree ("userId"); + +-- +-- 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 UNIQUE INDEX "users_cpf_hash_key" 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, + "authorship" text NOT NULL DEFAULT 'acs'::text, + "originDeviceId" text, + "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, + "notesEncrypted" text NOT NULL DEFAULT ''::text, + "notesKeyVersion" bigint NOT NULL DEFAULT 1, + "syncStatus" text NOT NULL, + "arrivalMethod" text NOT NULL DEFAULT 'manual'::text, + "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 "acs_refresh_tokens" table +-- +ALTER TABLE ONLY "acs_refresh_tokens" + ADD CONSTRAINT "acs_refresh_tokens_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "acs_upload_tokens" table +-- +ALTER TABLE ONLY "acs_upload_tokens" + ADD CONSTRAINT "acs_upload_tokens_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- 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 "data_subject_requests" table +-- +ALTER TABLE ONLY "data_subject_requests" + ADD CONSTRAINT "data_subject_requests_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; +ALTER TABLE ONLY "data_subject_requests" + ADD CONSTRAINT "data_subject_requests_fk_1" + FOREIGN KEY("decidedBy") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "enrollment_tokens" table +-- +ALTER TABLE ONLY "enrollment_tokens" + ADD CONSTRAINT "enrollment_tokens_fk_0" + FOREIGN KEY("patientId") + REFERENCES "patients"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; +ALTER TABLE ONLY "enrollment_tokens" + ADD CONSTRAINT "enrollment_tokens_fk_1" + FOREIGN KEY("createdByAcsId") + REFERENCES "acs"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "otp_challenges" table +-- +ALTER TABLE ONLY "otp_challenges" + ADD CONSTRAINT "otp_challenges_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "push_tokens" table +-- +ALTER TABLE ONLY "push_tokens" + ADD CONSTRAINT "push_tokens_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 "user_credentials" table +-- +ALTER TABLE ONLY "user_credentials" + ADD CONSTRAINT "user_credentials_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("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', '20261011174930924', now()) + ON CONFLICT ("module") + DO UPDATE SET "version" = '20261011174930924', "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/20261011174930924/definition_project.json b/backend/sinalacs_server/migrations/20261011174930924/definition_project.json new file mode 100644 index 00000000..37cfe17d --- /dev/null +++ b/backend/sinalacs_server/migrations/20261011174930924/definition_project.json @@ -0,0 +1,2567 @@ +{ + "__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 + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "acs_refresh_tokens", + "dartName": "AcsRefreshToken", + "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": "familyId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "issuedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "idleExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "absoluteExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "rotatedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "revokedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "acs_refresh_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_family_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "familyId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_user_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "acs_upload_tokens", + "dartName": "AcsUploadToken", + "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": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "issuedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "revokedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "acs_upload_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_user_device_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "deviceId" + } + ], + "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": "locationCell", + "columnType": 0, + "isNullable": true, + "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": "data_subject_requests", + "dartName": "DataSubjectRequest", + "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": "requestType", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestType" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsEncrypted", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsKeyVersion", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "dueAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "decidedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "decidedBy", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resolutionEncrypted", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resolutionKeyVersion", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "data_subject_requests_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "data_subject_requests_fk_1", + "columns": [ + "decidedBy" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_user_id_type_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "requestType" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "enrollment_tokens", + "dartName": "EnrollmentToken", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdByAcsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_1", + "columns": [ + "createdByAcsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_token_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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": "otp_challenges", + "dartName": "OtpChallenge", + "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": "codeHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "attempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "otp_challenges_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_user_id_created_at_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "createdAt" + } + ], + "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": "emergencyContactEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "emergencyContactKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "isChronic", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditionsEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditionsKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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": "push_tokens", + "dartName": "PushToken", + "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": "microAreaId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "token", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "platform", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "push_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_token_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "token" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_user_id_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "staff_accounts", + "dartName": "StaffAccount", + "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": "active", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "ubsId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeHash", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeExpiresAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeIssuedBy", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeIssuedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "staff_accounts_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "staff_accounts_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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": "answersEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "answersKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "contactPhone", + "columnType": 0, + "isNullable": true, + "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": "user_credentials", + "dartName": "UserCredential", + "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": "passwordHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "passwordSalt", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "memoryKb", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "iterations", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "parallelism", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "failedAttempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockedUntil", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockStreak", + "columnType": 6, + "isNullable": false, + "columnDefault": "0", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpSecretEncrypted", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpKeyVersion", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpEnabledAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpLastStep", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "user_credentials_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_user_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "cpfHash" + } + ], + "type": "btree", + "isUnique": true, + "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": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "authorship", + "columnType": 0, + "isNullable": false, + "columnDefault": "'acs'::text", + "dartType": "protocol:VisitAuthorship" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "originDeviceId", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__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": "notesEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "notesKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "syncStatus", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:SyncStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "arrivalMethod", + "columnType": 0, + "isNullable": false, + "columnDefault": "'manual'::text", + "dartType": "protocol:ArrivalMethod" + }, + { + "__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/20261011174930924/migration.json b/backend/sinalacs_server/migrations/20261011174930924/migration.json new file mode 100644 index 00000000..98d5d65e --- /dev/null +++ b/backend/sinalacs_server/migrations/20261011174930924/migration.json @@ -0,0 +1,65 @@ +{ + "__className__": "serverpod.DatabaseMigration", + "actions": [ + { + "__className__": "serverpod.DatabaseMigrationAction", + "type": "alterTable", + "alterTable": { + "__className__": "serverpod.TableMigration", + "name": "patients", + "schema": "public", + "addColumns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "emergencyContactEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "emergencyContactKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + } + ], + "deleteColumns": [ + "emergencyContact" + ], + "modifyColumns": [], + "addIndexes": [], + "deleteIndexes": [], + "addForeignKeys": [], + "deleteForeignKeys": [], + "warnings": [ + { + "__className__": "serverpod.DatabaseMigrationWarning", + "type": "columnDropped", + "message": "Column \"emergencyContact\" of table \"patients\" will be dropped.", + "table": "patients", + "columns": [ + "emergencyContact" + ], + "destrucive": true + } + ] + } + } + ], + "warnings": [ + { + "__className__": "serverpod.DatabaseMigrationWarning", + "type": "columnDropped", + "message": "Column \"emergencyContact\" of table \"patients\" will be dropped.", + "table": "patients", + "columns": [ + "emergencyContact" + ], + "destrucive": true + } + ], + "migrationApiVersion": 1 +} \ No newline at end of file diff --git a/backend/sinalacs_server/migrations/20261011174930924/migration.sql b/backend/sinalacs_server/migrations/20261011174930924/migration.sql new file mode 100644 index 00000000..020a23f7 --- /dev/null +++ b/backend/sinalacs_server/migrations/20261011174930924/migration.sql @@ -0,0 +1,27 @@ +BEGIN; + +-- +-- ACTION ALTER TABLE +-- +ALTER TABLE "patients" DROP COLUMN "emergencyContact"; +ALTER TABLE "patients" ADD COLUMN "emergencyContactEncrypted" text NOT NULL DEFAULT ''::text; +ALTER TABLE "patients" ADD COLUMN "emergencyContactKeyVersion" bigint NOT NULL DEFAULT 1; + +-- +-- MIGRATION VERSION FOR sinalacs +-- +INSERT INTO "serverpod_migrations" ("module", "version", "timestamp") + VALUES ('sinalacs', '20261011174930924', now()) + ON CONFLICT ("module") + DO UPDATE SET "version" = '20261011174930924', "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/20261012004814823/definition.json b/backend/sinalacs_server/migrations/20261012004814823/definition.json new file mode 100644 index 00000000..a7a00bf0 --- /dev/null +++ b/backend/sinalacs_server/migrations/20261012004814823/definition.json @@ -0,0 +1,3924 @@ +{ + "__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 + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "acs_refresh_tokens", + "dartName": "AcsRefreshToken", + "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": "familyId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "issuedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "idleExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "absoluteExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "rotatedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "revokedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "acs_refresh_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_family_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "familyId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_user_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "acs_upload_tokens", + "dartName": "AcsUploadToken", + "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": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "issuedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "revokedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "acs_upload_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_user_device_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "deviceId" + } + ], + "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": "locationCell", + "columnType": 0, + "isNullable": true, + "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" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "escalatedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "escalatedBy", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "escalationReason", + "columnType": 0, + "isNullable": true, + "dartType": "protocol:EscalationReason?" + } + ], + "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": "data_subject_requests", + "dartName": "DataSubjectRequest", + "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": "requestType", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestType" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsEncrypted", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsKeyVersion", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "dueAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "decidedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "decidedBy", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resolutionEncrypted", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resolutionKeyVersion", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "data_subject_requests_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "data_subject_requests_fk_1", + "columns": [ + "decidedBy" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_user_id_type_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "requestType" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "enrollment_tokens", + "dartName": "EnrollmentToken", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdByAcsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_1", + "columns": [ + "createdByAcsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_token_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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": "otp_challenges", + "dartName": "OtpChallenge", + "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": "codeHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "attempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "otp_challenges_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_user_id_created_at_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "createdAt" + } + ], + "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": "emergencyContactEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "emergencyContactKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "isChronic", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditionsEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditionsKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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": "push_tokens", + "dartName": "PushToken", + "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": "microAreaId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "token", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "platform", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "push_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_token_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "token" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_user_id_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "staff_accounts", + "dartName": "StaffAccount", + "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": "active", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "ubsId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeHash", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeExpiresAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeIssuedBy", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeIssuedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "staff_accounts_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "staff_accounts_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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": "answersEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "answersKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "contactPhone", + "columnType": 0, + "isNullable": true, + "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": "user_credentials", + "dartName": "UserCredential", + "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": "passwordHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "passwordSalt", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "memoryKb", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "iterations", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "parallelism", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "failedAttempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockedUntil", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockStreak", + "columnType": 6, + "isNullable": false, + "columnDefault": "0", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpSecretEncrypted", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpKeyVersion", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpEnabledAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpLastStep", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "user_credentials_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_user_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "cpfHash" + } + ], + "type": "btree", + "isUnique": true, + "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": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "authorship", + "columnType": 0, + "isNullable": false, + "columnDefault": "'acs'::text", + "dartType": "protocol:VisitAuthorship" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "originDeviceId", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__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": "notesEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "notesKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "syncStatus", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:SyncStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "arrivalMethod", + "columnType": 0, + "isNullable": false, + "columnDefault": "'manual'::text", + "dartType": "protocol:ArrivalMethod" + }, + { + "__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": "20261012004814823" + }, + { + "__className__": "serverpod.DatabaseMigrationVersion", + "module": "serverpod", + "version": "20260129180959368" + } + ], + "migrationApiVersion": 1 +} \ No newline at end of file diff --git a/backend/sinalacs_server/migrations/20261012004814823/definition.sql b/backend/sinalacs_server/migrations/20261012004814823/definition.sql new file mode 100644 index 00000000..f0fd6343 --- /dev/null +++ b/backend/sinalacs_server/migrations/20261012004814823/definition.sql @@ -0,0 +1,811 @@ +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"); +CREATE UNIQUE INDEX "acs_enrollment_id_key" ON "acs" USING btree ("enrollmentId"); + +-- +-- Class AcsRefreshToken as table acs_refresh_tokens +-- +CREATE TABLE "acs_refresh_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "familyId" uuid NOT NULL, + "tokenHash" text NOT NULL, + "deviceId" text NOT NULL, + "issuedAt" timestamp without time zone NOT NULL, + "idleExpiresAt" timestamp without time zone NOT NULL, + "absoluteExpiresAt" timestamp without time zone NOT NULL, + "rotatedAt" timestamp without time zone, + "revokedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "acs_refresh_tokens_hash_key" ON "acs_refresh_tokens" USING btree ("tokenHash"); +CREATE INDEX "acs_refresh_tokens_family_idx" ON "acs_refresh_tokens" USING btree ("familyId"); +CREATE INDEX "acs_refresh_tokens_user_idx" ON "acs_refresh_tokens" USING btree ("userId"); + +-- +-- Class AcsUploadToken as table acs_upload_tokens +-- +CREATE TABLE "acs_upload_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "tokenHash" text NOT NULL, + "deviceId" text NOT NULL, + "issuedAt" timestamp without time zone NOT NULL, + "expiresAt" timestamp without time zone NOT NULL, + "revokedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "acs_upload_tokens_hash_key" ON "acs_upload_tokens" USING btree ("tokenHash"); +CREATE INDEX "acs_upload_tokens_user_device_idx" ON "acs_upload_tokens" USING btree ("userId", "deviceId"); + +-- +-- 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, + "locationCell" text, + "status" text NOT NULL, + "mqttTopic" text NOT NULL, + "deviceId" text NOT NULL, + "retryCount" bigint NOT NULL, + "version" bigint NOT NULL, + "escalatedAt" timestamp without time zone, + "escalatedBy" uuid, + "escalationReason" text +); + +-- 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 DataSubjectRequest as table data_subject_requests +-- +CREATE TABLE "data_subject_requests" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "requestType" text NOT NULL, + "detailsEncrypted" text NOT NULL, + "detailsKeyVersion" bigint NOT NULL, + "status" text NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "dueAt" timestamp without time zone NOT NULL, + "decidedAt" timestamp without time zone, + "decidedBy" uuid, + "resolutionEncrypted" text, + "resolutionKeyVersion" bigint +); + +-- Indexes +CREATE INDEX "data_subject_requests_user_id_type_idx" ON "data_subject_requests" USING btree ("userId", "requestType"); + +-- +-- Class EnrollmentToken as table enrollment_tokens +-- +CREATE TABLE "enrollment_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "tokenHash" text NOT NULL, + "patientId" uuid NOT NULL, + "microAreaId" uuid NOT NULL, + "createdByAcsId" uuid NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "expiresAt" timestamp without time zone NOT NULL, + "consumedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "enrollment_tokens_token_hash_key" ON "enrollment_tokens" USING btree ("tokenHash"); + +-- +-- 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 OtpChallenge as table otp_challenges +-- +CREATE TABLE "otp_challenges" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "codeHash" text NOT NULL, + "attempts" bigint NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "expiresAt" timestamp without time zone NOT NULL, + "consumedAt" timestamp without time zone +); + +-- Indexes +CREATE INDEX "otp_challenges_user_id_created_at_idx" ON "otp_challenges" USING btree ("userId", "createdAt"); + +-- +-- Class Patient as table patients +-- +CREATE TABLE "patients" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "emergencyContactEncrypted" text NOT NULL DEFAULT ''::text, + "emergencyContactKeyVersion" bigint NOT NULL DEFAULT 1, + "isChronic" boolean NOT NULL, + "chronicConditionsEncrypted" text NOT NULL DEFAULT ''::text, + "chronicConditionsKeyVersion" bigint NOT NULL DEFAULT 1, + "lastLocationHash" text, + "lastTriageAt" timestamp without time zone +); + +-- +-- Class PushToken as table push_tokens +-- +CREATE TABLE "push_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "microAreaId" uuid, + "token" text NOT NULL, + "platform" text NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "updatedAt" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "push_tokens_token_key" ON "push_tokens" USING btree ("token"); +CREATE INDEX "push_tokens_user_id_idx" ON "push_tokens" USING btree ("userId"); + +-- +-- Class StaffAccount as table staff_accounts +-- +CREATE TABLE "staff_accounts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "enrollmentId" text NOT NULL, + "active" boolean NOT NULL, + "ubsId" uuid, + "activationCodeHash" text, + "activationCodeExpiresAt" timestamp without time zone, + "activationCodeIssuedBy" text, + "activationCodeIssuedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "staff_accounts_enrollment_id_key" ON "staff_accounts" USING btree ("enrollmentId"); + +-- +-- Class TriageSession as table triage_sessions +-- +CREATE TABLE "triage_sessions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "patientId" uuid NOT NULL, + "answersEncrypted" text NOT NULL DEFAULT ''::text, + "answersKeyVersion" bigint NOT NULL DEFAULT 1, + "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, + "contactPhone" text +); + +-- +-- Class UserCredential as table user_credentials +-- +CREATE TABLE "user_credentials" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "passwordHash" text NOT NULL, + "passwordSalt" text NOT NULL, + "memoryKb" bigint NOT NULL, + "iterations" bigint NOT NULL, + "parallelism" bigint NOT NULL, + "failedAttempts" bigint NOT NULL, + "lockedUntil" timestamp without time zone, + "lockStreak" bigint NOT NULL DEFAULT 0, + "createdAt" timestamp without time zone NOT NULL, + "updatedAt" timestamp without time zone NOT NULL, + "totpSecretEncrypted" text, + "totpKeyVersion" bigint, + "totpEnabledAt" timestamp without time zone, + "totpLastStep" bigint +); + +-- Indexes +CREATE UNIQUE INDEX "user_credentials_user_id_key" ON "user_credentials" USING btree ("userId"); + +-- +-- 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 UNIQUE INDEX "users_cpf_hash_key" 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, + "authorship" text NOT NULL DEFAULT 'acs'::text, + "originDeviceId" text, + "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, + "notesEncrypted" text NOT NULL DEFAULT ''::text, + "notesKeyVersion" bigint NOT NULL DEFAULT 1, + "syncStatus" text NOT NULL, + "arrivalMethod" text NOT NULL DEFAULT 'manual'::text, + "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 "acs_refresh_tokens" table +-- +ALTER TABLE ONLY "acs_refresh_tokens" + ADD CONSTRAINT "acs_refresh_tokens_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "acs_upload_tokens" table +-- +ALTER TABLE ONLY "acs_upload_tokens" + ADD CONSTRAINT "acs_upload_tokens_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- 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 "data_subject_requests" table +-- +ALTER TABLE ONLY "data_subject_requests" + ADD CONSTRAINT "data_subject_requests_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; +ALTER TABLE ONLY "data_subject_requests" + ADD CONSTRAINT "data_subject_requests_fk_1" + FOREIGN KEY("decidedBy") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "enrollment_tokens" table +-- +ALTER TABLE ONLY "enrollment_tokens" + ADD CONSTRAINT "enrollment_tokens_fk_0" + FOREIGN KEY("patientId") + REFERENCES "patients"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; +ALTER TABLE ONLY "enrollment_tokens" + ADD CONSTRAINT "enrollment_tokens_fk_1" + FOREIGN KEY("createdByAcsId") + REFERENCES "acs"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "otp_challenges" table +-- +ALTER TABLE ONLY "otp_challenges" + ADD CONSTRAINT "otp_challenges_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "push_tokens" table +-- +ALTER TABLE ONLY "push_tokens" + ADD CONSTRAINT "push_tokens_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 "user_credentials" table +-- +ALTER TABLE ONLY "user_credentials" + ADD CONSTRAINT "user_credentials_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("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', '20261012004814823', now()) + ON CONFLICT ("module") + DO UPDATE SET "version" = '20261012004814823', "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/20261012004814823/definition_project.json b/backend/sinalacs_server/migrations/20261012004814823/definition_project.json new file mode 100644 index 00000000..b16bba97 --- /dev/null +++ b/backend/sinalacs_server/migrations/20261012004814823/definition_project.json @@ -0,0 +1,2588 @@ +{ + "__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 + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "acs_refresh_tokens", + "dartName": "AcsRefreshToken", + "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": "familyId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "issuedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "idleExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "absoluteExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "rotatedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "revokedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "acs_refresh_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_family_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "familyId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_user_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "acs_upload_tokens", + "dartName": "AcsUploadToken", + "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": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "issuedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "revokedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "acs_upload_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_user_device_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "deviceId" + } + ], + "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": "locationCell", + "columnType": 0, + "isNullable": true, + "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" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "escalatedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "escalatedBy", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "escalationReason", + "columnType": 0, + "isNullable": true, + "dartType": "protocol:EscalationReason?" + } + ], + "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": "data_subject_requests", + "dartName": "DataSubjectRequest", + "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": "requestType", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestType" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsEncrypted", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsKeyVersion", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "dueAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "decidedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "decidedBy", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resolutionEncrypted", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resolutionKeyVersion", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "data_subject_requests_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "data_subject_requests_fk_1", + "columns": [ + "decidedBy" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_user_id_type_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "requestType" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "enrollment_tokens", + "dartName": "EnrollmentToken", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdByAcsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_1", + "columns": [ + "createdByAcsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_token_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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": "otp_challenges", + "dartName": "OtpChallenge", + "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": "codeHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "attempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "otp_challenges_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_user_id_created_at_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "createdAt" + } + ], + "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": "emergencyContactEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "emergencyContactKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "isChronic", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditionsEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditionsKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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": "push_tokens", + "dartName": "PushToken", + "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": "microAreaId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "token", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "platform", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "push_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_token_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "token" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_user_id_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "staff_accounts", + "dartName": "StaffAccount", + "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": "active", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "ubsId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeHash", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeExpiresAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeIssuedBy", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeIssuedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "staff_accounts_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "staff_accounts_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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": "answersEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "answersKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "contactPhone", + "columnType": 0, + "isNullable": true, + "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": "user_credentials", + "dartName": "UserCredential", + "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": "passwordHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "passwordSalt", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "memoryKb", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "iterations", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "parallelism", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "failedAttempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockedUntil", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockStreak", + "columnType": 6, + "isNullable": false, + "columnDefault": "0", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpSecretEncrypted", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpKeyVersion", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpEnabledAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpLastStep", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "user_credentials_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_user_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "cpfHash" + } + ], + "type": "btree", + "isUnique": true, + "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": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "authorship", + "columnType": 0, + "isNullable": false, + "columnDefault": "'acs'::text", + "dartType": "protocol:VisitAuthorship" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "originDeviceId", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__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": "notesEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "notesKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "syncStatus", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:SyncStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "arrivalMethod", + "columnType": 0, + "isNullable": false, + "columnDefault": "'manual'::text", + "dartType": "protocol:ArrivalMethod" + }, + { + "__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/20261012004814823/migration.json b/backend/sinalacs_server/migrations/20261012004814823/migration.json new file mode 100644 index 00000000..f4c6341f --- /dev/null +++ b/backend/sinalacs_server/migrations/20261012004814823/migration.json @@ -0,0 +1,46 @@ +{ + "__className__": "serverpod.DatabaseMigration", + "actions": [ + { + "__className__": "serverpod.DatabaseMigrationAction", + "type": "alterTable", + "alterTable": { + "__className__": "serverpod.TableMigration", + "name": "alerts", + "schema": "public", + "addColumns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "escalatedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "escalatedBy", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "escalationReason", + "columnType": 0, + "isNullable": true, + "dartType": "protocol:EscalationReason?" + } + ], + "deleteColumns": [], + "modifyColumns": [], + "addIndexes": [], + "deleteIndexes": [], + "addForeignKeys": [], + "deleteForeignKeys": [], + "warnings": [] + } + } + ], + "warnings": [], + "migrationApiVersion": 1 +} \ No newline at end of file diff --git a/backend/sinalacs_server/migrations/20261012004814823/migration.sql b/backend/sinalacs_server/migrations/20261012004814823/migration.sql new file mode 100644 index 00000000..9fa53855 --- /dev/null +++ b/backend/sinalacs_server/migrations/20261012004814823/migration.sql @@ -0,0 +1,27 @@ +BEGIN; + +-- +-- ACTION ALTER TABLE +-- +ALTER TABLE "alerts" ADD COLUMN "escalatedAt" timestamp without time zone; +ALTER TABLE "alerts" ADD COLUMN "escalatedBy" uuid; +ALTER TABLE "alerts" ADD COLUMN "escalationReason" text; + +-- +-- MIGRATION VERSION FOR sinalacs +-- +INSERT INTO "serverpod_migrations" ("module", "version", "timestamp") + VALUES ('sinalacs', '20261012004814823', now()) + ON CONFLICT ("module") + DO UPDATE SET "version" = '20261012004814823', "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/20261013151127052/definition.json b/backend/sinalacs_server/migrations/20261013151127052/definition.json new file mode 100644 index 00000000..d9470a72 --- /dev/null +++ b/backend/sinalacs_server/migrations/20261013151127052/definition.json @@ -0,0 +1,4161 @@ +{ + "__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 + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "acs_refresh_tokens", + "dartName": "AcsRefreshToken", + "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": "familyId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "issuedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "idleExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "absoluteExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "rotatedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "revokedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "acs_refresh_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_family_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "familyId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_user_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "acs_upload_tokens", + "dartName": "AcsUploadToken", + "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": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "issuedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "revokedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "acs_upload_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_user_device_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "deviceId" + } + ], + "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": "locationCell", + "columnType": 0, + "isNullable": true, + "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" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "escalatedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "escalatedBy", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "escalationReason", + "columnType": 0, + "isNullable": true, + "dartType": "protocol:EscalationReason?" + } + ], + "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_chain_checkpoints", + "dartName": "AuditChainCheckpoint", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "sequence", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "entryHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "signature", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_chain_checkpoints_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_chain_checkpoints_sequence_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "sequence" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "audit_exports", + "dartName": "AuditExport", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "requestedBy", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "requestedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "filtersJson", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "format", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "rowCount", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "firstSequence", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lastSequence", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "contentSha256", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chainState", + "columnType": 0, + "isNullable": false, + "dartType": "String" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "audit_exports_fk_0", + "columns": [ + "requestedBy" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_exports_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_exports_requested_at_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "requestedAt" + } + ], + "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 + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_logs_timestamp_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "timestamp" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_logs_user_sequence_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "sequence" + } + ], + "type": "btree", + "isUnique": false, + "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": "data_subject_requests", + "dartName": "DataSubjectRequest", + "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": "requestType", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestType" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsEncrypted", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsKeyVersion", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "dueAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "decidedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "decidedBy", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resolutionEncrypted", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resolutionKeyVersion", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "data_subject_requests_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "data_subject_requests_fk_1", + "columns": [ + "decidedBy" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_user_id_type_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "requestType" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "enrollment_tokens", + "dartName": "EnrollmentToken", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdByAcsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_1", + "columns": [ + "createdByAcsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_token_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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": "otp_challenges", + "dartName": "OtpChallenge", + "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": "codeHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "attempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "otp_challenges_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_user_id_created_at_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "createdAt" + } + ], + "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": "emergencyContactEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "emergencyContactKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "isChronic", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditionsEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditionsKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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": "push_tokens", + "dartName": "PushToken", + "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": "microAreaId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "token", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "platform", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "push_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_token_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "token" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_user_id_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "staff_accounts", + "dartName": "StaffAccount", + "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": "active", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "ubsId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeHash", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeExpiresAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeIssuedBy", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeIssuedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "staff_accounts_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "staff_accounts_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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": "answersEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "answersKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "contactPhone", + "columnType": 0, + "isNullable": true, + "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": "user_credentials", + "dartName": "UserCredential", + "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": "passwordHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "passwordSalt", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "memoryKb", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "iterations", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "parallelism", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "failedAttempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockedUntil", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockStreak", + "columnType": 6, + "isNullable": false, + "columnDefault": "0", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpSecretEncrypted", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpKeyVersion", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpEnabledAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpLastStep", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "user_credentials_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_user_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "cpfHash" + } + ], + "type": "btree", + "isUnique": true, + "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": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "authorship", + "columnType": 0, + "isNullable": false, + "columnDefault": "'acs'::text", + "dartType": "protocol:VisitAuthorship" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "originDeviceId", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__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": "notesEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "notesKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "syncStatus", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:SyncStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "arrivalMethod", + "columnType": 0, + "isNullable": false, + "columnDefault": "'manual'::text", + "dartType": "protocol:ArrivalMethod" + }, + { + "__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": "20261013151127052" + }, + { + "__className__": "serverpod.DatabaseMigrationVersion", + "module": "serverpod", + "version": "20260129180959368" + } + ], + "migrationApiVersion": 1 +} \ No newline at end of file diff --git a/backend/sinalacs_server/migrations/20261013151127052/definition.sql b/backend/sinalacs_server/migrations/20261013151127052/definition.sql new file mode 100644 index 00000000..d5b6705a --- /dev/null +++ b/backend/sinalacs_server/migrations/20261013151127052/definition.sql @@ -0,0 +1,856 @@ +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"); +CREATE UNIQUE INDEX "acs_enrollment_id_key" ON "acs" USING btree ("enrollmentId"); + +-- +-- Class AcsRefreshToken as table acs_refresh_tokens +-- +CREATE TABLE "acs_refresh_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "familyId" uuid NOT NULL, + "tokenHash" text NOT NULL, + "deviceId" text NOT NULL, + "issuedAt" timestamp without time zone NOT NULL, + "idleExpiresAt" timestamp without time zone NOT NULL, + "absoluteExpiresAt" timestamp without time zone NOT NULL, + "rotatedAt" timestamp without time zone, + "revokedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "acs_refresh_tokens_hash_key" ON "acs_refresh_tokens" USING btree ("tokenHash"); +CREATE INDEX "acs_refresh_tokens_family_idx" ON "acs_refresh_tokens" USING btree ("familyId"); +CREATE INDEX "acs_refresh_tokens_user_idx" ON "acs_refresh_tokens" USING btree ("userId"); + +-- +-- Class AcsUploadToken as table acs_upload_tokens +-- +CREATE TABLE "acs_upload_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "tokenHash" text NOT NULL, + "deviceId" text NOT NULL, + "issuedAt" timestamp without time zone NOT NULL, + "expiresAt" timestamp without time zone NOT NULL, + "revokedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "acs_upload_tokens_hash_key" ON "acs_upload_tokens" USING btree ("tokenHash"); +CREATE INDEX "acs_upload_tokens_user_device_idx" ON "acs_upload_tokens" USING btree ("userId", "deviceId"); + +-- +-- 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, + "locationCell" text, + "status" text NOT NULL, + "mqttTopic" text NOT NULL, + "deviceId" text NOT NULL, + "retryCount" bigint NOT NULL, + "version" bigint NOT NULL, + "escalatedAt" timestamp without time zone, + "escalatedBy" uuid, + "escalationReason" text +); + +-- Indexes +CREATE INDEX "alerts_micro_area_status_idx" ON "alerts" USING btree ("microAreaId", "status", "triggeredAt"); + +-- +-- Class AuditChainCheckpoint as table audit_chain_checkpoints +-- +CREATE TABLE "audit_chain_checkpoints" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "sequence" bigint NOT NULL, + "entryHash" text NOT NULL, + "signature" text NOT NULL, + "createdAt" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "audit_chain_checkpoints_sequence_idx" ON "audit_chain_checkpoints" USING btree ("sequence"); + +-- +-- Class AuditExport as table audit_exports +-- +CREATE TABLE "audit_exports" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "requestedBy" uuid NOT NULL, + "requestedAt" timestamp without time zone NOT NULL, + "filtersJson" text NOT NULL, + "format" text NOT NULL, + "rowCount" bigint NOT NULL, + "firstSequence" bigint, + "lastSequence" bigint, + "contentSha256" text NOT NULL, + "chainState" text NOT NULL +); + +-- Indexes +CREATE INDEX "audit_exports_requested_at_idx" ON "audit_exports" USING btree ("requestedAt"); + +-- +-- 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"); +CREATE INDEX "audit_logs_timestamp_idx" ON "audit_logs" USING btree ("timestamp"); +CREATE INDEX "audit_logs_user_sequence_idx" ON "audit_logs" USING btree ("userId", "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 DataSubjectRequest as table data_subject_requests +-- +CREATE TABLE "data_subject_requests" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "requestType" text NOT NULL, + "detailsEncrypted" text NOT NULL, + "detailsKeyVersion" bigint NOT NULL, + "status" text NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "dueAt" timestamp without time zone NOT NULL, + "decidedAt" timestamp without time zone, + "decidedBy" uuid, + "resolutionEncrypted" text, + "resolutionKeyVersion" bigint +); + +-- Indexes +CREATE INDEX "data_subject_requests_user_id_type_idx" ON "data_subject_requests" USING btree ("userId", "requestType"); + +-- +-- Class EnrollmentToken as table enrollment_tokens +-- +CREATE TABLE "enrollment_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "tokenHash" text NOT NULL, + "patientId" uuid NOT NULL, + "microAreaId" uuid NOT NULL, + "createdByAcsId" uuid NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "expiresAt" timestamp without time zone NOT NULL, + "consumedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "enrollment_tokens_token_hash_key" ON "enrollment_tokens" USING btree ("tokenHash"); + +-- +-- 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 OtpChallenge as table otp_challenges +-- +CREATE TABLE "otp_challenges" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "codeHash" text NOT NULL, + "attempts" bigint NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "expiresAt" timestamp without time zone NOT NULL, + "consumedAt" timestamp without time zone +); + +-- Indexes +CREATE INDEX "otp_challenges_user_id_created_at_idx" ON "otp_challenges" USING btree ("userId", "createdAt"); + +-- +-- Class Patient as table patients +-- +CREATE TABLE "patients" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "emergencyContactEncrypted" text NOT NULL DEFAULT ''::text, + "emergencyContactKeyVersion" bigint NOT NULL DEFAULT 1, + "isChronic" boolean NOT NULL, + "chronicConditionsEncrypted" text NOT NULL DEFAULT ''::text, + "chronicConditionsKeyVersion" bigint NOT NULL DEFAULT 1, + "lastLocationHash" text, + "lastTriageAt" timestamp without time zone +); + +-- +-- Class PushToken as table push_tokens +-- +CREATE TABLE "push_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "microAreaId" uuid, + "token" text NOT NULL, + "platform" text NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "updatedAt" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "push_tokens_token_key" ON "push_tokens" USING btree ("token"); +CREATE INDEX "push_tokens_user_id_idx" ON "push_tokens" USING btree ("userId"); + +-- +-- Class StaffAccount as table staff_accounts +-- +CREATE TABLE "staff_accounts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "enrollmentId" text NOT NULL, + "active" boolean NOT NULL, + "ubsId" uuid, + "activationCodeHash" text, + "activationCodeExpiresAt" timestamp without time zone, + "activationCodeIssuedBy" text, + "activationCodeIssuedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "staff_accounts_enrollment_id_key" ON "staff_accounts" USING btree ("enrollmentId"); + +-- +-- Class TriageSession as table triage_sessions +-- +CREATE TABLE "triage_sessions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "patientId" uuid NOT NULL, + "answersEncrypted" text NOT NULL DEFAULT ''::text, + "answersKeyVersion" bigint NOT NULL DEFAULT 1, + "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, + "contactPhone" text +); + +-- +-- Class UserCredential as table user_credentials +-- +CREATE TABLE "user_credentials" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "passwordHash" text NOT NULL, + "passwordSalt" text NOT NULL, + "memoryKb" bigint NOT NULL, + "iterations" bigint NOT NULL, + "parallelism" bigint NOT NULL, + "failedAttempts" bigint NOT NULL, + "lockedUntil" timestamp without time zone, + "lockStreak" bigint NOT NULL DEFAULT 0, + "createdAt" timestamp without time zone NOT NULL, + "updatedAt" timestamp without time zone NOT NULL, + "totpSecretEncrypted" text, + "totpKeyVersion" bigint, + "totpEnabledAt" timestamp without time zone, + "totpLastStep" bigint +); + +-- Indexes +CREATE UNIQUE INDEX "user_credentials_user_id_key" ON "user_credentials" USING btree ("userId"); + +-- +-- 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 UNIQUE INDEX "users_cpf_hash_key" 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, + "authorship" text NOT NULL DEFAULT 'acs'::text, + "originDeviceId" text, + "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, + "notesEncrypted" text NOT NULL DEFAULT ''::text, + "notesKeyVersion" bigint NOT NULL DEFAULT 1, + "syncStatus" text NOT NULL, + "arrivalMethod" text NOT NULL DEFAULT 'manual'::text, + "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 "acs_refresh_tokens" table +-- +ALTER TABLE ONLY "acs_refresh_tokens" + ADD CONSTRAINT "acs_refresh_tokens_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "acs_upload_tokens" table +-- +ALTER TABLE ONLY "acs_upload_tokens" + ADD CONSTRAINT "acs_upload_tokens_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- 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_exports" table +-- +ALTER TABLE ONLY "audit_exports" + ADD CONSTRAINT "audit_exports_fk_0" + FOREIGN KEY("requestedBy") + REFERENCES "users"("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 "data_subject_requests" table +-- +ALTER TABLE ONLY "data_subject_requests" + ADD CONSTRAINT "data_subject_requests_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; +ALTER TABLE ONLY "data_subject_requests" + ADD CONSTRAINT "data_subject_requests_fk_1" + FOREIGN KEY("decidedBy") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "enrollment_tokens" table +-- +ALTER TABLE ONLY "enrollment_tokens" + ADD CONSTRAINT "enrollment_tokens_fk_0" + FOREIGN KEY("patientId") + REFERENCES "patients"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; +ALTER TABLE ONLY "enrollment_tokens" + ADD CONSTRAINT "enrollment_tokens_fk_1" + FOREIGN KEY("createdByAcsId") + REFERENCES "acs"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "otp_challenges" table +-- +ALTER TABLE ONLY "otp_challenges" + ADD CONSTRAINT "otp_challenges_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "push_tokens" table +-- +ALTER TABLE ONLY "push_tokens" + ADD CONSTRAINT "push_tokens_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 "user_credentials" table +-- +ALTER TABLE ONLY "user_credentials" + ADD CONSTRAINT "user_credentials_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("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', '20261013151127052', now()) + ON CONFLICT ("module") + DO UPDATE SET "version" = '20261013151127052', "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/20261013151127052/definition_project.json b/backend/sinalacs_server/migrations/20261013151127052/definition_project.json new file mode 100644 index 00000000..cde9e3aa --- /dev/null +++ b/backend/sinalacs_server/migrations/20261013151127052/definition_project.json @@ -0,0 +1,2825 @@ +{ + "__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 + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "acs_refresh_tokens", + "dartName": "AcsRefreshToken", + "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": "familyId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "issuedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "idleExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "absoluteExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "rotatedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "revokedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "acs_refresh_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_family_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "familyId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_user_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "acs_upload_tokens", + "dartName": "AcsUploadToken", + "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": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "issuedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "revokedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "acs_upload_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_user_device_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "deviceId" + } + ], + "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": "locationCell", + "columnType": 0, + "isNullable": true, + "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" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "escalatedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "escalatedBy", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "escalationReason", + "columnType": 0, + "isNullable": true, + "dartType": "protocol:EscalationReason?" + } + ], + "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_chain_checkpoints", + "dartName": "AuditChainCheckpoint", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "sequence", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "entryHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "signature", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_chain_checkpoints_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_chain_checkpoints_sequence_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "sequence" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "audit_exports", + "dartName": "AuditExport", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "requestedBy", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "requestedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "filtersJson", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "format", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "rowCount", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "firstSequence", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lastSequence", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "contentSha256", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chainState", + "columnType": 0, + "isNullable": false, + "dartType": "String" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "audit_exports_fk_0", + "columns": [ + "requestedBy" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_exports_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_exports_requested_at_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "requestedAt" + } + ], + "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 + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_logs_timestamp_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "timestamp" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_logs_user_sequence_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "sequence" + } + ], + "type": "btree", + "isUnique": false, + "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": "data_subject_requests", + "dartName": "DataSubjectRequest", + "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": "requestType", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestType" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsEncrypted", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsKeyVersion", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "dueAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "decidedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "decidedBy", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resolutionEncrypted", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resolutionKeyVersion", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "data_subject_requests_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "data_subject_requests_fk_1", + "columns": [ + "decidedBy" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_user_id_type_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "requestType" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "enrollment_tokens", + "dartName": "EnrollmentToken", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdByAcsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_1", + "columns": [ + "createdByAcsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_token_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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": "otp_challenges", + "dartName": "OtpChallenge", + "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": "codeHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "attempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "otp_challenges_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_user_id_created_at_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "createdAt" + } + ], + "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": "emergencyContactEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "emergencyContactKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "isChronic", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditionsEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditionsKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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": "push_tokens", + "dartName": "PushToken", + "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": "microAreaId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "token", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "platform", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "push_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_token_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "token" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_user_id_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "staff_accounts", + "dartName": "StaffAccount", + "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": "active", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "ubsId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeHash", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeExpiresAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeIssuedBy", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeIssuedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "staff_accounts_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "staff_accounts_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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": "answersEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "answersKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "contactPhone", + "columnType": 0, + "isNullable": true, + "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": "user_credentials", + "dartName": "UserCredential", + "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": "passwordHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "passwordSalt", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "memoryKb", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "iterations", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "parallelism", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "failedAttempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockedUntil", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockStreak", + "columnType": 6, + "isNullable": false, + "columnDefault": "0", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpSecretEncrypted", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpKeyVersion", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpEnabledAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpLastStep", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "user_credentials_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_user_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "cpfHash" + } + ], + "type": "btree", + "isUnique": true, + "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": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "authorship", + "columnType": 0, + "isNullable": false, + "columnDefault": "'acs'::text", + "dartType": "protocol:VisitAuthorship" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "originDeviceId", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__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": "notesEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "notesKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "syncStatus", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:SyncStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "arrivalMethod", + "columnType": 0, + "isNullable": false, + "columnDefault": "'manual'::text", + "dartType": "protocol:ArrivalMethod" + }, + { + "__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/20261013151127052/migration.json b/backend/sinalacs_server/migrations/20261013151127052/migration.json new file mode 100644 index 00000000..c7e219a2 --- /dev/null +++ b/backend/sinalacs_server/migrations/20261013151127052/migration.json @@ -0,0 +1,270 @@ +{ + "__className__": "serverpod.DatabaseMigration", + "actions": [ + { + "__className__": "serverpod.DatabaseMigrationAction", + "type": "createTable", + "createTable": { + "__className__": "serverpod.TableDefinition", + "name": "audit_chain_checkpoints", + "dartName": "AuditChainCheckpoint", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "sequence", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "entryHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "signature", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_chain_checkpoints_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_chain_checkpoints_sequence_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "sequence" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + } + }, + { + "__className__": "serverpod.DatabaseMigrationAction", + "type": "createTable", + "createTable": { + "__className__": "serverpod.TableDefinition", + "name": "audit_exports", + "dartName": "AuditExport", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "requestedBy", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "requestedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "filtersJson", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "format", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "rowCount", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "firstSequence", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lastSequence", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "contentSha256", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chainState", + "columnType": 0, + "isNullable": false, + "dartType": "String" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "audit_exports_fk_0", + "columns": [ + "requestedBy" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_exports_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_exports_requested_at_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "requestedAt" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + } + }, + { + "__className__": "serverpod.DatabaseMigrationAction", + "type": "alterTable", + "alterTable": { + "__className__": "serverpod.TableMigration", + "name": "audit_logs", + "schema": "public", + "addColumns": [], + "deleteColumns": [], + "modifyColumns": [], + "addIndexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_logs_timestamp_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "timestamp" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_logs_user_sequence_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "sequence" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "deleteIndexes": [], + "addForeignKeys": [], + "deleteForeignKeys": [], + "warnings": [] + } + } + ], + "warnings": [], + "migrationApiVersion": 1 +} \ No newline at end of file diff --git a/backend/sinalacs_server/migrations/20261013151127052/migration.sql b/backend/sinalacs_server/migrations/20261013151127052/migration.sql new file mode 100644 index 00000000..7e9d1432 --- /dev/null +++ b/backend/sinalacs_server/migrations/20261013151127052/migration.sql @@ -0,0 +1,69 @@ +BEGIN; + +-- +-- ACTION CREATE TABLE +-- +CREATE TABLE "audit_chain_checkpoints" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "sequence" bigint NOT NULL, + "entryHash" text NOT NULL, + "signature" text NOT NULL, + "createdAt" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "audit_chain_checkpoints_sequence_idx" ON "audit_chain_checkpoints" USING btree ("sequence"); + +-- +-- ACTION CREATE TABLE +-- +CREATE TABLE "audit_exports" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "requestedBy" uuid NOT NULL, + "requestedAt" timestamp without time zone NOT NULL, + "filtersJson" text NOT NULL, + "format" text NOT NULL, + "rowCount" bigint NOT NULL, + "firstSequence" bigint, + "lastSequence" bigint, + "contentSha256" text NOT NULL, + "chainState" text NOT NULL +); + +-- Indexes +CREATE INDEX "audit_exports_requested_at_idx" ON "audit_exports" USING btree ("requestedAt"); + +-- +-- ACTION ALTER TABLE +-- +CREATE INDEX "audit_logs_timestamp_idx" ON "audit_logs" USING btree ("timestamp"); +CREATE INDEX "audit_logs_user_sequence_idx" ON "audit_logs" USING btree ("userId", "sequence"); +-- +-- ACTION CREATE FOREIGN KEY +-- +ALTER TABLE ONLY "audit_exports" + ADD CONSTRAINT "audit_exports_fk_0" + FOREIGN KEY("requestedBy") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + + +-- +-- MIGRATION VERSION FOR sinalacs +-- +INSERT INTO "serverpod_migrations" ("module", "version", "timestamp") + VALUES ('sinalacs', '20261013151127052', now()) + ON CONFLICT ("module") + DO UPDATE SET "version" = '20261013151127052', "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/20261014152407324/definition.json b/backend/sinalacs_server/migrations/20261014152407324/definition.json new file mode 100644 index 00000000..88293656 --- /dev/null +++ b/backend/sinalacs_server/migrations/20261014152407324/definition.json @@ -0,0 +1,4347 @@ +{ + "__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 + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "acs_refresh_tokens", + "dartName": "AcsRefreshToken", + "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": "familyId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "issuedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "idleExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "absoluteExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "rotatedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "revokedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "acs_refresh_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_family_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "familyId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_user_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "acs_upload_tokens", + "dartName": "AcsUploadToken", + "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": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "issuedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "revokedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "acs_upload_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_user_device_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "deviceId" + } + ], + "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": "locationCell", + "columnType": 0, + "isNullable": true, + "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" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "escalatedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "escalatedBy", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "escalationReason", + "columnType": 0, + "isNullable": true, + "dartType": "protocol:EscalationReason?" + } + ], + "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_chain_checkpoints", + "dartName": "AuditChainCheckpoint", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "sequence", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "entryHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "signature", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_chain_checkpoints_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_chain_checkpoints_sequence_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "sequence" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "audit_exports", + "dartName": "AuditExport", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "requestedBy", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "requestedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "filtersJson", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "format", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "rowCount", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "firstSequence", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lastSequence", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "contentSha256", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chainState", + "columnType": 0, + "isNullable": false, + "dartType": "String" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "audit_exports_fk_0", + "columns": [ + "requestedBy" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_exports_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_exports_requested_at_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "requestedAt" + } + ], + "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 + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_logs_timestamp_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "timestamp" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_logs_user_sequence_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "sequence" + } + ], + "type": "btree", + "isUnique": false, + "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": "data_subject_requests", + "dartName": "DataSubjectRequest", + "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": "requestType", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestType" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsEncrypted", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsKeyVersion", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "dueAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "decidedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "decidedBy", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resolutionEncrypted", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resolutionKeyVersion", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "data_subject_requests_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "data_subject_requests_fk_1", + "columns": [ + "decidedBy" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_user_id_type_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "requestType" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "enrollment_tokens", + "dartName": "EnrollmentToken", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdByAcsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_1", + "columns": [ + "createdByAcsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_token_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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": "otp_challenges", + "dartName": "OtpChallenge", + "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": "codeHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "attempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "otp_challenges_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_user_id_created_at_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "createdAt" + } + ], + "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": "emergencyContactEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "emergencyContactKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "isChronic", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditionsEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditionsKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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": "push_tokens", + "dartName": "PushToken", + "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": "microAreaId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "token", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "platform", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "push_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_token_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "token" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_user_id_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "scheduled_notices", + "dartName": "ScheduledNotice", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdBy", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "audience", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "title", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "message", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "scheduledFor", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:ScheduledNoticeStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "recipients", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "accepted", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "failureReason", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "sentAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "scheduled_notices_fk_0", + "columns": [ + "createdBy" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "scheduled_notices_fk_1", + "columns": [ + "microAreaId" + ], + "referenceTable": "micro_areas", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "scheduled_notices_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "scheduled_notices_due_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "status" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "scheduledFor" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "scheduled_notices_area_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "microAreaId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "createdAt" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "staff_accounts", + "dartName": "StaffAccount", + "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": "active", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "ubsId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeHash", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeExpiresAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeIssuedBy", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeIssuedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "staff_accounts_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "staff_accounts_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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": "answersEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "answersKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "contactPhone", + "columnType": 0, + "isNullable": true, + "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": "user_credentials", + "dartName": "UserCredential", + "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": "passwordHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "passwordSalt", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "memoryKb", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "iterations", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "parallelism", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "failedAttempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockedUntil", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockStreak", + "columnType": 6, + "isNullable": false, + "columnDefault": "0", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpSecretEncrypted", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpKeyVersion", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpEnabledAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpLastStep", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "user_credentials_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_user_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "cpfHash" + } + ], + "type": "btree", + "isUnique": true, + "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": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "authorship", + "columnType": 0, + "isNullable": false, + "columnDefault": "'acs'::text", + "dartType": "protocol:VisitAuthorship" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "originDeviceId", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__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": "notesEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "notesKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "syncStatus", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:SyncStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "arrivalMethod", + "columnType": 0, + "isNullable": false, + "columnDefault": "'manual'::text", + "dartType": "protocol:ArrivalMethod" + }, + { + "__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": "20261014152407324" + }, + { + "__className__": "serverpod.DatabaseMigrationVersion", + "module": "serverpod", + "version": "20260129180959368" + } + ], + "migrationApiVersion": 1 +} \ No newline at end of file diff --git a/backend/sinalacs_server/migrations/20261014152407324/definition.sql b/backend/sinalacs_server/migrations/20261014152407324/definition.sql new file mode 100644 index 00000000..bc95eda7 --- /dev/null +++ b/backend/sinalacs_server/migrations/20261014152407324/definition.sql @@ -0,0 +1,895 @@ +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"); +CREATE UNIQUE INDEX "acs_enrollment_id_key" ON "acs" USING btree ("enrollmentId"); + +-- +-- Class AcsRefreshToken as table acs_refresh_tokens +-- +CREATE TABLE "acs_refresh_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "familyId" uuid NOT NULL, + "tokenHash" text NOT NULL, + "deviceId" text NOT NULL, + "issuedAt" timestamp without time zone NOT NULL, + "idleExpiresAt" timestamp without time zone NOT NULL, + "absoluteExpiresAt" timestamp without time zone NOT NULL, + "rotatedAt" timestamp without time zone, + "revokedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "acs_refresh_tokens_hash_key" ON "acs_refresh_tokens" USING btree ("tokenHash"); +CREATE INDEX "acs_refresh_tokens_family_idx" ON "acs_refresh_tokens" USING btree ("familyId"); +CREATE INDEX "acs_refresh_tokens_user_idx" ON "acs_refresh_tokens" USING btree ("userId"); + +-- +-- Class AcsUploadToken as table acs_upload_tokens +-- +CREATE TABLE "acs_upload_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "tokenHash" text NOT NULL, + "deviceId" text NOT NULL, + "issuedAt" timestamp without time zone NOT NULL, + "expiresAt" timestamp without time zone NOT NULL, + "revokedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "acs_upload_tokens_hash_key" ON "acs_upload_tokens" USING btree ("tokenHash"); +CREATE INDEX "acs_upload_tokens_user_device_idx" ON "acs_upload_tokens" USING btree ("userId", "deviceId"); + +-- +-- 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, + "locationCell" text, + "status" text NOT NULL, + "mqttTopic" text NOT NULL, + "deviceId" text NOT NULL, + "retryCount" bigint NOT NULL, + "version" bigint NOT NULL, + "escalatedAt" timestamp without time zone, + "escalatedBy" uuid, + "escalationReason" text +); + +-- Indexes +CREATE INDEX "alerts_micro_area_status_idx" ON "alerts" USING btree ("microAreaId", "status", "triggeredAt"); + +-- +-- Class AuditChainCheckpoint as table audit_chain_checkpoints +-- +CREATE TABLE "audit_chain_checkpoints" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "sequence" bigint NOT NULL, + "entryHash" text NOT NULL, + "signature" text NOT NULL, + "createdAt" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "audit_chain_checkpoints_sequence_idx" ON "audit_chain_checkpoints" USING btree ("sequence"); + +-- +-- Class AuditExport as table audit_exports +-- +CREATE TABLE "audit_exports" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "requestedBy" uuid NOT NULL, + "requestedAt" timestamp without time zone NOT NULL, + "filtersJson" text NOT NULL, + "format" text NOT NULL, + "rowCount" bigint NOT NULL, + "firstSequence" bigint, + "lastSequence" bigint, + "contentSha256" text NOT NULL, + "chainState" text NOT NULL +); + +-- Indexes +CREATE INDEX "audit_exports_requested_at_idx" ON "audit_exports" USING btree ("requestedAt"); + +-- +-- 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"); +CREATE INDEX "audit_logs_timestamp_idx" ON "audit_logs" USING btree ("timestamp"); +CREATE INDEX "audit_logs_user_sequence_idx" ON "audit_logs" USING btree ("userId", "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 DataSubjectRequest as table data_subject_requests +-- +CREATE TABLE "data_subject_requests" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "requestType" text NOT NULL, + "detailsEncrypted" text NOT NULL, + "detailsKeyVersion" bigint NOT NULL, + "status" text NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "dueAt" timestamp without time zone NOT NULL, + "decidedAt" timestamp without time zone, + "decidedBy" uuid, + "resolutionEncrypted" text, + "resolutionKeyVersion" bigint +); + +-- Indexes +CREATE INDEX "data_subject_requests_user_id_type_idx" ON "data_subject_requests" USING btree ("userId", "requestType"); + +-- +-- Class EnrollmentToken as table enrollment_tokens +-- +CREATE TABLE "enrollment_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "tokenHash" text NOT NULL, + "patientId" uuid NOT NULL, + "microAreaId" uuid NOT NULL, + "createdByAcsId" uuid NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "expiresAt" timestamp without time zone NOT NULL, + "consumedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "enrollment_tokens_token_hash_key" ON "enrollment_tokens" USING btree ("tokenHash"); + +-- +-- 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 OtpChallenge as table otp_challenges +-- +CREATE TABLE "otp_challenges" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "codeHash" text NOT NULL, + "attempts" bigint NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "expiresAt" timestamp without time zone NOT NULL, + "consumedAt" timestamp without time zone +); + +-- Indexes +CREATE INDEX "otp_challenges_user_id_created_at_idx" ON "otp_challenges" USING btree ("userId", "createdAt"); + +-- +-- Class Patient as table patients +-- +CREATE TABLE "patients" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "emergencyContactEncrypted" text NOT NULL DEFAULT ''::text, + "emergencyContactKeyVersion" bigint NOT NULL DEFAULT 1, + "isChronic" boolean NOT NULL, + "chronicConditionsEncrypted" text NOT NULL DEFAULT ''::text, + "chronicConditionsKeyVersion" bigint NOT NULL DEFAULT 1, + "lastLocationHash" text, + "lastTriageAt" timestamp without time zone +); + +-- +-- Class PushToken as table push_tokens +-- +CREATE TABLE "push_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "microAreaId" uuid, + "token" text NOT NULL, + "platform" text NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "updatedAt" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "push_tokens_token_key" ON "push_tokens" USING btree ("token"); +CREATE INDEX "push_tokens_user_id_idx" ON "push_tokens" USING btree ("userId"); + +-- +-- Class ScheduledNotice as table scheduled_notices +-- +CREATE TABLE "scheduled_notices" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "createdBy" uuid NOT NULL, + "microAreaId" uuid NOT NULL, + "audience" text NOT NULL, + "title" text NOT NULL, + "message" text NOT NULL, + "scheduledFor" timestamp without time zone NOT NULL, + "status" text NOT NULL, + "recipients" bigint, + "accepted" bigint, + "failureReason" text, + "createdAt" timestamp without time zone NOT NULL, + "sentAt" timestamp without time zone +); + +-- Indexes +CREATE INDEX "scheduled_notices_due_idx" ON "scheduled_notices" USING btree ("status", "scheduledFor"); +CREATE INDEX "scheduled_notices_area_idx" ON "scheduled_notices" USING btree ("microAreaId", "createdAt"); + +-- +-- Class StaffAccount as table staff_accounts +-- +CREATE TABLE "staff_accounts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "enrollmentId" text NOT NULL, + "active" boolean NOT NULL, + "ubsId" uuid, + "activationCodeHash" text, + "activationCodeExpiresAt" timestamp without time zone, + "activationCodeIssuedBy" text, + "activationCodeIssuedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "staff_accounts_enrollment_id_key" ON "staff_accounts" USING btree ("enrollmentId"); + +-- +-- Class TriageSession as table triage_sessions +-- +CREATE TABLE "triage_sessions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "patientId" uuid NOT NULL, + "answersEncrypted" text NOT NULL DEFAULT ''::text, + "answersKeyVersion" bigint NOT NULL DEFAULT 1, + "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, + "contactPhone" text +); + +-- +-- Class UserCredential as table user_credentials +-- +CREATE TABLE "user_credentials" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "passwordHash" text NOT NULL, + "passwordSalt" text NOT NULL, + "memoryKb" bigint NOT NULL, + "iterations" bigint NOT NULL, + "parallelism" bigint NOT NULL, + "failedAttempts" bigint NOT NULL, + "lockedUntil" timestamp without time zone, + "lockStreak" bigint NOT NULL DEFAULT 0, + "createdAt" timestamp without time zone NOT NULL, + "updatedAt" timestamp without time zone NOT NULL, + "totpSecretEncrypted" text, + "totpKeyVersion" bigint, + "totpEnabledAt" timestamp without time zone, + "totpLastStep" bigint +); + +-- Indexes +CREATE UNIQUE INDEX "user_credentials_user_id_key" ON "user_credentials" USING btree ("userId"); + +-- +-- 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 UNIQUE INDEX "users_cpf_hash_key" 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, + "authorship" text NOT NULL DEFAULT 'acs'::text, + "originDeviceId" text, + "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, + "notesEncrypted" text NOT NULL DEFAULT ''::text, + "notesKeyVersion" bigint NOT NULL DEFAULT 1, + "syncStatus" text NOT NULL, + "arrivalMethod" text NOT NULL DEFAULT 'manual'::text, + "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 "acs_refresh_tokens" table +-- +ALTER TABLE ONLY "acs_refresh_tokens" + ADD CONSTRAINT "acs_refresh_tokens_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "acs_upload_tokens" table +-- +ALTER TABLE ONLY "acs_upload_tokens" + ADD CONSTRAINT "acs_upload_tokens_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- 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_exports" table +-- +ALTER TABLE ONLY "audit_exports" + ADD CONSTRAINT "audit_exports_fk_0" + FOREIGN KEY("requestedBy") + REFERENCES "users"("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 "data_subject_requests" table +-- +ALTER TABLE ONLY "data_subject_requests" + ADD CONSTRAINT "data_subject_requests_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; +ALTER TABLE ONLY "data_subject_requests" + ADD CONSTRAINT "data_subject_requests_fk_1" + FOREIGN KEY("decidedBy") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "enrollment_tokens" table +-- +ALTER TABLE ONLY "enrollment_tokens" + ADD CONSTRAINT "enrollment_tokens_fk_0" + FOREIGN KEY("patientId") + REFERENCES "patients"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; +ALTER TABLE ONLY "enrollment_tokens" + ADD CONSTRAINT "enrollment_tokens_fk_1" + FOREIGN KEY("createdByAcsId") + REFERENCES "acs"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "otp_challenges" table +-- +ALTER TABLE ONLY "otp_challenges" + ADD CONSTRAINT "otp_challenges_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "push_tokens" table +-- +ALTER TABLE ONLY "push_tokens" + ADD CONSTRAINT "push_tokens_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "scheduled_notices" table +-- +ALTER TABLE ONLY "scheduled_notices" + ADD CONSTRAINT "scheduled_notices_fk_0" + FOREIGN KEY("createdBy") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; +ALTER TABLE ONLY "scheduled_notices" + ADD CONSTRAINT "scheduled_notices_fk_1" + FOREIGN KEY("microAreaId") + REFERENCES "micro_areas"("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 "user_credentials" table +-- +ALTER TABLE ONLY "user_credentials" + ADD CONSTRAINT "user_credentials_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("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', '20261014152407324', now()) + ON CONFLICT ("module") + DO UPDATE SET "version" = '20261014152407324', "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/20261014152407324/definition_project.json b/backend/sinalacs_server/migrations/20261014152407324/definition_project.json new file mode 100644 index 00000000..64477752 --- /dev/null +++ b/backend/sinalacs_server/migrations/20261014152407324/definition_project.json @@ -0,0 +1,3011 @@ +{ + "__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 + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "acs_refresh_tokens", + "dartName": "AcsRefreshToken", + "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": "familyId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "issuedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "idleExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "absoluteExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "rotatedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "revokedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "acs_refresh_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_family_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "familyId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_user_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "acs_upload_tokens", + "dartName": "AcsUploadToken", + "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": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "issuedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "revokedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "acs_upload_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_user_device_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "deviceId" + } + ], + "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": "locationCell", + "columnType": 0, + "isNullable": true, + "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" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "escalatedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "escalatedBy", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "escalationReason", + "columnType": 0, + "isNullable": true, + "dartType": "protocol:EscalationReason?" + } + ], + "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_chain_checkpoints", + "dartName": "AuditChainCheckpoint", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "sequence", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "entryHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "signature", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_chain_checkpoints_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_chain_checkpoints_sequence_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "sequence" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "audit_exports", + "dartName": "AuditExport", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "requestedBy", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "requestedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "filtersJson", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "format", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "rowCount", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "firstSequence", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lastSequence", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "contentSha256", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chainState", + "columnType": 0, + "isNullable": false, + "dartType": "String" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "audit_exports_fk_0", + "columns": [ + "requestedBy" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_exports_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_exports_requested_at_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "requestedAt" + } + ], + "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 + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_logs_timestamp_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "timestamp" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_logs_user_sequence_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "sequence" + } + ], + "type": "btree", + "isUnique": false, + "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": "data_subject_requests", + "dartName": "DataSubjectRequest", + "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": "requestType", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestType" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsEncrypted", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsKeyVersion", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "dueAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "decidedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "decidedBy", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resolutionEncrypted", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resolutionKeyVersion", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "data_subject_requests_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "data_subject_requests_fk_1", + "columns": [ + "decidedBy" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_user_id_type_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "requestType" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "enrollment_tokens", + "dartName": "EnrollmentToken", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdByAcsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_1", + "columns": [ + "createdByAcsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_token_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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": "otp_challenges", + "dartName": "OtpChallenge", + "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": "codeHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "attempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "otp_challenges_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_user_id_created_at_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "createdAt" + } + ], + "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": "emergencyContactEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "emergencyContactKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "isChronic", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditionsEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditionsKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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": "push_tokens", + "dartName": "PushToken", + "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": "microAreaId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "token", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "platform", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "push_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_token_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "token" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_user_id_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "scheduled_notices", + "dartName": "ScheduledNotice", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdBy", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "audience", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "title", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "message", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "scheduledFor", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:ScheduledNoticeStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "recipients", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "accepted", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "failureReason", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "sentAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "scheduled_notices_fk_0", + "columns": [ + "createdBy" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "scheduled_notices_fk_1", + "columns": [ + "microAreaId" + ], + "referenceTable": "micro_areas", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "scheduled_notices_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "scheduled_notices_due_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "status" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "scheduledFor" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "scheduled_notices_area_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "microAreaId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "createdAt" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "staff_accounts", + "dartName": "StaffAccount", + "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": "active", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "ubsId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeHash", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeExpiresAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeIssuedBy", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeIssuedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "staff_accounts_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "staff_accounts_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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": "answersEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "answersKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "contactPhone", + "columnType": 0, + "isNullable": true, + "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": "user_credentials", + "dartName": "UserCredential", + "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": "passwordHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "passwordSalt", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "memoryKb", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "iterations", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "parallelism", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "failedAttempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockedUntil", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockStreak", + "columnType": 6, + "isNullable": false, + "columnDefault": "0", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpSecretEncrypted", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpKeyVersion", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpEnabledAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpLastStep", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "user_credentials_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_user_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "cpfHash" + } + ], + "type": "btree", + "isUnique": true, + "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": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "authorship", + "columnType": 0, + "isNullable": false, + "columnDefault": "'acs'::text", + "dartType": "protocol:VisitAuthorship" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "originDeviceId", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__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": "notesEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "notesKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "syncStatus", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:SyncStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "arrivalMethod", + "columnType": 0, + "isNullable": false, + "columnDefault": "'manual'::text", + "dartType": "protocol:ArrivalMethod" + }, + { + "__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/20261014152407324/migration.json b/backend/sinalacs_server/migrations/20261014152407324/migration.json new file mode 100644 index 00000000..dbf9e1cb --- /dev/null +++ b/backend/sinalacs_server/migrations/20261014152407324/migration.json @@ -0,0 +1,197 @@ +{ + "__className__": "serverpod.DatabaseMigration", + "actions": [ + { + "__className__": "serverpod.DatabaseMigrationAction", + "type": "createTable", + "createTable": { + "__className__": "serverpod.TableDefinition", + "name": "scheduled_notices", + "dartName": "ScheduledNotice", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdBy", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "audience", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "title", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "message", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "scheduledFor", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:ScheduledNoticeStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "recipients", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "accepted", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "failureReason", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "sentAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "scheduled_notices_fk_0", + "columns": [ + "createdBy" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "scheduled_notices_fk_1", + "columns": [ + "microAreaId" + ], + "referenceTable": "micro_areas", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "scheduled_notices_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "scheduled_notices_due_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "status" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "scheduledFor" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "scheduled_notices_area_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "microAreaId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "createdAt" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + } + } + ], + "warnings": [], + "migrationApiVersion": 1 +} \ No newline at end of file diff --git a/backend/sinalacs_server/migrations/20261014152407324/migration.sql b/backend/sinalacs_server/migrations/20261014152407324/migration.sql new file mode 100644 index 00000000..7045a95d --- /dev/null +++ b/backend/sinalacs_server/migrations/20261014152407324/migration.sql @@ -0,0 +1,60 @@ +BEGIN; + +-- +-- ACTION CREATE TABLE +-- +CREATE TABLE "scheduled_notices" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "createdBy" uuid NOT NULL, + "microAreaId" uuid NOT NULL, + "audience" text NOT NULL, + "title" text NOT NULL, + "message" text NOT NULL, + "scheduledFor" timestamp without time zone NOT NULL, + "status" text NOT NULL, + "recipients" bigint, + "accepted" bigint, + "failureReason" text, + "createdAt" timestamp without time zone NOT NULL, + "sentAt" timestamp without time zone +); + +-- Indexes +CREATE INDEX "scheduled_notices_due_idx" ON "scheduled_notices" USING btree ("status", "scheduledFor"); +CREATE INDEX "scheduled_notices_area_idx" ON "scheduled_notices" USING btree ("microAreaId", "createdAt"); + +-- +-- ACTION CREATE FOREIGN KEY +-- +ALTER TABLE ONLY "scheduled_notices" + ADD CONSTRAINT "scheduled_notices_fk_0" + FOREIGN KEY("createdBy") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; +ALTER TABLE ONLY "scheduled_notices" + ADD CONSTRAINT "scheduled_notices_fk_1" + FOREIGN KEY("microAreaId") + REFERENCES "micro_areas"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + + +-- +-- MIGRATION VERSION FOR sinalacs +-- +INSERT INTO "serverpod_migrations" ("module", "version", "timestamp") + VALUES ('sinalacs', '20261014152407324', now()) + ON CONFLICT ("module") + DO UPDATE SET "version" = '20261014152407324', "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/20261014155304871/definition.json b/backend/sinalacs_server/migrations/20261014155304871/definition.json new file mode 100644 index 00000000..7c5f5bb2 --- /dev/null +++ b/backend/sinalacs_server/migrations/20261014155304871/definition.json @@ -0,0 +1,4502 @@ +{ + "__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 + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "acs_refresh_tokens", + "dartName": "AcsRefreshToken", + "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": "familyId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "issuedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "idleExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "absoluteExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "rotatedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "revokedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "acs_refresh_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_family_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "familyId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_user_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "acs_upload_tokens", + "dartName": "AcsUploadToken", + "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": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "issuedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "revokedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "acs_upload_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_user_device_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "deviceId" + } + ], + "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": "locationCell", + "columnType": 0, + "isNullable": true, + "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" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "escalatedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "escalatedBy", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "escalationReason", + "columnType": 0, + "isNullable": true, + "dartType": "protocol:EscalationReason?" + } + ], + "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_chain_checkpoints", + "dartName": "AuditChainCheckpoint", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "sequence", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "entryHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "signature", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_chain_checkpoints_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_chain_checkpoints_sequence_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "sequence" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "audit_exports", + "dartName": "AuditExport", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "requestedBy", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "requestedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "filtersJson", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "format", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "rowCount", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "firstSequence", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lastSequence", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "contentSha256", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chainState", + "columnType": 0, + "isNullable": false, + "dartType": "String" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "audit_exports_fk_0", + "columns": [ + "requestedBy" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_exports_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_exports_requested_at_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "requestedAt" + } + ], + "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 + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_logs_timestamp_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "timestamp" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_logs_user_sequence_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "sequence" + } + ], + "type": "btree", + "isUnique": false, + "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": "data_subject_requests", + "dartName": "DataSubjectRequest", + "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": "requestType", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestType" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsEncrypted", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsKeyVersion", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "dueAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "decidedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "decidedBy", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resolutionEncrypted", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resolutionKeyVersion", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "data_subject_requests_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "data_subject_requests_fk_1", + "columns": [ + "decidedBy" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_user_id_type_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "requestType" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "enrollment_tokens", + "dartName": "EnrollmentToken", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdByAcsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_1", + "columns": [ + "createdByAcsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_token_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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": "otp_challenges", + "dartName": "OtpChallenge", + "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": "codeHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "attempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "otp_challenges_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_user_id_created_at_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "createdAt" + } + ], + "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": "emergencyContactEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "emergencyContactKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "isChronic", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditionsEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditionsKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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": "push_tokens", + "dartName": "PushToken", + "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": "microAreaId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "token", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "platform", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "push_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_token_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "token" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_user_id_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "scheduled_notices", + "dartName": "ScheduledNotice", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdBy", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "audience", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "title", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "message", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "scheduledFor", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:ScheduledNoticeStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "recipients", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "accepted", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "failureReason", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "sentAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "scheduled_notices_fk_0", + "columns": [ + "createdBy" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "scheduled_notices_fk_1", + "columns": [ + "microAreaId" + ], + "referenceTable": "micro_areas", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "scheduled_notices_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "scheduled_notices_due_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "status" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "scheduledFor" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "scheduled_notices_area_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "microAreaId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "createdAt" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "staff_accounts", + "dartName": "StaffAccount", + "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": "active", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "ubsId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeHash", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeExpiresAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeIssuedBy", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeIssuedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "staff_accounts_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "staff_accounts_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "terms_versions", + "dartName": "TermsVersion", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "version", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "summary", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "effectiveFrom", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdBy", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "legallyReviewedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "legallyReviewedBy", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "publishedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "publishedBy", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "terms_versions_fk_0", + "columns": [ + "createdBy" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "terms_versions_fk_1", + "columns": [ + "legallyReviewedBy" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "terms_versions_fk_2", + "columns": [ + "publishedBy" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "terms_versions_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "terms_versions_version_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "version" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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": "answersEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "answersKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "contactPhone", + "columnType": 0, + "isNullable": true, + "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": "user_credentials", + "dartName": "UserCredential", + "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": "passwordHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "passwordSalt", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "memoryKb", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "iterations", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "parallelism", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "failedAttempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockedUntil", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockStreak", + "columnType": 6, + "isNullable": false, + "columnDefault": "0", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpSecretEncrypted", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpKeyVersion", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpEnabledAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpLastStep", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "user_credentials_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_user_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "cpfHash" + } + ], + "type": "btree", + "isUnique": true, + "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": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "authorship", + "columnType": 0, + "isNullable": false, + "columnDefault": "'acs'::text", + "dartType": "protocol:VisitAuthorship" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "originDeviceId", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__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": "notesEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "notesKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "syncStatus", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:SyncStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "arrivalMethod", + "columnType": 0, + "isNullable": false, + "columnDefault": "'manual'::text", + "dartType": "protocol:ArrivalMethod" + }, + { + "__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": "20261014155304871" + }, + { + "__className__": "serverpod.DatabaseMigrationVersion", + "module": "serverpod", + "version": "20260129180959368" + } + ], + "migrationApiVersion": 1 +} \ No newline at end of file diff --git a/backend/sinalacs_server/migrations/20261014155304871/definition.sql b/backend/sinalacs_server/migrations/20261014155304871/definition.sql new file mode 100644 index 00000000..cdb84571 --- /dev/null +++ b/backend/sinalacs_server/migrations/20261014155304871/definition.sql @@ -0,0 +1,936 @@ +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"); +CREATE UNIQUE INDEX "acs_enrollment_id_key" ON "acs" USING btree ("enrollmentId"); + +-- +-- Class AcsRefreshToken as table acs_refresh_tokens +-- +CREATE TABLE "acs_refresh_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "familyId" uuid NOT NULL, + "tokenHash" text NOT NULL, + "deviceId" text NOT NULL, + "issuedAt" timestamp without time zone NOT NULL, + "idleExpiresAt" timestamp without time zone NOT NULL, + "absoluteExpiresAt" timestamp without time zone NOT NULL, + "rotatedAt" timestamp without time zone, + "revokedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "acs_refresh_tokens_hash_key" ON "acs_refresh_tokens" USING btree ("tokenHash"); +CREATE INDEX "acs_refresh_tokens_family_idx" ON "acs_refresh_tokens" USING btree ("familyId"); +CREATE INDEX "acs_refresh_tokens_user_idx" ON "acs_refresh_tokens" USING btree ("userId"); + +-- +-- Class AcsUploadToken as table acs_upload_tokens +-- +CREATE TABLE "acs_upload_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "tokenHash" text NOT NULL, + "deviceId" text NOT NULL, + "issuedAt" timestamp without time zone NOT NULL, + "expiresAt" timestamp without time zone NOT NULL, + "revokedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "acs_upload_tokens_hash_key" ON "acs_upload_tokens" USING btree ("tokenHash"); +CREATE INDEX "acs_upload_tokens_user_device_idx" ON "acs_upload_tokens" USING btree ("userId", "deviceId"); + +-- +-- 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, + "locationCell" text, + "status" text NOT NULL, + "mqttTopic" text NOT NULL, + "deviceId" text NOT NULL, + "retryCount" bigint NOT NULL, + "version" bigint NOT NULL, + "escalatedAt" timestamp without time zone, + "escalatedBy" uuid, + "escalationReason" text +); + +-- Indexes +CREATE INDEX "alerts_micro_area_status_idx" ON "alerts" USING btree ("microAreaId", "status", "triggeredAt"); + +-- +-- Class AuditChainCheckpoint as table audit_chain_checkpoints +-- +CREATE TABLE "audit_chain_checkpoints" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "sequence" bigint NOT NULL, + "entryHash" text NOT NULL, + "signature" text NOT NULL, + "createdAt" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "audit_chain_checkpoints_sequence_idx" ON "audit_chain_checkpoints" USING btree ("sequence"); + +-- +-- Class AuditExport as table audit_exports +-- +CREATE TABLE "audit_exports" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "requestedBy" uuid NOT NULL, + "requestedAt" timestamp without time zone NOT NULL, + "filtersJson" text NOT NULL, + "format" text NOT NULL, + "rowCount" bigint NOT NULL, + "firstSequence" bigint, + "lastSequence" bigint, + "contentSha256" text NOT NULL, + "chainState" text NOT NULL +); + +-- Indexes +CREATE INDEX "audit_exports_requested_at_idx" ON "audit_exports" USING btree ("requestedAt"); + +-- +-- 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"); +CREATE INDEX "audit_logs_timestamp_idx" ON "audit_logs" USING btree ("timestamp"); +CREATE INDEX "audit_logs_user_sequence_idx" ON "audit_logs" USING btree ("userId", "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 DataSubjectRequest as table data_subject_requests +-- +CREATE TABLE "data_subject_requests" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "requestType" text NOT NULL, + "detailsEncrypted" text NOT NULL, + "detailsKeyVersion" bigint NOT NULL, + "status" text NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "dueAt" timestamp without time zone NOT NULL, + "decidedAt" timestamp without time zone, + "decidedBy" uuid, + "resolutionEncrypted" text, + "resolutionKeyVersion" bigint +); + +-- Indexes +CREATE INDEX "data_subject_requests_user_id_type_idx" ON "data_subject_requests" USING btree ("userId", "requestType"); + +-- +-- Class EnrollmentToken as table enrollment_tokens +-- +CREATE TABLE "enrollment_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "tokenHash" text NOT NULL, + "patientId" uuid NOT NULL, + "microAreaId" uuid NOT NULL, + "createdByAcsId" uuid NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "expiresAt" timestamp without time zone NOT NULL, + "consumedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "enrollment_tokens_token_hash_key" ON "enrollment_tokens" USING btree ("tokenHash"); + +-- +-- 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 OtpChallenge as table otp_challenges +-- +CREATE TABLE "otp_challenges" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "codeHash" text NOT NULL, + "attempts" bigint NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "expiresAt" timestamp without time zone NOT NULL, + "consumedAt" timestamp without time zone +); + +-- Indexes +CREATE INDEX "otp_challenges_user_id_created_at_idx" ON "otp_challenges" USING btree ("userId", "createdAt"); + +-- +-- Class Patient as table patients +-- +CREATE TABLE "patients" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "emergencyContactEncrypted" text NOT NULL DEFAULT ''::text, + "emergencyContactKeyVersion" bigint NOT NULL DEFAULT 1, + "isChronic" boolean NOT NULL, + "chronicConditionsEncrypted" text NOT NULL DEFAULT ''::text, + "chronicConditionsKeyVersion" bigint NOT NULL DEFAULT 1, + "lastLocationHash" text, + "lastTriageAt" timestamp without time zone +); + +-- +-- Class PushToken as table push_tokens +-- +CREATE TABLE "push_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "microAreaId" uuid, + "token" text NOT NULL, + "platform" text NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "updatedAt" timestamp without time zone NOT NULL +); + +-- Indexes +CREATE UNIQUE INDEX "push_tokens_token_key" ON "push_tokens" USING btree ("token"); +CREATE INDEX "push_tokens_user_id_idx" ON "push_tokens" USING btree ("userId"); + +-- +-- Class ScheduledNotice as table scheduled_notices +-- +CREATE TABLE "scheduled_notices" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "createdBy" uuid NOT NULL, + "microAreaId" uuid NOT NULL, + "audience" text NOT NULL, + "title" text NOT NULL, + "message" text NOT NULL, + "scheduledFor" timestamp without time zone NOT NULL, + "status" text NOT NULL, + "recipients" bigint, + "accepted" bigint, + "failureReason" text, + "createdAt" timestamp without time zone NOT NULL, + "sentAt" timestamp without time zone +); + +-- Indexes +CREATE INDEX "scheduled_notices_due_idx" ON "scheduled_notices" USING btree ("status", "scheduledFor"); +CREATE INDEX "scheduled_notices_area_idx" ON "scheduled_notices" USING btree ("microAreaId", "createdAt"); + +-- +-- Class StaffAccount as table staff_accounts +-- +CREATE TABLE "staff_accounts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "enrollmentId" text NOT NULL, + "active" boolean NOT NULL, + "ubsId" uuid, + "activationCodeHash" text, + "activationCodeExpiresAt" timestamp without time zone, + "activationCodeIssuedBy" text, + "activationCodeIssuedAt" timestamp without time zone +); + +-- Indexes +CREATE UNIQUE INDEX "staff_accounts_enrollment_id_key" ON "staff_accounts" USING btree ("enrollmentId"); + +-- +-- Class TermsVersion as table terms_versions +-- +CREATE TABLE "terms_versions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "version" text NOT NULL, + "summary" text NOT NULL, + "effectiveFrom" timestamp without time zone NOT NULL, + "createdBy" uuid NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "legallyReviewedAt" timestamp without time zone, + "legallyReviewedBy" uuid, + "publishedAt" timestamp without time zone, + "publishedBy" uuid +); + +-- Indexes +CREATE UNIQUE INDEX "terms_versions_version_key" ON "terms_versions" USING btree ("version"); + +-- +-- Class TriageSession as table triage_sessions +-- +CREATE TABLE "triage_sessions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "patientId" uuid NOT NULL, + "answersEncrypted" text NOT NULL DEFAULT ''::text, + "answersKeyVersion" bigint NOT NULL DEFAULT 1, + "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, + "contactPhone" text +); + +-- +-- Class UserCredential as table user_credentials +-- +CREATE TABLE "user_credentials" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "passwordHash" text NOT NULL, + "passwordSalt" text NOT NULL, + "memoryKb" bigint NOT NULL, + "iterations" bigint NOT NULL, + "parallelism" bigint NOT NULL, + "failedAttempts" bigint NOT NULL, + "lockedUntil" timestamp without time zone, + "lockStreak" bigint NOT NULL DEFAULT 0, + "createdAt" timestamp without time zone NOT NULL, + "updatedAt" timestamp without time zone NOT NULL, + "totpSecretEncrypted" text, + "totpKeyVersion" bigint, + "totpEnabledAt" timestamp without time zone, + "totpLastStep" bigint +); + +-- Indexes +CREATE UNIQUE INDEX "user_credentials_user_id_key" ON "user_credentials" USING btree ("userId"); + +-- +-- 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 UNIQUE INDEX "users_cpf_hash_key" 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, + "authorship" text NOT NULL DEFAULT 'acs'::text, + "originDeviceId" text, + "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, + "notesEncrypted" text NOT NULL DEFAULT ''::text, + "notesKeyVersion" bigint NOT NULL DEFAULT 1, + "syncStatus" text NOT NULL, + "arrivalMethod" text NOT NULL DEFAULT 'manual'::text, + "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 "acs_refresh_tokens" table +-- +ALTER TABLE ONLY "acs_refresh_tokens" + ADD CONSTRAINT "acs_refresh_tokens_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "acs_upload_tokens" table +-- +ALTER TABLE ONLY "acs_upload_tokens" + ADD CONSTRAINT "acs_upload_tokens_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- 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_exports" table +-- +ALTER TABLE ONLY "audit_exports" + ADD CONSTRAINT "audit_exports_fk_0" + FOREIGN KEY("requestedBy") + REFERENCES "users"("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 "data_subject_requests" table +-- +ALTER TABLE ONLY "data_subject_requests" + ADD CONSTRAINT "data_subject_requests_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; +ALTER TABLE ONLY "data_subject_requests" + ADD CONSTRAINT "data_subject_requests_fk_1" + FOREIGN KEY("decidedBy") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "enrollment_tokens" table +-- +ALTER TABLE ONLY "enrollment_tokens" + ADD CONSTRAINT "enrollment_tokens_fk_0" + FOREIGN KEY("patientId") + REFERENCES "patients"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; +ALTER TABLE ONLY "enrollment_tokens" + ADD CONSTRAINT "enrollment_tokens_fk_1" + FOREIGN KEY("createdByAcsId") + REFERENCES "acs"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "otp_challenges" table +-- +ALTER TABLE ONLY "otp_challenges" + ADD CONSTRAINT "otp_challenges_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "push_tokens" table +-- +ALTER TABLE ONLY "push_tokens" + ADD CONSTRAINT "push_tokens_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "scheduled_notices" table +-- +ALTER TABLE ONLY "scheduled_notices" + ADD CONSTRAINT "scheduled_notices_fk_0" + FOREIGN KEY("createdBy") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; +ALTER TABLE ONLY "scheduled_notices" + ADD CONSTRAINT "scheduled_notices_fk_1" + FOREIGN KEY("microAreaId") + REFERENCES "micro_areas"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + +-- +-- Foreign relations for "terms_versions" table +-- +ALTER TABLE ONLY "terms_versions" + ADD CONSTRAINT "terms_versions_fk_0" + FOREIGN KEY("createdBy") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; +ALTER TABLE ONLY "terms_versions" + ADD CONSTRAINT "terms_versions_fk_1" + FOREIGN KEY("legallyReviewedBy") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; +ALTER TABLE ONLY "terms_versions" + ADD CONSTRAINT "terms_versions_fk_2" + FOREIGN KEY("publishedBy") + 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 "user_credentials" table +-- +ALTER TABLE ONLY "user_credentials" + ADD CONSTRAINT "user_credentials_fk_0" + FOREIGN KEY("userId") + REFERENCES "users"("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', '20261014155304871', now()) + ON CONFLICT ("module") + DO UPDATE SET "version" = '20261014155304871', "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/20261014155304871/definition_project.json b/backend/sinalacs_server/migrations/20261014155304871/definition_project.json new file mode 100644 index 00000000..7da6d964 --- /dev/null +++ b/backend/sinalacs_server/migrations/20261014155304871/definition_project.json @@ -0,0 +1,3166 @@ +{ + "__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 + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "acs_refresh_tokens", + "dartName": "AcsRefreshToken", + "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": "familyId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "issuedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "idleExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "absoluteExpiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "rotatedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "revokedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "acs_refresh_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_family_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "familyId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_refresh_tokens_user_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "acs_upload_tokens", + "dartName": "AcsUploadToken", + "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": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "deviceId", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "issuedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "revokedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "acs_upload_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "acs_upload_tokens_user_device_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "deviceId" + } + ], + "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": "locationCell", + "columnType": 0, + "isNullable": true, + "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" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "escalatedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "escalatedBy", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "escalationReason", + "columnType": 0, + "isNullable": true, + "dartType": "protocol:EscalationReason?" + } + ], + "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_chain_checkpoints", + "dartName": "AuditChainCheckpoint", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "sequence", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "entryHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "signature", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_chain_checkpoints_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_chain_checkpoints_sequence_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "sequence" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "audit_exports", + "dartName": "AuditExport", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "requestedBy", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "requestedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "filtersJson", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "format", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "rowCount", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "firstSequence", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lastSequence", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "contentSha256", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chainState", + "columnType": 0, + "isNullable": false, + "dartType": "String" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "audit_exports_fk_0", + "columns": [ + "requestedBy" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_exports_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_exports_requested_at_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "requestedAt" + } + ], + "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 + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_logs_timestamp_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "timestamp" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "audit_logs_user_sequence_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "sequence" + } + ], + "type": "btree", + "isUnique": false, + "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": "data_subject_requests", + "dartName": "DataSubjectRequest", + "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": "requestType", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestType" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsEncrypted", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "detailsKeyVersion", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:DataSubjectRequestStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "dueAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "decidedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "decidedBy", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resolutionEncrypted", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "resolutionKeyVersion", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "data_subject_requests_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "data_subject_requests_fk_1", + "columns": [ + "decidedBy" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "data_subject_requests_user_id_type_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "requestType" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "enrollment_tokens", + "dartName": "EnrollmentToken", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "tokenHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "patientId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdByAcsId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_0", + "columns": [ + "patientId" + ], + "referenceTable": "patients", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "enrollment_tokens_fk_1", + "columns": [ + "createdByAcsId" + ], + "referenceTable": "acs", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "enrollment_tokens_token_hash_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "tokenHash" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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": "otp_challenges", + "dartName": "OtpChallenge", + "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": "codeHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "attempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "expiresAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "consumedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "otp_challenges_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "otp_challenges_user_id_created_at_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "createdAt" + } + ], + "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": "emergencyContactEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "emergencyContactKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "isChronic", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditionsEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "chronicConditionsKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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": "push_tokens", + "dartName": "PushToken", + "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": "microAreaId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "token", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "platform", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "push_tokens_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_token_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "token" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "push_tokens_user_id_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "scheduled_notices", + "dartName": "ScheduledNotice", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdBy", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "microAreaId", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "audience", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "title", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "message", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "scheduledFor", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "status", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:ScheduledNoticeStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "recipients", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "accepted", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "failureReason", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "sentAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "scheduled_notices_fk_0", + "columns": [ + "createdBy" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "scheduled_notices_fk_1", + "columns": [ + "microAreaId" + ], + "referenceTable": "micro_areas", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "scheduled_notices_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "scheduled_notices_due_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "status" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "scheduledFor" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "scheduled_notices_area_idx", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "microAreaId" + }, + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "createdAt" + } + ], + "type": "btree", + "isUnique": false, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "staff_accounts", + "dartName": "StaffAccount", + "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": "active", + "columnType": 1, + "isNullable": false, + "dartType": "bool" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "ubsId", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeHash", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeExpiresAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeIssuedBy", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "activationCodeIssuedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + } + ], + "foreignKeys": [], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "staff_accounts_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "staff_accounts_enrollment_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "enrollmentId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + }, + { + "__className__": "serverpod.TableDefinition", + "name": "terms_versions", + "dartName": "TermsVersion", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "version", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "summary", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "effectiveFrom", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdBy", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "legallyReviewedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "legallyReviewedBy", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "publishedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "publishedBy", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "terms_versions_fk_0", + "columns": [ + "createdBy" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "terms_versions_fk_1", + "columns": [ + "legallyReviewedBy" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "terms_versions_fk_2", + "columns": [ + "publishedBy" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "terms_versions_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "terms_versions_version_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "version" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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": "answersEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "answersKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__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" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "contactPhone", + "columnType": 0, + "isNullable": true, + "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": "user_credentials", + "dartName": "UserCredential", + "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": "passwordHash", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "passwordSalt", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "memoryKb", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "iterations", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "parallelism", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "failedAttempts", + "columnType": 6, + "isNullable": false, + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockedUntil", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "lockStreak", + "columnType": 6, + "isNullable": false, + "columnDefault": "0", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "updatedAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpSecretEncrypted", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpKeyVersion", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpEnabledAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "totpLastStep", + "columnType": 6, + "isNullable": true, + "dartType": "int?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "user_credentials_fk_0", + "columns": [ + "userId" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "user_credentials_user_id_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "userId" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "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_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "cpfHash" + } + ], + "type": "btree", + "isUnique": true, + "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": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "authorship", + "columnType": 0, + "isNullable": false, + "columnDefault": "'acs'::text", + "dartType": "protocol:VisitAuthorship" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "originDeviceId", + "columnType": 0, + "isNullable": true, + "dartType": "String?" + }, + { + "__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": "notesEncrypted", + "columnType": 0, + "isNullable": false, + "columnDefault": "''::text", + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "notesKeyVersion", + "columnType": 6, + "isNullable": false, + "columnDefault": "1", + "dartType": "int" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "syncStatus", + "columnType": 0, + "isNullable": false, + "dartType": "protocol:SyncStatus" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "arrivalMethod", + "columnType": 0, + "isNullable": false, + "columnDefault": "'manual'::text", + "dartType": "protocol:ArrivalMethod" + }, + { + "__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/20261014155304871/migration.json b/backend/sinalacs_server/migrations/20261014155304871/migration.json new file mode 100644 index 00000000..7c0bcfe7 --- /dev/null +++ b/backend/sinalacs_server/migrations/20261014155304871/migration.json @@ -0,0 +1,166 @@ +{ + "__className__": "serverpod.DatabaseMigration", + "actions": [ + { + "__className__": "serverpod.DatabaseMigrationAction", + "type": "createTable", + "createTable": { + "__className__": "serverpod.TableDefinition", + "name": "terms_versions", + "dartName": "TermsVersion", + "module": "sinalacs", + "schema": "public", + "columns": [ + { + "__className__": "serverpod.ColumnDefinition", + "name": "id", + "columnType": 7, + "isNullable": false, + "columnDefault": "gen_random_uuid()", + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "version", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "summary", + "columnType": 0, + "isNullable": false, + "dartType": "String" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "effectiveFrom", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdBy", + "columnType": 7, + "isNullable": false, + "dartType": "UuidValue" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "createdAt", + "columnType": 4, + "isNullable": false, + "dartType": "DateTime" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "legallyReviewedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "legallyReviewedBy", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "publishedAt", + "columnType": 4, + "isNullable": true, + "dartType": "DateTime?" + }, + { + "__className__": "serverpod.ColumnDefinition", + "name": "publishedBy", + "columnType": 7, + "isNullable": true, + "dartType": "UuidValue?" + } + ], + "foreignKeys": [ + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "terms_versions_fk_0", + "columns": [ + "createdBy" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "terms_versions_fk_1", + "columns": [ + "legallyReviewedBy" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + }, + { + "__className__": "serverpod.ForeignKeyDefinition", + "constraintName": "terms_versions_fk_2", + "columns": [ + "publishedBy" + ], + "referenceTable": "users", + "referenceTableSchema": "public", + "referenceColumns": [ + "id" + ], + "onUpdate": 3, + "onDelete": 3 + } + ], + "indexes": [ + { + "__className__": "serverpod.IndexDefinition", + "indexName": "terms_versions_pkey", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "id" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": true + }, + { + "__className__": "serverpod.IndexDefinition", + "indexName": "terms_versions_version_key", + "elements": [ + { + "__className__": "serverpod.IndexElementDefinition", + "type": 0, + "definition": "version" + } + ], + "type": "btree", + "isUnique": true, + "isPrimary": false + } + ], + "managed": true + } + } + ], + "warnings": [], + "migrationApiVersion": 1 +} \ No newline at end of file diff --git a/backend/sinalacs_server/migrations/20261014155304871/migration.sql b/backend/sinalacs_server/migrations/20261014155304871/migration.sql new file mode 100644 index 00000000..3631fcd4 --- /dev/null +++ b/backend/sinalacs_server/migrations/20261014155304871/migration.sql @@ -0,0 +1,62 @@ +BEGIN; + +-- +-- ACTION CREATE TABLE +-- +CREATE TABLE "terms_versions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "version" text NOT NULL, + "summary" text NOT NULL, + "effectiveFrom" timestamp without time zone NOT NULL, + "createdBy" uuid NOT NULL, + "createdAt" timestamp without time zone NOT NULL, + "legallyReviewedAt" timestamp without time zone, + "legallyReviewedBy" uuid, + "publishedAt" timestamp without time zone, + "publishedBy" uuid +); + +-- Indexes +CREATE UNIQUE INDEX "terms_versions_version_key" ON "terms_versions" USING btree ("version"); + +-- +-- ACTION CREATE FOREIGN KEY +-- +ALTER TABLE ONLY "terms_versions" + ADD CONSTRAINT "terms_versions_fk_0" + FOREIGN KEY("createdBy") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; +ALTER TABLE ONLY "terms_versions" + ADD CONSTRAINT "terms_versions_fk_1" + FOREIGN KEY("legallyReviewedBy") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; +ALTER TABLE ONLY "terms_versions" + ADD CONSTRAINT "terms_versions_fk_2" + FOREIGN KEY("publishedBy") + REFERENCES "users"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; + + +-- +-- MIGRATION VERSION FOR sinalacs +-- +INSERT INTO "serverpod_migrations" ("module", "version", "timestamp") + VALUES ('sinalacs', '20261014155304871', now()) + ON CONFLICT ("module") + DO UPDATE SET "version" = '20261014155304871', "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 5ee746ef..294c3e79 100644 --- a/backend/sinalacs_server/migrations/migration_registry.txt +++ b/backend/sinalacs_server/migrations/migration_registry.txt @@ -13,3 +13,20 @@ 20260917233529326 20260919001437559 20260919032710552 +20260929005339393 +20260929192823862 +20261002153125316 +20261002161303717 +20261003151950052 +20261004013108186 +20261004014924364 +20261005204222939 +20261006195027882 +20261007153247428 +20261007191803293 +20261008190852100 +20261011174930924 +20261012004814823 +20261013151127052 +20261014152407324 +20261014155304871 diff --git a/backend/sinalacs_server/test/integration/admin_account_store_test.dart b/backend/sinalacs_server/test/integration/admin_account_store_test.dart new file mode 100644 index 00000000..f9afb62e --- /dev/null +++ b/backend/sinalacs_server/test/integration/admin_account_store_test.dart @@ -0,0 +1,1157 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/admin/admin_account_service.dart'; +import 'package:sinalacs_server/src/application/audit/audit_trail.dart'; +import 'package:sinalacs_server/src/application/auth/cpf.dart'; +import 'package:sinalacs_server/src/application/auth/development_auth_service.dart'; +import 'package:sinalacs_server/src/application/auth/institutional_auth_service.dart'; +import 'package:sinalacs_server/src/application/auth/password_hasher.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_acs_credential_store.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_admin_account_store.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_otp_challenge_store.dart'; +import 'package:sinalacs_server/src/runtime/alert_runtime.dart'; +import 'package:test/test.dart'; + +import 'test_tools/runtime_harness.dart'; +import 'test_tools/serverpod_test_tools.dart'; + +/// Gestão de contas do backoffice (#43) contra Postgres real: escopo por UBS, +/// ACS ainda sem território e `mfaActive`. Dados sintéticos; ids próprios. +/// +/// UBS A (microárea A): dois ACS — um com MFA ativa, um ainda sem microárea. +/// UBS B (microárea B): um ACS com MFA **pendente** (segredo gravado, ativação +/// não confirmada), que não conta como ativa, e outro sem nenhuma credencial. +const _ubsA = '00000000-0000-4000-8000-0000000000a1'; +const _ubsB = '00000000-0000-4000-8000-0000000000a2'; +const _maA = '00000000-0000-4000-8000-0000000000a3'; +const _maB = '00000000-0000-4000-8000-0000000000a4'; +const _acsA = '00000000-0000-4000-8000-0000000000a5'; +const _acsSemMicroarea = '00000000-0000-4000-8000-0000000000a6'; +const _acsBPendente = '00000000-0000-4000-8000-0000000000a7'; +const _acsBSemCredencial = '00000000-0000-4000-8000-0000000000a8'; +const _admin = '00000000-0000-4000-8000-0000000000a9'; +const _coordA = '00000000-0000-4000-8000-0000000000aa'; +const _coordSemUbs = '00000000-0000-4000-8000-0000000000ab'; + +/// Só os ACS que **este** arquivo semeia (`_seed`): as listagens de escopo de +/// sistema são globais por definição e o banco é compartilhado, então elas vêm +/// filtradas por aqui antes de qualquer igualdade exata (ver o porquê na +/// primeira asserção que usa o conjunto). +const _acsDoSemeio = { + _acsA, + _acsSemMicroarea, + _acsBPendente, + _acsBSemCredencial, +}; + +/// Só as contas de equipe que **este** arquivo semeia (`_seed`), pelo mesmo +/// motivo: `staffList()` também é uma listagem global. +const _staffDoSemeio = {_admin, _coordA, _coordSemUbs}; + +final _t = DateTime.utc(2026, 10, 8, 12); + +Future _usuario( + Session s, + String id, + String nome, + UserRole role, { + String? ma, +}) => User.db + .insertRow( + s, + User( + id: UuidValue.fromString(id), + cpfHash: 'admin-acc-$id', + name: nome, + birthDate: DateTime.utc(1980), + role: role, + microAreaId: ma == null ? null : UuidValue.fromString(ma), + createdAt: _t, + updatedAt: _t, + ), + ) + .then((_) {}); + +/// Credencial de login; `totpEnabledAt` só entra quando a MFA está ativa. +Future _credencial( + Session s, + String userId, { + bool mfaAtiva = false, + bool mfaPendente = false, +}) => UserCredential.db + .insertRow( + s, + UserCredential( + userId: UuidValue.fromString(userId), + passwordHash: 'hash-sintetico', + passwordSalt: 'salt-sintetico', + memoryKb: 65536, + iterations: 3, + parallelism: 1, + failedAttempts: 0, + createdAt: _t, + updatedAt: _t, + totpSecretEncrypted: mfaAtiva || mfaPendente ? 'segredo-sintetico' : null, + totpKeyVersion: mfaAtiva || mfaPendente ? 1 : null, + totpEnabledAt: mfaAtiva ? _t : null, + ), + ) + .then((_) {}); + +Future _acs( + Session s, + String id, + String matricula, + String ubs, +) => Acs.db + .insertRow( + s, + Acs( + id: UuidValue.fromString(id), + enrollmentId: matricula, + ubsId: UuidValue.fromString(ubs), + active: true, + ), + ) + .then((_) {}); + +Future _staff( + Session s, + String id, + String matricula, { + String? ubs, +}) => StaffAccount.db + .insertRow( + s, + StaffAccount( + id: UuidValue.fromString(id), + enrollmentId: matricula, + active: true, + ubsId: ubs == null ? null : UuidValue.fromString(ubs), + ), + ) + .then((_) {}); + +/// Auditoria de mentira: o que se prova aqui é o efeito da operação nas tabelas +/// (transação, login com a senha gerada), e a trilha do serviço já tem os +/// próprios testes contra um `AuditTrail` que registra de verdade. +/// `_RecordingAudit`, de `institutional_login_test.dart`, pelo mesmo motivo. +class _Audit extends AuditTrail { + final events = []; + + @override + Future record(AuditEvent event) async => events.add(event); +} + +/// O mesmo store real, com a pré-checagem de matrícula desligada: abre de +/// propósito a janela entre "a matrícula está livre" e o INSERT, que na +/// produção só outra requisição simultânea abre. +class _SemPreChecagem extends OrmAdminAccountStore { + _SemPreChecagem({required super.session}); + + @override + Future enrollmentIdTaken(String enrollmentId) async => false; +} + +/// Serviço montado como o endpoint o monta (`adminAccountServiceFor`), com o +/// hasher de custo reduzido do harness: a integração prova o caminho, não o +/// custo do Argon2id. +AdminAccountService _servico( + Session session, + AdminAccountStore store, { + required AuditTrail audit, +}) => AdminAccountService( + store: store, + credentials: OrmAcsCredentialStore(session: () => session), + totpStore: OrmAcsCredentialStore(session: () => session), + activationStore: OrmAcsCredentialStore(session: () => session, staff: true), + hasher: AlertRuntimeHarness.hasher, + audit: audit, + clock: () => _t, + random: Random(1), +); + +AuthenticatedUser _operador(String id, UserRole role) => AuthenticatedUser( + id: id, + role: role, + microAreaId: null, + deviceId: 'sem-aparelho', +); + +Future _contar(Session s, String consulta) async { + final linhas = await s.db.unsafeQuery(consulta); + return linhas.single[0] as int; +} + +/// A senha da corrida de matrícula: o `insertAcs` direto precisa de um digest. +Future _digestSintetico() => + AlertRuntimeHarness.hasher.derive('senha-sintetica-de-teste'); + +Future _seed(Session s) async { + for (final (id, nome) in [(_ubsA, 'UBS A'), (_ubsB, 'UBS B')]) { + await Ubs.db.insertRow( + s, + Ubs( + id: UuidValue.fromString(id), + name: nome, + address: 'Endereço sintético', + city: 'São Paulo', + state: 'SP', + ), + ); + } + for (final (id, nome, ubs) in [ + (_maA, 'Microárea A', _ubsA), + (_maB, 'Microárea B', _ubsB), + ]) { + await MicroArea.db.insertRow( + s, + MicroArea( + id: UuidValue.fromString(id), + name: nome, + ubsId: UuidValue.fromString(ubs), + geoJsonBoundary: '{}', + ), + ); + } + + // Nomes em ordem alfabética de propósito: a listagem ordena por nome e id. + await _usuario(s, _acsA, 'Ana ACS Sintética', UserRole.acs, ma: _maA); + await _acs(s, _acsA, 'ACS-ACC-1', _ubsA); + await _credencial(s, _acsA, mfaAtiva: true); + + await _usuario(s, _acsSemMicroarea, 'Bruno ACS Sintético', UserRole.acs); + await _acs(s, _acsSemMicroarea, 'ACS-ACC-2', _ubsA); + + await _usuario(s, _acsBPendente, 'Carla ACS Sintética', UserRole.acs, ma: _maB); + await _acs(s, _acsBPendente, 'ACS-ACC-3', _ubsB); + await _credencial(s, _acsBPendente, mfaPendente: true); + + await _usuario(s, _acsBSemCredencial, 'Dora ACS Sintética', UserRole.acs, ma: _maB); + await _acs(s, _acsBSemCredencial, 'ACS-ACC-4', _ubsB); + + await _usuario(s, _admin, 'Elisa Administradora Sintética', UserRole.admin); + await _staff(s, _admin, 'ADM-ACC-1'); + await _credencial(s, _admin, mfaAtiva: true); + + await _usuario(s, _coordA, 'Fábio Coordenador Sintético', UserRole.coordinator); + await _staff(s, _coordA, 'COO-ACC-1', ubs: _ubsA); + + await _usuario(s, _coordSemUbs, 'Gilda Coordenadora Sintética', UserRole.coordinator); + await _staff(s, _coordSemUbs, 'COO-ACC-2'); +} + +void main() { + withServerpod('Dada a gestão de contas do backoffice (#43)', ( + sessionBuilder, + endpoints, + ) { + late Session session; + late OrmAdminAccountStore store; + setUp(() async { + session = sessionBuilder.build(); + await _seed(session); + store = OrmAdminAccountStore(session: () => session); + }); + + test('o escopo de sistema vê os ACS das duas UBS, com o território de cada um', () async { + // O escopo de sistema enxerga o banco inteiro, e este banco é + // compartilhado: os grupos com `RollbackDatabase.disabled` de outros + // arquivos (o de corrida de `onboarding_endpoint_test.dart`, entre + // outros) COMMITAM fixtures enquanto a suíte roda em paralelo, e essas + // linhas aparecem na listagem global. O filtro restringe a asserção aos + // ACS que ESTE arquivo semeia — e é só isso que ele faz: a igualdade + // exata continua valendo, inteira, sobre as quatro linhas próprias. + final lista = (await store.acsList(const AdminScope.system())) + .where((a) => _acsDoSemeio.contains(a.id)) + .toList(); + expect(lista.map((a) => a.name), [ + 'Ana ACS Sintética', + 'Bruno ACS Sintético', + 'Carla ACS Sintética', + 'Dora ACS Sintética', + ]); + final ana = lista.first; + expect(ana.enrollmentId, 'ACS-ACC-1'); + expect(ana.ubsId, _ubsA); + expect(ana.ubsName, 'UBS A'); + expect(ana.microAreaId, _maA); + expect(ana.microAreaName, 'Microárea A'); + expect(ana.active, isTrue); + }); + + test('o escopo de UBS A não vê o ACS da UBS B', () async { + final lista = await store.acsList(const AdminScope.ubs(_ubsA)); + expect(lista.map((a) => a.name), [ + 'Ana ACS Sintética', + 'Bruno ACS Sintético', + ]); + expect(lista.map((a) => a.ubsId).toSet(), {_ubsA}); + }); + + test('ACS sem microárea aparece na listagem com microAreaId nulo', () async { + final lista = await store.acsList(const AdminScope.ubs(_ubsA)); + final bruno = lista.singleWhere((a) => a.name == 'Bruno ACS Sintético'); + expect(bruno.microAreaId, isNull); + expect(bruno.microAreaName, isNull); + expect(bruno.ubsName, 'UBS A'); + }); + + test('mfaActive reflete totpEnabledAt: pendente não conta como ativa', () async { + final lista = await store.acsList(const AdminScope.system()); + final porNome = {for (final a in lista) a.name: a.mfaActive}; + expect(porNome['Ana ACS Sintética'], isTrue); + expect( + porNome['Carla ACS Sintética'], + isFalse, + reason: 'segredo gravado sem ativação confirmada não é MFA ativa', + ); + expect(porNome['Dora ACS Sintética'], isFalse, reason: 'sem credencial'); + }); + + test('acsById devolve o ACS do escopo e nulo fora dele', () async { + final dentro = await store.acsById(const AdminScope.ubs(_ubsA), _acsA); + expect(dentro?.name, 'Ana ACS Sintética'); + expect( + await store.acsById(const AdminScope.ubs(_ubsB), _acsA), + isNull, + reason: 'ACS da UBS A não existe para o escopo da UBS B', + ); + expect(await store.acsById(const AdminScope.system(), _acsA), isNotNull); + }); + + test('acsById e staffById com id que não é UUID devolvem nulo, sem erro de SQL', () async { + for (final id in ['lixo', '123', '']) { + expect(await store.acsById(const AdminScope.system(), id), isNull); + expect(await store.staffById(id), isNull); + } + }); + + test('staffList traz papel, UBS e mfaActive de cada conta', () async { + // `staffList()` também é uma listagem global num banco compartilhado: os + // grupos com `RollbackDatabase.disabled` de outros arquivos (o login + // institucional e o de MFA, entre outros) COMMITAM contas de equipe + // enquanto a suíte roda em paralelo. O filtro prende a igualdade exata + // às contas que ESTE arquivo semeia. + final lista = (await store.staffList()) + .where((s) => _staffDoSemeio.contains(s.id)) + .toList(); + expect(lista.map((s) => s.name), [ + 'Elisa Administradora Sintética', + 'Fábio Coordenador Sintético', + 'Gilda Coordenadora Sintética', + ]); + final elisa = lista.first; + expect(elisa.enrollmentId, 'ADM-ACC-1'); + expect(elisa.role, UserRole.admin); + expect(elisa.ubsName, isNull, reason: 'o administrador vê o sistema inteiro'); + expect(elisa.mfaActive, isTrue); + final fabio = lista.singleWhere((s) => s.role == UserRole.coordinator && s.ubsName != null); + expect(fabio.ubsName, 'UBS A'); + expect(fabio.mfaActive, isFalse); + }); + + test('staffById devolve a conta e nulo para id desconhecido', () async { + expect( + (await store.staffById(_coordA))?.enrollmentId, + 'COO-ACC-1', + ); + expect(await store.staffById(_acsA), isNull, reason: 'ACS não é conta de equipe'); + }); + + test('ubsOf é a UBS do coordenador; nulo para o administrador e para id inválido', () async { + expect(await store.ubsOf(_coordA), _ubsA); + expect(await store.ubsOf(_admin), isNull); + expect(await store.ubsOf(_coordSemUbs), isNull); + expect(await store.ubsOf('lixo'), isNull); + }); + + test('microAreaFor devolve a UBS dona da microárea, e nulo fora do formato de UUID', () async { + expect(await store.microAreaFor(_maA), (name: 'Microárea A', ubsId: _ubsA)); + expect(await store.microAreaFor('00000000-0000-4000-8000-0000000000ff'), isNull); + expect(await store.microAreaFor('lixo'), isNull, reason: 'sem erro de sintaxe do ::uuid'); + }); + + test('enrollmentIdTaken só é verdadeiro para matrícula de ACS existente', () async { + expect(await store.enrollmentIdTaken('ACS-ACC-1'), isTrue); + expect(await store.enrollmentIdTaken('ACS-ACC-9'), isFalse); + expect(await store.enrollmentIdTaken('ADM-ACC-1'), isFalse, reason: 'matrícula de staff não é de ACS'); + }); + + group('createAcs (#43, cadastro)', () { + late _Audit auditoria; + late AdminAccountService servico; + final admin = _operador(_admin, UserRole.admin); + + setUp(() { + auditoria = _Audit(); + servico = _servico(session, store, audit: auditoria); + }); + + test('cadastro grava users+acs+user_credentials e o ACS consegue logar com a senha gerada', () async { + final r = await servico.createAcs( + admin, + name: 'Nova ACS Sintética', + enrollmentId: 'ACS-43-1', + microAreaId: _maA, + ); + + expect(r.acs.name, 'Nova ACS Sintética'); + expect(r.acs.enrollmentId, 'ACS-43-1'); + expect(r.acs.ubsId, _ubsA, reason: 'a UBS vem da microárea, nunca do pedido'); + expect(r.acs.ubsName, 'UBS A'); + expect(r.acs.microAreaId, _maA); + expect(r.acs.microAreaName, 'Microárea A'); + expect(r.acs.active, isTrue); + expect(r.acs.mfaActive, isFalse, reason: 'cadastro não ativa MFA nenhuma'); + + final id = UuidValue.fromString(r.acs.id); + final usuario = await User.db.findById(session, id); + expect(usuario!.role, UserRole.acs); + expect(usuario.microAreaId, UuidValue.fromString(_maA)); + expect((await Acs.db.findById(session, id))!.enrollmentId, 'ACS-43-1'); + final credencial = await UserCredential.db.findFirstRow( + session, + where: (t) => t.userId.equals(id), + ); + expect(credencial, isNotNull); + expect( + jsonEncode(credencial!.toJson()), + isNot(contains(r.initialPassword)), + reason: 'a senha em claro não é gravada em coluna nenhuma', + ); + + // A prova que interessa: a senha mostrada uma única vez é a que abre o + // login institucional (RF07), já com o território do cadastro. + final login = await InstitutionalAuthService( + store: AlertRuntimeHarness.store(session), + hasher: AlertRuntimeHarness.hasher, + audit: _Audit(), + ).login(matricula: 'ACS-43-1', password: r.initialPassword); + expect(login.id, r.acs.id); + expect(login.role, UserRole.acs); + expect(login.microAreaId, _maA); + + expect(auditoria.events.single.actionType, 'write'); + expect(auditoria.events.single.resourceType, 'admin_acs'); + expect(auditoria.events.single.result, 'created'); + expect(auditoria.events.single.resourceId, r.acs.id); + }); + + test('falha injetada depois de users não deixa órfão: nem users, nem acs, nem credencial', () async { + final quebrado = OrmAdminAccountStore( + session: () => session, + debugFailAfterUsersInsert: true, + ); + final servicoQuebrado = _servico(session, quebrado, audit: _Audit()); + + await expectLater( + servicoQuebrado.createAcs( + admin, + name: 'Fantasma Sintética', + enrollmentId: 'ACS-43-2', + microAreaId: _maA, + ), + throwsA(isA()), + ); + + expect( + await User.db.findFirstRow( + session, + where: (t) => t.name.equals('Fantasma Sintética'), + ), + isNull, + reason: 'a transação desfez o INSERT de users', + ); + expect( + await Acs.db.findFirstRow( + session, + where: (t) => t.enrollmentId.equals('ACS-43-2'), + ), + isNull, + ); + expect( + await _contar(session, 'SELECT count(*) FROM user_credentials'), + 3, + reason: 'só as credenciais do seed: nenhuma órfã ficou para trás', + ); + }); + + test('matrícula repetida devolve null (corrida) e o serviço responde validação, não 500', () async { + await servico.createAcs( + admin, + name: 'Primeira ACS Sintética', + enrollmentId: 'ACS-43-3', + microAreaId: _maA, + ); + + // Caso comum: a pré-checagem barra antes de qualquer escrita. + await expectLater( + servico.createAcs( + admin, + name: 'Segunda ACS Sintética', + enrollmentId: 'ACS-43-3', + microAreaId: _maA, + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Já existe um ACS com esta matrícula.', + ), + ), + ); + + // Corrida: o índice único é quem barra, e o store traduz o 23505 em + // `null` — não em erro de banco. + final semPreChecagem = _SemPreChecagem(session: () => session); + expect( + await semPreChecagem.insertAcs( + name: 'Segunda ACS Sintética', + enrollmentId: 'ACS-43-3', + microAreaId: _maA, + ubsId: _ubsA, + digest: await _digestSintetico(), + at: _t, + ), + isNull, + ); + + // E o serviço, com a janela aberta, responde a MESMA validação. + await expectLater( + _servico(session, semPreChecagem, audit: _Audit()).createAcs( + admin, + name: 'Terceira ACS Sintética', + enrollmentId: 'ACS-43-3', + microAreaId: _maA, + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Já existe um ACS com esta matrícula.', + ), + ), + ); + + expect( + await Acs.db.find( + session, + where: (t) => t.enrollmentId.equals('ACS-43-3'), + ), + hasLength(1), + ); + expect( + await User.db.find( + session, + where: (t) => t.name.equals('Terceira ACS Sintética'), + ), + isEmpty, + reason: 'o cadastro perdido da corrida não deixou usuário nenhum', + ); + }); + + test('o CPF placeholder e a data sentinela do ACS não abrem login passwordless de paciente', () async { + final r = await servico.createAcs( + admin, + name: 'Nova ACS Sintética', + enrollmentId: 'ACS-43-4', + microAreaId: _maA, + ); + final usuario = (await User.db.findById( + session, + UuidValue.fromString(r.acs.id), + ))!; + + expect(usuario.cpfHash, 'acs-sem-cpf-${r.acs.id}'); + expect(usuario.birthDate, DateTime.utc(1900, 1, 1)); + expect( + RegExp(r'^[0-9a-f]{64}$').hasMatch(usuario.cpfHash), + isFalse, + reason: 'o login procura por `hasher.hash(cpf)`, sempre 64 dígitos hexadecimais', + ); + + // O passo exato do login passwordless: `verifyOtp` procura pelo HMAC + // do CPF digitado, e o placeholder do ACS não tem como ser um. + final otpStore = OrmOtpChallengeStore(session: () => session); + final hmacDoCpf = AlertRuntime.instance.cpfHasherForTests.hash( + Cpf.tryParse('52998224725')!, + ); + expect(await otpStore.findByCpfHash(hmacDoCpf), isNull); + + // Controle: o mesmo caminho ACHA um paciente de verdade — o nulo acima + // é ausência de ACS, não um lookup que nunca devolve nada. + const paciente = '00000000-0000-4000-8000-0000000000ac'; + await User.db.insertRow( + session, + User( + id: UuidValue.fromString(paciente), + cpfHash: hmacDoCpf, + name: 'Paciente Sintética', + birthDate: DateTime.utc(1990), + role: UserRole.patient, + microAreaId: UuidValue.fromString(_maA), + createdAt: _t, + updatedAt: _t, + ), + ); + expect((await otpStore.findByCpfHash(hmacDoCpf))?.userId, paciente); + }); + }); + + group('setAcsMicroArea (#43, vínculo)', () { + late _Audit auditoria; + late AdminAccountService servico; + final admin = _operador(_admin, UserRole.admin); + final coordA = _operador(_coordA, UserRole.coordinator); + + setUp(() { + auditoria = _Audit(); + servico = _servico(session, store, audit: auditoria); + }); + + test('coordenador vincula à própria UBS o ACS que ainda não tinha território', () async { + final r = await servico.setAcsMicroArea( + coordA, + acsId: _acsSemMicroarea, + microAreaId: _maA, + ); + + expect(r.microAreaId, _maA); + expect(r.microAreaName, 'Microárea A'); + expect(r.ubsId, _ubsA, reason: 'a UBS vem da microárea-alvo'); + + final usuario = (await User.db.findById( + session, + UuidValue.fromString(_acsSemMicroarea), + ))!; + expect(usuario.microAreaId, UuidValue.fromString(_maA)); + expect(usuario.updatedAt, _t, reason: 'o carimbo do vínculo é o do serviço'); + expect( + (await Acs.db.findById(session, UuidValue.fromString(_acsSemMicroarea)))! + .ubsId, + UuidValue.fromString(_ubsA), + reason: 'a UBS do ACS não muda quando a microárea é da mesma UBS', + ); + + // A listagem do coordenador já o mostra no território novo. + final lista = await store.acsList(const AdminScope.ubs(_ubsA)); + expect( + lista.singleWhere((a) => a.id == _acsSemMicroarea).microAreaName, + 'Microárea A', + ); + + expect(auditoria.events.single.actionType, 'write'); + expect(auditoria.events.single.resourceType, 'admin_acs'); + expect(auditoria.events.single.result, 'micro_area_changed'); + expect(auditoria.events.single.resourceId, _acsSemMicroarea); + }); + + test('admin move o ACS para microárea de outra UBS e a UBS dele muda junto', () async { + await servico.setAcsMicroArea(admin, acsId: _acsA, microAreaId: _maB); + + expect( + (await Acs.db.findById(session, UuidValue.fromString(_acsA)))!.ubsId, + UuidValue.fromString(_ubsB), + reason: 'a UBS do vínculo sai da microárea-alvo, nunca do chamador', + ); + expect( + (await User.db.findById(session, UuidValue.fromString(_acsA)))!.microAreaId, + UuidValue.fromString(_maB), + ); + + final ubsA = await store.acsList(const AdminScope.ubs(_ubsA)); + expect(ubsA.map((a) => a.id), isNot(contains(_acsA))); + final ubsB = await store.acsList(const AdminScope.ubs(_ubsB)); + final ana = ubsB.singleWhere((a) => a.id == _acsA); + expect(ana.ubsName, 'UBS B'); + expect(ana.microAreaName, 'Microárea B'); + }); + + test('o escopo dentro do UPDATE: ACS de outra UBS não é movido nem chamando o store direto', () async { + // Sem a pré-checagem do serviço: quem barra é o predicado de escopo + // repetido no `WHERE` do UPDATE (a defesa em profundidade do TOCTOU). + expect( + await store.setAcsMicroArea( + acsId: _acsBPendente, + microAreaId: _maA, + ubsId: _ubsA, + scope: const AdminScope.ubs(_ubsA), + at: _t, + ), + isNull, + ); + // E um id fora do formato de UUID devolve o mesmo nulo, sem erro de SQL. + expect( + await store.setAcsMicroArea( + acsId: 'lixo', + microAreaId: _maA, + ubsId: _ubsA, + scope: const AdminScope.system(), + at: _t, + ), + isNull, + ); + + expect( + (await Acs.db.findById(session, UuidValue.fromString(_acsBPendente)))!.ubsId, + UuidValue.fromString(_ubsB), + ); + expect( + (await User.db.findById(session, UuidValue.fromString(_acsBPendente)))! + .microAreaId, + UuidValue.fromString(_maB), + reason: 'nenhuma das duas escritas da transação rodou', + ); + }); + + test('ACS inexistente recebe a mesma recusa do escopo, auditada denied', () async { + await expectLater( + servico.setAcsMicroArea( + coordA, + acsId: '00000000-0000-4000-8000-0000000000ff', + microAreaId: _maA, + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'ACS não encontrado.', + ), + ), + ); + expect(auditoria.events.single.actionType, 'write'); + expect(auditoria.events.single.result, 'denied'); + }); + + test('ACS órfão (sem a linha de users) não move nada: a transação desfaz o UPDATE de acs', () async { + // O acs não tem FK para users, então esta linha inconsistente é + // gravável — e é o que prova que o vínculo é atômico: a segunda escrita + // não acha o usuário e a transação inteira volta atrás. + const orfao = '00000000-0000-4000-8000-0000000000ad'; + await _acs(session, orfao, 'ACS-ACC-ORFAO', _ubsA); + await expectLater( + store.setAcsMicroArea( + acsId: orfao, + microAreaId: _maB, + ubsId: _ubsB, + scope: const AdminScope.system(), + at: _t, + ), + throwsA(isA()), + ); + expect( + (await Acs.db.findById(session, UuidValue.fromString(orfao)))!.ubsId, + UuidValue.fromString(_ubsA), + reason: 'o UPDATE de acs foi desfeito junto com a escrita que faltou', + ); + }); + }); + + group('setAcsActive (#43, desativação)', () { + late _Audit auditoria; + late AdminAccountService servico; + final admin = _operador(_admin, UserRole.admin); + final coordA = _operador(_coordA, UserRole.coordinator); + + setUp(() { + auditoria = _Audit(); + servico = _servico(session, store, audit: auditoria); + }); + + var proximoId = 0; + + /// Ids sintéticos e únicos das linhas de token deste grupo. + UuidValue novoId() => UuidValue.fromString( + '00000000-0000-4000-8000-' + '${(0xf00 + (++proximoId)).toRadixString(16).padLeft(12, '0')}', + ); + + /// O estado que a desativação tem de derrubar inteiro: **duas famílias** + /// de refresh (duas sessões) e **dois aparelhos** de envio diferido do + /// mesmo ACS. + Future tokens(String userId) async { + final id = UuidValue.fromString(userId); + await AcsRefreshToken.db.insert(session, [ + for (final aparelho in [1, 2]) + AcsRefreshToken( + id: novoId(), + userId: id, + familyId: novoId(), + tokenHash: 'hash-refresh-$userId-$aparelho', + deviceId: 'aparelho-$aparelho', + issuedAt: _t, + idleExpiresAt: _t.add(const Duration(hours: 2)), + absoluteExpiresAt: _t.add(const Duration(hours: 8)), + ), + ]); + await AcsUploadToken.db.insert(session, [ + for (final aparelho in [1, 2]) + AcsUploadToken( + id: novoId(), + userId: id, + tokenHash: 'hash-upload-$userId-$aparelho', + deviceId: 'aparelho-$aparelho', + issuedAt: _t, + expiresAt: _t.add(const Duration(days: 7)), + ), + ]); + } + + /// Linhas **vigentes** (não revogadas) do usuário numa das duas tabelas. + Future vigentes(String tabela, String userId) async { + final linhas = await session.db.unsafeQuery( + 'SELECT count(*) FROM $tabela ' + 'WHERE "userId" = @id::uuid AND "revokedAt" IS NULL', + parameters: QueryParameters.named({'id': userId}), + ); + return linhas.single[0] as int; + } + + Future ativo(String acsId) async => + (await Acs.db.findById(session, UuidValue.fromString(acsId)))!.active; + + test( + 'desativar grava a flag e revoga TODOS os tokens do ACS, nas duas tabelas', + () async { + await tokens(_acsA); + await tokens(_acsBPendente); + + final resultado = await servico.setAcsActive( + admin, + acsId: _acsA, + active: false, + ); + + expect(resultado.id, _acsA); + expect(resultado.active, isFalse); + expect(await ativo(_acsA), isFalse); + expect(await vigentes('acs_refresh_tokens', _acsA), 0); + expect(await vigentes('acs_upload_tokens', _acsA), 0); + + // O carimbo é o do relógio do serviço, nas linhas das duas tabelas. + final refreshes = await AcsRefreshToken.db.find( + session, + where: (t) => t.userId.equals(UuidValue.fromString(_acsA)), + ); + expect(refreshes, hasLength(2)); + expect(refreshes.map((l) => l.revokedAt), everyElement(_t)); + final uploads = await AcsUploadToken.db.find( + session, + where: (t) => t.userId.equals(UuidValue.fromString(_acsA)), + ); + expect(uploads, hasLength(2)); + expect(uploads.map((l) => l.revokedAt), everyElement(_t)); + + // A revogação é por conta: o outro ACS mantém os tokens dele. + expect(await vigentes('acs_refresh_tokens', _acsBPendente), 2); + expect(await vigentes('acs_upload_tokens', _acsBPendente), 2); + expect(await ativo(_acsBPendente), isTrue); + + expect(auditoria.events.single.actionType, 'write'); + expect(auditoria.events.single.result, 'deactivated'); + expect(auditoria.events.single.resourceId, _acsA); + }, + ); + + test('reativar grava a flag e não ressuscita token nenhum', () async { + await tokens(_acsA); + await servico.setAcsActive(admin, acsId: _acsA, active: false); + + final reativado = await servico.setAcsActive( + admin, + acsId: _acsA, + active: true, + ); + + expect(reativado.active, isTrue); + expect(await ativo(_acsA), isTrue); + expect( + await vigentes('acs_refresh_tokens', _acsA), + 0, + reason: 'revogado é revogado: o acesso novo se obtém com um login novo', + ); + expect(await vigentes('acs_upload_tokens', _acsA), 0); + expect( + auditoria.events.map((e) => e.result), + ['deactivated', 'activated'], + ); + }); + + test('desativar quem já está inativo é idempotente e audita uma linha nova', () async { + await tokens(_acsA); + await servico.setAcsActive(admin, acsId: _acsA, active: false); + final segunda = await servico.setAcsActive( + admin, + acsId: _acsA, + active: false, + ); + + expect(segunda.active, isFalse); + expect(await ativo(_acsA), isFalse); + expect( + auditoria.events.map((e) => e.result), + ['deactivated', 'deactivated'], + ); + }); + + test( + 'escopo dentro do UPDATE: ACS de outra UBS não é desativado nem revogado', + () async { + await tokens(_acsBPendente); + + // Sem a pré-checagem do serviço: quem barra é o predicado de escopo + // repetido no `WHERE` do UPDATE (a defesa em profundidade do TOCTOU). + expect( + await store.setAcsActive( + acsId: _acsBPendente, + scope: const AdminScope.ubs(_ubsA), + active: false, + at: _t, + ), + isFalse, + ); + // E um id fora do formato de UUID devolve o mesmo `false`, sem erro de SQL. + expect( + await store.setAcsActive( + acsId: 'lixo', + scope: const AdminScope.system(), + active: false, + at: _t, + ), + isFalse, + ); + + expect(await ativo(_acsBPendente), isTrue); + expect(await vigentes('acs_refresh_tokens', _acsBPendente), 2); + expect(await vigentes('acs_upload_tokens', _acsBPendente), 2); + + // Pelo serviço, a recusa é a mesma de "não existe". + await expectLater( + servico.setAcsActive(coordA, acsId: _acsBPendente, active: false), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'ACS não encontrado.', + ), + ), + ); + expect(auditoria.events.single.result, 'denied'); + }, + ); + + test( + 'a flag e as revogações são uma só transação: a falha depois delas desfaz as três', + () async { + await tokens(_acsA); + final quebrado = OrmAdminAccountStore( + session: () => session, + debugFailAfterRevocations: true, + ); + + await expectLater( + quebrado.setAcsActive( + acsId: _acsA, + scope: const AdminScope.system(), + active: false, + at: _t, + ), + throwsA(isA()), + ); + + expect( + await ativo(_acsA), + isTrue, + reason: 'o UPDATE da flag voltou atrás junto com as revogações', + ); + expect(await vigentes('acs_refresh_tokens', _acsA), 2); + expect(await vigentes('acs_upload_tokens', _acsA), 2); + }, + ); + }); + + group('resetStaffMfa (#43, MFA do staff)', () { + late _Audit auditoria; + late AdminAccountService servico; + final admin = _operador(_admin, UserRole.admin); + + setUp(() { + auditoria = _Audit(); + servico = _servico(session, store, audit: auditoria); + }); + + /// A linha de `user_credentials` da conta, para as provas por ORM das + /// colunas `totp*`. + Future credencialDe(String userId) async => + (await UserCredential.db.findFirstRow( + session, + where: (t) => t.userId.equals(UuidValue.fromString(userId)), + ))!; + + Future staffDe(String staffId) async => + (await StaffAccount.db.findById( + session, + UuidValue.fromString(staffId), + ))!; + + test( + 'zera as quatro colunas totp* e grava o código novo com o emissor, na mesma transação', + () async { + expect( + (await credencialDe(_admin)).totpSecretEncrypted, + isNotNull, + reason: 'a fixture do administrador nasce com MFA ativa', + ); + + final ok = await store.resetStaffMfa( + staffId: _admin, + codeHash: 'hash-do-codigo-novo', + expiresAt: _t.add(const Duration(hours: 24)), + issuedBy: _coordA, + at: _t, + ); + expect(ok, isTrue); + + // As QUATRO colunas voltam a NULL — inclusive `totpLastStep`, que + // deixaria um código do segredo antigo valendo como replay. + final linha = await credencialDe(_admin); + expect(linha.totpSecretEncrypted, isNull); + expect(linha.totpKeyVersion, isNull); + expect(linha.totpEnabledAt, isNull); + expect(linha.totpLastStep, isNull); + + // E o código novo está gravado, com a validade e o emissor do + // chamado: as duas escritas descrevem o mesmo instante. + final conta = await staffDe(_admin); + expect(conta.activationCodeHash, 'hash-do-codigo-novo'); + expect(conta.activationCodeExpiresAt, _t.add(const Duration(hours: 24))); + expect( + conta.activationCodeIssuedBy, + _coordA, + reason: 'a coluna guarda o UUID do ator; o rótulo humano vem da trilha', + ); + expect(conta.activationCodeIssuedAt, _t); + expect(conta.active, isTrue, reason: 'redefinir a MFA não mexe no acesso'); + }, + ); + + test( + 'id fora do formato, conta inexistente e conta inativa devolvem false sem escrever nada', + () async { + // A fixture compartilhada não tem conta de equipe desativada: a prova + // cria uma aqui, com MFA ativa, para que "nada escrito" seja visível. + const inativo = '00000000-0000-4000-8000-0000000000ac'; + await _usuario( + session, + inativo, + 'Hélio Inativo Sintético', + UserRole.coordinator, + ); + await StaffAccount.db.insertRow( + session, + StaffAccount( + id: UuidValue.fromString(inativo), + enrollmentId: 'COO-ACC-3', + active: false, + ), + ); + await _credencial(session, inativo, mfaAtiva: true); + + for (final alvo in [ + 'lixo', + '00000000-0000-4000-8000-0000000000ff', + inativo, + ]) { + expect( + await store.resetStaffMfa( + staffId: alvo, + codeHash: 'hash-do-codigo-novo', + expiresAt: _t.add(const Duration(hours: 24)), + issuedBy: _coordA, + at: _t, + ), + isFalse, + ); + } + + // A conta inativa fica exatamente como estava: nem a MFA zerada, nem + // código emitido — uma credencial de ativação viva para quem não pode + // entrar seria um caminho de volta pela porta dos fundos. + final linha = await credencialDe(inativo); + expect(linha.totpSecretEncrypted, 'segredo-sintetico'); + expect(linha.totpEnabledAt, _t); + final conta = await staffDe(inativo); + expect(conta.activationCodeHash, isNull); + expect(conta.activationCodeExpiresAt, isNull); + + // Pelo serviço, a recusa é a mesma de "não existe", e a trilha não + // diz que a MFA foi redefinida. + await expectLater( + servico.resetStaffMfa(admin, staffId: inativo), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Conta de equipe não encontrada.', + ), + ), + ); + expect(auditoria.events.single.result, 'denied'); + expect(auditoria.events.single.actionType, 'write'); + }, + ); + + test( + 'a limpeza da MFA e a emissão do código são uma só transação: a falha depois das duas desfaz as duas', + () async { + // Um código vigente de antes (o que a CLI teria emitido): se a + // transação não voltasse atrás inteira, ele teria sido substituído. + await OrmAcsCredentialStore( + session: () => session, + staff: true, + ).issue( + _admin, + codeHash: 'hash-antigo', + expiresAt: _t.add(const Duration(hours: 1)), + issuedBy: 'operador', + at: _t, + ); + final quebrado = OrmAdminAccountStore( + session: () => session, + debugFailAfterActivationIssue: true, + ); + + await expectLater( + quebrado.resetStaffMfa( + staffId: _admin, + codeHash: 'hash-do-codigo-novo', + expiresAt: _t.add(const Duration(hours: 24)), + issuedBy: _coordA, + at: _t, + ), + throwsA(isA()), + ); + + final linha = await credencialDe(_admin); + expect( + linha.totpSecretEncrypted, + 'segredo-sintetico', + reason: 'a limpeza voltou atrás junto com a emissão', + ); + expect(linha.totpEnabledAt, _t); + final conta = await staffDe(_admin); + expect( + conta.activationCodeHash, + 'hash-antigo', + reason: 'o código da transação que falhou não ficou gravado', + ); + expect(conta.activationCodeIssuedBy, 'operador'); + }, + ); + }); + }); +} diff --git a/backend/sinalacs_server/test/integration/admin_endpoint_accounts_test.dart b/backend/sinalacs_server/test/integration/admin_endpoint_accounts_test.dart new file mode 100644 index 00000000..cbc69391 --- /dev/null +++ b/backend/sinalacs_server/test/integration/admin_endpoint_accounts_test.dart @@ -0,0 +1,1271 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:serverpod/serverpod.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/auth/institutional_auth_service.dart'; +import 'package:sinalacs_server/src/application/auth/password_hasher.dart'; +import 'package:sinalacs_server/src/application/auth/refresh_token_service.dart'; +import 'package:sinalacs_server/src/application/auth/staff_activation_code.dart'; +import 'package:sinalacs_server/src/application/auth/totp.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/infrastructure/crypto/health_cipher_totp_vault.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_acs_credential_store.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_refresh_token_store.dart'; +import 'package:sinalacs_server/src/runtime/alert_runtime.dart'; +import 'package:test/test.dart'; + +import 'test_tools/runtime_harness.dart'; +import 'test_tools/serverpod_test_tools.dart'; + +/// `admin.acs`, `admin.staff`, `admin.createAcs`, `admin.setAcsMicroArea`, +/// `admin.setAcsActive`, `admin.resetAcsPassword` e `admin.resetAcsMfa` (#43) +/// pelo endpoint, com tokens reais e Postgres real: papel, escopo por UBS, +/// auditoria de cada leitura e minimização de PII. Dados sintéticos; ids +/// próprios. +const _ubsA = '00000000-0000-4000-8000-0000000000b1'; +const _ubsB = '00000000-0000-4000-8000-0000000000b2'; +const _maA = '00000000-0000-4000-8000-0000000000b3'; +const _maB = '00000000-0000-4000-8000-0000000000b4'; +const _acsA = '00000000-0000-4000-8000-0000000000b5'; +const _acsB = '00000000-0000-4000-8000-0000000000b6'; +const _admin = '00000000-0000-4000-8000-0000000000b7'; +const _coordA = '00000000-0000-4000-8000-0000000000b8'; +const _coordSemUbs = '00000000-0000-4000-8000-0000000000b9'; +const _paciente = '00000000-0000-4000-8000-0000000000ba'; + +/// Segunda microárea da UBS A: sem ela não há para onde mover um ACS sem +/// trocá-lo de UBS. +const _maA2 = '00000000-0000-4000-8000-0000000000bb'; + +/// ACS da prova de desativação, criado dentro do próprio caso: a fixture +/// compartilhada não tem senha (nenhum outro caso loga por ela). +const _acsDesat = '00000000-0000-4000-8000-0000000000bc'; +const _matriculaDesat = 'ACS-EP-DESAT-1'; +const _senha = 'senha-sintetica-de-teste'; + +/// ACS da prova de redefinição de senha, criado dentro do próprio caso: é ele +/// que nasce bloqueado e tem a credencial substituída. +const _acsSenha = '00000000-0000-4000-8000-0000000000be'; +const _matriculaSenha = 'ACS-EP-SENHA-1'; + +/// ACS da prova de redefinição de MFA, criado dentro do próprio caso: a MFA +/// dele é ativada pelo fluxo de verdade (segredo cifrado pelo cofre), e não +/// pelas colunas sintéticas da fixture compartilhada. +const _acsMfa = '00000000-0000-4000-8000-0000000000bf'; +const _matriculaMfa = 'ACS-EP-MFA-1'; + +/// Coordenador da prova da redefinição de MFA do staff (deferimento da #48), +/// criado dentro do próprio caso: a MFA dele é ativada pelo fluxo de verdade — +/// com o código de uso único que a CLI emitia —, e é ele que o administrador +/// redefine pela tela. +const _coordMfa = '00000000-0000-4000-8000-0000000000c1'; +const _matriculaCoordMfa = 'COO-EP-MFA-1'; + +/// Os dois códigos de ativação do caso: o que ativa a MFA original (consumido +/// no `confirm`) e o que fica vigente depois dela — o que a redefinição pela +/// tela precisa invalidar. Formato do `StaffActivationCode` (base32 em grupos +/// de 4), como os da CLI. +const _codigoDeAtivacao = 'ABCD-EFGH-JKLM-NPQR-STUV-WXYZ-23'; +const _codigoVigente = 'WXYZ-2345-ABCD-EFGH-JKLM-NPQR-67'; + +final _t = DateTime.utc(2026, 10, 8, 12); + +Future _usuario( + Session s, + String id, + String nome, + UserRole role, { + String? ma, +}) => User.db + .insertRow( + s, + User( + id: UuidValue.fromString(id), + cpfHash: 'admin-ep-acc-$id', + name: nome, + birthDate: DateTime.utc(1980), + role: role, + microAreaId: ma == null ? null : UuidValue.fromString(ma), + createdAt: _t, + updatedAt: _t, + ), + ) + .then((_) {}); + +/// Credencial de login; só a do ACS da UBS A tem MFA ativa. +/// +/// Com [digest], a senha é de verdade (Argon2id do custo reduzido do harness) e +/// abre o `auth.loginInstitutional` do endpoint; sem ele, o hash é o sintético +/// de sempre — nenhum caso desta suite loga por ele. +Future _credencial( + Session s, + String userId, { + bool mfaAtiva = false, + PasswordDigest? digest, +}) => + UserCredential.db + .insertRow( + s, + UserCredential( + userId: UuidValue.fromString(userId), + passwordHash: digest?.hashBase64 ?? 'hash-sintetico', + passwordSalt: digest?.saltBase64 ?? 'salt-sintetico', + memoryKb: digest?.memoryKb ?? 65536, + iterations: digest?.iterations ?? 3, + parallelism: digest?.parallelism ?? 1, + failedAttempts: 0, + createdAt: _t, + updatedAt: _t, + totpSecretEncrypted: mfaAtiva ? 'segredo-sintetico' : null, + totpKeyVersion: mfaAtiva ? 1 : null, + totpEnabledAt: mfaAtiva ? _t : null, + ), + ) + .then((_) {}); + +Future _seed(Session s) async { + for (final (id, nome) in [(_ubsA, 'UBS A'), (_ubsB, 'UBS B')]) { + await Ubs.db.insertRow( + s, + Ubs( + id: UuidValue.fromString(id), + name: nome, + address: 'Endereço sintético', + city: 'São Paulo', + state: 'SP', + ), + ); + } + for (final (id, nome, ubs) in [ + (_maA, 'Microárea A', _ubsA), + (_maA2, 'Microárea A2', _ubsA), + (_maB, 'Microárea B', _ubsB), + ]) { + await MicroArea.db.insertRow( + s, + MicroArea( + id: UuidValue.fromString(id), + name: nome, + ubsId: UuidValue.fromString(ubs), + geoJsonBoundary: '{}', + ), + ); + } + + await _usuario(s, _acsA, 'Ana ACS Sintética', UserRole.acs, ma: _maA); + await Acs.db.insertRow( + s, + Acs( + id: UuidValue.fromString(_acsA), + enrollmentId: 'ACS-EP-ACC-1', + ubsId: UuidValue.fromString(_ubsA), + active: true, + ), + ); + await _credencial(s, _acsA, mfaAtiva: true); + + await _usuario(s, _acsB, 'Bruno ACS Sintético', UserRole.acs, ma: _maB); + await Acs.db.insertRow( + s, + Acs( + id: UuidValue.fromString(_acsB), + enrollmentId: 'ACS-EP-ACC-2', + ubsId: UuidValue.fromString(_ubsB), + active: true, + ), + ); + + await _usuario(s, _admin, 'Admin Sintético', UserRole.admin); + await StaffAccount.db.insertRow( + s, + StaffAccount( + id: UuidValue.fromString(_admin), + enrollmentId: 'ADM-EP-ACC-1', + active: true, + ), + ); + + await _usuario(s, _coordA, 'Coord A Sintético', UserRole.coordinator); + await StaffAccount.db.insertRow( + s, + StaffAccount( + id: UuidValue.fromString(_coordA), + enrollmentId: 'COO-EP-ACC-1', + active: true, + ubsId: UuidValue.fromString(_ubsA), + ), + ); + + await _usuario(s, _coordSemUbs, 'Coord Sem UBS', UserRole.coordinator); + await StaffAccount.db.insertRow( + s, + StaffAccount( + id: UuidValue.fromString(_coordSemUbs), + enrollmentId: 'COO-EP-ACC-2', + active: true, + ), + ); + + await _usuario(s, _paciente, 'Paciente Sintético', UserRole.patient, ma: _maA); +} + +String _token(String id, UserRole role, {String? ma, DateTime? now}) => + AlertRuntime.instance.auth.issueToken( + AuthenticatedUser( + id: id, + role: role, + microAreaId: ma, + deviceId: 'sem-aparelho', + ), + now: now, + ); + +/// Auditoria de mentira para o login de conferência: o que se prova é que a +/// senha devolvida uma vez abre o RF07, não a trilha do login (que tem os +/// próprios testes). +class _Audit extends AuditTrail { + @override + Future record(AuditEvent event) async {} +} + +Future _auditorias(Session s, String recurso, String resultado) async { + final linhas = await s.db.unsafeQuery( + 'SELECT count(*) FROM audit_logs WHERE "resourceType" = @t AND result = @r', + parameters: QueryParameters.named({'t': recurso, 'r': resultado}), + ); + return linhas.single[0] as int; +} + +/// Linhas do usuário numa das tabelas de token — no total, ou só as vigentes +/// (`revokedAt IS NULL`). É a única leitura possível: nenhum endpoint devolve +/// estas tabelas, e é por SQL que a prova da desativação se faz. +Future _tokens( + Session s, + String tabela, + String userId, { + bool soVigentes = false, +}) async { + final filtro = soVigentes ? ' AND "revokedAt" IS NULL' : ''; + final linhas = await s.db.unsafeQuery( + 'SELECT count(*) FROM $tabela WHERE "userId" = @id::uuid$filtro', + parameters: QueryParameters.named({'id': userId}), + ); + return linhas.single[0] as int; +} + +/// Base32 (RFC 4648, sem preenchimento) → bytes: o que o aplicativo +/// autenticador faz com o segredo devolvido por `beginTotpEnrollment`. +Uint8List _deBase32(String texto) { + const alfabeto = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + var bits = 0; + var valor = 0; + final saida = []; + for (final c in texto.split('')) { + valor = (valor << 5) | alfabeto.indexOf(c); + bits += 5; + if (bits >= 8) { + saida.add((valor >> (bits - 8)) & 0xff); + bits -= 8; + } + } + return Uint8List.fromList(saida); +} + +/// Login do ACS com a MFA **obrigatória** ligada e o cofre de verdade: o +/// endpoint usa `config.requireAcsMfa`, que é falso no ambiente de teste, e é +/// com a exigência ligada que "a próxima entrada pede ativação de novo" vira +/// uma recusa observável. +InstitutionalAuthService _servicoDeLogin(Session session) { + final store = OrmAcsCredentialStore(session: () => session); + return InstitutionalAuthService( + store: store, + hasher: AlertRuntimeHarness.hasher, + audit: _Audit(), + totpStore: store, + vault: HealthCipherTotpVault(AlertRuntime.instance.healthDataCipher), + requireMfa: true, + ); +} + +/// A linha de `user_credentials` do usuário, para as provas por SQL/ORM do +/// estado de bloqueio e das colunas `totp*`. +Future _credencialDo(Session s, String userId) async => + (await UserCredential.db.findFirstRow( + s, + where: (t) => t.userId.equals(UuidValue.fromString(userId)), + ))!; + +/// A linha de `staff_accounts` da conta, para as provas das colunas +/// `activationCode*`. +Future _staffDo(Session s, String staffId) async => + (await StaffAccount.db.findById(s, UuidValue.fromString(staffId)))!; + +/// Emite um código de ativação direto no store, como a CLI de operador fazia +/// (`bin/issue_staff_activation_code.dart`): é o emissor que a redefinição pela +/// tela substitui. +Future _emitirCodigo(Session s, String staffId, String codigo) => + OrmAcsCredentialStore(session: () => s, staff: true).issue( + staffId, + codeHash: StaffActivationCode.hash(codigo), + expiresAt: DateTime.now().toUtc().add(StaffActivationCode.defaultValidity), + issuedBy: 'CLI-de-operador', + at: DateTime.now().toUtc(), + ); + +/// Uma visita sintética bem formada do paciente da Microárea A. O lote nunca +/// chega a ser aplicado: com o token revogado, `syncDeferred` recusa na +/// resolução do token, antes de abrir a transação — se ele chegasse a rodar, a +/// exceção seria outra. +VisitSyncEntry _visita() => VisitSyncEntry( + localId: '00000000-0000-4000-8000-0000000000bd', + patientId: _paciente, + scheduledAt: _t, + completedAt: _t.add(const Duration(hours: 1)), + status: 'realizada', + riskLevelBefore: RiskLevel.green, + notes: const {'campo': 'sintético'}, + version: 0, + arrivalMethod: ArrivalMethod.manual, +); + +void main() { + withServerpod('Dado o endpoint de contas do backoffice (#43)', ( + sessionBuilder, + endpoints, + ) { + late Session session; + setUp(() async { + session = sessionBuilder.build(); + await _seed(session); + }); + + test( + 'admin lê as duas listagens e cada leitura vira read/success em audit_logs', + () async { + final t = _token(_admin, UserRole.admin); + final acs = await endpoints.admin.acs(sessionBuilder, accessToken: t); + expect(acs.map((a) => a.name), [ + 'Ana ACS Sintética', + 'Bruno ACS Sintético', + ]); + expect(acs.first.mfaActive, isTrue); + expect(acs.first.ubsName, 'UBS A'); + expect(acs.first.microAreaName, 'Microárea A'); + expect(acs.last.mfaActive, isFalse); + + final equipe = await endpoints.admin.staff( + sessionBuilder, + accessToken: t, + ); + expect(equipe.map((s) => s.enrollmentId), [ + 'ADM-EP-ACC-1', + 'COO-EP-ACC-1', + 'COO-EP-ACC-2', + ]); + expect(await _auditorias(session, 'admin_acs', 'success'), 1); + expect(await _auditorias(session, 'admin_staff', 'success'), 1); + }, + ); + + test('coordenador vê só os ACS da própria UBS e não lê a equipe', () async { + final t = _token(_coordA, UserRole.coordinator); + final acs = await endpoints.admin.acs(sessionBuilder, accessToken: t); + expect(acs.map((a) => a.name), ['Ana ACS Sintética']); + expect(acs.map((a) => a.ubsId).toSet(), {_ubsA}); + + await expectLater( + endpoints.admin.staff(sessionBuilder, accessToken: t), + throwsA(isA()), + ); + expect(await _auditorias(session, 'admin_acs', 'success'), 1); + expect(await _auditorias(session, 'admin_staff', 'denied'), 1); + }); + + test( + 'acs e patient com token válido recebem AlertPermissionException nas duas e a recusa é auditada', + () async { + for (final (id, role, ma) in [ + (_acsA, UserRole.acs, _maA), + (_paciente, UserRole.patient, _maA), + ]) { + final t = _token(id, role, ma: ma); + await expectLater( + endpoints.admin.acs(sessionBuilder, accessToken: t), + throwsA(isA()), + ); + await expectLater( + endpoints.admin.staff(sessionBuilder, accessToken: t), + throwsA(isA()), + ); + } + expect(await _auditorias(session, 'admin_acs', 'denied'), 2); + expect(await _auditorias(session, 'admin_staff', 'denied'), 2); + expect(await _auditorias(session, 'admin_acs', 'success'), 0); + }, + ); + + test('coordenador sem UBS é recusado nas duas listagens', () async { + final t = _token(_coordSemUbs, UserRole.coordinator); + await expectLater( + endpoints.admin.acs(sessionBuilder, accessToken: t), + throwsA(isA()), + ); + await expectLater( + endpoints.admin.staff(sessionBuilder, accessToken: t), + throwsA(isA()), + ); + expect(await _auditorias(session, 'admin_acs', 'denied'), 1); + expect(await _auditorias(session, 'admin_staff', 'denied'), 1); + }); + + test('token adulterado e token expirado são recusados e não auditam leitura', () async { + final bom = _token(_admin, UserRole.admin); + final expirado = _token(_admin, UserRole.admin, now: DateTime.utc(2020)); + for (final t in ['${bom}x', expirado, '', 'lixo']) { + await expectLater( + endpoints.admin.acs(sessionBuilder, accessToken: t), + throwsA(isA()), + ); + } + expect(await _auditorias(session, 'admin_acs', 'success'), 0); + expect(await _auditorias(session, 'admin_acs', 'denied'), 0); + }); + + test( + 'admin cadastra um ACS: a senha volta uma vez, abre o login e a trilha registra created', + () async { + final t = _token(_admin, UserRole.admin); + final r = await endpoints.admin.createAcs( + sessionBuilder, + accessToken: t, + name: 'Nova ACS Sintética', + enrollmentId: 'ACS-EP-ACC-9', + microAreaId: _maA, + ); + + expect(r.acs.enrollmentId, 'ACS-EP-ACC-9'); + expect(r.acs.ubsId, _ubsA, reason: 'a UBS vem da microárea'); + expect(r.acs.microAreaId, _maA); + expect(r.acs.active, isTrue); + expect(r.initialPassword, isNotEmpty); + + // O ACS novo aparece na listagem de quem o cadastrou. + final lista = await endpoints.admin.acs(sessionBuilder, accessToken: t); + expect(lista.map((a) => a.enrollmentId), contains('ACS-EP-ACC-9')); + expect( + jsonEncode(lista.map((a) => a.toJson()).toList()), + isNot(contains(r.initialPassword)), + reason: 'a senha não volta em nenhuma listagem', + ); + + // A senha devolvida uma única vez é a que abre o login institucional. + final login = await InstitutionalAuthService( + store: AlertRuntimeHarness.store(sessionBuilder.build()), + hasher: AlertRuntimeHarness.hasher, + audit: _Audit(), + ).login(matricula: 'ACS-EP-ACC-9', password: r.initialPassword); + expect(login.role, UserRole.acs); + expect(login.microAreaId, _maA); + + expect(await _auditorias(session, 'admin_acs', 'created'), 1); + expect(await _auditorias(session, 'admin_acs', 'denied'), 0); + }, + ); + + test('coordenador cadastra na própria UBS e não na de outra', () async { + final t = _token(_coordA, UserRole.coordinator); + final r = await endpoints.admin.createAcs( + sessionBuilder, + accessToken: t, + name: 'Nova ACS da UBS A', + enrollmentId: 'ACS-EP-ACC-8', + microAreaId: _maA, + ); + expect(r.acs.ubsId, _ubsA); + + // Microárea de outra UBS: a mesma resposta de "não existe". + await expectLater( + endpoints.admin.createAcs( + sessionBuilder, + accessToken: t, + name: 'Nova ACS da UBS B', + enrollmentId: 'ACS-EP-ACC-7', + microAreaId: _maB, + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Microárea não encontrada.', + ), + ), + ); + expect(await _auditorias(session, 'admin_acs', 'created'), 1); + expect(await _auditorias(session, 'admin_acs', 'denied'), 1); + }); + + test('acs e patient não cadastram: a recusa é auditada como escrita', () async { + for (final (id, role, ma) in [ + (_acsA, UserRole.acs, _maA), + (_paciente, UserRole.patient, _maA), + ]) { + final t = _token(id, role, ma: ma); + await expectLater( + endpoints.admin.createAcs( + sessionBuilder, + accessToken: t, + name: 'Nova ACS Sintética', + enrollmentId: 'ACS-EP-ACC-9', + microAreaId: _maA, + ), + throwsA(isA()), + ); + } + expect(await _auditorias(session, 'admin_acs', 'denied'), 2); + expect( + await User.db.findFirstRow( + session, + where: (u) => u.name.equals('Nova ACS Sintética'), + ), + isNull, + ); + }); + + test('a listagem não devolve CPF, hash de CPF nem senha', () async { + final t = _token(_admin, UserRole.admin); + final json = jsonEncode( + (await endpoints.admin.acs(sessionBuilder, accessToken: t)) + .map((a) => a.toJson()) + .toList(), + ); + expect(json, contains('Ana ACS Sintética')); + expect(json, contains('ACS-EP-ACC-1')); + expect(json, isNot(contains('cpf'))); + expect(json, isNot(contains('admin-ep-acc-$_acsA'))); + expect(json, isNot(contains('Hash'))); + }); + + test( + 'coordenador vincula um ACS da própria UBS a outra microárea dela e a trilha registra micro_area_changed', + () async { + final t = _token(_coordA, UserRole.coordinator); + final r = await endpoints.admin.setAcsMicroArea( + sessionBuilder, + accessToken: t, + acsId: _acsA, + microAreaId: _maA2, + ); + + expect(r.id, _acsA); + expect(r.microAreaId, _maA2); + expect(r.microAreaName, 'Microárea A2'); + expect(r.ubsId, _ubsA, reason: 'a UBS vem da microárea-alvo'); + + // A listagem do coordenador já reflete o vínculo novo. + final lista = await endpoints.admin.acs(sessionBuilder, accessToken: t); + expect( + lista.singleWhere((a) => a.id == _acsA).microAreaName, + 'Microárea A2', + ); + + expect(await _auditorias(session, 'admin_acs', 'micro_area_changed'), 1); + expect(await _auditorias(session, 'admin_acs', 'denied'), 0); + }, + ); + + test( + 'microárea de outra UBS e ACS de outra UBS: a mesma recusa de "não encontrado", auditada denied', + () async { + final t = _token(_coordA, UserRole.coordinator); + await expectLater( + endpoints.admin.setAcsMicroArea( + sessionBuilder, + accessToken: t, + acsId: _acsA, + microAreaId: _maB, + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Microárea não encontrada.', + ), + ), + ); + await expectLater( + endpoints.admin.setAcsMicroArea( + sessionBuilder, + accessToken: t, + acsId: _acsB, + microAreaId: _maA, + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'ACS não encontrado.', + ), + ), + ); + + expect(await _auditorias(session, 'admin_acs', 'denied'), 2); + expect(await _auditorias(session, 'admin_acs', 'micro_area_changed'), 0); + expect( + (await User.db.findById(session, UuidValue.fromString(_acsB)))!.microAreaId, + UuidValue.fromString(_maB), + reason: 'nada mudou para o ACS de fora do escopo', + ); + }, + ); + + test( + 'o vínculo novo vale no próximo refresh: o JWT renovado carrega a microárea nova', + () async { + // 1. Cadastro pela UBS A: o ACS nasce vinculado à Microárea A, e a senha + // mostrada uma única vez é a que abre o login (RF07). + final t = _token(_admin, UserRole.admin); + final r = await endpoints.admin.createAcs( + sessionBuilder, + accessToken: t, + name: 'Nova ACS Vínculo', + enrollmentId: 'ACS-EP-VINC-1', + microAreaId: _maA, + ); + const aparelho = 'aparelho-vinculo-1'; + final login = await InstitutionalAuthService( + store: AlertRuntimeHarness.store(sessionBuilder.build()), + hasher: AlertRuntimeHarness.hasher, + audit: _Audit(), + ).login( + matricula: 'ACS-EP-VINC-1', + password: r.initialPassword, + deviceId: aparelho, + ); + expect(login.microAreaId, _maA); + + // O JWT emitido no login carrega a microárea antiga — é ele que o app + // usa até o próximo refresh. + final antes = AlertRuntimeHarness.verify( + AlertRuntime.instance.auth.issueToken(login), + )!; + expect(antes.microAreaId, _maA); + + // 2. O refresh token daquele aparelho (o mesmo `deviceId` do login), + // emitido como `auth.loginInstitutional` o emite. + final refreshToken = await RefreshTokenService( + store: OrmRefreshTokenStore(session: () => session), + audit: _Audit(), + ).issue(login); + + // 3. O coordenador da UBS A move o ACS para a outra microárea da UBS. + await endpoints.admin.setAcsMicroArea( + sessionBuilder, + accessToken: _token(_coordA, UserRole.coordinator), + acsId: r.acs.id, + microAreaId: _maA2, + ); + + // 4. A renovação relê `users.microAreaId` do banco a cada rotação: o + // JWT novo já vale no território novo, sem novo login (INV-01). + final renovada = await endpoints.auth.refreshSession( + sessionBuilder, + refreshToken: refreshToken, + deviceId: aparelho, + ); + final depois = AlertRuntimeHarness.verify(renovada.accessToken)!; + expect(depois.id, r.acs.id); + expect( + depois.microAreaId, + _maA2, + reason: 'a microárea do JWT renovado é a relida do banco, não a do token antigo', + ); + expect( + renovada.refreshToken, + isNot(refreshToken), + reason: 'a rotação devolve um filho novo', + ); + }, + ); + + test( + 'desativar revoga refresh e envio diferido: refreshSession e syncDeferred são recusados depois', + () async { + const aparelho = 'aparelho-desativacao-1'; + await _usuario( + session, + _acsDesat, + 'Dora ACS Desativável', + UserRole.acs, + ma: _maA, + ); + await Acs.db.insertRow( + session, + Acs( + id: UuidValue.fromString(_acsDesat), + enrollmentId: _matriculaDesat, + ubsId: UuidValue.fromString(_ubsA), + active: true, + ), + ); + await _credencial( + session, + _acsDesat, + digest: await AlertRuntimeHarness.hasher.derive(_senha), + ); + + // 1. Login real pelo endpoint, com aparelho: emite a sessão (refresh) e + // o token do envio diferido, que até aqui sobrevivem a um ao outro. + final login = await endpoints.auth.loginInstitutional( + sessionBuilder, + matricula: _matriculaDesat, + password: _senha, + deviceId: aparelho, + ); + final refreshToken = login.refreshToken; + final uploadToken = login.uploadToken; + expect(refreshToken, isNotNull); + expect(uploadToken, isNotNull); + + // 2. A desativação pelo backoffice. + final desativado = await endpoints.admin.setAcsActive( + sessionBuilder, + accessToken: _token(_admin, UserRole.admin), + acsId: _acsDesat, + active: false, + ); + expect(desativado.active, isFalse); + + // 3. SQL: as duas tabelas do usuário ficaram sem NENHUMA linha vigente + // (e as linhas existem — senão a asserção passaria por vacuidade). + for (final tabela in ['acs_refresh_tokens', 'acs_upload_tokens']) { + expect(await _tokens(session, tabela, _acsDesat), greaterThan(0)); + expect( + await _tokens(session, tabela, _acsDesat, soVigentes: true), + 0, + reason: '$tabela: toda linha do usuário desativado está revogada', + ); + } + + // 4. A renovação é recusada — e a linha da trilha diz que a recusa veio + // da revogação, não do desvio pela conta inativa (`denied_inactive`). + await expectLater( + endpoints.auth.refreshSession( + sessionBuilder, + refreshToken: refreshToken!, + deviceId: aparelho, + ), + throwsA(isA()), + ); + expect(await _auditorias(session, 'session_refresh', 'denied_revoked'), 1); + expect(await _auditorias(session, 'session_refresh', 'denied_inactive'), 0); + + // 5. O envio diferido é recusado na resolução do token, antes do lote: + // é o que impede o aparelho de um ACS desativado de subir visitas com + // a autoria dele durante os 7 dias de validade do token. + await expectLater( + endpoints.visits.syncDeferred( + sessionBuilder, + uploadToken: uploadToken!, + deviceId: aparelho, + visits: [_visita()], + ), + throwsA(isA()), + ); + expect( + await Visit.db.findFirstRow( + session, + where: (v) => v.localId.equals( + UuidValue.fromString(_visita().localId), + ), + ), + isNull, + reason: 'nada foi gravado', + ); + + // 6. E o login por senha recusa com a mensagem da conta inativa. + await expectLater( + endpoints.auth.loginInstitutional( + sessionBuilder, + matricula: _matriculaDesat, + password: _senha, + deviceId: aparelho, + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Este acesso está inativo.', + ), + ), + ); + + // 7. Reativar devolve o acesso por um LOGIN NOVO: os tokens revogados + // não voltam à vida. + await endpoints.admin.setAcsActive( + sessionBuilder, + accessToken: _token(_admin, UserRole.admin), + acsId: _acsDesat, + active: true, + ); + final novoLogin = await endpoints.auth.loginInstitutional( + sessionBuilder, + matricula: _matriculaDesat, + password: _senha, + deviceId: aparelho, + ); + expect(novoLogin.accessToken, isNotEmpty); + expect(novoLogin.refreshToken, isNot(refreshToken)); + await expectLater( + endpoints.auth.refreshSession( + sessionBuilder, + refreshToken: refreshToken, + deviceId: aparelho, + ), + throwsA(isA()), + ); + + expect(await _auditorias(session, 'admin_acs', 'deactivated'), 1); + expect(await _auditorias(session, 'admin_acs', 'activated'), 1); + expect(await _auditorias(session, 'admin_acs', 'denied'), 0); + }, + ); + + test( + 'redefinir a senha devolve uma senha nova, grava o Argon2id novo e zera o bloqueio (failedAttempts, lockedUntil e lockStreak)', + () async { + // Fixture do caso: um ACS com senha de verdade — a fixture compartilhada + // só guarda hash sintético, e é a senha que abre o login. + await _usuario(session, _acsSenha, 'Sofia ACS Senha', UserRole.acs, ma: _maA); + await Acs.db.insertRow( + session, + Acs( + id: UuidValue.fromString(_acsSenha), + enrollmentId: _matriculaSenha, + ubsId: UuidValue.fromString(_ubsA), + active: true, + ), + ); + await _credencial( + session, + _acsSenha, + digest: await AlertRuntimeHarness.hasher.derive(_senha), + ); + + // 1. Conta bloqueada com duas rodadas de bloqueio no histórico + // (`lockStreak = 2`): nem a senha certa entra enquanto o bloqueio + // vale. + final antes = await _credencialDo(session, _acsSenha); + await UserCredential.db.updateRow( + session, + antes + ..failedAttempts = 5 + ..lockedUntil = DateTime.now().toUtc().add(const Duration(hours: 1)) + ..lockStreak = 2, + ); + await expectLater( + endpoints.auth.loginInstitutional( + sessionBuilder, + matricula: _matriculaSenha, + password: _senha, + ), + throwsA(isA()), + ); + + // 2. O coordenador da UBS A redefine a senha: a nova é sorteada pelo + // servidor, nunca escolhida pelo operador. + final r = await endpoints.admin.resetAcsPassword( + sessionBuilder, + accessToken: _token(_coordA, UserRole.coordinator), + acsId: _acsSenha, + ); + expect(r.newPassword, isNotEmpty); + expect(r.newPassword, isNot(_senha)); + + // 3. A linha está zerada: a credencial nova não herda o bloqueio da + // antiga (e o hash é o de outro Argon2id). + final depois = await _credencialDo(session, _acsSenha); + expect(depois.failedAttempts, 0); + expect(depois.lockedUntil, isNull); + expect( + depois.lockStreak, + 0, + reason: 'as rodadas de bloqueio da credencial que deixou de existir não sobrevivem', + ); + expect(depois.passwordHash, isNot(antes.passwordHash)); + + // 4. A senha nova abre o RF07; a antiga, não. + final login = await endpoints.auth.loginInstitutional( + sessionBuilder, + matricula: _matriculaSenha, + password: r.newPassword, + ); + expect(AlertRuntimeHarness.verify(login.accessToken)!.id, _acsSenha); + await expectLater( + endpoints.auth.loginInstitutional( + sessionBuilder, + matricula: _matriculaSenha, + password: _senha, + ), + throwsA(isA()), + ); + + // 5. As sessões em curso não são revogadas por uma redefinição de senha + // (decisão D8): o que a trilha registra é a troca da credencial. + expect(await _auditorias(session, 'admin_acs', 'password_reset'), 1); + expect(await _auditorias(session, 'admin_acs', 'denied'), 0); + }, + ); + + test( + 'redefinir a MFA zera as quatro colunas totp* e a próxima entrada pede ativação de novo', + () async { + // Fixture do caso: ACS com senha de verdade e MFA ativada pelo fluxo + // real (begin → confirm), como o app do ACS faz. + await _usuario(session, _acsMfa, 'Marta ACS MFA', UserRole.acs, ma: _maA); + await Acs.db.insertRow( + session, + Acs( + id: UuidValue.fromString(_acsMfa), + enrollmentId: _matriculaMfa, + ubsId: UuidValue.fromString(_ubsA), + active: true, + ), + ); + await _credencial( + session, + _acsMfa, + digest: await AlertRuntimeHarness.hasher.derive(_senha), + ); + + final inicio = await endpoints.auth.beginTotpEnrollment( + sessionBuilder, + matricula: _matriculaMfa, + password: _senha, + ); + await endpoints.auth.confirmTotpEnrollment( + sessionBuilder, + matricula: _matriculaMfa, + password: _senha, + code: Totp.code(_deBase32(inicio.secretBase32), DateTime.now().toUtc()), + ); + + // Com a MFA ativa, a senha sozinha já não entra. + final comMfa = _servicoDeLogin(session); + await expectLater( + comMfa.login(matricula: _matriculaMfa, password: _senha), + throwsA(isA()), + ); + final hashDaSenha = (await _credencialDo(session, _acsMfa)).passwordHash; + + // O coordenador da UBS A redefine a MFA. + await endpoints.admin.resetAcsMfa( + sessionBuilder, + accessToken: _token(_coordA, UserRole.coordinator), + acsId: _acsMfa, + ); + + // As QUATRO colunas voltam a NULL — inclusive `totpLastStep`, que + // deixaria um código do segredo antigo valendo como replay. + final linha = await _credencialDo(session, _acsMfa); + expect(linha.totpSecretEncrypted, isNull); + expect(linha.totpKeyVersion, isNull); + expect(linha.totpEnabledAt, isNull); + expect(linha.totpLastStep, isNull); + expect( + linha.passwordHash, + hashDaSenha, + reason: 'redefinir a MFA não toca no primeiro fator', + ); + + // A entrada seguinte pede a ativação de novo: é o caminho que a recusa + // "Peça a redefinição à coordenação" não tinha. + await expectLater( + comMfa.login(matricula: _matriculaMfa, password: _senha), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Ative a verificação em duas etapas antes de entrar.', + ), + ), + ); + final novo = await endpoints.auth.beginTotpEnrollment( + sessionBuilder, + matricula: _matriculaMfa, + password: _senha, + ); + expect( + novo.secretBase32, + isNot(inicio.secretBase32), + reason: 'o segredo novo não é o antigo (que não existe mais em lugar nenhum)', + ); + + final lista = await endpoints.admin.acs( + sessionBuilder, + accessToken: _token(_admin, UserRole.admin), + ); + expect(lista.singleWhere((a) => a.id == _acsMfa).mfaActive, isFalse); + expect(await _auditorias(session, 'admin_acs', 'mfa_reset'), 1); + }, + ); + + test('redefinir a MFA de um ACS sem MFA é idempotente e ainda audita mfa_reset', () async { + // `_acsB` tem credencial e nenhum segredo em `user_credentials`: a + // limpeza não acha nada a apagar e a operação conclui do mesmo jeito. + await _credencial(session, _acsB); + expect((await _credencialDo(session, _acsB)).totpSecretEncrypted, isNull); + + final t = _token(_admin, UserRole.admin); + for (var i = 0; i < 2; i++) { + await endpoints.admin.resetAcsMfa(sessionBuilder, accessToken: t, acsId: _acsB); + } + + final depois = await _credencialDo(session, _acsB); + expect(depois.totpSecretEncrypted, isNull); + expect(depois.totpEnabledAt, isNull); + expect( + await _auditorias(session, 'admin_acs', 'mfa_reset'), + 2, + reason: 'idempotente não é silencioso: cada pedido do operador vira uma linha', + ); + }); + + test( + 'redefinir a MFA do staff emite um código que ativa a MFA nova, invalida o antigo e abre o loginStaff', + () async { + // Fixture do caso: coordenador com senha de verdade e MFA ativada pelo + // fluxo real — com o código de uso único que a CLI emitia. + await _usuario( + session, + _coordMfa, + 'Rita Coordenadora MFA', + UserRole.coordinator, + ); + await StaffAccount.db.insertRow( + session, + StaffAccount( + id: UuidValue.fromString(_coordMfa), + enrollmentId: _matriculaCoordMfa, + active: true, + ), + ); + await _credencial( + session, + _coordMfa, + digest: await AlertRuntimeHarness.hasher.derive(_senha), + ); + + await _emitirCodigo(session, _coordMfa, _codigoDeAtivacao); + final inicio = await endpoints.auth.beginStaffTotpEnrollment( + sessionBuilder, + matricula: _matriculaCoordMfa, + password: _senha, + activationCode: _codigoDeAtivacao, + ); + await endpoints.auth.confirmStaffTotpEnrollment( + sessionBuilder, + matricula: _matriculaCoordMfa, + password: _senha, + activationCode: _codigoDeAtivacao, + code: Totp.code(_deBase32(inicio.secretBase32), DateTime.now().toUtc()), + ); + expect((await _credencialDo(session, _coordMfa)).totpEnabledAt, isNotNull); + + // Um código vigente emitido depois da ativação (como a CLI podia + // emitir): é ele que a redefinição pela tela tem de invalidar. + await _emitirCodigo(session, _coordMfa, _codigoVigente); + + // 1. O administrador redefine a MFA: o código novo existe **só** nesta + // resposta, no mesmo formato do da CLI e com 24 h de validade. + final reset = await endpoints.admin.resetStaffMfa( + sessionBuilder, + accessToken: _token(_admin, UserRole.admin), + staffId: _coordMfa, + ); + expect( + reset.activationCode, + matches(RegExp(r'^[A-Z2-7]{4}(-[A-Z2-7]{4}){5}-[A-Z2-7]{2}$')), + ); + expect(reset.activationCode, isNot(_codigoVigente)); + expect( + reset.activationCodeExpiresAt.isAfter( + DateTime.now().toUtc().add(const Duration(hours: 23)), + ), + isTrue, + reason: 'validade de 24 h contada do relógio do servidor', + ); + + // 2. Na linha: as quatro colunas `totp*` em NULL e o código gravado só + // como SHA-256 — o que a tela mostrou não existe no banco, e a + // trilha registra o pedido com o alvo (a emissão pela tela entra na + // cadeia, o que a CLI não conseguia). + final linha = await _credencialDo(session, _coordMfa); + expect(linha.totpSecretEncrypted, isNull); + expect(linha.totpKeyVersion, isNull); + expect(linha.totpEnabledAt, isNull); + expect(linha.totpLastStep, isNull); + final conta = await _staffDo(session, _coordMfa); + expect(conta.activationCodeHash, StaffActivationCode.hash(reset.activationCode)); + expect( + conta.activationCodeHash, + isNot(reset.activationCode), + reason: 'nem o hash é o código: a resposta devolve o valor sorteado', + ); + expect( + conta.activationCodeIssuedBy, + _admin, + reason: 'a coluna guarda o UUID do ator; quem ele é vem da linha de auditoria', + ); + expect(await _auditorias(session, 'admin_staff', 'mfa_reset'), 1); + + // 3. O código que estava vigente não vale mais... + await expectLater( + endpoints.auth.beginStaffTotpEnrollment( + sessionBuilder, + matricula: _matriculaCoordMfa, + password: _senha, + activationCode: _codigoVigente, + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Código de ativação inválido ou expirado.', + ), + ), + ); + + // 4. ... e o devolvido pela tela ativa a MFA nova, com segredo novo. + final novo = await endpoints.auth.beginStaffTotpEnrollment( + sessionBuilder, + matricula: _matriculaCoordMfa, + password: _senha, + activationCode: reset.activationCode, + ); + expect( + novo.secretBase32, + isNot(inicio.secretBase32), + reason: 'o segredo novo não é o antigo (que não existe mais em lugar nenhum)', + ); + await endpoints.auth.confirmStaffTotpEnrollment( + sessionBuilder, + matricula: _matriculaCoordMfa, + password: _senha, + activationCode: reset.activationCode, + code: Totp.code(_deBase32(novo.secretBase32), DateTime.now().toUtc()), + ); + expect((await _credencialDo(session, _coordMfa)).totpEnabledAt, isNotNull); + expect( + await OrmAcsCredentialStore( + session: () => session, + staff: true, + ).find(_coordMfa), + isNull, + reason: 'o código é de uso único: a confirmação o consome', + ); + + // 5. O login do staff com a MFA nova entra. O passo seguinte da janela + // é o código do login: o passo usado na confirmação já está gravado, + // e o mesmo código não valeria duas vezes (replay). + final login = await endpoints.auth.loginStaff( + sessionBuilder, + matricula: _matriculaCoordMfa, + password: _senha, + totpCode: Totp.code( + _deBase32(novo.secretBase32), + DateTime.now().toUtc().add(const Duration(seconds: 30)), + ), + ); + final autenticado = AlertRuntimeHarness.verify(login.accessToken)!; + expect(autenticado.id, _coordMfa); + expect(autenticado.role, UserRole.coordinator); + }, + ); + + test( + 'só o administrador redefine a MFA do staff: o coordenador é recusado, e ninguém redefine a própria', + () async { + // O coordenador, mesmo com o próprio token e o alvo sendo ele mesmo: a + // recusa é de papel, não de alvo. + for (final alvo in [_coordA, _coordMfa]) { + await expectLater( + endpoints.admin.resetStaffMfa( + sessionBuilder, + accessToken: _token(_coordA, UserRole.coordinator), + staffId: alvo, + ), + throwsA(isA()), + ); + } + + // O administrador não redefine a própria MFA: pede a outro. + await expectLater( + endpoints.admin.resetStaffMfa( + sessionBuilder, + accessToken: _token(_admin, UserRole.admin), + staffId: _admin, + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Uma conta não redefine a própria MFA: peça a outro administrador.', + ), + ), + ); + + // O mesmo pedido com o id em maiúsculas: `UuidValue` e o `::uuid` do + // Postgres normalizam a caixa, então a recusa da própria conta não pode + // depender da grafia — sem isso, o administrador redefiniria a própria + // MFA com uma letra maiúscula (e a trilha registraria o id torto). + await expectLater( + endpoints.admin.resetStaffMfa( + sessionBuilder, + accessToken: _token(_admin, UserRole.admin), + staffId: _admin.toUpperCase(), + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Uma conta não redefine a própria MFA: peça a outro administrador.', + ), + ), + ); + + // Conta inexistente: a mesma recusa de "não encontrada", sem emitir. + await expectLater( + endpoints.admin.resetStaffMfa( + sessionBuilder, + accessToken: _token(_admin, UserRole.admin), + staffId: '00000000-0000-4000-8000-0000000000ff', + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Conta de equipe não encontrada.', + ), + ), + ); + + expect( + await _auditorias(session, 'admin_staff', 'denied'), + 5, + reason: 'as cinco recusas entram na trilha antes da exceção', + ); + expect( + await _auditorias(session, 'admin_staff', 'mfa_reset'), + 0, + reason: 'nenhuma recusa vira sucesso na trilha', + ); + // Nada foi escrito nas contas que apareceram nas tentativas: o + // administrador não perde a MFA por ter pedido a si mesmo. + expect((await _staffDo(session, _admin)).activationCodeHash, isNull); + expect((await _staffDo(session, _coordA)).activationCodeHash, isNull); + }, + ); + }); +} diff --git a/backend/sinalacs_server/test/integration/admin_endpoint_audit_chain_test.dart b/backend/sinalacs_server/test/integration/admin_endpoint_audit_chain_test.dart new file mode 100644 index 00000000..eb2e3003 --- /dev/null +++ b/backend/sinalacs_server/test/integration/admin_endpoint_audit_chain_test.dart @@ -0,0 +1,338 @@ +import 'dart:convert'; + +import 'package:serverpod/serverpod.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/config/app_config.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_audit_trail.dart'; +import 'package:sinalacs_server/src/runtime/alert_runtime.dart'; +import 'package:test/test.dart'; + +import 'test_tools/serverpod_test_tools.dart'; + +/// `admin.verifyAuditChain` (#45, Task 6) pelo endpoint, com tokens reais e +/// Postgres real. As adulterações (`UPDATE`/`DELETE` direto em `audit_logs`) +/// só existem no banco de teste do harness, desfeito a cada caso. Dados +/// sintéticos; ids próprios. +const _ubsA = '00000000-0000-4000-8000-0000000046c1'; +const _ubsB = '00000000-0000-4000-8000-0000000046c2'; +const _maA = '00000000-0000-4000-8000-0000000046c3'; +const _maB = '00000000-0000-4000-8000-0000000046c4'; +const _acsA = '00000000-0000-4000-8000-0000000046c5'; +const _acsB = '00000000-0000-4000-8000-0000000046c6'; +const _admin = '00000000-0000-4000-8000-0000000046c7'; +const _coordA = '00000000-0000-4000-8000-0000000046c8'; +const _paciente = '00000000-0000-4000-8000-0000000046c9'; + +/// O segredo cuja ausência na resposta o teste afirma. +const _segredo = AppConfig.developmentAuditChainSecret; + +final _t = DateTime.utc(2026, 10, 13, 12); + +AppConfig _config() => AppConfig( + mqttBroker: 'localhost:1883', + jwtSecret: 'test-secret', + auditChainSecret: _segredo, + healthDataEncryptionKey: AppConfig.developmentHealthDataEncryptionKey, + cpfHashPepper: AppConfig.developmentCpfHashPepper, + smsGateway: 'log', + mqttUsername: null, + mqttPassword: null, + mqttUseTls: false, + mqttCaCertificatePath: null, + appEnv: 'development', + enableDevLogin: true, +); + +Future _usuario(Session s, String id, UserRole role, {String? ma}) => + User.db + .insertRow( + s, + User( + id: UuidValue.fromString(id), + cpfHash: 'audit-chain-ep-$id', + name: 'Sintético $id', + birthDate: DateTime.utc(1980), + role: role, + microAreaId: ma == null ? null : UuidValue.fromString(ma), + createdAt: _t, + updatedAt: _t, + ), + ) + .then((_) {}); + +Future _seed(Session s) async { + for (final (id, nome) in [(_ubsA, 'UBS A'), (_ubsB, 'UBS B')]) { + await Ubs.db.insertRow( + s, + Ubs( + id: UuidValue.fromString(id), + name: nome, + address: 'Endereço sintético', + city: 'São Paulo', + state: 'SP', + ), + ); + } + for (final (id, ubs) in [(_maA, _ubsA), (_maB, _ubsB)]) { + await MicroArea.db.insertRow( + s, + MicroArea( + id: UuidValue.fromString(id), + name: 'Microárea $id', + ubsId: UuidValue.fromString(ubs), + geoJsonBoundary: '{}', + ), + ); + } + for (final (id, ma, ubs, mat) in [ + (_acsA, _maA, _ubsA, 'ACS-CADEIA-A'), + (_acsB, _maB, _ubsB, 'ACS-CADEIA-B'), + ]) { + await _usuario(s, id, UserRole.acs, ma: ma); + await Acs.db.insertRow( + s, + Acs( + id: UuidValue.fromString(id), + enrollmentId: mat, + ubsId: UuidValue.fromString(ubs), + active: true, + ), + ); + } + await _usuario(s, _paciente, UserRole.patient, ma: _maA); + await _usuario(s, _admin, UserRole.admin); + await StaffAccount.db.insertRow( + s, + StaffAccount( + id: UuidValue.fromString(_admin), + enrollmentId: 'ADM-CADEIA-1', + active: true, + ), + ); + await _usuario(s, _coordA, UserRole.coordinator); + await StaffAccount.db.insertRow( + s, + StaffAccount( + id: UuidValue.fromString(_coordA), + enrollmentId: 'COO-CADEIA-A', + active: true, + ubsId: UuidValue.fromString(_ubsA), + ), + ); +} + +String _token(String id, UserRole role, {String? ma}) => + AlertRuntime.instance.auth.issueToken( + AuthenticatedUser( + id: id, + role: role, + microAreaId: ma, + deviceId: 'sem-aparelho', + ), + ); + +void main() { + withServerpod('Dado admin.verifyAuditChain (#45, Task 6)', ( + sessionBuilder, + endpoints, + ) { + late Session session; + + setUp(() async { + session = sessionBuilder.build(); + AlertRuntime.instance.overrideConfig(_config()); + AlertRuntime.instance.resetAuditChainStatusCache(); + await _seed(session); + }); + tearDown(() { + AlertRuntime.instance.overrideConfig(null); + AlertRuntime.instance.resetAuditChainStatusCache(); + }); + + /// Grava uma linha pela trilha de produção e devolve a `sequence` dela. + Future linha(String autor, String resourceType) async { + await OrmAuditTrail(session: () => session, chainSecret: _segredo).record( + AuditEvent( + userId: autor, + actionType: 'read', + resourceType: resourceType, + result: 'success', + ), + ); + final l = await AuditLog.db.findFirstRow( + session, + where: (t) => t.resourceType.equals(resourceType), + orderBy: (t) => t.sequence, + orderDescending: true, + ); + return l!.sequence; + } + + Future verificar(String id, UserRole role) { + // Cada chamada é uma verificação nova: o cache de 60 s é do processo. + AlertRuntime.instance.resetAuditChainStatusCache(); + return endpoints.admin.verifyAuditChain( + sessionBuilder, + accessToken: _token(id, role), + ); + } + + Future ultimaSequence() async => (await AuditLog.db.findFirstRow( + session, + orderBy: (t) => t.sequence, + orderDescending: true, + ))!.sequence; + + test( + 'admin ⇒ verified sobre a cadeia gravada por OrmAuditTrail; ' + 'auditado antes; resposta sem segredo nem hash', + () async { + for (var i = 0; i < 4; i++) { + await linha(_acsA, 'cadeia_$i'); + } + final s = await verificar(_admin, UserRole.admin); + final ultima = await ultimaSequence(); + + expect(s.state, AuditIntegrityState.verified); + // A linha `read` do próprio pedido entra antes de verificar. + expect(s.checkedRows, ultima); + expect(s.headSequence, ultima); + expect(s.firstBrokenSequence, isNull); + expect(s.reasonCode, isNull); + final auditada = await AuditLog.db.findFirstRow( + session, + where: (t) => t.resourceType.equals('admin_audit_chain'), + ); + expect(auditada!.actionType, 'read'); + expect(auditada.result, 'success'); + expect(auditada.sequence, ultima); + + final texto = jsonEncode(s.toJson()); + expect(texto, isNot(contains(_segredo))); + expect(texto, isNot(contains('entryHash'))); + expect(texto, isNot(contains('Hash'))); + final hashes = await AuditLog.db.find(session); + for (final h in hashes) { + expect(texto, isNot(contains(h.entryHash))); + } + }, + ); + + test( + 'UPDATE direto do result ⇒ broken/hashMismatch na linha alterada', + () async { + await linha(_acsA, 'cadeia_0'); + final alvo = await linha(_acsA, 'cadeia_1'); + await linha(_acsA, 'cadeia_2'); + await session.db.unsafeExecute( + 'UPDATE audit_logs SET result = \'x\' WHERE sequence = @s', + parameters: QueryParameters.named({'s': alvo}), + ); + + final s = await verificar(_admin, UserRole.admin); + expect(s.state, AuditIntegrityState.broken); + expect(s.reasonCode, AuditChainReason.hashMismatch); + expect(s.firstBrokenSequence, alvo); + expect(s.brokenInScope, isTrue); + expect(s.headSequence, isNull); + expect(jsonEncode(s.toJson()), isNot(contains(_segredo))); + }, + ); + + test('DELETE de uma linha do meio ⇒ broken/sequenceGap', () async { + await linha(_acsA, 'cadeia_0'); + final meio = await linha(_acsA, 'cadeia_1'); + await linha(_acsA, 'cadeia_2'); + await session.db.unsafeExecute( + 'DELETE FROM audit_logs WHERE sequence = @s', + parameters: QueryParameters.named({'s': meio}), + ); + + final s = await verificar(_admin, UserRole.admin); + expect(s.state, AuditIntegrityState.broken); + expect(s.reasonCode, AuditChainReason.sequenceGap); + expect(s.firstBrokenSequence, meio + 1); + }); + + test( + 'coordenador com linha quebrada de outra UBS ⇒ broken, ' + 'brokenInScope=false, sem sequence; na própria UBS ⇒ com sequence', + () async { + final daB = await linha(_acsB, 'cadeia_b'); + await linha(_acsA, 'cadeia_a'); + await session.db.unsafeExecute( + 'UPDATE audit_logs SET result = \'x\' WHERE sequence = @s', + parameters: QueryParameters.named({'s': daB}), + ); + + final s = await verificar(_coordA, UserRole.coordinator); + expect(s.state, AuditIntegrityState.broken); + expect(s.brokenInScope, isFalse); + expect(s.firstBrokenSequence, isNull); + expect(s.reasonCode, isNull); + expect(s.checkedRows, isNull); + expect(s.headSequence, isNull); + final texto = jsonEncode(s.toJson()); + expect(texto, isNot(contains('"$daB"'))); + expect(texto, isNot(contains(_segredo))); + + // A mesma quebra, agora numa linha de autor da UBS A. + await session.db.unsafeExecute( + 'UPDATE audit_logs SET result = \'success\' WHERE sequence = @s', + parameters: QueryParameters.named({'s': daB}), + ); + final daA = await linha(_acsA, 'cadeia_a2'); + await session.db.unsafeExecute( + 'UPDATE audit_logs SET result = \'x\' WHERE sequence = @s', + parameters: QueryParameters.named({'s': daA}), + ); + final noEscopo = await verificar(_coordA, UserRole.coordinator); + expect(noEscopo.state, AuditIntegrityState.broken); + expect(noEscopo.brokenInScope, isTrue); + expect(noEscopo.firstBrokenSequence, daA); + expect(noEscopo.reasonCode, AuditChainReason.hashMismatch); + }, + ); + + test('ACS e paciente ⇒ AlertPermissionException e denied', () async { + for (final (id, role, ma) in [ + (_acsA, UserRole.acs, _maA), + (_paciente, UserRole.patient, _maA), + ]) { + await expectLater( + endpoints.admin.verifyAuditChain( + sessionBuilder, + accessToken: _token(id, role, ma: ma), + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'acesso restrito ao backoffice', + ), + ), + ); + } + final linhas = await AuditLog.db.find( + session, + where: (t) => t.resourceType.equals('admin_audit_chain'), + orderBy: (t) => t.sequence, + ); + expect(linhas.map((l) => l.result), ['denied', 'denied']); + expect(linhas.map((l) => l.userId.uuid), [_acsA, _paciente]); + }); + + test('sem token válido ⇒ falha, sem linha nenhuma', () async { + final antes = await AuditLog.db.count(session); + for (final token in ['', 'nao-e-um-token']) { + await expectLater( + endpoints.admin.verifyAuditChain(sessionBuilder, accessToken: token), + throwsA(isA()), + ); + } + expect(await AuditLog.db.count(session), antes); + }); + }); +} diff --git a/backend/sinalacs_server/test/integration/admin_endpoint_audit_export_test.dart b/backend/sinalacs_server/test/integration/admin_endpoint_audit_export_test.dart new file mode 100644 index 00000000..e850ee1a --- /dev/null +++ b/backend/sinalacs_server/test/integration/admin_endpoint_audit_export_test.dart @@ -0,0 +1,703 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/admin/audit_export_service.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/application/audit/audit_csv.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/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/infrastructure/database/orm_audit_export_store.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_audit_trail.dart'; +import 'package:sinalacs_server/src/runtime/alert_runtime.dart'; +import 'package:test/test.dart'; + +import 'test_tools/serverpod_test_tools.dart'; + +/// `admin.exportAuditLogs` (#45, Task 10) pelo endpoint, com tokens reais e +/// Postgres real (banco do harness, desfeito a cada caso). Dados sintéticos; +/// ids próprios. +const _ubsA = '00000000-0000-4000-8000-0000000047a1'; +const _maA = '00000000-0000-4000-8000-0000000047a3'; +const _acsA = '00000000-0000-4000-8000-0000000047a5'; +const _admin = '00000000-0000-4000-8000-0000000047a7'; +const _coordA = '00000000-0000-4000-8000-0000000047a8'; +const _paciente = '00000000-0000-4000-8000-0000000047a9'; + +/// `resourceId` sintético de uma linha (rótulo esperado: `#B18F`). +const _recurso = '00000000-0000-4000-8000-00000000b18f'; + +/// Matrícula sintética do autor: não pode aparecer no log do processo. +const _matriculaAcs = 'ACS-EXPORTA-A'; + +const _segredo = AppConfig.developmentAuditChainSecret; + +final _t = DateTime.utc(2026, 10, 13, 12); + +AppConfig _config() => AppConfig( + mqttBroker: 'localhost:1883', + jwtSecret: 'test-secret', + auditChainSecret: _segredo, + healthDataEncryptionKey: AppConfig.developmentHealthDataEncryptionKey, + cpfHashPepper: AppConfig.developmentCpfHashPepper, + smsGateway: 'log', + mqttUsername: null, + mqttPassword: null, + mqttUseTls: false, + mqttCaCertificatePath: null, + appEnv: 'development', + enableDevLogin: true, +); + +Future _usuario(Session s, String id, UserRole role, {String? ma}) => User + .db + .insertRow( + s, + User( + id: UuidValue.fromString(id), + cpfHash: 'audit-export-ep-$id', + name: 'Sintético $id', + birthDate: DateTime.utc(1980), + role: role, + microAreaId: ma == null ? null : UuidValue.fromString(ma), + createdAt: _t, + updatedAt: _t, + ), + ) + .then((_) {}); + +Future _seed(Session s) async { + await Ubs.db.insertRow( + s, + Ubs( + id: UuidValue.fromString(_ubsA), + name: 'UBS A', + address: 'Endereço sintético', + city: 'São Paulo', + state: 'SP', + ), + ); + await MicroArea.db.insertRow( + s, + MicroArea( + id: UuidValue.fromString(_maA), + name: 'Microárea A', + ubsId: UuidValue.fromString(_ubsA), + geoJsonBoundary: '{}', + ), + ); + await _usuario(s, _acsA, UserRole.acs, ma: _maA); + await Acs.db.insertRow( + s, + Acs( + id: UuidValue.fromString(_acsA), + enrollmentId: _matriculaAcs, + ubsId: UuidValue.fromString(_ubsA), + active: true, + ), + ); + await _usuario(s, _paciente, UserRole.patient, ma: _maA); + await _usuario(s, _admin, UserRole.admin); + await StaffAccount.db.insertRow( + s, + StaffAccount( + id: UuidValue.fromString(_admin), + enrollmentId: 'ADM-EXPORTA-1', + active: true, + ), + ); + await _usuario(s, _coordA, UserRole.coordinator); + await StaffAccount.db.insertRow( + s, + StaffAccount( + id: UuidValue.fromString(_coordA), + enrollmentId: 'COO-EXPORTA-A', + active: true, + ubsId: UuidValue.fromString(_ubsA), + ), + ); +} + +String _token(String id, UserRole role, {String? ma}) => + AlertRuntime.instance.auth.issueToken( + AuthenticatedUser( + id: id, + role: role, + microAreaId: ma, + deviceId: 'sem-aparelho', + ), + ); + +/// Janela que cobre o "agora" das linhas gravadas pela trilha de produção. +AdminAuditFilter _janela({String? resourceType}) { + final agora = DateTime.now().toUtc(); + return AdminAuditFilter( + from: agora.subtract(const Duration(days: 1)), + to: agora.add(const Duration(days: 1)), + resourceType: resourceType, + ); +} + +/// Captura tudo o que o processo escreve em stdout/stderr (e `print`) +/// enquanto [corpo] roda. +class _Captura implements StreamConsumer> { + final texto = StringBuffer(); + + @override + Future addStream(Stream> stream) => + stream.forEach((b) => texto.write(utf8.decode(b, allowMalformed: true))); + + @override + Future close() async {} +} + +Future _capturandoLog(StringBuffer saida, Future Function() corpo) { + final out = _Captura(); + final err = _Captura(); + final fakeOut = IOSink(out); + final fakeErr = IOSink(err); + return IOOverrides.runZoned( + () => runZoned( + corpo, + zoneSpecification: ZoneSpecification( + print: (_, _, _, linha) => saida.writeln(linha), + ), + ), + stdout: () => _Stdout(fakeOut), + stderr: () => _Stdout(fakeErr), + ).whenComplete(() async { + await fakeOut.flush(); + await fakeErr.flush(); + saida + ..write(out.texto) + ..write(err.texto); + }); +} + +/// `Stdout` mínimo sobre um [IOSink] (o que `IOOverrides` exige). +class _Stdout implements Stdout { + _Stdout(this._sink); + final IOSink _sink; + + @override + void write(Object? object) => _sink.write(object); + @override + void writeln([Object? object = '']) => _sink.writeln(object); + @override + void writeAll(Iterable objects, [String sep = '']) => + _sink.writeAll(objects, sep); + @override + void writeCharCode(int charCode) => _sink.writeCharCode(charCode); + @override + void add(List data) => _sink.add(data); + @override + void addError(Object error, [StackTrace? stackTrace]) => + _sink.addError(error, stackTrace); + @override + Future addStream(Stream> stream) => _sink.addStream(stream); + @override + Future flush() => _sink.flush(); + @override + Future close() => _sink.close(); + @override + Future get done => _sink.done; + @override + Encoding get encoding => utf8; + @override + set encoding(Encoding e) {} + @override + bool get hasTerminal => false; + @override + IOSink get nonBlocking => _sink; + @override + bool get supportsAnsiEscapes => false; + @override + int get terminalColumns => 80; + @override + int get terminalLines => 24; + @override + String get lineTerminator => '\n'; + @override + set lineTerminator(String t) {} +} + +void main() { + withServerpod('Dado admin.exportAuditLogs (#45, Task 10)', ( + sessionBuilder, + endpoints, + ) { + late Session session; + + setUp(() async { + session = sessionBuilder.build(); + AlertRuntime.instance.overrideConfig(_config()); + AlertRuntime.instance.resetAuditChainStatusCache(); + await _seed(session); + }); + tearDown(() { + AlertRuntime.instance.overrideConfig(null); + AlertRuntime.instance.resetAuditChainStatusCache(); + }); + + Future linha( + String autor, + String resourceType, { + String? resourceId, + String result = 'success', + }) => OrmAuditTrail(session: () => session, chainSecret: _segredo).record( + AuditEvent( + userId: autor, + actionType: 'read', + resourceType: resourceType, + resourceId: resourceId, + result: result, + ), + ); + + Future head() async => + (await AuditLog.db.findFirstRow( + session, + orderBy: (t) => t.sequence, + orderDescending: true, + ))?.sequence ?? + 0; + + Future> exports() => AuditLog.db.find( + session, + where: (t) => t.actionType.equals('export'), + orderBy: (t) => t.sequence, + ); + + Future exportar(AdminAuditFilter filtro) => + endpoints.admin.exportAuditLogs( + sessionBuilder, + accessToken: _token(_admin, UserRole.admin), + filter: filtro, + ); + + test( + 'admin exporta o recorte: contagem, sequences e sha256 coerentes; o CSV ' + 'não contém o próprio export; 1 linha export ligada a 1 audit_exports; ' + 'cadeia continua verified; log sem trecho do CSV', + () async { + await linha(_acsA, 'patient_directory', resourceId: _recurso); + await linha(_paciente, 'triage_session', result: '=CMD()'); + await linha(_admin, 'admin_indicators'); + final headAntes = await head(); + + final log = StringBuffer(); + final r = await _capturandoLog(log, () async { + // Sentinelas: provam que a captura vê stdout, stderr e print. + stdout.writeln('sentinela-stdout'); + stderr.writeln('sentinela-stderr'); + // ignore: avoid_print + print('sentinela-print'); + return exportar(_janela()); + }); + expect( + log.toString(), + allOf( + contains('sentinela-stdout'), + contains('sentinela-stderr'), + contains('sentinela-print'), + ), + ); + + // Recorte = tudo até o head capturado antes. + expect(r.rowCount, headAntes); + expect(r.firstSequence, 1); + expect(r.lastSequence, headAntes); + expect( + r.contentSha256, + sha256.convert(utf8.encode(r.csv)).toString(), + ); + expect(r.chainState, AuditIntegrityState.verified); + expect(r.fileName, startsWith('auditoria_')); + expect(r.fileName, endsWith('.csv')); + expect(r.fileName, isNot(contains(_matriculaAcs))); + + final linhasCsv = r.csv.split('\r\n')..removeLast(); + expect(linhasCsv.first, AuditCsv.columns.join(',')); + expect(linhasCsv, hasLength(headAntes + 1)); + expect( + linhasCsv.skip(1).map((l) => int.parse(l.split(',').first)), + [for (var i = 1; i <= headAntes; i++) i], + ); + // Minimização (D6) e defesa de fórmula (Task 9). + expect( + r.csv, + contains( + ',$_matriculaAcs,acs,read,patient_directory,' + '#B18F,success,verified', + ), + ); + expect(r.csv, contains(',Paciente #47A9,patient,')); + expect(r.csv, contains(",'=CMD(),")); + for (final proibido in [_recurso, _acsA, _paciente, _admin, _segredo]) { + expect(r.csv, isNot(contains(proibido))); + } + + // Exatamente um evento export, depois do recorte, ligado ao registro. + final evs = await exports(); + expect(evs, hasLength(1)); + final ev = evs.single; + expect(ev.sequence, greaterThan(r.lastSequence!)); + expect(ev.sequence, headAntes + 1); + expect(ev.resourceType, 'admin_audit_logs'); + expect(ev.result, 'success'); + expect(ev.userId.uuid, _admin); + expect(r.csv, isNot(contains(',export,'))); + + final regs = await AuditExport.db.find(session); + expect(regs, hasLength(1)); + final reg = regs.single; + expect(ev.resourceId, reg.id); + expect(reg.requestedBy.uuid, _admin); + expect(reg.format, 'csv'); + expect(reg.rowCount, r.rowCount); + expect(reg.firstSequence, r.firstSequence); + expect(reg.lastSequence, r.lastSequence); + expect(reg.contentSha256, r.contentSha256); + expect(reg.chainState, 'verified'); + final filtros = jsonDecode(reg.filtersJson) as Map; + expect(filtros.keys.toSet(), {'from', 'to'}); + for (final l in linhasCsv.skip(1)) { + expect(reg.filtersJson, isNot(contains(l))); + } + expect(reg.filtersJson, isNot(contains(_matriculaAcs))); + + // A resposta não carrega hash nem segredo. + final todas = await AuditLog.db.find(session); + final resposta = jsonEncode(r.toJson()); + for (final l in todas) { + expect(resposta, isNot(contains(l.entryHash))); + expect(resposta, isNot(contains(l.ipHash))); + } + expect(resposta, isNot(contains(_segredo))); + + // A cadeia segue íntegra depois do apêndice transacional. + final v = await AuditChainVerifier( + reader: OrmAuditChainReader(session: () => session), + secret: _segredo, + ).verify(); + expect(v.ok, isTrue); + expect(v.checked, headAntes + 1); + + // Nenhum trecho do CSV no log do processo. + final texto = log.toString(); + expect(texto, isNot(contains(_matriculaAcs))); + expect(texto, isNot(contains(AuditCsv.columns.join(',')))); + expect(texto, isNot(contains('#B18F'))); + expect(texto, isNot(contains(r.contentSha256))); + }, + ); + + test( + 'exportar duas vezes ⇒ +1 export por chamada; o 2º CSV contém o 1º ' + 'export (esperado) e não o próprio', + () async { + await linha(_acsA, 'patient_directory'); + final primeiro = await exportar(_janela()); + expect((await exports()), hasLength(1)); + final segundo = await exportar(_janela()); + final evs = await exports(); + expect(evs, hasLength(2)); + expect(await AuditExport.db.count(session), 2); + + expect(primeiro.csv, isNot(contains(',export,'))); + // O 2º recorte vai até a linha do 1º export, e só ela é export. + expect(segundo.lastSequence, evs.first.sequence); + expect(RegExp(',export,').allMatches(segundo.csv), hasLength(1)); + expect(evs.last.sequence, greaterThan(segundo.lastSequence!)); + final regs = await AuditExport.db.find( + session, + orderBy: (t) => t.requestedAt, + ); + expect( + evs.map((e) => e.resourceId).toSet(), + regs.map((r) => r.id).toSet(), + ); + final v = await AuditChainVerifier( + reader: OrmAuditChainReader(session: () => session), + secret: _segredo, + ).verify(); + expect(v.ok, isTrue); + }, + ); + + test( + 'filtro restringe o recorte (resourceType) e continua sem o export', + () async { + await linha(_acsA, 'patient_directory'); + await linha(_acsA, 'triage_session'); + await linha(_acsA, 'patient_directory'); + final r = await exportar(_janela(resourceType: 'patient_directory')); + expect(r.rowCount, 2); + expect(r.firstSequence, 1); + expect(r.lastSequence, 3); + expect(r.csv, isNot(contains('triage_session'))); + }, + ); + + Future verificarECachear() async { + final s = await endpoints.admin.verifyAuditChain( + sessionBuilder, + accessToken: _token(_admin, UserRole.admin), + ); + expect(s.state, AuditIntegrityState.verified); + } + + test( + 'cache verified + UPDATE direto numa linha + exportar dentro dos 60 s ' + '⇒ chainState gravado e devolvido é broken (verificação fresca)', + () async { + await linha(_acsA, 'patient_directory'); + await linha(_acsA, 'triage_session'); + await linha(_acsA, 'patient_directory'); + await verificarECachear(); + await session.db.unsafeExecute( + "UPDATE audit_logs SET result = 'x' WHERE sequence = 2", + ); + + final r = await exportar(_janela()); + expect(r.chainState, AuditIntegrityState.broken); + final reg = (await AuditExport.db.find(session)).single; + expect(reg.chainState, 'broken'); + }, + ); + + test( + 'cache verified (com checkpoint) + cauda apagada + exportar dentro dos ' + '60 s ⇒ chainState broken (tailTruncated), nunca verified', + () async { + for (var i = 0; i < 4; i++) { + await linha(_acsA, 'patient_directory'); + } + // A verificação grava o checkpoint na cabeça (a linha `read` dela). + await verificarECachear(); + final cabeca = (await AuditLog.db.findFirstRow( + session, + orderBy: (t) => t.sequence, + orderDescending: true, + ))!.sequence; + await session.db.unsafeExecute( + 'DELETE FROM audit_logs WHERE sequence >= @s', + parameters: QueryParameters.named({'s': cabeca - 1}), + ); + + final r = await exportar(_janela()); + expect(r.chainState, AuditIntegrityState.broken); + expect( + (await AuditExport.db.find(session)).single.chainState, + 'broken', + ); + final status = await endpoints.admin.verifyAuditChain( + sessionBuilder, + accessToken: _token(_admin, UserRole.admin), + ); + expect(status.reasonCode, AuditChainReason.tailTruncated); + }, + ); + + test( + 'coordenador, ACS e paciente ⇒ AlertPermissionException, denied ' + 'auditado, nada exportado', + () async { + await linha(_acsA, 'patient_directory'); + for (final (id, role, ma) in [ + (_coordA, UserRole.coordinator, null), + (_acsA, UserRole.acs, _maA), + (_paciente, UserRole.patient, _maA), + ]) { + await expectLater( + endpoints.admin.exportAuditLogs( + sessionBuilder, + accessToken: _token(id, role, ma: ma), + filter: _janela(), + ), + throwsA(isA()), + ); + } + final negadas = await AuditLog.db.find( + session, + where: (t) => t.resourceType.equals('admin_audit_export'), + orderBy: (t) => t.sequence, + ); + expect(negadas.map((l) => l.result), ['denied', 'denied', 'denied']); + expect(negadas.map((l) => l.actionType).toSet(), {'export'}); + expect(await AuditExport.db.count(session), 0); + expect( + await AuditLog.db.count( + session, + where: (t) => + t.result.equals('success') & t.actionType.equals('export'), + ), + 0, + ); + }, + ); + + test('sem período (ou só from) ⇒ AdminInvalidRequestException, sem ' + 'nenhuma linha nova', () async { + await linha(_acsA, 'patient_directory'); + final antes = await AuditLog.db.count(session); + for (final f in [ + AdminAuditFilter(), + AdminAuditFilter(from: DateTime.now().toUtc()), + AdminAuditFilter(to: DateTime.now().toUtc()), + ]) { + await expectLater( + exportar(f), + throwsA(isA()), + ); + } + expect(await AuditLog.db.count(session), antes); + expect(await AuditExport.db.count(session), 0); + }); + + test( + 'recorte acima de maxRows ⇒ refused_limit e AdminInvalidRequestException; ' + 'nenhuma exportação', + () async { + await linha(_acsA, 'patient_directory'); + // Linhas sintéticas em massa (hash fictício): só a contagem importa. + await session.db.unsafeExecute( + ''' + INSERT INTO audit_logs ("userId", "actionType", "resourceType", + "timestamp", "ipHash", result, sequence, "previousHash", "entryHash") + SELECT @u::uuid, 'read', 'massa_sintetica', now() at time zone 'utc', + 'ip', 'success', 1 + g, 'p', 'e' + FROM generate_series(1, @n) g + ''', + parameters: QueryParameters.named({ + 'u': _acsA, + 'n': AuditExportService.maxRows, + }), + ); + await expectLater( + exportar(_janela()), + throwsA(isA()), + ); + final recusa = await AuditLog.db.findFirstRow( + session, + where: (t) => t.resourceType.equals('admin_audit_export'), + ); + expect(recusa!.result, 'refused_limit'); + expect(recusa.actionType, 'export'); + expect(await AuditExport.db.count(session), 0); + expect((await exports()).where((e) => e.result == 'success'), isEmpty); + }, + ); + + test( + 'store: head, contagem limitada a cap e leitura presa a sequence <= head ' + '(linhas gravadas depois do head ficam fora: concorrência)', + () async { + for (var i = 0; i < 5; i++) { + await linha(_acsA, 'patient_directory'); + } + final store = OrmAuditExportStore( + session: () => session, + appendAudit: OrmAuditTrail( + session: () => session, + chainSecret: _segredo, + ).recordInTransaction, + ); + expect(await store.headSequence(), 5); + final f = _janela(); + expect(await store.countUpTo(f, headSequence: 3, cap: 100), 3); + expect(await store.countUpTo(f, headSequence: 5, cap: 2), 2); + final lidas = await store.readUpTo(f, headSequence: 3, limit: 100); + expect(lidas.map((l) => l.entry.sequence), [1, 2, 3]); + expect(lidas.first.actorRole, 'acs'); + expect(lidas.first.actorEnrollmentId, _matriculaAcs); + final limitadas = await store.readUpTo(f, headSequence: 5, limit: 2); + // Limite com head: as `limit` mais recentes até o head, em ordem + // crescente (o serviço só lê depois de a contagem caber no teto). + expect(limitadas.map((l) => l.entry.sequence), [4, 5]); + }, + ); + + test( + 'falha do apêndice dentro da transação ⇒ rollback: nada em ' + 'audit_exports, nenhum export success, nenhum CSV', + () async { + await linha(_acsA, 'patient_directory'); + final servico = AuditExportService( + store: OrmAuditExportStore( + session: () => session, + appendAudit: (s, tx, e) async { + // Grava a linha (a transação já tem o INSERT de audit_exports) + // e então falha: os dois têm de sumir. + await OrmAuditTrail( + session: () => s, + chainSecret: _segredo, + ).recordInTransaction(s, tx, e); + throw StateError('falha simulada depois do apêndice'); + }, + ), + audit: OrmAuditTrail(session: () => session, chainSecret: _segredo), + auditChain: AuditChain(secret: _segredo), + chainState: (_) async => AuditIntegrityState.verified, + ); + await expectLater( + servico.export( + AuthenticatedUser( + id: _admin, + role: UserRole.admin, + microAreaId: null, + deviceId: 'sem-aparelho', + ), + _janela(), + ), + throwsA(isA()), + ); + expect(await AuditExport.db.count(session), 0); + final evs = await exports(); + expect(evs.where((e) => e.result == 'success'), isEmpty); + final falhas = await AuditLog.db.find( + session, + where: (t) => t.resourceType.equals('admin_audit_export'), + ); + expect(falhas.map((l) => l.result), ['failed']); + final v = await AuditChainVerifier( + reader: OrmAuditChainReader(session: () => session), + secret: _segredo, + ).verify(); + expect(v.ok, isTrue); + }, + ); + + test( + 'resposta perdida depois do commit (Review Focus 8): o evento export e ' + 'o registro ficam; nada a desfazer — aceito, erra para auditar a mais', + () async { + await linha(_acsA, 'patient_directory'); + // Simula a conexão caindo DEPOIS do commit: o servidor concluiu, o + // cliente nunca recebe o arquivo. + Object? perdida; + try { + await exportar(_janela()); + throw const SocketException('resposta perdida (simulada)'); + } on SocketException catch (e) { + perdida = e; + } + expect(perdida, isNotNull); + final evs = await exports(); + expect(evs, hasLength(1)); + expect(evs.single.result, 'success'); + final reg = await AuditExport.db.find(session); + expect(reg, hasLength(1)); + expect(evs.single.resourceId, reg.single.id); + // Nenhum mecanismo desfaz: o registro diz "exportado", e o operador + // pede de novo (outro export, outra linha). + }, + ); + }); +} diff --git a/backend/sinalacs_server/test/integration/admin_endpoint_escalation_test.dart b/backend/sinalacs_server/test/integration/admin_endpoint_escalation_test.dart new file mode 100644 index 00000000..36bb26df --- /dev/null +++ b/backend/sinalacs_server/test/integration/admin_endpoint_escalation_test.dart @@ -0,0 +1,302 @@ +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/audit/audit_chain_verifier.dart'; +import 'package:sinalacs_server/src/application/auth/development_auth_service.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 '../support/health_data_fixtures.dart'; +import 'test_tools/serverpod_test_tools.dart'; + +/// `admin.escalateAlert` (#44) pelo endpoint, com tokens reais e Postgres real: +/// papel, escopo por UBS, auditoria, cadeia de hash e resposta sem identidade. +/// Dados sintéticos; ids próprios. +const _ubsA = '00000000-0000-4000-8000-0000000044d1'; +const _ubsB = '00000000-0000-4000-8000-0000000044d2'; +const _maA = '00000000-0000-4000-8000-0000000044d3'; +const _maB = '00000000-0000-4000-8000-0000000044d4'; +const _acs = '00000000-0000-4000-8000-0000000044d5'; +const _admin = '00000000-0000-4000-8000-0000000044d6'; +const _coordA = '00000000-0000-4000-8000-0000000044d7'; +const _coordB = '00000000-0000-4000-8000-0000000044d8'; +const _paciente = '00000000-0000-4000-8000-0000000044d9'; +const _segredo = 'test-audit-chain-secret'; + +final _t = DateTime.utc(2026, 10, 11, 12); + +AppConfig _config() => AppConfig( + mqttBroker: 'localhost:1883', + jwtSecret: 'test-secret', + auditChainSecret: _segredo, + healthDataEncryptionKey: AppConfig.developmentHealthDataEncryptionKey, + cpfHashPepper: AppConfig.developmentCpfHashPepper, + smsGateway: 'log', + mqttUsername: null, + mqttPassword: null, + mqttUseTls: false, + mqttCaCertificatePath: null, + appEnv: 'development', + enableDevLogin: true, +); + +Future _usuario(Session s, String id, UserRole role, {String? ma}) => User + .db + .insertRow( + s, + User( + id: UuidValue.fromString(id), + cpfHash: 'admin-esc-$id', + name: 'Sintético $id', + birthDate: DateTime.utc(1980), + role: role, + microAreaId: ma == null ? null : UuidValue.fromString(ma), + createdAt: _t, + updatedAt: _t, + ), + ) + .then((_) {}); + +Future _seed(Session s) async { + for (final (id, nome) in [(_ubsA, 'UBS A'), (_ubsB, 'UBS B')]) { + await Ubs.db.insertRow( + s, + Ubs( + id: UuidValue.fromString(id), + name: nome, + address: 'Endereço sintético', + city: 'São Paulo', + state: 'SP', + ), + ); + } + for (final (id, ubs) in [(_maA, _ubsA), (_maB, _ubsB)]) { + await MicroArea.db.insertRow( + s, + MicroArea( + id: UuidValue.fromString(id), + name: 'Microárea $id', + ubsId: UuidValue.fromString(ubs), + geoJsonBoundary: '{}', + ), + ); + } + await _usuario(s, _paciente, UserRole.patient, ma: _maA); + await Patient.db.insertRow( + s, + await encryptedPatient( + id: _paciente, + emergencyContact: 'contato sintético', + isChronic: false, + ), + ); + await _usuario(s, _acs, UserRole.acs, ma: _maA); + await _usuario(s, _admin, UserRole.admin); + await StaffAccount.db.insertRow( + s, + StaffAccount( + id: UuidValue.fromString(_admin), + enrollmentId: 'ADM-ESC-1', + active: true, + ), + ); + for (final (id, mat, ubs) in [ + (_coordA, 'COO-ESC-A', _ubsA), + (_coordB, 'COO-ESC-B', _ubsB), + ]) { + await _usuario(s, id, UserRole.coordinator); + await StaffAccount.db.insertRow( + s, + StaffAccount( + id: UuidValue.fromString(id), + enrollmentId: mat, + active: true, + ubsId: UuidValue.fromString(ubs), + ), + ); + } +} + +/// Alerta vermelho pendente, disparado bem antes do prazo padrão. +Future _alerta(Session s, String ma) => Alert.db.insertRow( + s, + Alert( + patientId: UuidValue.fromString(_paciente), + microAreaId: UuidValue.fromString(ma), + triggeredAt: DateTime.now().toUtc().subtract(const Duration(hours: 1)), + riskLevel: RiskLevel.red, + locationHash: 'hash-sintetico', + status: AlertStatus.pending, + mqttTopic: 'sinalacs/v1/microareas/$ma/alerts', + deviceId: 'device-sintetico', + retryCount: 0, + version: 1, + ), +); + +String _token(String id, UserRole role, {String? ma}) => + AlertRuntime.instance.auth.issueToken( + AuthenticatedUser( + id: id, + role: role, + microAreaId: ma, + deviceId: 'sem-aparelho', + ), + ); + +Future> _linhas(Session s, String resourceType) => + AuditLog.db.find( + s, + where: (t) => t.resourceType.equals(resourceType), + orderBy: (t) => t.sequence, + ); + +void main() { + withServerpod('Dado admin.escalateAlert (#44)', (sessionBuilder, endpoints) { + late Session session; + + setUp(() async { + session = sessionBuilder.build(); + AlertRuntime.instance.overrideConfig(_config()); + await _seed(session); + }); + tearDown(() => AlertRuntime.instance.overrideConfig(null)); + + test( + 'coordenador da UBS A escalona: linha escalated:no_ack_timeout, cadeia válida, resposta sem identidade', + () async { + final a = await _alerta(session, _maA); + final r = await endpoints.admin.escalateAlert( + sessionBuilder, + accessToken: _token(_coordA, UserRole.coordinator), + alertId: a.id!.uuid, + reason: EscalationReason.noAckTimeout, + ); + expect(r.alertId, a.id!.uuid); + expect(r.alreadyEscalated, isFalse); + final json = r.toJson()..remove('__className__'); + expect(json.keys.toSet(), { + 'alertId', + 'escalatedAt', + 'alreadyEscalated', + }); + expect(json.toString(), isNot(contains(_paciente))); + + final lido = (await Alert.db.findById(session, a.id!))!; + expect(lido.escalatedAt, r.escalatedAt); + expect(lido.escalatedBy, UuidValue.fromString(_coordA)); + expect(lido.status, AlertStatus.pending); + expect( + await AlertOutboxEntry.db.count( + session, + where: (t) => t.alertId.equals(a.id!), + ), + 1, + ); + + final linhas = await _linhas(session, 'admin_alert'); + expect(linhas, hasLength(1)); + expect(linhas.single.result, 'escalated:no_ack_timeout'); + expect(linhas.single.actionType, 'write'); + expect(linhas.single.resourceId, a.id); + expect(linhas.single.userId, UuidValue.fromString(_coordA)); + + // Repetir: idempotente, mesmo carimbo, segunda linha de auditoria. + final deNovo = await endpoints.admin.escalateAlert( + sessionBuilder, + accessToken: _token(_coordA, UserRole.coordinator), + alertId: a.id!.uuid, + reason: EscalationReason.patientRecontact, + ); + expect(deNovo.alreadyEscalated, isTrue); + expect(deNovo.escalatedAt, r.escalatedAt); + expect( + (await _linhas(session, 'admin_alert')).map((l) => l.result), + ['escalated:no_ack_timeout', 'already_escalated:patient_recontact'], + ); + + final verifier = AuditChainVerifier( + reader: OrmAuditChainReader(session: () => session), + secret: _segredo, + ); + expect((await verifier.verify()).ok, isTrue); + }, + ); + + test( + 'coordenador da UBS B: "Alerta não encontrado." e nenhuma linha de sucesso', + () async { + final a = await _alerta(session, _maA); + await expectLater( + endpoints.admin.escalateAlert( + sessionBuilder, + accessToken: _token(_coordB, UserRole.coordinator), + alertId: a.id!.uuid, + reason: EscalationReason.noAckTimeout, + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Alerta não encontrado.', + ), + ), + ); + final linhas = await _linhas(session, 'admin_alert'); + expect(linhas.map((l) => l.result), ['denied_not_found']); + expect((await Alert.db.findById(session, a.id!))!.escalatedAt, isNull); + expect( + await AlertOutboxEntry.db.count( + session, + where: (t) => t.alertId.equals(a.id!), + ), + 0, + ); + }, + ); + + test('ACS e paciente: acesso restrito e recusa auditada', () async { + final a = await _alerta(session, _maA); + for (final (id, role) in [ + (_acs, UserRole.acs), + (_paciente, UserRole.patient), + ]) { + await expectLater( + endpoints.admin.escalateAlert( + sessionBuilder, + accessToken: _token(id, role, ma: _maA), + alertId: a.id!.uuid, + reason: EscalationReason.noAckTimeout, + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'acesso restrito ao backoffice', + ), + ), + ); + } + final linhas = await _linhas(session, 'admin_alert'); + expect(linhas.map((l) => l.result), ['denied', 'denied']); + expect(linhas.map((l) => l.actionType), ['write', 'write']); + expect((await Alert.db.findById(session, a.id!))!.escalatedAt, isNull); + }); + + test('administrador escalona alerta de qualquer UBS', () async { + final b = await _alerta(session, _maB); + final r = await endpoints.admin.escalateAlert( + sessionBuilder, + accessToken: _token(_admin, UserRole.admin), + alertId: b.id!.uuid, + reason: EscalationReason.acsUnreachable, + ); + expect(r.alreadyEscalated, isFalse); + expect( + (await _linhas(session, 'admin_alert')).single.result, + 'escalated:acs_unreachable', + ); + }); + }); +} diff --git a/backend/sinalacs_server/test/integration/admin_endpoint_monitoring_test.dart b/backend/sinalacs_server/test/integration/admin_endpoint_monitoring_test.dart new file mode 100644 index 00000000..2763e8ac --- /dev/null +++ b/backend/sinalacs_server/test/integration/admin_endpoint_monitoring_test.dart @@ -0,0 +1,420 @@ +import 'dart:convert'; + +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/audit/audit_chain_verifier.dart'; +import 'package:sinalacs_server/src/application/auth/development_auth_service.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 '../support/health_data_fixtures.dart'; +import 'test_tools/serverpod_test_tools.dart'; + +/// `admin.alertsPulse` e `admin.endMonitoringSession` (#44, D5) pelo endpoint, +/// com tokens reais e Postgres real: o pulso não grava `read`, a sessão abre e +/// fecha uma vez, recusas auditadas, escopo por UBS, resposta sem identidade e +/// cadeia de auditoria íntegra. Dados sintéticos; ids próprios. +const _ubsA = '00000000-0000-4000-8000-0000000046b1'; +const _ubsB = '00000000-0000-4000-8000-0000000046b2'; +const _maA = '00000000-0000-4000-8000-0000000046b3'; +const _maB = '00000000-0000-4000-8000-0000000046b4'; +const _acs = '00000000-0000-4000-8000-0000000046b5'; +const _admin = '00000000-0000-4000-8000-0000000046b6'; +const _coordA = '00000000-0000-4000-8000-0000000046b7'; +const _coordB = '00000000-0000-4000-8000-0000000046b8'; +const _coordSemUbs = '00000000-0000-4000-8000-0000000046b9'; +const _paciente = '00000000-0000-4000-8000-0000000046ba'; +const _pacienteNome = 'Joana Sintética Pulso'; +const _segredo = 'test-audit-chain-secret'; + +final _t = DateTime.utc(2026, 10, 11, 12); + +AppConfig _config() => AppConfig( + mqttBroker: 'localhost:1883', + jwtSecret: 'test-secret', + auditChainSecret: _segredo, + healthDataEncryptionKey: AppConfig.developmentHealthDataEncryptionKey, + cpfHashPepper: AppConfig.developmentCpfHashPepper, + smsGateway: 'log', + mqttUsername: null, + mqttPassword: null, + mqttUseTls: false, + mqttCaCertificatePath: null, + appEnv: 'development', + enableDevLogin: true, +); + +Future _usuario( + Session s, + String id, + UserRole role, { + String? ma, + String? nome, +}) => User.db + .insertRow( + s, + User( + id: UuidValue.fromString(id), + cpfHash: 'admin-pulse-ep-$id', + name: nome ?? 'Sintético $id', + birthDate: DateTime.utc(1980), + role: role, + microAreaId: ma == null ? null : UuidValue.fromString(ma), + createdAt: _t, + updatedAt: _t, + ), + ) + .then((_) {}); + +Future _seed(Session s) async { + for (final (id, nome) in [(_ubsA, 'UBS A'), (_ubsB, 'UBS B')]) { + await Ubs.db.insertRow( + s, + Ubs( + id: UuidValue.fromString(id), + name: nome, + address: 'Endereço sintético', + city: 'São Paulo', + state: 'SP', + ), + ); + } + for (final (id, ubs) in [(_maA, _ubsA), (_maB, _ubsB)]) { + await MicroArea.db.insertRow( + s, + MicroArea( + id: UuidValue.fromString(id), + name: 'Microárea $id', + ubsId: UuidValue.fromString(ubs), + geoJsonBoundary: '{}', + ), + ); + } + await _usuario(s, _paciente, UserRole.patient, ma: _maA, nome: _pacienteNome); + await Patient.db.insertRow( + s, + await encryptedPatient( + id: _paciente, + emergencyContact: 'contato sintético', + isChronic: false, + ), + ); + await _usuario(s, _acs, UserRole.acs, ma: _maA); + await Acs.db.insertRow( + s, + Acs( + id: UuidValue.fromString(_acs), + enrollmentId: 'ACS-PULSO-1', + ubsId: UuidValue.fromString(_ubsA), + active: true, + ), + ); + await _usuario(s, _admin, UserRole.admin); + await StaffAccount.db.insertRow( + s, + StaffAccount( + id: UuidValue.fromString(_admin), + enrollmentId: 'ADM-PULSO-1', + active: true, + ), + ); + for (final (id, mat, ubs) in [ + (_coordA, 'COO-PULSO-A', _ubsA), + (_coordB, 'COO-PULSO-B', _ubsB), + (_coordSemUbs, 'COO-PULSO-X', null), + ]) { + await _usuario(s, id, UserRole.coordinator); + await StaffAccount.db.insertRow( + s, + StaffAccount( + id: UuidValue.fromString(id), + enrollmentId: mat, + active: true, + ubsId: ubs == null ? null : UuidValue.fromString(ubs), + ), + ); + } +} + +/// Alerta vermelho pendente, disparado bem antes do prazo padrão (vencido). +Future _alerta( + Session s, + String ma, { + Duration atras = const Duration(hours: 1), +}) => Alert.db.insertRow( + s, + Alert( + patientId: UuidValue.fromString(_paciente), + microAreaId: UuidValue.fromString(ma), + triggeredAt: DateTime.now().toUtc().subtract(atras), + riskLevel: RiskLevel.red, + locationHash: 'hash-sintetico', + status: AlertStatus.pending, + mqttTopic: 'sinalacs/v1/microareas/$ma/alerts', + deviceId: 'device-sintetico', + retryCount: 0, + version: 1, + ), +); + +String _token(String id, UserRole role, {String? ma}) => + AlertRuntime.instance.auth.issueToken( + AuthenticatedUser( + id: id, + role: role, + microAreaId: ma, + deviceId: 'sem-aparelho', + ), + ); + +Future> _linhas(Session s, String resourceType) => + AuditLog.db.find( + s, + where: (t) => t.resourceType.equals(resourceType), + orderBy: (t) => t.sequence, + ); + +Future _linhasRead(Session s) => + AuditLog.db.count(s, where: (t) => t.actionType.equals('read')); + +void main() { + withServerpod('Dado admin.alertsPulse e a sessão de monitoramento (#44, D5)', ( + sessionBuilder, + endpoints, + ) { + late Session session; + + setUp(() async { + session = sessionBuilder.build(); + AlertRuntime.instance.overrideConfig(_config()); + AlertRuntime.instance.resetMonitoringSessions(); + await _seed(session); + }); + tearDown(() { + AlertRuntime.instance.overrideConfig(null); + AlertRuntime.instance.resetMonitoringSessions(); + }); + + Future pulso(String id, UserRole role) => + endpoints.admin.alertsPulse( + sessionBuilder, + accessToken: _token(id, role), + ); + + test( + 'N pulsos: um START, nenhuma linha read, indicadores iguais a admin.indicators, resposta sem identidade', + () async { + await _alerta(session, _maA); + final readsAntes = await _linhasRead(session); + + late AdminAlertsPulse p; + for (var i = 0; i < 5; i++) { + p = await pulso(_coordA, UserRole.coordinator); + } + expect( + await _linhasRead(session), + readsAntes, + reason: 'pulso não grava read', + ); + final sessoes = await _linhas(session, 'admin_monitoring'); + expect(sessoes.map((l) => l.actionType), ['monitoring_session_start']); + expect(sessoes.single.result, 'success'); + expect(sessoes.single.resourceId, isNull); + expect(sessoes.single.userId, UuidValue.fromString(_coordA)); + + expect(p.overdueRed, 1); + expect(p.maxChangedAt, isNotNull); + expect( + p.serverNow.difference(DateTime.now().toUtc()).abs(), + lessThan(const Duration(minutes: 1)), + ); + + // Mesmo estado, mesma régua: o pulso embute o que admin.indicators dá. + final ind = await endpoints.admin.indicators( + sessionBuilder, + accessToken: _token(_coordA, UserRole.coordinator), + ); + expect(p.indicators.toJson(), ind.toJson()); + + // Nenhuma identidade: nem id, nem nome, nem rótulo de paciente. + final json = p.toJson()..remove('__className__'); + expect(json.keys.toSet(), { + 'indicators', + 'overdueRed', + 'maxChangedAt', + 'serverNow', + }); + final texto = jsonEncode(p.toJson()); + for (final proibido in [ + _paciente, + _pacienteNome, + _acs, + _maA, + _ubsA, + 'patient', + 'Label', + 'microArea', + 'cpf', + '#', + ]) { + expect(texto, isNot(contains(proibido)), reason: proibido); + } + }, + ); + + test( + 'endMonitoringSession: com sessão grava um END; repetir e chamar sem sessão não grava', + () async { + await pulso(_coordA, UserRole.coordinator); + for (var i = 0; i < 2; i++) { + await endpoints.admin.endMonitoringSession( + sessionBuilder, + accessToken: _token(_coordA, UserRole.coordinator), + ); + } + // Sem sessão nenhuma: nada. + await endpoints.admin.endMonitoringSession( + sessionBuilder, + accessToken: _token(_admin, UserRole.admin), + ); + final linhas = await _linhas(session, 'admin_monitoring'); + expect(linhas.map((l) => l.actionType), [ + 'monitoring_session_start', + 'monitoring_session_end', + ]); + expect(linhas.last.result, 'success'); + expect(linhas.last.userId, UuidValue.fromString(_coordA)); + + // Depois do END, o próximo pulso abre outra sessão. + await pulso(_coordA, UserRole.coordinator); + expect( + (await _linhas(session, 'admin_monitoring')).map((l) => l.actionType), + [ + 'monitoring_session_start', + 'monitoring_session_end', + 'monitoring_session_start', + ], + ); + }, + ); + + test( + 'ACS, paciente e coordenador sem UBS: recusados no pulso e no END, com linha denied', + () async { + for (final (id, role, ma) in [ + (_acs, UserRole.acs, _maA), + (_paciente, UserRole.patient, _maA), + (_coordSemUbs, UserRole.coordinator, null), + ]) { + await expectLater( + endpoints.admin.alertsPulse( + sessionBuilder, + accessToken: _token(id, role, ma: ma), + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'acesso restrito ao backoffice', + ), + ), + ); + await expectLater( + endpoints.admin.endMonitoringSession( + sessionBuilder, + accessToken: _token(id, role, ma: ma), + ), + throwsA(isA()), + ); + } + final pulsos = await _linhas(session, 'admin_pulse'); + expect(pulsos.map((l) => l.result), ['denied', 'denied', 'denied']); + expect(pulsos.map((l) => l.actionType).toSet(), {'read'}); + final fins = await _linhas(session, 'admin_monitoring'); + expect(fins.map((l) => l.result), ['denied', 'denied', 'denied']); + expect(fins.map((l) => l.actionType).toSet(), { + 'monitoring_session_end', + }); + }, + ); + + test( + 'coordenador da UBS B não vê os alertas da UBS A', + () async { + await _alerta(session, _maA); + await _alerta(session, _maA, atras: const Duration(minutes: 30)); + final b = await _alerta(session, _maB, atras: const Duration(hours: 2)); + + final pB = await pulso(_coordB, UserRole.coordinator); + expect(pB.overdueRed, 1); + expect(pB.maxChangedAt, b.triggeredAt); + expect(pB.indicators.red, 1); + + final pA = await pulso(_coordA, UserRole.coordinator); + expect(pA.overdueRed, 2); + expect(pA.indicators.red, 2); + + final pAdmin = await pulso(_admin, UserRole.admin); + expect(pAdmin.overdueRed, 3); + }, + ); + + test( + 'maxChangedAt muda após o ACK e após o escalonamento; a cadeia de auditoria segue íntegra', + () async { + final ack = await _alerta( + session, + _maA, + atras: const Duration(hours: 2), + ); + final esc = await _alerta( + session, + _maA, + atras: const Duration(hours: 3), + ); + + final p0 = await pulso(_coordA, UserRole.coordinator); + expect(p0.maxChangedAt, ack.triggeredAt); + expect(p0.overdueRed, 2); + + final r = await endpoints.alerts.acknowledge( + sessionBuilder, + accessToken: _token(_acs, UserRole.acs, ma: _maA), + alertId: ack.id!.uuid, + ); + expect(r.acknowledged, isTrue); + final p1 = await pulso(_coordA, UserRole.coordinator); + expect(p1.maxChangedAt!.isAfter(p0.maxChangedAt!), isTrue); + expect( + p1.maxChangedAt, + (await Alert.db.findById(session, ack.id!))!.acknowledgedAt, + ); + expect(p1.overdueRed, 1); + + final e = await endpoints.admin.escalateAlert( + sessionBuilder, + accessToken: _token(_coordA, UserRole.coordinator), + alertId: esc.id!.uuid, + reason: EscalationReason.noAckTimeout, + ); + final p2 = await pulso(_coordA, UserRole.coordinator); + expect(p2.maxChangedAt, e.escalatedAt); + expect(p2.maxChangedAt!.isAfter(p1.maxChangedAt!), isTrue); + expect(p2.overdueRed, 0); + + await endpoints.admin.endMonitoringSession( + sessionBuilder, + accessToken: _token(_coordA, UserRole.coordinator), + ); + + final verifier = AuditChainVerifier( + reader: OrmAuditChainReader(session: () => session), + secret: _segredo, + ); + expect((await verifier.verify()).ok, isTrue); + }, + ); + }); +} diff --git a/backend/sinalacs_server/test/integration/admin_endpoint_notices_test.dart b/backend/sinalacs_server/test/integration/admin_endpoint_notices_test.dart new file mode 100644 index 00000000..48cc4d0f --- /dev/null +++ b/backend/sinalacs_server/test/integration/admin_endpoint_notices_test.dart @@ -0,0 +1,280 @@ +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/auth/development_auth_service.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_scheduled_notice_store.dart'; +import 'package:sinalacs_server/src/runtime/alert_runtime.dart'; +import 'package:test/test.dart'; + +import 'test_tools/serverpod_test_tools.dart'; + +/// `admin.scheduleNotice` / `cancelScheduledNotice` / `noticeHistory` (#46) +/// pelo endpoint, com tokens reais e Postgres real. Dados sintéticos. +const _ubsA = '00000000-0000-4000-8000-0000000046a1'; +const _ubsB = '00000000-0000-4000-8000-0000000046b1'; +const _maA = '00000000-0000-4000-8000-0000000046a2'; +const _maB = '00000000-0000-4000-8000-0000000046b2'; +const _admin = '00000000-0000-4000-8000-0000000046c1'; +const _coordA = '00000000-0000-4000-8000-0000000046c2'; +const _acs = '00000000-0000-4000-8000-0000000046c3'; +const _paciente = '00000000-0000-4000-8000-0000000046c4'; +final _t = DateTime.utc(2026, 1, 1); + +AppConfig _config() => AppConfig( + mqttBroker: 'localhost:1883', + jwtSecret: 'test-secret', + auditChainSecret: 'test-audit-chain-secret', + healthDataEncryptionKey: AppConfig.developmentHealthDataEncryptionKey, + cpfHashPepper: AppConfig.developmentCpfHashPepper, + smsGateway: 'log', + mqttUsername: null, + mqttPassword: null, + mqttUseTls: false, + mqttCaCertificatePath: null, + appEnv: 'development', + enableDevLogin: true, +); + +String _token(String id, UserRole role) => AlertRuntime.instance.auth.issueToken( + AuthenticatedUser(id: id, role: role, microAreaId: null, deviceId: 'dev'), +); + +Future _seed(Session s) async { + for (final (ubs, ma, nome) in [ + (_ubsA, _maA, 'Microárea A'), + (_ubsB, _maB, 'Microárea B'), + ]) { + await Ubs.db.insertRow( + s, + Ubs( + id: UuidValue.fromString(ubs), + name: 'UBS $nome', + address: 'Endereço sintético', + city: 'Cidade', + state: 'SP', + ), + ); + await MicroArea.db.insertRow( + s, + MicroArea( + id: UuidValue.fromString(ma), + name: nome, + ubsId: UuidValue.fromString(ubs), + geoJsonBoundary: '{}', + ), + ); + } + await User.db.insert(s, [ + for (final (id, role) in [ + (_admin, UserRole.admin), + (_coordA, UserRole.coordinator), + (_acs, UserRole.acs), + (_paciente, UserRole.patient), + ]) + User( + id: UuidValue.fromString(id), + cpfHash: 'sintetico-$id', + name: 'Sintético $id', + birthDate: DateTime.utc(1980), + role: role, + createdAt: _t, + updatedAt: _t, + ), + ]); + await StaffAccount.db.insertRow( + s, + StaffAccount( + id: UuidValue.fromString(_admin), + enrollmentId: 'ADM-AVISO-1', + active: true, + ), + ); + await StaffAccount.db.insertRow( + s, + StaffAccount( + id: UuidValue.fromString(_coordA), + enrollmentId: 'COO-AVISO-1', + active: true, + ubsId: UuidValue.fromString(_ubsA), + ), + ); +} + +void main() { + withServerpod('Dado os endpoints de avisos do admin (#46)', ( + sessionBuilder, + endpoints, + ) { + late Session session; + late OrmScheduledNoticeStore store; + + setUp(() async { + session = sessionBuilder.build(); + AlertRuntime.instance.overrideConfig(_config()); + await _seed(session); + store = OrmScheduledNoticeStore(session: () => session); + }); + tearDown(() => AlertRuntime.instance.overrideConfig(null)); + + DateTime future() => DateTime.now().toUtc().add(const Duration(hours: 2)); + + Future agenda(String token, String ma) => + endpoints.admin.scheduleNotice( + sessionBuilder, + accessToken: token, + microAreaId: ma, + audience: 'everyone', + title: 'Vacinação', + message: 'TEXTO-SECRETO-DO-AVISO', + scheduledFor: future(), + ); + + Future> audit(String result) => AuditLog.db.find( + session, + where: (t) => + t.resourceType.equals('community_notice') & t.result.equals(result), + ); + + test('token inválido: falha de autenticação nos três métodos', () async { + await expectLater( + agenda('lixo', _maA), + throwsA(isA()), + ); + await expectLater( + endpoints.admin.cancelScheduledNotice( + sessionBuilder, + accessToken: 'lixo', + id: _maA, + ), + throwsA(isA()), + ); + await expectLater( + endpoints.admin.noticeHistory( + sessionBuilder, + accessToken: 'lixo', + limit: 50, + offset: 0, + ), + throwsA(isA()), + ); + }); + + test('paciente e ACS: AlertPermissionException e denied_role auditado', () async { + for (final (id, role) in [(_paciente, UserRole.patient), (_acs, UserRole.acs)]) { + await expectLater( + agenda(_token(id, role), _maA), + throwsA(isA()), + ); + await expectLater( + endpoints.admin.noticeHistory( + sessionBuilder, + accessToken: _token(id, role), + limit: 50, + offset: 0, + ), + throwsA(isA()), + ); + } + final negadas = await audit('denied_role'); + expect(negadas, hasLength(4)); + expect( + negadas.map((l) => l.userId).toSet(), + {UuidValue.fromString(_paciente), UuidValue.fromString(_acs)}, + ); + }); + + test('admin agenda: devolve o id e grava a linha pending', () async { + final id = await agenda(_token(_admin, UserRole.admin), _maB); + final r = await session.db.unsafeQuery( + 'SELECT "status" FROM scheduled_notices WHERE "id" = @id::uuid', + parameters: QueryParameters.named({'id': id}), + ); + expect(r.single.toColumnMap()['status'], 'pending'); + expect(await audit('scheduled'), hasLength(1)); + }); + + test('coordenador em microárea de outra UBS: negado e denied_territory', () async { + await expectLater( + agenda(_token(_coordA, UserRole.coordinator), _maB), + throwsA(isA()), + ); + expect(await audit('denied_territory'), hasLength(1)); + expect( + await session.db.unsafeQuery('SELECT 1 FROM scheduled_notices'), + isEmpty, + ); + }); + + test('cancelar pending funciona; cancelar enviado é AdminInvalidRequestException', () async { + final token = _token(_admin, UserRole.admin); + final pendente = await agenda(token, _maA); + await endpoints.admin.cancelScheduledNotice( + sessionBuilder, + accessToken: token, + id: pendente, + ); + final r = await session.db.unsafeQuery( + 'SELECT "status" FROM scheduled_notices WHERE "id" = @id::uuid', + parameters: QueryParameters.named({'id': pendente}), + ); + expect(r.single.toColumnMap()['status'], 'cancelled'); + + final enviado = await agenda(token, _maA); + await store.claimDue(DateTime.now().toUtc().add(const Duration(days: 1))); + await store.finish( + enviado, + status: ScheduledNoticeStatus.sent, + recipients: 3, + accepted: 2, + at: DateTime.now().toUtc(), + ); + await expectLater( + endpoints.admin.cancelScheduledNotice( + sessionBuilder, + accessToken: token, + id: enviado, + ), + throwsA(isA()), + ); + }); + + test('histórico traz nome da microárea e matrícula, nunca a mensagem; coordenador só vê a UBS', () async { + final admin = _token(_admin, UserRole.admin); + final a = await agenda(admin, _maA); + final b = await agenda(admin, _maB); + await agenda(_token(_coordA, UserRole.coordinator), _maA); + + final page = await endpoints.admin.noticeHistory( + sessionBuilder, + accessToken: admin, + limit: 50, + offset: 0, + ); + expect(page.entries.map((e) => e.id), containsAll([a, b])); + final ea = page.entries.firstWhere((e) => e.id == a); + expect(ea.microAreaName, 'Microárea A'); + expect(ea.createdByLabel, 'ADM-AVISO-1'); + expect(ea.title, 'Vacinação'); + expect(ea.status, ScheduledNoticeStatus.pending); + expect( + page.entries.any((e) => e.createdByLabel == 'COO-AVISO-1'), + isTrue, + ); + final json = page.toJson().toString(); + expect(json, isNot(contains('TEXTO-SECRETO-DO-AVISO'))); + for (final e in page.entries) { + expect(e.toJson().keys, isNot(contains('message'))); + } + + final coord = await endpoints.admin.noticeHistory( + sessionBuilder, + accessToken: _token(_coordA, UserRole.coordinator), + limit: 50, + offset: 0, + ); + expect(coord.entries, hasLength(2)); + expect(coord.entries.map((e) => e.microAreaId).toSet(), {_maA}); + expect(coord.entries.map((e) => e.id), isNot(contains(b))); + }); + }); +} diff --git a/backend/sinalacs_server/test/integration/admin_endpoint_terms_test.dart b/backend/sinalacs_server/test/integration/admin_endpoint_terms_test.dart new file mode 100644 index 00000000..ede43db5 --- /dev/null +++ b/backend/sinalacs_server/test/integration/admin_endpoint_terms_test.dart @@ -0,0 +1,241 @@ +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/auth/development_auth_service.dart'; +import 'package:sinalacs_server/src/config/app_config.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/runtime/alert_runtime.dart'; +import 'package:test/test.dart'; + +import 'test_tools/serverpod_test_tools.dart'; + +/// `admin.termsVersions` / `draftTermsVersion` / `markTermsReviewed` / +/// `publishTermsVersion` (#46) +/// pelo endpoint, com tokens reais e Postgres real. Dados sintéticos. +const _admin = '00000000-0000-4000-8000-0000000046d1'; +const _coordA = '00000000-0000-4000-8000-0000000046d2'; +const _acs = '00000000-0000-4000-8000-0000000046d3'; +const _paciente = '00000000-0000-4000-8000-0000000046d4'; +final _t = DateTime.utc(2026, 1, 1); + +AppConfig _config() => AppConfig( + mqttBroker: 'localhost:1883', + jwtSecret: 'test-secret', + auditChainSecret: 'test-audit-chain-secret', + healthDataEncryptionKey: AppConfig.developmentHealthDataEncryptionKey, + cpfHashPepper: AppConfig.developmentCpfHashPepper, + smsGateway: 'log', + mqttUsername: null, + mqttPassword: null, + mqttUseTls: false, + mqttCaCertificatePath: null, + appEnv: 'development', + enableDevLogin: true, +); + +String _token(String id, UserRole role) => AlertRuntime.instance.auth.issueToken( + AuthenticatedUser(id: id, role: role, microAreaId: null, deviceId: 'dev'), +); + +Future _seed(Session s) async { + await User.db.insert(s, [ + for (final (id, role) in [ + (_admin, UserRole.admin), + (_coordA, UserRole.coordinator), + (_acs, UserRole.acs), + (_paciente, UserRole.patient), + ]) + User( + id: UuidValue.fromString(id), + cpfHash: 'sintetico-$id', + name: 'Sintético $id', + birthDate: DateTime.utc(1980), + role: role, + createdAt: _t, + updatedAt: _t, + ), + ]); +} + +void main() { + withServerpod('Dado os endpoints de termos do admin (#46)', ( + sessionBuilder, + endpoints, + ) { + late Session session; + + setUp(() async { + session = sessionBuilder.build(); + AlertRuntime.instance.overrideConfig(_config()); + await _seed(session); + }); + tearDown(() => AlertRuntime.instance.overrideConfig(null)); + + final admin = () => _token(_admin, UserRole.admin); + DateTime inDays(int d) => DateTime.now().toUtc().add(Duration(days: d)); + + Future rascunho(String v, DateTime eff) => + endpoints.admin.draftTermsVersion( + sessionBuilder, + accessToken: admin(), + version: v, + summary: '[TEXTO PLACEHOLDER]', + effectiveFrom: eff, + ); + + Future> audit(String result) => AuditLog.db.find( + session, + where: (t) => + t.resourceType.equals('terms_version') & t.result.equals(result), + ); + + test('token inválido: falha de autenticação nos quatro métodos', () async { + for (final call in Function()>[ + () => endpoints.admin.termsVersions(sessionBuilder, accessToken: 'lixo'), + () => endpoints.admin.draftTermsVersion( + sessionBuilder, + accessToken: 'lixo', + version: '2099.1', + summary: '[TEXTO PLACEHOLDER]', + effectiveFrom: inDays(20), + ), + () => endpoints.admin.markTermsReviewed(sessionBuilder, accessToken: 'lixo', id: _admin), + () => endpoints.admin.publishTermsVersion(sessionBuilder, accessToken: 'lixo', id: _admin), + ]) { + await expectLater(call(), throwsA(isA())); + } + }); + + test('paciente, ACS e coordenador: AlertPermissionException e denied_role', () async { + final papeis = [ + (_paciente, UserRole.patient), + (_acs, UserRole.acs), + (_coordA, UserRole.coordinator), + ]; + for (final (id, role) in papeis) { + final t = _token(id, role); + await expectLater( + endpoints.admin.termsVersions(sessionBuilder, accessToken: t), + throwsA(isA()), + ); + await expectLater( + endpoints.admin.draftTermsVersion( + sessionBuilder, + accessToken: t, + version: '2099.1', + summary: '[TEXTO PLACEHOLDER]', + effectiveFrom: inDays(20), + ), + throwsA(isA()), + ); + await expectLater( + endpoints.admin.markTermsReviewed(sessionBuilder, accessToken: t, id: _admin), + throwsA(isA()), + ); + await expectLater( + endpoints.admin.publishTermsVersion(sessionBuilder, accessToken: t, id: _admin), + throwsA(isA()), + ); + } + final negadas = await audit('denied_role'); + expect(negadas, hasLength(12)); + expect( + negadas.map((l) => l.userId).toSet(), + {for (final (id, _) in papeis) UuidValue.fromString(id)}, + ); + }); + + test('admin rascunha, revisa e publica; o paciente passa a ver o aviso', () async { + final eff = inDays(20); + final t = _token(_paciente, UserRole.patient); + expect( + await endpoints.patients.termsChangeNotice(sessionBuilder, accessToken: t), + isNull, + ); + + final r = await rascunho('2099.1', eff); + expect(r.legallyReviewedAt, isNull); + expect(r.publishedAt, isNull); + expect(r.summary, '[TEXTO PLACEHOLDER]'); + + final rev = await endpoints.admin.markTermsReviewed( + sessionBuilder, accessToken: admin(), id: r.id); + expect(rev.legallyReviewedAt, isNotNull); + + final pub = await endpoints.admin.publishTermsVersion( + sessionBuilder, accessToken: admin(), id: r.id); + expect(pub.publishedAt, isNotNull); + + final aviso = await endpoints.patients.termsChangeNotice( + sessionBuilder, accessToken: t); + expect(aviso, isNotNull); + expect(aviso!.version, '2099.1'); + expect(aviso.summary, '[TEXTO PLACEHOLDER]'); + expect(aviso.effectiveFrom.toUtc().difference(eff).abs(), + lessThan(const Duration(seconds: 1))); + }); + + test('publicar sem revisão: AdminInvalidRequestException e denied_not_reviewed', () async { + final r = await rascunho('2099.2', inDays(20)); + await expectLater( + endpoints.admin.publishTermsVersion(sessionBuilder, accessToken: admin(), id: r.id), + throwsA(isA()), + ); + expect(await audit('denied_not_reviewed'), hasLength(1)); + }); + + test('vigência a 14 dias: recusada na publicação e denied_notice_period', () async { + final r = await rascunho('2099.3', inDays(14)); + await endpoints.admin.markTermsReviewed(sessionBuilder, accessToken: admin(), id: r.id); + await expectLater( + endpoints.admin.publishTermsVersion(sessionBuilder, accessToken: admin(), id: r.id), + throwsA(isA()), + ); + expect(await audit('denied_notice_period'), hasLength(1)); + }); + + test('vigência exatamente 15 dias depois da publicação: aceita', () async { + final agora = DateTime.utc(2099, 1, 1, 12); + final svc = AlertRuntime.instance.termsPublicationServiceFor( + session, + clock: () => agora, + ); + final user = AuthenticatedUser( + id: _admin, role: UserRole.admin, microAreaId: null, deviceId: 'dev'); + final r = await svc.draft( + user, + version: '2099.4', + summary: '[TEXTO PLACEHOLDER]', + effectiveFrom: agora.add(const Duration(days: 15)), + ); + await svc.markReviewed(user, r.id); + final pub = await svc.publish(user, r.id); + expect(pub.publishedAt, isNotNull); + }); + + test('listagem ordenada por createdAt decrescente', () async { + final a = await rascunho('2099.5', inDays(20)); + final b = await rascunho('2099.6', inDays(21)); + final lista = await endpoints.admin.termsVersions( + sessionBuilder, accessToken: admin()); + expect(lista.map((e) => e.id).toList(), [b.id, a.id]); + }); + + test('o resumo nunca aparece em audit_logs', () async { + final r = await endpoints.admin.draftTermsVersion( + sessionBuilder, + accessToken: admin(), + version: '2099.7', + summary: 'RESUMO-SECRETO-DOS-TERMOS', + effectiveFrom: inDays(20), + ); + await endpoints.admin.markTermsReviewed(sessionBuilder, accessToken: admin(), id: r.id); + await endpoints.admin.publishTermsVersion(sessionBuilder, accessToken: admin(), id: r.id); + await endpoints.admin.termsVersions(sessionBuilder, accessToken: admin()); + final logs = await AuditLog.db.find( + session, + where: (t) => t.resourceType.equals('terms_version'), + ); + expect(logs, isNotEmpty); + expect(logs.map((l) => l.toString()).join(), isNot(contains('RESUMO-SECRETO'))); + }); + }); +} diff --git a/backend/sinalacs_server/test/integration/admin_endpoint_test.dart b/backend/sinalacs_server/test/integration/admin_endpoint_test.dart new file mode 100644 index 00000000..e6acea42 --- /dev/null +++ b/backend/sinalacs_server/test/integration/admin_endpoint_test.dart @@ -0,0 +1,1037 @@ +import 'dart:convert'; + +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'; +import 'package:test/test.dart'; + +import '../support/health_data_fixtures.dart'; +import 'test_tools/serverpod_test_tools.dart'; + +/// `admin.*` (#40) pelo endpoint, com tokens reais e Postgres real: papel, +/// escopo por UBS, auditoria de cada leitura e minimização de PII. +/// Dados sintéticos; ids próprios. +const _ubsA = '00000000-0000-4000-8000-0000000000d1'; +const _ubsB = '00000000-0000-4000-8000-0000000000d2'; +const _maA = '00000000-0000-4000-8000-0000000000d3'; +const _maB = '00000000-0000-4000-8000-0000000000d4'; +const _admin = '00000000-0000-4000-8000-0000000000d5'; +const _coordA = '00000000-0000-4000-8000-0000000000d6'; +const _coordSemUbs = '00000000-0000-4000-8000-0000000000d7'; +const _acs = '00000000-0000-4000-8000-0000000000d8'; +const _paciente = '00000000-0000-4000-8000-0000000000d9'; +const _coordB = '00000000-0000-4000-8000-0000000000da'; +const _nomePaciente = 'Joana Sintética Pereira'; + +final _agora = DateTime.now().toUtc(); + +Future _usuario( + Session s, + String id, + String nome, + UserRole role, { + String? ma, +}) async { + await User.db.insertRow( + s, + User( + id: UuidValue.fromString(id), + cpfHash: 'admin-ep-$id', + name: nome, + birthDate: DateTime.utc(1980), + role: role, + microAreaId: ma == null ? null : UuidValue.fromString(ma), + createdAt: _agora, + updatedAt: _agora, + ), + ); +} + +Future _alerta( + Session s, + String ma, + RiskLevel risco, + AlertStatus status, + Duration atras, +) async { + await Alert.db.insertRow( + s, + Alert( + patientId: UuidValue.fromString(_paciente), + microAreaId: UuidValue.fromString(ma), + triggeredAt: _agora.subtract(atras), + riskLevel: risco, + locationHash: 'hash-sintetico', + status: status, + mqttTopic: 'sinalacs/v1/microareas/$ma/alerts', + deviceId: 'device-sintetico', + retryCount: 0, + version: 0, + ), + ); +} + +Future _seed(Session s) async { + for (final (id, nome) in [(_ubsA, 'UBS A'), (_ubsB, 'UBS B')]) { + await Ubs.db.insertRow( + s, + Ubs( + id: UuidValue.fromString(id), + name: nome, + address: 'Endereço sintético', + city: 'São Paulo', + state: 'SP', + ), + ); + } + for (final (id, nome, ubs) in [ + (_maA, 'Microárea A', _ubsA), + (_maB, 'Microárea B', _ubsB), + ]) { + await MicroArea.db.insertRow( + s, + MicroArea( + id: UuidValue.fromString(id), + name: nome, + ubsId: UuidValue.fromString(ubs), + geoJsonBoundary: '{}', + ), + ); + } + await _usuario(s, _acs, 'ACS Sintético', UserRole.acs, ma: _maA); + await Acs.db.insertRow( + s, + Acs( + id: UuidValue.fromString(_acs), + enrollmentId: 'ACS-EP-1', + ubsId: UuidValue.fromString(_ubsA), + active: true, + ), + ); + await _usuario(s, _paciente, _nomePaciente, UserRole.patient, ma: _maA); + await Patient.db.insertRow( + s, + await encryptedPatient( + id: _paciente, + emergencyContact: 'x', + isChronic: false, + ), + ); + await _usuario(s, _admin, 'Admin Sintético', UserRole.admin); + await StaffAccount.db.insertRow( + s, + StaffAccount( + id: UuidValue.fromString(_admin), + enrollmentId: 'ADM-EP-1', + active: true, + ), + ); + await _usuario(s, _coordA, 'Coord A Sintético', UserRole.coordinator); + await StaffAccount.db.insertRow( + s, + StaffAccount( + id: UuidValue.fromString(_coordA), + enrollmentId: 'COO-EP-1', + active: true, + ubsId: UuidValue.fromString(_ubsA), + ), + ); + await _usuario(s, _coordSemUbs, 'Coord Sem UBS', UserRole.coordinator); + await StaffAccount.db.insertRow( + s, + StaffAccount( + id: UuidValue.fromString(_coordSemUbs), + enrollmentId: 'COO-EP-2', + active: true, + ), + ); + + await _alerta( + s, + _maA, + RiskLevel.red, + AlertStatus.pending, + const Duration(minutes: 10), + ); + await _alerta( + s, + _maA, + RiskLevel.green, + AlertStatus.pending, + const Duration(minutes: 20), + ); + await _alerta( + s, + _maB, + RiskLevel.red, + AlertStatus.pending, + const Duration(minutes: 5), + ); +} + +String _token(String id, UserRole role, {String? ma, DateTime? now}) => + AlertRuntime.instance.auth.issueToken( + AuthenticatedUser( + id: id, + role: role, + microAreaId: ma, + deviceId: 'sem-aparelho', + ), + now: now, + ); + +Future _auditoriasAdmin(Session s, String resultado) async { + final linhas = await s.db.unsafeQuery( + 'SELECT count(*) FROM audit_logs WHERE "resourceType" LIKE \'admin_%\' AND result = @r', + parameters: QueryParameters.named({'r': resultado}), + ); + return linhas.single[0] as int; +} + +void main() { + withServerpod('Dado o endpoint admin do backoffice (#40)', ( + sessionBuilder, + endpoints, + ) { + late Session session; + setUp(() async { + session = sessionBuilder.build(); + await _seed(session); + }); + + test( + 'admin lê os quatro endpoints e cada leitura vira uma linha read/success em audit_logs', + () async { + final t = _token(_admin, UserRole.admin); + final ind = await endpoints.admin.indicators( + sessionBuilder, + accessToken: t, + ); + expect(ind.red, 2); + expect(ind.green, 1); + expect(ind.tmravSeconds, isNull); + expect( + (await endpoints.admin.microAreas( + sessionBuilder, + accessToken: t, + )).map((m) => m.name), + containsAll(['Microárea A', 'Microárea B']), + ); + expect( + (await endpoints.admin.alerts( + sessionBuilder, + accessToken: t, + limit: 50, + offset: 0, + )).items, + hasLength(3), + ); + await endpoints.admin.auditLogs( + sessionBuilder, + accessToken: t, + limit: 50, + ); + expect(await _auditoriasAdmin(session, 'success'), 4); + }, + ); + + test( + 'coordenador da UBS A não vê a UBS B (indicators, microAreas, alerts)', + () async { + final t = _token(_coordA, UserRole.coordinator); + expect( + (await endpoints.admin.indicators( + sessionBuilder, + accessToken: t, + )).red, + 1, + ); + expect( + (await endpoints.admin.microAreas( + sessionBuilder, + accessToken: t, + )).map((m) => m.name), + ['Microárea A'], + ); + final alertas = await endpoints.admin.alerts( + sessionBuilder, + accessToken: t, + limit: 50, + offset: 0, + ); + expect(alertas.items, hasLength(2)); + expect(alertas.items.map((a) => a.microAreaName).toSet(), { + 'Microárea A', + }); + final filtrandoB = await endpoints.admin.alerts( + sessionBuilder, + accessToken: t, + microAreaId: _maB, + limit: 50, + offset: 0, + ); + expect( + filtrandoB.items, + isEmpty, + reason: 'filtro de outra UBS não fura o escopo', + ); + }, + ); + + test( + 'coordenador lê a auditoria escopada à UBS dele; linha de staff fica invisível', + () async { + // Linhas reais: a leitura do diretório pelo ACS (ator com microárea da + // UBS A) e a leitura de indicadores pelo administrador (staff, sem + // microárea). A do coordenador é a própria chamada abaixo. + await endpoints.patients.listMicroArea( + sessionBuilder, + accessToken: _token(_acs, UserRole.acs, ma: _maA), + ); + await endpoints.admin.indicators( + sessionBuilder, + accessToken: _token(_admin, UserRole.admin), + ); + + final doCoordenador = await endpoints.admin.auditLogs( + sessionBuilder, + accessToken: _token(_coordA, UserRole.coordinator), + limit: 50, + ); + expect( + doCoordenador.items.map((e) => e.userLabel), + contains('ACS-EP-1 (ACS)'), + ); + expect( + doCoordenador.items.map((e) => e.userLabel), + isNot(contains('ADM-EP-1 (Administrador)')), + reason: 'linha de staff não tem microárea: fora do escopo da UBS', + ); + expect( + doCoordenador.items.map((e) => e.userLabel), + isNot(contains('COO-EP-1 (Coordenador)')), + reason: 'a própria leitura do coordenador também é linha de staff', + ); + + final doAdmin = await endpoints.admin.auditLogs( + sessionBuilder, + accessToken: _token(_admin, UserRole.admin), + limit: 50, + ); + expect( + doAdmin.items.map((e) => e.userLabel), + containsAll( + [ + 'ACS-EP-1 (ACS)', + 'ADM-EP-1 (Administrador)', + 'COO-EP-1 (Coordenador)', + ], + ), + reason: 'o administrador continua vendo o sistema inteiro', + ); + }, + ); + + test( + 'coordenador sem UBS recebe AlertPermissionException nos quatro', + () async { + final t = _token(_coordSemUbs, UserRole.coordinator); + for (final chamada in Function()>[ + () => endpoints.admin.indicators(sessionBuilder, accessToken: t), + () => endpoints.admin.microAreas(sessionBuilder, accessToken: t), + () => endpoints.admin.alerts( + sessionBuilder, + accessToken: t, + limit: 50, + offset: 0, + ), + () => endpoints.admin.auditLogs( + sessionBuilder, + accessToken: t, + limit: 50, + ), + ]) { + await expectLater( + chamada(), + throwsA(isA()), + ); + } + expect(await _auditoriasAdmin(session, 'denied'), 4); + }, + ); + + test( + 'acs (com microárea) e patient com token válido recebem AlertPermissionException nos quatro', + () async { + for (final (id, role, ma) in [ + (_acs, UserRole.acs, _maA), + (_paciente, UserRole.patient, _maA), + ]) { + final t = _token(id, role, ma: ma); + for (final chamada in Function()>[ + () => endpoints.admin.indicators(sessionBuilder, accessToken: t), + () => endpoints.admin.microAreas(sessionBuilder, accessToken: t), + () => endpoints.admin.alerts( + sessionBuilder, + accessToken: t, + limit: 50, + offset: 0, + ), + () => endpoints.admin.auditLogs( + sessionBuilder, + accessToken: t, + limit: 50, + ), + ]) { + await expectLater( + chamada(), + throwsA(isA()), + ); + } + } + expect(await _auditoriasAdmin(session, 'denied'), 8); + }, + ); + + test( + 'token adulterado e token expirado são recusados e não auditam leitura', + () async { + final bom = _token(_admin, UserRole.admin); + final expirado = _token( + _admin, + UserRole.admin, + now: DateTime.utc(2020), + ); + for (final t in ['${bom}x', expirado, '', 'lixo']) { + await expectLater( + endpoints.admin.indicators(sessionBuilder, accessToken: t), + throwsA(isA()), + ); + } + expect(await _auditoriasAdmin(session, 'success'), 0); + }, + ); + + test('a auditoria não devolve hash nem IP', () async { + final t = _token(_admin, UserRole.admin); + await endpoints.admin.indicators(sessionBuilder, accessToken: t); + final json = jsonEncode( + (await endpoints.admin.auditLogs( + sessionBuilder, + accessToken: t, + limit: 50, + )).toJson(), + ); + expect(json, isNot(contains('Hash'))); + expect(json, isNot(contains('ip'))); + expect(json, contains('ADM-EP-1 (Administrador)')); + }); + + test( + 'a resposta de alertas não contém nome nem UUID do paciente', + () async { + final json = jsonEncode( + (await endpoints.admin.alerts( + sessionBuilder, + accessToken: _token(_admin, UserRole.admin), + limit: 50, + offset: 0, + )).toJson(), + ); + expect(json, isNot(contains(_nomePaciente))); + expect(json, isNot(contains(_paciente))); + }, + ); + + test( + 'limit fora de 1..100 vira AdminInvalidRequestException e não é auditado como leitura', + () async { + final t = _token(_admin, UserRole.admin); + await expectLater( + endpoints.admin.alerts( + sessionBuilder, + accessToken: t, + limit: 0, + offset: 0, + ), + throwsA(isA()), + ); + await expectLater( + endpoints.admin.alerts( + sessionBuilder, + accessToken: t, + limit: 101, + offset: 0, + ), + throwsA(isA()), + ); + expect(await _auditoriasAdmin(session, 'success'), 0); + }, + ); + + group('auditoria com filtro (#45), pelo endpoint', () { + final filtro = AdminAuditFilter( + actionType: 'read', + resourceType: 'patient_directory', + ); + + test('sem token ou com token inválido: recusa de autenticação', () async { + for (final t in ['', 'lixo']) { + await expectLater( + endpoints.admin.auditLogs( + sessionBuilder, + limit: 50, + accessToken: t, + filter: filtro, + ), + throwsA(isA()), + ); + } + expect(await _auditoriasAdmin(session, 'success'), 0); + }); + + test( + 'acs e patient com filtro: AlertPermissionException e linha denied', + () async { + for (final (id, role) in [ + (_acs, UserRole.acs), + (_paciente, UserRole.patient), + ]) { + await expectLater( + endpoints.admin.auditLogs( + sessionBuilder, + limit: 50, + accessToken: _token(id, role, ma: _maA), + filter: filtro, + ), + throwsA(isA()), + ); + } + expect(await _auditoriasAdmin(session, 'denied'), 2); + expect(await _auditoriasAdmin(session, 'success'), 0); + }, + ); + + test('coordenador sem UBS com filtro é recusado', () async { + await expectLater( + endpoints.admin.auditLogs( + sessionBuilder, + limit: 50, + accessToken: _token(_coordSemUbs, UserRole.coordinator), + filter: filtro, + ), + throwsA(isA()), + ); + expect(await _auditoriasAdmin(session, 'denied'), 1); + }); + + test( + 'filtro inválido: AdminInvalidRequestException, sem linha de sucesso', + () async { + await expectLater( + endpoints.admin.auditLogs( + sessionBuilder, + limit: 50, + accessToken: _token(_admin, UserRole.admin), + filter: AdminAuditFilter(search: '%'), + ), + throwsA(isA()), + ); + expect(await _auditoriasAdmin(session, 'success'), 0); + }, + ); + + test( + 'filtro válido atravessa o protocolo e restringe as linhas', + () async { + await endpoints.patients.listMicroArea( + sessionBuilder, + accessToken: _token(_acs, UserRole.acs, ma: _maA), + ); + final t = _token(_admin, UserRole.admin); + await endpoints.admin.indicators(sessionBuilder, accessToken: t); + + final pagina = await endpoints.admin.auditLogs( + sessionBuilder, + limit: 50, + accessToken: t, + filter: filtro.copyWith( + actorEnrollmentId: 'acs-ep-1', + search: 'directory', + ), + ); + expect(pagina.items, isNotEmpty); + expect( + pagina.items.map((e) => (e.userLabel, e.resourceType)).toSet(), + {('ACS-EP-1 (ACS)', 'patient_directory')}, + ); + + final doCoordenador = await endpoints.admin.auditLogs( + sessionBuilder, + limit: 50, + accessToken: _token(_coordA, UserRole.coordinator), + filter: AdminAuditFilter(actorRole: UserRole.admin), + ); + expect( + doCoordenador.items, + isEmpty, + reason: 'o filtro não fura o escopo: staff segue invisível', + ); + + final semFiltro = await endpoints.admin.auditLogs( + sessionBuilder, + limit: 50, + accessToken: t, + ); + expect( + semFiltro.items.length, + greaterThan(pagina.items.length), + reason: 'o parâmetro é opcional: sem ele, nada é filtrado', + ); + }, + ); + + test( + 'resposta serializada de consulta filtrada não vaza hash, nome nem UUID', + () async { + await endpoints.patients.listMicroArea( + sessionBuilder, + accessToken: _token(_acs, UserRole.acs, ma: _maA), + ); + final t = _token(_admin, UserRole.admin); + await endpoints.admin.indicators(sessionBuilder, accessToken: t); + + final pagina = await endpoints.admin.auditLogs( + sessionBuilder, + limit: 50, + accessToken: t, + filter: AdminAuditFilter( + actionType: 'read', + search: 'directory', + ), + ); + expect(pagina.items, isNotEmpty); + final json = jsonEncode(pagina.toJson()); + for (final proibido in [ + 'ipHash', + 'previousHash', + 'entryHash', + _nomePaciente, + 'Joana', + ]) { + expect(json, isNot(contains(proibido)), reason: proibido); + } + // O `id` da própria linha de auditoria é o identificador público da + // entrada (chave do keyset/UI), não um resourceId nem um userId. + final semIdDaLinha = jsonEncode([ + for (final e in pagina.items) (e.toJson()..remove('id')), + ]); + expect( + RegExp( + r'[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}', + ).hasMatch(semIdDaLinha), + isFalse, + reason: 'nenhum UUID completo de resourceId/userId: $semIdDaLinha', + ); + }, + ); + + test('coordenador da UBS A filtrando actorRole=admin: vazio', () async { + await endpoints.admin.indicators( + sessionBuilder, + accessToken: _token(_admin, UserRole.admin), + ); + final pagina = await endpoints.admin.auditLogs( + sessionBuilder, + limit: 50, + accessToken: _token(_coordA, UserRole.coordinator), + filter: AdminAuditFilter(actorRole: UserRole.admin), + ); + expect(pagina.items, isEmpty); + }); + }); + + group('pedidos do titular (#42), ponta a ponta pelo endpoint', () { + String tPaciente() => _token(_paciente, UserRole.patient, ma: _maA); + String tCoordA() => _token(_coordA, UserRole.coordinator); + + Future coordenadorB() async { + await _usuario( + session, + _coordB, + 'Coord B Sintético', + UserRole.coordinator, + ); + await StaffAccount.db.insertRow( + session, + StaffAccount( + id: UuidValue.fromString(_coordB), + enrollmentId: 'COO-EP-3', + active: true, + ubsId: UuidValue.fromString(_ubsB), + ), + ); + } + + Future idDoPedido() async => (await DataSubjectRequest.db.find( + session, + where: (t) => t.userId.equals(UuidValue.fromString(_paciente)), + )).single.id!.uuid; + + test( + 'correção: paciente pede, coordenador lista (rótulo), abre, analisa e ' + 'atende; o paciente vê completed e a nota em myData', + () async { + await endpoints.patients.requestDataCorrection( + sessionBuilder, + accessToken: tPaciente(), + details: 'Meu contato de emergência sintético mudou.', + ); + final id = await idDoPedido(); + + final pagina = await endpoints.admin.dataSubjectRequests( + sessionBuilder, + accessToken: tCoordA(), + limit: 50, + offset: 0, + ); + expect(pagina.items, hasLength(1)); + final item = pagina.items.single; + expect(item.id, id); + expect(item.type, DataSubjectRequestType.correction); + expect(item.status, DataSubjectRequestStatus.open); + expect(item.patientLabel, startsWith('#')); + final jsonLista = jsonEncode(pagina.toJson()); + expect(jsonLista, isNot(contains(_nomePaciente))); + expect(jsonLista, isNot(contains(_paciente))); + expect(jsonLista, isNot(contains('contato de emergência'))); + + final detalhe = await endpoints.admin.dataSubjectRequest( + sessionBuilder, + accessToken: tCoordA(), + id: id, + ); + expect(detalhe.details, 'Meu contato de emergência sintético mudou.'); + expect(jsonEncode(detalhe.toJson()), isNot(contains(_nomePaciente))); + + await endpoints.admin.startDataSubjectReview( + sessionBuilder, + accessToken: tCoordA(), + id: id, + ); + expect( + (await endpoints.admin.dataSubjectRequest( + sessionBuilder, + accessToken: tCoordA(), + id: id, + )).status, + DataSubjectRequestStatus.inReview, + ); + + await endpoints.admin.completeDataSubjectRequest( + sessionBuilder, + accessToken: tCoordA(), + id: id, + note: 'Contato atualizado pela equipe.', + ); + + final dados = await endpoints.patients.myData( + sessionBuilder, + accessToken: tPaciente(), + ); + final pedido = dados.requests.single; + expect(pedido.status, DataSubjectRequestStatus.completed); + expect(pedido.resolution, 'Contato atualizado pela equipe.'); + + final decisoes = await AuditLog.db.find( + session, + where: (t) => + t.resourceType.equals('data_subject_request') & + t.resourceId.equals(UuidValue.fromString(id)) & + t.actionType.equals('write') & + t.userId.equals(UuidValue.fromString(_coordA)), + ); + expect( + decisoes.map((e) => e.result).toSet(), + {'in_review', 'completed'}, + ); + }, + ); + + test('recusa: o paciente vê rejected e o motivo', () async { + await endpoints.patients.requestDataCorrection( + sessionBuilder, + accessToken: tPaciente(), + details: 'Pedido sintético a recusar.', + ); + final id = await idDoPedido(); + await endpoints.admin.rejectDataSubjectRequest( + sessionBuilder, + accessToken: _token(_admin, UserRole.admin), + id: id, + reason: 'Dado já está correto.', + ); + final pedido = (await endpoints.patients.myData( + sessionBuilder, + accessToken: tPaciente(), + )).requests.single; + expect(pedido.status, DataSubjectRequestStatus.rejected); + expect(pedido.resolution, 'Dado já está correto.'); + }); + + test( + 'acs e patient com token válido recebem AlertPermissionException nos cinco', + () async { + await endpoints.patients.requestDataDeletion( + sessionBuilder, + accessToken: tPaciente(), + ); + final id = await idDoPedido(); + for (final t in [ + _token(_acs, UserRole.acs, ma: _maA), + tPaciente(), + ]) { + for (final chamada in Function()>[ + () => endpoints.admin.dataSubjectRequests( + sessionBuilder, + accessToken: t, + limit: 50, + offset: 0, + ), + () => endpoints.admin.dataSubjectRequest( + sessionBuilder, + accessToken: t, + id: id, + ), + () => endpoints.admin.startDataSubjectReview( + sessionBuilder, + accessToken: t, + id: id, + ), + () => endpoints.admin.completeDataSubjectRequest( + sessionBuilder, + accessToken: t, + id: id, + ), + () => endpoints.admin.rejectDataSubjectRequest( + sessionBuilder, + accessToken: t, + id: id, + reason: 'motivo sintético', + ), + ]) { + await expectLater( + chamada(), + throwsA(isA()), + ); + } + } + for (final t in ['', 'lixo']) { + await expectLater( + endpoints.admin.dataSubjectRequests( + sessionBuilder, + accessToken: t, + limit: 50, + offset: 0, + ), + throwsA(isA()), + ); + } + final pedido = (await DataSubjectRequest.db.find( + session, + where: (t) => t.userId.equals(UuidValue.fromString(_paciente)), + )).single; + expect(pedido.status, DataSubjectRequestStatus.open); + }, + ); + + test( + 'coordenador de outra UBS não vê nem decide o pedido, e a resposta é a ' + 'mesma de um id inexistente', + () async { + await coordenadorB(); + await endpoints.patients.requestDataDeletion( + sessionBuilder, + accessToken: tPaciente(), + ); + final id = await idDoPedido(); + final tB = _token(_coordB, UserRole.coordinator); + + expect( + (await endpoints.admin.dataSubjectRequests( + sessionBuilder, + accessToken: tB, + limit: 50, + offset: 0, + )).items, + isEmpty, + ); + + Future mensagem(Future Function() chamada) async { + try { + await chamada(); + } on AdminInvalidRequestException catch (e) { + return e.message; + } + fail('deveria ter lançado AdminInvalidRequestException'); + } + + const inexistente = '00000000-0000-4000-8000-0000000000ff'; + for (final (alvo, outro) in [ + ( + () => endpoints.admin.startDataSubjectReview( + sessionBuilder, + accessToken: tB, + id: id, + ), + () => endpoints.admin.startDataSubjectReview( + sessionBuilder, + accessToken: tB, + id: inexistente, + ), + ), + ( + () => endpoints.admin.dataSubjectRequest( + sessionBuilder, + accessToken: tB, + id: id, + ), + () => endpoints.admin.dataSubjectRequest( + sessionBuilder, + accessToken: tB, + id: inexistente, + ), + ), + ( + () => endpoints.admin.completeDataSubjectRequest( + sessionBuilder, + accessToken: tB, + id: id, + ), + () => endpoints.admin.completeDataSubjectRequest( + sessionBuilder, + accessToken: tB, + id: inexistente, + ), + ), + ( + () => endpoints.admin.rejectDataSubjectRequest( + sessionBuilder, + accessToken: tB, + id: id, + reason: 'motivo sintético', + ), + () => endpoints.admin.rejectDataSubjectRequest( + sessionBuilder, + accessToken: tB, + id: inexistente, + reason: 'motivo sintético', + ), + ), + ]) { + expect(await mensagem(alvo), await mensagem(outro)); + } + + final pedido = (await DataSubjectRequest.db.find( + session, + where: (t) => t.userId.equals(UuidValue.fromString(_paciente)), + )).single; + expect(pedido.status, DataSubjectRequestStatus.open); + final usuario = await User.db.findById( + session, + UuidValue.fromString(_paciente), + ); + expect(usuario!.name, _nomePaciente); + }, + ); + + test( + 'exclusão atendida anonimiza o titular, myData mostra completed e o ' + 'JWT ainda vivo não abre pedido novo nem registra push', + () async { + await endpoints.patients.requestDataDeletion( + sessionBuilder, + accessToken: tPaciente(), + ); + final id = await idDoPedido(); + await endpoints.admin.completeDataSubjectRequest( + sessionBuilder, + accessToken: tCoordA(), + id: id, + ); + + final usuario = await User.db.findById( + session, + UuidValue.fromString(_paciente), + ); + expect(usuario!.name, isNot(_nomePaciente)); + expect(usuario.name, 'Titular removido'); + expect(usuario.cpfHash, startsWith('removed:')); + + final dados = await endpoints.patients.myData( + sessionBuilder, + accessToken: tPaciente(), + ); + expect( + dados.requests.single.status, + DataSubjectRequestStatus.completed, + ); + expect(dados.requests.single.resolution, isNull); + + await expectLater( + endpoints.patients.requestDataCorrection( + sessionBuilder, + accessToken: tPaciente(), + details: 'texto sintético depois da exclusão', + ), + throwsA(isA()), + ); + await expectLater( + endpoints.patients.updateConsent( + sessionBuilder, + accessToken: tPaciente(), + purpose: ConsentPurpose.segmentedPush, + granted: true, + ), + throwsA(isA()), + ); + await expectLater( + endpoints.devices.registerPushToken( + sessionBuilder, + accessToken: tPaciente(), + token: 'token-sintetico-pos-exclusao', + platform: 'android', + ), + throwsA(isA()), + ); + expect( + await PushToken.db.count( + session, + where: (t) => t.userId.equals(UuidValue.fromString(_paciente)), + ), + 0, + ); + expect( + await DataSubjectRequest.db.count( + session, + where: (t) => t.userId.equals(UuidValue.fromString(_paciente)), + ), + 1, + ); + }, + ); + }); + + test('página além do fim: items vazio e nextOffset nulo', () async { + final p = await endpoints.admin.alerts( + sessionBuilder, + accessToken: _token(_admin, UserRole.admin), + offset: 50, + limit: 50, + ); + expect(p.items, isEmpty); + expect(p.nextOffset, isNull); + }); + }); +} diff --git a/backend/sinalacs_server/test/integration/admin_monitoring_store_test.dart b/backend/sinalacs_server/test/integration/admin_monitoring_store_test.dart new file mode 100644 index 00000000..f4d9ae05 --- /dev/null +++ b/backend/sinalacs_server/test/integration/admin_monitoring_store_test.dart @@ -0,0 +1,262 @@ +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/admin/admin_read_service.dart'; +import 'package:sinalacs_server/src/application/admin/alert_escalation_service.dart'; +import 'package:sinalacs_server/src/application/audit/audit_trail.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_admin_read_store.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_alert_escalation_store.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_audit_trail.dart'; +import 'package:test/test.dart'; + +import '../support/health_data_fixtures.dart'; +import 'test_tools/serverpod_test_tools.dart'; + +/// `OrmAdminReadStore.pulse` (#44, D5) contra Postgres real: `overdueRed` com a +/// regra de `overdue` da leitura, `maxChangedAt` que anda com ACK e +/// escalonamento, e escopo por UBS. Dados sintéticos; ids próprios. +const _ubsA = '00000000-0000-4000-8000-0000000045a1'; +const _ubsB = '00000000-0000-4000-8000-0000000045a2'; +const _ubsC = '00000000-0000-4000-8000-0000000045a3'; +const _maA = '00000000-0000-4000-8000-0000000045a4'; +const _maB = '00000000-0000-4000-8000-0000000045a5'; +const _coord = '00000000-0000-4000-8000-0000000045a6'; +const _paciente = '00000000-0000-4000-8000-0000000045a7'; + +final _t = DateTime.utc(2026, 10, 11, 12); +const _limite = Duration(seconds: 300); + +Future _seed(Session s) async { + for (final (id, nome) in [ + (_ubsA, 'UBS A'), + (_ubsB, 'UBS B'), + (_ubsC, 'UBS C'), + ]) { + await Ubs.db.insertRow( + s, + Ubs( + id: UuidValue.fromString(id), + name: nome, + address: 'Endereço sintético', + city: 'São Paulo', + state: 'SP', + ), + ); + } + for (final (id, ubs) in [(_maA, _ubsA), (_maB, _ubsB)]) { + await MicroArea.db.insertRow( + s, + MicroArea( + id: UuidValue.fromString(id), + name: 'Microárea $id', + ubsId: UuidValue.fromString(ubs), + geoJsonBoundary: '{}', + ), + ); + } + for (final (id, role) in [ + (_paciente, UserRole.patient), + (_coord, UserRole.coordinator), + ]) { + await User.db.insertRow( + s, + User( + id: UuidValue.fromString(id), + cpfHash: 'admin-pulse-$id', + name: 'Sintético $id', + birthDate: DateTime.utc(1980), + role: role, + microAreaId: role == UserRole.patient + ? UuidValue.fromString(_maA) + : null, + createdAt: _t, + updatedAt: _t, + ), + ); + } + await Patient.db.insertRow( + s, + await encryptedPatient( + id: _paciente, + emergencyContact: 'contato sintético', + isChronic: false, + ), + ); +} + +Future _alerta( + Session s, { + required DateTime em, + String? ma = _maA, + RiskLevel risco = RiskLevel.red, + AlertStatus status = AlertStatus.pending, + DateTime? escalonadoEm, +}) => Alert.db.insertRow( + s, + Alert( + patientId: UuidValue.fromString(_paciente), + microAreaId: ma == null ? null : UuidValue.fromString(ma), + triggeredAt: em, + escalatedAt: escalonadoEm, + riskLevel: risco, + locationHash: 'hash-sintetico', + status: status, + mqttTopic: 'sinalacs/v1/microareas/${ma ?? 'x'}/alerts', + deviceId: 'device-sintetico', + retryCount: 0, + version: 0, + ), +); + +void main() { + withServerpod('Dado o pulso da monitoração (#44, D5)', ( + sessionBuilder, + endpoints, + ) { + late Session session; + late OrmAdminReadStore store; + + setUp(() async { + session = sessionBuilder.build(); + await _seed(session); + store = OrmAdminReadStore( + session: () => session, + escalationThreshold: _limite, + ); + }); + + Future pulso(AdminScope escopo, {DateTime? now}) => + store.pulse(escopo, now: now ?? _t, threshold: _limite); + + test('escopo sem alerta: overdueRed 0 e maxChangedAt nulo', () async { + await _alerta(session, em: _t.subtract(const Duration(hours: 1))); + final r = await pulso(const AdminScope.ubs(_ubsC)); + expect(r.overdueRed, 0); + expect(r.maxChangedAt, isNull); + }); + + test( + 'overdueRed segue a regra de overdue: só vermelho pendente não escalonado, estritamente além do limite', + () async { + const us = Duration(microseconds: 1); + await _alerta(session, em: _t.subtract(_limite + us)); // vence + await _alerta(session, em: _t.subtract(_limite)); // exatamente: não + await _alerta(session, em: _t.add(const Duration(hours: 1))); // futuro + await _alerta( + session, + em: _t.subtract(const Duration(hours: 2)), + escalonadoEm: _t.subtract(const Duration(minutes: 1)), + ); // escalonado: não + await _alerta( + session, + em: _t.subtract(const Duration(hours: 3)), + risco: RiskLevel.yellow, + ); // amarelo: não + await _alerta( + session, + em: _t.subtract(const Duration(hours: 4)), + status: AlertStatus.acknowledged, + ); // reconhecido: não + await _alerta( + session, + em: _t.subtract(const Duration(hours: 5)), + ma: null, + ); // sem microárea: só o sistema vê + + expect((await pulso(const AdminScope.ubs(_ubsA))).overdueRed, 1); + expect((await pulso(const AdminScope.system())).overdueRed, 2); + + // Concorda, alerta a alerta, com a lista da leitura no mesmo estado. + for (final escopo in [ + const AdminScope.ubs(_ubsA), + const AdminScope.system(), + ]) { + final lista = await store.alerts( + escopo, + limit: 100, + offset: 0, + now: _t, + ); + expect( + (await pulso(escopo)).overdueRed, + lista.items.where((a) => a.overdue).length, + reason: 'escopo ${escopo.ubsId ?? 'sistema'}', + ); + } + }, + ); + + test( + 'coordenador da UBS B não vê os alertas da UBS A: contagem e maxChangedAt só de B', + () async { + final b = await _alerta( + session, + em: _t.subtract(const Duration(hours: 2)), + ma: _maB, + ); + await _alerta(session, em: _t.subtract(const Duration(minutes: 30))); + await _alerta(session, em: _t.subtract(const Duration(minutes: 20))); + + final r = await pulso(const AdminScope.ubs(_ubsB)); + expect(r.overdueRed, 1); + expect(r.maxChangedAt, b.triggeredAt); + }, + ); + + test('maxChangedAt anda com o ACK do alerta', () async { + final a = await _alerta( + session, + em: _t.subtract(const Duration(hours: 1)), + ); + final antes = (await pulso(const AdminScope.ubs(_ubsA))).maxChangedAt; + expect(antes, a.triggeredAt); + + final ack = _t.subtract(const Duration(minutes: 1)); + await Alert.db.updateRow( + session, + a.copyWith(status: AlertStatus.acknowledged, acknowledgedAt: ack), + ); + final depois = await pulso(const AdminScope.ubs(_ubsA)); + expect(depois.maxChangedAt, ack); + expect(depois.overdueRed, 0); + }); + + test( + 'maxChangedAt anda com o escalonamento, e escalonar tira o alerta de overdueRed', + () async { + final a = await _alerta( + session, + em: _t.subtract(const Duration(hours: 1)), + ); + expect((await pulso(const AdminScope.ubs(_ubsA))).overdueRed, 1); + + final r = + await OrmAlertEscalationStore( + session: () => session, + appendAudit: OrmAuditTrail( + session: () => session, + chainSecret: 'segredo-sintetico-de-teste', + ).recordInTransaction, + ).escalate( + const AdminScope.system(), + alertId: a.id!.uuid, + actorId: _coord, + reason: EscalationReason.noAckTimeout, + now: _t, + threshold: _limite, + auditFor: (_) => AuditEvent( + userId: _coord, + actionType: 'write', + resourceType: 'admin_alert', + resourceId: a.id!.uuid, + result: 'escalated:no_ack_timeout', + ), + ); + expect(r.outcome, EscalationOutcome.escalated); + + final depois = await pulso(const AdminScope.ubs(_ubsA)); + expect(depois.maxChangedAt, _t); + expect(depois.overdueRed, 0); + }, + ); + }); +} diff --git a/backend/sinalacs_server/test/integration/admin_read_store_test.dart b/backend/sinalacs_server/test/integration/admin_read_store_test.dart new file mode 100644 index 00000000..2c13662b --- /dev/null +++ b/backend/sinalacs_server/test/integration/admin_read_store_test.dart @@ -0,0 +1,1393 @@ +import 'dart:convert'; + +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/admin/alert_escalation_service.dart'; +import 'package:sinalacs_server/src/application/audit/audit_trail.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_admin_read_store.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_alert_escalation_store.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_audit_trail.dart'; +import 'package:test/test.dart'; + +import '../support/health_data_fixtures.dart'; +import 'test_tools/serverpod_test_tools.dart'; + +/// Leitura do backoffice (#40) contra Postgres real: escopo por UBS, TMRAV, +/// paginação e minimização de PII. Dados sintéticos; ids próprios. +/// +/// UBS A: microárea A (1 ACS, 5 alertas recentes + 1 vermelho fora da janela de +/// 30 dias). UBS B: microárea B (1 vermelho pendente). Mais um alerta vermelho +/// SEM microárea, que só o escopo de sistema enxerga. +const _ubsA = '00000000-0000-4000-8000-0000000000e1'; +const _ubsB = '00000000-0000-4000-8000-0000000000e2'; +const _maA = '00000000-0000-4000-8000-0000000000e3'; +const _maB = '00000000-0000-4000-8000-0000000000e4'; +const _acsId = '00000000-0000-4000-8000-0000000000e5'; +const _coordId = '00000000-0000-4000-8000-0000000000e6'; +const _adminId = '00000000-0000-4000-8000-0000000000e7'; +const _semUbsId = '00000000-0000-4000-8000-0000000000e8'; +const _acsBId = '00000000-0000-4000-8000-0000000000e9'; +const _pacienteNome = 'Maria Sintética da Silva'; +const _paciente = '00000000-0000-4000-8000-0000000000f1'; + +final _t = DateTime.utc(2026, 10, 7, 12); + +/// Aritmética de instante legível nas fixtures da auditoria com filtros. +extension on DateTime { + DateTime operator +(Duration d) => add(d); +} + +Future _usuario( + Session s, + String id, + String nome, + UserRole role, { + String? ma, +}) => User.db + .insertRow( + s, + User( + id: UuidValue.fromString(id), + cpfHash: 'admin-read-$id', + name: nome, + birthDate: DateTime.utc(1980), + role: role, + microAreaId: ma == null ? null : UuidValue.fromString(ma), + createdAt: _t, + updatedAt: _t, + ), + ) + .then((_) {}); + +Future _alerta( + Session s, { + required RiskLevel risco, + required AlertStatus status, + required DateTime em, + String? ma, + Duration? reconhecidoApos, + DateTime? escalonadoEm, +}) => Alert.db + .insertRow( + s, + Alert( + patientId: UuidValue.fromString(_paciente), + microAreaId: ma == null ? null : UuidValue.fromString(ma), + triggeredAt: em, + escalatedAt: escalonadoEm, + acknowledgedAt: reconhecidoApos == null + ? null + : em.add(reconhecidoApos), + riskLevel: risco, + locationHash: 'hash-sintetico', + status: status, + mqttTopic: 'sinalacs/v1/microareas/${ma ?? 'x'}/alerts', + deviceId: 'device-sintetico', + retryCount: 0, + version: 0, + ), + ) + .then((_) {}); + +Future _seed(Session s) async { + for (final (id, nome) in [(_ubsA, 'UBS A'), (_ubsB, 'UBS B')]) { + await Ubs.db.insertRow( + s, + Ubs( + id: UuidValue.fromString(id), + name: nome, + address: 'Endereço sintético', + city: 'São Paulo', + state: 'SP', + ), + ); + } + for (final (id, nome, ubs) in [ + (_maA, 'Microárea A', _ubsA), + (_maB, 'Microárea B', _ubsB), + ]) { + await MicroArea.db.insertRow( + s, + MicroArea( + id: UuidValue.fromString(id), + name: nome, + ubsId: UuidValue.fromString(ubs), + geoJsonBoundary: '{}', + ), + ); + } + await _usuario(s, _acsId, 'Carla ACS Sintética', UserRole.acs, ma: _maA); + await Acs.db.insertRow( + s, + Acs( + id: UuidValue.fromString(_acsId), + enrollmentId: 'ACS-ADM-RD-1', + ubsId: UuidValue.fromString(_ubsA), + active: true, + ), + ); + await _usuario(s, _paciente, _pacienteNome, UserRole.patient, ma: _maA); + await Patient.db.insertRow( + s, + await encryptedPatient( + id: _paciente, + emergencyContact: 'x', + isChronic: false, + ), + ); + + await _usuario(s, _adminId, 'Admin Sintético', UserRole.admin); + await StaffAccount.db.insertRow( + s, + StaffAccount( + id: UuidValue.fromString(_adminId), + enrollmentId: 'ADM-RD-1', + active: true, + ), + ); + await _usuario(s, _coordId, 'Coord Sintético', UserRole.coordinator); + await StaffAccount.db.insertRow( + s, + StaffAccount( + id: UuidValue.fromString(_coordId), + enrollmentId: 'COO-RD-1', + active: true, + ubsId: UuidValue.fromString(_ubsA), + ), + ); + await _usuario(s, _semUbsId, 'Coord Sem UBS', UserRole.coordinator); + await StaffAccount.db.insertRow( + s, + StaffAccount( + id: UuidValue.fromString(_semUbsId), + enrollmentId: 'COO-RD-2', + active: true, + ), + ); + + const min = Duration(minutes: 1); + await _alerta( + s, + risco: RiskLevel.red, + status: AlertStatus.pending, + em: _t.subtract(min * 10), + ma: _maA, + ); // a1 + await _alerta( + s, + risco: RiskLevel.red, + status: AlertStatus.acknowledged, + em: _t.subtract(min * 120), + ma: _maA, + reconhecidoApos: const Duration(seconds: 60), + ); // a2 + await _alerta( + s, + risco: RiskLevel.red, + status: AlertStatus.acknowledged, + em: _t.subtract(min * 60), + ma: _maA, + reconhecidoApos: const Duration(seconds: 120), + ); // a3 + await _alerta( + s, + risco: RiskLevel.yellow, + status: AlertStatus.pending, + em: _t.subtract(min * 30), + ma: _maA, + ); // a4 + await _alerta( + s, + risco: RiskLevel.green, + status: AlertStatus.pending, + em: _t.subtract(min * 40), + ma: _maA, + ); // a5 + await _alerta( + s, + risco: RiskLevel.red, + status: AlertStatus.pending, + em: _t.subtract(min * 5), + ma: _maB, + ); // b1 + await _alerta( + s, + risco: RiskLevel.red, + status: AlertStatus.pending, + em: _t.subtract(min * 180), + ); // n1: sem microárea + await _alerta( + s, + risco: RiskLevel.red, + status: AlertStatus.acknowledged, + em: _t.subtract(const Duration(days: 40)), + ma: _maA, + reconhecidoApos: const Duration(seconds: 1000), + ); // o1: fora dos 30 dias +} + +void main() { + withServerpod('Dado a leitura do backoffice (#40)', ( + sessionBuilder, + endpoints, + ) { + late Session session; + late OrmAdminReadStore store; + setUp(() async { + session = sessionBuilder.build(); + await _seed(session); + store = OrmAdminReadStore(session: () => session); + }); + + test( + 'indicadores do sistema contam por risco e calculam o TMRAV só na janela de 30 dias', + () async { + final r = await store.indicators(const AdminScope.system(), now: _t); + expect(r.red, 6); // a1 a2 a3 b1 n1 o1 + expect(r.yellow, 1); + expect(r.green, 1); + expect(r.openRedAlerts, 3); // a1 b1 n1 + expect(r.acknowledgedRedAlerts, 3); // a2 a3 o1 + expect( + r.tmravSeconds, + 90, + reason: 'média de 60 s e 120 s; o1 (40 dias) fica de fora', + ); + }, + ); + + test( + 'indicadores da UBS A não enxergam a UBS B nem o alerta sem microárea', + () async { + final r = await store.indicators(const AdminScope.ubs(_ubsA), now: _t); + expect(r.red, 4); // a1 a2 a3 o1 + expect(r.openRedAlerts, 1); + expect(r.acknowledgedRedAlerts, 3); + }, + ); + + test( + 'UBS B enxerga só o seu alerta e TMRAV nulo sem vermelho reconhecido', + () async { + final r = await store.indicators(const AdminScope.ubs(_ubsB), now: _t); + expect(r.red, 1); + expect(r.openRedAlerts, 1); + expect(r.tmravSeconds, isNull); + }, + ); + + test('microáreas trazem o ACS vinculado e respeitam a UBS', () async { + final sistema = await store.microAreas(const AdminScope.system()); + expect( + sistema.map((m) => m.name), + containsAll(['Microárea A', 'Microárea B']), + ); + final a = sistema.firstWhere((m) => m.name == 'Microárea A'); + expect(a.acsName, 'Carla ACS Sintética'); + expect(a.acsEnrollmentId, 'ACS-ADM-RD-1'); + expect(a.acsActive, isTrue); + final b = sistema.firstWhere((m) => m.name == 'Microárea B'); + expect(b.acsName, 'Sem ACS vinculado'); + expect(b.acsActive, isFalse); + + final soA = await store.microAreas(const AdminScope.ubs(_ubsA)); + expect(soA.map((m) => m.name), ['Microárea A']); + }); + + test( + 'microárea com DOIS ACS aparece UMA vez (o id é a chave do filtro do app)', + () async { + const segundoAcs = '00000000-0000-4000-8000-0000000000e9'; + await _usuario( + session, + segundoAcs, + 'Bruno ACS Sintético', + UserRole.acs, + ma: _maA, + ); + await Acs.db.insertRow( + session, + Acs( + id: UuidValue.fromString(segundoAcs), + enrollmentId: 'ACS-ADM-RD-2', + ubsId: UuidValue.fromString(_ubsA), + active: false, + ), + ); + + final lista = await store.microAreas(const AdminScope.system()); + + expect( + lista.map((m) => m.id).toSet(), + hasLength(lista.length), + reason: 'ids únicos', + ); + final a = lista.singleWhere((m) => m.name == 'Microárea A'); + expect(a.acsName, 'Carla ACS Sintética, Bruno ACS Sintético'); + expect(a.acsEnrollmentId, 'ACS-ADM-RD-1, ACS-ADM-RD-2'); + expect(a.acsActive, isTrue, reason: 'há ao menos um ACS ativo'); + expect( + lista.singleWhere((m) => m.name == 'Microárea B').acsActive, + isFalse, + ); + }, + ); + + test( + 'alertas: vencidos primeiro, depois triggeredAt desc, paginação por offset e nextOffset nulo no fim', + () async { + const sistema = AdminScope.system(); + final p1 = await store.alerts(sistema, limit: 3, offset: 0, now: _t); + expect(p1.items, hasLength(3)); + expect(p1.nextOffset, 3); + // a1 (10 min) e n1 (180 min) são vermelhos pendentes além dos 300 s: + // vêm antes, entre si do mais recente ao mais antigo. b1 (5 min, + // exatamente o prazo) não vence e abre os demais. + expect(p1.items.map((a) => a.triggeredAt).toList(), [ + _t.subtract(const Duration(minutes: 10)), + _t.subtract(const Duration(minutes: 180)), + _t.subtract(const Duration(minutes: 5)), + ]); + expect(p1.items.map((a) => a.overdue).toList(), [true, true, false]); + final p3 = await store.alerts(sistema, limit: 3, offset: 6, now: _t); + expect(p3.items, hasLength(2)); + expect(p3.nextOffset, isNull); + final alem = await store.alerts(sistema, limit: 3, offset: 30, now: _t); + expect(alem.items, isEmpty); + expect(alem.nextOffset, isNull); + }, + ); + + group('vencidos primeiro (#44): nenhum vencido fica além da 1ª página', () { + /// UBS B, microárea B: b1 (exatamente 300 s, não vence) + 54 vermelhos + /// recentes (1..54 s) + 1 vermelho pendente de 10 h atrás — o ÚNICO + /// vencido, e o mais antigo de todos. 56 alertas no escopo. + late DateTime vencidoEm; + setUp(() async { + for (var i = 1; i <= 54; i++) { + await _alerta( + session, + risco: i.isEven ? RiskLevel.red : RiskLevel.yellow, + status: AlertStatus.pending, + em: _t.subtract(Duration(seconds: i)), + ma: _maB, + ); + } + vencidoEm = _t.subtract(const Duration(hours: 10)); + await _alerta( + session, + risco: RiskLevel.red, + status: AlertStatus.pending, + em: vencidoEm, + ma: _maB, + ); + }); + + test( + 'o vencido mais antigo vem na 1ª página (limit 50), à frente de todos', + () async { + final p1 = await store.alerts( + const AdminScope.ubs(_ubsB), + limit: 50, + offset: 0, + now: _t, + ); + expect(p1.items, hasLength(50)); + expect(p1.items.first.triggeredAt, vencidoEm); + expect(p1.items.first.overdue, isTrue); + expect(p1.items.skip(1).any((a) => a.overdue), isFalse); + }, + ); + + test('a ordem dos não vencidos segue triggeredAt desc', () async { + final p1 = await store.alerts( + const AdminScope.ubs(_ubsB), + limit: 50, + offset: 0, + now: _t, + ); + final resto = p1.items.skip(1).map((a) => a.triggeredAt).toList(); + expect(resto, [ + for (var i = 1; i <= 49; i++) _t.subtract(Duration(seconds: i)), + ]); + }); + + test( + 'paginação: nextOffset 50, 2ª página com 6 e nextOffset nulo, sem repetir nem perder linha', + () async { + const ubsB = AdminScope.ubs(_ubsB); + final p1 = await store.alerts(ubsB, limit: 50, offset: 0, now: _t); + expect(p1.nextOffset, 50); + final p2 = await store.alerts( + ubsB, + limit: 50, + offset: p1.nextOffset!, + now: _t, + ); + expect(p2.items, hasLength(6)); + expect(p2.nextOffset, isNull); + final ids = [...p1.items, ...p2.items].map((a) => a.id).toList(); + expect(ids.toSet(), hasLength(56)); + // O último da lista é b1, o mais antigo dos não vencidos. + expect( + p2.items.last.triggeredAt, + _t.subtract(const Duration(minutes: 5)), + ); + }, + ); + + test( + 'filtros e escopo seguem valendo: microárea, status e UBS', + () async { + final soB = await store.alerts( + const AdminScope.system(), + microAreaId: _maB, + status: AlertStatus.pending, + limit: 50, + offset: 0, + now: _t, + ); + expect(soB.items.first.triggeredAt, vencidoEm); + expect( + soB.items.every((a) => a.microAreaName == 'Microárea B'), + isTrue, + ); + + final reconhecidosB = await store.alerts( + const AdminScope.system(), + microAreaId: _maB, + status: AlertStatus.acknowledged, + limit: 50, + offset: 0, + now: _t, + ); + expect(reconhecidosB.items, isEmpty); + + // O coordenador da UBS A não vê o vencido da UBS B; o dele (a1) + // abre a lista dele. + final soA = await store.alerts( + const AdminScope.ubs(_ubsA), + limit: 50, + offset: 0, + now: _t, + ); + expect(soA.items, hasLength(6)); + expect(soA.items.any((a) => a.triggeredAt == vencidoEm), isFalse); + expect( + soA.items.first.triggeredAt, + _t.subtract(const Duration(minutes: 10)), + ); + expect(soA.items.first.overdue, isTrue); + }, + ); + }); + + test( + 'alertas trazem só rótulo do paciente, sem nome nem UUID inteiro', + () async { + final p = await store.alerts( + const AdminScope.system(), + limit: 20, + offset: 0, + now: _t, + ); + for (final a in p.items) { + expect(a.patientLabel, matches(RegExp(r'^#[0-9A-F]{4}$'))); + } + final json = jsonEncode(p.toJson()); + expect(json, isNot(contains(_pacienteNome))); + expect(json, isNot(contains(_paciente))); + }, + ); + + test( + 'alerta sem microárea: conta no sistema, não aparece na UBS', + () async { + final sistema = await store.alerts( + const AdminScope.system(), + limit: 50, + offset: 0, + now: _t, + ); + expect(sistema.items, hasLength(8)); + expect( + sistema.items.where((a) => a.microAreaName == 'Sem microárea'), + hasLength(1), + ); + final soA = await store.alerts( + const AdminScope.ubs(_ubsA), + limit: 50, + offset: 0, + now: _t, + ); + expect(soA.items, hasLength(6)); + expect( + soA.items.any((a) => a.microAreaName == 'Sem microárea'), + isFalse, + ); + }, + ); + + test('alertas filtram por microárea e por status', () async { + final b = await store.alerts( + const AdminScope.system(), + microAreaId: _maB, + limit: 50, + offset: 0, + now: _t, + ); + expect(b.items, hasLength(1)); + final reconhecidos = await store.alerts( + const AdminScope.ubs(_ubsA), + status: AlertStatus.acknowledged, + limit: 50, + offset: 0, + now: _t, + ); + expect(reconhecidos.items, hasLength(3)); + expect(reconhecidos.items.every((a) => !a.overdue), isTrue); + }); + + test( + 'filtro de microárea de OUTRA UBS devolve vazio para o coordenador', + () async { + final r = await store.alerts( + const AdminScope.ubs(_ubsA), + microAreaId: _maB, + limit: 50, + offset: 0, + now: _t, + ); + expect(r.items, isEmpty); + }, + ); + + group('escalonamento (#44): escalatedAt e overdue calculado no servidor', () { + // Limite padrão: 300 s. `_t` é o "agora" do servidor. + const limite = Duration(seconds: 300); + + /// Cria um alerta na microárea A (`em` relativo a `_t`) e devolve o item + /// listado pelo store, localizado pelo instante exato do disparo. + Future lido(DateTime em) async { + final p = await store.alerts( + const AdminScope.system(), + limit: 100, + offset: 0, + now: _t, + ); + return p.items.singleWhere( + (a) => a.triggeredAt == em && a.microAreaName == 'Microárea A', + ); + } + + // Pina o acordo entre a leitura (`overdue`, OrmAdminReadStore) e o + // escalonamento (`vencido`, OrmAlertEscalationStore): o botão "Escalar" + // depende de os dois SQLs dizerem o mesmo. Se uma das regras mudar, este + // teste quebra. Referenciado nos dois pontos de SQL. + test( + 'acordo: overdue da leitura == escalonar devolve escalated (mesmo now e limite)', + () async { + const us = Duration(microseconds: 1); + Future semear( + RiskLevel risco, + DateTime em, { + DateTime? escalonadoEm, + }) => Alert.db.insertRow( + session, + Alert( + patientId: UuidValue.fromString(_paciente), + microAreaId: UuidValue.fromString(_maA), + triggeredAt: em, + escalatedAt: escalonadoEm, + riskLevel: risco, + locationHash: 'hash-sintetico', + status: AlertStatus.pending, + mqttTopic: 'sinalacs/v1/microareas/$_maA/alerts', + deviceId: 'device-sintetico', + retryCount: 0, + version: 0, + ), + ); + + // Instantes distintos entre si e dos fixtures do _seed. + final casos = <(String, Alert, bool, EscalationOutcome)>[ + ( + 'exatamente o limite', + await semear(RiskLevel.red, _t.subtract(limite)), + false, + EscalationOutcome.notDue, + ), + ( + 'limite + 1 microssegundo', + await semear(RiskLevel.red, _t.subtract(limite + us)), + true, + EscalationOutcome.escalated, + ), + ( + 'limite - 1 microssegundo', + await semear(RiskLevel.red, _t.subtract(limite - us)), + false, + EscalationOutcome.notDue, + ), + ( + 'limite + 1 s', + await semear( + RiskLevel.red, + _t.subtract(limite + const Duration(seconds: 1)), + ), + true, + EscalationOutcome.escalated, + ), + ( + 'limite - 1 s', + await semear( + RiskLevel.red, + _t.subtract(limite - const Duration(seconds: 1)), + ), + false, + EscalationOutcome.notDue, + ), + ( + 'triggeredAt 2 h no futuro', + await semear(RiskLevel.red, _t.add(const Duration(hours: 2))), + false, + EscalationOutcome.notDue, + ), + ( + 'já escalonado', + await semear( + RiskLevel.red, + _t.subtract(const Duration(hours: 5, minutes: 11)), + escalonadoEm: _t.subtract(const Duration(minutes: 1)), + ), + false, + EscalationOutcome.alreadyEscalated, + ), + ( + 'amarelo', + await semear( + RiskLevel.yellow, + _t.subtract(const Duration(hours: 5, minutes: 12)), + ), + false, + EscalationOutcome.notRed, + ), + ]; + + // O microssegundo precisa sobreviver à ida e volta pelo Postgres; + // sem isso o caso "+1 µs" não exercitaria a unidade. + final mais1us = casos[1].$2; + expect( + (await Alert.db.findById(session, mais1us.id!))!.triggeredAt, + _t.subtract(limite + us), + ); + + // Leitura de TODOS antes de escalar qualquer um (escalar muta a linha). + final lista = await store.alerts( + const AdminScope.system(), + limit: 100, + offset: 0, + now: _t, + ); + final overdue = { + for (final a in lista.items) a.id: a.overdue, + }; + + // Terceiro uso da mesma regra: o `overdueRed` do pulso (D5) conta + // exatamente os `overdue` da leitura, no mesmo estado e limite. + final pulso = await store.pulse( + const AdminScope.system(), + now: _t, + threshold: limite, + ); + expect( + pulso.overdueRed, + lista.items.where((a) => a.overdue).length, + reason: 'overdueRed do pulso e overdue da leitura discordam', + ); + + final escalonador = OrmAlertEscalationStore( + session: () => session, + appendAudit: OrmAuditTrail( + session: () => session, + chainSecret: 'segredo-sintetico-de-teste', + ).recordInTransaction, + ); + for (final (nome, alerta, esperadoOverdue, esperadoDesfecho) + in casos) { + final id = alerta.id!.uuid; + final r = await escalonador.escalate( + const AdminScope.system(), + alertId: id, + actorId: _coordId, + reason: EscalationReason.noAckTimeout, + now: _t, + threshold: limite, + auditFor: (o) => AuditEvent( + userId: _coordId, + actionType: 'write', + resourceType: 'admin_alert', + resourceId: id, + result: 'escalated:no_ack_timeout', + ), + ); + expect(r.outcome, esperadoDesfecho, reason: nome); + expect(overdue[id], esperadoOverdue, reason: 'leitura: $nome'); + expect( + overdue[id], + r.outcome == EscalationOutcome.escalated, + reason: 'leitura e escalonamento discordam: $nome', + ); + } + }, + ); + + test( + 'vermelho pendente acima do limite: overdue=true, escalatedAt nulo', + () async { + final em = _t.subtract(limite + const Duration(seconds: 1)); + await _alerta( + session, + risco: RiskLevel.red, + status: AlertStatus.pending, + em: em, + ma: _maA, + ); + final a = await lido(em); + expect(a.overdue, isTrue); + expect(a.escalatedAt, isNull); + }, + ); + + test('vermelho pendente dentro do prazo: overdue=false', () async { + final em = _t.subtract(limite - const Duration(seconds: 1)); + await _alerta( + session, + risco: RiskLevel.red, + status: AlertStatus.pending, + em: em, + ma: _maA, + ); + expect((await lido(em)).overdue, isFalse); + }); + + test( + 'exatamente no limite: overdue=false (estrito, como o escalonamento)', + () async { + final em = _t.subtract(limite); + await _alerta( + session, + risco: RiskLevel.red, + status: AlertStatus.pending, + em: em, + ma: _maA, + ); + expect((await lido(em)).overdue, isFalse); + }, + ); + + test('triggeredAt no futuro nunca vence', () async { + final em = _t.add(const Duration(hours: 1)); + await _alerta( + session, + risco: RiskLevel.red, + status: AlertStatus.pending, + em: em, + ma: _maA, + ); + expect((await lido(em)).overdue, isFalse); + }); + + test('reconhecido e amarelo antigos não são overdue', () async { + final emAck = _t.subtract(const Duration(hours: 5, minutes: 3)); + final emAmarelo = _t.subtract(const Duration(hours: 5, minutes: 4)); + await _alerta( + session, + risco: RiskLevel.red, + status: AlertStatus.acknowledged, + em: emAck, + ma: _maA, + reconhecidoApos: const Duration(seconds: 10), + ); + await _alerta( + session, + risco: RiskLevel.yellow, + status: AlertStatus.pending, + em: emAmarelo, + ma: _maA, + ); + expect((await lido(emAck)).overdue, isFalse); + expect((await lido(emAmarelo)).overdue, isFalse); + }); + + test( + 'D3: escalonado continua pending, listado, no filtro pending e em openRedAlerts; overdue=false', + () async { + final antes = await store.indicators( + const AdminScope.system(), + now: _t, + ); + final em = _t.subtract(const Duration(hours: 5, minutes: 7)); + final escalonado = _t.subtract(const Duration(minutes: 1)); + await _alerta( + session, + risco: RiskLevel.red, + status: AlertStatus.pending, + em: em, + ma: _maA, + escalonadoEm: escalonado, + ); + + final a = await lido(em); + expect(a.escalatedAt, escalonado); + expect(a.overdue, isFalse); + expect(a.status, AlertStatus.pending); + + final pendentes = await store.alerts( + const AdminScope.system(), + status: AlertStatus.pending, + limit: 100, + offset: 0, + now: _t, + ); + expect(pendentes.items.any((x) => x.triggeredAt == em), isTrue); + + final depois = await store.indicators( + const AdminScope.system(), + now: _t, + ); + expect( + depois.openRedAlerts, + antes.openRedAlerts + 1, + reason: 'escalar nunca esconde o alerta (D3)', + ); + }, + ); + + test('o limite é injetável: com 60 s, 2 min já vence', () async { + final menor = OrmAdminReadStore( + session: () => session, + escalationThreshold: const Duration(seconds: 60), + ); + final em = _t.subtract(const Duration(minutes: 2)); + await _alerta( + session, + risco: RiskLevel.red, + status: AlertStatus.pending, + em: em, + ma: _maA, + ); + final p = await menor.alerts( + const AdminScope.system(), + limit: 100, + offset: 0, + now: _t, + ); + expect(p.items.singleWhere((a) => a.triggeredAt == em).overdue, isTrue); + expect((await lido(em)).overdue, isFalse); + }); + + test('coordenador de OUTRA UBS não enxerga o alerta vencido', () async { + final em = _t.subtract(const Duration(hours: 5, minutes: 9)); + await _alerta( + session, + risco: RiskLevel.red, + status: AlertStatus.pending, + em: em, + ma: _maA, + ); + final p = await store.alerts( + const AdminScope.ubs(_ubsB), + limit: 100, + offset: 0, + now: _t, + ); + expect(p.items.any((a) => a.triggeredAt == em), isFalse); + }); + }); + + test( + 'auditoria: sequence desc, keyset, rótulo sem nome e sem hash nem IP', + () async { + final trilha = OrmAuditTrail( + session: () => session, + chainSecret: 'segredo-sintetico-de-teste', + ); + for (final (quem, recurso) in [ + (_adminId, 'admin_indicators'), + (_adminId, 'admin_alerts'), + (_acsId, 'session'), + ]) { + await trilha.record( + AuditEvent( + userId: quem, + actionType: 'read', + resourceType: recurso, + result: 'success', + ), + ); + } + final p = await store.auditLogs(const AdminScope.system(), limit: 2); + expect(p.items, hasLength(2)); + expect(p.nextBeforeSequence, p.items.last.entry.sequence); + expect(p.items.first.entry.sequence, greaterThan(p.items.last.entry.sequence)); + final proxima = await store.auditLogs( + const AdminScope.system(), + limit: 2, + beforeSequence: p.nextBeforeSequence, + ); + expect(proxima.items.first.entry.sequence, lessThan(p.items.last.entry.sequence)); + + final todas = await store.auditLogs( + const AdminScope.system(), + limit: 50, + ); + final rotulos = todas.items.map((e) => e.entry.userLabel).toSet(); + expect( + rotulos, + containsAll(['ADM-RD-1 (Administrador)', 'ACS-ADM-RD-1 (ACS)']), + ); + // O que a tela recebe é o `entry`; o selo interno não serializa. + final json = jsonEncode([for (final r in todas.items) r.entry.toJson()]); + expect(json, isNot(contains('Admin Sintético'))); + expect(json, isNot(contains('Hash'))); + expect(json, isNot(contains('ip'))); + }, + ); + + test( + 'auditoria do coordenador: só atores com microárea da UBS dele; staff fica invisível', + () async { + final trilha = OrmAuditTrail( + session: () => session, + chainSecret: 'segredo-sintetico-de-teste', + ); + // Dois staff (sem microárea: administrador e coordenador), o ACS da + // microárea A e o paciente dela — os dois últimos são da UBS A. + for (final (quem, recurso) in [ + (_adminId, 'admin_indicators'), + (_coordId, 'admin_audit_logs'), + (_acsId, 'session'), + (_paciente, 'triage_session'), + ]) { + await trilha.record( + AuditEvent( + userId: quem, + actionType: 'read', + resourceType: recurso, + result: 'success', + ), + ); + } + + final sistema = await store.auditLogs( + const AdminScope.system(), + limit: 50, + ); + expect(sistema.items, hasLength(4), reason: 'o administrador vê tudo'); + + final daUbsA = await store.auditLogs( + const AdminScope.ubs(_ubsA), + limit: 50, + ); + expect(daUbsA.items, hasLength(2)); + expect( + daUbsA.items.map((e) => e.entry.userLabel).toSet(), + {'ACS-ADM-RD-1 (ACS)', 'Paciente #00F1'}, + reason: 'só atores com microárea na UBS A', + ); + expect( + daUbsA.items.map((e) => e.entry.resourceType).toSet(), + {'session', 'triage_session'}, + reason: + 'as linhas de staff (admin_indicators, admin_audit_logs) ficam de fora', + ); + + expect( + (await store.auditLogs(const AdminScope.ubs(_ubsB), limit: 50)).items, + isEmpty, + reason: 'nenhum ator com microárea na UBS B', + ); + }, + ); + + test( + 'auditoria escopada pagina por keyset dentro do escopo', + () async { + final trilha = OrmAuditTrail( + session: () => session, + chainSecret: 'segredo-sintetico-de-teste', + ); + for (final (quem, recurso) in [ + (_adminId, 'admin_indicators'), + (_acsId, 'session'), + (_paciente, 'triage_session'), + ]) { + await trilha.record( + AuditEvent( + userId: quem, + actionType: 'read', + resourceType: recurso, + result: 'success', + ), + ); + } + + final soA = const AdminScope.ubs(_ubsA); + final p1 = await store.auditLogs(soA, limit: 1); + expect(p1.items, hasLength(1)); + expect(p1.nextBeforeSequence, p1.items.last.entry.sequence); + + final p2 = await store.auditLogs( + soA, + limit: 1, + beforeSequence: p1.nextBeforeSequence, + ); + expect(p2.items, hasLength(1)); + expect(p2.items.first.entry.sequence, lessThan(p1.items.first.entry.sequence)); + expect( + p2.nextBeforeSequence, + isNull, + reason: 'a terceira linha (do staff) não pertence ao escopo', + ); + }, + ); + + test( + 'ubsOf devolve a UBS do coordenador e null para administrador e sem UBS', + () async { + expect(await store.ubsOf(_coordId), _ubsA); + expect(await store.ubsOf(_adminId), isNull); + expect(await store.ubsOf(_semUbsId), isNull); + expect( + await store.ubsOf('00000000-0000-4000-8000-0000000000ff'), + isNull, + ); + }, + ); + + group('auditoria com filtros (#45, D1/D2/D10)', () { + // 12 linhas de 3 autores (admin, ACS da UBS A, ACS da UBS B), gravadas + // direto no ORM para controlar `timestamp` e hashes. A leitura não + // verifica a cadeia; só o filtro e o keyset estão em teste aqui. + // Linhas 5 e 6 têm o MESMO timestamp; a 8 tem sequence maior e + // timestamp menor que a 7. A 12 tem ipHash/entryHash com "cafe". + final t0 = DateTime.utc(2026, 10, 1); + const h = Duration(hours: 1); + late int base; + + Future linha( + int n, + String quem, + String acao, + String recurso, + String resultado, + DateTime em, { + String hash = '', + }) => AuditLog.db + .insertRow( + session, + AuditLog( + userId: UuidValue.fromString(quem), + actionType: acao, + resourceType: recurso, + timestamp: em, + ipHash: hash.isEmpty ? '0' * 64 : hash, + result: resultado, + sequence: base + n - 1, + previousHash: '0' * 64, + entryHash: hash.isEmpty ? '1' * 64 : hash, + ), + ) + .then((_) {}); + + setUp(() async { + await _usuario( + session, + _acsBId, + 'Bruno ACS Sintético', + UserRole.acs, + ma: _maB, + ); + await Acs.db.insertRow( + session, + Acs( + id: UuidValue.fromString(_acsBId), + enrollmentId: 'ACS-ADM-RD-2', + ubsId: UuidValue.fromString(_ubsB), + active: true, + ), + ); + final max = await session.db.unsafeQuery( + 'SELECT COALESCE(max(sequence), 0) FROM audit_logs', + ); + base = (max.single[0] as int) + 1; + await linha(1, _adminId, 'read', 'admin_indicators', 'success', t0 + h); + await linha( + 2, + _adminId, + 'read', + 'admin_audit_logs', + 'success', + t0 + h * 2, + ); + await linha(3, _acsId, 'login', 'session', 'granted', t0 + h * 3); + await linha( + 4, + _acsId, + 'read', + 'patient_directory', + 'granted', + t0 + h * 4, + ); + await linha( + 5, + _acsId, + 'write', + 'visit', + 'denied_territory', + t0 + h * 5, + ); + await linha(6, _acsBId, 'login', 'session', 'granted', t0 + h * 5); + await linha( + 7, + _acsBId, + 'read', + 'patient_directory', + 'granted', + t0 + h * 6, + ); + await linha( + 8, + _acsBId, + 'write', + 'visit', + 'success', + t0 + h * 2 + h ~/ 2, + ); + await linha(9, _adminId, 'write', 'admin_acs', 'created', t0 + h * 7); + await linha(10, _acsId, 'read', 'alert', 'denied', t0 + h * 8); + await linha(11, _acsBId, 'write', 'alert', 'acknowledged', t0 + h * 9); + await linha( + 12, + _adminId, + 'login', + 'staff_session', + 'granted', + t0 + h * 10, + hash: 'cafe' * 16, + ); + }); + + /// Números (1..12) das linhas devolvidas, na ordem da página. + Future> nums( + AdminAuditFilter? f, { + AdminScope scope = const AdminScope.system(), + int limit = 50, + }) async => [ + for (final e in (await store.auditLogs( + scope, + filter: f, + limit: limit, + )).items) + e.entry.sequence - base + 1, + ]; + + test('sem filtro: as 12, sequence desc', () async { + expect(await nums(null), [12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1]); + expect(await nums(AdminAuditFilter()), hasLength(12)); + }); + + test('cada filtro isolado', () async { + expect(await nums(AdminAuditFilter(actionType: 'login')), [12, 6, 3]); + expect( + await nums(AdminAuditFilter(resourceType: 'session')), + [6, 3], + reason: 'igualdade: staff_session não entra', + ); + expect( + await nums(AdminAuditFilter(result: 'granted')), + [12, 7, 6, 4, 3], + ); + expect( + await nums(AdminAuditFilter(from: t0 + h * 5)), + [12, 11, 10, 9, 7, 6, 5], + reason: 'from inclusivo: 5 e 6 (mesmo timestamp) entram', + ); + expect( + await nums(AdminAuditFilter(to: t0 + h * 5)), + [8, 4, 3, 2, 1], + reason: 'to exclusivo: 5 e 6 ficam de fora; 8 (tempo menor) entra', + ); + expect( + await nums(AdminAuditFilter(actorRole: UserRole.admin)), + [12, 9, 2, 1], + ); + expect( + await nums(AdminAuditFilter(actorRole: UserRole.acs)), + [11, 10, 8, 7, 6, 5, 4, 3], + ); + expect( + await nums(AdminAuditFilter(actorEnrollmentId: 'acs-adm-rd-2')), + [11, 8, 7, 6], + reason: 'matrícula por igualdade, sem caixa', + ); + expect( + await nums(AdminAuditFilter(actorEnrollmentId: 'ACS-ADM-RD')), + isEmpty, + reason: 'matrícula é igualdade, não prefixo', + ); + }); + + test('fronteira de to: um microssegundo depois inclui 5 e 6', () async { + expect( + await nums( + AdminAuditFilter( + from: t0 + h * 5, + to: t0 + h * 5 + const Duration(microseconds: 1), + ), + ), + [6, 5], + ); + }); + + test('busca: prefixo da matrícula, resourceType e result', () async { + expect( + await nums(AdminAuditFilter(search: 'acs-adm-rd-2')), + [11, 8, 7, 6], + ); + expect( + await nums(AdminAuditFilter(search: 'ADM-RD')), + [12, 9, 2, 1], + reason: 'prefixo da matrícula do admin, sem caixa', + ); + expect( + await nums(AdminAuditFilter(search: 'RD-1')), + isEmpty, + reason: 'na matrícula é só prefixo, não substring', + ); + expect(await nums(AdminAuditFilter(search: 'directory')), [7, 4]); + expect(await nums(AdminAuditFilter(search: 'territ')), [5]); + }); + + test('busca: curingas do usuário são literais', () async { + expect(await nums(AdminAuditFilter(search: '%')), isEmpty); + expect(await nums(AdminAuditFilter(search: '%%')), isEmpty); + expect( + await nums(AdminAuditFilter(search: 'a_')), + isEmpty, + reason: '"_" sem escape casaria "al" de alert e "ad" de admin', + ); + expect(await nums(AdminAuditFilter(search: r'\')), isEmpty); + expect( + await nums(AdminAuditFilter(search: 'n_a')), + [9, 2], + reason: '"_" literal ainda casa admin_acs e admin_audit_logs', + ); + }); + + test( + 'result com ":" (escalated:no_ack_timeout) filtra por igualdade', + () async { + await linha( + 13, + _adminId, + 'write', + 'admin_alert', + 'escalated:no_ack_timeout', + t0 + h * 11, + ); + expect( + await nums(AdminAuditFilter(result: 'escalated:no_ack_timeout')), + [13], + ); + expect( + await nums(AdminAuditFilter(result: 'escalated')), + isEmpty, + reason: 'igualdade, não prefixo', + ); + }, + ); + + test('busca nunca casa ipHash nem entryHash', () async { + expect(await nums(AdminAuditFilter(search: 'cafe')), isEmpty); + }); + + test('todos os filtros combinados (AND)', () async { + final f = AdminAuditFilter( + actorRole: UserRole.acs, + actionType: 'write', + resourceType: 'visit', + from: t0 + h * 2, + to: t0 + h * 6, + search: 'vis', + ); + expect(await nums(f), [8, 5]); + expect( + await nums(f.copyWith(actorEnrollmentId: 'ACS-ADM-RD-1')), + [5], + ); + expect(await nums(f.copyWith(result: 'success')), [8]); + }); + + test( + 'coordenador: o filtro nunca fura o escopo da UBS', + () async { + const soA = AdminScope.ubs(_ubsA); + expect(await nums(null, scope: soA), [10, 5, 4, 3]); + expect( + await nums( + AdminAuditFilter(actorEnrollmentId: 'ACS-ADM-RD-2'), + scope: soA, + ), + isEmpty, + reason: 'matrícula de outra UBS não traz linha', + ); + expect( + await nums(AdminAuditFilter(search: 'acs-adm-rd-2'), scope: soA), + isEmpty, + ); + expect( + await nums(AdminAuditFilter(actorRole: UserRole.admin), scope: soA), + isEmpty, + reason: 'linhas de staff seguem invisíveis', + ); + expect( + await nums(AdminAuditFilter(resourceType: 'session'), scope: soA), + [3], + ); + }, + ); + + test( + 'keyset com filtro: linha nova no topo não repete nem pula', + () async { + final f = AdminAuditFilter(actorRole: UserRole.acs); + final p1 = await store.auditLogs( + const AdminScope.system(), + filter: f, + limit: 3, + ); + expect(p1.items.map((e) => e.entry.sequence - base + 1), [11, 10, 8]); + expect(p1.nextBeforeSequence, base + 8 - 1); + + await linha(13, _acsBId, 'read', 'alert', 'granted', t0 + h * 11); + + final vistos = [...p1.items.map((e) => e.entry.sequence - base + 1)]; + int? antes = p1.nextBeforeSequence; + while (antes != null) { + final p = await store.auditLogs( + const AdminScope.system(), + filter: f, + limit: 3, + beforeSequence: antes, + ); + vistos.addAll(p.items.map((e) => e.entry.sequence - base + 1)); + antes = p.nextBeforeSequence; + } + expect(vistos, [11, 10, 8, 7, 6, 5, 4, 3]); + }, + ); + }); + }); +} diff --git a/backend/sinalacs_server/test/integration/alert_escalation_store_test.dart b/backend/sinalacs_server/test/integration/alert_escalation_store_test.dart new file mode 100644 index 00000000..2960be5a --- /dev/null +++ b/backend/sinalacs_server/test/integration/alert_escalation_store_test.dart @@ -0,0 +1,685 @@ +import 'dart:convert'; + +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/admin/alert_escalation_service.dart'; +import 'package:sinalacs_server/src/application/audit/audit_trail.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_alert_escalation_store.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:test/test.dart'; + +import '../support/health_data_fixtures.dart'; +import 'test_tools/serverpod_test_tools.dart'; + +/// Colunas de escalonamento de `alerts` e o store ORM do escalonamento (#44), +/// com Postgres real. Dados sintéticos; ids próprios. +const _paciente = '00000000-0000-4000-8000-0000000000a1'; +const _ubsA = '00000000-0000-4000-8000-0000000044e1'; +const _ubsB = '00000000-0000-4000-8000-0000000044e2'; +const _maA = '00000000-0000-4000-8000-0000000044e3'; +const _maB = '00000000-0000-4000-8000-0000000044e4'; +const _coord = '00000000-0000-4000-8000-0000000044e5'; +const _acs = '00000000-0000-4000-8000-0000000044e6'; +const _inexistente = '00000000-0000-4000-8000-0000000044ff'; +const _segredo = 'segredo-sintetico-da-cadeia'; +final _t = DateTime.utc(2026, 10, 11, 12); +const _limite = Duration(seconds: 300); + +Future _alerta( + Session s, { + Alert? base, + String? ma = _maA, + Duration idade = const Duration(seconds: 301), + RiskLevel risco = RiskLevel.red, + AlertStatus status = AlertStatus.pending, +}) => Alert.db.insertRow( + s, + base ?? + Alert( + patientId: UuidValue.fromString(_paciente), + microAreaId: ma == null ? null : UuidValue.fromString(ma), + triggeredAt: _t.subtract(idade), + riskLevel: risco, + locationHash: 'hash-sintetico', + locationCell: '-1580:-4783', + status: status, + mqttTopic: 'sinalacs/v1/microareas/${ma ?? 'x'}/alerts', + deviceId: 'device-sintetico', + retryCount: 0, + version: 1, + ), +); + +Future _semear(Session s) async { + await User.db.insertRow( + s, + User( + id: UuidValue.fromString(_paciente), + cpfHash: 'alert-escalation-paciente', + name: 'Paciente Sintético', + birthDate: DateTime.utc(1980), + role: UserRole.patient, + createdAt: _t, + updatedAt: _t, + ), + ); + await Patient.db.insertRow( + s, + await encryptedPatient( + id: _paciente, + emergencyContact: 'x', + isChronic: false, + ), + ); + for (final (id, nome) in [(_ubsA, 'UBS A esc'), (_ubsB, 'UBS B esc')]) { + await Ubs.db.insertRow( + s, + Ubs( + id: UuidValue.fromString(id), + name: nome, + address: 'Endereço sintético', + city: 'São Paulo', + state: 'SP', + ), + ); + } + for (final (id, ubs) in [(_maA, _ubsA), (_maB, _ubsB)]) { + await MicroArea.db.insertRow( + s, + MicroArea( + id: UuidValue.fromString(id), + name: 'Microárea $id', + ubsId: UuidValue.fromString(ubs), + geoJsonBoundary: '{}', + ), + ); + } + await User.db.insertRow( + s, + User( + id: UuidValue.fromString(_acs), + cpfHash: 'alert-escalation-acs', + name: 'ACS Sintético', + birthDate: DateTime.utc(1980), + role: UserRole.acs, + microAreaId: UuidValue.fromString(_maA), + createdAt: _t, + updatedAt: _t, + ), + ); + await Acs.db.insertRow( + s, + Acs( + id: UuidValue.fromString(_acs), + enrollmentId: 'ACS-ESC-1', + ubsId: UuidValue.fromString(_ubsA), + active: true, + ), + ); + // Autor das linhas de auditoria (`audit_logs.userId` referencia `users`). + await User.db.insertRow( + s, + User( + id: UuidValue.fromString(_coord), + cpfHash: 'alert-escalation-coord', + name: 'Coordenação Sintética', + birthDate: DateTime.utc(1980), + role: UserRole.coordinator, + createdAt: _t, + updatedAt: _t, + ), + ); + await StaffAccount.db.insertRow( + s, + StaffAccount( + id: UuidValue.fromString(_coord), + enrollmentId: 'COO-ESC-1', + active: true, + ubsId: UuidValue.fromString(_ubsA), + ), + ); +} + +/// Mesma forma do `auditFor` do serviço: o store só a chama nos dois desfechos +/// que audita. +AuditEvent Function(EscalationOutcome) _auditFor( + String alertId, { + String reason = 'no_ack_timeout', +}) => + (o) => AuditEvent( + userId: _coord, + actionType: 'write', + resourceType: 'admin_alert', + resourceId: alertId, + result: + '${o == EscalationOutcome.alreadyEscalated ? 'already_escalated' : 'escalated'}:$reason', + ); + +/// Trilha em memória: grava o que o store pediu dentro da transação. +class _AuditoriaGravada { + final eventos = []; + bool falhar = false; + + Future call(Session s, Transaction t, AuditEvent e) async { + if (falhar) throw StateError('falha injetada na auditoria (só em teste)'); + eventos.add(e); + } +} + +Future _outbox(Session s, UuidValue alertId) => + AlertOutboxEntry.db.count(s, where: (t) => t.alertId.equals(alertId)); + +Future> _resultados(Session s, UuidValue alertId) async => [ + for (final l in await AuditLog.db.find( + s, + where: (t) => t.resourceId.equals(alertId), + orderBy: (t) => t.sequence, + )) + l.result, +]; + +void main() { + withServerpod('Dado as colunas de escalonamento de alerts (#44)', ( + sessionBuilder, + endpoints, + ) { + late Session session; + setUp(() async { + session = sessionBuilder.build(); + await _semear(session); + }); + + test('um alerta novo nasce sem escalonamento', () async { + final criado = await _alerta(session); + final lido = await Alert.db.findById(session, criado.id!); + expect(lido!.escalatedAt, isNull); + expect(lido.escalatedBy, isNull); + expect(lido.escalationReason, isNull); + }); + + test('o escalonamento persiste data, autor e motivo fechado', () async { + final autor = UuidValue.fromString(_paciente); + final criado = await _alerta(session); + await Alert.db.updateRow( + session, + criado.copyWith( + escalatedAt: _t, + escalatedBy: autor, + escalationReason: EscalationReason.noAckTimeout, + ), + ); + final lido = await Alert.db.findById(session, criado.id!); + expect(lido!.escalatedAt, _t); + expect(lido.escalatedBy, autor); + expect(lido.escalationReason, EscalationReason.noAckTimeout); + }); + }); + + withServerpod('Dado o store ORM do escalonamento (#44)', ( + sessionBuilder, + endpoints, + ) { + late Session session; + late OrmAlertEscalationStore store; + + setUp(() async { + session = sessionBuilder.build(); + await _semear(session); + store = OrmAlertEscalationStore( + session: () => session, + appendAudit: OrmAuditTrail( + session: () => session, + chainSecret: _segredo, + ).recordInTransaction, + ); + }); + + Future escalar( + String alertId, { + AdminScope scope = const AdminScope.ubs(_ubsA), + EscalationReason reason = EscalationReason.noAckTimeout, + String reasonSnake = 'no_ack_timeout', + DateTime? now, + OrmAlertEscalationStore? via, + }) => (via ?? store).escalate( + scope, + alertId: alertId, + actorId: _coord, + reason: reason, + now: now ?? _t, + threshold: _limite, + auditFor: _auditFor(alertId, reason: reasonSnake), + ); + + test( + 'vencido: grava as 3 colunas, 1 reforço no outbox e 1 auditoria', + () async { + final a = await _alerta(session); + final r = await escalar(a.id!.uuid); + expect(r.outcome, EscalationOutcome.escalated); + expect(r.escalatedAt, _t); + + final lido = (await Alert.db.findById(session, a.id!))!; + expect(lido.escalatedAt, _t); + expect(lido.escalatedBy, UuidValue.fromString(_coord)); + expect(lido.escalationReason, EscalationReason.noAckTimeout); + // D3: o alerta segue pendente; nada além das 3 colunas muda. + expect(lido.status, AlertStatus.pending); + expect(lido.acknowledgedAt, isNull); + expect(lido.acsId, isNull); + expect(lido.version, a.version); + expect(lido.riskLevel, RiskLevel.red); + expect(lido.triggeredAt, a.triggeredAt); + + final entradas = await AlertOutboxEntry.db.find( + session, + where: (t) => t.alertId.equals(a.id!), + ); + expect(entradas, hasLength(1)); + final e = entradas.single; + expect(e.topic, 'sinalacs/v1/microareas/$_maA/alerts'); + expect(e.publishedAt, isNull); + expect(e.attempts, 0); + expect(e.nextAttemptAt, _t, reason: 'elegível já'); + final payload = jsonDecode(e.payload) as Map; + // Sem identidade além do envelope que o alerta já tinha. + expect(payload.keys.toSet(), { + 'version', + 'alert_id', + 'patient_id', + 'micro_area_id', + 'risk_level', + 'location_hash', + 'location_cell', + 'triggered_at', + 'escalated', + }); + expect(payload['alert_id'], a.id!.uuid); + expect(payload['micro_area_id'], _maA); + expect(payload['risk_level'], 'red'); + expect(payload['escalated'], isTrue); + expect(payload.values.join(' '), isNot(contains(_coord))); + + expect(await _resultados(session, a.id!), ['escalated:no_ack_timeout']); + }, + ); + + test( + 'replay: alreadyEscalated, carimbo original, sem 2º reforço', + () async { + final a = await _alerta(session); + await escalar(a.id!.uuid); + final depois = _t.add(const Duration(minutes: 10)); + final r = await escalar( + a.id!.uuid, + now: depois, + reason: EscalationReason.acsUnreachable, + reasonSnake: 'acs_unreachable', + ); + expect(r.outcome, EscalationOutcome.alreadyEscalated); + expect(r.escalatedAt, _t); + + final lido = (await Alert.db.findById(session, a.id!))!; + expect(lido.escalatedAt, _t); + expect(lido.escalationReason, EscalationReason.noAckTimeout); + expect(lido.escalatedBy, UuidValue.fromString(_coord)); + expect(await _outbox(session, a.id!), 1); + expect(await _resultados(session, a.id!), [ + 'escalated:no_ack_timeout', + 'already_escalated:acs_unreachable', + ]); + }, + ); + + test( + 'precedência e recusas: nada é escrito e o store não audita', + () async { + final casos = <(String, Future Function(), EscalationOutcome)>[ + ('inexistente', () async => _inexistente, EscalationOutcome.notFound), + ( + 'outra UBS', + () async => (await _alerta(session, ma: _maB)).id!.uuid, + EscalationOutcome.notFound, + ), + ( + 'sem microárea, para o coordenador', + () async => (await _alerta(session, ma: null)).id!.uuid, + EscalationOutcome.notFound, + ), + ( + // Precedência: escopo antes de cor. + 'amarelo de outra UBS', + () async => (await _alerta( + session, + ma: _maB, + risco: RiskLevel.yellow, + )).id!.uuid, + EscalationOutcome.notFound, + ), + ( + 'amarelo', + () async => + (await _alerta(session, risco: RiskLevel.yellow)).id!.uuid, + EscalationOutcome.notRed, + ), + ( + 'verde', + () async => + (await _alerta(session, risco: RiskLevel.green)).id!.uuid, + EscalationOutcome.notRed, + ), + ( + // Precedência: cor antes de status. + 'verde confirmado', + () async => (await _alerta( + session, + risco: RiskLevel.green, + status: AlertStatus.acknowledged, + )).id!.uuid, + EscalationOutcome.notRed, + ), + ( + 'confirmado', + () async => (await _alerta( + session, + status: AlertStatus.acknowledged, + )).id!.uuid, + EscalationOutcome.notPending, + ), + ( + 'exatamente no prazo (300 s)', + () async => (await _alerta(session, idade: _limite)).id!.uuid, + EscalationOutcome.notDue, + ), + ( + '1 µs antes do prazo', + () async => (await _alerta( + session, + idade: _limite - const Duration(microseconds: 1), + )).id!.uuid, + EscalationOutcome.notDue, + ), + ( + 'abaixo do prazo', + () async => (await _alerta( + session, + idade: const Duration(seconds: 10), + )).id!.uuid, + EscalationOutcome.notDue, + ), + ( + 'triggeredAt no futuro', + () async => (await _alerta( + session, + idade: const Duration(hours: -2), + )).id!.uuid, + EscalationOutcome.notDue, + ), + ]; + for (final (nome, criar, esperado) in casos) { + final id = await criar(); + final r = await escalar(id); + expect(r.outcome, esperado, reason: nome); + expect(r.escalatedAt, isNull, reason: nome); + final uuid = UuidValue.fromString(id); + final lido = await Alert.db.findById(session, uuid); + expect(lido?.escalatedAt, isNull, reason: nome); + expect(await _outbox(session, uuid), 0, reason: nome); + expect(await _resultados(session, uuid), isEmpty, reason: nome); + } + }, + ); + + test('precedência: confirmado E já escalonado → notPending', () async { + final a = await _alerta(session); + await escalar(a.id!.uuid); + await Alert.db.updateRow( + session, + (await Alert.db.findById( + session, + a.id!, + ))!.copyWith(status: AlertStatus.acknowledged), + ); + final r = await escalar(a.id!.uuid); + expect(r.outcome, EscalationOutcome.notPending); + }); + + test( + 'precedência: já escalonado antes do prazo → alreadyEscalated', + () async { + final a = await _alerta(session); + await escalar(a.id!.uuid); + // Um relógio que voltou para dentro do prazo não troca o desfecho. + final r = await escalar( + a.id!.uuid, + now: _t.subtract(const Duration(seconds: 200)), + ); + expect(r.outcome, EscalationOutcome.alreadyEscalated); + expect(r.escalatedAt, _t); + }, + ); + + test('1 s além do prazo: vence', () async { + final a = await _alerta( + session, + idade: _limite + const Duration(seconds: 1), + ); + expect((await escalar(a.id!.uuid)).outcome, EscalationOutcome.escalated); + }); + + test('1 µs além do prazo: vence (estritamente maior)', () async { + final a = await _alerta( + session, + idade: _limite + const Duration(microseconds: 1), + ); + expect((await escalar(a.id!.uuid)).outcome, EscalationOutcome.escalated); + }); + + test('administrador: qualquer UBS e alerta sem microárea', () async { + final b = await _alerta(session, ma: _maB); + expect( + (await escalar(b.id!.uuid, scope: const AdminScope.system())).outcome, + EscalationOutcome.escalated, + ); + expect(await _outbox(session, b.id!), 1); + + // Sem microárea não há tópico de território: escalona e audita, sem + // reforço (não há a quem entregar). + final semMa = await _alerta(session, ma: null); + expect( + (await escalar( + semMa.id!.uuid, + scope: const AdminScope.system(), + )).outcome, + EscalationOutcome.escalated, + ); + expect(await _outbox(session, semMa.id!), 0); + expect(await _resultados(session, semMa.id!), [ + 'escalated:no_ack_timeout', + ]); + }); + + test('id que não é UUID: notFound sem consultar o banco', () async { + final r = await escalar("x'; DROP TABLE alerts;--"); + expect(r.outcome, EscalationOutcome.notFound); + }); + + test('auditoria falhando desfaz UPDATE e reforço (fail-closed)', () async { + final a = await _alerta(session); + final trilha = _AuditoriaGravada()..falhar = true; + final quebrado = OrmAlertEscalationStore( + session: () => session, + appendAudit: trilha.call, + ); + await expectLater( + escalar(a.id!.uuid, via: quebrado), + throwsA(isA()), + ); + final lido = (await Alert.db.findById(session, a.id!))!; + expect(lido.escalatedAt, isNull); + expect(lido.escalatedBy, isNull); + expect(lido.escalationReason, isNull); + expect(await _outbox(session, a.id!), 0); + expect(await AuditLog.db.count(session), 0); + }); + + test( + 'ACK depois do escalonamento preserva as colunas de escalonamento', + () async { + final a = await _alerta(session); + await escalar(a.id!.uuid); + final ok = + await OrmAlertStore( + session: () => session, + appendAudit: (s, t, e) async {}, + ).acknowledge( + alertId: a.id!.uuid, + acsId: _acs, + microAreaId: _maA, + audit: const AuditEvent( + userId: _acs, + actionType: 'write', + resourceType: 'alert', + resourceId: _paciente, + result: 'acknowledged', + ), + ); + expect(ok, isTrue); + final lido = (await Alert.db.findById(session, a.id!))!; + expect(lido.status, AlertStatus.acknowledged); + expect(lido.escalatedAt, _t); + expect(lido.escalationReason, EscalationReason.noAckTimeout); + }, + ); + + test( + 'ubsOf: UBS do coordenador; id inválido ou sem conta → null', + () async { + expect(await store.ubsOf(_coord), _ubsA); + expect(await store.ubsOf(_inexistente), isNull); + expect(await store.ubsOf('nao-e-uuid'), isNull); + }, + ); + }); + + // Corrida: rollback desligado (com ele, sessões concorrentes colidem no + // savepoint compartilhado do harness). Trilha em memória, para não commitar + // linhas em `audit_logs` que as suítes paralelas conferem sem filtro; a + // limpeza do `finally` remove o resto. + withServerpod( + 'Dado o store ORM do escalonamento (#44), sem rollback (corrida)', + (sessionBuilder, endpoints) { + const rodadas = 5; + + Future limpar(Session s) async { + final alertas = await Alert.db.find( + s, + where: (t) => t.patientId.equals(UuidValue.fromString(_paciente)), + ); + for (final a in alertas) { + await AlertOutboxEntry.db.deleteWhere( + s, + where: (t) => t.alertId.equals(a.id!), + ); + } + await Alert.db.deleteWhere( + s, + where: (t) => t.patientId.equals(UuidValue.fromString(_paciente)), + ); + await Acs.db.deleteWhere( + s, + where: (t) => t.id.equals(UuidValue.fromString(_acs)), + ); + await StaffAccount.db.deleteWhere( + s, + where: (t) => t.id.equals(UuidValue.fromString(_coord)), + ); + await Patient.db.deleteWhere( + s, + where: (t) => t.id.equals(UuidValue.fromString(_paciente)), + ); + await User.db.deleteWhere( + s, + where: (t) => + t.id.equals(UuidValue.fromString(_paciente)) | + t.id.equals(UuidValue.fromString(_acs)) | + t.id.equals(UuidValue.fromString(_coord)), + ); + await MicroArea.db.deleteWhere( + s, + where: (t) => + t.id.equals(UuidValue.fromString(_maA)) | + t.id.equals(UuidValue.fromString(_maB)), + ); + await Ubs.db.deleteWhere( + s, + where: (t) => + t.id.equals(UuidValue.fromString(_ubsA)) | + t.id.equals(UuidValue.fromString(_ubsB)), + ); + } + + test( + 'duas chamadas simultâneas: 1 escalatedAt, 1 reforço, 1+1 auditoria', + () async { + final s = sessionBuilder.build(); + await limpar(s); + try { + await _semear(s); + for (var i = 0; i < rodadas; i++) { + final a = await _alerta(s); + final trilha = _AuditoriaGravada(); + Future tentar(DateTime now) => + OrmAlertEscalationStore( + session: sessionBuilder.build, + appendAudit: trilha.call, + ).escalate( + const AdminScope.ubs(_ubsA), + alertId: a.id!.uuid, + actorId: _coord, + reason: EscalationReason.noAckTimeout, + now: now, + threshold: _limite, + auditFor: _auditFor(a.id!.uuid), + ); + + final r = await Future.wait([ + tentar(_t), + tentar(_t.add(const Duration(seconds: 1))), + ]); + final desfechos = r.map((x) => x.outcome).toList(); + expect( + desfechos.where((o) => o == EscalationOutcome.escalated), + hasLength(1), + reason: 'rodada $i: $desfechos', + ); + expect( + desfechos.where((o) => o == EscalationOutcome.alreadyEscalated), + hasLength(1), + reason: 'rodada $i: $desfechos', + ); + // Os dois devolvem o MESMO carimbo: o do vencedor. + expect(r[0].escalatedAt, r[1].escalatedAt, reason: 'rodada $i'); + final lido = (await Alert.db.findById(s, a.id!))!; + expect(lido.escalatedAt, r[0].escalatedAt, reason: 'rodada $i'); + expect(lido.status, AlertStatus.pending, reason: 'rodada $i'); + expect(await _outbox(s, a.id!), 1, reason: 'rodada $i'); + expect( + trilha.eventos.map((e) => e.result).toList()..sort(), + [ + 'already_escalated:no_ack_timeout', + 'escalated:no_ack_timeout', + ], + reason: 'rodada $i', + ); + } + } finally { + await limpar(s); + } + }, + ); + }, + rollbackDatabase: RollbackDatabase.disabled, + ); +} diff --git a/backend/sinalacs_server/test/integration/audit_chain_checkpoint_test.dart b/backend/sinalacs_server/test/integration/audit_chain_checkpoint_test.dart new file mode 100644 index 00000000..07315aac --- /dev/null +++ b/backend/sinalacs_server/test/integration/audit_chain_checkpoint_test.dart @@ -0,0 +1,375 @@ +import 'dart:convert'; + +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/audit/audit_chain.dart'; +import 'package:sinalacs_server/src/application/audit/audit_chain_checkpoint_store.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/config/app_config.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_audit_checkpoint_store.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_audit_trail.dart'; +import 'package:sinalacs_server/src/runtime/alert_runtime.dart'; +import 'package:test/test.dart'; + +import 'test_tools/serverpod_test_tools.dart'; + +/// Checkpoint de cauda da cadeia de `audit_logs` (#45, Task 7, D4) pelo +/// endpoint `admin.verifyAuditChain`, com Postgres real e tokens reais. As +/// adulterações (`DELETE`/`UPDATE` direto, restauração de "backup") só +/// existem no banco de teste do harness, desfeito a cada caso. Dados +/// sintéticos; ids próprios. +const _admin = '00000000-0000-4000-8000-0000000047a1'; +const _ator = '00000000-0000-4000-8000-0000000047a2'; +const _segredo = AppConfig.developmentAuditChainSecret; + +final _t = DateTime.utc(2026, 10, 13, 12); + +AppConfig _config() => AppConfig( + mqttBroker: 'localhost:1883', + jwtSecret: 'test-secret', + auditChainSecret: _segredo, + healthDataEncryptionKey: AppConfig.developmentHealthDataEncryptionKey, + cpfHashPepper: AppConfig.developmentCpfHashPepper, + smsGateway: 'log', + mqttUsername: null, + mqttPassword: null, + mqttUseTls: false, + mqttCaCertificatePath: null, + appEnv: 'development', + enableDevLogin: true, +); + +Future _seed(Session s) async { + for (final id in [_admin, _ator]) { + await User.db.insertRow( + s, + User( + id: UuidValue.fromString(id), + cpfHash: 'checkpoint-$id', + name: 'Sintético $id', + birthDate: DateTime.utc(1980), + role: UserRole.admin, + createdAt: _t, + updatedAt: _t, + ), + ); + } + await StaffAccount.db.insertRow( + s, + StaffAccount( + id: UuidValue.fromString(_admin), + enrollmentId: 'ADM-CHECKPOINT-1', + active: true, + ), + ); +} + +void main() { + withServerpod('Dado o checkpoint de cauda da cadeia (#45, Task 7)', ( + sessionBuilder, + endpoints, + ) { + late Session session; + + setUp(() async { + session = sessionBuilder.build(); + AlertRuntime.instance.overrideConfig(_config()); + AlertRuntime.instance.resetAuditChainStatusCache(); + await _seed(session); + }); + tearDown(() { + AlertRuntime.instance.overrideConfig(null); + AlertRuntime.instance.resetAuditChainStatusCache(); + }); + + Future linhas(int n) async { + final trilha = OrmAuditTrail(session: () => session, chainSecret: _segredo); + for (var i = 0; i < n; i++) { + await trilha.record( + AuditEvent( + userId: _ator, + actionType: 'read', + resourceType: 'checkpoint_$i', + result: 'success', + ), + ); + } + } + + Future verificar() { + // Cada chamada é uma verificação nova: o cache de 60 s é do processo. + AlertRuntime.instance.resetAuditChainStatusCache(); + final token = AlertRuntime.instance.auth.issueToken( + AuthenticatedUser( + id: _admin, + role: UserRole.admin, + microAreaId: null, + deviceId: 'sem-aparelho', + ), + ); + return endpoints.admin.verifyAuditChain( + sessionBuilder, + accessToken: token, + ); + } + + Future cabeca() async => (await AuditLog.db.findFirstRow( + session, + orderBy: (t) => t.sequence, + orderDescending: true, + ))!.sequence; + + Future> checkpoints() => + AuditChainCheckpoint.db.find(session, orderBy: (t) => t.sequence); + + test( + 'verified grava o checkpoint assinado; apagar as 3 últimas linhas ⇒ ' + 'broken/tailTruncated', + () async { + await linhas(5); + final primeira = await verificar(); + expect(primeira.state, AuditIntegrityState.verified); + final head = await cabeca(); + expect(primeira.headSequence, head); + + final gravados = await checkpoints(); + expect(gravados, hasLength(1)); + final c = gravados.single; + final linhaHead = await AuditLog.db.findFirstRow( + session, + where: (t) => t.sequence.equals(head), + ); + expect(c.sequence, head); + expect(c.entryHash, linhaHead!.entryHash); + expect( + c.signature, + AuditChain(secret: _segredo).computeCheckpointSignature( + head, + linhaHead.entryHash, + ), + ); + // O verificador sozinho não veria: o que sobra é contíguo. + await session.db.unsafeExecute( + 'DELETE FROM audit_logs WHERE sequence > @s', + parameters: QueryParameters.named({'s': head - 3}), + ); + + final segunda = await verificar(); + expect(segunda.state, AuditIntegrityState.broken); + expect(segunda.reasonCode, AuditChainReason.tailTruncated); + expect(segunda.firstBrokenSequence, head); + expect(segunda.headSequence, isNull); + // Cadeia quebrada não grava; o checkpoint segue lá. + expect((await checkpoints()).map((c) => c.sequence), [head]); + final texto = jsonEncode(segunda.toJson()); + expect(texto, isNot(contains(c.entryHash))); + expect(texto, isNot(contains(c.signature))); + }, + ); + + test( + 'cauda apagada e a aplicação regrava por cima (HMAC válido) ⇒ ' + 'entryHash diverge no checkpoint ⇒ tailTruncated', + () async { + await linhas(5); + expect((await verificar()).state, AuditIntegrityState.verified); + final head = await cabeca(); + await session.db.unsafeExecute( + 'DELETE FROM audit_logs WHERE sequence > @s', + parameters: QueryParameters.named({'s': head - 3}), + ); + // A trilha de produção volta a ocupar as sequences apagadas. + await linhas(5); + expect(await cabeca(), greaterThan(head)); + + final s = await verificar(); + expect(s.state, AuditIntegrityState.broken); + expect(s.reasonCode, AuditChainReason.tailTruncated); + expect(s.firstBrokenSequence, head); + }, + ); + + test('sem apagar, linha nova por cima ⇒ verified e o checkpoint avança', + () async { + await linhas(3); + expect((await verificar()).state, AuditIntegrityState.verified); + final antes = (await checkpoints()).single.sequence; + + await linhas(2); + final s = await verificar(); + expect(s.state, AuditIntegrityState.verified); + final depois = await checkpoints(); + expect(depois.map((c) => c.sequence), [antes, await cabeca()]); + expect(depois.last.sequence, greaterThan(antes)); + }); + + test( + 'checkpoint com signature inválida ⇒ ignorado e sinalizado: ' + 'inconclusive/checkpointInvalid, nada gravado, nada apagado', + () async { + await linhas(3); + expect((await verificar()).state, AuditIntegrityState.verified); + await session.db.unsafeExecute( + 'UPDATE audit_chain_checkpoints SET signature = @sig', + parameters: QueryParameters.named({'sig': 'f' * 64}), + ); + + final s = await verificar(); + expect(s.state, AuditIntegrityState.inconclusive); + expect(s.reasonCode, AuditChainReason.checkpointInvalid); + expect(s.headSequence, isNull); + final depois = await checkpoints(); + expect(depois, hasLength(1)); + expect(depois.single.signature, 'f' * 64); + }, + ); + + test( + 'LIMITE CONHECIDO: restaurar o banco inteiro de um backup antigo ' + '(cadeia e checkpoints) ⇒ verified', + () async { + await linhas(3); + expect((await verificar()).state, AuditIntegrityState.verified); + // "Backup": cópia das duas tabelas neste instante. + await session.db.unsafeExecute( + 'CREATE TEMP TABLE bk_audit_logs AS SELECT * FROM audit_logs', + ); + await session.db.unsafeExecute( + 'CREATE TEMP TABLE bk_checkpoints AS ' + 'SELECT * FROM audit_chain_checkpoints', + ); + await linhas(4); + expect((await verificar()).state, AuditIntegrityState.verified); + expect(await checkpoints(), hasLength(2)); + + // "Restauração": as duas tabelas voltam juntas, coerentes entre si. + for (final sql in [ + 'DELETE FROM audit_chain_checkpoints', + 'DELETE FROM audit_logs', + 'INSERT INTO audit_logs SELECT * FROM bk_audit_logs', + 'INSERT INTO audit_chain_checkpoints SELECT * FROM bk_checkpoints', + ]) { + await session.db.unsafeExecute(sql); + } + + // A cauda perdida não é detectável: documentado em Pendências. + final s = await verificar(); + expect(s.state, AuditIntegrityState.verified); + expect(s.reasonCode, isNull); + }, + ); + + group('OrmAuditCheckpointStore', () { + AuditChainCheckpointRecord registro(int sequence) { + final hash = sequence.toRadixString(16).padLeft(64, '0'); + return AuditChainCheckpointRecord( + sequence: sequence, + entryHash: hash, + signature: AuditChain( + secret: _segredo, + ).computeCheckpointSignature(sequence, hash), + ); + } + + test('só avança: menor ou igual ao último não grava, sem erro', () async { + final store = OrmAuditCheckpointStore(session: () => session); + await store.advanceTo(registro(10)); + await store.advanceTo(registro(7)); + await store.advanceTo(registro(10)); + await store.advanceTo(registro(12)); + expect((await checkpoints()).map((c) => c.sequence), [10, 12]); + + final ultimos = await store.latest(limit: 1); + expect(ultimos.single.sequence, 12); + expect(ultimos.single.entryHash, registro(12).entryHash); + expect(ultimos.single.signature, registro(12).signature); + expect( + (await store.latest(limit: 5)).map((c) => c.sequence), + [12, 10], + ); + }); + + test('a mesma sequence de novo não vira erro de índice único', () async { + final store = OrmAuditCheckpointStore(session: () => session); + await store.advanceTo(registro(30)); + await expectLater(store.advanceTo(registro(30)), completes); + expect(await checkpoints(), hasLength(1)); + }); + }); + }); + + // Corrida real entre transações: o harness com rollback não aceita + // transações concorrentes, então este grupo grava de verdade, numa faixa + // de `sequence` só dele, e apaga tudo no `finally` — um checkpoint + // esquecido aqui faria toda verificação seguinte do banco de teste virar + // tailTruncated. + withServerpod( + 'Dado OrmAuditCheckpointStore sem rollback (corrida, #45 Task 7)', + (sessionBuilder, endpoints) { + const base = 8000000000000; + + AuditChainCheckpointRecord registro(int sequence) { + final hash = sequence.toRadixString(16).padLeft(64, '0'); + return AuditChainCheckpointRecord( + sequence: sequence, + entryHash: hash, + signature: AuditChain( + secret: _segredo, + ).computeCheckpointSignature(sequence, hash), + ); + } + + Future limpar(Session s) => AuditChainCheckpoint.db + .deleteWhere(s, where: (t) => t.sequence >= base) + .then((_) {}); + + Future> faixa(Session s) async => [ + for (final c in await AuditChainCheckpoint.db.find( + s, + where: (t) => t.sequence >= base, + orderBy: (t) => t.sequence, + )) + c.sequence, + ]; + + test('duas verificações gravando a MESMA sequence ao mesmo tempo ⇒ uma ' + 'linha, nenhuma exceção; sequences diferentes ⇒ nunca a menor ' + 'depois da maior', () async { + final s = sessionBuilder.build(); + await limpar(s); + try { + final a = OrmAuditCheckpointStore( + session: () => sessionBuilder.build(), + ); + final b = OrmAuditCheckpointStore( + session: () => sessionBuilder.build(), + ); + await Future.wait([ + a.advanceTo(registro(base + 1)), + b.advanceTo(registro(base + 1)), + ]); + expect(await faixa(s), [base + 1]); + + await Future.wait([ + a.advanceTo(registro(base + 2)), + b.advanceTo(registro(base + 3)), + ]); + final depois = await faixa(s); + // Ordem de commit livre: ou as duas avançaram (2 antes de 3), ou a + // 3 venceu e a 2 foi recusada. Nunca só a 2. + expect( + depois, + anyOf( + equals([base + 1, base + 2, base + 3]), + equals([base + 1, base + 3]), + ), + ); + } finally { + await limpar(s); + } + }); + }, + rollbackDatabase: RollbackDatabase.disabled, + ); +} diff --git a/backend/sinalacs_server/test/integration/audit_chain_reader_test.dart b/backend/sinalacs_server/test/integration/audit_chain_reader_test.dart new file mode 100644 index 00000000..5ab417a1 --- /dev/null +++ b/backend/sinalacs_server/test/integration/audit_chain_reader_test.dart @@ -0,0 +1,289 @@ +import 'dart:convert'; + +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/admin/admin_read_service.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/application/audit/audit_row_sealer.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/generated/protocol.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_admin_read_store.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_audit_chain_reader.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_audit_trail.dart'; +import 'package:test/test.dart'; + +import 'test_tools/serverpod_test_tools.dart'; + +/// Leitura em lotes da cadeia e selo por linha (#45, Task 5) contra Postgres +/// real. Dados sintéticos; as adulterações (`UPDATE`/`DELETE` direto) só +/// existem no banco de teste do harness, que é desfeito a cada caso. +const _segredo = 'segredo-sintetico-da-task-5'; +const _ator = '00000000-0000-4000-8000-0000000045a1'; +final _t = DateTime.utc(2026, 10, 13, 12); + +Future _ator1(Session s) => User.db + .insertRow( + s, + User( + id: UuidValue.fromString(_ator), + cpfHash: 'task5-$_ator', + name: 'Admin Sintético Task 5', + birthDate: DateTime.utc(1980), + role: UserRole.admin, + createdAt: _t, + updatedAt: _t, + ), + ) + .then((_) {}); + +/// Seis linhas encadeadas pelo caminho de produção; as linhas 2 e 5 (relativas +/// a [base]) são as únicas com `resourceType` `seal_alvo`. +Future _seisLinhas(Session s) async { + final trilha = OrmAuditTrail(session: () => s, chainSecret: _segredo); + for (var i = 1; i <= 6; i++) { + await trilha.record( + AuditEvent( + userId: _ator, + actionType: 'read', + resourceType: i == 2 || i == 5 ? 'seal_alvo' : 'seal_outro', + result: 'success', + ), + ); + } + final primeira = await AuditLog.db.findFirstRow( + s, + where: (t) => t.resourceType.equals('seal_outro'), + orderBy: (t) => t.sequence, + ); + return primeira!.sequence; // sequence da linha 1 +} + +void main() { + withServerpod('Dada a cadeia de audit_logs (#45, Task 5)', ( + sessionBuilder, + endpoints, + ) { + late Session session; + late OrmAuditChainReader reader; + late OrmAdminReadStore store; + final selador = AuditRowSealer(AuditChain(secret: _segredo)); + + setUp(() async { + session = sessionBuilder.build(); + await _ator1(session); + reader = OrmAuditChainReader(session: () => session); + store = OrmAdminReadStore(session: () => session); + }); + + Future> selosDaPagina(AdminAuditRowPage p) async => { + for (final r in p.items) r.entry.sequence: selador.sealInput(r.seal), + }; + + test( + 'readBatch: sequence > afterSequence, ordem crescente, no máximo limit', + () async { + final base = await _seisLinhas(session); + + final lote = await reader.readBatch(afterSequence: base, limit: 3); + expect(lote.map((e) => e.fields.sequence), [ + base + 1, + base + 2, + base + 3, + ]); + + final fim = await reader.readBatch(afterSequence: base + 3, limit: 100); + expect(fim.map((e) => e.fields.sequence), [base + 4, base + 5]); + + expect( + await reader.readBatch(afterSequence: base + 5, limit: 10), + isEmpty, + ); + }, + ); + + test( + 'entryAt devolve a linha com os campos da cadeia e nulo se não existe', + () async { + final base = await _seisLinhas(session); + final linha = await reader.entryAt(base + 2); + final anterior = await reader.entryAt(base + 1); + + expect(linha!.fields.sequence, base + 2); + expect(linha.fields.previousHash, anterior!.entryHash); + expect( + AuditChain(secret: _segredo).matches(linha.fields, linha.entryHash), + isTrue, + reason: 'o reader devolve exatamente o que o HMAC cobriu', + ); + expect(await reader.entryAt(base + 99), isNull); + }, + ); + + test( + 'o verificador em lotes lê a cadeia do Postgres e acha a edição', + () async { + final base = await _seisLinhas(session); + final verificador = AuditChainVerifier( + reader: reader, + secret: _segredo, + ); + + final integra = await verificador.verify(); + expect(integra.ok, isTrue); + expect(integra.checked, base + 5); + + await session.db.unsafeExecute( + 'UPDATE audit_logs SET result = \'x\' WHERE sequence = @s', + parameters: QueryParameters.named({'s': base + 4}), + ); + final quebrada = await verificador.verify(); + expect(quebrada.ok, isFalse); + expect(quebrada.reasonCode, AuditChainReason.hashMismatch); + expect(quebrada.brokenAtSequence, base + 4); + }, + ); + + test( + 'Review Focus 1: linhas filtradas não adjacentes (2 e 5) são verified; ' + 'editar a 5 deixa só ela invalid e a 6 continua verified pelo selo', + () async { + final base = await _seisLinhas(session); + final s2 = base + 1; + final s5 = base + 4; + final s6 = base + 5; + final filtro = AdminAuditFilter(resourceType: 'seal_alvo'); + + final pagina = await store.auditLogs( + const AdminScope.system(), + filter: filtro, + limit: 50, + ); + expect( + pagina.items.map((r) => r.entry.sequence), + [s5, s2], + reason: 'só as linhas 2 e 5, não contíguas', + ); + expect( + await selosDaPagina(pagina), + { + s5: AuditRowSeal.verified, + s2: AuditRowSeal.verified, + }, + reason: 'o vizinho vem do banco, não da linha ao lado na página', + ); + + await session.db.unsafeExecute( + 'UPDATE audit_logs SET result = \'x\' WHERE sequence = @s', + parameters: QueryParameters.named({'s': s5}), + ); + + final depois = await store.auditLogs( + const AdminScope.system(), + filter: filtro, + limit: 50, + ); + expect(await selosDaPagina(depois), { + s5: AuditRowSeal.invalid, + s2: AuditRowSeal.verified, + }); + + // Limite documentado em AuditRowSealer: a 6 encadeia no entryHash + // ORIGINAL da 5 (que não foi recalculado), então o selo dela confere. + final tudo = await store.auditLogs( + const AdminScope.system(), + limit: 50, + ); + final selos = await selosDaPagina(tudo); + expect(selos[s6], AuditRowSeal.verified); + expect(selos[s5], AuditRowSeal.invalid); + expect(selos[s2], AuditRowSeal.verified); + }, + ); + + test( + 'vizinho apagado ⇒ a linha seguinte fica unverifiable, nunca verified', + () async { + final base = await _seisLinhas(session); + await session.db.unsafeExecute( + 'DELETE FROM audit_logs WHERE sequence = @s', + parameters: QueryParameters.named({'s': base + 3}), + ); + + final pagina = await store.auditLogs( + const AdminScope.system(), + filter: AdminAuditFilter(resourceType: 'seal_alvo'), + limit: 50, + ); + expect(await selosDaPagina(pagina), { + base + 4: AuditRowSeal.unverifiable, + base + 1: AuditRowSeal.verified, + }); + }, + ); + + test( + 'o serviço sela cada linha e a resposta não carrega hash nem segredo', + () async { + final base = await _seisLinhas(session); + final servico = AdminReadService( + store: store, + audit: OrmAuditTrail(session: () => session, chainSecret: _segredo), + auditChain: AuditChain(secret: _segredo), + ); + final admin = AuthenticatedUser( + id: _ator, + role: UserRole.admin, + microAreaId: null, + deviceId: 'sem-aparelho', + ); + + final pagina = await servico.auditLogs(admin, limit: 50); + final porSequence = {for (final e in pagina.items) e.sequence: e}; + for (var s = base; s <= base + 5; s++) { + expect( + porSequence[s]!.integrity, + AuditRowSeal.verified, + reason: 'linha $s íntegra', + ); + } + + final json = jsonEncode(pagina.toJson()); + expect(json, contains('"integrity":"verified"')); + expect(json, isNot(contains('Hash'))); + expect(json, isNot(contains('ipHash'))); + expect(json, isNot(contains(_segredo))); + final linha = await AuditLog.db.findFirstRow( + session, + where: (t) => t.sequence.equals(base + 1), + ); + expect(json, isNot(contains(linha!.entryHash))); + expect(json, isNot(contains(linha.previousHash))); + expect(json, isNot(contains(linha.ipHash))); + }, + ); + + test('segredo errado no serviço ⇒ nenhuma linha verified', () async { + await _seisLinhas(session); + final servico = AdminReadService( + store: store, + audit: OrmAuditTrail(session: () => session, chainSecret: _segredo), + auditChain: AuditChain(secret: 'outro-segredo'), + ); + final pagina = await servico.auditLogs( + AuthenticatedUser( + id: _ator, + role: UserRole.admin, + microAreaId: null, + deviceId: 'sem-aparelho', + ), + limit: 50, + ); + expect(pagina.items, isNotEmpty); + expect( + pagina.items.where((e) => e.integrity == AuditRowSeal.verified), + isEmpty, + ); + }); + }); +} diff --git a/backend/sinalacs_server/test/integration/audit_schema_test.dart b/backend/sinalacs_server/test/integration/audit_schema_test.dart new file mode 100644 index 00000000..01335a44 --- /dev/null +++ b/backend/sinalacs_server/test/integration/audit_schema_test.dart @@ -0,0 +1,98 @@ +import 'dart:io'; + +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:test/test.dart'; + +import 'test_tools/serverpod_test_tools.dart'; + +/// Esquema da trilha de auditoria (#45): as colunas da cadeia de `audit_logs` +/// não mudam, e `audit_exports`/`audit_chain_checkpoints` existem e se +/// comportam como o desenho manda (checkpoint único por `sequence`). +String _definitionDoUltimoEsquema() { + final versoes = Directory('migrations') + .listSync() + .whereType() + .map((d) => d.path.split(Platform.pathSeparator).last) + .toList() + ..sort(); + return File('migrations/${versoes.last}/definition.sql').readAsStringSync(); +} + +String _tabela(String sql, String nome) { + final inicio = sql.indexOf('CREATE TABLE "$nome" ('); + expect(inicio, greaterThanOrEqualTo(0), reason: 'tabela $nome ausente'); + return sql.substring(inicio, sql.indexOf(');', inicio)); +} + +void main() { + group('Dado o definition.sql mais recente', () { + final sql = _definitionDoUltimoEsquema(); + + test('as colunas da cadeia de audit_logs não mudaram', () { + final t = _tabela(sql, 'audit_logs'); + expect(t, contains('"sequence" bigint NOT NULL')); + expect(t, contains('"previousHash" text NOT NULL')); + expect(t, contains('"entryHash" text NOT NULL')); + expect(t, contains('"timestamp" timestamp without time zone NOT NULL')); + expect(sql, contains('CREATE UNIQUE INDEX "audit_logs_sequence_idx"')); + }); + + test('audit_logs ganhou os índices de consulta', () { + expect(sql, contains('"audit_logs_timestamp_idx" ON "audit_logs"')); + expect(sql, contains('"audit_logs_user_sequence_idx" ON "audit_logs"')); + }); + }); + + withServerpod('Dado o esquema de auditoria em Postgres real', + (sessionBuilder, endpoints) { + test('audit_chain_checkpoints rejeita duas linhas com a mesma sequence', + () async { + final session = sessionBuilder.build(); + AuditChainCheckpoint cp() => AuditChainCheckpoint( + sequence: 10, + entryHash: 'a' * 64, + signature: 'b' * 64, + createdAt: DateTime.now().toUtc(), + ); + await AuditChainCheckpoint.db.insertRow(session, cp()); + await expectLater( + AuditChainCheckpoint.db.insertRow(session, cp()), + throwsA(isA()), + ); + }); + + test('audit_exports grava e relê uma exportação (sequências opcionais)', + () async { + final session = sessionBuilder.build(); + final agora = DateTime.now().toUtc(); + final user = await User.db.insertRow( + session, + User( + cpfHash: 'audit-export-1', + name: 'Admin sintético', + birthDate: DateTime.utc(1980), + role: UserRole.admin, + createdAt: agora, + updatedAt: agora, + ), + ); + final gravada = await AuditExport.db.insertRow( + session, + AuditExport( + requestedBy: user.id!, + requestedAt: agora, + filtersJson: '{}', + format: 'csv', + rowCount: 0, + contentSha256: 'c' * 64, + chainState: 'ok', + ), + ); + final lida = await AuditExport.db.findById(session, gravada.id!); + expect(lida!.firstSequence, isNull); + expect(lida.lastSequence, isNull); + expect(lida.rowCount, 0); + }); + }); +} diff --git a/backend/sinalacs_server/test/integration/data_subject_case_store_test.dart b/backend/sinalacs_server/test/integration/data_subject_case_store_test.dart new file mode 100644 index 00000000..f617afb8 --- /dev/null +++ b/backend/sinalacs_server/test/integration/data_subject_case_store_test.dart @@ -0,0 +1,859 @@ +import 'dart:convert'; + +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/admin/admin_read_service.dart'; +import 'package:sinalacs_server/src/application/audit/audit_trail.dart'; +import 'package:sinalacs_server/src/application/auth/cpf.dart'; +import 'package:sinalacs_server/src/application/auth/passwordless_auth_service.dart'; +import 'package:sinalacs_server/src/application/auth/sms_gateway.dart'; +import 'package:sinalacs_server/src/application/onboarding/consent_signature.dart'; +import 'package:sinalacs_server/src/application/patients/push_token_service.dart'; +import 'package:sinalacs_server/src/config/app_config.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/infrastructure/crypto/encrypted_json.dart'; +import 'package:sinalacs_server/src/infrastructure/crypto/hmac_cpf_hasher.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_audit_trail.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_data_subject_case_store.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_otp_challenge_store.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_push_token_store.dart'; +import 'package:test/test.dart'; + +import '../support/health_data_fixtures.dart'; +import 'test_tools/serverpod_test_tools.dart'; + +/// Atendimento de pedidos do titular (#42) contra Postgres real: escopo por UBS, +/// ordem por prazo, decifração só no detalhe, anonimização transacional, corrida +/// entre dois analistas e rollback quando a auditoria falha. Dados sintéticos; +/// ids próprios (sufixo `42xx`) para não colidir com outros arquivos. +const _ubsA = '00000000-0000-4000-8000-000000004201'; +const _ubsB = '00000000-0000-4000-8000-000000004202'; +const _maA = '00000000-0000-4000-8000-000000004203'; +const _maB = '00000000-0000-4000-8000-000000004204'; +const _pacA = '00000000-0000-4000-8000-000000004211'; +const _pacB = '00000000-0000-4000-8000-000000004212'; +const _semArea = '00000000-0000-4000-8000-000000004213'; +const _acsId = '00000000-0000-4000-8000-000000004214'; +const _adminId = '00000000-0000-4000-8000-000000004215'; +const _ubsCorrida = '00000000-0000-4000-8000-0000000043f1'; +const _maCorrida = '00000000-0000-4000-8000-0000000043f2'; +const _pedA1 = '00000000-0000-4000-8000-000000004221'; +const _pedA2 = '00000000-0000-4000-8000-000000004222'; +const _pedA3 = '00000000-0000-4000-8000-000000004223'; +const _pedB1 = '00000000-0000-4000-8000-000000004224'; +const _pedSemArea = '00000000-0000-4000-8000-000000004225'; + +const _nomeA = 'Joana Sintética Pereira'; +const _detalheA2 = 'Corrigir telefone sintético para 0000-0000'; +const _resolucao = 'Pedido atendido conforme LGPD (texto sintético)'; + +/// CPF sintético e válido, usado só aqui (nunca um CPF real). +const _cpfSintetico = '52998224725'; + +final _t = DateTime.utc(2026, 10, 8, 12); + +const _segredoConsentimento = 'segredo-consentimento-sintetico'; + +final _hasher = HmacCpfHasher(pepper: AppConfig.developmentCpfHashPepper); + +/// Grava o evento numa lista em vez de `audit_logs`: usado onde a linha real +/// da cadeia não pode ser commitada (grupo sem rollback) ou onde a falha é +/// injetada de propósito. +class _AuditoriaGravada { + final eventos = []; + bool falhar = false; + + Future call(Session s, Transaction t, AuditEvent e) async { + if (falhar) throw StateError('falha injetada na auditoria (só em teste)'); + eventos.add(e); + } +} + +/// Trilha sem efeito para o serviço de OTP (a auditoria dele não é o que se mede). +class _SemAuditoria extends AuditTrail { + @override + Future record(AuditEvent event) async {} +} + +Future _usuario( + Session s, + String id, + String nome, + UserRole role, { + String? ma, + String? cpfHash, +}) => User.db + .insertRow( + s, + User( + id: UuidValue.fromString(id), + cpfHash: cpfHash ?? 'case-store-$id', + name: nome, + birthDate: DateTime.utc(1985, 3, 4), + role: role, + microAreaId: ma == null ? null : UuidValue.fromString(ma), + createdAt: _t, + updatedAt: _t, + ), + ) + .then((_) {}); + +Future _pedido( + Session s, + String id, + String userId, { + required DataSubjectRequestType tipo, + required DateTime dueAt, + DataSubjectRequestStatus status = DataSubjectRequestStatus.open, + String? detalhe, +}) async { + final cifrado = await testHealthDataCipher().encryptJson(detalhe); + await DataSubjectRequest.db.insertRow( + s, + DataSubjectRequest( + id: UuidValue.fromString(id), + userId: UuidValue.fromString(userId), + requestType: tipo, + detailsEncrypted: cifrado.ciphertextBase64, + detailsKeyVersion: cifrado.keyVersion, + status: status, + createdAt: dueAt.subtract(const Duration(days: 15)), + dueAt: dueAt, + ), + ); +} + +Future _territorio(Session s) async { + for (final (id, nome) in [(_ubsA, 'UBS A 42'), (_ubsB, 'UBS B 42')]) { + await Ubs.db.insertRow( + s, + Ubs( + id: UuidValue.fromString(id), + name: nome, + address: 'Endereço sintético', + city: 'São Paulo', + state: 'SP', + ), + ); + } + for (final (id, ubs) in [(_maA, _ubsA), (_maB, _ubsB)]) { + await MicroArea.db.insertRow( + s, + MicroArea( + id: UuidValue.fromString(id), + name: 'Microárea $id', + ubsId: UuidValue.fromString(ubs), + geoJsonBoundary: '{}', + ), + ); + } +} + +/// Paciente A (UBS A) com prontuário completo, paciente B (UBS B), um titular +/// sem microárea, um ACS e um administrador. Pedidos: +/// - A1: exclusão de A, vence em _t-1d (vencido); +/// - A2: correção de A, com details, vence em _t+5d; +/// - A3: exclusão de A, em análise, vence em _t+10d; +/// - B1: correção de B, vence em _t+2d; +/// - SemArea: correção do titular sem microárea, vence em _t+3d. +Future _seed(Session s) async { + await _territorio(s); + await _usuario(s, _acsId, 'ACS Sintético 42', UserRole.acs, ma: _maA); + await Acs.db.insertRow( + s, + Acs( + id: UuidValue.fromString(_acsId), + enrollmentId: 'ACS-42-1', + ubsId: UuidValue.fromString(_ubsA), + active: true, + ), + ); + await _usuario(s, _adminId, 'Admin Sintético 42', UserRole.admin); + await _usuario( + s, + _pacA, + _nomeA, + UserRole.patient, + ma: _maA, + cpfHash: _hasher.hash(Cpf.tryParse(_cpfSintetico)!), + ); + final pacienteA = await encryptedPatient( + id: _pacA, + emergencyContact: 'contato sintético', + isChronic: true, + chronicConditions: ['condição sintética'], + ); + await Patient.db.insertRow(s, pacienteA.copyWith(lastLocationHash: 'loc-sintetica')); + await _usuario(s, _pacB, 'Paciente B Sintético', UserRole.patient, ma: _maB); + await Patient.db.insertRow( + s, + await encryptedPatient(id: _pacB, emergencyContact: 'x', isChronic: false), + ); + await _usuario(s, _semArea, 'Sem Área Sintético', UserRole.patient); + + await _pedido(s, _pedA1, _pacA, + tipo: DataSubjectRequestType.deletion, dueAt: _t.subtract(const Duration(days: 1))); + await _pedido(s, _pedA2, _pacA, + tipo: DataSubjectRequestType.correction, + dueAt: _t.add(const Duration(days: 5)), + detalhe: _detalheA2); + await _pedido(s, _pedA3, _pacA, + tipo: DataSubjectRequestType.deletion, + dueAt: _t.add(const Duration(days: 10)), + status: DataSubjectRequestStatus.inReview); + await _pedido(s, _pedB1, _pacB, + tipo: DataSubjectRequestType.correction, + dueAt: _t.add(const Duration(days: 2)), + detalhe: 'texto B sintético'); + await _pedido(s, _pedSemArea, _semArea, + tipo: DataSubjectRequestType.correction, + dueAt: _t.add(const Duration(days: 3)), + detalhe: 'texto sem área sintético'); +} + +/// O "prontuário" acessório do paciente A: token de push, desafio OTP, +/// credencial, triagem, alerta, visita e consentimento. +Future _prontuarioA(Session s) async { + final a = UuidValue.fromString(_pacA); + await PushToken.db.insertRow( + s, + PushToken( + userId: a, + microAreaId: UuidValue.fromString(_maA), + token: 'tok-42-a', + platform: 'android', + createdAt: _t, + updatedAt: _t, + ), + ); + await OtpChallenge.db.insertRow( + s, + OtpChallenge( + userId: a, + codeHash: 'hash-sintetico', + attempts: 0, + createdAt: _t, + expiresAt: _t.add(const Duration(minutes: 5)), + ), + ); + await UserCredential.db.insertRow( + s, + UserCredential( + userId: a, + passwordHash: 'x', + passwordSalt: 'y', + memoryKb: 1, + iterations: 1, + parallelism: 1, + failedAttempts: 0, + createdAt: _t, + updatedAt: _t, + ), + ); + final respostas = await testHealthDataCipher().encryptJson([ + TriageAnswer(question: 'fever', answer: 'sim').toJson(), + ]); + await TriageSession.db.insertRow( + s, + TriageSession( + patientId: a, + answersEncrypted: respostas.ciphertextBase64, + answersKeyVersion: respostas.keyVersion, + resultRisk: RiskLevel.yellow, + resultDisplay: 'Amarelo', + createdAt: _t, + deviceId: 'device-sintetico', + ), + ); + await Alert.db.insertRow( + s, + Alert( + patientId: a, + microAreaId: UuidValue.fromString(_maA), + triggeredAt: _t, + riskLevel: RiskLevel.red, + locationHash: 'hash-sintetico', + status: AlertStatus.pending, + mqttTopic: 'sinalacs/v1/microareas/$_maA/alerts', + deviceId: 'device-sintetico', + retryCount: 0, + version: 0, + ), + ); + await Visit.db.insertRow( + s, + Visit( + patientId: a, + acsId: UuidValue.fromString(_acsId), + scheduledAt: _t, + status: 'realizada', + riskLevelBefore: RiskLevel.green, + syncStatus: SyncStatus.synced, + localId: UuidValue.fromString('00000000-0000-4000-8000-000000004231'), + version: 1, + ), + ); + await ConsentLog.db.insertRow( + s, + ConsentLog( + userId: a, + purpose: ConsentPurpose.segmentedPush.name, + action: 'granted', + version: '2026.1', + timestamp: _t, + ipHash: 'nao-aplicavel-teste', + userAgent: 'nao-aplicavel-teste', + signature: 'assinatura-de-teste', + ), + ); +} + +AuditEvent _evento(String id, String result) => AuditEvent( + userId: _adminId, + actionType: 'write', + resourceType: 'data_subject_request', + resourceId: id, + result: result, +); + +void main() { + withServerpod('Dado o atendimento de pedidos do titular (#42)', ( + sessionBuilder, + endpoints, + ) { + late Session session; + late _AuditoriaGravada auditoria; + late OrmDataSubjectCaseStore store; + const sistema = AdminScope.system(); + + setUp(() async { + session = sessionBuilder.build(); + await _seed(session); + auditoria = _AuditoriaGravada(); + store = OrmDataSubjectCaseStore( + session, + testHealthDataCipher(), + chainSecret: _segredoConsentimento, + appendAudit: auditoria.call, + ); + }); + + Future pedido(String id) async => + (await DataSubjectRequest.db.findById(session, UuidValue.fromString(id)))!; + + Future usuario(String id) async => + (await User.db.findById(session, UuidValue.fromString(id)))!; + + Future excluir(String id, {OrmDataSubjectCaseStore? via}) => + (via ?? store).decide( + sistema, + id, + from: const {DataSubjectRequestStatus.open, DataSubjectRequestStatus.inReview}, + to: DataSubjectRequestStatus.completed, + resolution: null, + decidedBy: _adminId, + anonymize: true, + audit: _evento(id, 'completed'), + now: _t, + ); + + test('list ordena por dueAt crescente e traz vencidos primeiro', () async { + final p = await store.list(sistema, limit: 50, offset: 0, now: _t); + expect(p.items.map((i) => i.id).toList(), [_pedA1, _pedB1, _pedSemArea, _pedA2, _pedA3]); + expect(p.items.first.overdue, isTrue); + expect(p.items.skip(1).every((i) => !i.overdue), isTrue); + expect(p.nextOffset, isNull); + + final p1 = await store.list(sistema, limit: 2, offset: 0, now: _t); + expect(p1.items.map((i) => i.id), [_pedA1, _pedB1]); + expect(p1.nextOffset, 2); + final p3 = await store.list(sistema, limit: 2, offset: 4, now: _t); + expect(p3.items.map((i) => i.id), [_pedA3]); + expect(p3.nextOffset, isNull); + + final emAnalise = await store.list( + sistema, + status: DataSubjectRequestStatus.inReview, + limit: 50, + offset: 0, + now: _t, + ); + expect(emAnalise.items.map((i) => i.id), [_pedA3]); + expect(emAnalise.items.single.patientLabel, matches(RegExp(r'^#[0-9A-F]{4}$'))); + }); + + test('escopo de UBS: coordenador só vê pacientes da própria UBS', () async { + final soA = await store.list(const AdminScope.ubs(_ubsA), limit: 50, offset: 0, now: _t); + expect(soA.items.map((i) => i.id).toSet(), {_pedA1, _pedA2, _pedA3}); + final soB = await store.list(const AdminScope.ubs(_ubsB), limit: 50, offset: 0, now: _t); + expect(soB.items.map((i) => i.id), [_pedB1]); + + // Fora do escopo e inexistente são indistinguíveis: os dois devolvem null. + expect(await store.find(const AdminScope.ubs(_ubsA), _pedB1, now: _t), isNull); + expect(await store.find(const AdminScope.ubs(_ubsA), _pedSemArea, now: _t), isNull); + expect( + await store.find(sistema, '00000000-0000-4000-8000-0000000042ff', now: _t), + isNull, + ); + expect(await store.find(sistema, 'nao-e-uuid', now: _t), isNull); + expect(await store.find(sistema, _pedSemArea, now: _t), isNotNull); + + // Decidir fora do escopo também não acontece. + final ok = await store.decide( + const AdminScope.ubs(_ubsA), + _pedB1, + from: const {DataSubjectRequestStatus.open}, + to: DataSubjectRequestStatus.rejected, + resolution: 'motivo sintético', + decidedBy: _adminId, + anonymize: false, + audit: _evento(_pedB1, 'rejected'), + now: _t, + ); + expect(ok, isFalse); + expect((await pedido(_pedB1)).status, DataSubjectRequestStatus.open); + expect(auditoria.eventos, isEmpty); + }); + + test('find decifra details só no detalhe; a lista não tem details', () async { + final lista = await store.list(sistema, limit: 50, offset: 0, now: _t); + final json = jsonEncode(lista.toJson()); + expect(json, isNot(contains(_detalheA2))); + expect(json, isNot(contains(_nomeA))); + expect(json, isNot(contains(_pacA))); + + final d = await store.find(sistema, _pedA2, now: _t); + expect(d, isNotNull); + expect(d!.details, _detalheA2); + expect(d.type, DataSubjectRequestType.correction); + expect(d.resolution, isNull); + expect(d.decidedAt, isNull); + expect(d.patientLabel, matches(RegExp(r'^#[0-9A-F]{4}$'))); + + // Correção atendida com nota: a nota é cifrada e volta no detalhe. + final ok = await store.decide( + sistema, + _pedA2, + from: const {DataSubjectRequestStatus.open}, + to: DataSubjectRequestStatus.completed, + resolution: _resolucao, + decidedBy: _adminId, + anonymize: false, + audit: _evento(_pedA2, 'completed'), + now: _t, + ); + expect(ok, isTrue); + final linha = await pedido(_pedA2); + expect(linha.resolutionEncrypted, isNot(contains(_resolucao))); + expect(linha.resolutionKeyVersion, linha.detailsKeyVersion); + expect(linha.decidedBy, UuidValue.fromString(_adminId)); + expect(linha.decidedAt, _t); + final depois = await store.find(sistema, _pedA2, now: _t); + expect(depois!.resolution, _resolucao); + expect(depois.status, DataSubjectRequestStatus.completed); + expect(depois.decidedAt, _t); + // Correção não anonimiza ninguém. + expect((await usuario(_pacA)).name, _nomeA); + expect(auditoria.eventos.single.result, 'completed'); + }); + + test( + 'decide(exclusão) anonimiza user/patient, apaga push_tokens/otp/credential/triage answers e mantém alerts, visits, audit_logs, consent_logs', + () async { + await _prontuarioA(session); + // Uma linha real na cadeia antes: ela tem de sobreviver à anonimização. + await OrmAuditTrail(session: () => session, chainSecret: 'segredo-sintetico') + .record(AuditEvent( + userId: _pacA, + actionType: 'read', + resourceType: 'patient_data', + result: 'success', + )); + final cpfAntes = (await usuario(_pacA)).cpfHash; + final a = UuidValue.fromString(_pacA); + + expect(await excluir(_pedA1), isTrue); + + final u = await usuario(_pacA); + expect(u.name, 'Titular removido'); + expect(u.cpfHash, startsWith('removed:')); + expect(u.cpfHash, isNot(cpfAntes)); + expect(u.role, UserRole.patient); + final p = (await Patient.db.findById(session, a))!; + expect(p.chronicConditionsEncrypted, ''); + expect(p.lastLocationHash, isNull); + expect(p.emergencyContactEncrypted, ''); + expect(u.birthDate, DateTime.utc(1900, 1, 1)); + + expect(await PushToken.db.count(session, where: (t) => t.userId.equals(a)), 0); + expect(await OtpChallenge.db.count(session, where: (t) => t.userId.equals(a)), 0); + expect(await UserCredential.db.count(session, where: (t) => t.userId.equals(a)), 0); + final triagens = await TriageSession.db.find(session, where: (t) => t.patientId.equals(a)); + expect(triagens, hasLength(1), reason: 'a sessão fica para estatística'); + expect(await decryptedTriageAnswers(triagens.single), isEmpty); + expect(triagens.single.resultRisk, RiskLevel.yellow); + + expect(await Alert.db.count(session, where: (t) => t.patientId.equals(a)), 1); + expect(await Visit.db.count(session, where: (t) => t.patientId.equals(a)), 1); + // O consentimento original fica intocado (append-only); entra um + // `denied` de segmentedPush, assinado como os do painel do titular. + final consentimentos = await ConsentLog.db.find( + session, + where: (t) => t.userId.equals(a), + orderBy: (t) => t.timestamp, + ); + expect(consentimentos, hasLength(2)); + expect(consentimentos.first.action, 'granted'); + final negado = consentimentos.last; + expect(negado.purpose, ConsentPurpose.segmentedPush.name); + expect(negado.action, 'denied'); + expect(negado.timestamp, _t.add(const Duration(microseconds: 1))); + expect( + negado.signature, + ConsentSignature(secret: _segredoConsentimento).compute( + userId: _pacA, + purpose: negado.purpose, + action: negado.action, + version: negado.version, + timestamp: negado.timestamp, + ), + ); + expect(await AuditLog.db.count(session, where: (t) => t.userId.equals(a)), 1); + + final linha = await pedido(_pedA1); + expect(linha.status, DataSubjectRequestStatus.completed); + expect(linha.decidedBy, UuidValue.fromString(_adminId)); + expect(linha.decidedAt, _t); + expect(auditoria.eventos.map((e) => e.resourceId), [_pedA1, _pedA3]); + + // O paciente B não é tocado. + expect((await usuario(_pacB)).name, 'Paciente B Sintético'); + }, + ); + + test('decide(exclusão) fecha também um segundo pedido de exclusão aberto do mesmo titular', () async { + expect(await excluir(_pedA1), isTrue); + + final a3 = await pedido(_pedA3); + expect(a3.status, DataSubjectRequestStatus.completed); + expect(a3.decidedAt, _t); + expect(a3.decidedBy, UuidValue.fromString(_adminId)); + // A correção aberta do mesmo titular continua aberta: só exclusões fecham. + expect((await pedido(_pedA2)).status, DataSubjectRequestStatus.open); + // Uma linha de auditoria por pedido fechado: o decidido e o irmão. + expect(auditoria.eventos, hasLength(2)); + final irmao = auditoria.eventos.last; + expect(irmao.resourceId, _pedA3); + expect(irmao.userId, _adminId); + expect(irmao.actionType, 'write'); + expect(irmao.resourceType, 'data_subject_request'); + expect(irmao.result, 'completed'); + // O pedido fechado junto não pode ser decidido de novo. + expect(await excluir(_pedA3), isFalse); + }); + + test('depois da anonimização, a sessão viva do titular não registra token de push', () async { + // Consentimento `granted` vigente antes: sem o `denied` da anonimização, + // o registro passaria. + await _prontuarioA(session); + expect(await excluir(_pedA1), isTrue); + + final r = await OrmPushTokenStore(session: () => session).registerIfConsented( + userId: _pacA, + microAreaId: _maA, + token: 'tok-42-depois', + platform: 'android', + now: _t.add(const Duration(minutes: 1)), + ); + + expect(r.outcome, PushRegistration.refused); + expect( + await PushToken.db.count( + session, + where: (t) => t.userId.equals(UuidValue.fromString(_pacA)), + ), + 0, + ); + }); + + test('decide em titular sem linha em patients não quebra', () async { + const pedido42 = '00000000-0000-4000-8000-000000004226'; + await _pedido(session, pedido42, _semArea, + tipo: DataSubjectRequestType.deletion, dueAt: _t.add(const Duration(days: 1))); + + expect(await excluir(pedido42), isTrue); + + final u = await usuario(_semArea); + expect(u.name, 'Titular removido'); + expect(u.cpfHash, startsWith('removed:')); + expect((await pedido(pedido42)).status, DataSubjectRequestStatus.completed); + }); + + test('exclusão de quem não é paciente é recusada e nada muda', () async { + const pedidoAcs = '00000000-0000-4000-8000-000000004227'; + await _pedido(session, pedidoAcs, _acsId, + tipo: DataSubjectRequestType.deletion, dueAt: _t.add(const Duration(days: 1))); + + await expectLater(excluir(pedidoAcs), throwsA(isA())); + + expect((await usuario(_acsId)).name, 'ACS Sintético 42'); + expect((await pedido(pedidoAcs)).status, DataSubjectRequestStatus.open); + expect(auditoria.eventos, isEmpty); + }); + + test('estado fora de `from` devolve false sem gravar nada', () async { + final ok = await store.decide( + sistema, + _pedA3, // inReview + from: const {DataSubjectRequestStatus.open}, + to: DataSubjectRequestStatus.inReview, + resolution: null, + decidedBy: _adminId, + anonymize: false, + audit: _evento(_pedA3, 'in_review'), + now: _t, + ); + expect(ok, isFalse); + expect(auditoria.eventos, isEmpty); + expect((await pedido(_pedA3)).decidedAt, isNull); + }); + + test('falha na auditoria desfaz a mudança de status (rollback)', () async { + await _prontuarioA(session); + final cpfAntes = (await usuario(_pacA)).cpfHash; + auditoria.falhar = true; + + await expectLater(excluir(_pedA1), throwsA(isA())); + + final linha = await pedido(_pedA1); + expect(linha.status, DataSubjectRequestStatus.open); + expect(linha.decidedAt, isNull); + expect(linha.decidedBy, isNull); + expect((await pedido(_pedA3)).status, DataSubjectRequestStatus.inReview); + final u = await usuario(_pacA); + expect(u.name, _nomeA); + expect(u.cpfHash, cpfAntes); + final a = UuidValue.fromString(_pacA); + expect(await PushToken.db.count(session, where: (t) => t.userId.equals(a)), 1); + expect(await UserCredential.db.count(session, where: (t) => t.userId.equals(a)), 1); + }); + + test('com a trilha real, a linha de auditoria entra na cadeia na mesma transação', () async { + final trilha = OrmAuditTrail(session: () => session, chainSecret: 'segredo-sintetico'); + final real = OrmDataSubjectCaseStore( + session, + testHealthDataCipher(), + chainSecret: _segredoConsentimento, + appendAudit: trilha.recordInTransaction, + ); + expect(await excluir(_pedA1, via: real), isTrue); + final irmas = await AuditLog.db.find( + session, + where: (t) => t.resourceId.equals(UuidValue.fromString(_pedA3)), + ); + expect(irmas.single.result, 'completed', reason: 'o pedido irmão também é auditado'); + final linhas = await AuditLog.db.find( + session, + where: (t) => t.resourceId.equals(UuidValue.fromString(_pedA1)), + ); + expect(linhas, hasLength(1)); + expect(linhas.single.actionType, 'write'); + expect(linhas.single.resourceType, 'data_subject_request'); + expect(linhas.single.result, 'completed'); + expect(linhas.single.userId, UuidValue.fromString(_adminId)); + }); + + test('depois da anonimização, requestOtp/verifyOtp do titular recusam com a mensagem genérica de sempre', () async { + final cpf = Cpf.tryParse(_cpfSintetico)!; + final naoCadastrado = Cpf.tryParse('98765432100')!; + final sms = RecordingSmsGateway(); + final otp = PasswordlessAuthService( + store: OrmOtpChallengeStore(session: () => session), + hasher: _hasher, + sms: sms, + audit: _SemAuditoria(), + codeGenerator: () => '123456', + ); + final nascimento = DateTime.utc(1985, 3, 4); + + // Controle positivo: antes da exclusão o CPF acha o titular e recebe código. + await otp.requestOtp(cpf: cpf, birthDate: nascimento, now: _t); + expect(sms.sent, hasLength(1)); + + expect(await excluir(_pedA1), isTrue); + + await otp.requestOtp(cpf: cpf, birthDate: nascimento, now: _t.add(const Duration(minutes: 2))); + expect(sms.sent, hasLength(1), reason: 'nenhum código novo para o titular removido'); + expect( + await OtpChallenge.db.count( + session, + where: (t) => t.userId.equals(UuidValue.fromString(_pacA)), + ), + 0, + ); + + Future recusa(Cpf c) async { + try { + await otp.verifyOtp(cpf: c, code: '123456', now: _t.add(const Duration(minutes: 3))); + } on OtpRequestException catch (e) { + return e.message; + } + fail('verifyOtp deveria recusar'); + } + + final removido = await recusa(cpf); + expect(removido, await recusa(naoCadastrado)); + expect(removido, 'Código inválido ou expirado. Peça um novo.'); + // Nenhuma conta recriada com o hash do CPF. + expect( + await User.db.count(session, where: (t) => t.cpfHash.equals(_hasher.hash(cpf))), + 0, + ); + }); + + test('subjectOf devolve o titular do pedido e null para id inexistente ou malformado', () async { + expect(await store.subjectOf(_pedA1), _pacA); + expect(await store.subjectOf('00000000-0000-4000-8000-0000000042ff'), isNull); + expect(await store.subjectOf('nao-e-uuid'), isNull); + }); + + test('ubsOf devolve a UBS do coordenador e null para quem não tem', () async { + const coord = '00000000-0000-4000-8000-000000004216'; + await _usuario(session, coord, 'Coord Sintético 42', UserRole.coordinator); + await StaffAccount.db.insertRow( + session, + StaffAccount( + id: UuidValue.fromString(coord), + enrollmentId: 'COO-42-1', + active: true, + ubsId: UuidValue.fromString(_ubsA), + ), + ); + expect(await store.ubsOf(coord), _ubsA); + expect(await store.ubsOf(_adminId), isNull); + expect(await store.ubsOf('nao-e-uuid'), isNull); + }); + }); + + // Sem rollback automático: com ele, sessões concorrentes do harness colidem no + // savepoint compartilhado (ver `institutional_login_test.dart`). A auditoria + // aqui é a lista em memória e não `audit_logs`: uma linha commitada na cadeia + // seria vista por suítes paralelas que contam `AuditLog` sem filtro. O que a + // lista prova é o que importa — o perdedor da corrida sai ANTES de chegar à + // auditoria e à anonimização, que ficam dentro da mesma transação. + withServerpod( + 'Dado o atendimento de pedidos do titular (#42), sem rollback (corrida)', + (sessionBuilder, endpoints) { + const rodadas = 5; + String idDe(int base, int i) => + '00000000-0000-4000-8000-0000000043${(base + i).toRadixString(16).padLeft(2, '0')}'; + + Future limpar(Session s) async { + for (var i = 0; i < rodadas; i++) { + final user = UuidValue.fromString(idDe(0x10, i)); + await DataSubjectRequest.db.deleteWhere(s, where: (t) => t.userId.equals(user)); + await ConsentLog.db.deleteWhere(s, where: (t) => t.userId.equals(user)); + await Patient.db.deleteWhere(s, where: (t) => t.id.equals(user)); + await User.db.deleteWhere(s, where: (t) => t.id.equals(user)); + } + await User.db.deleteWhere( + s, + where: (t) => + t.id.equals(UuidValue.fromString(idDe(0x01, 0))) | + t.id.equals(UuidValue.fromString(idDe(0x02, 0))), + ); + await MicroArea.db.deleteWhere(s, where: (t) => t.id.equals(UuidValue.fromString(_maCorrida))); + await Ubs.db.deleteWhere(s, where: (t) => t.id.equals(UuidValue.fromString(_ubsCorrida))); + } + + test('duas decisões simultâneas: uma true, outra false; uma só anonimização', () async { + final s = sessionBuilder.build(); + final analista1 = idDe(0x01, 0); + final analista2 = idDe(0x02, 0); + await limpar(s); + try { + await Ubs.db.insertRow( + s, + Ubs( + id: UuidValue.fromString(_ubsCorrida), + name: 'UBS Corrida 42', + address: 'Endereço sintético', + city: 'São Paulo', + state: 'SP', + ), + ); + await MicroArea.db.insertRow( + s, + MicroArea( + id: UuidValue.fromString(_maCorrida), + name: 'Microárea Corrida 42', + ubsId: UuidValue.fromString(_ubsCorrida), + geoJsonBoundary: '{}', + ), + ); + await _usuario(s, analista1, 'Analista 1 Sintético', UserRole.admin); + await _usuario(s, analista2, 'Analista 2 Sintético', UserRole.admin); + + for (var i = 0; i < rodadas; i++) { + final titular = idDe(0x10, i); + final pedidoId = idDe(0x20, i); + await _usuario(s, titular, 'Titular Corrida $i', UserRole.patient, ma: _maCorrida); + await Patient.db.insertRow( + s, + await encryptedPatient(id: titular, emergencyContact: 'x', isChronic: false), + ); + await _pedido(s, pedidoId, titular, + tipo: DataSubjectRequestType.deletion, dueAt: _t); + + final auditoria = _AuditoriaGravada(); + Future decidir(String analista) => OrmDataSubjectCaseStore( + sessionBuilder.build(), + testHealthDataCipher(), + chainSecret: _segredoConsentimento, + appendAudit: auditoria.call, + ).decide( + const AdminScope.system(), + pedidoId, + from: const {DataSubjectRequestStatus.open, DataSubjectRequestStatus.inReview}, + to: DataSubjectRequestStatus.completed, + resolution: null, + decidedBy: analista, + anonymize: true, + audit: AuditEvent( + userId: analista, + actionType: 'write', + resourceType: 'data_subject_request', + resourceId: pedidoId, + result: 'completed', + ), + now: _t, + ); + + final r = await Future.wait([decidir(analista1), decidir(analista2)]); + expect(r.where((x) => x).length, 1, reason: 'rodada $i'); + expect(auditoria.eventos, hasLength(1), reason: 'rodada $i: uma só auditoria'); + final vencedor = r[0] ? analista1 : analista2; + expect(auditoria.eventos.single.userId, vencedor); + + final linha = (await DataSubjectRequest.db.findById(s, UuidValue.fromString(pedidoId)))!; + expect(linha.status, DataSubjectRequestStatus.completed); + expect(linha.decidedBy, UuidValue.fromString(vencedor), reason: 'o perdedor não sobrescreve'); + final u = (await User.db.findById(s, UuidValue.fromString(titular)))!; + expect(u.name, 'Titular removido'); + // Uma anonimização só, medida no banco: um único `denied` gravado. + expect( + await ConsentLog.db.count( + s, + where: (t) => t.userId.equals(UuidValue.fromString(titular)), + ), + 1, + reason: 'rodada $i', + ); + } + } finally { + await limpar(s); + } + }); + }, + rollbackDatabase: RollbackDatabase.disabled, + ); +} diff --git a/backend/sinalacs_server/test/integration/data_subject_request_schema_test.dart b/backend/sinalacs_server/test/integration/data_subject_request_schema_test.dart new file mode 100644 index 00000000..e793eaa4 --- /dev/null +++ b/backend/sinalacs_server/test/integration/data_subject_request_schema_test.dart @@ -0,0 +1,33 @@ +import 'package:test/test.dart'; + +import 'test_tools/serverpod_test_tools.dart'; + +/// Esquema de `data_subject_requests` contra Postgres real: as colunas de +/// decisão (#42) existem e são todas anuláveis, para que pedidos já abertos +/// continuem válidos sem backfill. +void main() { + withServerpod('Dado o esquema de data_subject_requests', ( + sessionBuilder, + endpoints, + ) { + test('tem as colunas de decisão, todas anuláveis', () async { + final session = sessionBuilder.build(); + final rows = await session.db.unsafeQuery( + "select column_name, is_nullable from information_schema.columns " + "where table_name = 'data_subject_requests'", + ); + final byName = { + for (final r in rows) + r.toColumnMap()['column_name']: r.toColumnMap()['is_nullable'], + }; + for (final c in [ + 'decidedAt', + 'decidedBy', + 'resolutionEncrypted', + 'resolutionKeyVersion', + ]) { + expect(byName[c], 'YES', reason: c); + } + }); + }); +} diff --git a/backend/sinalacs_server/test/integration/data_subject_rights_endpoint_test.dart b/backend/sinalacs_server/test/integration/data_subject_rights_endpoint_test.dart new file mode 100644 index 00000000..07bc6eee --- /dev/null +++ b/backend/sinalacs_server/test/integration/data_subject_rights_endpoint_test.dart @@ -0,0 +1,680 @@ +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/onboarding/consent_signature.dart'; +import 'package:sinalacs_server/src/application/onboarding/onboarding_service.dart' show ConsentLogEntry, consentPolicyVersion; +import 'package:sinalacs_server/src/config/app_config.dart'; +import 'package:sinalacs_server/src/application/patients/patient_data_overview_service.dart' + show ConsentRecordSnapshot, DataSubjectRequestSnapshot; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_data_subject_rights_store.dart'; +import 'package:sinalacs_server/src/runtime/alert_runtime.dart'; +import 'package:test/test.dart'; + +import '../support/health_data_fixtures.dart'; +import 'test_tools/serverpod_test_tools.dart'; + +/// Prova, contra Postgres real, os direitos do titular exercidos pelo app +/// (LGPD-RF05/RF08): a linha assinada em `consent_logs`, o pedido cifrado em +/// `data_subject_requests` e o reflexo de ambos em `patients.myData`. +/// `data_subject_rights_service_test.dart` prova as regras com fakes. +const _patientId = '00000000-0000-4000-8000-000000000001'; +const _acsId = '00000000-0000-4000-8000-000000000002'; +const _microAreaId = '00000000-0000-4000-8000-000000000003'; +const _ubsId = '00000000-0000-4000-8000-000000000004'; +const _chainSecret = 'test-audit-chain-secret'; + +AppConfig _config() => AppConfig( + mqttBroker: 'localhost:1883', + jwtSecret: 'test-secret', + auditChainSecret: _chainSecret, + healthDataEncryptionKey: AppConfig.developmentHealthDataEncryptionKey, + cpfHashPepper: AppConfig.developmentCpfHashPepper, + smsGateway: 'log', + 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.insertRow( + session, + MicroArea( + id: UuidValue.fromString(_microAreaId), + name: 'Microárea 12', + ubsId: UuidValue.fromString(_ubsId), + geoJsonBoundary: '{}', + ), + ); + final now = DateTime.now().toUtc(); + await User.db.insert(session, [ + User( + id: UuidValue.fromString(_patientId), + cpfHash: 'development-patient', + name: 'Paciente de desenvolvimento', + birthDate: DateTime.utc(1990, 1, 1), + role: UserRole.patient, + microAreaId: UuidValue.fromString(_microAreaId), + createdAt: now, + updatedAt: now, + ), + User( + id: UuidValue.fromString(_acsId), + cpfHash: 'development-acs', + name: 'ACS de desenvolvimento', + birthDate: DateTime.utc(1980, 1, 1), + role: UserRole.acs, + microAreaId: UuidValue.fromString(_microAreaId), + 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.insertRow( + session, + await encryptedPatient( + id: _patientId, + emergencyContact: 'Contato de desenvolvimento', + isChronic: false, + chronicConditions: const [], + ), + ); +} + +// Grupo de corrida (sem rollback): ids próprios, distintos dos `8000` do grupo +// principal e dos fixos de `auth.developmentLogin`, para poder ser limpo à mão. +const _raceUbsId = '00000000-0000-4000-9100-000000000001'; +const _raceMicroAreaId = '00000000-0000-4000-9100-000000000002'; +const _racePatientId = '00000000-0000-4000-9100-000000000003'; + +Future _seedRace(Session session) async { + await Ubs.db.insertRow( + session, + Ubs( + id: UuidValue.fromString(_raceUbsId), + name: 'UBS Desenvolvimento (corrida exclusão)', + address: 'Endereço local', + city: 'São Paulo', + state: 'SP', + ), + ); + await MicroArea.db.insertRow( + session, + MicroArea( + id: UuidValue.fromString(_raceMicroAreaId), + name: 'Microárea de corrida', + ubsId: UuidValue.fromString(_raceUbsId), + geoJsonBoundary: '{}', + ), + ); + final now = DateTime.now().toUtc(); + await User.db.insertRow( + session, + User( + id: UuidValue.fromString(_racePatientId), + cpfHash: 'development-patient-corrida-exclusao', + name: 'Paciente de corrida', + birthDate: DateTime.utc(1975, 3, 10), + role: UserRole.patient, + microAreaId: UuidValue.fromString(_raceMicroAreaId), + createdAt: now, + updatedAt: now, + ), + ); + await Patient.db.insertRow( + session, + await encryptedPatient( + id: _racePatientId, + emergencyContact: 'Contato de desenvolvimento', + isChronic: false, + ), + ); +} + +/// Desfaz à mão o que [_seedRace] e o teste gravam: esse grupo roda com +/// `RollbackDatabase.disabled`. Filhos antes dos pais. +Future _cleanupRace(Session session) async { + await ConsentLog.db.deleteWhere( + session, + where: (t) => t.userId.equals(UuidValue.fromString(_racePatientId)), + ); + await DataSubjectRequest.db.deleteWhere( + session, + where: (t) => t.userId.equals(UuidValue.fromString(_racePatientId)), + ); + await Patient.db.deleteWhere( + session, + where: (t) => t.id.equals(UuidValue.fromString(_racePatientId)), + ); + await User.db.deleteWhere( + session, + where: (t) => t.id.equals(UuidValue.fromString(_racePatientId)), + ); + await MicroArea.db.deleteWhere( + session, + where: (t) => t.id.equals(UuidValue.fromString(_raceMicroAreaId)), + ); + await Ubs.db.deleteWhere( + session, + where: (t) => t.id.equals(UuidValue.fromString(_raceUbsId)), + ); +} + +void main() { + withServerpod('Dados os direitos do titular exercidos pelo app', (sessionBuilder, endpoints) { + setUp(() => AlertRuntime.instance.overrideConfig(_config())); + tearDown(() => AlertRuntime.instance.overrideConfig(null)); + + Future patientToken() async => + (await endpoints.auth.developmentLogin(sessionBuilder, role: 'patient')).accessToken; + + test('updateConsent grava linha assinada em consent_logs e myData passa a mostrá-la', () async { + final session = sessionBuilder.build(); + await _seed(session); + final token = await patientToken(); + + final record = await endpoints.patients.updateConsent( + sessionBuilder, + accessToken: token, + purpose: ConsentPurpose.localReminders, + granted: false, + ); + expect(record.action, 'denied'); + + final row = (await ConsentLog.db.find( + session, + where: (t) => t.userId.equals(UuidValue.fromString(_patientId)), + )) + .single; + expect(row.purpose, 'localReminders'); + expect(row.action, 'denied'); + expect(row.ipHash, 'nao-aplicavel-painel-titular'); + expect( + row.signature, + ConsentSignature(secret: _chainSecret).compute( + userId: _patientId, + purpose: row.purpose, + action: row.action, + version: row.version, + timestamp: row.timestamp, + ), + ); + + final overview = await endpoints.patients.myData(sessionBuilder, accessToken: token); + expect(overview.consents.last.purpose, 'localReminders'); + expect(overview.consents.last.action, 'denied'); + }); + + test('acceptTermsOfUse grava termsOfUse assinado e myData passa a mostrá-lo', () async { + final session = sessionBuilder.build(); + await _seed(session); + final token = await patientToken(); + + final record = await endpoints.patients.acceptTermsOfUse(sessionBuilder, accessToken: token); + expect(record.purpose, 'termsOfUse'); + expect(record.action, 'granted'); + + final row = (await ConsentLog.db.find( + session, + where: (t) => t.userId.equals(UuidValue.fromString(_patientId)), + )) + .single; + expect(row.purpose, 'termsOfUse'); + expect(row.version, consentPolicyVersion); + expect( + row.signature, + ConsentSignature(secret: _chainSecret).compute( + userId: _patientId, + purpose: row.purpose, + action: row.action, + version: row.version, + timestamp: row.timestamp, + ), + ); + + final overview = await endpoints.patients.myData(sessionBuilder, accessToken: token); + expect(overview.consents.last.purpose, 'termsOfUse'); + }); + + test('acceptTermsOfUse recusa token de ACS sem gravar nada', () async { + final session = sessionBuilder.build(); + await _seed(session); + final acsToken = + (await endpoints.auth.developmentLogin(sessionBuilder, role: 'acs')).accessToken; + + await expectLater( + endpoints.patients.acceptTermsOfUse(sessionBuilder, accessToken: acsToken), + throwsA(isA()), + ); + expect(await ConsentLog.db.count(session, where: (t) => t.userId.equals(UuidValue.fromString(_patientId))), 0); + }); + + test('hasAcceptedCurrentTerms: falso antes, verdadeiro depois de aceitar', () async { + final session = sessionBuilder.build(); + await _seed(session); + final token = await patientToken(); + + expect(await endpoints.patients.hasAcceptedCurrentTerms(sessionBuilder, accessToken: token), isFalse); + await endpoints.patients.acceptTermsOfUse(sessionBuilder, accessToken: token); + expect(await endpoints.patients.hasAcceptedCurrentTerms(sessionBuilder, accessToken: token), isTrue); + }); + + test('termsChangeNotice sem agenda devolve null e não grava auditoria', () async { + final session = sessionBuilder.build(); + await _seed(session); + final token = await patientToken(); + final before = await AuditLog.db.count(session); + + expect(await endpoints.patients.termsChangeNotice(sessionBuilder, accessToken: token), isNull); + expect(await AuditLog.db.count(session), before); + }); + + test('termsChangeNotice recusa token de ACS e token inválido', () async { + final session = sessionBuilder.build(); + await _seed(session); + final acsToken = + (await endpoints.auth.developmentLogin(sessionBuilder, role: 'acs')).accessToken; + await expectLater( + endpoints.patients.termsChangeNotice(sessionBuilder, accessToken: acsToken), + throwsA(isA()), + ); + await expectLater( + endpoints.patients.termsChangeNotice(sessionBuilder, accessToken: 'lixo'), + throwsA(isA()), + ); + }); + + test('hasAcceptedCurrentTerms recusa token de ACS', () async { + final session = sessionBuilder.build(); + await _seed(session); + final acsToken = + (await endpoints.auth.developmentLogin(sessionBuilder, role: 'acs')).accessToken; + + await expectLater( + endpoints.patients.hasAcceptedCurrentTerms(sessionBuilder, accessToken: acsToken), + throwsA(isA()), + ); + }); + + test('hasGrantedConsent: acompanha a decisão mais recente e não grava auditoria', () async { + final session = sessionBuilder.build(); + await _seed(session); + final token = await patientToken(); + Future consulta() => endpoints.patients + .hasGrantedConsent(sessionBuilder, accessToken: token, purpose: ConsentPurpose.segmentedPush); + + expect(await consulta(), isFalse); + await endpoints.patients + .updateConsent(sessionBuilder, accessToken: token, purpose: ConsentPurpose.segmentedPush, granted: true); + final auditoriaAntes = await AuditLog.db.count(session); + expect(await consulta(), isTrue); + expect(await AuditLog.db.count(session), auditoriaAntes); + await endpoints.patients + .updateConsent(sessionBuilder, accessToken: token, purpose: ConsentPurpose.segmentedPush, granted: false); + expect(await consulta(), isFalse); + }); + + test('hasGrantedConsent recusa token de ACS', () async { + final session = sessionBuilder.build(); + await _seed(session); + final acsToken = + (await endpoints.auth.developmentLogin(sessionBuilder, role: 'acs')).accessToken; + + await expectLater( + endpoints.patients.hasGrantedConsent( + sessionBuilder, accessToken: acsToken, purpose: ConsentPurpose.segmentedPush), + throwsA(isA()), + ); + }); + + test('acceptTermsOfUse duas vezes grava uma linha só', () async { + final session = sessionBuilder.build(); + await _seed(session); + final token = await patientToken(); + + await endpoints.patients.acceptTermsOfUse(sessionBuilder, accessToken: token); + await endpoints.patients.acceptTermsOfUse(sessionBuilder, accessToken: token); + + expect(await ConsentLog.db.count(session, where: (t) => t.userId.equals(UuidValue.fromString(_patientId))), 1); + }); + + test('updateConsent recusa a finalidade obrigatória sem gravar nada', () async { + final session = sessionBuilder.build(); + await _seed(session); + final token = await patientToken(); + + await expectLater( + endpoints.patients.updateConsent( + sessionBuilder, + accessToken: token, + purpose: ConsentPurpose.healthDataProcessing, + granted: false, + ), + throwsA(isA()), + ); + expect(await ConsentLog.db.count(session, where: (t) => t.userId.equals(UuidValue.fromString(_patientId))), 0); + }); + + test('requestDataDeletion repetido deixa um pedido só, com prazo de 15 dias, visível em myData', () async { + final session = sessionBuilder.build(); + await _seed(session); + final token = await patientToken(); + + final first = await endpoints.patients.requestDataDeletion(sessionBuilder, accessToken: token); + final second = await endpoints.patients.requestDataDeletion(sessionBuilder, accessToken: token); + + expect(second.createdAt, first.createdAt); + expect(first.dueAt.difference(first.createdAt), const Duration(days: 15)); + expect(first.status, DataSubjectRequestStatus.open); + expect(await DataSubjectRequest.db.count(session, where: (t) => t.userId.equals(UuidValue.fromString(_patientId))), 1); + + final overview = await endpoints.patients.myData(sessionBuilder, accessToken: token); + expect(overview.requests.single.type, DataSubjectRequestType.deletion); + expect(overview.requests.single.status, DataSubjectRequestStatus.open); + }); + + test('requestDataCorrection guarda o texto cifrado e myData devolve o texto decifrado', () async { + final session = sessionBuilder.build(); + await _seed(session); + final token = await patientToken(); + + await endpoints.patients.requestDataCorrection( + sessionBuilder, + accessToken: token, + details: 'Meu contato de emergência mudou.', + ); + + final row = (await DataSubjectRequest.db.find(session, where: (t) => t.userId.equals(UuidValue.fromString(_patientId)))).single; + expect(row.detailsEncrypted, isNot(contains('contato'))); + expect(row.detailsEncrypted, isNotEmpty); + + final overview = await endpoints.patients.myData(sessionBuilder, accessToken: token); + expect(overview.requests.single.type, DataSubjectRequestType.correction); + expect(overview.requests.single.details, 'Meu contato de emergência mudou.'); + }); + + test('um ACS não chama nenhuma das três', () async { + await _seed(sessionBuilder.build()); + final token = + (await endpoints.auth.developmentLogin(sessionBuilder, role: 'acs')).accessToken; + + await expectLater( + endpoints.patients.updateConsent( + sessionBuilder, + accessToken: token, + purpose: ConsentPurpose.localReminders, + granted: false, + ), + throwsA(isA()), + ); + await expectLater( + endpoints.patients.requestDataDeletion(sessionBuilder, accessToken: token), + throwsA(isA()), + ); + await expectLater( + endpoints.patients.requestDataCorrection(sessionBuilder, accessToken: token, details: 'x'), + throwsA(isA()), + ); + }); + + test( + 'conta anonimizada (#42) com JWT ainda vivo não abre pedido nem mexe em consentimento', + () async { + final session = sessionBuilder.build(); + await _seed(session); + final token = await patientToken(); + // O que a exclusão atendida faz em `users.cpfHash` (orm_data_subject_case_store). + await session.db.unsafeExecute( + 'UPDATE users SET "cpfHash" = \'removed:sintetico\' WHERE id = @id::uuid', + parameters: QueryParameters.named({'id': _patientId}), + ); + + for (final chamada in Function()>[ + () => endpoints.patients.requestDataDeletion(sessionBuilder, accessToken: token), + () => endpoints.patients.requestDataCorrection( + sessionBuilder, + accessToken: token, + details: 'texto sintético', + ), + () => endpoints.patients.updateConsent( + sessionBuilder, + accessToken: token, + purpose: ConsentPurpose.segmentedPush, + granted: true, + ), + () => endpoints.patients.updateConsent( + sessionBuilder, + accessToken: token, + purpose: ConsentPurpose.localReminders, + granted: false, + ), + () => endpoints.patients.acceptTermsOfUse(sessionBuilder, accessToken: token), + ]) { + await expectLater(chamada(), throwsA(isA())); + } + expect(await DataSubjectRequest.db.count(session, where: (t) => t.userId.equals(UuidValue.fromString(_patientId))), 0); + expect(await ConsentLog.db.count(session, where: (t) => t.userId.equals(UuidValue.fromString(_patientId))), 0); + await expectLater( + endpoints.devices.registerPushToken( + sessionBuilder, + accessToken: token, + token: 'token-sintetico-anonimizado', + platform: 'android', + ), + throwsA(isA()), + ); + expect(await PushToken.db.count(session, where: (t) => t.userId.equals(UuidValue.fromString(_patientId))), 0); + }, + ); + + test( + 'conta anonimizada (#42) com JWT vivo não regrava condições crônicas', + () async { + final session = sessionBuilder.build(); + await _seed(session); + final token = await patientToken(); + final antes = (await Patient.db.findById(session, UuidValue.fromString(_patientId)))! + .chronicConditionsEncrypted; + await session.db.unsafeExecute( + 'UPDATE users SET "cpfHash" = \'removed:sintetico\' WHERE id = @id::uuid', + parameters: QueryParameters.named({'id': _patientId}), + ); + + try { + await endpoints.patients.updateChronicConditions( + sessionBuilder, + accessToken: token, + conditions: const ['hipertensão sintética'], + ); + fail('deveria recusar'); + } on AlertPermissionException catch (e) { + expect(e.message, 'Sessão encerrada. Entre novamente.'); + } + final depois = (await Patient.db.findById(session, UuidValue.fromString(_patientId)))! + .chronicConditionsEncrypted; + expect(depois, antes); + }, + ); + + test( + 'triagem: conta normal grava a sessão; conta anonimizada (#42) recebe a ' + 'mesma classificação e nada é gravado', + () async { + final session = sessionBuilder.build(); + await _seed(session); + final token = await patientToken(); + + Future avaliar() => endpoints.triage.evaluate( + sessionBuilder, + accessToken: token, + chestPain: true, + difficultyBreathing: false, + fever: true, + persistentVomiting: false, + bleeding: false, + severeWeakness: false, + ); + + final normal = await avaliar(); + expect(await TriageSession.db.count(session, where: (t) => t.patientId.equals(UuidValue.fromString(_patientId))), 1); + final linha = (await TriageSession.db.find(session, where: (t) => t.patientId.equals(UuidValue.fromString(_patientId)))).single; + expect(linha.answersEncrypted, isNotEmpty); + + await session.db.unsafeExecute( + 'UPDATE users SET "cpfHash" = \'removed:sintetico\' WHERE id = @id::uuid', + parameters: QueryParameters.named({'id': _patientId}), + ); + final anonimizada = await avaliar(); + expect(anonimizada.risk, normal.risk); + expect(await TriageSession.db.count(session, where: (t) => t.patientId.equals(UuidValue.fromString(_patientId))), 1, + reason: 'nenhuma sessão nova (nem respostas) para a conta anonimizada'); + }, + ); + + test('cada escrita deixa linha real em audit_logs', () async { + final session = sessionBuilder.build(); + await _seed(session); + final token = await patientToken(); + + await endpoints.patients.updateConsent( + sessionBuilder, + accessToken: token, + purpose: ConsentPurpose.segmentedPush, + granted: true, + ); + await endpoints.patients.requestDataDeletion(sessionBuilder, accessToken: token); + + final consentRows = await AuditLog.db.find( + session, + where: (t) => t.resourceType.equals('consent_log'), + ); + final requestRows = await AuditLog.db.find( + session, + where: (t) => t.resourceType.equals('data_subject_request'), + ); + expect(consentRows, hasLength(1)); + expect(requestRows, hasLength(1)); + expect(requestRows.single.userId, UuidValue.fromString(_patientId)); + }); + }); + + // Grupo separado, com rollback desligado: com o rollback ligado, todas as + // chamadas dividem a MESMA transação externa do harness e chamadas + // concorrentes que abrem a própria transação são recusadas. Só assim cada + // chamada abre uma transação real do Postgres, que é o que a corrida exige. + withServerpod( + 'Dado o pedido de exclusão, sem rollback automático (corrida)', + (sessionBuilder, endpoints) { + setUp(() => AlertRuntime.instance.overrideConfig(_config())); + tearDown(() => AlertRuntime.instance.overrideConfig(null)); + + test('três pedidos de exclusão simultâneos deixam um só pedido aberto', () async { + final session = sessionBuilder.build(); + await _seedRace(session); + try { + // Direto no store ORM, com uma `Session` por chamada (como cada + // requisição monta a sua): passar pelo endpoint gravaria linhas de + // `audit_logs`, que têm FK para `users` e cadeia de hash — e a + // limpeza manual quebraria os dois. + Future attempt() async { + final store = OrmDataSubjectRightsStore( + session: () => sessionBuilder.build(), + chainSecret: _chainSecret, + cipher: AlertRuntime.instance.healthDataCipher, + ); + try { + return await store.createDeletionRequestIfNoneOpen( + userId: _racePatientId, + createdAt: DateTime.now().toUtc(), + dueAt: DateTime.now().toUtc().add(const Duration(days: 15)), + ); + } catch (error) { + return error; + } + } + + final results = await Future.wait([attempt(), attempt(), attempt()]); + + final outcomes = results + .whereType<({DataSubjectRequestSnapshot request, bool created})>() + .toList(); + expect(outcomes, hasLength(3), reason: 'nenhuma chamada pode falhar: $results'); + expect(outcomes.where((o) => o.created), hasLength(1), + reason: 'só uma das três cria; as outras recebem a dela'); + expect(outcomes.map((o) => o.request.id).toSet(), hasLength(1)); + final open = await DataSubjectRequest.db.find( + session, + where: (t) => + t.userId.equals(UuidValue.fromString(_racePatientId)) & + t.requestType.equals(DataSubjectRequestType.deletion) & + t.status.equals(DataSubjectRequestStatus.open), + ); + expect(open, hasLength(1)); + } finally { + await _cleanupRace(session); + } + }); + + test('dois aceites do termo simultâneos gravam uma linha só', () async { + final session = sessionBuilder.build(); + await _seedRace(session); + try { + Future attempt() async { + final store = OrmDataSubjectRightsStore( + session: () => sessionBuilder.build(), + chainSecret: _chainSecret, + cipher: AlertRuntime.instance.healthDataCipher, + ); + try { + return await store.recordConsentUnlessCurrent(ConsentLogEntry( + userId: _racePatientId, + purpose: ConsentPurpose.termsOfUse, + action: 'granted', + version: consentPolicyVersion, + timestamp: DateTime.now().toUtc(), + )); + } catch (error) { + return error; + } + } + + final results = await Future.wait([attempt(), attempt(), attempt()]); + + final outcomes = + results.whereType<({String? id, ConsentRecordSnapshot? existing})>().toList(); + expect(outcomes, hasLength(3), reason: 'nenhuma chamada pode falhar: $results'); + expect(outcomes.where((o) => o.id != null), hasLength(1)); + final rows = await ConsentLog.db.find( + session, + where: (t) => + t.userId.equals(UuidValue.fromString(_racePatientId)) & + t.purpose.equals(ConsentPurpose.termsOfUse.name), + ); + expect(rows, hasLength(1)); + } finally { + await _cleanupRace(session); + } + }); + }, + rollbackDatabase: RollbackDatabase.disabled, + ); +} diff --git a/backend/sinalacs_server/test/integration/health_data_encryption_test.dart b/backend/sinalacs_server/test/integration/health_data_encryption_test.dart index 669039ea..9c279069 100644 --- a/backend/sinalacs_server/test/integration/health_data_encryption_test.dart +++ b/backend/sinalacs_server/test/integration/health_data_encryption_test.dart @@ -120,12 +120,21 @@ Future _seed(Session session) async { /// Lê a coluna como texto puro, sem passar pelo ORM — é o que garante que a /// asserção olha para o BYTE gravado, e não para o valor já decifrado. +/// +/// [rowId] restringe à linha semeada por este teste: os grupos de corrida dos +/// outros arquivos commitam linhas em `patients` por alguns instantes, e contar +/// a tabela inteira tornava este teste intermitente. Future _colunaBruta( Session session, { required String table, required String column, + String? rowId, }) async { - final rows = await session.db.unsafeQuery('SELECT "$column"::text FROM "$table";'); + final rows = await session.db.unsafeQuery( + rowId == null + ? 'SELECT "$column"::text FROM "$table";' + : 'SELECT "$column"::text FROM "$table" WHERE "id" = \'$rowId\'::uuid;', + ); expect(rows, hasLength(1), reason: 'o seed deve ter gravado exatamente 1 linha'); return rows.single.first.toString(); } @@ -177,6 +186,7 @@ void main() { session, table: 'patients', column: 'chronicConditionsEncrypted', + rowId: _patientId, ); expect(bruto, isNotEmpty); expectSemTextoClaro(bruto, _condicoes); @@ -185,6 +195,7 @@ void main() { session, table: 'patients', column: 'chronicConditionsKeyVersion', + rowId: _patientId, ); expect(versao, '1'); diff --git a/backend/sinalacs_server/test/integration/institutional_lock_streak_test.dart b/backend/sinalacs_server/test/integration/institutional_lock_streak_test.dart new file mode 100644 index 00000000..8fc3ed60 --- /dev/null +++ b/backend/sinalacs_server/test/integration/institutional_lock_streak_test.dart @@ -0,0 +1,102 @@ +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:test/test.dart'; + +import 'test_tools/runtime_harness.dart'; +import 'test_tools/serverpod_test_tools.dart'; + +/// Bloqueio progressivo do login institucional contra Postgres real: prova que +/// `lockStreak` sobe uma vez por bloqueio aplicado — inclusive numa rajada +/// concorrente — e zera no login válido. O store falso do teste unitário só +/// espelha o `CASE`; quem prova que o SQL o aplica é este. +/// +/// Dados sintéticos; ids próprios, distintos das outras suítes. +const _acsId = '00000000-0000-4000-8000-0000000000a1'; +const _microAreaId = '00000000-0000-4000-8000-0000000000a2'; +const _ubsId = '00000000-0000-4000-8000-0000000000a3'; +const _matricula = 'ACS-LK-001'; + +Future _seed(Session session) async { + await Ubs.db.insertRow( + session, + Ubs( + id: UuidValue.fromString(_ubsId), + name: 'UBS lockStreak', + address: 'Endereço local', + city: 'São Paulo', + state: 'SP', + ), + ); + await MicroArea.db.insertRow( + session, + MicroArea( + id: UuidValue.fromString(_microAreaId), + name: 'Microárea lockStreak', + ubsId: UuidValue.fromString(_ubsId), + geoJsonBoundary: '{}', + ), + ); + final now = DateTime.now().toUtc(); + await User.db.insertRow( + session, + User( + id: UuidValue.fromString(_acsId), + cpfHash: 'development-acs-lock-streak', + name: 'ACS de desenvolvimento (lockStreak)', + birthDate: DateTime.utc(1980), + role: UserRole.acs, + microAreaId: UuidValue.fromString(_microAreaId), + createdAt: now, + updatedAt: now, + ), + ); + await Acs.db.insertRow( + session, + Acs( + id: UuidValue.fromString(_acsId), + enrollmentId: _matricula, + ubsId: UuidValue.fromString(_ubsId), + active: true, + ), + ); + await AlertRuntimeHarness.store(session).saveCredential( + _acsId, + await AlertRuntimeHarness.hasher.derive('senha-sintetica-de-teste'), + now, + ); +} + +void main() { + withServerpod('Dado o bloqueio progressivo do login institucional', (sessionBuilder, endpoints) { + setUp(() async => _seed(sessionBuilder.build())); + + test('lockStreak sobe uma vez por bloqueio aplicado e zera no login válido', () async { + final store = AlertRuntimeHarness.store(sessionBuilder.build()); + final at = DateTime.utc(2026, 10, 5, 12); + final lockUntil = at.add(const Duration(minutes: 15)); + + for (var i = 0; i < 5; i++) { + await store.registerFailedAttempt(_acsId, + restartCounter: false, maxFailedAttempts: 5, lockUntil: lockUntil, at: at); + } + expect((await store.findByEnrollmentId(_matricula))!.lockStreak, 1); + + // Rajada concorrente na linha já bloqueada: o WHERE recusa; a sequência não sobe de novo. + await Future.wait([ + for (var i = 0; i < 5; i++) + store.registerFailedAttempt(_acsId, + restartCounter: false, maxFailedAttempts: 5, lockUntil: lockUntil, at: at), + ]); + expect((await store.findByEnrollmentId(_matricula))!.lockStreak, 1); + + // Bloqueio vencido: o contador recomeça em 1, mas a escalada é preservada. + final depois = at.add(const Duration(minutes: 16)); + await store.registerFailedAttempt(_acsId, + restartCounter: true, maxFailedAttempts: 5, lockUntil: depois.add(const Duration(minutes: 30)), at: depois); + expect((await store.findByEnrollmentId(_matricula))!.lockStreak, 1); + + await store.registerSuccessfulLogin(_acsId, depois.add(const Duration(minutes: 1))); + expect((await store.findByEnrollmentId(_matricula))!.lockStreak, 0); + }); + }); +} diff --git a/backend/sinalacs_server/test/integration/institutional_mfa_test.dart b/backend/sinalacs_server/test/integration/institutional_mfa_test.dart new file mode 100644 index 00000000..ee28abe5 --- /dev/null +++ b/backend/sinalacs_server/test/integration/institutional_mfa_test.dart @@ -0,0 +1,435 @@ +import 'dart:typed_data'; + +import 'package:serverpod/serverpod.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/auth/institutional_auth_service.dart'; +import 'package:sinalacs_server/src/application/auth/totp.dart'; +import 'package:sinalacs_server/src/application/auth/totp_secret_vault.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/infrastructure/crypto/health_cipher_totp_vault.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_acs_credential_store.dart'; +import 'package:sinalacs_server/src/runtime/alert_runtime.dart'; +import 'package:test/test.dart'; + +import 'test_tools/runtime_harness.dart'; +import 'test_tools/serverpod_test_tools.dart'; + +/// MFA por TOTP (RF07) contra Postgres real: prova que as quatro colunas +/// `totp*` de `user_credentials` são gravadas e relidas pelo +/// `OrmAcsCredentialStore` — o que o store falso do teste unitário não +/// alcança — e que o segredo passa pelo cofre de verdade +/// (`HealthCipherTotpVault`, AES-256-GCM) sem nunca ir em claro para a linha. +/// +/// Dados sintéticos; ids próprios, distintos das outras suítes. +const _acsId = '00000000-0000-4000-8000-000000000081'; +const _microAreaId = '00000000-0000-4000-8000-000000000082'; +const _ubsId = '00000000-0000-4000-8000-000000000083'; +const _matricula = 'ACS-MFA-001'; +const _senha = 'senha-sintetica-de-teste'; + +Future _seed(Session session) async { + await Ubs.db.insertRow( + session, + Ubs( + id: UuidValue.fromString(_ubsId), + name: 'UBS MFA', + address: 'Endereço local', + city: 'São Paulo', + state: 'SP', + ), + ); + await MicroArea.db.insertRow( + session, + MicroArea( + id: UuidValue.fromString(_microAreaId), + name: 'Microárea MFA', + ubsId: UuidValue.fromString(_ubsId), + geoJsonBoundary: '{}', + ), + ); + final now = DateTime.now().toUtc(); + await User.db.insertRow( + session, + User( + id: UuidValue.fromString(_acsId), + cpfHash: 'development-acs-mfa', + name: 'ACS de desenvolvimento (MFA)', + birthDate: DateTime.utc(1980), + role: UserRole.acs, + microAreaId: UuidValue.fromString(_microAreaId), + createdAt: now, + updatedAt: now, + ), + ); + await Acs.db.insertRow( + session, + Acs( + id: UuidValue.fromString(_acsId), + enrollmentId: _matricula, + ubsId: UuidValue.fromString(_ubsId), + active: true, + ), + ); + await AlertRuntimeHarness.store(session).saveCredential( + _acsId, + await AlertRuntimeHarness.hasher.derive(_senha), + now, + ); +} + +Future _linha(Session session) async => (await UserCredential.db.findFirstRow( + session, + where: (t) => t.userId.equals(UuidValue.fromString(_acsId)), + ))!; + +/// Base32 (RFC 4648, sem preenchimento) → bytes: o que o aplicativo +/// autenticador faz com o segredo devolvido por `beginTotpEnrollment`. +Uint8List _deBase32(String texto) { + const alfabeto = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + var bits = 0; + var valor = 0; + final saida = []; + for (final c in texto.split('')) { + valor = (valor << 5) | alfabeto.indexOf(c); + bits += 5; + if (bits >= 8) { + saida.add((valor >> (bits - 8)) & 0xff); + bits -= 8; + } + } + return Uint8List.fromList(saida); +} + +class _SemAuditoria extends AuditTrail { + @override + Future record(AuditEvent event) async {} +} + +void main() { + withServerpod('Dada a MFA por TOTP do login institucional (RF07)', ( + sessionBuilder, + endpoints, + ) { + setUp(() async => _seed(sessionBuilder.build())); + + test('begin → confirm → login com código, e o replay é recusado', () async { + final session = sessionBuilder.build(); + + final inicio = await endpoints.auth.beginTotpEnrollment( + sessionBuilder, + matricula: _matricula, + password: _senha, + ); + final segredo = _deBase32(inicio.secretBase32); + expect(segredo, hasLength(20)); + expect(Totp.base32(segredo), inicio.secretBase32); + + // Pendente: segredo cifrado na linha, MFA ainda não vale. + final pendente = await _linha(session); + expect(pendente.totpSecretEncrypted, isNotNull); + expect(pendente.totpSecretEncrypted, isNot(contains(inicio.secretBase32))); + expect(pendente.totpKeyVersion, isNotNull); + expect(pendente.totpEnabledAt, isNull); + expect(pendente.totpLastStep, isNull); + + // E pendente não bloqueia o login só com a senha. + final semMfa = await endpoints.auth.loginInstitutional( + sessionBuilder, + matricula: _matricula, + password: _senha, + ); + expect(semMfa.accessToken, isNotEmpty); + + final agora = DateTime.now().toUtc(); + await endpoints.auth.confirmTotpEnrollment( + sessionBuilder, + matricula: _matricula, + password: _senha, + code: Totp.code(segredo, agora), + ); + final ativa = await _linha(session); + expect(ativa.totpEnabledAt, isNotNull); + final passoDaAtivacao = ativa.totpLastStep; + expect(passoDaAtivacao, isNotNull); + + // Ativa: a senha sozinha não entra mais. + await expectLater( + endpoints.auth.loginInstitutional( + sessionBuilder, + matricula: _matricula, + password: _senha, + ), + throwsA(isA()), + ); + + // O código usado na ativação não serve para entrar (o passo dele já foi + // gravado); o do passo seguinte serve — está dentro da janela de ±1. + final codigoUsado = Totp.code(segredo, agora); + await expectLater( + endpoints.auth.loginInstitutional( + sessionBuilder, + matricula: _matricula, + password: _senha, + totpCode: codigoUsado, + ), + throwsA(isA()), + ); + expect((await _linha(session)).failedAttempts, 1); + + final proximo = agora.add(const Duration(seconds: Totp.period)); + final codigo = Totp.code(segredo, proximo); + final login = await endpoints.auth.loginInstitutional( + sessionBuilder, + matricula: _matricula, + password: _senha, + totpCode: codigo, + ); + expect(AlertRuntimeHarness.verify(login.accessToken)?.id, _acsId); + final depois = await _linha(session); + expect(depois.totpLastStep, Totp.stepOf(proximo)); + expect(depois.failedAttempts, 0); + + // Replay do código que acabou de entrar: recusado pelo passo gravado. + await expectLater( + endpoints.auth.loginInstitutional( + sessionBuilder, + matricula: _matricula, + password: _senha, + totpCode: codigo, + ), + throwsA(isA()), + ); + + // Com a MFA ativa, uma nova ativação é recusada e não troca o segredo. + await expectLater( + endpoints.auth.beginTotpEnrollment( + sessionBuilder, + matricula: _matricula, + password: _senha, + ), + throwsA(isA()), + ); + expect((await _linha(session)).totpSecretEncrypted, ativa.totpSecretEncrypted); + }); + + test('registerStep só deixa o passo avançar, nunca voltar', () async { + final session = sessionBuilder.build(); + final store = OrmAcsCredentialStore(session: () => session); + + expect(await store.registerStep(_acsId, 100), isTrue); + expect((await _linha(session)).totpLastStep, 100); + + // Mesmo passo e passo menor: nada muda, e o store diz que não gravou. + expect(await store.registerStep(_acsId, 100), isFalse); + expect(await store.registerStep(_acsId, 99), isFalse); + expect((await _linha(session)).totpLastStep, 100); + + expect(await store.registerStep(_acsId, 101), isTrue); + expect((await _linha(session)).totpLastStep, 101); + }); + + test('saveSecret não sobrescreve MFA ativa; enable só ativa o segredo pendente que conferiu', + () async { + final session = sessionBuilder.build(); + final store = OrmAcsCredentialStore(session: () => session); + const a = SealedSecret(ciphertextBase64: 'cifra-sintetica-a', keyVersion: 1); + const b = SealedSecret(ciphertextBase64: 'cifra-sintetica-b', keyVersion: 1); + final at = DateTime.now().toUtc(); + + expect(await store.saveSecret(_acsId, a, at), isTrue); + // Um novo início trocou o segredo: o código do antigo não ativa o novo. + expect(await store.saveSecret(_acsId, b, at), isTrue); + expect(await store.enable(_acsId, a, 10, at), isFalse); + expect((await _linha(session)).totpEnabledAt, isNull); + + expect(await store.enable(_acsId, b, 10, at), isTrue); + // Ativa: segunda confirmação e novo início são recusados pela linha. + expect(await store.enable(_acsId, b, 11, at), isFalse); + expect(await store.saveSecret(_acsId, a, at), isFalse); + final linha = await _linha(session); + expect(linha.totpSecretEncrypted, b.ciphertextBase64); + expect(linha.totpLastStep, 10); + }); + + test('com requireMfa ligado, ACS sem MFA ativa recebe MfaEnrollmentRequiredException', + () async { + final session = sessionBuilder.build(); + final store = OrmAcsCredentialStore(session: () => session); + final servico = InstitutionalAuthService( + store: store, + hasher: AlertRuntimeHarness.hasher, + audit: _SemAuditoria(), + totpStore: store, + vault: HealthCipherTotpVault(AlertRuntime.instance.healthDataCipher), + requireMfa: true, + ); + + await expectLater( + servico.login(matricula: _matricula, password: _senha), + throwsA(isA()), + ); + // Não é tentativa errada: a senha conferiu. + expect((await _linha(session)).failedAttempts, 0); + }); + }); + + // Concorrência de verdade exige o rollback DESLIGADO: com ele, todas as + // `Session` do harness dividem o mesmo `TransactionManager` e chamadas + // simultâneas são recusadas (ver o grupo "rajada" de + // `institutional_login_test.dart`). Sem rollback, cada tentativa abre a sua + // conexão. Pelo serviço com store ORM e cofre reais, e trilha de mentira: + // auditoria commitada quebraria a cadeia que outras suítes conferem. + withServerpod( + 'Dada a MFA por TOTP, sem rollback automático (concorrência)', + (sessionBuilder, endpoints) { + test('o mesmo código válido em requisições simultâneas abre UMA sessão só', () async { + await _seedCorrida(sessionBuilder.build()); + try { + final segredo = await _ativarMfaCorrida(sessionBuilder); + final codigo = Totp.code( + segredo, + DateTime.now().toUtc().add(const Duration(seconds: Totp.period)), + ); + + final resultados = await Future.wait([ + for (var i = 0; i < _concorrentes; i++) + _servicoCorrida(sessionBuilder) + .login(matricula: _corridaMatricula, password: _senha, totpCode: codigo) + .then((u) => u, onError: (Object e) => e), + ]); + + expect(resultados.whereType(), hasLength(1), + reason: 'RFC 6238 §5.2: um código validado não vale de novo'); + expect( + resultados.where((r) => r is! AuthenticatedUser), + everyElement(isA()), + ); + } finally { + await _limparCorrida(sessionBuilder.build()); + } + }); + + test('a mesma confirmação em requisições simultâneas ativa UMA vez só', () async { + await _seedCorrida(sessionBuilder.build()); + try { + final inicio = await _servicoCorrida(sessionBuilder) + .beginTotpEnrollment(matricula: _corridaMatricula, password: _senha); + final codigo = Totp.code(_deBase32(inicio.secretBase32), DateTime.now().toUtc()); + + final resultados = await Future.wait([ + for (var i = 0; i < _concorrentes; i++) + _servicoCorrida(sessionBuilder) + .confirmTotpEnrollment(matricula: _corridaMatricula, password: _senha, code: codigo) + .then((_) => 'ativou', onError: (Object e) => e), + ]); + + expect(resultados.where((r) => r == 'ativou'), hasLength(1)); + expect( + resultados.where((r) => r != 'ativou'), + everyElement(isA()), + ); + } finally { + await _limparCorrida(sessionBuilder.build()); + } + }); + }, + rollbackDatabase: RollbackDatabase.disabled, + ); +} + +const _concorrentes = 5; +const _corridaAcsId = '00000000-0000-4000-8000-000000000091'; +const _corridaMicroAreaId = '00000000-0000-4000-8000-000000000092'; +const _corridaUbsId = '00000000-0000-4000-8000-000000000093'; +const _corridaMatricula = 'ACS-MFA-CORRIDA-001'; + +/// Uma `Session` por chamada, como no servidor: conexões independentes. +InstitutionalAuthService _servicoCorrida(TestSessionBuilder sessionBuilder) { + final store = OrmAcsCredentialStore(session: () => sessionBuilder.build()); + return InstitutionalAuthService( + store: store, + hasher: AlertRuntimeHarness.hasher, + audit: _SemAuditoria(), + totpStore: store, + vault: HealthCipherTotpVault(AlertRuntime.instance.healthDataCipher), + ); +} + +/// Ativa a MFA (begin + confirm, em sequência) e devolve o segredo. +Future _ativarMfaCorrida(TestSessionBuilder sessionBuilder) async { + final inicio = await _servicoCorrida(sessionBuilder) + .beginTotpEnrollment(matricula: _corridaMatricula, password: _senha); + final segredo = _deBase32(inicio.secretBase32); + await _servicoCorrida(sessionBuilder).confirmTotpEnrollment( + matricula: _corridaMatricula, + password: _senha, + code: Totp.code(segredo, DateTime.now().toUtc()), + ); + return segredo; +} + +/// Idempotente: limpa primeiro, para uma execução que morreu no meio não +/// deixar chave duplicada. +Future _seedCorrida(Session session) async { + await _limparCorrida(session); + await Ubs.db.insertRow( + session, + Ubs( + id: UuidValue.fromString(_corridaUbsId), + name: 'UBS MFA Corrida', + address: 'Endereço local', + city: 'São Paulo', + state: 'SP', + ), + ); + await MicroArea.db.insertRow( + session, + MicroArea( + id: UuidValue.fromString(_corridaMicroAreaId), + name: 'Microárea MFA Corrida', + ubsId: UuidValue.fromString(_corridaUbsId), + geoJsonBoundary: '{}', + ), + ); + final now = DateTime.now().toUtc(); + await User.db.insertRow( + session, + User( + id: UuidValue.fromString(_corridaAcsId), + cpfHash: 'development-acs-mfa-corrida', + name: 'ACS de desenvolvimento (MFA corrida)', + birthDate: DateTime.utc(1980), + role: UserRole.acs, + microAreaId: UuidValue.fromString(_corridaMicroAreaId), + createdAt: now, + updatedAt: now, + ), + ); + await Acs.db.insertRow( + session, + Acs( + id: UuidValue.fromString(_corridaAcsId), + enrollmentId: _corridaMatricula, + ubsId: UuidValue.fromString(_corridaUbsId), + active: true, + ), + ); + await AlertRuntimeHarness.store(session).saveCredential( + _corridaAcsId, + await AlertRuntimeHarness.hasher.derive(_senha), + now, + ); +} + +Future _limparCorrida(Session session) async { + final id = UuidValue.fromString(_corridaAcsId); + await UserCredential.db.deleteWhere(session, where: (t) => t.userId.equals(id)); + await Acs.db.deleteWhere(session, where: (t) => t.id.equals(id)); + await User.db.deleteWhere(session, where: (t) => t.id.equals(id)); + await MicroArea.db.deleteWhere( + session, + where: (t) => t.id.equals(UuidValue.fromString(_corridaMicroAreaId)), + ); + await Ubs.db.deleteWhere(session, where: (t) => t.id.equals(UuidValue.fromString(_corridaUbsId))); +} diff --git a/backend/sinalacs_server/test/integration/issue_staff_activation_code_test.dart b/backend/sinalacs_server/test/integration/issue_staff_activation_code_test.dart new file mode 100644 index 00000000..0abf37db --- /dev/null +++ b/backend/sinalacs_server/test/integration/issue_staff_activation_code_test.dart @@ -0,0 +1,111 @@ +import 'dart:io'; + +import 'package:postgres/postgres.dart' as pg; +import 'package:sinalacs_server/src/application/auth/staff_activation_code.dart'; +import 'package:sinalacs_server/src/ops/staff_activation_issuer.dart'; +import 'package:test/test.dart'; + +/// Emissão do código de ativação pela CLI do operador (#48), contra o Postgres +/// de teste (porta 9090, `sinalacs_test`). Tudo roda numa transação que o teste +/// desfaz: nada fica no banco. Dados sintéticos. +const _ativo = '00000000-0000-4000-8000-0000000000c1'; +const _inativo = '00000000-0000-4000-8000-0000000000c2'; + +class _Desfazer implements Exception {} + +String _senhaDoBancoDeTeste() { + final texto = File('config/passwords.yaml').readAsStringSync(); + final m = RegExp(r'^test:\s*\n(?:[ \t]+.*\n)*?[ \t]+database:\s*[\x27"]?([^\x27"\n]+)', multiLine: true) + .firstMatch(texto); + if (m == null) fail('config/passwords.yaml sem test.database (rode scripts/dev/bootstrap_env.sh)'); + return m.group(1)!.trim(); +} + +Future _comTransacao(Future Function(pg.Session tx) corpo) async { + final conexao = await pg.Connection.open( + pg.Endpoint(host: 'localhost', port: 9090, database: 'sinalacs_test', username: 'postgres', password: _senhaDoBancoDeTeste()), + settings: const pg.ConnectionSettings(sslMode: pg.SslMode.disable), + ); + try { + await conexao.runTx((tx) async { + await _semear(tx); + await corpo(tx); + throw _Desfazer(); + }); + } on _Desfazer { + // rollback esperado + } finally { + await conexao.close(); + } +} + +Future _semear(pg.Session tx) async { + for (final (id, matricula, ativo) in [(_ativo, 'ADM-CLI-001', true), (_inativo, 'ADM-CLI-002', false)]) { + await tx.execute( + pg.Sql.named('INSERT INTO "users" ("id","cpfHash","name","birthDate","role","microAreaId","createdAt","updatedAt") ' + "VALUES (@id,@hash,'Admin CLI','1985-01-01','admin',NULL,NOW(),NOW())"), + parameters: {'id': id, 'hash': 'cli-test-$id'}, + ); + await tx.execute( + pg.Sql.named('INSERT INTO "staff_accounts" ("id","enrollmentId","active") VALUES (@id,@m,@a)'), + parameters: {'id': id, 'm': matricula, 'a': ativo}, + ); + } +} + +void main() { + final t0 = DateTime.utc(2026, 10, 7, 12); + + test('matrícula inexistente: notFound e nada é gravado', () => _comTransacao((tx) async { + final r = await issueStaffActivationCode(tx, + matricula: 'NAO-EXISTE', issuedBy: 'operador', validity: const Duration(hours: 24), now: t0); + expect(r.code, isNull); + expect(r.status, IssueStatus.notFound); + })); + + test('conta inativa: notFound (a mensagem não distingue inexistente de inativa)', () => _comTransacao((tx) async { + final r = await issueStaffActivationCode(tx, + matricula: 'ADM-CLI-002', issuedBy: 'operador', validity: const Duration(hours: 24), now: t0); + expect(r.status, IssueStatus.notFound); + final linha = await tx.execute(pg.Sql.named('SELECT "activationCodeHash" FROM "staff_accounts" WHERE "id"=@id'), + parameters: {'id': _inativo}); + expect(linha.single[0], isNull); + })); + + test('conta ativa: grava só o hash, a validade e quem emitiu', () => _comTransacao((tx) async { + final r = await issueStaffActivationCode(tx, + matricula: 'ADM-CLI-001', issuedBy: 'operador', validity: const Duration(hours: 24), now: t0); + expect(r.status, IssueStatus.issued); + final codigo = r.code!; + final linha = (await tx.execute( + pg.Sql.named('SELECT "activationCodeHash","activationCodeExpiresAt","activationCodeIssuedBy","activationCodeIssuedAt" ' + 'FROM "staff_accounts" WHERE "id"=@id'), + parameters: {'id': _ativo}, + )) + .single; + expect(linha[0], StaffActivationCode.hash(codigo)); + expect(linha[0], isNot(contains(StaffActivationCode.normalize(codigo)))); + expect(linha[1], t0.add(const Duration(hours: 24))); + expect(linha[2], 'operador'); + expect(linha[3], t0); + })); + + test('emitir de novo troca o código', () => _comTransacao((tx) async { + final a = await issueStaffActivationCode(tx, + matricula: 'ADM-CLI-001', issuedBy: 'op-1', validity: const Duration(hours: 1), now: t0); + final b = await issueStaffActivationCode(tx, + matricula: 'ADM-CLI-001', issuedBy: 'op-2', validity: const Duration(hours: 1), now: t0); + expect(a.code, isNot(b.code)); + final h = (await tx.execute(pg.Sql.named('SELECT "activationCodeHash" FROM "staff_accounts" WHERE "id"=@id'), + parameters: {'id': _ativo})) + .single[0]; + expect(h, StaffActivationCode.hash(b.code!)); + })); + + test('quem emitiu é obrigatório', () => _comTransacao((tx) async { + await expectLater( + issueStaffActivationCode(tx, matricula: 'ADM-CLI-001', issuedBy: ' ', validity: const Duration(hours: 1), now: t0), + throwsArgumentError, + ); + })); +} diff --git a/backend/sinalacs_server/test/integration/mqtt_alert_dispatcher_resilience_test.dart b/backend/sinalacs_server/test/integration/mqtt_alert_dispatcher_resilience_test.dart new file mode 100644 index 00000000..50cca9dc --- /dev/null +++ b/backend/sinalacs_server/test/integration/mqtt_alert_dispatcher_resilience_test.dart @@ -0,0 +1,711 @@ +@Tags(['resiliencia']) +library; + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:mqtt_client/mqtt_client.dart' show MqttConnectionHandlerBase; +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/config/app_config.dart'; +import 'package:sinalacs_server/src/domain/entities/alert_delivery.dart'; +import 'package:sinalacs_server/src/infrastructure/mqtt/mqtt_alert_dispatcher.dart'; +import 'package:test/test.dart'; + +import '../support/fake_mqtt_broker.dart'; + +AppConfig _config(int port) => AppConfig( + mqttBroker: '127.0.0.1:$port', + jwtSecret: 'test-secret', + auditChainSecret: 'test-audit-chain-secret', + healthDataEncryptionKey: AppConfig.developmentHealthDataEncryptionKey, + cpfHashPepper: AppConfig.developmentCpfHashPepper, + smsGateway: 'log', + mqttUsername: null, + mqttPassword: null, + mqttUseTls: false, + mqttCaCertificatePath: null, + appEnv: 'development', + enableDevLogin: true, + ); + +AlertDelivery _delivery() => AlertDelivery( + alertId: '00000000-0000-4000-8000-0000000000aa', + patientId: '00000000-0000-4000-8000-000000000001', + microAreaId: '00000000-0000-4000-8000-000000000003', + riskLevel: 'red', + locationHash: 'sem-local-00', + triggeredAt: DateTime.utc(2026, 9, 16, 10), + ); + +/// Outbox em memória para medir uma rodada de `drainOnce` contra o dispatcher +/// real: [n] entradas pendentes, publicadas e falhas registradas. +class _OutboxEmMemoria implements AlertOutbox { + _OutboxEmMemoria(int n) + : _pendentes = [ + for (var i = 0; i < n; i++) + PendingDelivery(entryId: 'e$i', delivery: _delivery(), attempts: 1), + ]; + + final List _pendentes; + final publicadas = []; + final falhas = []; + + @override + Future enqueue(AlertDelivery alert) async {} + + @override + Future> claimDue({int limit = 32}) async => + _pendentes.take(limit).toList(); + + @override + Future markPublished(String entryId) async => publicadas.add(entryId); + + @override + Future markFailed(String entryId, String error, DateTime next) async => + falhas.add(entryId); +} + +/// Polling a cada 25 ms até [cond] valer; falha com `timeout` depois de [timeout]. +Future _until( + bool Function() cond, { + Duration timeout = const Duration(seconds: 5), +}) async { + final limite = DateTime.now().add(timeout); + while (!cond()) { + if (DateTime.now().isAfter(limite)) fail('timeout'); + await Future.delayed(const Duration(milliseconds: 25)); + } +} + +/// Broker falso + dispatcher com `onFatal` e `log` gravados, já com o +/// encerramento registrado antes de qualquer `await` do teste. +class _Ambiente { + _Ambiente(this.broker, this.d, this.fatais, this.logs); + + final FakeMqttBroker broker; + final MqttAlertDispatcher d; + final List fatais; + final StringBuffer logs; +} + +Future<_Ambiente> _ambiente({ + Duration backoff = const Duration(milliseconds: 100), + bool esperaFatal = false, + Duration publishAckTimeout = const Duration(seconds: 10), + int keepAliveSeconds = 30, + int pingResponseTimeoutSeconds = 10, +}) async { + final broker = await FakeMqttBroker.start(); + final fatais = []; + final logs = StringBuffer(); + final d = MqttAlertDispatcher( + config: _config(broker.port), + initialBackoff: backoff, + onFatal: fatais.add, + log: logs.writeln, + publishAckTimeout: publishAckTimeout, + keepAliveSeconds: keepAliveSeconds, + pingResponseTimeoutSeconds: pingResponseTimeoutSeconds, + ); + addTearDown(() async { + await d.close(); + await broker.close(); + if (!esperaFatal) { + expect(fatais, isEmpty, reason: 'onFatal não devia ser chamado'); + } + }); + return _Ambiente(broker, d, fatais, logs); +} + +/// Roda [body] numa zona guardada. Erro que escape para essa zona falha o +/// teste na hora (em vez de pendurar o `await` de um `runZonedGuarded`). +Future _guardado(Future Function() body) { + final fim = Completer(); + runZonedGuarded(() async { + await body(); + if (!fim.isCompleted) fim.complete(); + }, (e, s) { + if (!fim.isCompleted) fim.completeError(e, s); + }); + return fim.future; +} + +const _brokenPipe = + SocketException('Broken pipe', osError: OSError('Broken pipe', 32)); + +/// Sentinelas de VAZ-01: nada disto pode aparecer num log do dispatcher. +const _proibidos = [ + 'sentinela-vaz01-local', + '0000000000aa', + 'localhost', + '127.0.0.1', + 'sinalacs-backend-', + 'Broken pipe', + 'StackTrace', + '#0 ', +]; + +void _semVazamento(String capturado) { + for (final proibido in _proibidos) { + expect(capturado, isNot(contains(proibido)), reason: 'vazou "$proibido"'); + } +} + +/// Erro que carrega, de propósito, tudo o que um log bruto vazaria: mensagem +/// com sentinelas, endereço, porta e o texto do errno. +SocketException _erroCarregado() => SocketException( + 'Broken pipe sentinela-vaz01-local 0000000000aa localhost', + osError: const OSError('Broken pipe', 32), + address: InternetAddress('127.0.0.1'), + port: 8883, + ); + +/// Handler de conexão cujo uso sempre lança [_erroCarregado]. +class _HandlerQueLanca implements MqttConnectionHandlerBase { + @override + dynamic noSuchMethod(Invocation invocation) => throw _erroCarregado(); +} + +/// `stderr` do processo de teste capturado (a parte que o dispatcher escreve +/// quando não recebe `log`). +class _StderrCapturado implements Stdout { + final buffer = StringBuffer(); + + @override + void writeln([Object? object = '']) => buffer.writeln(object); + + @override + void write(Object? object) => buffer.write(object); + + @override + dynamic noSuchMethod(Invocation invocation) => null; +} + +/// Sobe a sonda de processo contra [brokerAddr] e devolve o stderr acumulado. +Future<({Process probe, StringBuffer stderrText})> _sonda( + String brokerAddr, +) async { + final probe = await Process.start( + 'dart', + ['run', 'test/support/mqtt_dispatcher_probe.dart'], + environment: {'MQTT_BROKER': brokerAddr}, + ); + final stderrText = StringBuffer(); + probe.stderr.transform(utf8.decoder).listen(stderrText.write); + await probe.stdout + .transform(utf8.decoder) + .firstWhere((s) => s.contains('PRONTO')); + return (probe: probe, stderrText: stderrText); +} + +void main() { + group('MqttAlertDispatcher resiliência', () { + test('conecta ao broker falso', () async { + final a = await _ambiente(); + + await a.d.connect(); + + expect(a.d.isConnected, isTrue); + expect(a.broker.connectCount, 1); + }); + + test( + 'processo congelado e conexão derrubada: sobrevive e reconecta', + () async { + final broker = await FakeMqttBroker.start(); + final probe = await Process.start( + 'dart', + ['run', 'test/support/mqtt_dispatcher_probe.dart'], + environment: {'MQTT_BROKER': 'localhost:${broker.port}'}, + ); + final stderrText = StringBuffer(); + probe.stderr.transform(utf8.decoder).listen(stderrText.write); + await probe.stdout + .transform(utf8.decoder) + .firstWhere((s) => s.contains('PRONTO')); + expect(broker.connectCount, 1); + + var saiu = false; + unawaited(probe.exitCode.then((_) => saiu = true)); + + probe.kill(ProcessSignal.sigstop); + broker.dropClients(); // o broker "expira" a conexão ociosa + // Mais que o keepAlivePeriod (30 s). + await Future.delayed(const Duration(seconds: 35)); + probe.kill(ProcessSignal.sigcont); + + await Future.delayed(const Duration(seconds: 10)); + final caiu = saiu; + final reconectou = broker.connectCount; + probe.kill(); + await broker.close(); + expect(caiu, isFalse, reason: 'o processo caiu: $stderrText'); + expect(reconectou, greaterThan(1), reason: 'não reconectou'); + }, + timeout: const Timeout(Duration(seconds: 90)), + skip: Platform.isWindows ? 'SIGSTOP não existe no Windows' : false, + ); + + test('reconecta depois que o broker derruba a conexão e volta a publicar', + () async { + final a = await _ambiente(); + await a.d.connect(); + expect(a.d.isConnected, isTrue); + + a.broker.dropClients(); + await _until(() => a.broker.connectCount >= 2 && a.d.isConnected); + + await a.d.publish(_delivery()); + expect(a.broker.publishCount, 1); + }); + + test('durante a queda publish lança MqttUnavailableException', () async { + final a = await _ambiente(backoff: const Duration(seconds: 5)); + await a.d.connect(); + await a.broker.close(); // nada mais aceita conexões + await _until(() => !a.d.isConnected); + await expectLater( + a.d.publish(_delivery()), + throwsA(isA()), + ); + }); + + test( + 'socket real: RST do broker completa o Socket.done com erro, que é ' + 'tratado na zona do dispatcher, e ele reconecta', () async { + final a = await _ambiente(); + await _guardado(() async { + await a.d.connect(); + expect(a.d.isConnected, isTrue); + + a.broker.resetClients(); + await _until(() => a.broker.connectCount >= 2 && a.d.isConnected); + // Margem para um erro tardio do socket antigo aparecer. + await Future.delayed(const Duration(milliseconds: 200)); + }); + // O erro do socket real chegou ao handler (e não ao processo). + expect(a.logs.toString(), contains('errno=104')); + await a.d.publish(_delivery()); + expect(a.broker.publishCount, 1); + }, skip: Platform.isLinux ? false : 'SO_LINGER usa o número do Linux'); + + test( + 'política do handler (seam, não socket real): erro transitório ' + 'assíncrono não escapa e aciona reconexão', () async { + final a = await _ambiente(); + await _guardado(() async { + await a.d.connect(); + await a.d.simulateAsyncErrorForTest(_brokenPipe); + await _until(() => a.broker.connectCount >= 2 && a.d.isConnected); + }); + }); + + test('cliente obsoleto: onDisconnected antigo não derruba a conexão nova', + () async { + final a = await _ambiente(); + await a.d.connect(); + final antigo = a.d.clientForTest!; + + a.broker.dropClients(); + await _until(() => a.broker.connectCount >= 2 && a.d.isConnected); + final novo = a.d.clientForTest!; + expect(identical(antigo, novo), isFalse); + + // O callback do cliente anterior dispara de novo, já com o novo ativo. + antigo.onDisconnected!(); + await Future.delayed(const Duration(milliseconds: 500)); + + expect(a.broker.connectCount, 2); + expect(a.d.isConnected, isTrue); + expect(identical(a.d.clientForTest, novo), isTrue); + }); + + test( + 'cliente obsoleto: erro assíncrono tardio na zona do cliente antigo ' + 'não derruba a conexão nova', () async { + final a = await _ambiente(); + await a.d.connect(); + final antigo = a.d.clientForTest!; + + a.broker.dropClients(); + await _until(() => a.broker.connectCount >= 2 && a.d.isConnected); + final novo = a.d.clientForTest!; + + await a.d.simulateAsyncErrorForTest(_brokenPipe, from: antigo); + await Future.delayed(const Duration(milliseconds: 500)); + + expect(a.broker.connectCount, 2); + expect(a.d.isConnected, isTrue); + expect(identical(a.d.clientForTest, novo), isTrue); + }); + + test( + 'queda na troca de cliente durante a reconexão não deixa o ' + 'dispatcher com um cliente morto', () async { + final a = await _ambiente(); + await a.d.connect(); + var injetado = false; + // A queda chega exatamente na janela entre `_client = novo` e o fim da + // troca, com `_reconnecting` ainda verdadeiro. + a.d.afterClientSwapForTest = () async { + if (injetado) return; + injetado = true; + await a.d.simulateAsyncErrorForTest( + const SocketException( + 'reset', + osError: OSError('Connection reset by peer', 104), + ), + ); + }; + + a.broker.dropClients(); + await _until(() => a.broker.connectCount >= 3 && a.d.isConnected); + expect(injetado, isTrue); + + await a.d.publish(_delivery()); + expect(a.broker.publishCount, 1); + }); + + test('erro desconhecido não é engolido: é registrado e chama onFatal', + () async { + final a = await _ambiente(esperaFatal: true); + await _guardado(() async { + await a.d.connect(); + await a.d.simulateAsyncErrorForTest(StateError('detalhe-interno')); + }); + expect(a.fatais, [isA()]); + expect(a.logs.toString(), contains('StateError')); + expect(a.logs.toString(), isNot(contains('detalhe-interno'))); + // Erro fatal não é tratado como queda: nada de reconexão. + await Future.delayed(const Duration(milliseconds: 300)); + expect(a.broker.connectCount, 1); + }); + + test('erro depois de close() não chama onFatal', () async { + final a = await _ambiente(); + await a.d.connect(); + final antigo = a.d.clientForTest!; + await a.d.close(); + + await a.d.simulateAsyncErrorForTest( + StateError('detalhe-interno'), + from: antigo, + ); + await a.d.simulateAsyncErrorForTest(_brokenPipe, from: antigo); + await Future.delayed(const Duration(milliseconds: 300)); + + expect(a.fatais, isEmpty); + expect(a.logs.toString(), contains('StateError')); + expect(a.logs.toString(), isNot(contains('detalhe-interno'))); + expect(a.broker.connectCount, 1); + }); + + test('o registro do erro transitório traz só tipo e errno (VAZ-01)', + () async { + final a = await _ambiente(); + await a.d.connect(); + await a.d.simulateAsyncErrorForTest( + SocketException( + 'texto-que-nao-pode-vazar', + osError: const OSError('Connection reset by peer', 104), + address: InternetAddress('10.9.8.7'), + port: 8883, + ), + ); + await _until(() => a.broker.connectCount >= 2 && a.d.isConnected); + final texto = a.logs.toString(); + expect(texto, contains('SocketException')); + expect(texto, contains('errno=104')); + expect(texto, isNot(contains('texto-que-nao-pode-vazar'))); + expect(texto, isNot(contains('10.9.8.7'))); + expect(texto, isNot(contains('reset by peer'))); + }); + }); + + group('VAZ-01: o log não carrega identidade, local nem payload (#56)', () { + test('queda transitória: só tipo e errno', () async { + final a = await _ambiente(); + await a.d.connect(); + await a.d.simulateAsyncErrorForTest(_erroCarregado()); + await _until(() => a.broker.connectCount >= 2 && a.d.isConnected); + final texto = a.logs.toString(); + expect(texto, contains('SocketException')); + expect(texto, contains('errno=32')); + _semVazamento(texto); + }); + + test('falha de reconexão: só o tipo', () async { + final a = await _ambiente(); + await a.d.connect(); + await a.broker.close(); + a.broker.dropClients(); + await _until( + () => a.logs.toString().contains('Falha ao reconectar'), + ); + final texto = a.logs.toString(); + expect(texto, contains('SocketException')); + _semVazamento(texto); + }); + + test('falha na conexão inicial: só o tipo, também no stderr real', + () async { + final broker = await FakeMqttBroker.start(); + final porta = broker.port; + await broker.close(); + final capturado = _StderrCapturado(); + final d = MqttAlertDispatcher( + config: _config(porta), + initialBackoff: const Duration(seconds: 30), + onFatal: (_) {}, + ); + await IOOverrides.runZoned( + () => d.connect(), + stderr: () => capturado, + ); + await d.close(); + final texto = capturado.buffer.toString(); + expect(texto, contains('Falha ao conectar ao broker MQTT')); + expect(texto, contains('SocketException')); + _semVazamento(texto); + }); + + test('erro fatal via onFatal: tipo no log, mensagem nunca', () async { + final a = await _ambiente(esperaFatal: true); + await _guardado(() async { + await a.d.connect(); + await a.d.simulateAsyncErrorForTest( + StateError('Broken pipe sentinela-vaz01-local 0000000000aa localhost'), + ); + }); + final texto = a.logs.toString(); + expect(a.fatais, [isA()]); + expect(texto, contains('StateError')); + _semVazamento(texto); + }); + + test('erro tardio depois de close(): só o tipo e o errno', () async { + final a = await _ambiente(); + await a.d.connect(); + final antigo = a.d.clientForTest!; + await a.d.close(); + await a.d.simulateAsyncErrorForTest(_erroCarregado(), from: antigo); + final texto = a.logs.toString(); + expect(texto, contains('SocketException')); + expect(texto, contains('encerrado')); + _semVazamento(texto); + }); + + test('erro de teardown: só o tipo', () async { + final a = await _ambiente(); + await a.d.connect(); + // Qualquer acesso do cliente ao seu handler lança o erro carregado: + // o `disconnect()` do teardown falha e cai no `catch` do dispatcher. + // ignore: invalid_use_of_protected_member + a.d.clientForTest!.connectionHandler = _HandlerQueLanca(); + await a.d.close(); + final texto = a.logs.toString(); + expect(texto, contains('Falha ao encerrar cliente MQTT antigo (')); + expect(texto, contains('SocketException')); + _semVazamento(texto); + }); + + test('processo real: stderr da sonda traz só tipo e errno', () async { + final broker = await FakeMqttBroker.start(); + final s = await _sonda('localhost:${broker.port}'); + broker.resetClients(); + await _until(() => broker.connectCount >= 2); + await broker.close(); + await Future.delayed(const Duration(seconds: 3)); + s.probe.kill(); + final texto = s.stderrText.toString(); + expect(texto, contains('SocketException')); + _semVazamento(texto); + }, skip: Platform.isLinux ? false : 'SO_LINGER usa o número do Linux'); + + test('processo real: conexão inicial recusada loga só o tipo', () async { + final broker = await FakeMqttBroker.start(); + final porta = broker.port; + await broker.close(); + final s = await _sonda('localhost:$porta'); + await Future.delayed(const Duration(seconds: 3)); + s.probe.kill(); + final texto = s.stderrText.toString(); + expect(texto, contains('Falha ao conectar ao broker MQTT')); + expect(texto, contains('SocketException')); + _semVazamento(texto); + }); + }); + + group('publicação confirmada por PUBACK (H5, #56)', () { + test('completa só depois do PUBACK do broker', () async { + final a = await _ambiente(); + await a.d.connect(); + + await a.d.publish(_delivery()); + + expect(a.broker.publishCount, 1); + expect(a.broker.pubackCount, 1); + }); + + test( + 'sem PUBACK: falha no prazo, dá a conexão como perdida, reconecta e a ' + 'publicação seguinte é confirmada', () async { + final a = await _ambiente( + publishAckTimeout: const Duration(milliseconds: 300), + ); + await a.d.connect(); + final antigo = a.d.clientForTest; + a.broker.withholdPuback = true; + + final inicio = DateTime.now(); + await expectLater( + a.d.publish(_delivery()), + throwsA(isA()), + ); + expect(DateTime.now().difference(inicio), + greaterThanOrEqualTo(const Duration(milliseconds: 250))); + expect(a.broker.publishCount, 1, reason: 'o PUBLISH saiu'); + // O tempo limite derrubou o cliente na hora: nada de "connected" morto. + expect(identical(a.d.clientForTest, antigo), isFalse); + + a.broker.withholdPuback = false; + await _until(() => a.broker.connectCount >= 2 && a.d.isConnected); + await a.d.publish(_delivery()); + expect(a.broker.pubackCount, 1); + expect(a.logs.toString(), contains('PUBACK não recebido')); + }); + + test( + 'depois de um tempo limite as publicações seguintes falham na hora, ' + 'sem esperar outro prazo inteiro', () async { + final a = await _ambiente( + backoff: const Duration(seconds: 30), + publishAckTimeout: const Duration(milliseconds: 500), + ); + await a.d.connect(); + a.broker.withholdPuback = true; + + await expectLater( + a.d.publish(_delivery()), + throwsA(isA()), + ); + final inicio = DateTime.now(); + for (var i = 0; i < 5; i++) { + await expectLater( + a.d.publish(_delivery()), + throwsA(isA()), + ); + } + expect(DateTime.now().difference(inicio), + lessThan(const Duration(milliseconds: 200))); + expect(a.broker.publishCount, 1, reason: 'só a primeira chegou a sair'); + }); + + test( + 'publicações em voo falham com MqttUnavailableException quando a ' + 'conexão cai antes do PUBACK', () async { + final a = await _ambiente(); + await a.d.connect(); + a.broker.withholdPuback = true; + + final emVoo = [ + a.d.publish(_delivery()), + a.d.publish(_delivery()), + a.d.publish(_delivery()), + ]; + await _until(() => a.broker.publishCount == 3); + final inicio = DateTime.now(); + a.broker.dropClients(); + + for (final f in emVoo) { + await expectLater(f, throwsA(isA())); + } + // Pela queda, não pelo prazo de 10 s. + expect(DateTime.now().difference(inicio), + lessThan(const Duration(seconds: 5))); + + a.broker.withholdPuback = false; + await _until(() => a.broker.connectCount >= 2 && a.d.isConnected); + await a.d.publish(_delivery()); + }); + + test('close() falha as publicações em voo', () async { + final a = await _ambiente(); + await a.d.connect(); + a.broker.withholdPuback = true; + final emVoo = a.d.publish(_delivery()); + await _until(() => a.broker.publishCount == 1); + + await a.d.close(); + + await expectLater(emVoo, throwsA(isA())); + }); + + test( + 'partição sem FIN/RST: sem PINGRESP no prazo a biblioteca derruba a ' + 'conexão e o dispatcher reconecta', () async { + final a = await _ambiente(keepAliveSeconds: 1, pingResponseTimeoutSeconds: 1); + await _guardado(() async { + await a.d.connect(); + final antigo = a.d.clientForTest; + a.broker.ignorePings = true; + // PINGREQ em ~1 s, sem resposta por 1 s: queda detectada em ~2 s. + await _until( + () => !identical(a.d.clientForTest, antigo), + timeout: const Duration(seconds: 6), + ); + a.broker.ignorePings = false; + await _until(() => a.broker.connectCount >= 2 && a.d.isConnected); + }); + await a.d.publish(_delivery()); + expect(a.broker.pubackCount, 1); + }); + + test( + 'outbox: lote de 32 com o broker mudo leva um prazo de PUBACK, não 32, ' + 'e nenhuma entrada é marcada publicada', () async { + final a = await _ambiente( + backoff: const Duration(seconds: 30), + publishAckTimeout: const Duration(milliseconds: 400), + ); + await a.d.connect(); + a.broker.withholdPuback = true; + final outbox = _OutboxEmMemoria(32); + final drenador = AlertOutboxDispatcher(outbox: outbox, publisher: a.d); + + final inicio = DateTime.now(); + final publicadas = await drenador.drainOnce(); + final duracao = DateTime.now().difference(inicio); + + expect(publicadas, 0); + expect(outbox.publicadas, isEmpty); + expect(outbox.falhas, hasLength(32)); + expect(duracao, lessThan(const Duration(seconds: 2)), + reason: '32 x 400 ms seriam 12,8 s'); + expect(a.broker.publishCount, 1, reason: 'as outras falham na hora'); + }); + + test('VAZ-01: a falha de confirmação registra só o tipo, sem identidade', + () async { + final a = await _ambiente( + publishAckTimeout: const Duration(milliseconds: 200), + ); + await a.d.connect(); + a.broker.withholdPuback = true; + await expectLater( + a.d.publish(_delivery()), + throwsA(isA()), + ); + a.broker.withholdPuback = false; + await _until(() => a.broker.connectCount >= 2 && a.d.isConnected); + final texto = a.logs.toString(); + expect(texto, contains('PUBACK não recebido')); + _semVazamento(texto); + expect(texto, isNot(contains('sem-local-00')), reason: 'locationHash'); + expect(texto, isNot(contains('000000000003')), reason: 'microárea'); + expect(texto, isNot(contains('000000000001')), reason: 'paciente'); + }); + }); +} diff --git a/backend/sinalacs_server/test/integration/mqtt_alert_dispatcher_test.dart b/backend/sinalacs_server/test/integration/mqtt_alert_dispatcher_test.dart index 5898b2fa..62e4eec7 100644 --- a/backend/sinalacs_server/test/integration/mqtt_alert_dispatcher_test.dart +++ b/backend/sinalacs_server/test/integration/mqtt_alert_dispatcher_test.dart @@ -31,11 +31,11 @@ AlertDelivery _delivery() => AlertDelivery( void main() { group('MqttAlertDispatcher', () { - test('falha ao publicar quando o broker não está conectado', () { + test('falha ao publicar quando o broker não está conectado', () async { final dispatcher = MqttAlertDispatcher(config: _config()); - expect( - () => dispatcher.publish(_delivery()), + await expectLater( + dispatcher.publish(_delivery()), throwsA(isA()), ); }); diff --git a/backend/sinalacs_server/test/integration/notice_scheduling_endpoint_test.dart b/backend/sinalacs_server/test/integration/notice_scheduling_endpoint_test.dart new file mode 100644 index 00000000..fa21461d --- /dev/null +++ b/backend/sinalacs_server/test/integration/notice_scheduling_endpoint_test.dart @@ -0,0 +1,320 @@ +import 'package:serverpod/serverpod.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/notices/notice_scheduling_service.dart'; +import 'package:sinalacs_server/src/application/notices/notice_service.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_scheduled_notice_store.dart'; +import 'package:test/test.dart'; + +import 'test_tools/serverpod_test_tools.dart'; + +/// Serviço e store ORM reais de avisos agendados (#46) sobre o Postgres de +/// teste. O envio é um duplo: sem Gorush não há como falar com FCM aqui. +const _ubsA = '00000000-0000-4000-8000-0000000000a1'; +const _ubsB = '00000000-0000-4000-8000-0000000000b1'; +const _maA = '00000000-0000-4000-8000-0000000000a2'; +const _maB = '00000000-0000-4000-8000-0000000000b2'; +const _admin = '00000000-0000-4000-8000-0000000000c1'; +const _coordA = '00000000-0000-4000-8000-0000000000c2'; + +class _SilentAudit extends AuditTrail { + final events = []; + @override + Future record(AuditEvent event) async => events.add(event); +} + +AuthenticatedUser _user(String id, UserRole role) => + AuthenticatedUser(id: id, role: role, microAreaId: null, deviceId: 'dev'); + +Future _seed(Session s) async { + for (final (ubs, ma, nome) in [(_ubsA, _maA, 'Microárea A'), (_ubsB, _maB, 'Microárea B')]) { + await Ubs.db.insertRow( + s, + Ubs( + id: UuidValue.fromString(ubs), + name: 'UBS sintética $nome', + address: 'Endereço sintético', + city: 'Cidade', + state: 'SP', + ), + ); + await MicroArea.db.insertRow( + s, + MicroArea( + id: UuidValue.fromString(ma), + name: nome, + ubsId: UuidValue.fromString(ubs), + geoJsonBoundary: '{}', + ), + ); + } + final now = DateTime.utc(2026, 1, 1); + await User.db.insert(s, [ + for (final (id, role) in [(_admin, UserRole.admin), (_coordA, UserRole.coordinator)]) + User( + id: UuidValue.fromString(id), + cpfHash: 'sintetico-$id', + name: 'Staff sintético', + birthDate: DateTime.utc(1980, 1, 1), + role: role, + createdAt: now, + updatedAt: now, + ), + ]); + await StaffAccount.db.insertRow( + s, + StaffAccount( + id: UuidValue.fromString(_coordA), + enrollmentId: 'COO-AVISO-1', + active: true, + ubsId: UuidValue.fromString(_ubsA), + ), + ); + await StaffAccount.db.insertRow( + s, + StaffAccount( + id: UuidValue.fromString(_admin), + enrollmentId: 'ADM-AVISO-1', + active: true, + ubsId: null, + ), + ); +} + +void main() { + withServerpod('Dado o agendamento de avisos sobre o Postgres (#46)', ( + sessionBuilder, + endpoints, + ) { + final now = DateTime.utc(2026, 10, 14, 12); + late Session session; + late OrmScheduledNoticeStore store; + late _SilentAudit audit; + late List sent; + late NoticeSchedulingService service; + var clock = now; + + Future> row(String id) async { + final r = await session.db.unsafeQuery( + 'SELECT * FROM scheduled_notices WHERE "id" = @id::uuid', + parameters: QueryParameters.named({'id': id}), + ); + return r.single.toColumnMap(); + } + + Future insertDue(String ma, {Duration ago = const Duration(minutes: 1)}) => + store.insert( + createdBy: _admin, + microAreaId: ma, + audience: 'everyone', + title: 'Título sintético', + message: 'Mensagem sintética', + scheduledFor: now.subtract(ago), + createdAt: now.subtract(const Duration(hours: 1)), + ); + + setUp(() async { + session = sessionBuilder.build(); + await _seed(session); + store = OrmScheduledNoticeStore(session: () => session); + audit = _SilentAudit(); + sent = []; + clock = now; + service = NoticeSchedulingService( + store: store, + audit: audit, + clock: () => clock, + sendNow: ({ + required microAreaId, + required audience, + required title, + required message, + required actorId, + auditResourceId, + }) async { + sent.add(microAreaId); + return const NoticeSendSnapshot(recipients: 7, accepted: 6); + }, + ); + }); + + test('agendar como admin grava uma linha pending com o texto', () async { + final id = await service.schedule( + _user(_admin, UserRole.admin), + microAreaId: _maA, + audience: 'everyone', + title: 'Vacinação', + message: 'Campanha na UBS', + scheduledFor: now.add(const Duration(hours: 2)), + ); + final r = await row(id); + expect(r['status'], 'pending'); + expect(r['title'], 'Vacinação'); + expect(r['recipients'], isNull); + expect(audit.events.single.result, 'scheduled'); + }); + + test('dispatchDue com o relógio avançado envia e grava sent com contagens', () async { + final id = await service.schedule( + _user(_admin, UserRole.admin), + microAreaId: _maA, + audience: 'everyone', + title: 'T', + message: 'M', + scheduledFor: now.add(const Duration(hours: 2)), + ); + expect(await service.dispatchDue(), 0, reason: 'ainda não venceu'); + expect((await row(id))['status'], 'pending'); + + clock = now.add(const Duration(hours: 3)); + expect(await service.dispatchDue(), 1); + final r = await row(id); + expect(r['status'], 'sent'); + expect(r['recipients'], 7); + expect(r['accepted'], 6); + expect(r['sentAt'], isNotNull); + expect(sent, [_maA]); + expect(await service.dispatchDue(), 0, reason: 'não reenvia o que já foi enviado'); + }); + + test('claimDue concorrente reserva cada linha uma única vez', () async { + final ids = [for (var i = 0; i < 6; i++) await insertDue(_maA)]; + final results = await Future.wait([ + store.claimDue(now, limit: 4), + store.claimDue(now, limit: 4), + store.claimDue(now, limit: 4), + ]); + final claimed = results.expand((l) => l.map((s) => s.id)).toList(); + expect(claimed, hasLength(ids.length)); + expect(claimed.toSet(), ids.toSet()); + for (final id in ids) { + expect((await row(id))['status'], 'sending'); + } + expect(await store.claimDue(now), isEmpty); + }); + + test('claimDue não reserva o que ainda não venceu', () async { + final futuro = await store.insert( + createdBy: _admin, + microAreaId: _maA, + audience: 'everyone', + title: 'T', + message: 'M', + scheduledFor: now.add(const Duration(hours: 1)), + createdAt: now, + ); + expect(await store.claimDue(now), isEmpty); + expect((await row(futuro))['status'], 'pending'); + }); + + test('cancelIfPending só cancela pending', () async { + final pendente = await insertDue(_maA); + expect(await store.cancelIfPending(pendente), isTrue); + expect((await row(pendente))['status'], 'cancelled'); + expect(await store.cancelIfPending(pendente), isFalse); + + final enviando = await insertDue(_maA); + await store.claimDue(now); + expect(await store.cancelIfPending(enviando), isFalse); + expect((await row(enviando))['status'], 'sending'); + + await store.finish(enviando, status: ScheduledNoticeStatus.sent, recipients: 1, accepted: 1, at: now); + expect(await store.cancelIfPending(enviando), isFalse); + expect((await row(enviando))['status'], 'sent'); + }); + + test('history do coordenador só traz a própria UBS, em ordem decrescente', () async { + final a1 = await insertDue(_maA, ago: const Duration(hours: 3)); + final a2 = await insertDue(_maA, ago: const Duration(hours: 1)); + final b1 = await insertDue(_maB); + final coord = await service.history(_user(_coordA, UserRole.coordinator)); + expect(coord.map((i) => i.id), [a2, a1]); + expect(coord.map((i) => i.id), isNot(contains(b1))); + final admin = await service.history(_user(_admin, UserRole.admin)); + expect(admin.map((i) => i.id).toSet(), {a1, a2, b1}); + final soB = await store.history(microAreaId: _maB, limit: 10, offset: 0); + expect(soB.map((s) => s.id), [b1]); + final pagina = await store.history(ubsId: _ubsA, limit: 1, offset: 1); + expect(pagina.map((s) => s.id), [a1]); + }); + + test('findById devolve a linha ou null; ubsOf* leem staff e microárea', () async { + final id = await insertDue(_maA); + final s = (await store.findById(id))!; + expect(s.status, ScheduledNoticeStatus.pending); + expect(s.createdBy, _admin); + expect(s.microAreaId, _maA); + expect(s.message, 'Mensagem sintética'); + expect(s.scheduledFor.isUtc, isTrue); + expect(await store.findById('00000000-0000-4000-8000-0000000000ff'), isNull); + expect(await store.ubsOfStaff(_coordA), _ubsA); + expect(await store.ubsOfStaff(_admin), isNull); + expect(await store.ubsOfMicroArea(_maB), _ubsB); + expect(await store.ubsOfMicroArea('00000000-0000-4000-8000-0000000000ff'), isNull); + }); + + test('a varredura leva sending antigo a unknown, sem tocar o recente nem o pending', () async { + final velho = await insertDue(_maA, ago: const Duration(minutes: 30)); + final recente = await insertDue(_maA, ago: const Duration(minutes: 2)); + await store.claimDue(now); + final pendente = await store.insert( + createdBy: _admin, + microAreaId: _maA, + audience: 'everyone', + title: 'T', + message: 'M', + scheduledFor: now.add(const Duration(hours: 1)), + createdAt: now, + ); + audit.events.clear(); + expect(await service.recoverStuckSending(), 1); + expect(audit.events, hasLength(1)); + expect(audit.events.single.result, 'unknown_after_restart'); + expect(audit.events.single.resourceId, velho); + expect(audit.events.single.userId, _admin); + expect(audit.events.single.resourceType, 'community_notice'); + final r = await row(velho); + expect(r['status'], 'unknown'); + expect(r['failureReason'], noticeStuckSendingReason); + expect((await row(recente))['status'], 'sending'); + expect((await row(pendente))['status'], 'pending'); + expect(await service.recoverStuckSending(), 0); + expect(audit.events, hasLength(1), reason: 'sem avisos presos, nada novo na trilha'); + expect(sent, isEmpty, reason: 'a varredura nunca reenvia'); + }); + + test('authorityOf lê papel, situação e UBS; sem conta de staff devolve null', () async { + final adm = (await store.authorityOf(_admin))!; + expect(adm.role, UserRole.admin); + expect(adm.active, isTrue); + expect(adm.ubsId, isNull); + final coo = (await store.authorityOf(_coordA))!; + expect(coo.role, UserRole.coordinator); + expect(coo.ubsId, _ubsA); + expect(await store.authorityOf('00000000-0000-4000-8000-0000000000ff'), isNull); + }); + + test('coordenador desativado depois de agendar: o disparo não envia', () async { + final id = await service.schedule( + _user(_coordA, UserRole.coordinator), + microAreaId: _maA, + audience: 'everyone', + title: 'Título sintético', + message: 'Mensagem sintética', + scheduledFor: now.add(const Duration(minutes: 5)), + ); + await session.db.unsafeExecute( + 'UPDATE staff_accounts SET "active" = false WHERE "id" = @id::uuid', + parameters: QueryParameters.named({'id': _coordA}), + ); + clock = now.add(const Duration(minutes: 10)); + expect(await service.dispatchDue(), 1); + expect(sent, isEmpty); + final r = await row(id); + expect(r['status'], 'failed'); + expect(r['failureReason'], noticeAuthorRevokedReason); + expect(audit.events.last.result, 'denied_author_revoked'); + }); + }); +} diff --git a/backend/sinalacs_server/test/integration/notices_endpoint_test.dart b/backend/sinalacs_server/test/integration/notices_endpoint_test.dart new file mode 100644 index 00000000..83a78674 --- /dev/null +++ b/backend/sinalacs_server/test/integration/notices_endpoint_test.dart @@ -0,0 +1,356 @@ +import 'dart:convert'; + +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/config/app_config.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/infrastructure/push/gorush_client.dart'; +import 'package:sinalacs_server/src/runtime/alert_runtime.dart'; +import 'package:test/test.dart'; + +import '../support/health_data_fixtures.dart'; +import 'test_tools/serverpod_test_tools.dart'; + +/// Prova, contra Postgres real, a segmentação de `notices.sendSegmented` (RF14): +/// microárea do ACS, consentimento MAIS RECENTE de `segmentedPush`, filtro de +/// crônicos e poda de token inválido. O Gorush é um duplo: sem credenciais +/// FCM/APNs não há como falar com um de verdade aqui. +const _patientId = '00000000-0000-4000-8000-000000000001'; +const _acsId = '00000000-0000-4000-8000-000000000002'; +const _microAreaId = '00000000-0000-4000-8000-000000000003'; +const _ubsId = '00000000-0000-4000-8000-000000000004'; +const _otherMicroAreaId = '00000000-0000-4000-8000-000000000013'; +const _revokedId = '00000000-0000-4000-8000-000000000021'; +const _outsiderId = '00000000-0000-4000-8000-000000000022'; +const _plainId = '00000000-0000-4000-8000-000000000023'; +const _noClinicalId = '00000000-0000-4000-8000-000000000024'; +const _chainSecret = 'test-audit-chain-secret'; + +AppConfig _config() => AppConfig( + mqttBroker: 'localhost:1883', + jwtSecret: 'test-secret', + auditChainSecret: _chainSecret, + healthDataEncryptionKey: AppConfig.developmentHealthDataEncryptionKey, + cpfHashPepper: AppConfig.developmentCpfHashPepper, + smsGateway: 'log', + mqttUsername: null, + mqttPassword: null, + mqttUseTls: false, + mqttCaCertificatePath: null, + appEnv: 'development', + enableDevLogin: true, + ); + +class _RecordingSender implements PushSender { + List lastTargets = const []; + PushSendReport report = const PushSendReport(accepted: 1, invalidTokens: []); + int calls = 0; + + @override + Future send(PushMessage message, List targets) async { + calls++; + lastTargets = targets; + return report; + } +} + +Future _seed( + Session session, { + String ubsId = _ubsId, + String microAreaId = _microAreaId, + String patientId = _patientId, + String acsId = _acsId, + String enrollmentId = 'ACS-001', +}) 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.insertRow( + session, + MicroArea( + id: UuidValue.fromString(microAreaId), + name: 'Microárea 12', + ubsId: UuidValue.fromString(ubsId), + geoJsonBoundary: '{}', + ), + ); + final now = DateTime.now().toUtc(); + await User.db.insert(session, [ + User( + id: UuidValue.fromString(patientId), + cpfHash: 'development-patient-$patientId', + name: 'Paciente de desenvolvimento', + birthDate: DateTime.utc(1990, 1, 1), + role: UserRole.patient, + microAreaId: UuidValue.fromString(microAreaId), + createdAt: now, + updatedAt: now, + ), + User( + id: UuidValue.fromString(acsId), + cpfHash: 'development-acs-$acsId', + name: 'ACS de desenvolvimento', + birthDate: DateTime.utc(1980, 1, 1), + role: UserRole.acs, + microAreaId: UuidValue.fromString(microAreaId), + createdAt: now, + updatedAt: now, + ), + ]); + await Acs.db.insertRow( + session, + Acs( + id: UuidValue.fromString(acsId), + enrollmentId: enrollmentId, + ubsId: UuidValue.fromString(ubsId), + active: true, + ), + ); + await Patient.db.insertRow( + session, + await encryptedPatient( + id: patientId, + emergencyContact: 'Contato de desenvolvimento', + isChronic: false, + chronicConditions: const [], + ), + ); +} + + + +Future _addPatient( + Session session, { + required String id, + required String microAreaId, + required bool chronic, + bool withPatientRow = true, +}) async { + final now = DateTime.now().toUtc(); + await User.db.insertRow( + session, + User( + id: UuidValue.fromString(id), + cpfHash: 'development-notice-$id', + name: 'Paciente de teste', + birthDate: DateTime.utc(1985, 5, 5), + role: UserRole.patient, + microAreaId: UuidValue.fromString(microAreaId), + createdAt: now, + updatedAt: now, + ), + ); + if (!withPatientRow) return; + await Patient.db.insertRow( + session, + await encryptedPatient( + id: id, + emergencyContact: 'Contato de teste', + isChronic: chronic, + chronicConditions: const [], + ), + ); +} + +Future _consent(Session session, String userId, String action, DateTime at) => + ConsentLog.db.insertRow( + session, + ConsentLog( + userId: UuidValue.fromString(userId), + purpose: ConsentPurpose.segmentedPush.name, + action: action, + version: '2026.1', + timestamp: at, + ipHash: 'nao-aplicavel-teste', + userAgent: 'nao-aplicavel-teste', + signature: 'assinatura-de-teste', + ), + ); + +Future _token(Session session, String userId, String token, String microAreaId) { + final now = DateTime.now().toUtc(); + return PushToken.db.insertRow( + session, + PushToken( + userId: UuidValue.fromString(userId), + microAreaId: UuidValue.fromString(microAreaId), + token: token, + platform: 'android', + createdAt: now, + updatedAt: now, + ), + ); +} + +void main() { + withServerpod('Dado o aviso comunitário do ACS (RF14)', (sessionBuilder, endpoints) { + late _RecordingSender sender; + + setUp(() { + AlertRuntime.instance.overrideConfig(_config()); + sender = _RecordingSender(); + AlertRuntime.instance.overrideNoticeSender(() => sender); + }); + tearDown(() { + AlertRuntime.instance.overrideNoticeSender(null); + AlertRuntime.instance.overrideConfig(null); + }); + + Future tokenOf(String role) async => + (await endpoints.auth.developmentLogin(sessionBuilder, role: role)).accessToken; + + /// A microárea A tem: o paciente do dev-login (consentiu), um que revogou + /// DEPOIS de registrar o token e um sem crônica. A B tem um que consentiu. + Future seedAll() async { + final session = sessionBuilder.build(); + await _seed(session); + await MicroArea.db.insertRow( + session, + MicroArea( + id: UuidValue.fromString(_otherMicroAreaId), + name: 'Microárea 13', + ubsId: UuidValue.fromString(_ubsId), + geoJsonBoundary: '{}', + ), + ); + await _addPatient(session, id: _revokedId, microAreaId: _microAreaId, chronic: true); + await _addPatient(session, id: _plainId, microAreaId: _microAreaId, chronic: false); + await _addPatient(session, id: _outsiderId, microAreaId: _otherMicroAreaId, chronic: true); + final t0 = DateTime.utc(2026, 9, 1); + await _consent(session, _patientId, 'granted', t0); + await _token(session, _patientId, 'tok-a1', _microAreaId); + await _consent(session, _revokedId, 'granted', t0); + await _consent(session, _revokedId, 'denied', t0.add(const Duration(days: 1))); + await _token(session, _revokedId, 'tok-revogou', _microAreaId); // sobra no banco de propósito + await _consent(session, _plainId, 'granted', t0); + await _token(session, _plainId, 'tok-a3', _microAreaId); + await _consent(session, _outsiderId, 'granted', t0); + await _token(session, _outsiderId, 'tok-b1', _otherMicroAreaId); + return session; + } + + Future send(String token, {String audience = 'everyone'}) => + endpoints.notices.sendSegmented( + sessionBuilder, + accessToken: token, + title: 'Vacinação', + message: 'Amanhã, das 8h às 12h.', + audience: audience, + ); + + test('só recebe quem tem consentimento vigente na microárea do ACS', () async { + await seedAll(); + final result = await send(await tokenOf('acs')); + + expect(sender.lastTargets.map((t) => t.token).toList(), ['tok-a1', 'tok-a3']); + expect(result.recipients, 2); + }); + + test('quem revogou depois de registrar o token não recebe, mesmo com o token no banco', () async { + final session = await seedAll(); + expect(await PushToken.db.count(session, where: (t) => t.token.equals('tok-revogou')), 1); + await send(await tokenOf('acs')); + expect(sender.lastTargets.map((t) => t.token), isNot(contains('tok-revogou'))); + }); + + test('o filtro de crônicos usa patients.isChronic', () async { + final session = await seedAll(); + // torna o paciente do dev-login crônico; o "a3" continua não crônico + final patient = await Patient.db.findById(session, UuidValue.fromString(_patientId)); + await Patient.db.updateRow(session, patient!.copyWith(isChronic: true)); + + await send(await tokenOf('acs'), audience: 'chronic'); + + expect(sender.lastTargets.map((t) => t.token).toList(), ['tok-a1']); + }); + + test('sem destinatário consentido: 0 e sem chamar o relé', () async { + final session = sessionBuilder.build(); + await _seed(session); + final result = await send(await tokenOf('acs')); + expect((result.recipients, result.accepted), (0, 0)); + expect(sender.calls, 0); + }); + + test('token inválido devolvido pelo provedor some do banco', () async { + final session = await seedAll(); + sender.report = const PushSendReport(accepted: 1, invalidTokens: ['tok-a1']); + await send(await tokenOf('acs')); + expect(await PushToken.db.count(session, where: (t) => t.token.equals('tok-a1')), 0); + expect(await PushToken.db.count(session, where: (t) => t.token.equals('tok-a3')), 1); + }); + + test('grava uma linha community_notice só com ACS e microárea', () async { + final session = await seedAll(); + final before = await AuditLog.db.count(session); + await send(await tokenOf('acs')); + final rows = await AuditLog.db.find(session, orderBy: (t) => t.timestamp, orderDescending: true, limit: 1); + expect(await AuditLog.db.count(session), before + 1); + expect(rows.single.resourceType, 'community_notice'); + expect(rows.single.result, 'granted'); + expect(rows.single.resourceId, UuidValue.fromString(_microAreaId)); + expect(jsonEncode(rows.single.toJson()).contains('Amanhã, das 8h'), isFalse); + }); + + test('paciente sem linha clínica recebe "para todos" e fica fora do filtro de crônicos', () async { + final session = await seedAll(); + await _addPatient(session, + id: _noClinicalId, microAreaId: _microAreaId, chronic: false, withPatientRow: false); + await _consent(session, _noClinicalId, 'granted', DateTime.utc(2026, 9, 1)); + await _token(session, _noClinicalId, 'tok-sem-clinica', _microAreaId); + + await send(await tokenOf('acs')); + expect(sender.lastTargets.map((t) => t.token), contains('tok-sem-clinica')); + + sender.lastTargets = const []; // o filtro pode não deixar ninguém: o relé nem é chamado + await send(await tokenOf('acs'), audience: 'chronic'); + expect(sender.lastTargets.map((t) => t.token), isNot(contains('tok-sem-clinica'))); + }); + + test('consentimentos com o mesmo timestamp: o de id maior decide', () async { + final session = await seedAll(); + final at = DateTime.utc(2026, 9, 2); + Future linha(String id, String action) => ConsentLog.db.insertRow( + session, + ConsentLog( + id: UuidValue.fromString(id), + userId: UuidValue.fromString(_plainId), + purpose: ConsentPurpose.segmentedPush.name, + action: action, + version: '2026.1', + timestamp: at, + ipHash: 'nao-aplicavel-teste', + userAgent: 'nao-aplicavel-teste', + signature: 'assinatura-de-teste', + ), + ); + // `_plainId` já tem um 'granted' de t0 em `seedAll`; estas duas linhas empatam + // entre si e são as mais recentes. O 'granted' tem o id MENOR. + await linha('00000000-0000-4000-8000-0000000000c1', 'granted'); + await linha('00000000-0000-4000-8000-0000000000c2', 'denied'); + + await send(await tokenOf('acs')); + + expect(sender.lastTargets.map((t) => t.token), isNot(contains('tok-a3'))); + }); + + test('paciente e token inválido são recusados', () async { + await seedAll(); + await expectLater(send(await tokenOf('patient')), throwsA(isA())); + await expectLater(send('lixo'), throwsA(isA())); + expect(sender.calls, 0); + }); + + test('sem relé configurado, a chamada falha com NoticeDeliveryException', () async { + await seedAll(); + AlertRuntime.instance.overrideNoticeSender(() => null); + await expectLater(send(await tokenOf('acs')), throwsA(isA())); + }); + }); +} diff --git a/backend/sinalacs_server/test/integration/onboarding_endpoint_test.dart b/backend/sinalacs_server/test/integration/onboarding_endpoint_test.dart index ef8de48a..643eaecf 100644 --- a/backend/sinalacs_server/test/integration/onboarding_endpoint_test.dart +++ b/backend/sinalacs_server/test/integration/onboarding_endpoint_test.dart @@ -271,7 +271,7 @@ void main() { }); test( - 'completeEnrollment com token válido grava 3 consent_logs e devolve sessão do paciente', + 'completeEnrollment com token válido grava 4 consent_logs e devolve sessão do paciente', () async { final session = sessionBuilder.build(); await _seed(session); @@ -289,18 +289,20 @@ void main() { healthDataConsent: true, remindersConsent: false, pushConsent: true, + termsAccepted: true, ); final rows = await ConsentLog.db.find( session, where: (t) => t.userId.equals(UuidValue.fromString(_patientId)), ); - expect(rows, hasLength(3)); + expect(rows, hasLength(4)); expect(rows.every((r) => r.version == '2026.1'), isTrue); final byPurpose = {for (final r in rows) r.purpose: r.action}; expect(byPurpose['healthDataProcessing'], 'granted'); expect(byPurpose['localReminders'], 'denied'); expect(byPurpose['segmentedPush'], 'granted'); + expect(byPurpose['termsOfUse'], 'granted'); final user = AlertRuntime.instance.auth.verifyToken(result.accessToken); expect(user, isNotNull); @@ -335,6 +337,7 @@ void main() { healthDataConsent: true, remindersConsent: true, pushConsent: true, + termsAccepted: true, ); await expectLater( @@ -344,6 +347,7 @@ void main() { healthDataConsent: true, remindersConsent: true, pushConsent: true, + termsAccepted: true, ), throwsA(isA()), ); @@ -369,6 +373,7 @@ void main() { healthDataConsent: false, remindersConsent: true, pushConsent: true, + termsAccepted: true, ), throwsA(isA()), ); @@ -379,6 +384,46 @@ void main() { ); expect(rows, isEmpty); }); + + test('recusa do Termo de Uso falha, não grava nada e não consome o convite', () async { + final session = sessionBuilder.build(); + await _seed(session); + + final login = await endpoints.auth.developmentLogin(sessionBuilder, role: 'acs'); + final generated = await endpoints.onboarding.generateEnrollmentToken( + sessionBuilder, + accessToken: login.accessToken, + patientId: _patientId, + ); + + await expectLater( + endpoints.onboarding.completeEnrollment( + sessionBuilder, + token: generated.token, + healthDataConsent: true, + remindersConsent: true, + pushConsent: true, + termsAccepted: false, + ), + throwsA(isA()), + ); + final rows = await ConsentLog.db.find( + session, + where: (t) => t.userId.equals(UuidValue.fromString(_patientId)), + ); + expect(rows, isEmpty); + + // O mesmo convite ainda serve: a recusa foi antes do consumo. + final result = await endpoints.onboarding.completeEnrollment( + sessionBuilder, + token: generated.token, + healthDataConsent: true, + remindersConsent: true, + pushConsent: true, + termsAccepted: true, + ); + expect(result.accessToken, isNotEmpty); + }); }); // Grupo separado, com rollback desligado: o grupo principal (acima) faz @@ -436,6 +481,7 @@ void main() { healthDataConsent: true, remindersConsent: true, pushConsent: true, + termsAccepted: true, ); } catch (error) { return error; @@ -451,12 +497,12 @@ void main() { expect(failures, hasLength(1), reason: 'a outra deve falhar de forma auditável, não silenciosa'); - // 3 linhas, não 6: a chamada perdedora nunca chega a gravar consentimento. + // 4 linhas, não 8: a chamada perdedora nunca chega a gravar consentimento. final rows = await ConsentLog.db.find( session, where: (t) => t.userId.equals(UuidValue.fromString(_racePatientId)), ); - expect(rows, hasLength(3)); + expect(rows, hasLength(4)); } finally { // Sem rollback automático neste grupo: a limpeza é manual, e roda // mesmo se uma asserção acima falhar, para o teste ficar repetível. diff --git a/backend/sinalacs_server/test/integration/passwordless_login_test.dart b/backend/sinalacs_server/test/integration/passwordless_login_test.dart index 246ef9f9..b6d7b6b5 100644 --- a/backend/sinalacs_server/test/integration/passwordless_login_test.dart +++ b/backend/sinalacs_server/test/integration/passwordless_login_test.dart @@ -9,6 +9,7 @@ import 'package:sinalacs_server/src/infrastructure/database/orm_otp_challenge_st import 'package:sinalacs_server/src/runtime/alert_runtime.dart'; import 'package:test/test.dart'; +import '../support/health_data_fixtures.dart'; import 'test_tools/runtime_harness.dart'; import 'test_tools/serverpod_test_tools.dart'; @@ -139,6 +140,10 @@ void main() { ); expect(result.accessToken, isNotEmpty); + // Só o ACS tem refresh token (LGPD-RT06); o paciente entra por OTP. + expect(result.refreshToken, isNull); + // Nem token de envio diferido (D7): ele só sai do login institucional. + expect(result.uploadToken, isNull); final sessao = AlertRuntime.instance.auth.verifyToken(result.accessToken); expect(sessao?.role, UserRole.patient); // A microárea do token é a do PACIENTE, lida do banco pelo store: é ela @@ -730,12 +735,10 @@ Future _seedPatient(Session session) async { ); await Patient.db.insertRow( session, - Patient( - id: UuidValue.fromString(_patientId), + await encryptedPatient( + id: _patientId, emergencyContact: 'Contato sintético', isChronic: false, - chronicConditionsEncrypted: '', - chronicConditionsKeyVersion: 1, ), ); } diff --git a/backend/sinalacs_server/test/integration/push_token_endpoint_test.dart b/backend/sinalacs_server/test/integration/push_token_endpoint_test.dart new file mode 100644 index 00000000..404e4080 --- /dev/null +++ b/backend/sinalacs_server/test/integration/push_token_endpoint_test.dart @@ -0,0 +1,614 @@ +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/patients/push_token_service.dart' + show PushRegistration, PushRegistrationResult, maxPushTokensPerUser; +import 'package:sinalacs_server/src/config/app_config.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/application/onboarding/onboarding_service.dart' + show ConsentLogEntry, consentPolicyVersion; +import 'package:sinalacs_server/src/infrastructure/database/orm_data_subject_rights_store.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_push_token_store.dart'; +import 'package:sinalacs_server/src/runtime/alert_runtime.dart'; +import 'package:test/test.dart'; + +import '../support/health_data_fixtures.dart'; +import 'test_tools/serverpod_test_tools.dart'; + +/// Prova, contra Postgres real, o registro do token de push (RF14): só com o +/// consentimento `segmentedPush` vigente, sem duplicar, e apagado na revogação. +const _patientId = '00000000-0000-4000-8000-000000000001'; +const _acsId = '00000000-0000-4000-8000-000000000002'; +const _microAreaId = '00000000-0000-4000-8000-000000000003'; +const _ubsId = '00000000-0000-4000-8000-000000000004'; +// Ids do grupo de corrida (sem rollback): distintos dos `8000` dos outros +// arquivos de integração, que rodam em paralelo contra o mesmo banco. +const _raceUbsId = '00000000-0000-4000-9200-000000000004'; +const _raceMicroAreaId = '00000000-0000-4000-9200-000000000003'; +const _racePatientId = '00000000-0000-4000-9200-000000000001'; +const _raceAcsId = '00000000-0000-4000-9200-000000000002'; +const _chainSecret = 'test-audit-chain-secret'; + +AppConfig _config() => AppConfig( + mqttBroker: 'localhost:1883', + jwtSecret: 'test-secret', + auditChainSecret: _chainSecret, + healthDataEncryptionKey: AppConfig.developmentHealthDataEncryptionKey, + cpfHashPepper: AppConfig.developmentCpfHashPepper, + smsGateway: 'log', + mqttUsername: null, + mqttPassword: null, + mqttUseTls: false, + mqttCaCertificatePath: null, + appEnv: 'development', + enableDevLogin: true, + ); + +Future _seed( + Session session, { + String ubsId = _ubsId, + String microAreaId = _microAreaId, + String patientId = _patientId, + String acsId = _acsId, + String enrollmentId = 'ACS-001', +}) 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.insertRow( + session, + MicroArea( + id: UuidValue.fromString(microAreaId), + name: 'Microárea 12', + ubsId: UuidValue.fromString(ubsId), + geoJsonBoundary: '{}', + ), + ); + final now = DateTime.now().toUtc(); + await User.db.insert(session, [ + User( + id: UuidValue.fromString(patientId), + cpfHash: 'development-patient-$patientId', + name: 'Paciente de desenvolvimento', + birthDate: DateTime.utc(1990, 1, 1), + role: UserRole.patient, + microAreaId: UuidValue.fromString(microAreaId), + createdAt: now, + updatedAt: now, + ), + User( + id: UuidValue.fromString(acsId), + cpfHash: 'development-acs-$acsId', + name: 'ACS de desenvolvimento', + birthDate: DateTime.utc(1980, 1, 1), + role: UserRole.acs, + microAreaId: UuidValue.fromString(microAreaId), + createdAt: now, + updatedAt: now, + ), + ]); + await Acs.db.insertRow( + session, + Acs( + id: UuidValue.fromString(acsId), + enrollmentId: enrollmentId, + ubsId: UuidValue.fromString(ubsId), + active: true, + ), + ); + await Patient.db.insertRow( + session, + await encryptedPatient( + id: patientId, + emergencyContact: 'Contato de desenvolvimento', + isChronic: false, + chronicConditions: const [], + ), + ); +} + + +/// Semente enxuta do grupo de corrida: só o que o registro de token e o +/// consentimento exigem por chave estrangeira (UBS, microárea e usuários). Sem +/// `Patient` nem `Acs`: esse grupo COMMITA, e outros arquivos contam linhas +/// dessas tabelas inteiras; quanto menos ele deixa visível, menos interfere. +Future _seedRaceLean(Session session) async { + await Ubs.db.insertRow( + session, + Ubs( + id: UuidValue.fromString(_raceUbsId), + name: 'UBS de corrida (push)', + address: 'Endereço local', + city: 'São Paulo', + state: 'SP', + ), + ); + await MicroArea.db.insertRow( + session, + MicroArea( + id: UuidValue.fromString(_raceMicroAreaId), + name: 'Microárea de corrida (push)', + ubsId: UuidValue.fromString(_raceUbsId), + geoJsonBoundary: '{}', + ), + ); + final now = DateTime.now().toUtc(); + for (final (id, role) in [(_racePatientId, UserRole.patient), (_raceAcsId, UserRole.patient)]) { + await User.db.insertRow( + session, + User( + id: UuidValue.fromString(id), + cpfHash: 'development-push-race-$id', + name: 'Titular de corrida', + birthDate: DateTime.utc(1985, 5, 5), + role: role, + microAreaId: UuidValue.fromString(_raceMicroAreaId), + createdAt: now, + updatedAt: now, + ), + ); + } +} + +void main() { + withServerpod('Dado o registro de token de push do paciente (RF14)', (sessionBuilder, endpoints) { + setUp(() => AlertRuntime.instance.overrideConfig(_config())); + tearDown(() => AlertRuntime.instance.overrideConfig(null)); + + Future patientToken() async => + (await endpoints.auth.developmentLogin(sessionBuilder, role: 'patient')).accessToken; + + Future countToken(Session session, String token) => + PushToken.db.count(session, where: (t) => t.token.equals(token)); + + Future setPush(String accessToken, bool granted) => endpoints.patients.updateConsent( + sessionBuilder, + accessToken: accessToken, + purpose: ConsentPurpose.segmentedPush, + granted: granted, + ); + + test('paciente com consentimento registra e repete sem duplicar', () async { + final session = sessionBuilder.build(); + await _seed(session); + final token = await patientToken(); + await setPush(token, true); + + for (var i = 0; i < 2; i++) { + await endpoints.devices.registerPushToken( + sessionBuilder, + accessToken: token, + token: 'tok-1', + platform: 'android', + ); + } + + expect(await countToken(session, 'tok-1'), 1); + }); + + test('sem consentimento a chamada falha e nada é gravado', () async { + final session = sessionBuilder.build(); + await _seed(session); + final token = await patientToken(); + + await expectLater( + endpoints.devices.registerPushToken( + sessionBuilder, + accessToken: token, + token: 'tok-2', + platform: 'android', + ), + throwsA(isA()), + ); + expect(await countToken(session, 'tok-2'), 0); + }); + + test('revogar segmentedPush apaga os tokens do titular', () async { + final session = sessionBuilder.build(); + await _seed(session); + final token = await patientToken(); + await setPush(token, true); + await endpoints.devices.registerPushToken( + sessionBuilder, + accessToken: token, + token: 'tok-3', + platform: 'ios', + ); + + await setPush(token, false); + + expect(await countToken(session, 'tok-3'), 0); + }); + + test('conceder de novo depois de revogar volta a permitir o registro', () async { + final session = sessionBuilder.build(); + await _seed(session); + final token = await patientToken(); + await setPush(token, true); + await setPush(token, false); + await setPush(token, true); + + await endpoints.devices.registerPushToken( + sessionBuilder, + accessToken: token, + token: 'tok-4', + platform: 'android', + ); + + expect(await countToken(session, 'tok-4'), 1); + }); + + test('token de acesso inválido é recusado', () async { + await expectLater( + endpoints.devices.registerPushToken( + sessionBuilder, + accessToken: 'lixo', + token: 'tok', + platform: 'android', + ), + throwsA(isA()), + ); + }); + }); + + // Grupo de corrida (sem rollback): cada chamada abre a própria transação + // real do Postgres, que é o que registrar × revogar exige. + withServerpod( + 'Dado registrar e revogar ao mesmo tempo, sem rollback automático (corrida)', + (sessionBuilder, endpoints) { + setUp(() => AlertRuntime.instance.overrideConfig(_config())); + tearDown(() => AlertRuntime.instance.overrideConfig(null)); + + Future cleanup(Session session) async { + final id = UuidValue.fromString(_racePatientId); + final other = UuidValue.fromString(_raceAcsId); + await PushToken.db.deleteWhere(session, where: (t) => t.userId.equals(id) | t.userId.equals(other)); + await ConsentLog.db.deleteWhere(session, where: (t) => t.userId.equals(id) | t.userId.equals(other)); + await User.db.deleteWhere( + session, + where: (t) => t.id.equals(id) | t.id.equals(UuidValue.fromString(_raceAcsId)), + ); + await MicroArea.db.deleteWhere( + session, + where: (t) => t.id.equals(UuidValue.fromString(_raceMicroAreaId)), + ); + await Ubs.db.deleteWhere(session, where: (t) => t.id.equals(UuidValue.fromString(_raceUbsId))); + } + + test('depois de registrar × revogar em paralelo, denied nunca convive com token', () async { + final session = sessionBuilder.build(); + try { + await _seedRaceLean(session); + final consents = OrmDataSubjectRightsStore( + session: () => sessionBuilder.build(), + chainSecret: _chainSecret, + cipher: AlertRuntime.instance.healthDataCipher, + ); + final tokens = OrmPushTokenStore(session: () => sessionBuilder.build()); + Future consent(String action) async { + await consents.recordConsent(ConsentLogEntry( + userId: _racePatientId, + purpose: ConsentPurpose.segmentedPush, + action: action, + version: consentPolicyVersion, + timestamp: DateTime.now().toUtc(), + )); + } + + for (var i = 0; i < 40; i++) { + await consent('granted'); + await Future.wait([ + tokens.registerIfConsented( + userId: _racePatientId, + microAreaId: _raceMicroAreaId, + token: 'tok-corrida', + platform: 'android', + now: DateTime.now().toUtc(), + ), + consents.recordConsentRevokingPush(ConsentLogEntry( + userId: _racePatientId, + purpose: ConsentPurpose.segmentedPush, + action: 'denied', + version: consentPolicyVersion, + timestamp: DateTime.now().toUtc(), + )), + ]); + final left = await PushToken.db.count( + session, + where: (t) => t.userId.equals(UuidValue.fromString(_racePatientId)), + ); + expect(left, 0, reason: 'iteração $i: consentimento revogado, token ficou'); + } + } finally { + await cleanup(session); + } + }); + + test('aparelho de outro titular sem consentimento perde o vínculo do dono antigo', () async { + final session = sessionBuilder.build(); + try { + await _seedRaceLean(session); + final consents = OrmDataSubjectRightsStore( + session: () => sessionBuilder.build(), + chainSecret: _chainSecret, + cipher: AlertRuntime.instance.healthDataCipher, + ); + final tokens = OrmPushTokenStore(session: () => sessionBuilder.build()); + await consents.recordConsent(ConsentLogEntry( + userId: _racePatientId, + purpose: ConsentPurpose.segmentedPush, + action: 'granted', + version: consentPolicyVersion, + timestamp: DateTime.now().toUtc(), + )); + expect( + (await tokens.registerIfConsented( + userId: _racePatientId, + microAreaId: _raceMicroAreaId, + token: 'tok-compartilhado', + platform: 'android', + now: DateTime.now().toUtc(), + )) + .outcome, + PushRegistration.registered, + ); + + // O ACS de teste faz o papel da segunda pessoa: nunca consentiu. + final registered = await tokens.registerIfConsented( + userId: _raceAcsId, + microAreaId: _raceMicroAreaId, + token: 'tok-compartilhado', + platform: 'android', + now: DateTime.now().toUtc(), + ); + + expect(registered.outcome, PushRegistration.refused); + expect( + await PushToken.db.count(session, where: (t) => t.token.equals('tok-compartilhado')), + 0, + reason: 'o token continuaria ligado ao titular anterior', + ); + } finally { + await cleanup(session); + } + }); + + Future grant(OrmDataSubjectRightsStore consents, String userId) => + consents.recordConsent(ConsentLogEntry( + userId: userId, + purpose: ConsentPurpose.segmentedPush, + action: 'granted', + version: consentPolicyVersion, + timestamp: DateTime.now().toUtc(), + )); + + OrmDataSubjectRightsStore consentStore({bool failAfterTokenDelete = false}) => + OrmDataSubjectRightsStore( + session: () => sessionBuilder.build(), + chainSecret: _chainSecret, + cipher: AlertRuntime.instance.healthDataCipher, + debugFailAfterTokenDelete: failAfterTokenDelete, + ); + + test('revogação atômica: falha depois de apagar os tokens desfaz tudo', () async { + final session = sessionBuilder.build(); + try { + await _seedRaceLean(session); + await grant(consentStore(), _racePatientId); + final tokens = OrmPushTokenStore(session: () => sessionBuilder.build()); + await tokens.registerIfConsented( + userId: _racePatientId, + microAreaId: _raceMicroAreaId, + token: 'tok-atomico', + platform: 'android', + now: DateTime.now().toUtc(), + ); + + await expectLater( + consentStore(failAfterTokenDelete: true).recordConsentRevokingPush(ConsentLogEntry( + userId: _racePatientId, + purpose: ConsentPurpose.segmentedPush, + action: 'denied', + version: consentPolicyVersion, + timestamp: DateTime.now().toUtc(), + )), + throwsA(isA()), + ); + + expect( + await PushToken.db.count(session, where: (t) => t.token.equals('tok-atomico')), + 1, + reason: 'o apagamento dos tokens tinha de reverter junto', + ); + final denied = await ConsentLog.db.count( + session, + where: (t) => t.userId.equals(UuidValue.fromString(_racePatientId)) & t.action.equals('denied'), + ); + expect(denied, 0); + } finally { + await cleanup(session); + } + }); + + test('passou do teto, o token mais antigo sai e o novo entra', () async { + final session = sessionBuilder.build(); + try { + await _seedRaceLean(session); + await grant(consentStore(), _racePatientId); + final tokens = OrmPushTokenStore(session: () => sessionBuilder.build()); + final base = DateTime.now().toUtc(); + for (var i = 0; i <= maxPushTokensPerUser; i++) { + await tokens.registerIfConsented( + userId: _racePatientId, + microAreaId: _raceMicroAreaId, + token: 'tok-teto-$i', + platform: 'android', + now: base.add(Duration(seconds: i)), + ); + } + + final left = await PushToken.db.find( + session, + where: (t) => t.userId.equals(UuidValue.fromString(_racePatientId)), + ); + expect(left, hasLength(maxPushTokensPerUser)); + expect(left.map((t) => t.token), isNot(contains('tok-teto-0'))); + expect(left.map((t) => t.token), contains('tok-teto-$maxPushTokensPerUser')); + } finally { + await cleanup(session); + } + }); + + test('troca de dono devolve ownerChanged; repetir devolve registered', () async { + final session = sessionBuilder.build(); + try { + await _seedRaceLean(session); + final consents = consentStore(); + await grant(consents, _racePatientId); + await grant(consents, _raceAcsId); + final tokens = OrmPushTokenStore(session: () => sessionBuilder.build()); + Future register(String userId) => tokens.registerIfConsented( + userId: userId, + microAreaId: _raceMicroAreaId, + token: 'tok-dono', + platform: 'android', + now: DateTime.now().toUtc(), + ); + + expect((await register(_racePatientId)).outcome, PushRegistration.registered); + final trocado = await register(_raceAcsId); + expect(trocado.outcome, PushRegistration.ownerChanged); + expect(trocado.previousOwnerId, _racePatientId); + expect((await register(_raceAcsId)).outcome, PushRegistration.registered); + expect(await PushToken.db.count(session, where: (t) => t.token.equals('tok-dono')), 1); + } finally { + await cleanup(session); + } + }); + + test('a poda do teto só apaga tokens do próprio titular', () async { + final session = sessionBuilder.build(); + try { + await _seedRaceLean(session); + final consents = consentStore(); + await grant(consents, _racePatientId); + await grant(consents, _raceAcsId); + final tokens = OrmPushTokenStore(session: () => sessionBuilder.build()); + final base = DateTime.now().toUtc(); + // B (_raceAcsId) está no teto; o token mais antigo dele é 'tok-b-0'. + for (var i = 0; i < maxPushTokensPerUser; i++) { + await tokens.registerIfConsented( + userId: _raceAcsId, + microAreaId: _raceMicroAreaId, + token: 'tok-b-$i', + platform: 'android', + now: base.add(Duration(seconds: i)), + ); + } + // C (_racePatientId) recebe o token mais antigo de B: troca de dono. + await tokens.registerIfConsented( + userId: _racePatientId, + microAreaId: _raceMicroAreaId, + token: 'tok-b-0', + platform: 'android', + now: base.add(const Duration(minutes: 1)), + ); + // B registra o 11º token: a poda dele NÃO pode levar o 'tok-b-0' que agora é de C. + await tokens.registerIfConsented( + userId: _raceAcsId, + microAreaId: _raceMicroAreaId, + token: 'tok-b-novo', + platform: 'android', + now: base.add(const Duration(minutes: 2)), + ); + final dono = await PushToken.db.findFirstRow(session, where: (t) => t.token.equals('tok-b-0')); + expect(dono?.userId, UuidValue.fromString(_racePatientId)); + } finally { + await cleanup(session); + } + }); + + test('relógio que voltou: o token recém-gravado nunca é o podado', () async { + final session = sessionBuilder.build(); + try { + await _seedRaceLean(session); + await grant(consentStore(), _racePatientId); + final tokens = OrmPushTokenStore(session: () => sessionBuilder.build()); + final futuro = DateTime.now().toUtc().add(const Duration(hours: 1)); + for (var i = 0; i < maxPushTokensPerUser; i++) { + await tokens.registerIfConsented( + userId: _racePatientId, + microAreaId: _raceMicroAreaId, + token: 'tok-f-$i', + platform: 'android', + now: futuro, + ); + } + // `now` MENOR que o de todos os outros: por updatedAt ele seria o mais antigo. + final result = await tokens.registerIfConsented( + userId: _racePatientId, + microAreaId: _raceMicroAreaId, + token: 'tok-agora', + platform: 'android', + now: DateTime.now().toUtc(), + ); + expect(result.outcome, PushRegistration.registered); + expect(await PushToken.db.count(session, where: (t) => t.token.equals('tok-agora')), 1); + expect( + await PushToken.db.count( + session, + where: (t) => t.userId.equals(UuidValue.fromString(_racePatientId)), + ), + maxPushTokensPerUser, + ); + } finally { + await cleanup(session); + } + }); + + test('consentimentos com o MESMO timestamp: o de id maior decide', () async { + final session = sessionBuilder.build(); + try { + await _seedRaceLean(session); + final at = DateTime.utc(2026, 9, 1); + Future linha(String id, String action) => ConsentLog.db.insertRow( + session, + ConsentLog( + id: UuidValue.fromString(id), + userId: UuidValue.fromString(_racePatientId), + purpose: ConsentPurpose.segmentedPush.name, + action: action, + version: '2026.1', + timestamp: at, + ipHash: 'nao-aplicavel-teste', + userAgent: 'nao-aplicavel-teste', + signature: 'assinatura-de-teste', + ), + ); + // O 'granted' tem o id MENOR e entra primeiro; o 'denied' tem o id maior. + await linha('00000000-0000-4000-9200-0000000000a1', 'granted'); + await linha('00000000-0000-4000-9200-0000000000a2', 'denied'); + final tokens = OrmPushTokenStore(session: () => sessionBuilder.build()); + + final result = await tokens.registerIfConsented( + userId: _racePatientId, + microAreaId: _raceMicroAreaId, + token: 'tok-empate', + platform: 'android', + now: DateTime.now().toUtc(), + ); + + expect(result.outcome, PushRegistration.refused); + } finally { + await cleanup(session); + } + }); + }, + rollbackDatabase: RollbackDatabase.disabled, + ); +} 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 31031cca..e591634a 100644 --- a/backend/sinalacs_server/test/integration/red_alert_cycle_test.dart +++ b/backend/sinalacs_server/test/integration/red_alert_cycle_test.dart @@ -1,12 +1,15 @@ 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/config/app_config.dart'; import 'package:sinalacs_server/src/domain/entities/alert_delivery.dart'; import 'package:sinalacs_server/src/endpoints/health_endpoint.dart'; import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/infrastructure/mqtt/mqtt_alert_dispatcher.dart'; import 'package:sinalacs_server/src/runtime/alert_runtime.dart'; import 'package:test/test.dart'; +import '../support/fake_mqtt_broker.dart'; import '../support/health_data_fixtures.dart'; import 'test_tools/serverpod_test_tools.dart'; @@ -24,7 +27,7 @@ class _RecordingPublisher implements AlertPublisher { bool offline = false; @override - void publish(AlertDelivery alert) { + Future publish(AlertDelivery alert) async { if (offline) throw Exception('broker indisponível'); published.add(alert); } @@ -54,6 +57,55 @@ AppConfig _config({required bool enableDevLogin}) => AppConfig( enableDevLogin: enableDevLogin, ); +/// Dispatcher MQTT real apontado ao [FakeMqttBroker] (TCP plano em +/// 127.0.0.1). O canal de produção é TLS/mTLS: a queda por RST/EPIPE real do +/// socket TLS foi provada no Docker (Tarefa 7v da #56); aqui o que se prova é o +/// caminho outbox → dispatcher → reconexão, que não depende da camada TLS. +AppConfig _mqttConfig(int port) => AppConfig( + mqttBroker: '127.0.0.1:$port', + jwtSecret: 'test-secret', + auditChainSecret: 'test-audit-chain-secret', + healthDataEncryptionKey: AppConfig.developmentHealthDataEncryptionKey, + cpfHashPepper: AppConfig.developmentCpfHashPepper, + smsGateway: 'log', + mqttUsername: null, + mqttPassword: null, + mqttUseTls: false, + mqttCaCertificatePath: null, + appEnv: 'development', + enableDevLogin: true, + ); + +/// Polling a cada 25 ms até [cond] valer; falha depois de [timeout]. +Future _until( + bool Function() cond, { + Duration timeout = const Duration(seconds: 5), +}) async { + final limite = DateTime.now().add(timeout); + while (!cond()) { + if (DateTime.now().isAfter(limite)) fail('timeout'); + await Future.delayed(const Duration(milliseconds: 25)); + } +} + +/// Publisher que executa [antes] e só então delega ao dispatcher real. +/// +/// Usado no experimento H5 para derrubar o socket no instante exato entre a +/// checagem de estado e a escrita: o dispatcher ainda vê `connected` porque o +/// evento de fim de conexão só é processado num turno seguinte do event loop. +class _PublisherComGancho implements AlertPublisher { + _PublisherComGancho(this._real, this.antes); + + final AlertPublisher _real; + void Function() antes; + + @override + Future publish(AlertDelivery alert) { + antes(); + return _real.publish(alert); + } +} + Future _seed(Session session) async { await Ubs.db.insertRow( session, @@ -390,6 +442,208 @@ void main() { expect(confirmado.acknowledged, isTrue); }); + group('com o MqttAlertDispatcher real e o broker falso', () { + late FakeMqttBroker broker; + late MqttAlertDispatcher dispatcher; + late List fatais; + + setUp(() async { + broker = await FakeMqttBroker.start(); + fatais = []; + dispatcher = MqttAlertDispatcher( + config: _mqttConfig(broker.port), + initialBackoff: const Duration(milliseconds: 100), + maxBackoff: const Duration(milliseconds: 400), + // Curto para o teste do PUBACK retido não esperar 10 s. + publishAckTimeout: const Duration(milliseconds: 500), + onFatal: fatais.add, + log: (_) {}, + ); + AlertRuntime.instance.overridePublisher(dispatcher); + }); + + tearDown(() async { + await dispatcher.close(); + await broker.close(); + expect(fatais, isEmpty, reason: 'onFatal não devia ser chamado'); + }); + + Future criarAlerta(String chave) async { + final token = await endpoints.auth + .developmentLogin(sessionBuilder, role: 'patient'); + return endpoints.alerts.createRedAlert( + sessionBuilder, + accessToken: token.accessToken, + idempotencyKey: chave, + locationHash: 'hash-$chave', + ); + } + + Future confirmar(String alertId) async { + final tokenAcs = + await endpoints.auth.developmentLogin(sessionBuilder, role: 'acs'); + final ack = await endpoints.alerts.acknowledge( + sessionBuilder, + accessToken: tokenAcs.accessToken, + alertId: alertId, + ); + return ack.acknowledged; + } + + AlertOutboxDispatcher drenador(Session session) => + AlertRuntime.instance.dispatcherFor( + session, + clock: () => + DateTime.now().toUtc().add(const Duration(minutes: 5)), + ); + + test( + 'alerta criado com o broker fora é entregue depois da reconexão, ' + 'uma vez só (#56)', () async { + final session = sessionBuilder.build(); + await _seed(session); + + await dispatcher.connect(); + expect(dispatcher.isConnected, isTrue); + + // Interrupção: o broker some (conexões e porta). + final porta = broker.port; + await broker.close(); + await _until(() => !dispatcher.isConnected); + + final criado = await criarAlerta('queda-1'); + final alertId = criado.alertId; + // Aceito e enfileirado, não recusado (INV-03). + expect(criado.published, isFalse); + expect(await Alert.db.find(session), hasLength(1)); + final pendentes = await AlertOutboxEntry.db.find(session); + expect(pendentes, hasLength(1)); + expect(pendentes.single.publishedAt, isNull); + + // O broker volta na MESMA porta; o dispatcher reconecta sozinho. + broker = await FakeMqttBroker.start(port: porta); + await _until(() => dispatcher.isConnected && broker.connectCount >= 1); + + // A tentativa imediata adiou a entrada pelo backoff do outbox: o + // relógio adiantado faz a varredura reclamá-la. + final publicadas = await drenador(session).drainOnce(); + expect(publicadas, 1); + await _until(() => broker.publishCount == 1); + final depois = await AlertOutboxEntry.db.find(session); + expect(depois.single.publishedAt, isNotNull); + + // Segunda varredura: nada a publicar, sem duplicidade. + expect(await drenador(session).drainOnce(), 0); + await Future.delayed(const Duration(milliseconds: 200)); + expect(broker.publishCount, 1); + + expect(await confirmar(alertId), isTrue); + }); + + // H5 (#56) — CORRIGIDA (Tarefa 9b). Antes, `publish()` devolvia assim + // que a mensagem ia para o buffer do socket e o outbox carimbava + // `publishedAt` sem PUBACK; uma reconexão cria um cliente MQTT novo + // (sessão limpa), então a mensagem em voo nunca era retransmitida e o + // alerta se perdia para o canal MQTT. Agora o outbox só marca publicado + // depois do PUBACK: sem ele a entrada fica pendente e é reentregue. + test( + 'H5: socket morto antes de o dispatcher perceber — a entrada NÃO é ' + 'marcada publicada e é entregue depois da reconexão', () async { + final session = sessionBuilder.build(); + await _seed(session); + await dispatcher.connect(); + expect(dispatcher.isConnected, isTrue); + + // O broker derruba as conexões no mesmo turno em que o dispatcher + // vai escrever: o estado da biblioteca ainda diz `connected`. + final gancho = _PublisherComGancho(dispatcher, broker.dropClients); + AlertRuntime.instance.overridePublisher(gancho); + + final criado = await criarAlerta('h5-socket-morto'); + gancho.antes = () {}; + + // Sem PUBACK o endpoint não relata entrega, e o outbox segue pendente. + expect(criado.published, isFalse); + final entrada = (await AlertOutboxEntry.db.find(session)).single; + expect(entrada.publishedAt, isNull, + reason: 'H5: nada de publicado sem PUBACK'); + expect(entrada.lastError, isNotNull); + expect(broker.publishCount, 0); + + // O dispatcher reconecta com um cliente novo e a varredura reentrega. + await _until(() => broker.connectCount >= 2 && dispatcher.isConnected); + expect(await drenador(session).drainOnce(), 1); + expect(broker.publishCount, 1); + expect(broker.pubackCount, 1); + final depois = (await AlertOutboxEntry.db.find(session)).single; + expect(depois.publishedAt, isNotNull); + + // Nada mais a entregar, e o ACK fecha o ciclo. + expect(await drenador(session).drainOnce(), 0); + expect(broker.publishCount, 1); + expect(await confirmar(criado.alertId), isTrue); + }); + + test( + 'H5: broker recebe o PUBLISH, não responde PUBACK e cai — a entrada ' + 'fica pendente e é reentregue (pelo menos uma vez)', () async { + final session = sessionBuilder.build(); + await _seed(session); + await dispatcher.connect(); + expect(dispatcher.isConnected, isTrue); + broker.dropOnPublish = true; + + final criado = await criarAlerta('h5-sem-puback'); + + expect(criado.published, isFalse); + expect(broker.publishCount, 1); + final entrada = (await AlertOutboxEntry.db.find(session)).single; + expect(entrada.publishedAt, isNull, + reason: 'H5: nada de publicado antes do PUBACK'); + + broker.dropOnPublish = false; + await _until(() => broker.connectCount >= 2 && dispatcher.isConnected); + expect(await drenador(session).drainOnce(), 1); + // Duplicata aceita (QoS 1, ACK idempotente); perda, não. + expect(broker.publishCount, 2); + expect(broker.pubackCount, 1); + expect((await AlertOutboxEntry.db.find(session)).single.publishedAt, + isNotNull); + expect(await drenador(session).drainOnce(), 0); + expect(broker.publishCount, 2); + }); + + test( + 'H5: conexão morta sem FIN/RST (o broker some sem PUBACK, cenário C) ' + '— falha no prazo, reconecta e a entrega acontece', () async { + final session = sessionBuilder.build(); + await _seed(session); + await dispatcher.connect(); + final antigo = dispatcher.clientForTest; + broker.withholdPuback = true; + + final criado = await criarAlerta('h5-particao'); + + expect(criado.published, isFalse); + expect(broker.publishCount, 1); + expect(broker.pubackCount, 0); + expect((await AlertOutboxEntry.db.find(session)).single.publishedAt, + isNull); + // O tempo limite do PUBACK deu a conexão como perdida. + expect(identical(dispatcher.clientForTest, antigo), isFalse); + + broker.withholdPuback = false; + await _until(() => broker.connectCount >= 2 && dispatcher.isConnected); + expect(await drenador(session).drainOnce(), 1); + expect(broker.pubackCount, 1); + expect((await AlertOutboxEntry.db.find(session)).single.publishedAt, + isNotNull); + final alerta = (await Alert.db.find(session)).single; + expect(alerta.status, AlertStatus.pending); + expect(await confirmar(criado.alertId), isTrue); + }); + }); + test('a mesma chave com outra localização é rejeitada', () async { await _seed(sessionBuilder.build()); final token = await endpoints.auth diff --git a/backend/sinalacs_server/test/integration/red_alert_discard_paths_db_test.dart b/backend/sinalacs_server/test/integration/red_alert_discard_paths_db_test.dart new file mode 100644 index 00000000..18260325 --- /dev/null +++ b/backend/sinalacs_server/test/integration/red_alert_discard_paths_db_test.dart @@ -0,0 +1,355 @@ +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/admin/admin_scope.dart'; +import 'package:sinalacs_server/src/application/audit/audit_chain_verifier.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/retention/expurge_service.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_alert_outbox.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_alert_store.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_audit_chain_reader.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_data_subject_case_store.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_retention_store.dart'; +import 'package:sinalacs_server/src/runtime/alert_runtime.dart'; +import 'package:test/test.dart'; + +import '../support/health_data_fixtures.dart'; +import 'test_tools/serverpod_test_tools.dart'; + +/// Parte com Postgres do inventário de rotas de descarte de alerta vermelho +/// (#44, Task 3); a parte hermética está em +/// `test/unit/red_alert_discard_paths_test.dart`. Os buracos +/// que a Task 3 provou (ACK sem auditoria, expurgo de vermelho pending) foram +/// fechados na Task 4. Ids iguais aos do dev-login, para o RPC real rodar. +/// Dados sintéticos. +const _ubs = '00000000-0000-4000-8000-000000000004'; +const _ma = '00000000-0000-4000-8000-000000000003'; +const _pac = '00000000-0000-4000-8000-000000000001'; +const _acs = '00000000-0000-4000-8000-000000000002'; +const _adm = '00000000-0000-4000-8000-000000004405'; +const _pedido = '00000000-0000-4000-8000-000000004406'; + +final _agora = DateTime.utc(2026, 10, 11, 12); + +AppConfig _config() => AppConfig( + mqttBroker: 'localhost:1883', + jwtSecret: 'test-secret', + auditChainSecret: 'test-audit-chain-secret', + healthDataEncryptionKey: AppConfig.developmentHealthDataEncryptionKey, + cpfHashPepper: AppConfig.developmentCpfHashPepper, + smsGateway: 'log', + mqttUsername: null, + mqttPassword: null, + mqttUseTls: false, + mqttCaCertificatePath: null, + appEnv: 'development', + enableDevLogin: true, +); + +Future _user(Session s, String id, UserRole role, {String? ma}) => User.db + .insertRow( + s, + User( + id: UuidValue.fromString(id), + cpfHash: 'discard-$id', + name: 'Sintético $id', + birthDate: DateTime.utc(1985, 3, 4), + role: role, + microAreaId: ma == null ? null : UuidValue.fromString(ma), + createdAt: _agora, + updatedAt: _agora, + ), + ) + .then((_) {}); + +Future _seed(Session s) async { + await Ubs.db.insertRow( + s, + Ubs( + id: UuidValue.fromString(_ubs), + name: 'UBS descarte', + address: 'Endereço sintético', + city: 'São Paulo', + state: 'SP', + ), + ); + await MicroArea.db.insertRow( + s, + MicroArea( + id: UuidValue.fromString(_ma), + name: 'Microárea descarte', + ubsId: UuidValue.fromString(_ubs), + geoJsonBoundary: '{}', + ), + ); + await _user(s, _pac, UserRole.patient, ma: _ma); + await _user(s, _acs, UserRole.acs, ma: _ma); + await _user(s, _adm, UserRole.admin); + await Patient.db.insertRow( + s, + await encryptedPatient( + id: _pac, + emergencyContact: 'contato sintético', + isChronic: false, + ), + ); + await Acs.db.insertRow( + s, + Acs( + id: UuidValue.fromString(_acs), + enrollmentId: 'DESC-001', + ubsId: UuidValue.fromString(_ubs), + active: true, + ), + ); +} + +Future _alertaVermelho(Session s, {required DateTime em}) => + Alert.db.insertRow( + s, + Alert( + patientId: UuidValue.fromString(_pac), + microAreaId: UuidValue.fromString(_ma), + triggeredAt: em, + riskLevel: RiskLevel.red, + locationHash: 'hash-sintetico', + status: AlertStatus.pending, + mqttTopic: 'sinalacs/v1/microareas/$_ma/alerts', + deviceId: 'device-sintetico', + retryCount: 0, + version: 0, + ), + ); + +Future _auditoriaDoAlerta(Session s, UuidValue alertId) => + AuditLog.db.count(s, where: (t) => t.resourceId.equals(alertId)); + +void main() { + withServerpod('Rotas de descarte de alerta vermelho (#44)', ( + sessionBuilder, + endpoints, + ) { + late Session session; + + setUp(() async { + session = sessionBuilder.build(); + AlertRuntime.instance.overrideConfig(_config()); + await _seed(session); + }); + tearDown(() => AlertRuntime.instance.overrideConfig(null)); + + test( + 'alerts.acknowledge (RPC): vai a acknowledged E deixa auditoria', + () async { + final alerta = await _alertaVermelho(session, em: _agora); + final token = await endpoints.auth.developmentLogin( + sessionBuilder, + role: 'acs', + ); + final r = await endpoints.alerts.acknowledge( + sessionBuilder, + accessToken: token.accessToken, + alertId: alerta.id!.uuid, + ); + expect(r.acknowledged, isTrue); + final depois = await Alert.db.findById(session, alerta.id!); + expect(depois!.status, AlertStatus.acknowledged); + expect( + await _auditoriaDoAlerta(session, alerta.id!), + greaterThan(0), + reason: 'ACK de alerta vermelho sem linha em audit_logs', + ); + final verifier = AuditChainVerifier( + reader: OrmAuditChainReader(session: () => session), + secret: 'test-audit-chain-secret', + ); + expect((await verifier.verify()).ok, isTrue); + }, + ); + + test('ACK com a trilha de auditoria falhando é desfeito por inteiro', () async { + final alerta = await _alertaVermelho(session, em: _agora); + final store = OrmAlertStore( + session: () => session, + appendAudit: (s, t, e) async => throw StateError('trilha fora do ar'), + ); + + await expectLater( + store.acknowledge( + alertId: alerta.id!.uuid, + acsId: _acs, + microAreaId: _ma, + audit: const AuditEvent( + userId: _acs, + actionType: 'write', + resourceType: 'alert', + resourceId: _pac, + result: 'acknowledged', + ), + ), + throwsA(isA()), + ); + + final depois = (await Alert.db.findById(session, alerta.id!))!; + expect(depois.status, AlertStatus.pending); + expect(depois.acknowledgedAt, isNull); + expect(depois.acsId, isNull); + expect(await AlertDeliveryRecord.db.count(session), 0); + expect(await AuditLog.db.count(session), 0); + }); + + test( + 'ACK por MQTT (mesmo caminho do callback de server.dart)', + () async { + // server.dart: runtime.serviceFor(session).acknowledge(user com + // deviceId "mqtt"). O dispatcher só decodifica a mensagem e chama este + // callback; o broker real não é necessário para provar a escrita. + final alerta = await _alertaVermelho(session, em: _agora); + final ok = await AlertRuntime.instance + .serviceFor(session) + .acknowledge( + user: AuthenticatedUser( + id: _acs, + role: UserRole.acs, + microAreaId: _ma, + deviceId: 'mqtt', + ), + alertId: alerta.id!.uuid, + ); + expect(ok, isTrue); + expect( + (await Alert.db.findById(session, alerta.id!))!.status, + AlertStatus.acknowledged, + ); + expect( + await _auditoriaDoAlerta(session, alerta.id!), + greaterThan(0), + reason: 'ACK por MQTT sem linha em audit_logs', + ); + final linha = await AuditLog.db.findFirstRow( + session, + where: (t) => t.resourceId.equals(alerta.id!), + ); + expect( + linha!.result, + 'acknowledged_mqtt', + reason: 'o ACK por MQTT não é autenticado e não pode parecer o do RPC', + ); + }, + ); + + test( + 'outbox esgotando tentativas: alerta segue pending e a entrega, viva', + () async { + final alerta = await _alertaVermelho(session, em: _agora); + final outbox = OrmAlertOutbox( + session: () => session, + clock: () => _agora, + ); + await AlertOutboxEntry.db.insertRow( + session, + AlertOutboxEntry( + alertId: alerta.id!, + topic: 'sinalacs/v1/microareas/$_ma/alerts', + payload: '{}', + createdAt: _agora, + attempts: 0, + nextAttemptAt: _agora, + ), + ); + final entrada = (await AlertOutboxEntry.db.findFirstRow(session))!; + for (var i = 0; i < 30; i++) { + await outbox.markFailed( + entrada.id!.uuid, + 'broker indisponível', + _agora.add(const Duration(seconds: 60)), + ); + } + + final depois = await Alert.db.findById(session, alerta.id!); + expect(depois!.status, AlertStatus.pending); + expect(depois.riskLevel, RiskLevel.red); + final linha = (await AlertOutboxEntry.db.findFirstRow(session))!; + expect(linha.publishedAt, isNull, reason: 'nunca marcado entregue'); + expect(linha.lastError, 'broker indisponível'); + }, + ); + + test( + 'retenção LGPD: não apaga vermelho pending vencido (D7)', + () async { + final velho = await _alertaVermelho( + session, + em: _agora.subtract(const Duration(days: 800)), + ); + final cortes = RetentionCutoffs( + alerts: _agora.subtract(const Duration(days: 730)), + visits: _agora.subtract(const Duration(days: 1825)), + triageSessions: _agora.subtract(const Duration(days: 1825)), + ); + + await OrmRetentionStore(session: () => session).purgeDue(cortes); + + final restante = await Alert.db.findById(session, velho.id!); + expect( + restante, + isNotNull, + reason: 'expurgo apagou alerta vermelho ainda pending', + ); + expect(restante!.status, AlertStatus.pending); + }, + ); + + test( + 'anonimização do titular: não apaga nem altera status de alerta', + () async { + final alerta = await _alertaVermelho(session, em: _agora); + await DataSubjectRequest.db.insertRow( + session, + DataSubjectRequest( + id: UuidValue.fromString(_pedido), + userId: UuidValue.fromString(_pac), + requestType: DataSubjectRequestType.deletion, + detailsEncrypted: '', + detailsKeyVersion: 1, + status: DataSubjectRequestStatus.open, + createdAt: _agora.subtract(const Duration(days: 15)), + dueAt: _agora, + ), + ); + final store = OrmDataSubjectCaseStore( + session, + testHealthDataCipher(), + chainSecret: 'segredo-sintetico', + appendAudit: (s, t, e) async {}, + ); + + final ok = await store.decide( + const AdminScope.system(), + _pedido, + from: const {DataSubjectRequestStatus.open}, + to: DataSubjectRequestStatus.completed, + resolution: null, + decidedBy: _adm, + anonymize: true, + audit: const AuditEvent( + userId: _adm, + actionType: 'write', + resourceType: 'data_subject_request', + resourceId: _pedido, + result: 'completed', + ), + now: _agora, + ); + + expect(ok, isTrue); + final depois = await Alert.db.findById(session, alerta.id!); + expect(depois, isNotNull); + expect(depois!.status, AlertStatus.pending); + expect(depois.riskLevel, RiskLevel.red); + expect(depois.triggeredAt, alerta.triggeredAt); + }, + ); + }); +} diff --git a/backend/sinalacs_server/test/integration/refresh_token_test.dart b/backend/sinalacs_server/test/integration/refresh_token_test.dart new file mode 100644 index 00000000..b4bcb16c --- /dev/null +++ b/backend/sinalacs_server/test/integration/refresh_token_test.dart @@ -0,0 +1,611 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/auth/refresh_token_service.dart'; +import 'package:sinalacs_server/src/application/auth/totp.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_refresh_token_store.dart'; +import 'package:test/test.dart'; + +import 'test_tools/runtime_harness.dart'; +import 'test_tools/serverpod_test_tools.dart'; + +/// Store do refresh token (LGPD-RT06) contra Postgres real: prova a rotação +/// atômica e o fechamento da corrida entre `insert` e `revokeFamily`, que um +/// store falso não alcança. Dados sintéticos; ids próprios (`...0a1`). +const _ubsId = '00000000-0000-4000-8000-0000000000a1'; +const _microAreaId = '00000000-0000-4000-8000-0000000000a2'; +const _acsId = '00000000-0000-4000-8000-0000000000a3'; +const _outroAcsId = '00000000-0000-4000-8000-0000000000a4'; +const _pacienteId = '00000000-0000-4000-8000-0000000000a5'; +const _familiaA = '00000000-0000-4000-8000-0000000000b1'; +const _familiaB = '00000000-0000-4000-8000-0000000000b2'; + +String _sha(String t) => sha256.convert(utf8.encode(t)).toString(); + +String _id(int n) => + '00000000-0000-4000-8000-${n.toRadixString(16).padLeft(12, '0')}'; + +Future _seed(Session session) async { + await Ubs.db.insertRow( + session, + Ubs( + id: UuidValue.fromString(_ubsId), + name: 'UBS Refresh', + address: 'Endereço local', + city: 'São Paulo', + state: 'SP', + ), + ); + await MicroArea.db.insertRow( + session, + MicroArea( + id: UuidValue.fromString(_microAreaId), + name: 'Microárea Refresh', + ubsId: UuidValue.fromString(_ubsId), + geoJsonBoundary: '{}', + ), + ); + final now = DateTime.now().toUtc(); + Future user(String id, UserRole role, String cpf) => User.db.insertRow( + session, + User( + id: UuidValue.fromString(id), + cpfHash: cpf, + name: 'Usuário sintético', + birthDate: DateTime.utc(1980), + role: role, + microAreaId: UuidValue.fromString(_microAreaId), + createdAt: now, + updatedAt: now, + ), + ); + await user(_acsId, UserRole.acs, 'development-acs-refresh'); + await user(_outroAcsId, UserRole.acs, 'development-acs-refresh-2'); + await user(_pacienteId, UserRole.patient, 'development-patient-refresh'); + await Acs.db.insertRow( + session, + Acs( + id: UuidValue.fromString(_acsId), + enrollmentId: 'ACS-REFRESH-001', + ubsId: UuidValue.fromString(_ubsId), + active: true, + ), + ); + await Acs.db.insertRow( + session, + Acs( + id: UuidValue.fromString(_outroAcsId), + enrollmentId: 'ACS-REFRESH-002', + ubsId: UuidValue.fromString(_ubsId), + active: false, + ), + ); +} + +RefreshTokenRecord _record( + String id, { + String userId = _acsId, + String familyId = _familiaA, + DateTime? absolute, +}) { + final at = DateTime.utc(2026, 10, 3, 12); + return RefreshTokenRecord( + id: id, + userId: userId, + familyId: familyId, + deviceId: 'device-1', + issuedAt: at, + idleExpiresAt: at.add(const Duration(hours: 2)), + absoluteExpiresAt: absolute ?? at.add(const Duration(hours: 8)), + ); +} + +OrmRefreshTokenStore _store(Session s) => + OrmRefreshTokenStore(session: () => s); + +void main() { + endpointTests(); + withServerpod('Dado o store do refresh token do ACS', ( + sessionBuilder, + endpoints, + ) { + late Session session; + late OrmRefreshTokenStore store; + + setUp(() async { + session = sessionBuilder.build(); + await _seed(session); + store = _store(session); + }); + + test( + 'insert + findByHash devolvem os mesmos campos e a linha guarda só o hash', + () async { + final r = _record(_id(1)); + expect(await store.insert(r, _sha('token-em-claro')), isTrue); + + final lido = await store.findByHash(_sha('token-em-claro')); + expect(lido, isNotNull); + expect(lido!.id, r.id); + expect(lido.userId, r.userId); + expect(lido.familyId, r.familyId); + expect(lido.deviceId, r.deviceId); + expect(lido.issuedAt, r.issuedAt); + expect(lido.idleExpiresAt, r.idleExpiresAt); + expect(lido.absoluteExpiresAt, r.absoluteExpiresAt); + expect(lido.rotatedAt, isNull); + expect(lido.revokedAt, isNull); + + final linhas = await AcsRefreshToken.db.find(session); + expect(linhas, hasLength(1)); + expect(linhas.single.tokenHash, _sha('token-em-claro')); + expect(linhas.single.tokenHash, isNot('token-em-claro')); + expect(await store.findByHash(_sha('outro')), isNull); + }, + ); + + test( + 'markRotated em linha vigente devolve true, e a segunda vez false', + () async { + await store.insert(_record(_id(1)), _sha('t1')); + expect( + await store.markRotated(_id(1), DateTime.utc(2026, 10, 3, 13)), + isTrue, + ); + expect( + await store.markRotated(_id(1), DateTime.utc(2026, 10, 3, 13)), + isFalse, + ); + expect( + (await store.findByHash(_sha('t1')))!.rotatedAt, + DateTime.utc(2026, 10, 3, 13), + ); + }, + ); + + test('markRotated em linha revogada devolve false', () async { + await store.insert(_record(_id(1)), _sha('t1')); + await store.revokeFamily(_familiaA, DateTime.utc(2026, 10, 3, 13)); + expect( + await store.markRotated(_id(1), DateTime.utc(2026, 10, 3, 14)), + isFalse, + ); + }); + + test('revokeFamily revoga a família inteira e só ela', () async { + await store.insert(_record(_id(1)), _sha('a1')); + await store.insert(_record(_id(2)), _sha('a2')); + await store.insert(_record(_id(3), familyId: _familiaB), _sha('b1')); + final at = DateTime.utc(2026, 10, 3, 13); + await store.revokeFamily(_familiaA, at); + + expect((await store.findByHash(_sha('a1')))!.revokedAt, at); + expect((await store.findByHash(_sha('a2')))!.revokedAt, at); + expect((await store.findByHash(_sha('b1')))!.revokedAt, isNull); + }); + + test( + 'insert em família com token revogado devolve false e não grava nada', + () async { + await store.insert(_record(_id(1)), _sha('a1')); + await store.revokeFamily(_familiaA, DateTime.utc(2026, 10, 3, 13)); + + expect(await store.insert(_record(_id(2)), _sha('a2')), isFalse); + expect(await store.findByHash(_sha('a2')), isNull); + expect(await AcsRefreshToken.db.find(session), hasLength(1)); + }, + ); + + test('insert em família vigente (ou nova) devolve true', () async { + expect(await store.insert(_record(_id(1)), _sha('a1')), isTrue); + expect(await store.insert(_record(_id(2)), _sha('a2')), isTrue); + // Revogar outra família não impede esta. + await store.insert(_record(_id(3), familyId: _familiaB), _sha('b1')); + await store.revokeFamily(_familiaB, DateTime.utc(2026, 10, 3, 13)); + expect(await store.insert(_record(_id(4)), _sha('a3')), isTrue); + expect(await AcsRefreshToken.db.find(session), hasLength(4)); + }); + + test('findAccount devolve active e microAreaId reais', () async { + final ativo = await store.findAccount(_acsId); + expect(ativo!.active, isTrue); + expect(ativo.microAreaId, _microAreaId); + + final inativo = await store.findAccount(_outroAcsId); + expect(inativo!.active, isFalse); + expect(inativo.microAreaId, _microAreaId); + }); + + test( + 'findAccount devolve null para quem não é ACS ou não existe', + () async { + expect(await store.findAccount(_pacienteId), isNull); + expect(await store.findAccount(_id(999)), isNull); + }, + ); + + test( + 'deleteExpiredFor só apaga linhas do próprio usuário vencidas', + () async { + final base = DateTime.utc(2026, 10, 3, 12); + await store.insert( + _record(_id(1), absolute: base.subtract(const Duration(hours: 1))), + _sha('vencido'), + ); + await store.insert( + _record(_id(2), absolute: base.add(const Duration(hours: 1))), + _sha('vigente'), + ); + await store.insert( + _record( + _id(3), + userId: _outroAcsId, + familyId: _familiaB, + absolute: base.subtract(const Duration(hours: 1)), + ), + _sha('vencido-de-outro'), + ); + + await store.deleteExpiredFor(_acsId, base); + + expect(await store.findByHash(_sha('vencido')), isNull); + expect(await store.findByHash(_sha('vigente')), isNotNull); + expect(await store.findByHash(_sha('vencido-de-outro')), isNotNull); + }, + ); + }); + + // Corridas precisam de conexões independentes: sem rollback automático cada + // `Session` usa a sua. A limpeza é manual, no `finally`. + withServerpod( + 'Dado o store do refresh token, sem rollback (corridas)', + ( + sessionBuilder, + endpoints, + ) { + Future limpar() async { + final s = sessionBuilder.build(); + final a = UuidValue.fromString(_acsId); + final b = UuidValue.fromString(_outroAcsId); + final p = UuidValue.fromString(_pacienteId); + await AcsRefreshToken.db.deleteWhere( + s, + where: (t) => t.userId.equals(a) | t.userId.equals(b), + ); + await Acs.db.deleteWhere( + s, + where: (t) => t.id.equals(a) | t.id.equals(b), + ); + await User.db.deleteWhere( + s, + where: (t) => t.id.equals(a) | t.id.equals(b) | t.id.equals(p), + ); + await MicroArea.db.deleteWhere( + s, + where: (t) => t.id.equals(UuidValue.fromString(_microAreaId)), + ); + await Ubs.db.deleteWhere( + s, + where: (t) => t.id.equals(UuidValue.fromString(_ubsId)), + ); + } + + test('markRotated em paralelo: exatamente um true', () async { + await limpar(); + try { + await _seed(sessionBuilder.build()); + await _store( + sessionBuilder.build(), + ).insert(_record(_id(1)), _sha('t1')); + final resultados = await Future.wait([ + for (var i = 0; i < 6; i++) + _store( + sessionBuilder.build(), + ).markRotated(_id(1), DateTime.utc(2026, 10, 3, 13)), + ]); + expect(resultados.where((r) => r), hasLength(1)); + } finally { + await limpar(); + } + }); + + test( + 'insert concorrente com revokeFamily nunca deixa filho vigente após a revogação', + () async { + await limpar(); + try { + await _seed(sessionBuilder.build()); + for (var i = 0; i < 8; i++) { + final familia = _id(0x100 + i); + await _store(sessionBuilder.build()).insert( + _record(_id(0x200 + i * 10), familyId: familia), + _sha('pai$i'), + ); + await Future.wait([ + for (var k = 1; k <= 3; k++) + _store(sessionBuilder.build()).insert( + _record(_id(0x200 + i * 10 + k), familyId: familia), + _sha('f$i-$k'), + ), + _store( + sessionBuilder.build(), + ).revokeFamily(familia, DateTime.utc(2026, 10, 3, 13)), + ]); + // A revogação já terminou: nenhuma linha da família pode estar vigente, + // e nenhum insert posterior pode passar. + final vigentes = await AcsRefreshToken.db.find( + sessionBuilder.build(), + where: (t) => + t.familyId.equals(UuidValue.fromString(familia)) & + t.revokedAt.equals(null), + ); + expect(vigentes, isEmpty, reason: 'iteração $i'); + expect( + await _store(sessionBuilder.build()).insert( + _record(_id(0x200 + i * 10 + 9), familyId: familia), + _sha('tarde$i'), + ), + isFalse, + ); + } + } finally { + await limpar(); + } + }, + ); + }, + rollbackDatabase: RollbackDatabase.disabled, + ); +} + +const _matricula = 'ACS-REFRESH-001'; +const _senha = 'senha-sintetica-de-teste'; +const _aparelho = 'aparelho-refresh-1'; +const _outraMicroArea = '00000000-0000-4000-8000-0000000000a6'; + +Uint8List _deBase32(String texto) { + const alfabeto = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + var bits = 0; + var valor = 0; + final saida = []; + for (final c in texto.split('')) { + valor = (valor << 5) | alfabeto.indexOf(c); + bits += 5; + if (bits >= 8) { + saida.add((valor >> (bits - 8)) & 0xff); + bits -= 8; + } + } + return Uint8List.fromList(saida); +} + +/// Endpoints `auth.loginInstitutional`, `refreshSession` e `logout` contra +/// Postgres real. O relógio do endpoint não é injetável: o envelhecimento do +/// token é feito reescrevendo `rotatedAt` pelo ORM. +void endpointTests() { + withServerpod('Dado o refresh token do ACS nos endpoints de auth', ( + sessionBuilder, + endpoints, + ) { + late Session session; + late Uint8List segredo; + var passo = 0; + + setUp(() async { + session = sessionBuilder.build(); + await _seed(session); + await AlertRuntimeHarness.store(session).saveCredential( + _acsId, + await AlertRuntimeHarness.hasher.derive(_senha), + DateTime.now().toUtc(), + ); + final inicio = await endpoints.auth.beginTotpEnrollment( + sessionBuilder, + matricula: _matricula, + password: _senha, + ); + segredo = _deBase32(inicio.secretBase32); + final agora = DateTime.now().toUtc(); + await endpoints.auth.confirmTotpEnrollment( + sessionBuilder, + matricula: _matricula, + password: _senha, + code: Totp.code(segredo, agora), + ); + passo = 1; + }); + + Future entrar() { + final codigo = Totp.code( + segredo, + DateTime.now().toUtc().add(Duration(seconds: Totp.period * passo++)), + ); + return endpoints.auth.loginInstitutional( + sessionBuilder, + matricula: _matricula, + password: _senha, + deviceId: _aparelho, + totpCode: codigo, + ); + } + + test('o login com MFA devolve refresh token e o device_id do JWT', () async { + final login = await entrar(); + expect(login.refreshToken, isNotEmpty); + final user = AlertRuntimeHarness.verify(login.accessToken); + expect(user?.deviceId, _aparelho); + }); + + test('refreshSession troca o token sem senha nem TOTP', () async { + final login = await entrar(); + final renovado = await endpoints.auth.refreshSession( + sessionBuilder, + refreshToken: login.refreshToken!, + deviceId: _aparelho, + ); + final user = AlertRuntimeHarness.verify(renovado.accessToken); + expect(user, isNotNull); + expect(user!.id, _acsId); + expect(user.microAreaId, _microAreaId); + expect(renovado.refreshToken, isNotEmpty); + expect(renovado.refreshToken, isNot(login.refreshToken)); + }); + + test('reuso do token antigo além da tolerância revoga a família', () async { + final login = await entrar(); + final filho = await endpoints.auth.refreshSession( + sessionBuilder, + refreshToken: login.refreshToken!, + deviceId: _aparelho, + ); + // Envelhece a rotação do pai: o relógio do endpoint não é injetável. + final hash = sha256.convert(utf8.encode(login.refreshToken!)).toString(); + final pai = (await AcsRefreshToken.db.findFirstRow( + session, + where: (t) => t.tokenHash.equals(hash), + ))!; + pai.rotatedAt = DateTime.now().toUtc().subtract( + const Duration(minutes: 5), + ); + await AcsRefreshToken.db.updateRow(session, pai); + + await expectLater( + endpoints.auth.refreshSession( + sessionBuilder, + refreshToken: login.refreshToken!, + deviceId: _aparelho, + ), + throwsA(isA()), + ); + await expectLater( + endpoints.auth.refreshSession( + sessionBuilder, + refreshToken: filho.refreshToken!, + deviceId: _aparelho, + ), + throwsA(isA()), + ); + }); + + test('a microárea nova do banco chega ao JWT seguinte', () async { + final login = await entrar(); + await MicroArea.db.insertRow( + session, + MicroArea( + id: UuidValue.fromString(_outraMicroArea), + name: 'Outra microárea', + ubsId: UuidValue.fromString(_ubsId), + geoJsonBoundary: '{}', + ), + ); + final u = (await User.db.findById(session, UuidValue.fromString(_acsId)))!; + u.microAreaId = UuidValue.fromString(_outraMicroArea); + await User.db.updateRow(session, u); + + final renovado = await endpoints.auth.refreshSession( + sessionBuilder, + refreshToken: login.refreshToken!, + deviceId: _aparelho, + ); + expect( + AlertRuntimeHarness.verify(renovado.accessToken)?.microAreaId, + _outraMicroArea, + ); + }); + + test('ACS desativado recebe SessionExpiredException', () async { + final login = await entrar(); + final acs = (await Acs.db.findById(session, UuidValue.fromString(_acsId)))!; + acs.active = false; + await Acs.db.updateRow(session, acs); + await expectLater( + endpoints.auth.refreshSession( + sessionBuilder, + refreshToken: login.refreshToken!, + deviceId: _aparelho, + ), + throwsA(isA()), + ); + // A recusa revogou a família: reativar o ACS não reabre o token. + acs.active = true; + await Acs.db.updateRow(session, acs); + await expectLater( + endpoints.auth.refreshSession( + sessionBuilder, + refreshToken: login.refreshToken!, + deviceId: _aparelho, + ), + throwsA(isA()), + ); + }); + + test('login sem deviceId (ausente ou em branco) não emite refresh token', + () async { + for (final id in [null, '', ' ']) { + // O passo do TOTP só avança: zera o último usado para repetir o login. + final cred = (await UserCredential.db.findFirstRow( + session, + where: (t) => t.userId.equals(UuidValue.fromString(_acsId)), + ))!; + cred.totpLastStep = null; + await UserCredential.db.updateRow(session, cred); + final codigo = Totp.code(segredo, DateTime.now().toUtc()); + final login = await endpoints.auth.loginInstitutional( + sessionBuilder, + matricula: _matricula, + password: _senha, + deviceId: id, + totpCode: codigo, + ); + expect(login.accessToken, isNotEmpty, reason: 'deviceId=$id'); + expect(login.refreshToken, isNull, reason: 'deviceId=$id'); + } + }); + + test('refreshSession de outro aparelho é recusado e revoga a família', + () async { + final login = await entrar(); + final filho = await endpoints.auth.refreshSession( + sessionBuilder, + refreshToken: login.refreshToken!, + deviceId: _aparelho, + ); + await expectLater( + endpoints.auth.refreshSession( + sessionBuilder, + refreshToken: filho.refreshToken!, + deviceId: 'aparelho-do-ladrao', + ), + throwsA(isA()), + ); + await expectLater( + endpoints.auth.refreshSession( + sessionBuilder, + refreshToken: filho.refreshToken!, + deviceId: _aparelho, + ), + throwsA(isA()), + ); + }); + + test('logout revoga o token; token desconhecido não lança', () async { + final login = await entrar(); + await endpoints.auth.logout( + sessionBuilder, + refreshToken: login.refreshToken!, + ); + await expectLater( + endpoints.auth.refreshSession( + sessionBuilder, + refreshToken: login.refreshToken!, + deviceId: _aparelho, + ), + throwsA(isA()), + ); + await endpoints.auth.logout(sessionBuilder, refreshToken: 'inexistente'); + }); + }); +} diff --git a/backend/sinalacs_server/test/integration/retention_expurge_test.dart b/backend/sinalacs_server/test/integration/retention_expurge_test.dart new file mode 100644 index 00000000..2c095dff --- /dev/null +++ b/backend/sinalacs_server/test/integration/retention_expurge_test.dart @@ -0,0 +1,336 @@ +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/retention/expurge_service.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_retention_store.dart'; +import 'package:test/test.dart'; + +import '../support/health_data_fixtures.dart'; +import 'test_tools/serverpod_test_tools.dart'; + +/// Prova, contra Postgres real, o expurgo LGPD-RF07 (decisões B1 + B3): +/// +/// 1. linhas vencidas saem (`alerts`, `visits`, `triage_sessions`) e as +/// recentes ficam; +/// 2. os dependentes de `alerts` (`alert_deliveries`, +/// `alert_idempotency_keys`, `alert_outbox`) saem junto, antes do pai — +/// as FKs são ON DELETE NO ACTION; +/// 3. as contagens voltam corretas, apuradas ANTES do DELETE (B3). +/// +/// O corte de `visits` usa COALESCE("completedAt", "scheduledAt"): a visita +/// antiga sem `completedAt` é alcançada pelo `scheduledAt` (NOT NULL) — sem o +/// fallback, ela escaparia do expurgo para sempre. +/// +/// Dados sintéticos apenas (LGPD). +const _ubsId = '00000000-0000-4000-8000-0000000000c1'; +const _patientId = '00000000-0000-4000-8000-0000000000c2'; +const _acsId = '00000000-0000-4000-8000-0000000000c3'; + +final _now = DateTime.utc(2026, 10, 7, 3, 30); +final _cutoffAlerts = _now.subtract(const Duration(days: 730)); +final _cutoffVisits = _now.subtract(const Duration(days: 1825)); +final _cutoffTriage = _now.subtract(const Duration(days: 1825)); + +RetentionCutoffs get _cutoffs => RetentionCutoffs( + alerts: _cutoffAlerts, + visits: _cutoffVisits, + triageSessions: _cutoffTriage, + ); + +Future _seed(Session session) async { + await Ubs.db.insertRow( + session, + Ubs( + id: UuidValue.fromString(_ubsId), + name: 'UBS Retenção', + address: 'Endereço sintético', + city: 'São Paulo', + state: 'SP', + ), + ); + await User.db.insertRow( + session, + User( + id: UuidValue.fromString(_patientId), + cpfHash: 'retencao-paciente', + name: 'Paciente Retenção', + birthDate: DateTime.utc(1990, 1, 1), + role: UserRole.patient, + createdAt: _now, + updatedAt: _now, + ), + ); + await User.db.insertRow( + session, + User( + id: UuidValue.fromString(_acsId), + cpfHash: 'retencao-acs', + name: 'ACS Retenção', + birthDate: DateTime.utc(1985, 1, 1), + role: UserRole.acs, + createdAt: _now, + updatedAt: _now, + ), + ); + await Patient.db.insertRow( + session, + await encryptedPatient( + id: _patientId, + emergencyContact: 'Contato retenção', + isChronic: false, + ), + ); + await Acs.db.insertRow( + session, + Acs( + id: UuidValue.fromString(_acsId), + enrollmentId: 'RET-001', + ubsId: UuidValue.fromString(_ubsId), + active: true, + ), + ); + + // Alerta vencido + os três dependentes; um alerta recente sem dependentes. + final alertaVencido = await Alert.db.insertRow( + session, + Alert( + patientId: UuidValue.fromString(_patientId), + triggeredAt: _cutoffAlerts.subtract(const Duration(days: 1)), + riskLevel: RiskLevel.red, + locationHash: 'hash-vencido', + status: AlertStatus.acknowledged, + mqttTopic: 'sinalacs/v1/alerts/retencao', + deviceId: 'device-retencao', + retryCount: 0, + version: 0, + ), + ); + final alertaVencidoId = alertaVencido.id!; + await AlertDeliveryRecord.db.insertRow( + session, + AlertDeliveryRecord( + alertId: alertaVencidoId, + acsId: UuidValue.fromString(_acsId), + acknowledgedAt: _now, + ), + ); + await AlertIdempotencyKey.db.insertRow( + session, + AlertIdempotencyKey( + key: 'chave-vencida', + alertId: alertaVencidoId, + locationHash: 'hash-vencido', + createdAt: _now, + ), + ); + await AlertOutboxEntry.db.insertRow( + session, + AlertOutboxEntry( + alertId: alertaVencidoId, + topic: 'sinalacs/v1/alerts/retencao', + payload: '{}', + createdAt: _now, + attempts: 1, + nextAttemptAt: _now, + ), + ); + await Alert.db.insertRow( + session, + Alert( + patientId: UuidValue.fromString(_patientId), + triggeredAt: _now, + riskLevel: RiskLevel.yellow, + locationHash: 'hash-recente', + status: AlertStatus.pending, + mqttTopic: 'sinalacs/v1/alerts/retencao', + deviceId: 'device-retencao', + retryCount: 0, + version: 0, + ), + ); + + // Visita vencida SEM completedAt (prova o fallback para scheduledAt) e uma + // visita recente. + await Visit.db.insertRow( + session, + Visit( + patientId: UuidValue.fromString(_patientId), + scheduledAt: _cutoffVisits.subtract(const Duration(days: 10)), + status: 'agendada', + riskLevelBefore: RiskLevel.green, + syncStatus: SyncStatus.synced, + localId: UuidValue.fromString('00000000-0000-4000-8000-0000000000d1'), + version: 1, + ), + ); + await Visit.db.insertRow( + session, + Visit( + patientId: UuidValue.fromString(_patientId), + scheduledAt: _now, + completedAt: _now, + status: 'realizada', + riskLevelBefore: RiskLevel.green, + syncStatus: SyncStatus.synced, + localId: UuidValue.fromString('00000000-0000-4000-8000-0000000000d2'), + version: 1, + ), + ); + + // Triagem vencida e triagem recente. + await TriageSession.db.insertRow( + session, + TriageSession( + patientId: UuidValue.fromString(_patientId), + resultRisk: RiskLevel.green, + resultDisplay: 'Verde', + createdAt: _cutoffTriage.subtract(const Duration(days: 1)), + deviceId: 'device-retencao', + ), + ); + await TriageSession.db.insertRow( + session, + TriageSession( + patientId: UuidValue.fromString(_patientId), + resultRisk: RiskLevel.yellow, + resultDisplay: 'Amarelo', + createdAt: _now, + deviceId: 'device-retencao', + ), + ); +} + +void main() { + withServerpod('Expurgo LGPD-RF07 (B1 + contadores B3)', (sessionBuilder, _) { + test('remove só o vencido — com dependentes — e conta antes de apagar', + () async { + final session = sessionBuilder.build(); + await _seed(session); + + final store = OrmRetentionStore(session: () => session); + final counts = await store.purgeDue(_cutoffs); + + // B3: contagens apuradas antes do DELETE, incluindo o desdobramento + // por risco. + expect(counts.alerts, 1); + expect(counts.alertsByRisk, {'red': 1}); + expect(counts.alertDeliveries, 1); + expect(counts.alertIdempotencyKeys, 1); + expect(counts.alertOutbox, 1); + expect(counts.visits, 1); + expect(counts.triageSessions, 1); + + // B1: o vencido saiu; o recente ficou. + expect(await Alert.db.count(session), 1); + expect(await AlertDeliveryRecord.db.count(session), 0); + expect(await AlertIdempotencyKey.db.count(session), 0); + expect(await AlertOutboxEntry.db.count(session), 0); + expect(await Visit.db.count(session), 1); + expect(await TriageSession.db.count(session), 1); + + // A visita que ficou é a recente. + final visita = await Visit.db.findFirstRow(session); + expect(visita!.localId.uuid, '00000000-0000-4000-8000-0000000000d2'); + }); + + test('o corte sem nada vencido devolve contagens zeradas e não apaga nada', + () async { + final session = sessionBuilder.build(); + await _seed(session); + + final futuro = RetentionCutoffs( + alerts: _now.subtract(const Duration(days: 36500)), + visits: _now.subtract(const Duration(days: 36500)), + triageSessions: _now.subtract(const Duration(days: 36500)), + ); + final counts = await OrmRetentionStore(session: () => session) + .purgeDue(futuro); + + expect(counts.alerts, 0); + expect(counts.visits, 0); + expect(counts.triageSessions, 0); + expect(await Alert.db.count(session), 2); + expect(await Visit.db.count(session), 2); + expect(await TriageSession.db.count(session), 2); + }); + + test('D7: vermelho pending vencido sobrevive com os filhos; amarelo pending sai', + () async { + final session = sessionBuilder.build(); + await _seed(session); + final antigo = _cutoffAlerts.subtract(const Duration(days: 30)); + final vermelho = await Alert.db.insertRow( + session, + Alert( + patientId: UuidValue.fromString(_patientId), + triggeredAt: antigo, + riskLevel: RiskLevel.red, + locationHash: 'hash-retido', + status: AlertStatus.pending, + mqttTopic: 'sinalacs/v1/alerts/retencao', + deviceId: 'device-retencao', + retryCount: 0, + version: 0, + ), + ); + await AlertDeliveryRecord.db.insertRow( + session, + AlertDeliveryRecord( + alertId: vermelho.id!, + acsId: UuidValue.fromString(_acsId), + acknowledgedAt: _now, + ), + ); + await AlertIdempotencyKey.db.insertRow( + session, + AlertIdempotencyKey( + key: 'chave-retida', + alertId: vermelho.id!, + locationHash: 'hash-retido', + createdAt: _now, + ), + ); + await AlertOutboxEntry.db.insertRow( + session, + AlertOutboxEntry( + alertId: vermelho.id!, + topic: 'sinalacs/v1/alerts/retencao', + payload: '{}', + createdAt: _now, + attempts: 1, + nextAttemptAt: _now, + ), + ); + final amarelo = await Alert.db.insertRow( + session, + Alert( + patientId: UuidValue.fromString(_patientId), + triggeredAt: antigo, + riskLevel: RiskLevel.yellow, + locationHash: 'hash-amarelo', + status: AlertStatus.pending, + mqttTopic: 'sinalacs/v1/alerts/retencao', + deviceId: 'device-retencao', + retryCount: 0, + version: 0, + ), + ); + + final counts = await OrmRetentionStore(session: () => session) + .purgeDue(_cutoffs); + + // Saíram o vermelho acknowledged (do seed) e o amarelo pending; o + // vermelho pending ficou e é contado como retido. + expect(counts.alerts, 2); + expect(counts.alertsByRisk, {'red': 1, 'yellow': 1}); + expect(counts.skippedPendingRed, 1); + expect(counts.alertDeliveries, 1, reason: 'só o do alerta apagado'); + expect(counts.alertIdempotencyKeys, 1); + expect(counts.alertOutbox, 1); + expect(await Alert.db.findById(session, vermelho.id!), isNotNull); + expect(await Alert.db.findById(session, amarelo.id!), isNull); + expect(await AlertDeliveryRecord.db.count(session), 1); + expect(await AlertIdempotencyKey.db.count(session), 1); + expect(await AlertOutboxEntry.db.count(session), 1); + }); + }); +} diff --git a/backend/sinalacs_server/test/integration/staff_account_schema_test.dart b/backend/sinalacs_server/test/integration/staff_account_schema_test.dart new file mode 100644 index 00000000..849dfb09 --- /dev/null +++ b/backend/sinalacs_server/test/integration/staff_account_schema_test.dart @@ -0,0 +1,65 @@ +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:test/test.dart'; + +import 'test_tools/serverpod_test_tools.dart'; + +/// Esquema de `staff_accounts` contra Postgres real: a matrícula de staff é +/// única, e é o índice único (não outro erro) que barra a segunda linha. +Future _insertStaffUser( + Session session, { + required UserRole role, + required String cpfHash, +}) async { + final now = DateTime.now().toUtc(); + final user = await User.db.insertRow( + session, + User( + cpfHash: cpfHash, + name: 'Staff sintético', + birthDate: DateTime.utc(1980), + role: role, + createdAt: now, + updatedAt: now, + ), + ); + return user.id!; +} + +void main() { + withServerpod('Dado o esquema de staff_accounts', (sessionBuilder, endpoints) { + test('matrícula de staff é única', () async { + final session = sessionBuilder.build(); + final primeiro = await _insertStaffUser( + session, + role: UserRole.admin, + cpfHash: 'staff-schema-1', + ); + final segundo = await _insertStaffUser( + session, + role: UserRole.admin, + cpfHash: 'staff-schema-2', + ); + await StaffAccount.db.insertRow( + session, + StaffAccount(id: primeiro, enrollmentId: 'ADM-T1', active: true), + ); + + await expectLater( + StaffAccount.db.insertRow( + session, + StaffAccount(id: segundo, enrollmentId: 'ADM-T1', active: true), + ), + throwsA( + // 23505 = unique_violation, e a mensagem nomeia o índice: prova que + // foi a unicidade da matrícula, e não outro erro, que barrou. + isA().having( + (e) => e.toString(), + 'mensagem', + allOf(contains('23505'), contains('staff_accounts_enrollment_id_key')), + ), + ), + ); + }); + }); +} diff --git a/backend/sinalacs_server/test/integration/staff_activation_store_test.dart b/backend/sinalacs_server/test/integration/staff_activation_store_test.dart new file mode 100644 index 00000000..7951ca80 --- /dev/null +++ b/backend/sinalacs_server/test/integration/staff_activation_store_test.dart @@ -0,0 +1,79 @@ +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_acs_credential_store.dart'; +import 'package:test/test.dart'; + +import 'test_tools/serverpod_test_tools.dart'; + +/// Código de ativação do staff (#48) contra Postgres real: o store grava, +/// lê, substitui e apaga o código sem tocar nas outras colunas da conta. +/// +/// Dados sintéticos; ids próprios, distintos das outras suítes. +const _staffId = '00000000-0000-4000-8000-0000000000b1'; + +Future _seed(Session session) async { + final now = DateTime.now().toUtc(); + await User.db.insertRow( + session, + User( + id: UuidValue.fromString(_staffId), + cpfHash: 'development-staff-activation', + name: 'Administrador sintético', + birthDate: DateTime.utc(1980), + role: UserRole.admin, + microAreaId: null, + createdAt: now, + updatedAt: now, + ), + ); + await StaffAccount.db.insertRow( + session, + StaffAccount( + id: UuidValue.fromString(_staffId), + enrollmentId: 'ADM-ACT-001', + active: true, + ), + ); +} + +void main() { + withServerpod('Dado o código de ativação do staff (#48)', (sessionBuilder, endpoints) { + setUp(() async => _seed(sessionBuilder.build())); + + test('issue grava; find devolve; issue de novo substitui; clear apaga', () async { + final session = sessionBuilder.build(); + final store = OrmAcsCredentialStore(session: () => session, staff: true); + final t0 = DateTime.utc(2026, 10, 7, 12); + + expect(await store.find(_staffId), isNull); + + await store.issue( + _staffId, + codeHash: 'h1', + expiresAt: t0.add(const Duration(hours: 1)), + issuedBy: 'operador', + at: t0, + ); + expect((await store.find(_staffId))?.codeHash, 'h1'); + + await store.issue( + _staffId, + codeHash: 'h2', + expiresAt: t0.add(const Duration(hours: 2)), + issuedBy: 'operador-2', + at: t0, + ); + final vigente = await store.find(_staffId); + expect(vigente?.codeHash, 'h2'); + expect(vigente?.expiresAt, t0.add(const Duration(hours: 2))); + + await store.clear(_staffId); + expect(await store.find(_staffId), isNull); + + // `clear` apaga hash e validade, mas mantém o último registro de emissão. + final conta = await StaffAccount.db.findById(session, UuidValue.fromString(_staffId)); + expect(conta?.activationCodeIssuedBy, 'operador-2'); + expect(conta?.active, isTrue); + }); + }); +} diff --git a/backend/sinalacs_server/test/integration/staff_login_test.dart b/backend/sinalacs_server/test/integration/staff_login_test.dart new file mode 100644 index 00000000..2642c913 --- /dev/null +++ b/backend/sinalacs_server/test/integration/staff_login_test.dart @@ -0,0 +1,686 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/audit/audit_trail.dart'; +import 'package:sinalacs_server/src/application/auth/institutional_auth_service.dart'; +import 'package:sinalacs_server/src/application/auth/staff_activation_code.dart'; +import 'package:sinalacs_server/src/application/auth/totp.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/infrastructure/crypto/health_cipher_totp_vault.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_acs_credential_store.dart'; +import 'package:sinalacs_server/src/runtime/alert_runtime.dart'; +import 'package:test/test.dart'; + +import 'test_tools/runtime_harness.dart'; +import 'test_tools/serverpod_test_tools.dart'; + +/// Login institucional do staff do backoffice (#39) contra Postgres real: +/// prova a travessia `staff_accounts.enrollmentId → users.role → +/// user_credentials` do `OrmAcsCredentialStore(staff: true)` e que as duas +/// tabelas de matrícula não se cruzam — matrícula de ACS não existe para o +/// login do staff, e matrícula de staff não existe para o login do ACS. +/// +/// Dados sintéticos; ids próprios, distintos das outras suítes. +const _adminId = '00000000-0000-4000-8000-0000000000a1'; +const _adminMatricula = 'ADM-INT-001'; +const _acsId = '00000000-0000-4000-8000-0000000000a2'; +const _acsMatricula = 'ACS-001'; +const _microAreaId = '00000000-0000-4000-8000-0000000000a3'; +const _ubsId = '00000000-0000-4000-8000-0000000000a4'; +const _codigoAtivacao = 'ABCD-EFGH-JKLM-NPQR-STUV-WXYZ-23'; +const _senha = 'senha-sintetica-de-teste'; +const _mensagemGenerica = 'Matrícula ou senha inválidos.'; + +/// Segredo TOTP sintético, gravado já ativo (como se a ativação tivesse +/// acontecido): o caso aqui é o login, não a ativação. +final _segredo = Uint8List.fromList(List.generate(20, (i) => 40 + i)); + +Future _seed(Session session) async { + final now = DateTime.now().toUtc(); + + // Administrador: sem microárea, com linha em staff_accounts. + await User.db.insertRow( + session, + User( + id: UuidValue.fromString(_adminId), + cpfHash: 'development-staff-admin', + name: 'Administrador sintético', + birthDate: DateTime.utc(1980), + role: UserRole.admin, + microAreaId: null, + createdAt: now, + updatedAt: now, + ), + ); + await StaffAccount.db.insertRow( + session, + StaffAccount( + id: UuidValue.fromString(_adminId), + enrollmentId: _adminMatricula, + active: true, + ), + ); + await AlertRuntimeHarness.store(session).saveCredential( + _adminId, + await AlertRuntimeHarness.hasher.derive(_senha), + now, + ); + final sealed = await HealthCipherTotpVault( + AlertRuntime.instance.healthDataCipher, + ).seal(_segredo); + final credencial = (await UserCredential.db.findFirstRow( + session, + where: (t) => t.userId.equals(UuidValue.fromString(_adminId)), + ))!; + await UserCredential.db.updateRow( + session, + credencial + ..totpSecretEncrypted = sealed.ciphertextBase64 + ..totpKeyVersion = sealed.keyVersion + ..totpEnabledAt = now + ..totpLastStep = null, + ); + + // ACS com território, mesma senha: a matrícula ACS-001 do login do ACS. + await Ubs.db.insertRow( + session, + Ubs( + id: UuidValue.fromString(_ubsId), + name: 'UBS Staff', + address: 'Endereço local', + city: 'São Paulo', + state: 'SP', + ), + ); + await MicroArea.db.insertRow( + session, + MicroArea( + id: UuidValue.fromString(_microAreaId), + name: 'Microárea Staff', + ubsId: UuidValue.fromString(_ubsId), + geoJsonBoundary: '{}', + ), + ); + await User.db.insertRow( + session, + User( + id: UuidValue.fromString(_acsId), + cpfHash: 'development-staff-acs', + name: 'ACS sintético', + birthDate: DateTime.utc(1980), + role: UserRole.acs, + microAreaId: UuidValue.fromString(_microAreaId), + createdAt: now, + updatedAt: now, + ), + ); + await Acs.db.insertRow( + session, + Acs( + id: UuidValue.fromString(_acsId), + enrollmentId: _acsMatricula, + ubsId: UuidValue.fromString(_ubsId), + active: true, + ), + ); + await AlertRuntimeHarness.store(session).saveCredential( + _acsId, + await AlertRuntimeHarness.hasher.derive(_senha), + now, + ); +} + +class _SemAuditoria extends AuditTrail { + @override + Future record(AuditEvent event) async {} +} + +InstitutionalAuthService _servico(Session session, {required bool staff}) { + final store = OrmAcsCredentialStore(session: () => session, staff: staff); + return InstitutionalAuthService( + store: store, + hasher: AlertRuntimeHarness.hasher, + audit: _SemAuditoria(), + totpStore: store, + vault: HealthCipherTotpVault(AlertRuntime.instance.healthDataCipher), + audience: staff ? CredentialAudience.staff : CredentialAudience.acs, + ); +} + +/// Decodifica base32 (RFC 4648, sem padding) como o app autenticador faz com +/// o segredo devolvido por `beginStaffTotpEnrollment`. +Uint8List _deBase32(String texto) { + const alfabeto = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + var bits = 0; + var valor = 0; + final saida = []; + for (final c in texto.split('')) { + valor = (valor << 5) | alfabeto.indexOf(c); + bits += 5; + if (bits >= 8) { + saida.add((valor >> (bits - 8)) & 0xff); + bits -= 8; + } + } + return Uint8List.fromList(saida); +} + +/// O seed grava o admin com MFA ativa; a ativação pelo endpoint parte de um +/// admin sem MFA, então zera as colunas de TOTP. +Future _semMfa(Session session) async { + final credencial = (await UserCredential.db.findFirstRow( + session, + where: (t) => t.userId.equals(UuidValue.fromString(_adminId)), + ))!; + await UserCredential.db.updateRow( + session, + credencial + ..totpSecretEncrypted = null + ..totpKeyVersion = null + ..totpEnabledAt = null + ..totpLastStep = null, + ); + await OrmAcsCredentialStore(session: () => session, staff: true).issue( + _adminId, + codeHash: StaffActivationCode.hash(_codigoAtivacao), + expiresAt: DateTime.now().toUtc().add(StaffActivationCode.defaultValidity), + issuedBy: 'operador-de-teste', + at: DateTime.now().toUtc(), + ); +} + +Future _credencialAdmin(Session session) async => + (await UserCredential.db.findFirstRow( + session, + where: (t) => t.userId.equals(UuidValue.fromString(_adminId)), + ))!; + +Map _payload(String token) => + jsonDecode( + utf8.decode( + base64Url.decode(base64Url.normalize(token.split('.')[1])), + ), + ) + as Map; + +void main() { + withServerpod('Dado o login institucional do staff do backoffice (#39)', ( + sessionBuilder, + endpoints, + ) { + setUp(() async => _seed(sessionBuilder.build())); + + test( + 'administrador com código válido recebe JWT com role admin e sem microárea', + () async { + final session = sessionBuilder.build(); + final agora = DateTime.now().toUtc(); + final user = await _servico(session, staff: true).login( + matricula: _adminMatricula, + password: _senha, + totpCode: Totp.code(_segredo, agora), + now: agora, + ); + expect(user.id, _adminId); + expect(user.role, UserRole.admin); + expect(user.microAreaId, isNull); + + final token = AlertRuntime.instance.auth.issueToken(user); + final payload = _payload(token); + expect(payload['role'], 'admin'); + expect(payload.containsKey('micro_area_id'), isTrue); + expect(payload['micro_area_id'], isNull); + + final linha = (await UserCredential.db.findFirstRow( + session, + where: (t) => t.userId.equals(UuidValue.fromString(_adminId)), + ))!; + expect(linha.totpLastStep, Totp.stepOf(agora)); + }, + ); + + test('administrador sem código recebe MfaRequiredException', () async { + await expectLater( + _servico( + sessionBuilder.build(), + staff: true, + ).login(matricula: _adminMatricula, password: _senha), + throwsA(isA()), + ); + }); + + test( + 'matrícula de ACS no login do staff é recusada com a mensagem genérica', + () async { + await expectLater( + _servico(sessionBuilder.build(), staff: true).login( + matricula: _acsMatricula, + password: _senha, + totpCode: Totp.code(_segredo, DateTime.now().toUtc()), + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + _mensagemGenerica, + ), + ), + ); + }, + ); + + test( + 'matrícula de staff no login do ACS é recusada com a mensagem genérica', + () async { + await expectLater( + _servico(sessionBuilder.build(), staff: false).login( + matricula: _adminMatricula, + password: _senha, + totpCode: Totp.code(_segredo, DateTime.now().toUtc()), + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + _mensagemGenerica, + ), + ), + ); + }, + ); + + test('usuário admin com linha em acs não entra pelo login do ACS', () async { + // Um admin que (por erro de cadastro) tem matrícula em `acs`: o store do + // ACS só devolve linha de papel `acs`, então ele é "inexistente" ali. + final session = sessionBuilder.build(); + await Acs.db.insertRow( + session, + Acs( + id: UuidValue.fromString(_adminId), + enrollmentId: 'ACS-ADM-001', + ubsId: UuidValue.fromString(_ubsId), + active: true, + ), + ); + final store = OrmAcsCredentialStore(session: () => session); + expect(await store.findByEnrollmentId('ACS-ADM-001'), isNull); + // A matrícula de ACS legítima continua sendo encontrada, com papel acs. + expect( + (await store.findByEnrollmentId(_acsMatricula))?.role, + UserRole.acs, + ); + }); + + test('o store do staff devolve o papel gravado em users', () async { + final record = await OrmAcsCredentialStore( + session: () => sessionBuilder.build(), + staff: true, + ).findByEnrollmentId(_adminMatricula); + expect(record, isNotNull); + expect(record!.role, UserRole.admin); + expect(record.microAreaId, isNull); + expect(record.active, isTrue); + expect(record.totp?.enabled, isTrue); + }); + + test( + 'auth.loginStaff com credenciais e código certos devolve só o JWT de 15 min', + () async { + final agora = DateTime.now().toUtc(); + final result = await endpoints.auth.loginStaff( + sessionBuilder, + matricula: _adminMatricula, + password: _senha, + totpCode: Totp.code(_segredo, agora), + ); + expect(result.tokenType, 'Bearer'); + expect(result.refreshToken, isNull); + expect(result.uploadToken, isNull); + final user = AlertRuntimeHarness.verify(result.accessToken)!; + expect(user.id, _adminId); + expect(user.role, UserRole.admin); + expect(user.microAreaId, isNull); + expect(user.deviceId, InstitutionalAuthService.deviceIdAbsent); + expect( + AlertRuntimeHarness.tokenLifetime(result.accessToken), + const Duration(minutes: 15), + ); + }, + ); + + test( + 'auth.loginStaff grava em audit_logs o recurso staff_session, não session', + () async { + await endpoints.auth.loginStaff( + sessionBuilder, + matricula: _adminMatricula, + password: _senha, + totpCode: Totp.code(_segredo, DateTime.now().toUtc()), + ); + final linhas = await AuditLog.db.find( + sessionBuilder.build(), + where: (t) => + t.userId.equals(UuidValue.fromString(_adminId)) & + t.actionType.equals('login'), + ); + expect(linhas.map((l) => l.result), contains('granted')); + expect( + linhas.map((l) => l.resourceType), + everyElement('staff_session'), + ); + }, + ); + + test('auth.loginStaff sem código recebe MfaRequiredException', () async { + await expectLater( + endpoints.auth.loginStaff( + sessionBuilder, + matricula: _adminMatricula, + password: _senha, + ), + throwsA(isA()), + ); + }); + + test( + 'cinco senhas erradas bloqueiam a conta do staff (reuso do bloqueio)', + () async { + for (var i = 0; i < InstitutionalAuthService.maxFailedAttempts; i++) { + await expectLater( + endpoints.auth.loginStaff( + sessionBuilder, + matricula: _adminMatricula, + password: 'errada', + ), + throwsA(isA()), + ); + } + final credencial = (await UserCredential.db.findFirstRow( + sessionBuilder.build(), + where: (t) => t.userId.equals(UuidValue.fromString(_adminId)), + ))!; + expect( + credencial.failedAttempts, + InstitutionalAuthService.maxFailedAttempts, + ); + expect(credencial.lockedUntil, isNotNull); + // Bloqueada: nem a senha certa com o código certo entra. + await expectLater( + endpoints.auth.loginStaff( + sessionBuilder, + matricula: _adminMatricula, + password: _senha, + totpCode: Totp.code(_segredo, DateTime.now().toUtc()), + ), + throwsA(isA()), + ); + }, + ); + + // Mesma política para as duas audiências (5 falhas), mas o contador é por + // linha de user_credentials: falhas de uma conta não tocam a outra. + Future credencialDe(String userId) async => + (await UserCredential.db.findFirstRow( + sessionBuilder.build(), + where: (t) => t.userId.equals(UuidValue.fromString(userId)), + ))!; + + test( + 'falhas no login do ACS bloqueiam só o ACS, não o staff (contador por conta)', + () async { + for (var i = 0; i < InstitutionalAuthService.maxFailedAttempts; i++) { + await expectLater( + endpoints.auth.loginInstitutional( + sessionBuilder, + matricula: _acsMatricula, + password: 'errada', + ), + throwsA(isA()), + ); + } + // O laço contou de verdade: o ACS está bloqueado. + final acs = await credencialDe(_acsId); + expect(acs.failedAttempts, InstitutionalAuthService.maxFailedAttempts); + expect(acs.lockedUntil, isNotNull); + // O admin não foi tocado. + final admin = await credencialDe(_adminId); + expect(admin.failedAttempts, 0); + expect(admin.lockedUntil, isNull); + }, + ); + + test( + 'falhas no login do staff bloqueiam só o staff, não o ACS (contador por conta)', + () async { + for (var i = 0; i < InstitutionalAuthService.maxFailedAttempts; i++) { + await expectLater( + endpoints.auth.loginStaff( + sessionBuilder, + matricula: _adminMatricula, + password: 'errada', + ), + throwsA(isA()), + ); + } + final admin = await credencialDe(_adminId); + expect( + admin.failedAttempts, + InstitutionalAuthService.maxFailedAttempts, + ); + expect(admin.lockedUntil, isNotNull); + final acs = await credencialDe(_acsId); + expect(acs.failedAttempts, 0); + expect(acs.lockedUntil, isNull); + }, + ); + + test( + 'token de admin é recusado por um caminho só de ACS (patients.listMicroArea)', + () async { + final result = await endpoints.auth.loginStaff( + sessionBuilder, + matricula: _adminMatricula, + password: _senha, + totpCode: Totp.code(_segredo, DateTime.now().toUtc()), + ); + await expectLater( + endpoints.patients.listMicroArea( + sessionBuilder, + accessToken: result.accessToken, + ), + throwsA(isA()), + ); + }, + ); + + group('ativação de MFA do staff pelo endpoint', () { + test('begin com código errado é recusado e não grava segredo', () async { + final session = sessionBuilder.build(); + await _semMfa(session); + + await expectLater( + endpoints.auth.beginStaffTotpEnrollment( + sessionBuilder, + matricula: _adminMatricula, + password: _senha, + activationCode: 'ZZZZ-ZZZZ-ZZZZ', + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Código de ativação inválido ou expirado.', + ), + ), + ); + final cred = await _credencialAdmin(session); + expect(cred.totpSecretEncrypted, isNull); + expect(cred.failedAttempts, 1); + }); + + test('confirm com código certo ativa e o código some do banco', () async { + final session = sessionBuilder.build(); + await _semMfa(session); + final inicio = await endpoints.auth.beginStaffTotpEnrollment( + sessionBuilder, + matricula: _adminMatricula, + password: _senha, + activationCode: _codigoAtivacao, + ); + final store = OrmAcsCredentialStore(session: () => session, staff: true); + expect(await store.find(_adminId), isNotNull, reason: 'begin não consome o código'); + + await endpoints.auth.confirmStaffTotpEnrollment( + sessionBuilder, + matricula: _adminMatricula, + password: _senha, + activationCode: _codigoAtivacao, + code: Totp.code(_deBase32(inicio.secretBase32), DateTime.now().toUtc()), + ); + expect((await _credencialAdmin(session)).totpEnabledAt, isNotNull); + expect(await store.find(_adminId), isNull); + }); + + test('begin devolve segredo e URI otpauth e não ativa a MFA', () async { + final session = sessionBuilder.build(); + await _semMfa(session); + + final inicio = await endpoints.auth.beginStaffTotpEnrollment( + sessionBuilder, + matricula: _adminMatricula, + password: _senha, + activationCode: _codigoAtivacao, + ); + + expect(inicio.secretBase32, isNotEmpty); + expect(inicio.otpauthUri, startsWith('otpauth://totp/')); + final cred = await _credencialAdmin(session); + expect( + cred.totpSecretEncrypted, + isNotNull, + reason: 'segredo pendente gravado', + ); + expect( + cred.totpEnabledAt, + isNull, + reason: 'MFA só vale depois do confirm', + ); + }); + + test( + 'confirm com código certo ativa; depois disso loginStaff exige o código', + () async { + final session = sessionBuilder.build(); + await _semMfa(session); + final inicio = await endpoints.auth.beginStaffTotpEnrollment( + sessionBuilder, + matricula: _adminMatricula, + password: _senha, + activationCode: _codigoAtivacao, + ); + final codigo = Totp.code( + _deBase32(inicio.secretBase32), + DateTime.now().toUtc(), + ); + + await endpoints.auth.confirmStaffTotpEnrollment( + sessionBuilder, + matricula: _adminMatricula, + password: _senha, + activationCode: _codigoAtivacao, + code: codigo, + ); + + expect((await _credencialAdmin(session)).totpEnabledAt, isNotNull); + await expectLater( + endpoints.auth.loginStaff( + sessionBuilder, + matricula: _adminMatricula, + password: _senha, + ), + throwsA(isA()), + ); + }, + ); + + test( + 'confirm com código errado não ativa a MFA e conta uma tentativa', + () async { + final session = sessionBuilder.build(); + await _semMfa(session); + final inicio = await endpoints.auth.beginStaffTotpEnrollment( + sessionBuilder, + matricula: _adminMatricula, + password: _senha, + activationCode: _codigoAtivacao, + ); + // Diferente do código do passo atual; coincidir com um passo vizinho tem chance ~2e-6. + final certo = Totp.code( + _deBase32(inicio.secretBase32), + DateTime.now().toUtc(), + ); + final errado = certo == '000000' ? '111111' : '000000'; + + await expectLater( + endpoints.auth.confirmStaffTotpEnrollment( + sessionBuilder, + matricula: _adminMatricula, + password: _senha, + activationCode: _codigoAtivacao, + code: errado, + ), + throwsA(isA()), + ); + + final cred = await _credencialAdmin(session); + expect(cred.totpEnabledAt, isNull); + expect(cred.failedAttempts, 1); + }, + ); + + test( + 'begin com senha errada é recusado com a mensagem genérica', + () async { + await _semMfa(sessionBuilder.build()); + await expectLater( + endpoints.auth.beginStaffTotpEnrollment( + sessionBuilder, + matricula: _adminMatricula, + password: 'senha-errada', + activationCode: _codigoAtivacao, + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + _mensagemGenerica, + ), + ), + ); + }, + ); + + test( + 'begin com matrícula de ACS é recusado como matrícula inexistente', + () async { + await expectLater( + endpoints.auth.beginStaffTotpEnrollment( + sessionBuilder, + matricula: _acsMatricula, + password: _senha, + activationCode: _codigoAtivacao, + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + _mensagemGenerica, + ), + ), + ); + }, + ); + }); + }); +} diff --git a/backend/sinalacs_server/test/integration/terms_change_notice_endpoint_test.dart b/backend/sinalacs_server/test/integration/terms_change_notice_endpoint_test.dart new file mode 100644 index 00000000..0b72634e --- /dev/null +++ b/backend/sinalacs_server/test/integration/terms_change_notice_endpoint_test.dart @@ -0,0 +1,188 @@ +import 'package:serverpod/serverpod.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_terms_version_store.dart'; +import 'package:sinalacs_server/src/runtime/alert_runtime.dart'; +import 'package:test/test.dart'; + +import '../support/health_data_fixtures.dart'; +import 'test_tools/serverpod_test_tools.dart'; + +/// Agenda de mudança dos termos (#46) com o store ORM real: rascunho, revisão e +/// publicação por `OrmTermsVersionStore`, e o cartão lido pelo endpoint do +/// paciente. O relógio do endpoint é o real, então as datas são relativas a ele. +const _patientId = '00000000-0000-4000-8000-000000000001'; +const _microAreaId = '00000000-0000-4000-8000-000000000003'; +const _ubsId = '00000000-0000-4000-8000-000000000004'; +const _adminId = '00000000-0000-4000-8000-0000000000c1'; + +AppConfig _config() => AppConfig( + mqttBroker: 'localhost:1883', + jwtSecret: 'test-secret', + auditChainSecret: 'test-audit-chain-secret', + healthDataEncryptionKey: AppConfig.developmentHealthDataEncryptionKey, + cpfHashPepper: AppConfig.developmentCpfHashPepper, + smsGateway: 'log', + 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 sintética', + address: 'Endereço local', + city: 'São Paulo', + state: 'SP', + ), + ); + await MicroArea.db.insertRow( + session, + MicroArea( + id: UuidValue.fromString(_microAreaId), + name: 'Microárea sintética', + ubsId: UuidValue.fromString(_ubsId), + geoJsonBoundary: '{}', + ), + ); + final now = DateTime.now().toUtc(); + await User.db.insert(session, [ + User( + id: UuidValue.fromString(_patientId), + cpfHash: 'development-patient', + name: 'Paciente de desenvolvimento', + birthDate: DateTime.utc(1990, 1, 1), + role: UserRole.patient, + microAreaId: UuidValue.fromString(_microAreaId), + createdAt: now, + updatedAt: now, + ), + User( + id: UuidValue.fromString(_adminId), + cpfHash: 'development-admin-termos', + name: 'Admin sintético', + birthDate: DateTime.utc(1980, 1, 1), + role: UserRole.admin, + createdAt: now, + updatedAt: now, + ), + ]); + await Patient.db.insertRow( + session, + await encryptedPatient( + id: _patientId, + emergencyContact: 'Contato de desenvolvimento', + isChronic: false, + chronicConditions: const [], + ), + ); +} + +void main() { + withServerpod('Dada a agenda de termos publicada no Postgres (#46)', (sessionBuilder, endpoints) { + setUp(() => AlertRuntime.instance.overrideConfig(_config())); + tearDown(() => AlertRuntime.instance.overrideConfig(null)); + + late Session session; + late OrmTermsVersionStore store; + late String token; + + Future rascunho(String version, {int vigenciaEmDias = 20, String summary = 'Resumo.'}) => + store.insertDraft( + version: version, + summary: summary, + effectiveFrom: DateTime.now().toUtc().add(Duration(days: vigenciaEmDias)), + createdBy: _adminId, + createdAt: DateTime.now().toUtc(), + ); + + Future publica(String id, {Duration publicadaHa = const Duration(hours: 1)}) async { + final at = DateTime.now().toUtc().subtract(publicadaHa); + expect(await store.markReviewed(id, reviewedBy: _adminId, at: at), isTrue); + expect(await store.markPublished(id, publishedBy: _adminId, at: at), isTrue); + } + + setUp(() async { + session = sessionBuilder.build(); + await _seed(session); + store = OrmTermsVersionStore(session: () => session); + token = (await endpoints.auth.developmentLogin(sessionBuilder, role: 'patient')).accessToken; + }); + + test('rascunho e revisada-não-publicada não aparecem; publicada aparece', () async { + final id = await rascunho('2099.1', summary: 'Novo canal de dúvidas.'); + expect(await endpoints.patients.termsChangeNotice(sessionBuilder, accessToken: token), isNull); + + expect(await store.markReviewed(id, reviewedBy: _adminId, at: DateTime.now().toUtc()), isTrue); + expect(await endpoints.patients.termsChangeNotice(sessionBuilder, accessToken: token), isNull); + + expect(await store.markPublished(id, publishedBy: _adminId, at: DateTime.now().toUtc()), isTrue); + final notice = (await endpoints.patients.termsChangeNotice(sessionBuilder, accessToken: token))!; + expect(notice.version, '2099.1'); + expect(notice.summary, 'Novo canal de dúvidas.'); + expect(notice.effectiveFrom.isAfter(DateTime.now().toUtc()), isTrue); + }); + + test('a segunda publicação, enquanto a primeira não vigora, é a servida', () async { + await publica(await rascunho('2099.1'), publicadaHa: const Duration(hours: 2)); + await publica(await rascunho('2099.2'), publicadaHa: const Duration(hours: 1)); + final notice = (await endpoints.patients.termsChangeNotice(sessionBuilder, accessToken: token))!; + expect(notice.version, '2099.2'); + }); + + test('linha inconsistente (menos de 15 dias) não derruba o cartão: devolve null', () async { + await publica(await rascunho('2099.3', vigenciaEmDias: 3)); + expect(await endpoints.patients.termsChangeNotice(sessionBuilder, accessToken: token), isNull); + }); + + test('vigência já passada não é aviso', () async { + final id = await rascunho('2099.4', vigenciaEmDias: 1); + await publica(id); + await session.db.unsafeQuery( + 'UPDATE terms_versions SET "effectiveFrom" = @e WHERE "id" = @id::uuid', + parameters: QueryParameters.named({ + 'e': DateTime.now().toUtc().subtract(const Duration(hours: 1)), + 'id': id, + }), + ); + expect(await endpoints.patients.termsChangeNotice(sessionBuilder, accessToken: token), isNull); + }); + + test('markPublished sem revisão devolve false e não publica', () async { + final id = await rascunho('2099.5'); + expect(await store.markPublished(id, publishedBy: _adminId, at: DateTime.now().toUtc()), isFalse); + expect((await store.findById(id))!.publishedAt, isNull); + }); + + test('markReviewed duas vezes: a segunda devolve false; depois de publicada também', () async { + final id = await rascunho('2099.6'); + final at = DateTime.now().toUtc(); + expect(await store.markReviewed(id, reviewedBy: _adminId, at: at), isTrue); + expect(await store.markReviewed(id, reviewedBy: _adminId, at: at), isFalse); + expect(await store.markPublished(id, publishedBy: _adminId, at: at), isTrue); + expect(await store.markPublished(id, publishedBy: _adminId, at: at), isFalse); + expect(await store.markReviewed(id, reviewedBy: _adminId, at: at), isFalse); + }); + + test('id que não é UUID devolve null/false em vez de estourar', () async { + expect(await store.findById('nao-e-uuid'), isNull); + expect(await store.markReviewed('nao-e-uuid', reviewedBy: _adminId, at: DateTime.now().toUtc()), isFalse); + expect(await store.markPublished('nao-e-uuid', publishedBy: _adminId, at: DateTime.now().toUtc()), isFalse); + }); + + test('versionExists e list enxergam as linhas, com datas em UTC', () async { + final id = await rascunho('2099.7'); + expect(await store.versionExists('2099.7'), isTrue); + expect(await store.versionExists('2099.8'), isFalse); + final todas = await store.list(); + expect(todas.map((e) => e.id), contains(id)); + expect(todas.every((e) => e.effectiveFrom.isUtc && e.createdAt.isUtc), isTrue); + }); + }); +} 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 1a5f8b67..ac72e1cc 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 @@ -14,26 +14,76 @@ import 'package:serverpod_test/serverpod_test.dart' as _i1; import 'package:serverpod/serverpod.dart' as _i2; import 'dart:async' as _i3; -import 'package:sinalacs_server/src/generated/api/red_alert_result.dart' as _i4; -import 'package:sinalacs_server/src/generated/api/alert_ack_result.dart' as _i5; +import 'package:sinalacs_server/src/generated/api/admin_indicators.dart' as _i4; +import 'package:sinalacs_server/src/generated/api/admin_micro_area.dart' as _i5; +import 'package:sinalacs_server/src/generated/api/admin_alert_page.dart' as _i6; +import 'package:sinalacs_server/src/generated/enums/alert_status.dart' as _i7; +import 'package:sinalacs_server/src/generated/api/admin_alerts_pulse.dart' + as _i8; +import 'package:sinalacs_server/src/generated/api/admin_audit_page.dart' as _i9; +import 'package:sinalacs_server/src/generated/api/admin_audit_filter.dart' + as _i10; +import 'package:sinalacs_server/src/generated/api/admin_audit_chain_status.dart' + as _i11; +import 'package:sinalacs_server/src/generated/api/admin_audit_export.dart' + as _i12; +import 'package:sinalacs_server/src/generated/api/admin_data_subject_request_page.dart' + as _i13; +import 'package:sinalacs_server/src/generated/enums/data_subject_request_status.dart' + as _i14; +import 'package:sinalacs_server/src/generated/api/admin_data_subject_request_detail.dart' + as _i15; +import 'package:sinalacs_server/src/generated/api/admin_acs.dart' as _i16; +import 'package:sinalacs_server/src/generated/api/admin_staff.dart' as _i17; +import 'package:sinalacs_server/src/generated/api/admin_acs_creation_result.dart' + as _i18; +import 'package:sinalacs_server/src/generated/api/admin_password_reset_result.dart' + as _i19; +import 'package:sinalacs_server/src/generated/api/admin_staff_mfa_reset_result.dart' + as _i20; +import 'package:sinalacs_server/src/generated/api/admin_escalation_result.dart' + as _i21; +import 'package:sinalacs_server/src/generated/enums/escalation_reason.dart' + as _i22; +import 'package:sinalacs_server/src/generated/api/notice_history_page.dart' + as _i23; +import 'package:sinalacs_server/src/generated/api/terms_version_entry.dart' + as _i24; +import 'package:sinalacs_server/src/generated/api/red_alert_result.dart' + as _i25; +import 'package:sinalacs_server/src/generated/api/alert_ack_result.dart' + as _i26; import 'package:sinalacs_server/src/generated/api/alert_status_result.dart' - as _i6; + as _i27; import 'package:sinalacs_server/src/generated/api/development_login_result.dart' - as _i7; -import 'package:sinalacs_server/src/generated/api/service_health.dart' as _i8; + as _i28; +import 'package:sinalacs_server/src/generated/api/totp_enrollment_start.dart' + as _i29; +import 'package:sinalacs_server/src/generated/api/service_health.dart' as _i30; +import 'package:sinalacs_server/src/generated/api/notice_send_result.dart' + as _i31; import 'package:sinalacs_server/src/generated/api/enrollment_token_result.dart' - as _i9; + as _i32; import 'package:sinalacs_server/src/generated/api/enrollment_result.dart' - as _i10; + as _i33; import 'package:sinalacs_server/src/generated/api/micro_area_patient.dart' - as _i11; + as _i34; import 'package:sinalacs_server/src/generated/api/patient_data_overview.dart' - as _i12; -import 'package:sinalacs_server/src/generated/api/triage_result.dart' as _i13; + as _i35; +import 'package:sinalacs_server/src/generated/api/patient_consent_record.dart' + as _i36; +import 'package:sinalacs_server/src/generated/enums/consent_purpose.dart' + as _i37; +import 'package:sinalacs_server/src/generated/api/terms_change_notice.dart' + as _i38; +import 'package:sinalacs_server/src/generated/api/patient_data_subject_request_record.dart' + as _i39; +import 'package:sinalacs_server/src/generated/api/triage_result.dart' as _i40; +import 'package:sinalacs_server/src/generated/api/ubs_contact.dart' as _i41; import 'package:sinalacs_server/src/generated/api/visit_sync_result.dart' - as _i14; + as _i42; import 'package:sinalacs_server/src/generated/api/visit_sync_entry.dart' - as _i15; + as _i43; 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'; @@ -148,18 +198,26 @@ void withServerpod( } class TestEndpoints { + late final _AdminEndpoint admin; + late final _AlertsEndpoint alerts; late final _AuthEndpoint auth; + late final _DevicesEndpoint devices; + late final _HealthEndpoint health; + late final _NoticesEndpoint notices; + late final _OnboardingEndpoint onboarding; late final _PatientsEndpoint patients; late final _TriageEndpoint triage; + late final _UbsEndpoint ubs; + late final _VisitsEndpoint visits; } @@ -170,6 +228,10 @@ class _InternalTestEndpoints extends TestEndpoints _i2.SerializationManager serializationManager, _i2.EndpointDispatch endpoints, ) { + admin = _AdminEndpoint( + endpoints, + serializationManager, + ); alerts = _AlertsEndpoint( endpoints, serializationManager, @@ -178,10 +240,18 @@ class _InternalTestEndpoints extends TestEndpoints endpoints, serializationManager, ); + devices = _DevicesEndpoint( + endpoints, + serializationManager, + ); health = _HealthEndpoint( endpoints, serializationManager, ); + notices = _NoticesEndpoint( + endpoints, + serializationManager, + ); onboarding = _OnboardingEndpoint( endpoints, serializationManager, @@ -194,6 +264,10 @@ class _InternalTestEndpoints extends TestEndpoints endpoints, serializationManager, ); + ubs = _UbsEndpoint( + endpoints, + serializationManager, + ); visits = _VisitsEndpoint( endpoints, serializationManager, @@ -201,8 +275,8 @@ class _InternalTestEndpoints extends TestEndpoints } } -class _AlertsEndpoint { - _AlertsEndpoint( +class _AdminEndpoint { + _AdminEndpoint( this._endpointDispatch, this._serializationManager, ); @@ -211,30 +285,22 @@ class _AlertsEndpoint { final _i2.SerializationManager _serializationManager; - _i3.Future<_i4.RedAlertResult> createRedAlert( + _i3.Future<_i4.AdminIndicators> indicators( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, - required String idempotencyKey, - required String locationHash, - String? locationCell, }) async { return _i1.callAwaitableFunctionAndHandleExceptions(() async { var _localUniqueSession = (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( - endpoint: 'alerts', - method: 'createRedAlert', + endpoint: 'admin', + method: 'indicators', ); try { var _localCallContext = await _endpointDispatch.getMethodCallContext( createSessionCallback: (_) => _localUniqueSession, - endpointPath: 'alerts', - methodName: 'createRedAlert', - parameters: _i1.testObjectToJson({ - 'accessToken': accessToken, - 'idempotencyKey': idempotencyKey, - 'locationHash': locationHash, - 'locationCell': locationCell, - }), + endpointPath: 'admin', + methodName: 'indicators', + parameters: _i1.testObjectToJson({'accessToken': accessToken}), serializationManager: _serializationManager, ); var _localReturnValue = @@ -242,7 +308,7 @@ class _AlertsEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i4.RedAlertResult>); + as _i3.Future<_i4.AdminIndicators>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -250,26 +316,22 @@ class _AlertsEndpoint { }); } - _i3.Future<_i5.AlertAckResult> acknowledge( + _i3.Future> microAreas( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, - required String alertId, }) async { return _i1.callAwaitableFunctionAndHandleExceptions(() async { var _localUniqueSession = (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( - endpoint: 'alerts', - method: 'acknowledge', + endpoint: 'admin', + method: 'microAreas', ); try { var _localCallContext = await _endpointDispatch.getMethodCallContext( createSessionCallback: (_) => _localUniqueSession, - endpointPath: 'alerts', - methodName: 'acknowledge', - parameters: _i1.testObjectToJson({ - 'accessToken': accessToken, - 'alertId': alertId, - }), + endpointPath: 'admin', + methodName: 'microAreas', + parameters: _i1.testObjectToJson({'accessToken': accessToken}), serializationManager: _serializationManager, ); var _localReturnValue = @@ -277,7 +339,7 @@ class _AlertsEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i5.AlertAckResult>); + as _i3.Future>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -285,22 +347,32 @@ class _AlertsEndpoint { }); } - _i3.Future<_i6.AlertStatusResult> statusFor( + _i3.Future<_i6.AdminAlertPage> alerts( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, + String? microAreaId, + _i7.AlertStatus? status, + required int limit, + required int offset, }) async { return _i1.callAwaitableFunctionAndHandleExceptions(() async { var _localUniqueSession = (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( - endpoint: 'alerts', - method: 'statusFor', + endpoint: 'admin', + method: 'alerts', ); try { var _localCallContext = await _endpointDispatch.getMethodCallContext( createSessionCallback: (_) => _localUniqueSession, - endpointPath: 'alerts', - methodName: 'statusFor', - parameters: _i1.testObjectToJson({'accessToken': accessToken}), + endpointPath: 'admin', + methodName: 'alerts', + parameters: _i1.testObjectToJson({ + 'accessToken': accessToken, + 'microAreaId': microAreaId, + 'status': status, + 'limit': limit, + 'offset': offset, + }), serializationManager: _serializationManager, ); var _localReturnValue = @@ -308,41 +380,30 @@ class _AlertsEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i6.AlertStatusResult>); + as _i3.Future<_i6.AdminAlertPage>); return _localReturnValue; } finally { await _localUniqueSession.close(); } }); } -} - -class _AuthEndpoint { - _AuthEndpoint( - this._endpointDispatch, - this._serializationManager, - ); - - final _i2.EndpointDispatch _endpointDispatch; - - final _i2.SerializationManager _serializationManager; - _i3.Future<_i7.DevelopmentLoginResult> developmentLogin( + _i3.Future<_i8.AdminAlertsPulse> alertsPulse( _i1.TestSessionBuilder sessionBuilder, { - required String role, + required String accessToken, }) async { return _i1.callAwaitableFunctionAndHandleExceptions(() async { var _localUniqueSession = (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( - endpoint: 'auth', - method: 'developmentLogin', + endpoint: 'admin', + method: 'alertsPulse', ); try { var _localCallContext = await _endpointDispatch.getMethodCallContext( createSessionCallback: (_) => _localUniqueSession, - endpointPath: 'auth', - methodName: 'developmentLogin', - parameters: _i1.testObjectToJson({'role': role}), + endpointPath: 'admin', + methodName: 'alertsPulse', + parameters: _i1.testObjectToJson({'accessToken': accessToken}), serializationManager: _serializationManager, ); var _localReturnValue = @@ -350,7 +411,7 @@ class _AuthEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i7.DevelopmentLoginResult>); + as _i3.Future<_i8.AdminAlertsPulse>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -358,28 +419,22 @@ class _AuthEndpoint { }); } - _i3.Future<_i7.DevelopmentLoginResult> loginInstitutional( + _i3.Future endMonitoringSession( _i1.TestSessionBuilder sessionBuilder, { - required String matricula, - required String password, - String? deviceId, + required String accessToken, }) async { return _i1.callAwaitableFunctionAndHandleExceptions(() async { var _localUniqueSession = (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( - endpoint: 'auth', - method: 'loginInstitutional', + endpoint: 'admin', + method: 'endMonitoringSession', ); try { var _localCallContext = await _endpointDispatch.getMethodCallContext( createSessionCallback: (_) => _localUniqueSession, - endpointPath: 'auth', - methodName: 'loginInstitutional', - parameters: _i1.testObjectToJson({ - 'matricula': matricula, - 'password': password, - 'deviceId': deviceId, - }), + endpointPath: 'admin', + methodName: 'endMonitoringSession', + parameters: _i1.testObjectToJson({'accessToken': accessToken}), serializationManager: _serializationManager, ); var _localReturnValue = @@ -387,7 +442,7 @@ class _AuthEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i7.DevelopmentLoginResult>); + as _i3.Future); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -395,25 +450,29 @@ class _AuthEndpoint { }); } - _i3.Future requestOtp( + _i3.Future<_i9.AdminAuditPage> auditLogs( _i1.TestSessionBuilder sessionBuilder, { - required String cpf, - required DateTime birthDate, + required String accessToken, + _i10.AdminAuditFilter? filter, + required int limit, + int? beforeSequence, }) async { return _i1.callAwaitableFunctionAndHandleExceptions(() async { var _localUniqueSession = (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( - endpoint: 'auth', - method: 'requestOtp', + endpoint: 'admin', + method: 'auditLogs', ); try { var _localCallContext = await _endpointDispatch.getMethodCallContext( createSessionCallback: (_) => _localUniqueSession, - endpointPath: 'auth', - methodName: 'requestOtp', + endpointPath: 'admin', + methodName: 'auditLogs', parameters: _i1.testObjectToJson({ - 'cpf': cpf, - 'birthDate': birthDate, + 'accessToken': accessToken, + 'filter': filter, + 'limit': limit, + 'beforeSequence': beforeSequence, }), serializationManager: _serializationManager, ); @@ -422,7 +481,7 @@ class _AuthEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future); + as _i3.Future<_i9.AdminAuditPage>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -430,28 +489,22 @@ class _AuthEndpoint { }); } - _i3.Future<_i7.DevelopmentLoginResult> verifyOtp( + _i3.Future<_i11.AdminAuditChainStatus> verifyAuditChain( _i1.TestSessionBuilder sessionBuilder, { - required String cpf, - required String code, - String? deviceId, + required String accessToken, }) async { return _i1.callAwaitableFunctionAndHandleExceptions(() async { var _localUniqueSession = (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( - endpoint: 'auth', - method: 'verifyOtp', + endpoint: 'admin', + method: 'verifyAuditChain', ); try { var _localCallContext = await _endpointDispatch.getMethodCallContext( createSessionCallback: (_) => _localUniqueSession, - endpointPath: 'auth', - methodName: 'verifyOtp', - parameters: _i1.testObjectToJson({ - 'cpf': cpf, - 'code': code, - 'deviceId': deviceId, - }), + endpointPath: 'admin', + methodName: 'verifyAuditChain', + parameters: _i1.testObjectToJson({'accessToken': accessToken}), serializationManager: _serializationManager, ); var _localReturnValue = @@ -459,40 +512,34 @@ class _AuthEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i7.DevelopmentLoginResult>); + as _i3.Future<_i11.AdminAuditChainStatus>); return _localReturnValue; } finally { await _localUniqueSession.close(); } }); } -} - -class _HealthEndpoint { - _HealthEndpoint( - this._endpointDispatch, - this._serializationManager, - ); - - final _i2.EndpointDispatch _endpointDispatch; - - final _i2.SerializationManager _serializationManager; - _i3.Future<_i8.ServiceHealth> check( - _i1.TestSessionBuilder sessionBuilder, - ) async { + _i3.Future<_i12.AdminAuditExport> exportAuditLogs( + _i1.TestSessionBuilder sessionBuilder, { + required String accessToken, + required _i10.AdminAuditFilter filter, + }) async { return _i1.callAwaitableFunctionAndHandleExceptions(() async { var _localUniqueSession = (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( - endpoint: 'health', - method: 'check', + endpoint: 'admin', + method: 'exportAuditLogs', ); try { var _localCallContext = await _endpointDispatch.getMethodCallContext( createSessionCallback: (_) => _localUniqueSession, - endpointPath: 'health', - methodName: 'check', - parameters: _i1.testObjectToJson({}), + endpointPath: 'admin', + methodName: 'exportAuditLogs', + parameters: _i1.testObjectToJson({ + 'accessToken': accessToken, + 'filter': filter, + }), serializationManager: _serializationManager, ); var _localReturnValue = @@ -500,44 +547,37 @@ class _HealthEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i8.ServiceHealth>); + as _i3.Future<_i12.AdminAuditExport>); return _localReturnValue; } finally { await _localUniqueSession.close(); } }); } -} - -class _OnboardingEndpoint { - _OnboardingEndpoint( - this._endpointDispatch, - this._serializationManager, - ); - - final _i2.EndpointDispatch _endpointDispatch; - - final _i2.SerializationManager _serializationManager; - _i3.Future<_i9.EnrollmentTokenResult> generateEnrollmentToken( + _i3.Future<_i13.AdminDataSubjectRequestPage> dataSubjectRequests( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, - required String patientId, + _i14.DataSubjectRequestStatus? status, + required int limit, + required int offset, }) async { return _i1.callAwaitableFunctionAndHandleExceptions(() async { var _localUniqueSession = (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( - endpoint: 'onboarding', - method: 'generateEnrollmentToken', + endpoint: 'admin', + method: 'dataSubjectRequests', ); try { var _localCallContext = await _endpointDispatch.getMethodCallContext( createSessionCallback: (_) => _localUniqueSession, - endpointPath: 'onboarding', - methodName: 'generateEnrollmentToken', + endpointPath: 'admin', + methodName: 'dataSubjectRequests', parameters: _i1.testObjectToJson({ 'accessToken': accessToken, - 'patientId': patientId, + 'status': status, + 'limit': limit, + 'offset': offset, }), serializationManager: _serializationManager, ); @@ -546,7 +586,7 @@ class _OnboardingEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i9.EnrollmentTokenResult>); + as _i3.Future<_i13.AdminDataSubjectRequestPage>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -554,29 +594,25 @@ class _OnboardingEndpoint { }); } - _i3.Future<_i10.EnrollmentResult> completeEnrollment( + _i3.Future<_i15.AdminDataSubjectRequestDetail> dataSubjectRequest( _i1.TestSessionBuilder sessionBuilder, { - required String token, - required bool healthDataConsent, - required bool remindersConsent, - required bool pushConsent, + required String accessToken, + required String id, }) async { return _i1.callAwaitableFunctionAndHandleExceptions(() async { var _localUniqueSession = (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( - endpoint: 'onboarding', - method: 'completeEnrollment', + endpoint: 'admin', + method: 'dataSubjectRequest', ); try { var _localCallContext = await _endpointDispatch.getMethodCallContext( createSessionCallback: (_) => _localUniqueSession, - endpointPath: 'onboarding', - methodName: 'completeEnrollment', + endpointPath: 'admin', + methodName: 'dataSubjectRequest', parameters: _i1.testObjectToJson({ - 'token': token, - 'healthDataConsent': healthDataConsent, - 'remindersConsent': remindersConsent, - 'pushConsent': pushConsent, + 'accessToken': accessToken, + 'id': id, }), serializationManager: _serializationManager, ); @@ -585,41 +621,34 @@ class _OnboardingEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i10.EnrollmentResult>); + as _i3.Future<_i15.AdminDataSubjectRequestDetail>); return _localReturnValue; } finally { await _localUniqueSession.close(); } }); } -} -class _PatientsEndpoint { - _PatientsEndpoint( - this._endpointDispatch, - this._serializationManager, - ); - - final _i2.EndpointDispatch _endpointDispatch; - - final _i2.SerializationManager _serializationManager; - - _i3.Future> listMicroArea( + _i3.Future startDataSubjectReview( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, + required String id, }) async { return _i1.callAwaitableFunctionAndHandleExceptions(() async { var _localUniqueSession = (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( - endpoint: 'patients', - method: 'listMicroArea', + endpoint: 'admin', + method: 'startDataSubjectReview', ); try { var _localCallContext = await _endpointDispatch.getMethodCallContext( createSessionCallback: (_) => _localUniqueSession, - endpointPath: 'patients', - methodName: 'listMicroArea', - parameters: _i1.testObjectToJson({'accessToken': accessToken}), + endpointPath: 'admin', + methodName: 'startDataSubjectReview', + parameters: _i1.testObjectToJson({ + 'accessToken': accessToken, + 'id': id, + }), serializationManager: _serializationManager, ); var _localReturnValue = @@ -627,7 +656,7 @@ class _PatientsEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future>); + as _i3.Future); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -635,22 +664,28 @@ class _PatientsEndpoint { }); } - _i3.Future> myChronicConditions( + _i3.Future completeDataSubjectRequest( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, + required String id, + String? note, }) async { return _i1.callAwaitableFunctionAndHandleExceptions(() async { var _localUniqueSession = (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( - endpoint: 'patients', - method: 'myChronicConditions', + endpoint: 'admin', + method: 'completeDataSubjectRequest', ); try { var _localCallContext = await _endpointDispatch.getMethodCallContext( createSessionCallback: (_) => _localUniqueSession, - endpointPath: 'patients', - methodName: 'myChronicConditions', - parameters: _i1.testObjectToJson({'accessToken': accessToken}), + endpointPath: 'admin', + methodName: 'completeDataSubjectRequest', + parameters: _i1.testObjectToJson({ + 'accessToken': accessToken, + 'id': id, + 'note': note, + }), serializationManager: _serializationManager, ); var _localReturnValue = @@ -658,7 +693,7 @@ class _PatientsEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future>); + as _i3.Future); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -666,25 +701,27 @@ class _PatientsEndpoint { }); } - _i3.Future updateChronicConditions( + _i3.Future rejectDataSubjectRequest( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, - required List conditions, + required String id, + required String reason, }) async { return _i1.callAwaitableFunctionAndHandleExceptions(() async { var _localUniqueSession = (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( - endpoint: 'patients', - method: 'updateChronicConditions', + endpoint: 'admin', + method: 'rejectDataSubjectRequest', ); try { var _localCallContext = await _endpointDispatch.getMethodCallContext( createSessionCallback: (_) => _localUniqueSession, - endpointPath: 'patients', - methodName: 'updateChronicConditions', + endpointPath: 'admin', + methodName: 'rejectDataSubjectRequest', parameters: _i1.testObjectToJson({ 'accessToken': accessToken, - 'conditions': conditions, + 'id': id, + 'reason': reason, }), serializationManager: _serializationManager, ); @@ -701,21 +738,21 @@ class _PatientsEndpoint { }); } - _i3.Future<_i12.PatientDataOverview> myData( + _i3.Future> acs( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, }) async { return _i1.callAwaitableFunctionAndHandleExceptions(() async { var _localUniqueSession = (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( - endpoint: 'patients', - method: 'myData', + endpoint: 'admin', + method: 'acs', ); try { var _localCallContext = await _endpointDispatch.getMethodCallContext( createSessionCallback: (_) => _localUniqueSession, - endpointPath: 'patients', - methodName: 'myData', + endpointPath: 'admin', + methodName: 'acs', parameters: _i1.testObjectToJson({'accessToken': accessToken}), serializationManager: _serializationManager, ); @@ -724,55 +761,30 @@ class _PatientsEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i12.PatientDataOverview>); + as _i3.Future>); return _localReturnValue; } finally { await _localUniqueSession.close(); } }); } -} - -class _TriageEndpoint { - _TriageEndpoint( - this._endpointDispatch, - this._serializationManager, - ); - final _i2.EndpointDispatch _endpointDispatch; - - final _i2.SerializationManager _serializationManager; - - _i3.Future<_i13.TriageResult> evaluate( + _i3.Future> staff( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, - required bool chestPain, - required bool difficultyBreathing, - required bool fever, - required bool persistentVomiting, - required bool bleeding, - required bool severeWeakness, }) async { return _i1.callAwaitableFunctionAndHandleExceptions(() async { var _localUniqueSession = (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( - endpoint: 'triage', - method: 'evaluate', + endpoint: 'admin', + method: 'staff', ); try { var _localCallContext = await _endpointDispatch.getMethodCallContext( createSessionCallback: (_) => _localUniqueSession, - endpointPath: 'triage', - methodName: 'evaluate', - parameters: _i1.testObjectToJson({ - 'accessToken': accessToken, - 'chestPain': chestPain, - 'difficultyBreathing': difficultyBreathing, - 'fever': fever, - 'persistentVomiting': persistentVomiting, - 'bleeding': bleeding, - 'severeWeakness': severeWeakness, - }), + endpointPath: 'admin', + methodName: 'staff', + parameters: _i1.testObjectToJson({'accessToken': accessToken}), serializationManager: _serializationManager, ); var _localReturnValue = @@ -780,29 +792,1755 @@ class _TriageEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i13.TriageResult>); + as _i3.Future>); return _localReturnValue; } finally { await _localUniqueSession.close(); } }); } -} - -class _VisitsEndpoint { - _VisitsEndpoint( - this._endpointDispatch, - this._serializationManager, - ); - final _i2.EndpointDispatch _endpointDispatch; + _i3.Future<_i18.AdminAcsCreationResult> createAcs( + _i1.TestSessionBuilder sessionBuilder, { + required String accessToken, + required String name, + required String enrollmentId, + required String microAreaId, + }) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'admin', + method: 'createAcs', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'admin', + methodName: 'createAcs', + parameters: _i1.testObjectToJson({ + 'accessToken': accessToken, + 'name': name, + 'enrollmentId': enrollmentId, + 'microAreaId': microAreaId, + }), + serializationManager: _serializationManager, + ); + var _localReturnValue = + await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) + as _i3.Future<_i18.AdminAcsCreationResult>); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } + + _i3.Future<_i16.AdminAcs> setAcsMicroArea( + _i1.TestSessionBuilder sessionBuilder, { + required String accessToken, + required String acsId, + required String microAreaId, + }) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'admin', + method: 'setAcsMicroArea', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'admin', + methodName: 'setAcsMicroArea', + parameters: _i1.testObjectToJson({ + 'accessToken': accessToken, + 'acsId': acsId, + 'microAreaId': microAreaId, + }), + serializationManager: _serializationManager, + ); + var _localReturnValue = + await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) + as _i3.Future<_i16.AdminAcs>); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } + + _i3.Future<_i16.AdminAcs> setAcsActive( + _i1.TestSessionBuilder sessionBuilder, { + required String accessToken, + required String acsId, + required bool active, + }) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'admin', + method: 'setAcsActive', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'admin', + methodName: 'setAcsActive', + parameters: _i1.testObjectToJson({ + 'accessToken': accessToken, + 'acsId': acsId, + 'active': active, + }), + serializationManager: _serializationManager, + ); + var _localReturnValue = + await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) + as _i3.Future<_i16.AdminAcs>); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } + + _i3.Future<_i19.AdminPasswordResetResult> resetAcsPassword( + _i1.TestSessionBuilder sessionBuilder, { + required String accessToken, + required String acsId, + }) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'admin', + method: 'resetAcsPassword', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'admin', + methodName: 'resetAcsPassword', + parameters: _i1.testObjectToJson({ + 'accessToken': accessToken, + 'acsId': acsId, + }), + serializationManager: _serializationManager, + ); + var _localReturnValue = + await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) + as _i3.Future<_i19.AdminPasswordResetResult>); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } + + _i3.Future resetAcsMfa( + _i1.TestSessionBuilder sessionBuilder, { + required String accessToken, + required String acsId, + }) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'admin', + method: 'resetAcsMfa', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'admin', + methodName: 'resetAcsMfa', + parameters: _i1.testObjectToJson({ + 'accessToken': accessToken, + 'acsId': acsId, + }), + serializationManager: _serializationManager, + ); + var _localReturnValue = + await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) + as _i3.Future); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } + + _i3.Future<_i20.AdminStaffMfaResetResult> resetStaffMfa( + _i1.TestSessionBuilder sessionBuilder, { + required String accessToken, + required String staffId, + }) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'admin', + method: 'resetStaffMfa', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'admin', + methodName: 'resetStaffMfa', + parameters: _i1.testObjectToJson({ + 'accessToken': accessToken, + 'staffId': staffId, + }), + serializationManager: _serializationManager, + ); + var _localReturnValue = + await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) + as _i3.Future<_i20.AdminStaffMfaResetResult>); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } + + _i3.Future<_i21.AdminEscalationResult> escalateAlert( + _i1.TestSessionBuilder sessionBuilder, { + required String accessToken, + required String alertId, + required _i22.EscalationReason reason, + }) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'admin', + method: 'escalateAlert', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'admin', + methodName: 'escalateAlert', + parameters: _i1.testObjectToJson({ + 'accessToken': accessToken, + 'alertId': alertId, + 'reason': reason, + }), + serializationManager: _serializationManager, + ); + var _localReturnValue = + await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) + as _i3.Future<_i21.AdminEscalationResult>); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } + + _i3.Future scheduleNotice( + _i1.TestSessionBuilder sessionBuilder, { + required String accessToken, + required String microAreaId, + required String audience, + required String title, + required String message, + required DateTime scheduledFor, + }) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'admin', + method: 'scheduleNotice', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'admin', + methodName: 'scheduleNotice', + parameters: _i1.testObjectToJson({ + 'accessToken': accessToken, + 'microAreaId': microAreaId, + 'audience': audience, + 'title': title, + 'message': message, + 'scheduledFor': scheduledFor, + }), + serializationManager: _serializationManager, + ); + var _localReturnValue = + await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) + as _i3.Future); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } + + _i3.Future cancelScheduledNotice( + _i1.TestSessionBuilder sessionBuilder, { + required String accessToken, + required String id, + }) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'admin', + method: 'cancelScheduledNotice', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'admin', + methodName: 'cancelScheduledNotice', + parameters: _i1.testObjectToJson({ + 'accessToken': accessToken, + 'id': id, + }), + serializationManager: _serializationManager, + ); + var _localReturnValue = + await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) + as _i3.Future); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } + + _i3.Future<_i23.NoticeHistoryPage> noticeHistory( + _i1.TestSessionBuilder sessionBuilder, { + required String accessToken, + String? microAreaId, + required int limit, + required int offset, + }) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'admin', + method: 'noticeHistory', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'admin', + methodName: 'noticeHistory', + parameters: _i1.testObjectToJson({ + 'accessToken': accessToken, + 'microAreaId': microAreaId, + 'limit': limit, + 'offset': offset, + }), + serializationManager: _serializationManager, + ); + var _localReturnValue = + await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) + as _i3.Future<_i23.NoticeHistoryPage>); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } + + _i3.Future> termsVersions( + _i1.TestSessionBuilder sessionBuilder, { + required String accessToken, + }) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'admin', + method: 'termsVersions', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'admin', + methodName: 'termsVersions', + 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(); + } + }); + } + + _i3.Future<_i24.TermsVersionEntry> draftTermsVersion( + _i1.TestSessionBuilder sessionBuilder, { + required String accessToken, + required String version, + required String summary, + required DateTime effectiveFrom, + }) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'admin', + method: 'draftTermsVersion', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'admin', + methodName: 'draftTermsVersion', + parameters: _i1.testObjectToJson({ + 'accessToken': accessToken, + 'version': version, + 'summary': summary, + 'effectiveFrom': effectiveFrom, + }), + serializationManager: _serializationManager, + ); + var _localReturnValue = + await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) + as _i3.Future<_i24.TermsVersionEntry>); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } + + _i3.Future<_i24.TermsVersionEntry> markTermsReviewed( + _i1.TestSessionBuilder sessionBuilder, { + required String accessToken, + required String id, + }) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'admin', + method: 'markTermsReviewed', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'admin', + methodName: 'markTermsReviewed', + parameters: _i1.testObjectToJson({ + 'accessToken': accessToken, + 'id': id, + }), + serializationManager: _serializationManager, + ); + var _localReturnValue = + await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) + as _i3.Future<_i24.TermsVersionEntry>); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } + + _i3.Future<_i24.TermsVersionEntry> publishTermsVersion( + _i1.TestSessionBuilder sessionBuilder, { + required String accessToken, + required String id, + }) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'admin', + method: 'publishTermsVersion', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'admin', + methodName: 'publishTermsVersion', + parameters: _i1.testObjectToJson({ + 'accessToken': accessToken, + 'id': id, + }), + serializationManager: _serializationManager, + ); + var _localReturnValue = + await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) + as _i3.Future<_i24.TermsVersionEntry>); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } +} + +class _AlertsEndpoint { + _AlertsEndpoint( + this._endpointDispatch, + this._serializationManager, + ); + + final _i2.EndpointDispatch _endpointDispatch; + + final _i2.SerializationManager _serializationManager; + + _i3.Future<_i25.RedAlertResult> createRedAlert( + _i1.TestSessionBuilder sessionBuilder, { + required String accessToken, + required String idempotencyKey, + required String locationHash, + String? locationCell, + }) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'alerts', + method: 'createRedAlert', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'alerts', + methodName: 'createRedAlert', + parameters: _i1.testObjectToJson({ + 'accessToken': accessToken, + 'idempotencyKey': idempotencyKey, + 'locationHash': locationHash, + 'locationCell': locationCell, + }), + serializationManager: _serializationManager, + ); + var _localReturnValue = + await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) + as _i3.Future<_i25.RedAlertResult>); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } + + _i3.Future<_i26.AlertAckResult> acknowledge( + _i1.TestSessionBuilder sessionBuilder, { + required String accessToken, + required String alertId, + }) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'alerts', + method: 'acknowledge', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'alerts', + methodName: 'acknowledge', + parameters: _i1.testObjectToJson({ + 'accessToken': accessToken, + 'alertId': alertId, + }), + serializationManager: _serializationManager, + ); + var _localReturnValue = + await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) + as _i3.Future<_i26.AlertAckResult>); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } + + _i3.Future<_i27.AlertStatusResult> statusFor( + _i1.TestSessionBuilder sessionBuilder, { + required String accessToken, + }) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'alerts', + method: 'statusFor', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'alerts', + methodName: 'statusFor', + parameters: _i1.testObjectToJson({'accessToken': accessToken}), + serializationManager: _serializationManager, + ); + var _localReturnValue = + await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) + as _i3.Future<_i27.AlertStatusResult>); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } +} + +class _AuthEndpoint { + _AuthEndpoint( + this._endpointDispatch, + this._serializationManager, + ); + + final _i2.EndpointDispatch _endpointDispatch; + + final _i2.SerializationManager _serializationManager; + + _i3.Future<_i28.DevelopmentLoginResult> developmentLogin( + _i1.TestSessionBuilder sessionBuilder, { + required String role, + }) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'auth', + method: 'developmentLogin', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'auth', + methodName: 'developmentLogin', + parameters: _i1.testObjectToJson({'role': role}), + serializationManager: _serializationManager, + ); + var _localReturnValue = + await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) + as _i3.Future<_i28.DevelopmentLoginResult>); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } + + _i3.Future<_i28.DevelopmentLoginResult> loginInstitutional( + _i1.TestSessionBuilder sessionBuilder, { + required String matricula, + required String password, + String? deviceId, + String? totpCode, + }) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'auth', + method: 'loginInstitutional', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'auth', + methodName: 'loginInstitutional', + parameters: _i1.testObjectToJson({ + 'matricula': matricula, + 'password': password, + 'deviceId': deviceId, + 'totpCode': totpCode, + }), + serializationManager: _serializationManager, + ); + var _localReturnValue = + await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) + as _i3.Future<_i28.DevelopmentLoginResult>); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } + + _i3.Future<_i28.DevelopmentLoginResult> loginStaff( + _i1.TestSessionBuilder sessionBuilder, { + required String matricula, + required String password, + String? totpCode, + }) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'auth', + method: 'loginStaff', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'auth', + methodName: 'loginStaff', + parameters: _i1.testObjectToJson({ + 'matricula': matricula, + 'password': password, + 'totpCode': totpCode, + }), + serializationManager: _serializationManager, + ); + var _localReturnValue = + await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) + as _i3.Future<_i28.DevelopmentLoginResult>); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } + + _i3.Future<_i29.TotpEnrollmentStart> beginStaffTotpEnrollment( + _i1.TestSessionBuilder sessionBuilder, { + required String matricula, + required String password, + required String activationCode, + }) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'auth', + method: 'beginStaffTotpEnrollment', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'auth', + methodName: 'beginStaffTotpEnrollment', + parameters: _i1.testObjectToJson({ + 'matricula': matricula, + 'password': password, + 'activationCode': activationCode, + }), + serializationManager: _serializationManager, + ); + var _localReturnValue = + await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) + as _i3.Future<_i29.TotpEnrollmentStart>); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } + + _i3.Future confirmStaffTotpEnrollment( + _i1.TestSessionBuilder sessionBuilder, { + required String matricula, + required String password, + required String activationCode, + required String code, + }) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'auth', + method: 'confirmStaffTotpEnrollment', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'auth', + methodName: 'confirmStaffTotpEnrollment', + parameters: _i1.testObjectToJson({ + 'matricula': matricula, + 'password': password, + 'activationCode': activationCode, + 'code': code, + }), + serializationManager: _serializationManager, + ); + var _localReturnValue = + await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) + as _i3.Future); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } + + _i3.Future<_i28.DevelopmentLoginResult> refreshSession( + _i1.TestSessionBuilder sessionBuilder, { + required String refreshToken, + required String deviceId, + }) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'auth', + method: 'refreshSession', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'auth', + methodName: 'refreshSession', + parameters: _i1.testObjectToJson({ + 'refreshToken': refreshToken, + 'deviceId': deviceId, + }), + serializationManager: _serializationManager, + ); + var _localReturnValue = + await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) + as _i3.Future<_i28.DevelopmentLoginResult>); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } + + _i3.Future logout( + _i1.TestSessionBuilder sessionBuilder, { + required String refreshToken, + }) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'auth', + method: 'logout', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'auth', + methodName: 'logout', + parameters: _i1.testObjectToJson({'refreshToken': refreshToken}), + serializationManager: _serializationManager, + ); + var _localReturnValue = + await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) + as _i3.Future); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } + + _i3.Future<_i29.TotpEnrollmentStart> beginTotpEnrollment( + _i1.TestSessionBuilder sessionBuilder, { + required String matricula, + required String password, + }) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'auth', + method: 'beginTotpEnrollment', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'auth', + methodName: 'beginTotpEnrollment', + parameters: _i1.testObjectToJson({ + 'matricula': matricula, + 'password': password, + }), + serializationManager: _serializationManager, + ); + var _localReturnValue = + await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) + as _i3.Future<_i29.TotpEnrollmentStart>); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } + + _i3.Future confirmTotpEnrollment( + _i1.TestSessionBuilder sessionBuilder, { + required String matricula, + required String password, + required String code, + }) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'auth', + method: 'confirmTotpEnrollment', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'auth', + methodName: 'confirmTotpEnrollment', + parameters: _i1.testObjectToJson({ + 'matricula': matricula, + 'password': password, + 'code': code, + }), + serializationManager: _serializationManager, + ); + var _localReturnValue = + await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) + as _i3.Future); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } + + _i3.Future requestOtp( + _i1.TestSessionBuilder sessionBuilder, { + required String cpf, + required DateTime birthDate, + }) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'auth', + method: 'requestOtp', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'auth', + methodName: 'requestOtp', + parameters: _i1.testObjectToJson({ + 'cpf': cpf, + 'birthDate': birthDate, + }), + serializationManager: _serializationManager, + ); + var _localReturnValue = + await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) + as _i3.Future); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } + + _i3.Future<_i28.DevelopmentLoginResult> verifyOtp( + _i1.TestSessionBuilder sessionBuilder, { + required String cpf, + required String code, + String? deviceId, + }) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'auth', + method: 'verifyOtp', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'auth', + methodName: 'verifyOtp', + parameters: _i1.testObjectToJson({ + 'cpf': cpf, + 'code': code, + 'deviceId': deviceId, + }), + serializationManager: _serializationManager, + ); + var _localReturnValue = + await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) + as _i3.Future<_i28.DevelopmentLoginResult>); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } +} + +class _DevicesEndpoint { + _DevicesEndpoint( + this._endpointDispatch, + this._serializationManager, + ); + + final _i2.EndpointDispatch _endpointDispatch; + + final _i2.SerializationManager _serializationManager; + + _i3.Future registerPushToken( + _i1.TestSessionBuilder sessionBuilder, { + required String accessToken, + required String token, + required String platform, + }) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'devices', + method: 'registerPushToken', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'devices', + methodName: 'registerPushToken', + parameters: _i1.testObjectToJson({ + 'accessToken': accessToken, + 'token': token, + 'platform': platform, + }), + serializationManager: _serializationManager, + ); + var _localReturnValue = + await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) + as _i3.Future); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } +} + +class _HealthEndpoint { + _HealthEndpoint( + this._endpointDispatch, + this._serializationManager, + ); + + final _i2.EndpointDispatch _endpointDispatch; + + final _i2.SerializationManager _serializationManager; + + _i3.Future<_i30.ServiceHealth> check( + _i1.TestSessionBuilder sessionBuilder, + ) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'health', + method: 'check', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'health', + methodName: 'check', + parameters: _i1.testObjectToJson({}), + serializationManager: _serializationManager, + ); + var _localReturnValue = + await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) + as _i3.Future<_i30.ServiceHealth>); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } +} + +class _NoticesEndpoint { + _NoticesEndpoint( + this._endpointDispatch, + this._serializationManager, + ); + + final _i2.EndpointDispatch _endpointDispatch; + + final _i2.SerializationManager _serializationManager; + + _i3.Future<_i31.NoticeSendResult> sendSegmented( + _i1.TestSessionBuilder sessionBuilder, { + required String accessToken, + required String title, + required String message, + required String audience, + }) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'notices', + method: 'sendSegmented', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'notices', + methodName: 'sendSegmented', + parameters: _i1.testObjectToJson({ + 'accessToken': accessToken, + 'title': title, + 'message': message, + 'audience': audience, + }), + serializationManager: _serializationManager, + ); + var _localReturnValue = + await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) + as _i3.Future<_i31.NoticeSendResult>); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } +} + +class _OnboardingEndpoint { + _OnboardingEndpoint( + this._endpointDispatch, + this._serializationManager, + ); + + final _i2.EndpointDispatch _endpointDispatch; + + final _i2.SerializationManager _serializationManager; + + _i3.Future<_i32.EnrollmentTokenResult> generateEnrollmentToken( + _i1.TestSessionBuilder sessionBuilder, { + required String accessToken, + required String patientId, + }) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'onboarding', + method: 'generateEnrollmentToken', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'onboarding', + methodName: 'generateEnrollmentToken', + parameters: _i1.testObjectToJson({ + 'accessToken': accessToken, + 'patientId': patientId, + }), + serializationManager: _serializationManager, + ); + var _localReturnValue = + await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) + as _i3.Future<_i32.EnrollmentTokenResult>); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } + + _i3.Future<_i33.EnrollmentResult> completeEnrollment( + _i1.TestSessionBuilder sessionBuilder, { + required String token, + required bool healthDataConsent, + required bool remindersConsent, + required bool pushConsent, + required bool termsAccepted, + }) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'onboarding', + method: 'completeEnrollment', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'onboarding', + methodName: 'completeEnrollment', + parameters: _i1.testObjectToJson({ + 'token': token, + 'healthDataConsent': healthDataConsent, + 'remindersConsent': remindersConsent, + 'pushConsent': pushConsent, + 'termsAccepted': termsAccepted, + }), + serializationManager: _serializationManager, + ); + var _localReturnValue = + await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) + as _i3.Future<_i33.EnrollmentResult>); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } +} + +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(); + } + }); + } + + _i3.Future> myChronicConditions( + _i1.TestSessionBuilder sessionBuilder, { + required String accessToken, + }) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'patients', + method: 'myChronicConditions', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'patients', + methodName: 'myChronicConditions', + 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(); + } + }); + } + + _i3.Future updateChronicConditions( + _i1.TestSessionBuilder sessionBuilder, { + required String accessToken, + required List conditions, + }) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'patients', + method: 'updateChronicConditions', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'patients', + methodName: 'updateChronicConditions', + parameters: _i1.testObjectToJson({ + 'accessToken': accessToken, + 'conditions': conditions, + }), + serializationManager: _serializationManager, + ); + var _localReturnValue = + await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) + as _i3.Future); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } + + _i3.Future<_i35.PatientDataOverview> myData( + _i1.TestSessionBuilder sessionBuilder, { + required String accessToken, + }) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'patients', + method: 'myData', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'patients', + methodName: 'myData', + parameters: _i1.testObjectToJson({'accessToken': accessToken}), + serializationManager: _serializationManager, + ); + var _localReturnValue = + await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) + as _i3.Future<_i35.PatientDataOverview>); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } + + _i3.Future<_i36.PatientConsentRecord> updateConsent( + _i1.TestSessionBuilder sessionBuilder, { + required String accessToken, + required _i37.ConsentPurpose purpose, + required bool granted, + }) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'patients', + method: 'updateConsent', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'patients', + methodName: 'updateConsent', + parameters: _i1.testObjectToJson({ + 'accessToken': accessToken, + 'purpose': purpose, + 'granted': granted, + }), + serializationManager: _serializationManager, + ); + var _localReturnValue = + await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) + as _i3.Future<_i36.PatientConsentRecord>); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } + + _i3.Future<_i36.PatientConsentRecord> acceptTermsOfUse( + _i1.TestSessionBuilder sessionBuilder, { + required String accessToken, + }) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'patients', + method: 'acceptTermsOfUse', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'patients', + methodName: 'acceptTermsOfUse', + parameters: _i1.testObjectToJson({'accessToken': accessToken}), + serializationManager: _serializationManager, + ); + var _localReturnValue = + await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) + as _i3.Future<_i36.PatientConsentRecord>); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } + + _i3.Future hasAcceptedCurrentTerms( + _i1.TestSessionBuilder sessionBuilder, { + required String accessToken, + }) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'patients', + method: 'hasAcceptedCurrentTerms', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'patients', + methodName: 'hasAcceptedCurrentTerms', + 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(); + } + }); + } + + _i3.Future hasGrantedConsent( + _i1.TestSessionBuilder sessionBuilder, { + required String accessToken, + required _i37.ConsentPurpose purpose, + }) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'patients', + method: 'hasGrantedConsent', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'patients', + methodName: 'hasGrantedConsent', + parameters: _i1.testObjectToJson({ + 'accessToken': accessToken, + 'purpose': purpose, + }), + serializationManager: _serializationManager, + ); + var _localReturnValue = + await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) + as _i3.Future); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } + + _i3.Future<_i38.TermsChangeNotice?> termsChangeNotice( + _i1.TestSessionBuilder sessionBuilder, { + required String accessToken, + }) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'patients', + method: 'termsChangeNotice', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'patients', + methodName: 'termsChangeNotice', + parameters: _i1.testObjectToJson({'accessToken': accessToken}), + serializationManager: _serializationManager, + ); + var _localReturnValue = + await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) + as _i3.Future<_i38.TermsChangeNotice?>); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } + + _i3.Future<_i39.PatientDataSubjectRequestRecord> requestDataDeletion( + _i1.TestSessionBuilder sessionBuilder, { + required String accessToken, + }) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'patients', + method: 'requestDataDeletion', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'patients', + methodName: 'requestDataDeletion', + parameters: _i1.testObjectToJson({'accessToken': accessToken}), + serializationManager: _serializationManager, + ); + var _localReturnValue = + await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) + as _i3.Future<_i39.PatientDataSubjectRequestRecord>); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } + + _i3.Future<_i39.PatientDataSubjectRequestRecord> requestDataCorrection( + _i1.TestSessionBuilder sessionBuilder, { + required String accessToken, + required String details, + }) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'patients', + method: 'requestDataCorrection', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'patients', + methodName: 'requestDataCorrection', + parameters: _i1.testObjectToJson({ + 'accessToken': accessToken, + 'details': details, + }), + serializationManager: _serializationManager, + ); + var _localReturnValue = + await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) + as _i3.Future<_i39.PatientDataSubjectRequestRecord>); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } +} + +class _TriageEndpoint { + _TriageEndpoint( + this._endpointDispatch, + this._serializationManager, + ); + + final _i2.EndpointDispatch _endpointDispatch; + + final _i2.SerializationManager _serializationManager; + + _i3.Future<_i40.TriageResult> evaluate( + _i1.TestSessionBuilder sessionBuilder, { + required String accessToken, + required bool chestPain, + required bool difficultyBreathing, + required bool fever, + required bool persistentVomiting, + required bool bleeding, + required bool severeWeakness, + }) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'triage', + method: 'evaluate', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'triage', + methodName: 'evaluate', + parameters: _i1.testObjectToJson({ + 'accessToken': accessToken, + 'chestPain': chestPain, + 'difficultyBreathing': difficultyBreathing, + 'fever': fever, + 'persistentVomiting': persistentVomiting, + 'bleeding': bleeding, + 'severeWeakness': severeWeakness, + }), + serializationManager: _serializationManager, + ); + var _localReturnValue = + await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) + as _i3.Future<_i40.TriageResult>); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } +} + +class _UbsEndpoint { + _UbsEndpoint( + this._endpointDispatch, + this._serializationManager, + ); + + final _i2.EndpointDispatch _endpointDispatch; + + final _i2.SerializationManager _serializationManager; + + _i3.Future<_i41.UbsContact> myContact( + _i1.TestSessionBuilder sessionBuilder, { + required String accessToken, + }) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'ubs', + method: 'myContact', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'ubs', + methodName: 'myContact', + parameters: _i1.testObjectToJson({'accessToken': accessToken}), + serializationManager: _serializationManager, + ); + var _localReturnValue = + await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) + as _i3.Future<_i41.UbsContact>); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } +} + +class _VisitsEndpoint { + _VisitsEndpoint( + this._endpointDispatch, + this._serializationManager, + ); + + final _i2.EndpointDispatch _endpointDispatch; final _i2.SerializationManager _serializationManager; - _i3.Future> sync( + _i3.Future> sync( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, - required List<_i15.VisitSyncEntry> visits, + required List<_i43.VisitSyncEntry> visits, }) async { return _i1.callAwaitableFunctionAndHandleExceptions(() async { var _localUniqueSession = @@ -826,7 +2564,112 @@ class _VisitsEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future>); + as _i3.Future>); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } + + _i3.Future> syncLegacy( + _i1.TestSessionBuilder sessionBuilder, { + required String accessToken, + required String deviceId, + required List<_i43.VisitSyncEntry> visits, + }) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'visits', + method: 'syncLegacy', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'visits', + methodName: 'syncLegacy', + parameters: _i1.testObjectToJson({ + 'accessToken': accessToken, + 'deviceId': deviceId, + 'visits': visits, + }), + serializationManager: _serializationManager, + ); + var _localReturnValue = + await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) + as _i3.Future>); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } + + _i3.Future> syncDeferred( + _i1.TestSessionBuilder sessionBuilder, { + required String uploadToken, + required String deviceId, + required List<_i43.VisitSyncEntry> visits, + }) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'visits', + method: 'syncDeferred', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'visits', + methodName: 'syncDeferred', + parameters: _i1.testObjectToJson({ + 'uploadToken': uploadToken, + 'deviceId': deviceId, + 'visits': visits, + }), + serializationManager: _serializationManager, + ); + var _localReturnValue = + await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) + as _i3.Future>); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } + + _i3.Future revokeUploadToken( + _i1.TestSessionBuilder sessionBuilder, { + required String uploadToken, + }) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'visits', + method: 'revokeUploadToken', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'visits', + methodName: 'revokeUploadToken', + parameters: _i1.testObjectToJson({'uploadToken': uploadToken}), + serializationManager: _serializationManager, + ); + var _localReturnValue = + await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) + as _i3.Future); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -834,7 +2677,7 @@ class _VisitsEndpoint { }); } - _i3.Future> pull( + _i3.Future> pull( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, required DateTime since, @@ -861,7 +2704,7 @@ class _VisitsEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future>); + as _i3.Future>); return _localReturnValue; } finally { await _localUniqueSession.close(); diff --git a/backend/sinalacs_server/test/integration/upload_token_test.dart b/backend/sinalacs_server/test/integration/upload_token_test.dart new file mode 100644 index 00000000..93192af6 --- /dev/null +++ b/backend/sinalacs_server/test/integration/upload_token_test.dart @@ -0,0 +1,548 @@ +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/auth/development_auth_service.dart'; +import 'package:sinalacs_server/src/application/auth/institutional_auth_service.dart'; +import 'package:sinalacs_server/src/application/auth/opaque_token.dart'; +import 'package:sinalacs_server/src/application/auth/upload_token_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/generated/protocol.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_upload_token_store.dart'; +import 'package:sinalacs_server/src/runtime/alert_runtime.dart'; +import 'package:test/test.dart'; + +import '../support/health_data_fixtures.dart'; +import 'test_tools/runtime_harness.dart'; +import 'test_tools/serverpod_test_tools.dart'; + +/// Token de envio diferido do ACS (D7 do plano 2026-10-03) contra Postgres +/// real: emissão no `auth.loginInstitutional`, `visits.syncDeferred` com a +/// autoria do DONO do token e `visits.revokeUploadToken`. +/// +/// O que se prova aqui, adversarialmente: o token de A nunca grava com a +/// autoria de B, nunca fora da microárea ATUAL de A, nunca toma um `localId` +/// de B, e sobrevive ao fim da sessão de A (logout/refresh revogado) — mas não +/// a revogação, vencimento, outro aparelho, conta inativa ou sem microárea. +/// +/// Dados sintéticos; ids próprios (`...0d?`). +const _ubsId = '00000000-0000-4000-8000-0000000000d1'; +const _areaId = '00000000-0000-4000-8000-0000000000d2'; +const _outraAreaId = '00000000-0000-4000-8000-0000000000d3'; +const _acsA = '00000000-0000-4000-8000-0000000000d4'; +const _acsB = '00000000-0000-4000-8000-0000000000d5'; +const _pacienteNaArea = '00000000-0000-4000-8000-0000000000d6'; +const _pacienteFora = '00000000-0000-4000-8000-0000000000d7'; + +const _matriculaA = 'ACS-UPLOAD-A'; +const _matriculaB = 'ACS-UPLOAD-B'; +const _senha = 'senha-sintetica-de-teste'; +const _aparelhoA = 'aparelho-upload-A'; +const _aparelhoB = 'aparelho-upload-B'; + +AppConfig _config() => AppConfig( + mqttBroker: 'localhost:1883', + jwtSecret: 'test-secret', + auditChainSecret: 'test-audit-chain-secret', + healthDataEncryptionKey: AppConfig.developmentHealthDataEncryptionKey, + cpfHashPepper: AppConfig.developmentCpfHashPepper, + smsGateway: 'log', + 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 Envio Diferido', + address: 'Endereço local', + city: 'São Paulo', + state: 'SP', + ), + ); + await MicroArea.db.insert(session, [ + MicroArea( + id: UuidValue.fromString(_areaId), + name: 'Microárea do envio', + ubsId: UuidValue.fromString(_ubsId), + geoJsonBoundary: '{}', + ), + MicroArea( + id: UuidValue.fromString(_outraAreaId), + name: 'Microárea vizinha', + ubsId: UuidValue.fromString(_ubsId), + geoJsonBoundary: '{}', + ), + ]); + final now = DateTime.now().toUtc(); + User user(String id, UserRole role, String cpf, String area) => User( + id: UuidValue.fromString(id), + cpfHash: cpf, + name: 'Usuário sintético', + birthDate: DateTime.utc(1980), + role: role, + microAreaId: UuidValue.fromString(area), + createdAt: now, + updatedAt: now, + ); + await User.db.insert(session, [ + user(_acsA, UserRole.acs, 'development-acs-upload-a', _areaId), + user(_acsB, UserRole.acs, 'development-acs-upload-b', _areaId), + user(_pacienteNaArea, UserRole.patient, 'development-patient-upload-6', _areaId), + user(_pacienteFora, UserRole.patient, 'development-patient-upload-7', _outraAreaId), + ]); + await Acs.db.insert(session, [ + Acs( + id: UuidValue.fromString(_acsA), + enrollmentId: _matriculaA, + ubsId: UuidValue.fromString(_ubsId), + active: true, + ), + Acs( + id: UuidValue.fromString(_acsB), + enrollmentId: _matriculaB, + ubsId: UuidValue.fromString(_ubsId), + active: true, + ), + ]); + await Patient.db.insert(session, [ + await encryptedPatient( + id: _pacienteNaArea, emergencyContact: 'Contato sintético', isChronic: false), + await encryptedPatient( + id: _pacienteFora, emergencyContact: 'Contato sintético', isChronic: false), + ]); + final digest = await AlertRuntimeHarness.hasher.derive(_senha); + for (final id in [_acsA, _acsB]) { + await AlertRuntimeHarness.store(session).saveCredential(id, digest, now); + } +} + +VisitSyncEntry _entry(String localId, + {String patientId = _pacienteNaArea, int version = 0, String status = 'realizada'}) => + VisitSyncEntry( + localId: localId, + patientId: patientId, + scheduledAt: DateTime.utc(2026, 10, 2, 9), + completedAt: DateTime.utc(2026, 10, 2, 10), + status: status, + riskLevelBefore: RiskLevel.green, + notes: const {'campo': 'sem intercorrências'}, + version: version, + arrivalMethod: ArrivalMethod.manual, + ); + +Future _row(Session session, String localId) => Visit.db.findFirstRow( + session, + where: (t) => t.localId.equals(UuidValue.fromString(localId)), + ); + +String _local(int n) => '00000000-0000-4000-8000-${(0xe00 + n).toRadixString(16).padLeft(12, '0')}'; + +void main() { + withServerpod('Dado o token de envio diferido do ACS', (sessionBuilder, endpoints) { + late Session session; + + setUp(() async { + AlertRuntime.instance.overrideConfig(_config()); + session = sessionBuilder.build(); + await _seed(session); + }); + tearDown(() => AlertRuntime.instance.overrideConfig(null)); + + Future entrar( + {String matricula = _matriculaA, String? deviceId = _aparelhoA}) => + endpoints.auth.loginInstitutional( + sessionBuilder, + matricula: matricula, + password: _senha, + deviceId: deviceId, + ); + + Future> enviar(String token, List visits, + {String deviceId = _aparelhoA}) => + endpoints.visits.syncDeferred( + sessionBuilder, + uploadToken: token, + deviceId: deviceId, + visits: visits, + ); + + Future recusado(Future call) => expectLater( + call, + throwsA(isA().having( + (e) => e.message, 'message', UploadTokenService.deniedMessage)), + ); + + Future> trilha(String result) => AuditLog.db.find( + session, + where: (t) => t.result.equals(result), + ); + + test('o login com deviceId devolve uploadToken; a tabela guarda só o hash', + () async { + final login = await entrar(); + final token = login.uploadToken; + expect(token, isNotNull); + expect(token!.length, greaterThanOrEqualTo(43)); + expect(token, isNot(login.refreshToken)); + + final linhas = await AcsUploadToken.db.find(session); + expect(linhas, hasLength(1)); + expect(linhas.single.tokenHash, OpaqueToken.hash(token)); + expect(linhas.single.userId.uuid, _acsA); + expect(linhas.single.deviceId, _aparelhoA); + expect(linhas.single.revokedAt, isNull); + expect( + linhas.single.expiresAt.difference(linhas.single.issuedAt), + UploadTokenService.lifetime, + ); + final emitidos = await trilha('upload_token_issued'); + expect(emitidos.single.userId.uuid, _acsA); + }); + + test('login sem deviceId (ausente ou em branco) devolve uploadToken nulo', + () async { + for (final id in [null, '', ' ']) { + final login = await entrar(deviceId: id); + expect(login.accessToken, isNotEmpty, reason: 'deviceId=$id'); + expect(login.uploadToken, isNull, reason: 'deviceId=$id'); + } + expect(await AcsUploadToken.db.find(session), isEmpty); + }); + + test('developmentLogin (paciente e ACS) nunca devolve uploadToken', () async { + for (final role in ['patient', 'acs']) { + final login = await endpoints.auth.developmentLogin(sessionBuilder, role: role); + expect(login.uploadToken, isNull, reason: role); + } + }); + + test('syncDeferred grava a visita com a autoria do DONO do token', () async { + final login = await entrar(); + final resultados = await enviar(login.uploadToken!, [_entry(_local(1))]); + expect(resultados.single.syncStatus, SyncStatus.synced); + final row = (await _row(session, _local(1)))!; + expect(row.acsId?.uuid, _acsA); + expect(row.authorship, VisitAuthorship.acs); + expect(row.originDeviceId, isNull); + + final lote = await trilha('visit_deferred_sync'); + expect(lote, hasLength(1)); + expect(lote.single.userId.uuid, _acsA); + expect(lote.single.resourceId, isNull); + }); + + test('token de A com paciente de outra microárea → rejected, nada gravado', + () async { + final login = await entrar(); + final r = await enviar( + login.uploadToken!, [_entry(_local(2), patientId: _pacienteFora)]); + expect(r.single.syncStatus, SyncStatus.rejected); + expect(await _row(session, _local(2)), isNull); + }); + + test('token de A não toma um localId que já pertence a B', () async { + final tokenB = AlertRuntime.instance.auth.issueToken(const AuthenticatedUser( + id: _acsB, role: UserRole.acs, microAreaId: _areaId, deviceId: _aparelhoB)); + await endpoints.visits.sync(sessionBuilder, + accessToken: tokenB, visits: [_entry(_local(3))]); + + final login = await entrar(); + final r = await enviar(login.uploadToken!, + [_entry(_local(3), version: 1, status: 'sobrescrita por A')]); + expect(r.single.syncStatus, SyncStatus.rejected); + final row = (await _row(session, _local(3)))!; + expect(row.acsId?.uuid, _acsB); + expect(row.status, 'realizada'); + expect(row.version, 1); + }); + + test('token de B apresentado do aparelho de A é recusado; nunca há autoria de B', + () async { + final loginB = await entrar(matricula: _matriculaB, deviceId: _aparelhoB); + await recusado(enviar(loginB.uploadToken!, [_entry(_local(4))])); + expect(await _row(session, _local(4)), isNull); + // E o token de A, mesmo no envio da visita "de B", grava como A. + final loginA = await entrar(); + await enviar(loginA.uploadToken!, [_entry(_local(5))]); + expect((await _row(session, _local(5)))!.acsId?.uuid, _acsA); + }); + + test('a microárea é RELIDA: depois da troca, o território é o atual', () async { + final login = await entrar(); + final a = (await User.db.findById(session, UuidValue.fromString(_acsA)))!; + a.microAreaId = UuidValue.fromString(_outraAreaId); + await User.db.updateRow(session, a); + + final r = await enviar(login.uploadToken!, [ + _entry(_local(6)), // área antiga + _entry(_local(7), patientId: _pacienteFora), // área atual + ]); + expect(r[0].syncStatus, SyncStatus.rejected); + expect(r[1].syncStatus, SyncStatus.synced); + expect((await _row(session, _local(7)))!.acsId?.uuid, _acsA); + }); + + test('D7: syncDeferred continua valendo depois do logout (refresh revogado)', + () async { + final login = await entrar(); + await endpoints.auth.logout(sessionBuilder, refreshToken: login.refreshToken!); + await expectLater( + endpoints.auth.refreshSession(sessionBuilder, + refreshToken: login.refreshToken!, deviceId: _aparelhoA), + throwsA(isA()), + ); + final r = await enviar(login.uploadToken!, [_entry(_local(8))]); + expect(r.single.syncStatus, SyncStatus.synced); + expect((await _row(session, _local(8)))!.acsId?.uuid, _acsA); + }); + + test('revokeUploadToken: recusa depois, idempotente, ignora desconhecido', + () async { + final login = await entrar(); + await endpoints.visits.revokeUploadToken(sessionBuilder, + uploadToken: login.uploadToken!); + await recusado(enviar(login.uploadToken!, [_entry(_local(9))])); + await endpoints.visits.revokeUploadToken(sessionBuilder, + uploadToken: login.uploadToken!); + await endpoints.visits.revokeUploadToken(sessionBuilder, + uploadToken: 'token-que-nao-existe'); + expect(await _row(session, _local(9)), isNull); + }); + + test('segundo login do mesmo (usuário, aparelho) revoga o primeiro token', + () async { + final primeiro = await entrar(); + final segundo = await entrar(); + await recusado(enviar(primeiro.uploadToken!, [_entry(_local(10))])); + final r = await enviar(segundo.uploadToken!, [_entry(_local(10))]); + expect(r.single.syncStatus, SyncStatus.synced); + // Outro aparelho do mesmo ACS não é afetado. + final outro = await entrar(deviceId: 'aparelho-upload-A2'); + await entrar(); + final r2 = await enviar(outro.uploadToken!, [_entry(_local(11))], + deviceId: 'aparelho-upload-A2'); + expect(r2.single.syncStatus, SyncStatus.synced); + }); + + test('vencido, outro aparelho, conta inativa e sem microárea → a MESMA recusa', + () async { + // Vencido: o relógio do endpoint não é injetável; envelhece a linha. + final vencido = await entrar(); + final linha = (await AcsUploadToken.db.findFirstRow(session, + where: (t) => t.tokenHash.equals(OpaqueToken.hash(vencido.uploadToken!))))!; + linha.expiresAt = DateTime.now().toUtc().subtract(const Duration(seconds: 1)); + await AcsUploadToken.db.updateRow(session, linha); + await recusado(enviar(vencido.uploadToken!, [_entry(_local(12))])); + + // Outro aparelho (e o token cai: nem o aparelho certo usa depois). + final roubado = await entrar(); + await recusado(enviar(roubado.uploadToken!, [_entry(_local(12))], + deviceId: 'aparelho-do-ladrao')); + await recusado(enviar(roubado.uploadToken!, [_entry(_local(12))])); + + // Conta inativa. + final inativo = await entrar(); + final acs = (await Acs.db.findById(session, UuidValue.fromString(_acsA)))!; + acs.active = false; + await Acs.db.updateRow(session, acs); + await recusado(enviar(inativo.uploadToken!, [_entry(_local(12))])); + acs.active = true; + await Acs.db.updateRow(session, acs); + + // Microárea removida. + final semArea = await entrar(); + final u = (await User.db.findById(session, UuidValue.fromString(_acsA)))!; + u.microAreaId = null; + await User.db.updateRow(session, u); + await recusado(enviar(semArea.uploadToken!, [_entry(_local(12))])); + + // Desconhecido. + await recusado(enviar('token-inventado', [_entry(_local(12))])); + + expect(await _row(session, _local(12)), isNull); + for (final motivo in ['expired', 'device', 'inactive']) { + expect(await trilha('upload_token_denied_$motivo'), isNotEmpty, + reason: motivo); + } + }); + + test('lote acima de maxLegacyBatch → AlertValidationException antes de tudo', + () async { + final login = await entrar(); + final antes = (await AuditLog.db.find(session)).length; + await expectLater( + enviar(login.uploadToken!, [ + for (var i = 0; i <= VisitSyncService.maxLegacyBatch; i++) _entry(_local(100 + i)), + ]), + throwsA(isA()), + ); + expect(await Visit.db.find(session), isEmpty); + expect((await AuditLog.db.find(session)).length, antes); + }); + + test('o token nunca aparece na trilha de auditoria', () async { + final login = await entrar(); + final token = login.uploadToken!; + await enviar(token, [_entry(_local(13))]); + await recusado(enviar(token, [_entry(_local(14))], deviceId: 'outro')); + await endpoints.visits.revokeUploadToken(sessionBuilder, uploadToken: token); + for (final row in await AuditLog.db.find(session)) { + final texto = row.toJson().toString(); + expect(texto, isNot(contains(token))); + expect(texto, isNot(contains(OpaqueToken.hash(token)))); + } + }); + test('login com o sentinela público de deviceId: sem refresh nem upload, e não falha', + () async { + final login = await entrar(deviceId: InstitutionalAuthService.deviceIdAbsent); + expect(login.accessToken, isNotEmpty); + expect(login.refreshToken, isNull); + expect(login.uploadToken, isNull); + expect(await AcsUploadToken.db.find(session), isEmpty); + expect(await AcsRefreshToken.db.find(session), isEmpty); + }); + + test('ordem: token inválido com 201 visitas → AlertValidationException (teto antes do token)', + () async { + await expectLater( + enviar('token-inventado', [ + for (var i = 0; i <= VisitSyncService.maxLegacyBatch; i++) _entry(_local(400 + i)), + ]), + throwsA(isA()), + ); + }); + + test('escopo: token de envio não serve como JWT nem como refresh; JWT não serve como token de envio', + () async { + final login = await entrar(); + final upload = login.uploadToken!; + + // Token de envio no lugar do JWT. + await expectLater( + endpoints.visits.sync(sessionBuilder, accessToken: upload, visits: [_entry(_local(20))]), + throwsA(isA()), + ); + await expectLater( + endpoints.visits.pull(sessionBuilder, + accessToken: upload, since: DateTime.utc(2026)), + throwsA(isA()), + ); + await expectLater( + endpoints.patients.listMicroArea(sessionBuilder, accessToken: upload), + throwsA(isA()), + ); + // Token de envio no lugar do refresh token. + await expectLater( + endpoints.auth.refreshSession(sessionBuilder, + refreshToken: upload, deviceId: _aparelhoA), + throwsA(isA()), + ); + + // JWT no lugar do token de envio. + await recusado(enviar(login.accessToken, [_entry(_local(21))])); + await endpoints.visits.revokeUploadToken(sessionBuilder, + uploadToken: login.accessToken); // sem efeito, sem lançar + + // Nada disso gravou visita nem derrubou o token de envio verdadeiro. + expect(await _row(session, _local(20)), isNull); + expect(await _row(session, _local(21)), isNull); + final r = await enviar(upload, [_entry(_local(22))]); + expect(r.single.syncStatus, SyncStatus.synced); + }); + }); + + // Corridas precisam de conexões independentes: sem rollback automático cada + // `Session` usa a sua. Só o store (sem trilha de auditoria, que prenderia o + // usuário por FK); a limpeza apaga só as linhas deste grupo. + withServerpod( + 'Dado o token de envio diferido, sem rollback (corridas)', + (sessionBuilder, endpoints) { + const ubs = '00000000-0000-4000-8000-0000000000f1'; + const area = '00000000-0000-4000-8000-0000000000f2'; + const acs = '00000000-0000-4000-8000-0000000000f3'; + const aparelho = 'aparelho-corrida'; + + Future limpar() async { + final s = sessionBuilder.build(); + await AcsUploadToken.db.deleteWhere(s, + where: (t) => t.userId.equals(UuidValue.fromString(acs))); + await User.db.deleteWhere(s, where: (t) => t.id.equals(UuidValue.fromString(acs))); + await MicroArea.db + .deleteWhere(s, where: (t) => t.id.equals(UuidValue.fromString(area))); + await Ubs.db.deleteWhere(s, where: (t) => t.id.equals(UuidValue.fromString(ubs))); + } + + Future semear() async { + final s = sessionBuilder.build(); + await Ubs.db.insertRow( + s, + Ubs( + id: UuidValue.fromString(ubs), + name: 'UBS Corrida', + address: 'Endereço local', + city: 'São Paulo', + state: 'SP')); + await MicroArea.db.insertRow( + s, + MicroArea( + id: UuidValue.fromString(area), + name: 'Microárea Corrida', + ubsId: UuidValue.fromString(ubs), + geoJsonBoundary: '{}')); + final now = DateTime.now().toUtc(); + await User.db.insertRow( + s, + User( + id: UuidValue.fromString(acs), + cpfHash: 'development-acs-upload-corrida', + name: 'Usuário sintético', + birthDate: DateTime.utc(1980), + role: UserRole.acs, + microAreaId: UuidValue.fromString(area), + createdAt: now, + updatedAt: now)); + } + + test('emissões paralelas do mesmo (usuário, aparelho) deixam exatamente UM vigente', + () async { + await limpar(); + try { + await semear(); + var n = 0; + for (var rodada = 0; rodada < 8; rodada++) { + final at = DateTime.utc(2026, 10, 3, 12, rodada); + await Future.wait([ + for (var k = 0; k < 6; k++) + OrmUploadTokenStore(session: sessionBuilder.build).replace( + UploadTokenRecord( + id: '00000000-0000-4000-8000-${(0xf00 + n++).toRadixString(16).padLeft(12, '0')}', + userId: acs, + deviceId: aparelho, + issuedAt: at, + expiresAt: at.add(UploadTokenService.lifetime), + ), + OpaqueToken.hash('corrida-$rodada-$k'), + ), + ]); + final vigentes = await AcsUploadToken.db.find( + sessionBuilder.build(), + where: (t) => + t.userId.equals(UuidValue.fromString(acs)) & + t.deviceId.equals(aparelho) & + t.revokedAt.equals(null), + ); + expect(vigentes, hasLength(1), reason: 'rodada $rodada'); + } + } finally { + await limpar(); + } + }); + }, + rollbackDatabase: RollbackDatabase.disabled, + ); +} diff --git a/backend/sinalacs_server/test/integration/visit_legacy_sync_test.dart b/backend/sinalacs_server/test/integration/visit_legacy_sync_test.dart new file mode 100644 index 00000000..4fab66ec --- /dev/null +++ b/backend/sinalacs_server/test/integration/visit_legacy_sync_test.dart @@ -0,0 +1,502 @@ +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/audit/audit_chain_verifier.dart'; +import 'package:sinalacs_server/src/application/auth/development_auth_service.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 '../support/health_data_fixtures.dart'; +import 'test_tools/serverpod_test_tools.dart'; + +/// `visits.syncLegacy` (D4 do plano 2026-10-03) contra Postgres real: prova o +/// que o store falso do teste unitário não alcança — `visits.acsId` nulo de +/// verdade na coluna (FK opcional para `acs`), o enum `authorship` persistido, +/// `originDeviceId`, a transação do endpoint e a linha real em `audit_logs`. +/// +/// A sessão do ACS é só o TRANSPORTE: a visita legada foi gravada no aparelho +/// antes de existir dono por visita, e a autoria é desconhecida. +/// +/// Dados sintéticos. O ACS é o do `developmentLogin` (ids fixos do seed). +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'; + +const _deviceId = 'aparelho-legado-01'; + +AppConfig _config() => AppConfig( + mqttBroker: 'localhost:1883', + jwtSecret: 'test-secret', + auditChainSecret: 'test-audit-chain-secret', + healthDataEncryptionKey: AppConfig.developmentHealthDataEncryptionKey, + cpfHashPepper: AppConfig.developmentCpfHashPepper, + smsGateway: 'log', + 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 Legado', + 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-legado-05', + name: 'Paciente Sintético', + 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-legado-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, [ + await encryptedPatient( + id: _patientInAreaId, + emergencyContact: 'Contato sintético', + isChronic: false, + ), + await encryptedPatient( + id: _patientOutsideAreaId, + emergencyContact: 'Contato sintético', + isChronic: false, + ), + ]); +} + +VisitSyncEntry _entry({ + required String localId, + String patientId = _patientInAreaId, + int version = 0, + String status = 'realizada', +}) => + VisitSyncEntry( + localId: localId, + patientId: patientId, + scheduledAt: DateTime.utc(2026, 9, 12, 9), + completedAt: DateTime.utc(2026, 9, 12, 10), + status: status, + riskLevelBefore: RiskLevel.green, + notes: const {'campo': 'sem intercorrências'}, + version: version, + arrivalMethod: ArrivalMethod.manual, + ); + +Future _row(Session session, String localId) => Visit.db.findFirstRow( + session, + where: (t) => t.localId.equals(UuidValue.fromString(localId)), + ); + +void main() { + withServerpod('Dado o envio de visitas legadas (autoria desconhecida)', + (sessionBuilder, endpoints) { + setUp(() => AlertRuntime.instance.overrideConfig(_config())); + tearDown(() => AlertRuntime.instance.overrideConfig(null)); + + Future acsToken() async => + (await endpoints.auth.developmentLogin(sessionBuilder, role: 'acs')).accessToken; + + test('syncLegacy grava authorship legacyUnclaimed, acsId nulo e originDeviceId', () async { + final session = sessionBuilder.build(); + await _seed(session); + const localId = '00000000-0000-4000-8000-0000000001a1'; + + final results = await endpoints.visits.syncLegacy( + sessionBuilder, + accessToken: await acsToken(), + deviceId: _deviceId, + visits: [_entry(localId: localId)], + ); + + expect(results.single.syncStatus, SyncStatus.synced); + expect(results.single.serverVersion, 1); + final row = (await _row(session, localId))!; + expect(row.acsId, isNull); + expect(row.authorship, VisitAuthorship.legacyUnclaimed); + expect(row.originDeviceId, _deviceId); + expect(row.syncAt, isNotNull); + }); + + test('syncLegacy NÃO grava o transportador como autor (acsId permanece nulo)', () async { + final session = sessionBuilder.build(); + await _seed(session); + const localId = '00000000-0000-4000-8000-0000000001a2'; + final token = await acsToken(); + + await endpoints.visits.syncLegacy(sessionBuilder, + accessToken: token, deviceId: _deviceId, visits: [_entry(localId: localId)]); + await endpoints.visits.syncLegacy(sessionBuilder, + accessToken: token, + deviceId: _deviceId, + visits: [_entry(localId: localId, version: 0, status: 'paciente ausente')]); + + final row = (await _row(session, localId))!; + expect(row.acsId, isNull); + expect(row.authorship, VisitAuthorship.legacyUnclaimed); + expect(row.status, 'paciente ausente'); + expect(row.version, 2); + }); + + test('visita legada de paciente de OUTRA microárea: rejected, nada gravado', () async { + final session = sessionBuilder.build(); + await _seed(session); + + final results = await endpoints.visits.syncLegacy( + sessionBuilder, + accessToken: await acsToken(), + deviceId: _deviceId, + visits: [ + _entry( + localId: '00000000-0000-4000-8000-0000000001a3', + patientId: _patientOutsideAreaId, + ), + ], + ); + + expect(results.single.syncStatus, SyncStatus.rejected); + expect(await Visit.db.find(session), isEmpty); + final denied = await AuditLog.db.find( + session, + where: (t) => + t.resourceType.equals('visit_legacy') & t.result.equals('denied_territory'), + ); + expect(denied, hasLength(1)); + expect(denied.single.userId, UuidValue.fromString(_acsId)); + }); + + test('mesmo localId reenviado: idempotente (synced, sem duplicar, sem mudar autoria)', + () async { + final session = sessionBuilder.build(); + await _seed(session); + const localId = '00000000-0000-4000-8000-0000000001a4'; + final token = await acsToken(); + + await endpoints.visits.syncLegacy(sessionBuilder, + accessToken: token, deviceId: _deviceId, visits: [_entry(localId: localId)]); + final retry = await endpoints.visits.syncLegacy(sessionBuilder, + accessToken: token, deviceId: _deviceId, visits: [_entry(localId: localId, version: 1)]); + + expect(retry.single.syncStatus, SyncStatus.synced); + expect(retry.single.serverVersion, 1); + final rows = await Visit.db.find(session); + expect(rows, hasLength(1)); + expect(rows.single.acsId, isNull); + expect(rows.single.authorship, VisitAuthorship.legacyUnclaimed); + }); + + test( + 'localId que já existe como visita COM autor ACS: conflito/rejected, autoria original ' + 'preservada', () async { + final session = sessionBuilder.build(); + await _seed(session); + const localId = '00000000-0000-4000-8000-0000000001a5'; + final token = await acsToken(); + + await endpoints.visits.sync(sessionBuilder, + accessToken: token, visits: [_entry(localId: localId)]); + final results = await endpoints.visits.syncLegacy(sessionBuilder, + accessToken: token, + deviceId: _deviceId, + visits: [_entry(localId: localId, version: 0, status: 'paciente ausente')]); + + expect(results.single.syncStatus, SyncStatus.rejected); + expect(results.single.message, 'visita já registrada com autor — versão diferente'); + final row = (await _row(session, localId))!; + expect(row.acsId, UuidValue.fromString(_acsId)); + expect(row.authorship, VisitAuthorship.acs); + expect(row.originDeviceId, isNull); + expect(row.status, 'realizada'); + }); + + test('visits.sync comum NÃO reivindica uma visita legada (rejected)', () async { + final session = sessionBuilder.build(); + await _seed(session); + const localId = '00000000-0000-4000-8000-0000000001a6'; + final token = await acsToken(); + + await endpoints.visits.syncLegacy(sessionBuilder, + accessToken: token, deviceId: _deviceId, visits: [_entry(localId: localId)]); + final results = await endpoints.visits.sync(sessionBuilder, + accessToken: token, + visits: [_entry(localId: localId, version: 0, status: 'paciente ausente')]); + + expect(results.single.syncStatus, SyncStatus.rejected); + final row = (await _row(session, localId))!; + expect(row.acsId, isNull); + expect(row.authorship, VisitAuthorship.legacyUnclaimed); + expect(row.version, 1); + }); + + test( + 'papel diferente de acs, sem microárea ou conta inativa: StateError ' + '(AlertPermissionException no endpoint)', () async { + final session = sessionBuilder.build(); + await _seed(session); + + final patient = await endpoints.auth.developmentLogin(sessionBuilder, role: 'patient'); + await expectLater( + endpoints.visits.syncLegacy(sessionBuilder, + accessToken: patient.accessToken, + deviceId: _deviceId, + visits: [_entry(localId: '00000000-0000-4000-8000-0000000001a7')]), + throwsA(isA()), + ); + + final semArea = AlertRuntime.instance.auth.issueToken(const AuthenticatedUser( + id: _acsId, + role: UserRole.acs, + microAreaId: null, + deviceId: 'acs-device-001', + )); + await expectLater( + endpoints.visits.syncLegacy(sessionBuilder, + accessToken: semArea, + deviceId: _deviceId, + visits: [_entry(localId: '00000000-0000-4000-8000-0000000001a8')]), + throwsA(isA()), + ); + + final token = await acsToken(); + final acs = (await Acs.db.findById(session, UuidValue.fromString(_acsId)))!; + await Acs.db.updateRow(session, acs.copyWith(active: false)); + await expectLater( + endpoints.visits.syncLegacy(sessionBuilder, + accessToken: token, + deviceId: _deviceId, + visits: [_entry(localId: '00000000-0000-4000-8000-0000000001a9')]), + throwsA(isA()), + ); + + expect(await Visit.db.find(session), isEmpty); + }); + + test('cada lote gera audit_logs visit_legacy_sync com o transportador, sem conteúdo clínico', + () async { + final session = sessionBuilder.build(); + await _seed(session); + + await endpoints.visits.syncLegacy( + sessionBuilder, + accessToken: await acsToken(), + deviceId: _deviceId, + visits: [ + _entry(localId: '00000000-0000-4000-8000-0000000001b1'), + _entry(localId: '00000000-0000-4000-8000-0000000001b2'), + ], + ); + + final rows = await AuditLog.db.find( + session, + where: (t) => t.result.equals('visit_legacy_sync'), + ); + expect(rows, hasLength(1), reason: 'um evento por LOTE, não por visita'); + expect(rows.single.userId, UuidValue.fromString(_acsId)); + expect(rows.single.actionType, 'write'); + expect(rows.single.resourceType, 'visit_legacy'); + expect(rows.single.resourceId, isNull); + + final verifier = AuditChainVerifier( + reader: OrmAuditChainReader(session: () => session), + secret: 'test-audit-chain-secret', + ); + expect((await verifier.verify()).ok, isTrue); + }); + + test('pull entrega a visita legada à microárea sem expor autor (campo acsId ausente)', + () async { + final session = sessionBuilder.build(); + await _seed(session); + const localId = '00000000-0000-4000-8000-0000000001c1'; + final token = await acsToken(); + + await endpoints.visits.syncLegacy(sessionBuilder, + accessToken: token, deviceId: _deviceId, visits: [_entry(localId: localId)]); + final pulled = await endpoints.visits.pull( + sessionBuilder, + accessToken: token, + since: DateTime.utc(2000), + ); + + expect(pulled.map((e) => e.localId), [localId]); + expect(pulled.single.notes, {'campo': 'sem intercorrências'}); + final json = pulled.single.toJson(); + expect(json.containsKey('acsId'), isFalse); + expect(json.containsKey('originDeviceId'), isFalse); + }); + + test('uma linha visit_legacy_sync_item por visita gravada, com o id da visita', () async { + final session = sessionBuilder.build(); + await _seed(session); + const a = '00000000-0000-4000-8000-0000000001d1'; + const b = '00000000-0000-4000-8000-0000000001d2'; + final token = await acsToken(); + + await endpoints.visits.syncLegacy(sessionBuilder, accessToken: token, deviceId: _deviceId, visits: [ + _entry(localId: a), + _entry(localId: b), + _entry(localId: '00000000-0000-4000-8000-0000000001d3', patientId: _patientOutsideAreaId), + ]); + // Reenvio idêntico: no-op, sem item novo. + await endpoints.visits.syncLegacy(sessionBuilder, + accessToken: token, deviceId: _deviceId, visits: [_entry(localId: a, version: 1)]); + + final itens = await AuditLog.db.find( + session, + where: (t) => t.result.equals('visit_legacy_sync_item'), + ); + expect(itens, hasLength(2)); + final ids = {(await _row(session, a))!.id, (await _row(session, b))!.id}; + expect(itens.map((r) => r.resourceId).toSet(), ids); + for (final r in itens) { + expect(r.userId, UuidValue.fromString(_acsId)); + expect(r.resourceType, 'visit_legacy'); + expect(r.actionType, 'write'); + } + final lotes = await AuditLog.db.find(session, where: (t) => t.result.equals('visit_legacy_sync')); + expect(lotes, hasLength(2)); + }); + + test('lote acima de 200 visitas: AlertValidationException, nada gravado', () async { + final session = sessionBuilder.build(); + await _seed(session); + + await expectLater( + endpoints.visits.syncLegacy(sessionBuilder, + accessToken: await acsToken(), + deviceId: _deviceId, + visits: [ + for (var i = 0; i < 201; i++) + _entry(localId: '00000000-0000-4000-8000-${(0x300 + i).toRadixString(16).padLeft(12, '0')}'), + ]), + throwsA(isA()), + ); + expect(await Visit.db.find(session), isEmpty); + expect(await AuditLog.db.find(session), isEmpty); + }); + + test('localId com autor ACS reenviado na MESMA versão: synced, nada muda', () async { + final session = sessionBuilder.build(); + await _seed(session); + const localId = '00000000-0000-4000-8000-0000000001e1'; + final token = await acsToken(); + + await endpoints.visits.sync(sessionBuilder, accessToken: token, visits: [_entry(localId: localId)]); + final results = await endpoints.visits.syncLegacy(sessionBuilder, + accessToken: token, deviceId: _deviceId, visits: [_entry(localId: localId, version: 1)]); + + expect(results.single.syncStatus, SyncStatus.synced); + expect(results.single.serverVersion, 1); + final row = (await _row(session, localId))!; + expect(row.acsId, UuidValue.fromString(_acsId)); + expect(row.authorship, VisitAuthorship.acs); + expect(row.version, 1); + expect( + await AuditLog.db.find(session, where: (t) => t.result.equals('visit_legacy_sync_item')), + isEmpty, + ); + }); + + test('localId com autor de paciente de OUTRO território, mesma versão: recusa genérica, ' + 'sem synced', () async { + final session = sessionBuilder.build(); + await _seed(session); + const localId = '00000000-0000-4000-8000-0000000001f1'; + // Visita com autor de um paciente da microárea vizinha, gravada direto. + final notas = await encryptedVisitNotes(const {}); + await Visit.db.insertRow( + session, + Visit( + patientId: UuidValue.fromString(_patientOutsideAreaId), + acsId: UuidValue.fromString(_acsId), + scheduledAt: DateTime.utc(2026, 9, 12, 9), + status: 'realizada', + riskLevelBefore: RiskLevel.green, + notesEncrypted: notas.ciphertextBase64, + notesKeyVersion: notas.keyVersion, + syncStatus: SyncStatus.synced, + localId: UuidValue.fromString(localId), + syncAt: DateTime.utc(2026, 9, 12, 11), + version: 1, + ), + ); + final token = await acsToken(); + + for (final patientId in [_patientOutsideAreaId, _patientInAreaId]) { + final results = await endpoints.visits.syncLegacy(sessionBuilder, + accessToken: token, + deviceId: _deviceId, + visits: [_entry(localId: localId, patientId: patientId, version: 1)]); + expect(results.single.syncStatus, SyncStatus.rejected, reason: patientId); + expect(results.single.message, 'paciente fora da sua microárea', reason: patientId); + expect(results.single.serverVersion, isNull, reason: patientId); + } + expect( + await AuditLog.db.find(session, where: (t) => t.result.equals('visit_legacy_sync_item')), + isEmpty, + ); + }); + }); +} diff --git a/backend/sinalacs_server/test/support/fake_mqtt_broker.dart b/backend/sinalacs_server/test/support/fake_mqtt_broker.dart new file mode 100644 index 00000000..cc8e6a5f --- /dev/null +++ b/backend/sinalacs_server/test/support/fake_mqtt_broker.dart @@ -0,0 +1,139 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +/// Broker MQTT 3.1.1 mínimo sobre TCP real, só para testar o dispatcher. +/// +/// Cobre CONNECT, SUBSCRIBE, PINGREQ e PUBLISH QoS 1 (com PUBACK). Qualquer outro pacote +/// é ignorado, não rejeitado. +class FakeMqttBroker { + FakeMqttBroker._(this._server); + + final ServerSocket _server; + final _clients = []; + final _buffers = {}; + + int connectCount = 0; + int publishCount = 0; + bool ignorePings = false; + + /// H5 (#56): conta o PUBLISH, mas não responde PUBACK e derruba a conexão + /// logo em seguida, como um broker que cai com a mensagem em voo. + bool dropOnPublish = false; + + /// H5 (#56): conta o PUBLISH e mantém a conexão aberta, mas nunca responde + /// PUBACK, como um par que some sem FIN/RST (partição de rede). + bool withholdPuback = false; + + /// Número de PUBACKs enviados. + int pubackCount = 0; + + int get port => _server.port; + + static Future start({int port = 0}) async { + final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, port); + final broker = FakeMqttBroker._(server); + server.listen(broker._accept); + return broker; + } + + void _accept(Socket socket) { + _clients.add(socket); + // O lado do broker também escreve (CONNACK, PINGRESP...) num socket que o + // cliente pode ter fechado: o EPIPE completaria o `done` do sink sem + // ouvinte e cairia na zona do teste, mascarando o que se quer medir. + socket.done.then((_) {}, onError: (Object _) {}); + socket.listen( + (data) => _onData(socket, data), + onError: (_) {}, + onDone: () { + _clients.remove(socket); + _buffers.remove(socket); + }, + cancelOnError: true, + ); + } + + void _onData(Socket socket, List data) { + final builder = _buffers.putIfAbsent(socket, BytesBuilder.new)..add(data); + final bytes = builder.takeBytes(); + var offset = 0; + while (offset + 2 <= bytes.length) { + var length = 0; + var multiplier = 1; + var i = offset + 1; + int digit; + var incompleto = false; + do { + if (i >= bytes.length) { + incompleto = true; + break; + } + digit = bytes[i++]; + length += (digit & 127) * multiplier; + multiplier *= 128; + } while (digit & 128 != 0); + if (incompleto || i + length > bytes.length) break; + final end = i + length; + _handle(socket, bytes[offset], Uint8List.sublistView(bytes, i, end)); + offset = end; + } + // O que sobrou (pacote parcial) volta ao buffer do socket. + if (offset < bytes.length) { + _buffers[socket]!.add(Uint8List.sublistView(bytes, offset)); + } + } + + void _handle(Socket socket, int header, Uint8List body) { + switch (header >> 4) { + case 1: // CONNECT + connectCount++; + socket.add([0x20, 0x02, 0x00, 0x00]); + case 8: // SUBSCRIBE + socket.add([0x90, 0x03, body[0], body[1], 0x01]); + case 12: // PINGREQ + if (!ignorePings) socket.add([0xD0, 0x00]); + case 3: // PUBLISH + publishCount++; + if (dropOnPublish) { + socket.destroy(); + return; + } + if (withholdPuback) return; + if ((header >> 1) & 3 == 1) { + final topicLength = (body[0] << 8) | body[1]; + socket.add([0x40, 0x02, body[2 + topicLength], body[3 + topicLength]]); + pubackCount++; + } + } + } + + /// Fecha todas as conexões abertas, mas continua aceitando novas. + void dropClients() { + for (final socket in List.of(_clients)) { + socket.destroy(); + } + } + + /// Derruba todas as conexões com RST (SO_LINGER 0) em vez de FIN, como uma + /// partição ou um par que some: no cliente, o erro (errno 104) completa o + /// `Socket.done` do sink de escrita sem ouvinte, o mesmo caminho da #56. + void resetClients() { + final linger = Uint8List(8); + linger.buffer.asByteData() + ..setInt32(0, 1, Endian.host) // l_onoff + ..setInt32(4, 0, Endian.host); // l_linger + for (final socket in List.of(_clients)) { + // SOL_SOCKET/SO_LINGER do Linux (13); em outro SO a opção pode falhar. + socket.setRawOption( + RawSocketOption(RawSocketOption.levelSocket, 13, linger), + ); + socket.destroy(); + } + } + + Future close() async { + dropClients(); + await _server.close(); + } +} diff --git a/backend/sinalacs_server/test/support/health_data_fixtures.dart b/backend/sinalacs_server/test/support/health_data_fixtures.dart index 526bffde..a1d9ad20 100644 --- a/backend/sinalacs_server/test/support/health_data_fixtures.dart +++ b/backend/sinalacs_server/test/support/health_data_fixtures.dart @@ -6,9 +6,10 @@ import 'package:sinalacs_server/src/infrastructure/crypto/health_data_cipher.dar /// Ajudantes para os testes que precisam SEMEAR linhas cifradas. /// -/// `patients.chronicConditions` virou `chronicConditionsEncrypted` (Track E, +/// `patients.chronicConditions` virou `chronicConditionsEncrypted` e +/// `patients.emergencyContact` virou `emergencyContactEncrypted` (Track E, /// RNF03/INV-04). `patients.updateChronicConditions` é hoje o único caminho -/// Dart de produção que escreve essa coluna (perfil clínico do próprio +/// Dart de produção que escreve a coluna clínica (perfil do próprio /// paciente); estas funções aqui são para o estado de PARTIDA de cada teste /// de integração, sem passar pelo endpoint. Cifram com a MESMA chave de /// desenvolvimento que o `AlertRuntime` usa nos testes, para que o store @@ -21,18 +22,21 @@ HealthDataCipher testHealthDataCipher() => HealthDataCipher( keyVersion: 1, ); -/// Um `Patient` pronto para `insertRow`, com as condições crônicas já -/// cifradas. +/// Um `Patient` pronto para `insertRow`, com as condições crônicas e o +/// contato de emergência já cifrados. Future encryptedPatient({ required String id, required String emergencyContact, required bool isChronic, List chronicConditions = const [], }) async { - final encrypted = await testHealthDataCipher().encryptJson(chronicConditions); + final cipher = testHealthDataCipher(); + final encrypted = await cipher.encryptJson(chronicConditions); + final encryptedContact = await cipher.encryptJson(emergencyContact); return Patient( id: UuidValue.fromString(id), - emergencyContact: emergencyContact, + emergencyContactEncrypted: encryptedContact.ciphertextBase64, + emergencyContactKeyVersion: encryptedContact.keyVersion, isChronic: isChronic, chronicConditionsEncrypted: encrypted.ciphertextBase64, chronicConditionsKeyVersion: encrypted.keyVersion, diff --git a/backend/sinalacs_server/test/support/institutional_auth_fixtures.dart b/backend/sinalacs_server/test/support/institutional_auth_fixtures.dart new file mode 100644 index 00000000..5d1f9159 --- /dev/null +++ b/backend/sinalacs_server/test/support/institutional_auth_fixtures.dart @@ -0,0 +1,150 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:sinalacs_server/src/application/audit/audit_trail.dart'; +import 'package:sinalacs_server/src/application/auth/institutional_auth_service.dart'; +import 'package:sinalacs_server/src/application/auth/password_hasher.dart'; +import 'package:sinalacs_server/src/application/auth/totp_secret_vault.dart'; + +/// Fakes compartilhados pelos testes unitários do login institucional com MFA +/// (`institutional_auth_mfa_test.dart` e `institutional_auth_staff_test.dart`). +/// +/// Cofre de teste: base64 reversível. Quem prova a cifra real é +/// `health_data_cipher_test.dart`. +class FakeTotpVault implements TotpSecretVault { + @override + Future seal(Uint8List secret) async => + SealedSecret(ciphertextBase64: base64Encode(secret), keyVersion: 1); + + @override + Future open(SealedSecret sealed) async => base64Decode(sealed.ciphertextBase64); +} + +/// Store em memória de uma única credencial, encontrada pela [matricula]. +class FakeCredentialStore implements AcsCredentialStore, TotpStore { + FakeCredentialStore(this.record, {this.matricula = 'ACS-001'}); + + AcsCredentialRecord record; + final String matricula; + int enabledCalls = 0; + int? lastRegisteredStep; + int successfulLogins = 0; + + /// Simula a requisição concorrente que gravou antes: o `UPDATE` condicional + /// do store real não altera linha nenhuma e devolve `false`. + bool perdeCorrida = false; + + @override + Future findByEnrollmentId(String enrollmentId) async => + enrollmentId == matricula ? record : null; + + @override + Future registerFailedAttempt(String acsId, + {required bool restartCounter, + required int maxFailedAttempts, + required DateTime lockUntil, + required DateTime at}) async { + final anterior = record.failedAttempts; + final proximo = restartCounter ? 1 : anterior + 1; + record = _copia(failedAttempts: proximo, lockedUntil: proximo >= maxFailedAttempts ? lockUntil : null); + } + + @override + Future registerSuccessfulLogin(String acsId, DateTime at) async { + successfulLogins++; + record = _copia(failedAttempts: 0, lockedUntil: null); + } + + @override + Future saveCredential(String acsId, PasswordDigest digest, DateTime at) async {} + + @override + Future saveSecret(String acsId, SealedSecret s, DateTime at) async { + if (perdeCorrida) return false; + record = _copia(totp: TotpEnrollment(sealed: s, enabled: false, lastStep: null)); + return true; + } + + @override + Future enable(String acsId, SealedSecret pending, int step, DateTime at) async { + if (perdeCorrida) return false; + enabledCalls++; + final t = record.totp!; + record = _copia(totp: TotpEnrollment(sealed: t.sealed, enabled: true, lastStep: step)); + return true; + } + + @override + Future registerStep(String acsId, int step) async { + if (perdeCorrida) return false; + lastRegisteredStep = step; + final t = record.totp!; + record = _copia(totp: TotpEnrollment(sealed: t.sealed, enabled: t.enabled, lastStep: step)); + return true; + } + + /// `clearTotp` do store real: o estado da MFA some por inteiro — as quatro + /// colunas `totp*` viram NULL —, e não só o segredo. + @override + Future clearTotp(String acsId, DateTime at) async { + record = _copia(apagarTotp: true); + } + + /// Copia a linha preservando papel e sequência de bloqueios: um fake que + /// perdesse o papel faria uma conta de staff "virar" ACS no meio do teste. + /// [apagarTotp] é o único jeito de gravar `totp: null` — `totp` posicional + /// não distingue "não mexer" de "apagar". + AcsCredentialRecord _copia({ + int? failedAttempts, + DateTime? lockedUntil, + TotpEnrollment? totp, + bool apagarTotp = false, + }) => + AcsCredentialRecord( + acsId: record.acsId, + microAreaId: record.microAreaId, + active: record.active, + role: record.role, + digest: record.digest, + failedAttempts: failedAttempts ?? record.failedAttempts, + lockedUntil: failedAttempts != null ? lockedUntil : record.lockedUntil, + lockStreak: record.lockStreak, + totp: apagarTotp ? null : (totp ?? record.totp), + ); +} + +/// Trilha que guarda os eventos, para o teste conferir o desfecho auditado. +class RecordingAudit extends AuditTrail { + final events = []; + + List get results => [for (final e in events) e.result]; + + List get resourceTypes => [for (final e in events) e.resourceType]; + + @override + Future record(AuditEvent event) async => events.add(event); +} + +/// Código de ativação do staff em memória (#48). +class FakeStaffActivationStore implements StaffActivationStore { + final _porConta = {}; + String? ultimoEmissor; + + @override + Future issue( + String staffId, { + required String codeHash, + required DateTime expiresAt, + required String issuedBy, + required DateTime at, + }) async { + ultimoEmissor = issuedBy; + _porConta[staffId] = StaffActivationRecord(codeHash: codeHash, expiresAt: expiresAt); + } + + @override + Future find(String staffId) async => _porConta[staffId]; + + @override + Future clear(String staffId) async => _porConta.remove(staffId); +} diff --git a/backend/sinalacs_server/test/support/mqtt_dispatcher_probe.dart b/backend/sinalacs_server/test/support/mqtt_dispatcher_probe.dart new file mode 100644 index 00000000..055c9282 --- /dev/null +++ b/backend/sinalacs_server/test/support/mqtt_dispatcher_probe.dart @@ -0,0 +1,29 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:sinalacs_server/src/config/app_config.dart'; +import 'package:sinalacs_server/src/infrastructure/mqtt/mqtt_alert_dispatcher.dart'; + +/// Sonda de processo: conecta um dispatcher ao broker de `MQTT_BROKER`, +/// imprime PRONTO no stdout e fica viva. Não loga nada além do dispatcher. +Future main() async { + final dispatcher = MqttAlertDispatcher( + config: AppConfig( + mqttBroker: Platform.environment['MQTT_BROKER'] ?? 'localhost:1883', + jwtSecret: 'test-secret', + auditChainSecret: 'test-audit-chain-secret', + healthDataEncryptionKey: AppConfig.developmentHealthDataEncryptionKey, + cpfHashPepper: AppConfig.developmentCpfHashPepper, + smsGateway: 'log', + mqttUsername: null, + mqttPassword: null, + mqttUseTls: false, + mqttCaCertificatePath: null, + appEnv: 'development', + enableDevLogin: true, + ), + ); + await dispatcher.connect(); + stdout.writeln('PRONTO'); + await Completer().future; +} diff --git a/backend/sinalacs_server/test/support/mqtt_dispatcher_probe_tls.dart b/backend/sinalacs_server/test/support/mqtt_dispatcher_probe_tls.dart new file mode 100644 index 00000000..5e8e1c81 --- /dev/null +++ b/backend/sinalacs_server/test/support/mqtt_dispatcher_probe_tls.dart @@ -0,0 +1,76 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:sinalacs_server/src/config/app_config.dart'; +import 'package:sinalacs_server/src/infrastructure/mqtt/mqtt_alert_dispatcher.dart'; + +/// Sonda de diagnóstico (#56, Tarefa 6b): dispatcher com TLS + mTLS contra o +/// broker real, dentro de `runZonedGuarded` com marcador de zona. Grava em +/// `PROBE_OUT` (fora do repositório) o tipo do erro, o errno, a pilha completa +/// e a zona em que o erro chegou. Segredos só vêm do ambiente; nada é impresso. +/// +/// `PROBE_MODE=guarded` (padrão): o `connect` roda dentro da zona guardada. +/// `PROBE_MODE=root`: o `connect` roda na zona raiz e só `main` é guardado +/// (mostra se um erro nascido fora da zona escapa dela). +Future main() async { + final out = File(Platform.environment['PROBE_OUT'] ?? '/dev/stderr'); + void log(String s) => + out.writeAsStringSync('${DateTime.now().toIso8601String()} $s\n', + mode: FileMode.append); + final env = Platform.environment; + final config = AppConfig( + mqttBroker: env['MQTT_BROKER'] ?? 'localhost:8883', + jwtSecret: 'test-secret', + auditChainSecret: 'test-audit-chain-secret', + healthDataEncryptionKey: AppConfig.developmentHealthDataEncryptionKey, + cpfHashPepper: AppConfig.developmentCpfHashPepper, + smsGateway: 'log', + mqttUsername: env['MQTT_USERNAME'], + mqttPassword: env['MQTT_PASSWORD'], + mqttUseTls: true, + mqttCaCertificatePath: env['MQTT_CA_CERT_PATH'], + mqttClientCertificatePath: env['MQTT_CLIENT_CERT_PATH'], + mqttClientKeyPath: env['MQTT_CLIENT_KEY_PATH'], + appEnv: 'development', + enableDevLogin: true, + ); + final mode = env['PROBE_MODE'] ?? 'guarded'; + final done = Completer(); + + Future body() async { + final d = MqttAlertDispatcher(config: config); + await d.connect(); + stdout.writeln('PRONTO'); + log('PRONTO zona=${Zone.current[#marker]}'); + } + + runZonedGuarded(() { + if (mode == 'root') { + Zone.root.run(() => body()); + } else { + body(); + } + }, (error, stack) { + var errno = ''; + if (error is SocketException) errno = ' errno=${error.osError?.errorCode}'; + log('ERRO tipo=${error.runtimeType}$errno zonaDoHandler=' + '${Zone.current[#marker]} msg=${error.toString().split('\n').first}'); + log('PILHA(vazia=${stack == StackTrace.empty}):\n$stack'); + }, + zoneValues: {#marker: 'ZONA_PROBE'}, + zoneSpecification: ZoneSpecification( + // Chamado quando um Future/Stream é completado com erro: a pilha SÍNCRONA + // neste ponto mostra quem originou o erro (o erro em si chega sem pilha). + errorCallback: (self, parent, zone, error, stackTrace) { + if (error is SocketException) { + log('ORIGEM errorCallback tipo=${error.runtimeType} ' + 'errno=${error.osError?.errorCode} ' + 'zonaCorrente=${Zone.current[#marker]} ' + 'pilhaOriginal=${stackTrace == null ? "null" : "presente"}\n' + '${StackTrace.current}'); + } + return parent.errorCallback(zone, error, stackTrace); + }, + )); + await done.future; +} diff --git a/backend/sinalacs_server/test/unit/admin_account_service_test.dart b/backend/sinalacs_server/test/unit/admin_account_service_test.dart new file mode 100644 index 00000000..69db7fe8 --- /dev/null +++ b/backend/sinalacs_server/test/unit/admin_account_service_test.dart @@ -0,0 +1,1446 @@ +import 'dart:math'; + +import 'package:sinalacs_server/src/application/admin/admin_account_service.dart'; +import 'package:sinalacs_server/src/application/admin/initial_password.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/auth/institutional_auth_service.dart'; +import 'package:sinalacs_server/src/application/auth/password_hasher.dart'; +import 'package:sinalacs_server/src/application/auth/staff_activation_code.dart'; +import 'package:sinalacs_server/src/application/auth/totp_secret_vault.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:test/test.dart'; + +/// Regras da gestão de contas do backoffice (#43) sem banco: papel, escopo por +/// UBS e a auditoria de cada leitura. Dados sintéticos. +const _ubsDoCoordenador = 'ubs-a'; + +/// Id em UUID de verdade da prova de normalização: `UuidValue` e o `::uuid` do +/// Postgres normalizam a caixa, então a mesma conta pode chegar grafada de duas +/// formas — e nenhuma delas pode ser tratada como outra conta. +const _uuidDoCoordenador = '00000000-0000-4000-8000-00000000ab01'; + +/// Registra cada chamada com a ordem global, para provar "auditoria antes do dado". +final _ordem = []; + +class _Store implements AdminAccountStore { + AdminScope? ultimoEscopo; + int chamadas = 0; + final Map ubs = { + 'coord-a': _ubsDoCoordenador, + 'coord-sem-ubs': null, + }; + + /// Microáreas conhecidas: a UBS de cada uma é o que decide se o coordenador + /// pode cadastrar nela. `ma-b` é de outra UBS de propósito. + final Map microAreas = { + 'ma-a': (ubsId: _ubsDoCoordenador, name: 'Microárea A'), + 'ma-b': (ubsId: 'ubs-b', name: 'Microárea B'), + }; + + /// ACS conhecidos, por id: o `ubsId` de cada um é o que o escopo compara. + /// `acs-a` é da UBS do coordenador; `acs-b`, da outra. + final acs = { + 'acs-a': AdminAcs( + id: 'acs-a', + name: 'Ana ACS', + enrollmentId: 'ACS-1', + ubsId: _ubsDoCoordenador, + ubsName: 'UBS A', + microAreaId: 'ma-antiga', + microAreaName: 'Microárea Antiga', + active: true, + mfaActive: false, + ), + 'acs-b': AdminAcs( + id: 'acs-b', + name: 'Bia ACS', + enrollmentId: 'ACS-2', + ubsId: 'ubs-b', + ubsName: 'UBS B', + microAreaId: 'ma-b', + microAreaName: 'Microárea B', + active: true, + mfaActive: false, + ), + }; + + /// Matrículas já cadastradas (a pré-checagem do serviço). + final matriculas = {}; + + /// `true` = a pré-checagem passou e o índice único do banco disparou + /// (`insertAcs` devolve `null`): a corrida que o catch de 23505 fecha. + bool corridaDeMatricula = false; + + /// `true` = a pré-checagem de [setAcsMicroArea] achou o ACS e o `UPDATE` + /// condicional do banco não (o ACS saiu do escopo no meio): a corrida que o + /// `WHERE` com o predicado de escopo fecha. + bool corridaDeVinculo = false; + + /// Mesma corrida de [corridaDeVinculo], para [setAcsActive]. + bool corridaDeAtivacao = false; + + int insertAcsChamadas = 0; + int setAcsMicroAreaChamadas = 0; + int setAcsActiveChamadas = 0; + String? ultimaUbs; + String? ultimaMicroArea; + bool? ultimoActive; + PasswordDigest? ultimoDigest; + DateTime? ultimoAt; + + void _marca(String nome, [AdminScope? escopo]) { + chamadas++; + ultimoEscopo = escopo; + _ordem.add('store:$nome'); + } + + @override + Future<({String? ubsId, String? name})?> microAreaFor(String microAreaId) async { + _marca('microAreaFor'); + return microAreas[microAreaId]; + } + + @override + Future enrollmentIdTaken(String enrollmentId) async { + _marca('enrollmentIdTaken'); + return matriculas.contains(enrollmentId); + } + + @override + Future insertAcs({ + required String name, + required String enrollmentId, + required String microAreaId, + required String ubsId, + required PasswordDigest digest, + required DateTime at, + }) async { + _marca('insertAcs'); + insertAcsChamadas++; + ultimaUbs = ubsId; + ultimaMicroArea = microAreaId; + ultimoDigest = digest; + ultimoAt = at; + if (corridaDeMatricula) return null; + return AdminAcs( + id: 'acs-novo-1', + name: name, + enrollmentId: enrollmentId, + ubsId: ubsId, + ubsName: 'UBS A', + microAreaId: microAreaId, + microAreaName: 'Microárea A', + active: true, + mfaActive: false, + ); + } + + @override + Future acsById(AdminScope scope, String acsId) async { + _marca('acsById', scope); + final encontrado = acs[acsId]; + if (encontrado == null) return null; + if (scope.ubsId != null && encontrado.ubsId != scope.ubsId) return null; + return encontrado; + } + + @override + Future setAcsMicroArea({ + required String acsId, + required String microAreaId, + required String ubsId, + required AdminScope scope, + required DateTime at, + }) async { + _marca('setAcsMicroArea', scope); + setAcsMicroAreaChamadas++; + ultimaUbs = ubsId; + ultimaMicroArea = microAreaId; + ultimoAt = at; + final encontrado = acs[acsId]; + if (encontrado == null) return null; + if (scope.ubsId != null && encontrado.ubsId != scope.ubsId) return null; + if (corridaDeVinculo) return null; + final movido = encontrado.copyWith( + ubsId: ubsId, + microAreaId: microAreaId, + microAreaName: microAreas[microAreaId]?.name, + ); + acs[acsId] = movido; + return movido; + } + + @override + Future setAcsActive({ + required String acsId, + required AdminScope scope, + required bool active, + required DateTime at, + }) async { + _marca('setAcsActive', scope); + setAcsActiveChamadas++; + ultimoActive = active; + ultimoAt = at; + final encontrado = acs[acsId]; + if (encontrado == null) return false; + if (scope.ubsId != null && encontrado.ubsId != scope.ubsId) return false; + if (corridaDeAtivacao) return false; + acs[acsId] = encontrado.copyWith(active: active); + return true; + } + + @override + Future> acsList(AdminScope scope) async { + _marca('acsList', scope); + return []; + } + + @override + Future> staffList() async { + _marca('staffList'); + return []; + } + + /// Contas de equipe conhecidas, por id: é o alvo da redefinição da MFA do + /// staff. `staff-inativo` existe e está desativada — a mesma recusa do + /// inexistente, para que o administrador não descubra a existência de uma + /// conta que não pode operar. + final staff = { + 'admin-1': AdminStaff( + id: 'admin-1', + name: 'Elisa Administradora', + enrollmentId: 'ADM-1', + role: UserRole.admin, + ubsName: null, + active: true, + mfaActive: true, + ), + 'coord-a': AdminStaff( + id: 'coord-a', + name: 'Fábio Coordenador', + enrollmentId: 'COO-1', + role: UserRole.coordinator, + ubsName: _ubsDoCoordenador, + active: true, + mfaActive: true, + ), + 'staff-inativo': AdminStaff( + id: 'staff-inativo', + name: 'Gilda Coordenadora Inativa', + enrollmentId: 'COO-2', + role: UserRole.coordinator, + ubsName: _ubsDoCoordenador, + active: false, + mfaActive: false, + ), + _uuidDoCoordenador: AdminStaff( + id: _uuidDoCoordenador, + name: 'Hélio Coordenador UUID', + enrollmentId: 'COO-3', + role: UserRole.coordinator, + ubsName: _ubsDoCoordenador, + active: true, + mfaActive: false, + ), + }; + + @override + Future staffById(String staffId) async { + _marca('staffById'); + return staff[staffId]; + } + + /// O `resetStaffMfa` do store real: `clearTotp` e `issue` numa transação só. + /// Aqui fica o pedido do serviço — o que o teste observa é o que ele mandou + /// gravar (as duas escritas no banco têm prova na suíte de integração). + final resetsDoStaff = + <({String staffId, String codeHash, DateTime expiresAt, String issuedBy, DateTime at})>[]; + + /// `true` = o alvo sumiu (ou foi desativado) entre a checagem do serviço e a + /// transação: o `WHERE` do banco não alcança linha nenhuma e o store devolve + /// `false` — o mesmo desfecho de "não existe". + bool corridaDeResetStaff = false; + + @override + Future resetStaffMfa({ + required String staffId, + required String codeHash, + required DateTime expiresAt, + required String issuedBy, + required DateTime at, + }) async { + _marca('resetStaffMfa'); + if (corridaDeResetStaff) return false; + resetsDoStaff.add(( + staffId: staffId, + codeHash: codeHash, + expiresAt: expiresAt, + issuedBy: issuedBy, + at: at, + )); + return true; + } + + @override + Future ubsOf(String staffId) async => ubs[staffId]; +} + +/// Código de ativação do staff: guarda a emissão pedida pela redefinição da MFA +/// (#43). `find`/`clear` não são do caminho da redefinição — chamá-los aqui é +/// erro de montagem do teste, como no `_NaoUsado` que este fake substitui. +class _Ativacoes implements StaffActivationStore { + final emissoes = + <({String staffId, String codeHash, DateTime expiresAt, String issuedBy, DateTime at})>[]; + + @override + Future issue( + String staffId, { + required String codeHash, + required DateTime expiresAt, + required String issuedBy, + required DateTime at, + }) async { + _ordem.add('activation:issue'); + emissoes.add(( + staffId: staffId, + codeHash: codeHash, + expiresAt: expiresAt, + issuedBy: issuedBy, + at: at, + )); + } + + @override + Future find(String staffId) => + throw UnimplementedError('find não é usado aqui'); + + @override + Future clear(String staffId) => + throw UnimplementedError('clear não é usado aqui'); +} + +/// Credencial do ACS: guarda a substituição pedida pela redefinição de senha — +/// é o que o teste observa, porque o serviço não tem retorno daqui. +class _Credenciais implements AcsCredentialStore { + final trocas = <(String, PasswordDigest, DateTime)>[]; + + @override + Future saveCredential(String acsId, PasswordDigest digest, DateTime at) async { + _ordem.add('credentials:saveCredential'); + trocas.add((acsId, digest, at)); + } + + @override + Future findByEnrollmentId(String enrollmentId) => + throw UnimplementedError('findByEnrollmentId não é usado aqui'); + + @override + Future registerFailedAttempt( + String acsId, { + required bool restartCounter, + required int maxFailedAttempts, + required DateTime lockUntil, + required DateTime at, + }) => throw UnimplementedError('registerFailedAttempt não é usado aqui'); + + @override + Future registerSuccessfulLogin(String acsId, DateTime at) => + throw UnimplementedError('registerSuccessfulLogin não é usado aqui'); +} + +/// Estado da MFA: guarda as limpezas pedidas pela redefinição de MFA. +class _Totp implements TotpStore { + final limpezas = <(String, DateTime)>[]; + + @override + Future clearTotp(String acsId, DateTime at) async { + _ordem.add('totp:clearTotp'); + limpezas.add((acsId, at)); + } + + @override + Future saveSecret(String acsId, SealedSecret secret, DateTime at) => + throw UnimplementedError('saveSecret não é usado aqui'); + + @override + Future enable(String acsId, SealedSecret pending, int step, DateTime at) => + throw UnimplementedError('enable não é usado aqui'); + + @override + Future registerStep(String acsId, int step) => + throw UnimplementedError('registerStep não é usado aqui'); +} + +/// Hasher de mentira: guarda a senha em claro que recebeu, para o teste provar +/// que a credencial gravada é exatamente a que o serviço devolve ao operador. +/// O custo do Argon2id não é o que se prova aqui — a derivação de verdade tem +/// os próprios testes. +class _Hasher implements PasswordHasher { + final senhas = []; + + static const digest = PasswordDigest( + hashBase64: 'hash-sintetico', + saltBase64: 'salt-sintetico', + memoryKb: 512, + iterations: 1, + parallelism: 1, + ); + + @override + Future derive(String password) async { + senhas.add(password); + return digest; + } + + @override + Future matches(String password, PasswordDigest digest) async => false; +} + +class _Audit extends AuditTrail { + final events = []; + bool falhar = false; + + @override + Future record(AuditEvent event) async { + if (falhar) throw StateError('auditoria indisponível'); + _ordem.add('audit:${event.actionType}:${event.resourceType}:${event.result}'); + events.add(event); + } +} + +AuthenticatedUser _u(String id, UserRole role) => AuthenticatedUser( + id: id, + role: role, + microAreaId: null, + deviceId: 'sem-aparelho', +); + +void main() { + late _Store store; + late _Audit audit; + late _Hasher hasher; + late _Credenciais credenciais; + late _Totp totp; + late _Ativacoes ativacoes; + late AdminAccountService servico; + final admin = _u('admin-1', UserRole.admin); + final coordA = _u('coord-a', UserRole.coordinator); + final coordSemUbs = _u('coord-sem-ubs', UserRole.coordinator); + + setUp(() { + _ordem.clear(); + store = _Store(); + audit = _Audit(); + hasher = _Hasher(); + credenciais = _Credenciais(); + totp = _Totp(); + ativacoes = _Ativacoes(); + servico = AdminAccountService( + store: store, + credentials: credenciais, + totpStore: totp, + activationStore: ativacoes, + hasher: hasher, + audit: audit, + // Sorteio e relógio fixos: o teste compara a senha devolvida com a que o + // hasher recebeu, não com um valor escrito à mão. + random: Random(1), + clock: () => DateTime.utc(2026, 10, 8, 12), + ); + }); + + test( + 'admin lista ACS com escopo de sistema e a leitura é auditada ANTES do dado', + () async { + await servico.acsList(admin); + expect(store.ultimoEscopo!.ubsId, isNull); + expect(_ordem, ['audit:read:admin_acs:success', 'store:acsList']); + expect(audit.events.single.actionType, 'read'); + expect(audit.events.single.userId, 'admin-1'); + expect(audit.events.single.resourceType, 'admin_acs'); + }, + ); + + test( + 'coordenador lista só a própria UBS; a leitura é auditada com o recurso admin_acs', + () async { + await servico.acsList(coordA); + expect(store.ultimoEscopo!.ubsId, 'ubs-a'); + expect( + audit.events.single, + isA() + .having((e) => e.actionType, 'actionType', 'read') + .having((e) => e.resourceType, 'resourceType', 'admin_acs'), + ); + }, + ); + + test('staffList é só do administrador: coordenador recebe recusa auditada', () async { + await expectLater( + servico.staffList(coordA), + throwsA(isA()), + ); + expect(audit.events.single.result, 'denied'); + expect(audit.events.single.resourceType, 'admin_staff'); + expect(store.chamadas, 0, reason: 'nenhum dado sai'); + }); + + test('admin lista a equipe e a leitura é auditada ANTES do dado', () async { + await servico.staffList(admin); + expect(_ordem, ['audit:read:admin_staff:success', 'store:staffList']); + expect(audit.events.single.resourceType, 'admin_staff'); + }); + + test( + 'coordenador sem UBS é recusado em acsList e staffList, com auditoria denied', + () async { + await expectLater( + servico.acsList(coordSemUbs), + throwsA(isA()), + ); + await expectLater( + servico.staffList(coordSemUbs), + throwsA(isA()), + ); + expect(store.chamadas, 0); + expect(audit.events, hasLength(2)); + expect(audit.events.map((e) => e.result), everyElement('denied')); + }, + ); + + test( + 'acs e patient com token válido são recusados nas duas listagens', + () async { + for (final role in [UserRole.acs, UserRole.patient]) { + final u = _u('u-${role.name}', role); + await expectLater( + servico.acsList(u), + throwsA(isA()), + ); + await expectLater( + servico.staffList(u), + throwsA(isA()), + ); + } + expect(store.chamadas, 0); + expect(audit.events, hasLength(4)); + expect(audit.events.map((e) => e.result), everyElement('denied')); + }, + ); + + test('se a auditoria falha, o store não é chamado (fail-closed)', () async { + audit.falhar = true; + await expectLater(servico.acsList(admin), throwsA(isA())); + expect(store.chamadas, 0); + }); + + group('createAcs', () { + test( + 'nome/matrícula/microárea inválidos → AdminInvalidRequestException, nada escrito, recusa auditada', + () async { + final casos = <(AuthenticatedUser, String, String, String, String)>[ + // (operador, nome, matrícula, microárea, mensagem) + (admin, ' ', 'ACS-43-1', 'ma-a', 'Informe nome e matrícula do ACS.'), + (admin, 'Nova ACS', ' ', 'ma-a', 'Informe nome e matrícula do ACS.'), + (admin, 'Nova ACS', 'ACS-43-1', 'inexistente', 'Microárea não encontrada.'), + // Microárea de OUTRA UBS recebe a MESMA mensagem de "não existe": o + // coordenador não descobre território alheio por tentativa. + (coordA, 'Nova ACS', 'ACS-43-1', 'ma-b', 'Microárea não encontrada.'), + ]; + for (final (operador, nome, matricula, microarea, mensagem) in casos) { + await expectLater( + servico.createAcs( + operador, + name: nome, + enrollmentId: matricula, + microAreaId: microarea, + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + mensagem, + ), + ), + ); + } + // Os limites de tamanho entram na mesma validação do vazio. + for (final (nome, matricula) in [ + ('N' * 121, 'ACS-43-1'), + ('Nova ACS', 'A' * 33), + ]) { + await expectLater( + servico.createAcs( + admin, + name: nome, + enrollmentId: matricula, + microAreaId: 'ma-a', + ), + throwsA(isA()), + ); + } + + expect(audit.events, hasLength(casos.length + 2)); + expect(audit.events.map((e) => e.actionType), everyElement('write')); + expect(audit.events.map((e) => e.result), everyElement('denied')); + expect(store.insertAcsChamadas, 0, reason: 'nada é escrito'); + expect(hasher.senhas, isEmpty, reason: 'nem a senha chega a ser sorteada'); + }, + ); + + test( + 'coordenador cadastra na própria UBS e a auditoria registra created com o id do novo ACS', + () async { + final resultado = await servico.createAcs( + coordA, + name: ' Nova ACS ', + enrollmentId: ' ACS-43-1 ', + microAreaId: 'ma-a', + ); + + expect(resultado.acs.name, 'Nova ACS', reason: 'trim do nome'); + expect(resultado.acs.enrollmentId, 'ACS-43-1', reason: 'trim da matrícula'); + expect( + resultado.acs.ubsId, + _ubsDoCoordenador, + reason: 'a UBS vem da microárea escolhida, nunca do pedido', + ); + expect(store.ultimaUbs, _ubsDoCoordenador); + expect(store.ultimaMicroArea, 'ma-a'); + expect(store.ultimoAt, DateTime.utc(2026, 10, 8, 12)); + expect(store.ultimoDigest, _Hasher.digest); + + expect( + resultado.initialPassword, + matches( + RegExp( + r'^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{4}' + r'(-[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{4}){3}$', + ), + ), + ); + expect( + hasher.senhas, + [resultado.initialPassword], + reason: 'a credencial gravada é exatamente a senha devolvida ao operador', + ); + + expect( + _ordem, + [ + 'store:microAreaFor', + 'store:enrollmentIdTaken', + 'store:insertAcs', + 'audit:write:admin_acs:created', + ], + reason: 'a linha de sucesso entra DEPOIS do commit', + ); + final linha = audit.events.single; + expect(linha.userId, 'coord-a'); + expect(linha.actionType, 'write'); + expect(linha.resourceType, 'admin_acs'); + expect(linha.result, 'created'); + expect(linha.resourceId, resultado.acs.id); + }, + ); + + test('matrícula duplicada → mesma mensagem de validação, nunca erro de servidor', () async { + const mensagem = 'Já existe um ACS com esta matrícula.'; + + // Caso comum: a pré-checagem vê a matrícula e nada é escrito. + store.matriculas.add('ACS-43-1'); + await expectLater( + servico.createAcs( + admin, + name: 'Nova ACS', + enrollmentId: 'ACS-43-1', + microAreaId: 'ma-a', + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + mensagem, + ), + ), + ); + expect(store.insertAcsChamadas, 0); + + // Corrida: a pré-checagem passou e quem barrou foi o índice único do + // banco (`insertAcs` devolveu null) — a mesma resposta, não um 500. + store.matriculas.clear(); + store.corridaDeMatricula = true; + await expectLater( + servico.createAcs( + admin, + name: 'Nova ACS', + enrollmentId: 'ACS-43-1', + microAreaId: 'ma-a', + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + mensagem, + ), + ), + ); + expect(store.insertAcsChamadas, 1); + + expect(audit.events, hasLength(2)); + expect(audit.events.map((e) => e.actionType), everyElement('write')); + expect(audit.events.map((e) => e.result), everyElement('denied')); + expect(hasher.senhas, hasLength(1), reason: 'só a corrida chega a sortear a senha'); + }); + + test('papel fora do backoffice e coordenador sem UBS recebem recusa auditada', () async { + for (final usuario in [ + _u('u-acs', UserRole.acs), + _u('u-paciente', UserRole.patient), + coordSemUbs, + ]) { + await expectLater( + servico.createAcs( + usuario, + name: 'Nova ACS', + enrollmentId: 'ACS-43-1', + microAreaId: 'ma-a', + ), + throwsA(isA()), + ); + } + expect(store.chamadas, 0); + expect(audit.events, hasLength(3)); + expect( + audit.events.map((e) => e.actionType), + everyElement('write'), + reason: 'a linha descreve a TENTATIVA de escrita, não uma leitura', + ); + expect(audit.events.map((e) => e.result), everyElement('denied')); + }); + }); + + group('setAcsMicroArea', () { + test( + 'microárea inexistente e microárea de outra UBS → mesma mensagem, nada escrito, recusa auditada', + () async { + final casos = <(AuthenticatedUser, String, String)>[ + // (operador, acsId, microárea-alvo) + (admin, 'acs-a', 'inexistente'), + // Microárea de OUTRA UBS recebe a MESMA mensagem de "não existe". + (coordA, 'acs-a', 'ma-b'), + ]; + for (final (operador, acsId, microarea) in casos) { + await expectLater( + servico.setAcsMicroArea(operador, acsId: acsId, microAreaId: microarea), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Microárea não encontrada.', + ), + ), + ); + } + expect(store.setAcsMicroAreaChamadas, 0, reason: 'nada é escrito'); + expect( + _ordem.where((c) => c.startsWith('store:')), + ['store:microAreaFor', 'store:microAreaFor'], + reason: 'a microárea-alvo é validada antes de qualquer leitura do ACS', + ); + expect(audit.events, hasLength(casos.length)); + expect(audit.events.map((e) => e.actionType), everyElement('write')); + expect(audit.events.map((e) => e.result), everyElement('denied')); + }, + ); + + test('ACS inexistente e ACS de outra UBS → mesma recusa auditada', () async { + final casos = <(AuthenticatedUser, String)>[ + (admin, 'acs-inexistente'), + // ACS da outra UBS: o coordenador não descobre território alheio. + (coordA, 'acs-b'), + ]; + for (final (operador, acsId) in casos) { + await expectLater( + servico.setAcsMicroArea(operador, acsId: acsId, microAreaId: 'ma-a'), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'ACS não encontrado.', + ), + ), + ); + } + expect(store.setAcsMicroAreaChamadas, 0, reason: 'nada é escrito'); + expect(audit.events, hasLength(casos.length)); + expect(audit.events.map((e) => e.actionType), everyElement('write')); + expect(audit.events.map((e) => e.result), everyElement('denied')); + }); + + test('coordenador move o ACS da própria UBS e a trilha registra micro_area_changed com o id', () async { + final resultado = await servico.setAcsMicroArea( + coordA, + acsId: 'acs-a', + microAreaId: 'ma-a', + ); + + expect(resultado.id, 'acs-a'); + expect(resultado.microAreaId, 'ma-a'); + expect(resultado.microAreaName, 'Microárea A'); + expect(resultado.ubsId, _ubsDoCoordenador, reason: 'a UBS vem da microárea-alvo'); + expect(store.ultimaUbs, _ubsDoCoordenador); + expect(store.ultimaMicroArea, 'ma-a'); + expect(store.ultimoAt, DateTime.utc(2026, 10, 8, 12)); + + expect( + _ordem, + [ + 'store:microAreaFor', + 'store:acsById', + 'store:setAcsMicroArea', + 'store:acsById', + 'audit:write:admin_acs:micro_area_changed', + ], + reason: 'a linha de sucesso entra DEPOIS do commit, e a releitura é do serviço', + ); + final linha = audit.events.single; + expect(linha.userId, 'coord-a'); + expect(linha.actionType, 'write'); + expect(linha.resourceType, 'admin_acs'); + expect(linha.result, 'micro_area_changed'); + expect(linha.resourceId, 'acs-a'); + }); + + test('administrador move entre UBS: a UBS nova vem da microárea-alvo, não do escopo', () async { + final resultado = await servico.setAcsMicroArea( + admin, + acsId: 'acs-a', + microAreaId: 'ma-b', + ); + + expect( + store.ultimaUbs, + 'ubs-b', + reason: 'o escopo do administrador é nulo; a UBS do vínculo é a da microárea', + ); + expect(resultado.ubsId, 'ubs-b'); + expect(resultado.microAreaId, 'ma-b'); + expect(resultado.microAreaName, 'Microárea B'); + }); + + test('corrida: o ACS sai do escopo entre a pré-checagem e o UPDATE → mesma recusa', () async { + // A pré-checagem viu o ACS; o UPDATE condicional do banco não achou + // (`setAcsMicroArea` devolve null). A resposta é a mesma de "não existe". + store.corridaDeVinculo = true; + await expectLater( + servico.setAcsMicroArea(coordA, acsId: 'acs-a', microAreaId: 'ma-a'), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'ACS não encontrado.', + ), + ), + ); + expect(store.setAcsMicroAreaChamadas, 1); + expect(audit.events.single.actionType, 'write'); + expect(audit.events.single.result, 'denied'); + }); + + test('papel fora do backoffice e coordenador sem UBS recebem recusa auditada', () async { + for (final usuario in [ + _u('u-acs', UserRole.acs), + _u('u-paciente', UserRole.patient), + coordSemUbs, + ]) { + await expectLater( + servico.setAcsMicroArea(usuario, acsId: 'acs-a', microAreaId: 'ma-a'), + throwsA(isA()), + ); + } + expect(store.chamadas, 0, reason: 'nem a microárea-alvo é consultada'); + expect(audit.events, hasLength(3)); + expect( + audit.events.map((e) => e.actionType), + everyElement('write'), + reason: 'a linha descreve a TENTATIVA de escrita, não uma leitura', + ); + expect(audit.events.map((e) => e.result), everyElement('denied')); + }); + }); + + group('setAcsActive', () { + test( + 'desativar devolve a linha inativa e audita deactivated DEPOIS do commit', + () async { + final resultado = await servico.setAcsActive( + admin, + acsId: 'acs-a', + active: false, + ); + + expect(resultado.id, 'acs-a'); + expect(resultado.active, isFalse); + expect(store.ultimoActive, isFalse); + expect(store.ultimoAt, DateTime.utc(2026, 10, 8, 12)); + expect(store.ultimoEscopo!.ubsId, isNull, reason: 'escopo do administrador'); + + expect( + _ordem, + [ + 'store:acsById', + 'store:setAcsActive', + 'store:acsById', + 'audit:write:admin_acs:deactivated', + ], + reason: 'a linha de sucesso entra DEPOIS do commit, e a releitura é do serviço', + ); + final linha = audit.events.single; + expect(linha.userId, 'admin-1'); + expect(linha.actionType, 'write'); + expect(linha.resourceType, 'admin_acs'); + expect(linha.result, 'deactivated'); + expect(linha.resourceId, 'acs-a'); + }, + ); + + test('coordenador desativa o ACS da própria UBS no escopo de UBS', () async { + final resultado = await servico.setAcsActive( + coordA, + acsId: 'acs-a', + active: false, + ); + + expect(store.ultimoEscopo!.ubsId, _ubsDoCoordenador); + expect(resultado.active, isFalse); + expect(audit.events.single.result, 'deactivated'); + expect(audit.events.single.resourceId, 'acs-a'); + }); + + test( + 'reativar audita activated e devolve a linha ativa de novo', + () async { + await servico.setAcsActive(admin, acsId: 'acs-a', active: false); + final resultado = await servico.setAcsActive( + admin, + acsId: 'acs-a', + active: true, + ); + + expect(resultado.active, isTrue); + expect(store.ultimoActive, isTrue); + expect( + audit.events.map((e) => e.result), + ['deactivated', 'activated'], + reason: 'cada pedido do operador vira uma linha, com o desfecho do pedido', + ); + expect(audit.events.map((e) => e.resourceId), everyElement('acs-a')); + }, + ); + + test('desativar um ACS já inativo é idempotente e audita uma linha nova', () async { + final primeira = await servico.setAcsActive(admin, acsId: 'acs-a', active: false); + final segunda = await servico.setAcsActive(admin, acsId: 'acs-a', active: false); + + expect(primeira.active, isFalse); + expect(segunda.active, isFalse); + expect(store.setAcsActiveChamadas, 2, reason: 'o segundo pedido também escreve'); + expect( + audit.events.map((e) => e.result), + ['deactivated', 'deactivated'], + reason: 'idempotente não é silencioso: a trilha registra os dois pedidos', + ); + }); + + test('ACS inexistente e ACS de outra UBS → mesma recusa auditada, nada escrito', () async { + final casos = <(AuthenticatedUser, String)>[ + (admin, 'acs-inexistente'), + // ACS da outra UBS: o coordenador não descobre território alheio. + (coordA, 'acs-b'), + ]; + for (final (operador, acsId) in casos) { + await expectLater( + servico.setAcsActive(operador, acsId: acsId, active: false), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'ACS não encontrado.', + ), + ), + ); + } + expect(store.setAcsActiveChamadas, 0, reason: 'nada é escrito'); + expect(audit.events, hasLength(casos.length)); + expect(audit.events.map((e) => e.actionType), everyElement('write')); + expect(audit.events.map((e) => e.result), everyElement('denied')); + }); + + test('corrida: o ACS sai do escopo entre a pré-checagem e o UPDATE → mesma recusa', () async { + store.corridaDeAtivacao = true; + await expectLater( + servico.setAcsActive(coordA, acsId: 'acs-a', active: false), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'ACS não encontrado.', + ), + ), + ); + expect(store.setAcsActiveChamadas, 1); + expect(audit.events.single.actionType, 'write'); + expect(audit.events.single.result, 'denied'); + }); + + test('papel fora do backoffice e coordenador sem UBS recebem recusa auditada', () async { + for (final usuario in [ + _u('u-acs', UserRole.acs), + _u('u-paciente', UserRole.patient), + coordSemUbs, + ]) { + await expectLater( + servico.setAcsActive(usuario, acsId: 'acs-a', active: false), + throwsA(isA()), + ); + } + expect(store.chamadas, 0, reason: 'nem o alvo é lido'); + expect(audit.events, hasLength(3)); + expect( + audit.events.map((e) => e.actionType), + everyElement('write'), + reason: 'a linha descreve a TENTATIVA de escrita, não uma leitura', + ); + expect(audit.events.map((e) => e.result), everyElement('denied')); + }); + }); + + group('resetAcsPassword', () { + test( + 'admin redefine: senha nova sorteada, Argon2id derivado e auditoria password_reset DEPOIS do commit', + () async { + final resultado = await servico.resetAcsPassword(admin, acsId: 'acs-a'); + + expect( + resultado.newPassword, + matches( + RegExp( + r'^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{4}' + r'(-[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{4}){3}$', + ), + ), + reason: 'o mesmo formato da senha inicial: o operador não escolhe a senha', + ); + expect( + hasher.senhas, + [resultado.newPassword], + reason: 'a credencial gravada é exatamente a senha devolvida ao operador', + ); + expect(credenciais.trocas, [ + ('acs-a', _Hasher.digest, DateTime.utc(2026, 10, 8, 12)), + ]); + + expect( + _ordem, + [ + 'store:acsById', + 'credentials:saveCredential', + 'audit:write:admin_acs:password_reset', + ], + reason: 'o alvo é validado antes do sorteio e a linha de sucesso entra DEPOIS do commit', + ); + final linha = audit.events.single; + expect(linha.userId, 'admin-1'); + expect(linha.actionType, 'write'); + expect(linha.resourceType, 'admin_acs'); + expect(linha.result, 'password_reset'); + expect(linha.resourceId, 'acs-a'); + }, + ); + + test('coordenador redefine a senha do ACS da própria UBS, com o escopo de UBS', () async { + final resultado = await servico.resetAcsPassword(coordA, acsId: 'acs-a'); + + expect(resultado.newPassword, isNotEmpty); + expect(store.ultimoEscopo!.ubsId, _ubsDoCoordenador); + expect(audit.events.single.result, 'password_reset'); + expect(audit.events.single.resourceId, 'acs-a'); + }); + + test('ACS inexistente e ACS de outra UBS → mesma recusa auditada, nada escrito', () async { + final casos = <(AuthenticatedUser, String)>[ + (admin, 'acs-inexistente'), + // ACS da outra UBS: o coordenador não descobre território alheio. + (coordA, 'acs-b'), + ]; + for (final (operador, acsId) in casos) { + await expectLater( + servico.resetAcsPassword(operador, acsId: acsId), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'ACS não encontrado.', + ), + ), + ); + } + expect(credenciais.trocas, isEmpty, reason: 'a credencial antiga fica como está'); + expect( + hasher.senhas, + isEmpty, + reason: 'nem a senha é sorteada para um alvo fora do escopo (derivar é caro)', + ); + expect(audit.events, hasLength(casos.length)); + expect(audit.events.map((e) => e.actionType), everyElement('write')); + expect(audit.events.map((e) => e.result), everyElement('denied')); + }); + + test('papel fora do backoffice e coordenador sem UBS recebem recusa auditada', () async { + for (final usuario in [ + _u('u-acs', UserRole.acs), + _u('u-paciente', UserRole.patient), + coordSemUbs, + ]) { + await expectLater( + servico.resetAcsPassword(usuario, acsId: 'acs-a'), + throwsA(isA()), + ); + } + expect(store.chamadas, 0, reason: 'nem o alvo é lido'); + expect(audit.events, hasLength(3)); + expect( + audit.events.map((e) => e.actionType), + everyElement('write'), + reason: 'a linha descreve a TENTATIVA de escrita, não uma leitura', + ); + expect(audit.events.map((e) => e.result), everyElement('denied')); + }); + }); + + group('resetAcsMfa', () { + test('zera as quatro colunas totp* e audita mfa_reset com o id DEPOIS da limpeza', () async { + await servico.resetAcsMfa(admin, acsId: 'acs-a'); + + expect(totp.limpezas, [('acs-a', DateTime.utc(2026, 10, 8, 12))]); + expect(_ordem, [ + 'store:acsById', + 'totp:clearTotp', + 'audit:write:admin_acs:mfa_reset', + ]); + final linha = audit.events.single; + expect(linha.userId, 'admin-1'); + expect(linha.actionType, 'write'); + expect(linha.resourceType, 'admin_acs'); + expect(linha.result, 'mfa_reset'); + expect(linha.resourceId, 'acs-a'); + }); + + test('ACS sem MFA é idempotente: a limpeza é pedida do mesmo jeito e audita mfa_reset', () async { + // `acs-a` não tem MFA (a limpeza não acha nada a apagar): a operação + // conclui, e o que a trilha registra é o pedido do operador. + await servico.resetAcsMfa(coordA, acsId: 'acs-a'); + await servico.resetAcsMfa(coordA, acsId: 'acs-a'); + + expect(totp.limpezas, hasLength(2)); + expect(audit.events, hasLength(2)); + expect(audit.events.map((e) => e.result), ['mfa_reset', 'mfa_reset']); + expect(audit.events.map((e) => e.resourceId), everyElement('acs-a')); + }); + + test('ACS inexistente e ACS de outra UBS → mesma recusa auditada, nada limpo', () async { + final casos = <(AuthenticatedUser, String)>[ + (admin, 'acs-inexistente'), + (coordA, 'acs-b'), + ]; + for (final (operador, acsId) in casos) { + await expectLater( + servico.resetAcsMfa(operador, acsId: acsId), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'ACS não encontrado.', + ), + ), + ); + } + expect(totp.limpezas, isEmpty, reason: 'a MFA de um alvo fora do escopo fica como está'); + expect(audit.events, hasLength(casos.length)); + expect(audit.events.map((e) => e.actionType), everyElement('write')); + expect(audit.events.map((e) => e.result), everyElement('denied')); + }); + + test('papel fora do backoffice e coordenador sem UBS recebem recusa auditada', () async { + for (final usuario in [ + _u('u-acs', UserRole.acs), + _u('u-paciente', UserRole.patient), + coordSemUbs, + ]) { + await expectLater( + servico.resetAcsMfa(usuario, acsId: 'acs-a'), + throwsA(isA()), + ); + } + expect(store.chamadas, 0); + expect(audit.events, hasLength(3)); + expect(audit.events.map((e) => e.actionType), everyElement('write')); + expect(audit.events.map((e) => e.result), everyElement('denied')); + }); + }); + + group('resetStaffMfa do staff', () { + test( + 'admin redefine: código novo de 24 h, só o SHA-256 gravado, issuedBy do ator e trilha mfa_reset DEPOIS do commit', + () async { + final resultado = await servico.resetStaffMfa(admin, staffId: 'coord-a'); + + expect( + resultado.activationCode, + matches(RegExp(r'^[A-Z2-7]{4}(-[A-Z2-7]{4}){5}-[A-Z2-7]{2}$')), + reason: 'o mesmo formato do código da CLI (#48): 130 bits em grupos de 4', + ); + final gravado = store.resetsDoStaff.single; + expect(gravado.staffId, 'coord-a'); + expect( + gravado.issuedBy, + 'admin-1', + reason: 'a coluna guarda o UUID do ator; o rótulo humano vem da linha de auditoria', + ); + expect(gravado.at, DateTime.utc(2026, 10, 8, 12)); + expect( + gravado.expiresAt, + DateTime.utc(2026, 10, 8, 12).add(const Duration(hours: 24)), + reason: 'a validade é a do código da CLI (`StaffActivationCode.defaultValidity`)', + ); + expect(resultado.activationCodeExpiresAt, gravado.expiresAt); + expect( + StaffActivationCode.matches(resultado.activationCode, gravado.codeHash), + isTrue, + reason: 'o código devolvido é o único que casa com o hash gravado', + ); + expect( + gravado.codeHash, + isNot(contains(resultado.activationCode)), + reason: 'o código em claro não existe no servidor: só o SHA-256 chega ao store', + ); + expect( + resultado.activationCode, + isNot(gravado.codeHash), + reason: 'nem o próprio hash é o código: a resposta devolve o valor sorteado', + ); + + expect(_ordem, [ + 'store:staffById', + 'store:resetStaffMfa', + 'audit:write:admin_staff:mfa_reset', + ]); + final linha = audit.events.single; + expect(linha.userId, 'admin-1'); + expect(linha.actionType, 'write'); + expect(linha.resourceType, 'admin_staff'); + expect(linha.result, 'mfa_reset'); + expect(linha.resourceId, 'coord-a'); + }, + ); + + test('cada pedido emite um código novo: o anterior deixa de valer', () async { + final primeiro = await servico.resetStaffMfa(admin, staffId: 'coord-a'); + final segundo = await servico.resetStaffMfa(admin, staffId: 'coord-a'); + + expect(segundo.activationCode, isNot(primeiro.activationCode)); + expect(store.resetsDoStaff, hasLength(2)); + expect(store.resetsDoStaff.map((r) => r.codeHash).toSet(), hasLength(2)); + expect( + StaffActivationCode.matches(segundo.activationCode, store.resetsDoStaff.last.codeHash), + isTrue, + ); + }); + + test( + 'só o administrador redefine a MFA do staff: o coordenador é recusado, inclusive para a própria conta', + () async { + for (final alvo in ['coord-a', 'coord-sem-ubs']) { + await expectLater( + servico.resetStaffMfa(coordA, staffId: alvo), + throwsA(isA()), + ); + } + expect(store.chamadas, 0, reason: 'nem o alvo é lido: a recusa é de papel'); + expect(ativacoes.emissoes, isEmpty); + expect(store.resetsDoStaff, isEmpty); + expect(audit.events, hasLength(2)); + expect(audit.events.map((e) => e.actionType), everyElement('write')); + expect(audit.events.map((e) => e.resourceType), everyElement('admin_staff')); + expect(audit.events.map((e) => e.result), everyElement('denied')); + expect(audit.events.map((e) => e.userId), everyElement('coord-a')); + }, + ); + + test('papel fora do backoffice é recusado com a mesma trilha denied', () async { + for (final usuario in [ + _u('u-acs', UserRole.acs), + _u('u-paciente', UserRole.patient), + ]) { + await expectLater( + servico.resetStaffMfa(usuario, staffId: 'coord-a'), + throwsA(isA()), + ); + } + expect(store.chamadas, 0); + expect(audit.events, hasLength(2)); + expect(audit.events.map((e) => e.actionType), everyElement('write')); + expect(audit.events.map((e) => e.result), everyElement('denied')); + }); + + test('uma conta não redefine a própria MFA: pede a outro administrador', () async { + await expectLater( + servico.resetStaffMfa(admin, staffId: 'admin-1'), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Uma conta não redefine a própria MFA: peça a outro administrador.', + ), + ), + ); + expect( + store.chamadas, + 0, + reason: 'a recusa é do chamador sobre si mesmo: nem o alvo é lido', + ); + expect(store.resetsDoStaff, isEmpty); + expect(audit.events.single.result, 'denied'); + expect(audit.events.single.actionType, 'write'); + expect(audit.events.single.resourceType, 'admin_staff'); + expect(audit.events.single.userId, 'admin-1'); + }); + + test( + 'a própria conta com o id em maiúsculas também é recusada: a comparação é canônica', + () async { + // `UuidValue` e o `::uuid` do Postgres normalizam a caixa; a comparação + // ingênua de strings, não — e o administrador que manda o próprio id + // com uma letra maiúscula redefiniria a própria MFA, exatamente o que a + // decisão 9.4 do plano proíbe. + const meuId = '00000000-0000-4000-8000-00000000ab01'; + final eu = _u(meuId, UserRole.admin); + + await expectLater( + servico.resetStaffMfa(eu, staffId: meuId.toUpperCase()), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Uma conta não redefine a própria MFA: peça a outro administrador.', + ), + ), + ); + expect(store.chamadas, 0, reason: 'a recusa vem antes de ler o alvo'); + expect(store.resetsDoStaff, isEmpty); + expect(audit.events.single.result, 'denied'); + expect(audit.events.single.userId, meuId); + }, + ); + + test('o alvo é normalizado: o store e a trilha recebem o id canônico', () async { + // O alvo chega com letra maiúscula e sai em minúsculas: duas linhas de + // auditoria do mesmo alvo não podem parecer dois alvos diferentes. + final resultado = await servico.resetStaffMfa( + admin, + staffId: _uuidDoCoordenador.toUpperCase(), + ); + + expect(store.resetsDoStaff.single.staffId, _uuidDoCoordenador); + expect(audit.events.single.resourceId, _uuidDoCoordenador); + expect( + StaffActivationCode.matches( + resultado.activationCode, + store.resetsDoStaff.single.codeHash, + ), + isTrue, + ); + }); + + test( + 'alvo inexistente e conta inativa recebem a mesma recusa auditada, sem emissão', + () async { + final casos = ['staff-inexistente', 'staff-inativo']; + for (final staffId in casos) { + await expectLater( + servico.resetStaffMfa(admin, staffId: staffId), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Conta de equipe não encontrada.', + ), + ), + ); + } + expect( + store.resetsDoStaff, + isEmpty, + reason: 'nem o código é sorteado para um alvo que não pode entrar', + ); + expect(audit.events, hasLength(casos.length)); + expect(audit.events.map((e) => e.result), everyElement('denied')); + expect(audit.events.map((e) => e.resourceType), everyElement('admin_staff')); + }); + + test( + 'corrida: o alvo sai do ar entre a checagem e a transação → mesma recusa, nada emitido', + () async { + // A pré-checagem acha a conta; o `WHERE` da transação (existência e + // `active`) não. O desfecho é o de "não existe", e a trilha não mente + // dizendo que a MFA foi redefinida. + store.corridaDeResetStaff = true; + + await expectLater( + servico.resetStaffMfa(admin, staffId: 'coord-a'), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Conta de equipe não encontrada.', + ), + ), + ); + expect(store.resetsDoStaff, isEmpty); + expect(audit.events.single.result, 'denied'); + }, + ); + }); + + group('AcsInitialPassword', () { + test('generate: 16 caracteres do alfabeto, em 4 grupos de 4', () { + final senha = AcsInitialPassword.generate(Random(1)); + expect(senha, hasLength(19), reason: '16 caracteres e 3 hífens'); + expect( + senha, + matches( + RegExp( + r'^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{4}' + r'(-[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{4}){3}$', + ), + ), + ); + expect( + senha, + isNot(contains(RegExp('[01ILO]'))), + reason: 'o alfabeto exclui os caracteres que se confundem ao ler e ao digitar', + ); + }); + + test('generate: dois sorteios diferem', () { + expect( + AcsInitialPassword.generate(Random(1)), + isNot(AcsInitialPassword.generate(Random(2))), + ); + final semSemente = {for (var i = 0; i < 8; i++) AcsInitialPassword.generate()}; + expect(semSemente, hasLength(8), reason: 'sem semente, o sorteio é `Random.secure()`'); + }); + }); +} diff --git a/backend/sinalacs_server/test/unit/admin_labels_test.dart b/backend/sinalacs_server/test/unit/admin_labels_test.dart new file mode 100644 index 00000000..358c430b --- /dev/null +++ b/backend/sinalacs_server/test/unit/admin_labels_test.dart @@ -0,0 +1,34 @@ +import 'package:sinalacs_server/src/application/admin/admin_labels.dart'; +import 'package:test/test.dart'; + +void main() { + const id = '5b6f2c1e-0000-4000-8000-00000000a18f'; + + test('rótulo do paciente = # + últimos 4 hex em maiúsculas', () { + expect(AdminLabels.patient(id), '#A18F'); + expect(AdminLabels.patient('abc'), '#????'); + }); + + test('rótulo do usuário nunca contém nome; paciente vira Paciente #XXXX', () { + expect(AdminLabels.user(role: 'patient', id: id), 'Paciente #A18F'); + expect( + AdminLabels.user(role: 'admin', id: id, enrollmentId: 'ADM-001'), + 'ADM-001 (Administrador)', + ); + expect( + AdminLabels.user(role: 'acs', id: id, enrollmentId: 'ACS-001'), + 'ACS-001 (ACS)', + ); + expect(AdminLabels.user(role: 'coordinator', id: id), 'Coordenador #A18F'); + }); + + test( + 'paciente ignora matrícula (não há matrícula de paciente a mostrar)', + () { + expect( + AdminLabels.user(role: 'patient', id: id, enrollmentId: 'X'), + 'Paciente #A18F', + ); + }, + ); +} diff --git a/backend/sinalacs_server/test/unit/admin_monitoring_service_test.dart b/backend/sinalacs_server/test/unit/admin_monitoring_service_test.dart new file mode 100644 index 00000000..b33476b2 --- /dev/null +++ b/backend/sinalacs_server/test/unit/admin_monitoring_service_test.dart @@ -0,0 +1,341 @@ +import 'package:sinalacs_server/src/application/admin/admin_monitoring_service.dart'; +import 'package:sinalacs_server/src/application/admin/admin_read_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/generated/protocol.dart'; +import 'package:test/test.dart'; + +/// Pulso da monitoração do backoffice e sessão de monitoramento (#44, D5) sem +/// banco: relógio e store falsos. O pulso não grava `read`; a sessão abre com +/// START fail-closed e fecha com END best-effort. Dados sintéticos. +const _ubsA = 'ubs-a'; +const _limite = Duration(seconds: 300); + +final _ordem = []; + +class _Store implements AdminReadStore { + final Map ubs = {'coord-a': _ubsA, 'coord-sem-ubs': null}; + AdminScope? ultimoEscopo; + DateTime? ultimoAgora; + Duration? ultimoLimite; + int chamadas = 0; + final maxChangedAt = DateTime.utc(2026, 10, 11, 11, 59); + + @override + Future ubsOf(String staffId) async => ubs[staffId]; + + @override + Future indicators( + AdminScope scope, { + required DateTime now, + }) async { + chamadas++; + ultimoEscopo = scope; + _ordem.add('store:indicators'); + return AdminIndicators( + red: 3, + yellow: 1, + green: 2, + openRedAlerts: 2, + acknowledgedRedAlerts: 1, + tmravSeconds: 42, + ); + } + + @override + Future pulse( + AdminScope scope, { + required DateTime now, + required Duration threshold, + }) async { + chamadas++; + ultimoEscopo = scope; + ultimoAgora = now; + ultimoLimite = threshold; + _ordem.add('store:pulse'); + return (overdueRed: 1, maxChangedAt: maxChangedAt); + } + + @override + Future> microAreas(AdminScope scope) => + throw UnimplementedError(); + + @override + Future alerts( + AdminScope scope, { + String? microAreaId, + AlertStatus? status, + required int limit, + required int offset, + required DateTime now, + }) => throw UnimplementedError(); + + @override + Future auditLogs( + AdminScope scope, { + AdminAuditFilter? filter, + required int limit, + int? beforeSequence, + }) => throw UnimplementedError(); +} + +class _Audit extends AuditTrail { + final events = []; + bool falhar = false; + + @override + Future record(AuditEvent event) async { + if (falhar) throw StateError('auditoria indisponível'); + _ordem.add('audit:${event.actionType}:${event.result}'); + events.add(event); + } + + Iterable get acoes => events.map((e) => e.actionType); +} + +AuthenticatedUser _u(String id, UserRole role, {String? ma}) => + AuthenticatedUser( + id: id, + role: role, + microAreaId: ma, + deviceId: 'sem-aparelho', + ); + +void main() { + late _Store store; + late _Audit audit; + late Map sessoes; + late DateTime agora; + late AdminMonitoringService servico; + final admin = _u('admin-1', UserRole.admin); + final coordA = _u('coord-a', UserRole.coordinator); + final coordSemUbs = _u('coord-sem-ubs', UserRole.coordinator); + final acs = _u('acs-1', UserRole.acs, ma: 'ma-1'); + final paciente = _u('pac-1', UserRole.patient, ma: 'ma-1'); + final t0 = DateTime.utc(2026, 10, 11, 12); + + setUp(() { + _ordem.clear(); + store = _Store(); + audit = _Audit(); + sessoes = {}; + agora = t0; + servico = AdminMonitoringService( + store: store, + audit: audit, + sessions: sessoes, + threshold: _limite, + clock: () => agora, + ); + }); + + group('pulso', () { + test( + 'embute os indicadores, overdueRed, maxChangedAt e o relógio do servidor', + () async { + final p = await servico.alertsPulse(coordA); + expect(p.indicators.red, 3); + expect(p.indicators.tmravSeconds, 42); + expect(p.overdueRed, 1); + expect(p.maxChangedAt, store.maxChangedAt); + expect(p.serverNow, t0); + expect(p.serverNow.isUtc, isTrue); + expect(store.ultimoEscopo!.ubsId, _ubsA); + expect(store.ultimoAgora, t0); + expect(store.ultimoLimite, _limite); + }, + ); + + test('administrador pulsa com escopo de sistema', () async { + await servico.alertsPulse(admin); + expect(store.ultimoEscopo!.ubsId, isNull); + }); + + test( + 'NÃO grava linha read: só o START, e ele vem antes de qualquer dado', + () async { + await servico.alertsPulse(coordA); + expect(audit.acoes, ['monitoring_session_start']); + final start = audit.events.single; + expect(start.resourceType, 'admin_monitoring'); + expect(start.result, 'success'); + expect(start.resourceId, isNull); + expect(start.userId, 'coord-a'); + expect(_ordem.first, 'audit:monitoring_session_start:success'); + expect(_ordem, isNot(contains(startsWith('audit:read')))); + }, + ); + + test( + 'dois pulsos dentro de 90 s: um START; o terceiro, 91 s depois do segundo, abre outro', + () async { + await servico.alertsPulse(coordA); + agora = t0.add(const Duration(seconds: 30)); + await servico.alertsPulse(coordA); + expect(audit.acoes, ['monitoring_session_start']); + expect(sessoes['coord-a'], agora); + + agora = agora.add(const Duration(seconds: 91)); + await servico.alertsPulse(coordA); + expect(audit.acoes, [ + 'monitoring_session_start', + 'monitoring_session_start', + ]); + }, + ); + + test( + 'exatamente 90 s ainda é sessão viva; 90 s + 1 µs abre outra', + () async { + await servico.alertsPulse(coordA); + agora = t0.add(AdminMonitoringService.sessionIdle); + await servico.alertsPulse(coordA); + expect(audit.events, hasLength(1), reason: 'exatamente 90 s'); + + agora = agora.add( + AdminMonitoringService.sessionIdle + const Duration(microseconds: 1), + ); + await servico.alertsPulse(coordA); + expect(audit.events, hasLength(2), reason: '90 s + 1 µs'); + }, + ); + + test('a sessão é por usuário', () async { + await servico.alertsPulse(coordA); + await servico.alertsPulse(admin); + expect(audit.events.map((e) => e.userId), ['coord-a', 'admin-1']); + }); + + test( + 'START que falha: a exceção sobe, nenhum dado sai, o mapa fica como estava e o próximo pulso tenta o START de novo', + () async { + audit.falhar = true; + await expectLater(servico.alertsPulse(coordA), throwsStateError); + expect(store.chamadas, 0); + expect(sessoes, isEmpty); + + audit.falhar = false; + agora = t0.add(const Duration(seconds: 5)); + await servico.alertsPulse(coordA); + expect(audit.acoes, ['monitoring_session_start']); + expect(sessoes['coord-a'], agora); + }, + ); + + test( + 'START que falha numa sessão expirada não renova o instante antigo', + () async { + await servico.alertsPulse(coordA); + agora = t0.add(const Duration(seconds: 200)); + audit.falhar = true; + await expectLater(servico.alertsPulse(coordA), throwsStateError); + expect(sessoes['coord-a'], t0); + }, + ); + + test( + 'ACS e paciente recusados com linha denied/read em admin_pulse, sem START nem dado', + () async { + for (final u in [acs, paciente, coordSemUbs]) { + await expectLater( + servico.alertsPulse(u), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'acesso restrito ao backoffice', + ), + ), + ); + } + expect(audit.events.map((e) => e.result), [ + 'denied', + 'denied', + 'denied', + ]); + expect(audit.acoes, ['read', 'read', 'read']); + expect(audit.events.map((e) => e.resourceType).toSet(), { + 'admin_pulse', + }); + expect(store.chamadas, 0); + expect(sessoes, isEmpty); + }, + ); + }); + + group('fim da sessão', () { + test( + 'com sessão: grava END e remove a entrada; repetir não grava', + () async { + await servico.alertsPulse(coordA); + await servico.endMonitoringSession(coordA); + await servico.endMonitoringSession(coordA); + expect(audit.acoes, [ + 'monitoring_session_start', + 'monitoring_session_end', + ]); + final end = audit.events.last; + expect(end.resourceType, 'admin_monitoring'); + expect(end.result, 'success'); + expect(end.resourceId, isNull); + expect(sessoes, isEmpty); + }, + ); + + test('sem sessão: não grava nada', () async { + await servico.endMonitoringSession(coordA); + expect(audit.events, isEmpty); + }); + + test( + 'END com falha de auditoria não lança, e a entrada some (o próximo pulso abre outra sessão)', + () async { + await servico.alertsPulse(coordA); + audit.falhar = true; + await servico.endMonitoringSession(coordA); + expect(sessoes, isEmpty); + + audit.falhar = false; + agora = t0.add(const Duration(seconds: 10)); + await servico.alertsPulse(coordA); + expect(audit.acoes, [ + 'monitoring_session_start', + 'monitoring_session_start', + ]); + }, + ); + + test( + 'ACS, paciente e coordenador sem UBS recusados, com linha denied, sem tocar a sessão de ninguém', + () async { + await servico.alertsPulse(coordA); + for (final u in [acs, paciente, coordSemUbs]) { + await expectLater( + servico.endMonitoringSession(u), + throwsA(isA()), + ); + } + expect(audit.events.skip(1).map((e) => e.result), [ + 'denied', + 'denied', + 'denied', + ]); + expect(audit.events.skip(1).map((e) => e.resourceType).toSet(), { + 'admin_monitoring', + }); + expect(audit.events.skip(1).map((e) => e.actionType).toSet(), { + 'monitoring_session_end', + }); + expect(sessoes.keys, ['coord-a']); + }, + ); + + test( + 'recusa auditada que falha ao gravar propaga a falha (fail-closed)', + () async { + audit.falhar = true; + await expectLater(servico.endMonitoringSession(acs), throwsStateError); + }, + ); + }); +} diff --git a/backend/sinalacs_server/test/unit/admin_read_service_test.dart b/backend/sinalacs_server/test/unit/admin_read_service_test.dart new file mode 100644 index 00000000..e83d3b5d --- /dev/null +++ b/backend/sinalacs_server/test/unit/admin_read_service_test.dart @@ -0,0 +1,505 @@ +import 'package:sinalacs_server/src/application/admin/admin_read_service.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/application/audit/audit_row_sealer.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/generated/protocol.dart'; +import 'package:sinalacs_server/src/infrastructure/database/orm_admin_read_store.dart' + show escaparLike; +import 'package:test/test.dart'; + +/// Regras do backoffice (#40) sem banco: papel, escopo, auditoria que vem ANTES +/// do dado e validação de paginação. Dados sintéticos. +const _ubsDoCoordenador = 'ubs-a'; +const _segredoDaCadeia = 'segredo-sintetico-do-servico'; + +/// Linha de auditoria como o store a devolve: [integridadeDoStore] é o que o +/// store pôs no `entry` (o serviço tem de ignorar), e a cadeia é montada com +/// [segredo]. +AdminAuditRow _linha( + int sequence, { + required String previousHash, + String? previousEntryHash, + String segredo = _segredoDaCadeia, + AuditRowSeal integridadeDoStore = AuditRowSeal.unverifiable, +}) { + final campos = AuditChainFields( + sequence: sequence, + previousHash: previousHash, + userId: 'u-$sequence', + actionType: 'read', + resourceType: 'admin_audit_logs', + resourceId: null, + timestamp: DateTime.utc(2026, 10, 13, 12, sequence), + ipHash: 'ip-$sequence', + result: 'success', + ); + return AdminAuditRow( + entry: AdminAuditEntry( + id: 'id-$sequence', + sequence: sequence, + userLabel: 'X (Administrador)', + actionType: 'read', + resourceType: 'admin_audit_logs', + timestamp: campos.timestamp, + result: 'success', + integrity: integridadeDoStore, + ), + seal: AuditSealInput( + row: AuditChainEntry( + fields: campos, + entryHash: AuditChain(secret: segredo).computeEntryHash(campos), + ), + previousEntryHash: previousEntryHash, + ), + ); +} + +/// Registra cada chamada com a ordem global, para provar "auditoria antes do dado". +final _ordem = []; + +class _Store implements AdminReadStore { + AdminScope? ultimoEscopo; + int chamadas = 0; + int? ultimoLimit; + AdminAuditFilter? ultimoFiltro; + List proximaPagina = []; + int? proximoCursor; + DateTime? ultimoAgoraDosAlertas; + final Map ubs = { + 'coord-a': _ubsDoCoordenador, + 'coord-sem-ubs': null, + }; + + void _marca(String nome, [AdminScope? escopo]) { + chamadas++; + ultimoEscopo = escopo; + _ordem.add('store:$nome'); + } + + @override + Future indicators( + AdminScope scope, { + required DateTime now, + }) async { + _marca('indicators', scope); + return AdminIndicators( + red: 1, + yellow: 0, + green: 0, + openRedAlerts: 1, + acknowledgedRedAlerts: 0, + tmravSeconds: null, + ); + } + + /// O pulso (D5) é do `AdminMonitoringService`; o serviço de leitura não o usa. + @override + Future pulse( + AdminScope scope, { + required DateTime now, + required Duration threshold, + }) => throw UnimplementedError(); + + @override + Future> microAreas(AdminScope scope) async { + _marca('microAreas', scope); + return []; + } + + @override + Future alerts( + AdminScope scope, { + String? microAreaId, + AlertStatus? status, + required int limit, + required int offset, + required DateTime now, + }) async { + _marca('alerts', scope); + ultimoAgoraDosAlertas = now; + ultimoLimit = limit; + return AdminAlertPage(items: []); + } + + @override + Future auditLogs( + AdminScope scope, { + AdminAuditFilter? filter, + required int limit, + int? beforeSequence, + }) async { + _marca('auditLogs', scope); + ultimoLimit = limit; + ultimoFiltro = filter; + return AdminAuditRowPage( + items: proximaPagina, + nextBeforeSequence: proximoCursor, + ); + } + + @override + Future ubsOf(String staffId) async => ubs[staffId]; +} + +class _Audit extends AuditTrail { + final events = []; + bool falhar = false; + + @override + Future record(AuditEvent event) async { + if (falhar) throw StateError('auditoria indisponível'); + _ordem.add('audit:${event.resourceType}:${event.result}'); + events.add(event); + } +} + +AuthenticatedUser _u(String id, UserRole role) => AuthenticatedUser( + id: id, + role: role, + microAreaId: null, + deviceId: 'sem-aparelho', +); + +void main() { + late _Store store; + late _Audit audit; + late AdminReadService servico; + final admin = _u('admin-1', UserRole.admin); + final coordA = _u('coord-a', UserRole.coordinator); + final coordSemUbs = _u('coord-sem-ubs', UserRole.coordinator); + + setUp(() { + _ordem.clear(); + store = _Store(); + audit = _Audit(); + servico = AdminReadService( + store: store, + audit: audit, + auditChain: AuditChain(secret: _segredoDaCadeia), + clock: () => DateTime.utc(2026, 10, 7, 12), + ); + }); + + test( + 'alertas: o serviço passa ao store o relógio DO SERVIDOR, em UTC (vencido nunca vem do aparelho)', + () async { + await servico.alerts(admin); + expect(store.ultimoAgoraDosAlertas, DateTime.utc(2026, 10, 7, 12)); + expect(store.ultimoAgoraDosAlertas!.isUtc, isTrue); + }, + ); + + test( + 'admin lê com escopo de sistema e a leitura é auditada ANTES do dado', + () async { + await servico.indicators(admin); + expect(store.ultimoEscopo!.ubsId, isNull); + expect(_ordem, ['audit:admin_indicators:success', 'store:indicators']); + expect(audit.events.single.actionType, 'read'); + expect(audit.events.single.userId, 'admin-1'); + }, + ); + + test('coordenador lê só a sua UBS', () async { + await servico.alerts(coordA); + expect(store.ultimoEscopo!.ubsId, _ubsDoCoordenador); + }); + + test( + 'coordenador sem UBS é recusado em indicators, microAreas e alerts, com auditoria denied', + () async { + for (final chamada in Function()>[ + () => servico.indicators(coordSemUbs), + () => servico.microAreas(coordSemUbs), + () => servico.alerts(coordSemUbs), + ]) { + await expectLater(chamada(), throwsA(isA())); + } + expect(store.chamadas, 0, reason: 'nenhum dado sai'); + expect(audit.events.map((e) => e.result), everyElement('denied')); + expect(audit.events, hasLength(3)); + }, + ); + + test( + 'coordenador lê a auditoria escopada à sua UBS, auditada ANTES do dado', + () async { + await servico.auditLogs(coordA, limit: 10); + expect(store.ultimoEscopo!.ubsId, _ubsDoCoordenador); + expect(store.ultimoLimit, 10); + expect(_ordem, ['audit:admin_audit_logs:success', 'store:auditLogs']); + expect(audit.events.single.result, 'success'); + }, + ); + + test('coordenador sem UBS é recusado na auditoria (fail-closed)', () async { + await expectLater( + servico.auditLogs(coordSemUbs), + throwsA(isA()), + ); + expect(store.chamadas, 0, reason: 'nenhum dado sai'); + expect(audit.events.single.result, 'denied'); + }); + + group('selo por linha (#45, D3)', () { + test('o serviço sela cada linha e ignora o integrity que o store pôs', + () async { + final l1 = _linha(1, previousHash: AuditChain.genesisHash); + final l2 = _linha( + 2, + previousHash: l1.seal.row.entryHash, + previousEntryHash: l1.seal.row.entryHash, + ); + // Store "mente" verified numa linha forjada com outro segredo. + final forjada = _linha( + 3, + previousHash: l2.seal.row.entryHash, + previousEntryHash: l2.seal.row.entryHash, + segredo: 'segredo-do-atacante', + integridadeDoStore: AuditRowSeal.verified, + ); + // Vizinho 9 ausente no banco. + final orfa = _linha(10, previousHash: 'f' * 64); + store.proximaPagina = [orfa, forjada, l2, l1]; + + final pagina = await servico.auditLogs(admin); + + expect( + {for (final e in pagina.items) e.sequence: e.integrity}, + { + 10: AuditRowSeal.unverifiable, + 3: AuditRowSeal.invalid, + 2: AuditRowSeal.verified, + 1: AuditRowSeal.verified, + }, + ); + expect(pagina.items.map((e) => e.sequence), [10, 3, 2, 1], + reason: 'a ordem do store (sequence desc) é preservada'); + }); + + test('o cursor do store passa adiante sem mudar', () async { + store.proximaPagina = [_linha(1, previousHash: AuditChain.genesisHash)]; + store.proximoCursor = 1; + final pagina = await servico.auditLogs(admin, limit: 1); + expect(pagina.nextBeforeSequence, 1); + }); + }); + + test('admin lê a auditoria com escopo de sistema', () async { + await servico.auditLogs(admin); + expect(store.chamadas, 1); + expect(store.ultimoEscopo!.ubsId, isNull); + expect(audit.events.single.resourceType, 'admin_audit_logs'); + }); + + test( + 'acs e patient são recusados em todos os métodos e a recusa é auditada', + () async { + for (final role in [UserRole.acs, UserRole.patient]) { + final u = _u('u-${role.name}', role); + for (final chamada in Function()>[ + () => servico.indicators(u), + () => servico.microAreas(u), + () => servico.alerts(u), + () => servico.auditLogs(u), + ]) { + await expectLater( + chamada(), + throwsA(isA()), + ); + } + } + expect(store.chamadas, 0); + expect(audit.events, hasLength(8)); + expect(audit.events.map((e) => e.result), everyElement('denied')); + }, + ); + + test('se a auditoria falha, o store não é chamado (fail-closed)', () async { + audit.falhar = true; + await expectLater(servico.alerts(admin), throwsA(isA())); + expect(store.chamadas, 0); + }); + + test( + 'limit 0, 101 e -1 são recusados com a mensagem de paginação, sem tocar o store', + () async { + for (final limit in [0, 101, -1]) { + await expectLater( + servico.alerts(admin, limit: limit), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Parâmetro de paginação inválido.', + ), + ), + ); + } + await expectLater( + servico.auditLogs(admin, limit: 0), + throwsA(isA()), + ); + expect(store.chamadas, 0); + }, + ); + + test('offset negativo é recusado; limit 1 e 100 passam', () async { + await expectLater( + servico.alerts(admin, offset: -1), + throwsA(isA()), + ); + await servico.alerts(admin, limit: 1); + await servico.alerts(admin, limit: 100); + expect(store.ultimoLimit, 100); + }); + + group('filtro da auditoria (#45, D1/D2)', () { + final de = DateTime.utc(2026, 10, 1); + final invalidos = { + 'resourceType com maiúscula': AdminAuditFilter(resourceType: 'Session'), + 'resourceType com curinga': AdminAuditFilter(resourceType: 'sess%'), + 'resourceType vazio': AdminAuditFilter(resourceType: ''), + 'resourceType com 65': AdminAuditFilter(resourceType: 'a' * 65), + 'result com espaço': AdminAuditFilter(result: 'denied x'), + 'result com %': AdminAuditFilter(result: 'escalated:%'), + 'result com ;': AdminAuditFilter(result: 'denied;drop'), + 'result com maiúscula': AdminAuditFilter(result: 'Escalated:no_ack'), + 'resourceType com espaço': AdminAuditFilter(resourceType: 'admin alert'), + 'resourceType com ;': AdminAuditFilter(resourceType: 'alert;'), + 'result com aspas': AdminAuditFilter(result: "x' OR '1'='1"), + 'actionType desconhecida': AdminAuditFilter(actionType: 'delete'), + 'from == to': AdminAuditFilter(from: de, to: de), + 'from > to': AdminAuditFilter( + from: de, + to: de.subtract(const Duration(seconds: 1)), + ), + 'janela de 366 d + 1 µs': AdminAuditFilter( + from: de, + to: de.add(const Duration(days: 366, microseconds: 1)), + ), + 'busca com 1': AdminAuditFilter(search: 'a'), + 'busca vazia': AdminAuditFilter(search: ''), + 'busca com 65': AdminAuditFilter(search: 'a' * 65), + 'matrícula com 33': AdminAuditFilter(actorEnrollmentId: 'A' * 33), + 'matrícula vazia': AdminAuditFilter(actorEnrollmentId: ''), + }; + + for (final MapEntry(key: caso, value: filtro) in invalidos.entries) { + test( + 'inválido ($caso) ⇒ AdminInvalidRequestException, sem auditoria e sem store', + () async { + await expectLater( + servico.auditLogs(admin, filter: filtro), + throwsA( + isA().having( + (e) => e.message, + 'message', + startsWith('Filtro de auditoria inválido.'), + ), + ), + ); + expect( + audit.events, + isEmpty, + reason: 'entrada inválida não é acesso', + ); + expect(store.chamadas, 0); + }, + ); + } + + test('a mensagem de recusa nunca ecoa o valor recebido', () async { + const valor = "zz' OR 1=1 --"; + await expectLater( + servico.auditLogs(admin, filter: AdminAuditFilter(result: valor)), + throwsA( + isA().having( + (e) => e.message, + 'message', + isNot(contains(valor)), + ), + ), + ); + }); + + test( + 'acs com filtro inválido continua recusado por papel (denied auditado)', + () async { + await expectLater( + servico.auditLogs( + _u('acs-1', UserRole.acs), + filter: AdminAuditFilter(search: 'a'), + ), + throwsA(isA()), + ); + expect(audit.events.single.result, 'denied'); + }, + ); + + test( + 'válido ⇒ audita success ANTES do store e repassa o filtro intacto', + () async { + final filtro = AdminAuditFilter( + actorEnrollmentId: 'ACS-0001', + actorRole: UserRole.acs, + actionType: 'export', + resourceType: 'admin_audit_logs', + result: 'denied_territory', + from: de, + to: de.add(const Duration(days: 366)), + search: '%_', + ); + await servico.auditLogs(coordA, filter: filtro, limit: 20); + expect(_ordem, ['audit:admin_audit_logs:success', 'store:auditLogs']); + expect(store.ultimoFiltro, same(filtro)); + expect(store.ultimoLimit, 20); + expect(store.ultimoEscopo!.ubsId, _ubsDoCoordenador); + }, + ); + + test( + 'resourceType/result aceitam dígito e ":" (ex.: escalated:no_ack_timeout)', + () async { + final f = AdminAuditFilter( + resourceType: 'admin_alert', + result: 'escalated:no_ack_timeout', + ); + await servico.auditLogs(admin, filter: f); + expect(store.ultimoFiltro, same(f)); + await servico.auditLogs( + admin, + filter: AdminAuditFilter(result: 'already_escalated:x1'), + ); + expect(store.chamadas, 2); + }, + ); + + test('só from, só to e busca com 2 e 64 passam', () async { + for (final f in [ + AdminAuditFilter(from: de), + AdminAuditFilter(to: de), + AdminAuditFilter(search: 'ab'), + AdminAuditFilter(search: 'a' * 64), + AdminAuditFilter(actorEnrollmentId: 'A' * 32), + AdminAuditFilter(resourceType: 'a' * 64), + ]) { + await servico.auditLogs(admin, filter: f); + } + expect(store.chamadas, 6); + }); + }); + + group('escaparLike', () { + test('escapa \\, % e _ (a barra primeiro)', () { + expect(escaparLike('%'), r'\%'); + expect(escaparLike('_'), r'\_'); + expect(escaparLike(r'\'), r'\\'); + expect(escaparLike(r'a\%_b'), r'a\\\%\_b'); + expect(escaparLike('admin'), 'admin'); + expect(escaparLike(''), ''); + }); + }); +} diff --git a/backend/sinalacs_server/test/unit/admin_scope_test.dart b/backend/sinalacs_server/test/unit/admin_scope_test.dart new file mode 100644 index 00000000..df8e81b0 --- /dev/null +++ b/backend/sinalacs_server/test/unit/admin_scope_test.dart @@ -0,0 +1,197 @@ +import 'package:sinalacs_server/src/application/admin/admin_scope.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/generated/protocol.dart'; +import 'package:test/test.dart'; + +/// A regra única de papel/escopo do backoffice (#43) sem banco: papel, escopo, +/// recusa auditada e fail-closed. Dados sintéticos. +const _ubsDoCoordenador = 'ubs-a'; + +class _Store implements AdminScopeStore { + _Store({Map? ubs}) : ubs = ubs ?? const {}; + + final Map ubs; + int chamadas = 0; + + @override + Future ubsOf(String staffId) async { + chamadas++; + return ubs[staffId]; + } +} + +class _Audit extends AuditTrail { + final events = []; + bool falhar = false; + + @override + Future record(AuditEvent event) async { + if (falhar) throw StateError('auditoria indisponível'); + events.add(event); + } +} + +AuthenticatedUser _u(String id, UserRole role) => AuthenticatedUser( + id: id, + role: role, + microAreaId: null, + deviceId: 'sem-aparelho', +); + +void main() { + late _Store store; + late _Audit audit; + late AdminScopeResolver resolver; + final admin = _u('admin-1', UserRole.admin); + final coordA = _u('coord-a', UserRole.coordinator); + final coordSemUbs = _u('coord-sem-ubs', UserRole.coordinator); + + setUp(() { + store = _Store(ubs: {'coord-a': _ubsDoCoordenador, 'coord-sem-ubs': null}); + audit = _Audit(); + resolver = AdminScopeResolver(store: store, audit: audit); + }); + + test( + 'admin resolve para o sistema inteiro e a UBS nem é consultada', + () async { + final escopo = await resolver.resolve(admin, recurso: 'admin_indicators'); + expect(escopo.ubsId, isNull); + expect(store.chamadas, 0); + expect(audit.events, isEmpty); + }, + ); + + test( + 'coordenador resolve para a própria UBS, sem linha de auditoria', + () async { + final escopo = await resolver.resolve( + coordA, + recurso: 'admin_indicators', + ); + expect(escopo.ubsId, _ubsDoCoordenador); + expect(audit.events, isEmpty); + }, + ); + + test( + 'coordenador sem UBS é recusado, fail-closed, com denied na trilha', + () async { + final audit = _Audit(); + final r = AdminScopeResolver( + store: _Store(ubs: {'coord': null}), + audit: audit, + ); + await expectLater( + r.resolve(_u('coord', UserRole.coordinator), recurso: 'admin_acs'), + throwsA(isA()), + ); + expect(audit.events.single.result, 'denied'); + expect(audit.events.single.resourceType, 'admin_acs'); + }, + ); + + test( + 'acs e patient são recusados mesmo com token válido, com denied na trilha', + () async { + for (final role in [UserRole.acs, UserRole.patient]) { + final u = _u('u-${role.name}', role); + await expectLater( + resolver.resolve(u, recurso: 'admin_indicators'), + throwsA( + isA().having( + (e) => e.message, + 'message', + AdminScopeResolver.negado, + ), + ), + ); + expect(audit.events.last.userId, u.id); + expect(audit.events.last.actionType, 'read'); + expect(audit.events.last.resourceType, 'admin_indicators'); + expect(audit.events.last.result, 'denied'); + } + expect(store.chamadas, 0, reason: 'papel recusado nem chega ao store'); + }, + ); + + test('requireAdmin recusa coordenador e audita; admin passa', () async { + await expectLater( + resolver.requireAdmin(coordA, recurso: 'admin_audit_logs'), + throwsA( + isA().having( + (e) => e.message, + 'message', + AdminScopeResolver.negado, + ), + ), + ); + expect(audit.events.single.result, 'denied'); + expect(audit.events.single.resourceType, 'admin_audit_logs'); + + await resolver.requireAdmin(admin, recurso: 'admin_audit_logs'); + expect(audit.events, hasLength(1), reason: 'quem passa não gera denied'); + }); + + test('requireAdmin recusa acs e patient', () async { + for (final role in [UserRole.acs, UserRole.patient]) { + await expectLater( + resolver.requireAdmin(_u('u-${role.name}', role), recurso: 'admin_acs'), + throwsA(isA()), + ); + } + expect(audit.events, hasLength(2)); + expect(audit.events.map((e) => e.result), everyElement('denied')); + }); + + test('recusa em caminho de escrita audita a TENTATIVA: actionType write', () async { + // Papel fora do backoffice (resolve) e coordenador sem UBS (escopo). + await expectLater( + resolver.resolve( + _u('u-acs', UserRole.acs), + recurso: 'admin_acs', + actionType: 'write', + ), + throwsA(isA()), + ); + await expectLater( + resolver.resolve(coordSemUbs, recurso: 'admin_acs', actionType: 'write'), + throwsA(isA()), + ); + // requireAdmin aceita o mesmo parâmetro. + await expectLater( + resolver.requireAdmin( + coordA, + recurso: 'admin_staff', + actionType: 'write', + ), + throwsA(isA()), + ); + + expect(audit.events, hasLength(3)); + expect( + audit.events.map((e) => e.actionType), + everyElement('write'), + reason: 'a linha descreve o que foi TENTADO: uma escrita', + ); + expect(audit.events.map((e) => e.result), everyElement('denied')); + + // O default continua `read`: nenhuma linha do caminho de leitura muda de forma. + await expectLater( + resolver.resolve(coordSemUbs, recurso: 'admin_indicators'), + throwsA(isA()), + ); + expect(audit.events.last.actionType, 'read'); + expect(audit.events.last.resourceType, 'admin_indicators'); + expect(audit.events.last.result, 'denied'); + }); + + test('se a auditoria falha, a exceção sobe no lugar da recusa', () async { + audit.falhar = true; + await expectLater( + resolver.resolve(coordSemUbs, recurso: 'admin_indicators'), + throwsA(isA()), + ); + }); +} diff --git a/backend/sinalacs_server/test/unit/alert_delivery_test.dart b/backend/sinalacs_server/test/unit/alert_delivery_test.dart index 89ef733f..daf16c7b 100644 --- a/backend/sinalacs_server/test/unit/alert_delivery_test.dart +++ b/backend/sinalacs_server/test/unit/alert_delivery_test.dart @@ -66,6 +66,47 @@ void main() { }); }); + test('reforço de escalonamento (#44): marcador aditivo, sobrevive ao outbox', () { + final base = AlertDelivery( + alertId: 'alert-123', + patientId: 'patient-456', + microAreaId: 'area-12', + riskLevel: 'red', + locationHash: '6gyf4bf', + triggeredAt: DateTime.utc(2026, 9, 1, 12), + ); + // Entrega comum: o envelope não muda (sem a chave). + expect(jsonDecode(base.toJson()).containsKey('escalated'), isFalse); + expect(AlertDelivery.tryParse(base.toJson())!.escalated, isFalse); + + final reforco = AlertDelivery( + alertId: base.alertId, + patientId: base.patientId, + microAreaId: base.microAreaId, + riskLevel: base.riskLevel, + locationHash: base.locationHash, + triggeredAt: base.triggeredAt, + escalated: true, + ); + final json = jsonDecode(reforco.toJson()) as Map; + expect(json['escalated'], isTrue); + expect(json['version'], 1, reason: 'aditivo: o app do ACS segue lendo v1'); + expect(json.keys.toSet(), { + 'version', + 'alert_id', + 'patient_id', + 'micro_area_id', + 'risk_level', + 'location_hash', + 'triggered_at', + 'escalated', + }); + // O drenador reidrata o payload gravado e republica: o marcador não se perde. + final reidratado = AlertDelivery.tryParse(reforco.toJson())!; + expect(reidratado.escalated, isTrue); + expect(jsonDecode(reidratado.toJson())['escalated'], isTrue); + }); + test('vincula o ACK ao alerta e ao ACS que o confirmou', () { final acknowledgement = AlertDeliveryAck( alertId: 'alert-123', diff --git a/backend/sinalacs_server/test/unit/alert_escalation_service_test.dart b/backend/sinalacs_server/test/unit/alert_escalation_service_test.dart new file mode 100644 index 00000000..ec185643 --- /dev/null +++ b/backend/sinalacs_server/test/unit/alert_escalation_service_test.dart @@ -0,0 +1,306 @@ +import 'package:sinalacs_server/src/application/admin/alert_escalation_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/generated/protocol.dart'; +import 'package:test/test.dart'; + +/// Regras do escalonamento de alerta vermelho (#44) sem banco: prazo, estado, +/// papel/escopo, idempotência e auditoria fail-closed. Dados sintéticos. +const _ubsA = 'ubs-a'; +const _alerta = '00000000-0000-4000-8000-00000000a001'; +final _agora = DateTime.utc(2026, 10, 11, 12, 0, 0); +const _limite = Duration(seconds: 300); + +class _Registro { + _Registro({ + required this.ubsId, + required this.triggeredAt, + this.risk = RiskLevel.red, + this.status = AlertStatus.pending, + }); + final String ubsId; + final DateTime triggeredAt; + final RiskLevel risk; + AlertStatus status; + DateTime? escalatedAt; + String? escalatedBy; + EscalationReason? reason; + int efeitos = 0; +} + +/// Cumpre a mesma regra que o SQL do store real cumprirá: vencido é +/// `now - triggeredAt > threshold`, estritamente; futuro nunca vence. +class _Store implements AlertEscalationStore { + final registros = {}; + final ubs = {'coord-a': _ubsA, 'coord-b': 'ubs-b'}; + int chamadas = 0; + + @override + Future ubsOf(String staffId) async => ubs[staffId]; + + @override + Future escalate( + AdminScope scope, { + required String alertId, + required String actorId, + required EscalationReason reason, + required DateTime now, + required Duration threshold, + required AuditEvent Function(EscalationOutcome) auditFor, + }) async { + chamadas++; + final r = registros[alertId]; + if (r == null || (scope.ubsId != null && scope.ubsId != r.ubsId)) { + return const EscalationAttempt(EscalationOutcome.notFound); + } + if (r.risk != RiskLevel.red) { + return const EscalationAttempt(EscalationOutcome.notRed); + } + if (r.status != AlertStatus.pending) { + return const EscalationAttempt(EscalationOutcome.notPending); + } + if (r.escalatedAt != null) { + // Auditoria antes da escrita: se falhar, nada muda (aqui, nada muda). + await audit.record(auditFor(EscalationOutcome.alreadyEscalated)); + return EscalationAttempt( + EscalationOutcome.alreadyEscalated, + escalatedAt: r.escalatedAt, + ); + } + final decorrido = now.difference(r.triggeredAt); + if (!(decorrido > threshold)) { + return const EscalationAttempt(EscalationOutcome.notDue); + } + // Mesma "transação": a linha de auditoria primeiro; se lançar, não escreve. + await audit.record(auditFor(EscalationOutcome.escalated)); + r + ..escalatedAt = now + ..escalatedBy = actorId + ..reason = reason; + r.efeitos++; + return EscalationAttempt(EscalationOutcome.escalated, escalatedAt: now); + } + + late _Audit audit; +} + +class _Audit extends AuditTrail { + final events = []; + bool falhar = false; + + @override + Future record(AuditEvent event) async { + if (falhar) throw StateError('auditoria indisponível'); + events.add(event); + } +} + +AuthenticatedUser _u(String id, UserRole role) => AuthenticatedUser( + id: id, + role: role, + microAreaId: null, + deviceId: 'sem-aparelho', +); + +void main() { + late _Store store; + late _Audit audit; + late AlertEscalationService service; + var agora = _agora; + + void semear({ + Duration idade = const Duration(seconds: 301), + RiskLevel risk = RiskLevel.red, + AlertStatus status = AlertStatus.pending, + String ubs = _ubsA, + }) { + store.registros[_alerta] = _Registro( + ubsId: ubs, + triggeredAt: _agora.subtract(idade), + risk: risk, + status: status, + ); + } + + setUp(() { + agora = _agora; + store = _Store(); + audit = _Audit(); + store.audit = audit; + service = AlertEscalationService( + store: store, + audit: audit, + threshold: _limite, + clock: () => agora, + ); + }); + + Future escalar({ + String id = _alerta, + String quem = 'coord-a', + UserRole papel = UserRole.coordinator, + EscalationReason motivo = EscalationReason.noAckTimeout, + }) => service.escalate(_u(quem, papel), alertId: id, reason: motivo); + + test('antes do prazo: recusa, audita denied_not_due, não escreve', () async { + semear(idade: const Duration(seconds: 299)); + await expectLater(escalar(), throwsA(isA())); + expect(store.registros[_alerta]!.escalatedAt, isNull); + expect(store.registros[_alerta]!.efeitos, 0); + expect(audit.events.single.result, 'denied_not_due'); + expect(audit.events.single.actionType, 'write'); + expect(audit.events.single.resourceType, 'admin_alert'); + expect(audit.events.single.resourceId, _alerta); + }); + + test('exatamente no prazo (300 s) ainda não vence: estritamente maior', () async { + semear(idade: const Duration(seconds: 300)); + await expectLater(escalar(), throwsA(isA())); + expect(audit.events.single.result, 'denied_not_due'); + }); + + test('depois do prazo: escalona e audita escalated:no_ack_timeout', () async { + semear(); + final r = await escalar(); + expect(r.alertId, _alerta); + expect(r.alreadyEscalated, isFalse); + expect(r.escalatedAt, _agora); + final reg = store.registros[_alerta]!; + expect(reg.escalatedBy, 'coord-a'); + expect(reg.reason, EscalationReason.noAckTimeout); + expect(reg.status, AlertStatus.pending, reason: 'D3: segue pendente'); + final e = audit.events.single; + expect(e.userId, 'coord-a'); + expect(e.actionType, 'write'); + expect(e.resourceType, 'admin_alert'); + expect(e.resourceId, _alerta); + expect(e.result, 'escalated:no_ack_timeout'); + }); + + test('motivos em snake_case na auditoria', () async { + semear(); + await escalar(motivo: EscalationReason.acsUnreachable); + expect(audit.events.single.result, 'escalated:acs_unreachable'); + semear(); + audit.events.clear(); + await escalar(motivo: EscalationReason.patientRecontact); + expect(audit.events.single.result, 'escalated:patient_recontact'); + }); + + test('segunda chamada: alreadyEscalated=true, escalatedAt inalterado, 1 efeito', + () async { + semear(); + final primeira = await escalar(); + agora = _agora.add(const Duration(minutes: 10)); + final segunda = await escalar(motivo: EscalationReason.acsUnreachable); + expect(segunda.alreadyEscalated, isTrue); + expect(segunda.escalatedAt, primeira.escalatedAt); + final reg = store.registros[_alerta]!; + expect(reg.efeitos, 1); + expect(reg.reason, EscalationReason.noAckTimeout); + expect(audit.events.last.result, 'already_escalated:acs_unreachable'); + }); + + test('alerta já reconhecido: recusa, status inalterado', () async { + semear(status: AlertStatus.acknowledged); + await expectLater( + escalar(), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Alerta já confirmado.', + ), + ), + ); + expect(store.registros[_alerta]!.status, AlertStatus.acknowledged); + expect(store.registros[_alerta]!.escalatedAt, isNull); + expect(audit.events.single.result, 'denied_not_pending'); + }); + + test('alerta amarelo/verde: recusa', () async { + for (final risk in [RiskLevel.yellow, RiskLevel.green]) { + audit.events.clear(); + semear(risk: risk); + await expectLater(escalar(), throwsA(isA())); + expect(store.registros[_alerta]!.escalatedAt, isNull); + expect(audit.events.single.result, 'denied_not_red'); + } + }); + + test('triggeredAt no futuro nunca é vencido', () async { + semear(idade: const Duration(hours: -2)); + await expectLater(escalar(), throwsA(isA())); + expect(store.registros[_alerta]!.escalatedAt, isNull); + expect(audit.events.single.result, 'denied_not_due'); + }); + + test('acs e patient: AlertPermissionException + denied auditado', () async { + semear(); + for (final papel in [UserRole.acs, UserRole.patient]) { + audit.events.clear(); + await expectLater( + escalar(quem: 'u-1', papel: papel), + throwsA(isA()), + ); + expect(audit.events.single.result, 'denied'); + expect(audit.events.single.actionType, 'write'); + } + expect(store.chamadas, 0); + expect(store.registros[_alerta]!.escalatedAt, isNull); + }); + + test('coordenador de outra UBS: mesma mensagem de não encontrado', () async { + semear(); + String? msg(Object? e) => (e as AdminInvalidRequestException).message; + String? outraUbs; + String? inexistente; + try { + await escalar(quem: 'coord-b'); + } catch (e) { + outraUbs = msg(e); + } + try { + await escalar(id: '00000000-0000-4000-8000-00000000ffff'); + } catch (e) { + inexistente = msg(e); + } + expect(outraUbs, 'Alerta não encontrado.'); + expect(inexistente, outraUbs); + expect(audit.events.map((e) => e.result), [ + 'denied_not_found', + 'denied_not_found', + ]); + expect(store.registros[_alerta]!.escalatedAt, isNull); + }); + + test('administrador escala em qualquer UBS', () async { + semear(ubs: 'ubs-z'); + final r = await escalar(quem: 'adm-1', papel: UserRole.admin); + expect(r.alreadyEscalated, isFalse); + }); + + test('id que não é UUID: recusa sem tocar o store', () async { + await expectLater( + escalar(id: "x'; DROP TABLE alerts;--"), + throwsA(isA()), + ); + expect(store.chamadas, 0); + expect(audit.events.single.result, 'denied_invalid_id'); + expect(audit.events.single.resourceId, isNull); + }); + + test('se a auditoria falha, nada é escrito', () async { + semear(); + audit.falhar = true; + await expectLater(escalar(), throwsA(isA())); + expect(store.registros[_alerta]!.escalatedAt, isNull); + expect(store.registros[_alerta]!.efeitos, 0); + }); + + test('se a auditoria da recusa falha, a exceção da trilha sobe', () async { + semear(idade: const Duration(seconds: 10)); + audit.falhar = true; + await expectLater(escalar(), throwsA(isA())); + }); +} diff --git a/backend/sinalacs_server/test/unit/alert_outbox_dispatcher_test.dart b/backend/sinalacs_server/test/unit/alert_outbox_dispatcher_test.dart index 7d3c1dbd..20a7abff 100644 --- a/backend/sinalacs_server/test/unit/alert_outbox_dispatcher_test.dart +++ b/backend/sinalacs_server/test/unit/alert_outbox_dispatcher_test.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + 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/domain/entities/alert_delivery.dart'; @@ -10,7 +12,7 @@ class _FlakyPublisher implements AlertPublisher { final List published = []; @override - void publish(AlertDelivery alert) { + Future publish(AlertDelivery alert) async { if (failures > 0) { failures--; throw Exception('broker indisponível'); @@ -69,6 +71,29 @@ AlertDelivery _delivery(String id) => AlertDelivery( triggeredAt: DateTime.utc(2026, 9, 1, 12), ); +class _StderrCapturado implements Stdout { + final buffer = StringBuffer(); + + @override + void writeln([Object? object = '']) => buffer.writeln(object); + + @override + void write(Object? object) => buffer.write(object); + + @override + dynamic noSuchMethod(Invocation invocation) => null; +} + +class _PublisherQueLanca implements AlertPublisher { + @override + Future publish(AlertDelivery alert) async => throw SocketException( + 'Broken pipe sentinela-vaz01-local localhost', + osError: const OSError('Broken pipe', 32), + address: InternetAddress('127.0.0.1'), + port: 8883, + ); +} + void main() { final now = DateTime.utc(2026, 9, 1, 12); @@ -155,4 +180,48 @@ void main() { expect(await dispatcher.publishNow('alerta-1'), isFalse); expect(await dispatcher.publishNow('alerta-1'), isTrue); }); + + test( + 'VAZ-01: o log da entrega adiada traz só o id sintético, a tentativa e o ' + 'tipo do erro', () async { + final outbox = _RecordingOutbox(); + await outbox.enqueue( + AlertDelivery( + alertId: '00000000-0000-4000-8000-0000000000aa', + patientId: 'patient-001', + microAreaId: 'area-12', + riskLevel: 'red', + locationHash: 'sentinela-vaz01-local', + triggeredAt: now, + ), + ); + final dispatcher = AlertOutboxDispatcher( + outbox: outbox, + publisher: _PublisherQueLanca(), + clock: () => now, + ); + final capturado = _StderrCapturado(); + + await IOOverrides.runZoned( + () => dispatcher.drainOnce(), + stderr: () => capturado, + ); + + expect( + capturado.buffer.toString().trim(), + 'Entrega do alerta 00000000-0000-4000-8000-0000000000aa adiada ' + '(tentativa 1, erro SocketException).', + ); + for (final proibido in [ + 'sentinela-vaz01-local', + 'localhost', + '127.0.0.1', + 'Broken pipe', + 'patient-001', + 'area-12', + '#0 ', + ]) { + expect(capturado.buffer.toString(), isNot(contains(proibido))); + } + }); } diff --git a/backend/sinalacs_server/test/unit/app_config_test.dart b/backend/sinalacs_server/test/unit/app_config_test.dart index eb5babfb..137191b7 100644 --- a/backend/sinalacs_server/test/unit/app_config_test.dart +++ b/backend/sinalacs_server/test/unit/app_config_test.dart @@ -363,6 +363,62 @@ void main() { }); }); + group('certificado de cliente MQTT (mTLS)', () { + Map producao({bool tls = true, String? cert, String? key}) => { + 'APP_ENV': 'production', + 'JWT_SECRET': 'a' * 64, + 'AUDIT_CHAIN_SECRET': 'b' * 64, + 'HEALTH_DATA_ENCRYPTION_KEY': 'c' * 64, + 'CPF_HASH_PEPPER': 'd' * 64, + 'SMS_GATEWAY': 'log', + if (tls) 'MQTT_USE_TLS': 'true', + 'MQTT_CLIENT_CERT_PATH': ?cert, + 'MQTT_CLIENT_KEY_PATH': ?key, + }; + + test('só o certificado, sem a chave, é erro', () { + expect( + () => AppConfig.fromMap({'MQTT_CLIENT_CERT_PATH': '/c/backend.crt'}), + throwsA(isA().having( + (e) => e.message, 'message', contains('devem vir juntos'))), + ); + }); + + test('os dois em development leem os caminhos', () { + final config = AppConfig.fromMap({ + 'MQTT_CLIENT_CERT_PATH': '/c/backend.crt', + 'MQTT_CLIENT_KEY_PATH': '/c/backend.key', + }); + expect(config.mqttClientCertificatePath, '/c/backend.crt'); + expect(config.mqttClientKeyPath, '/c/backend.key'); + }); + + test('nenhum em development é aceito', () { + final config = AppConfig.fromMap(const {}); + expect(config.mqttClientCertificatePath, isNull); + expect(config.mqttClientKeyPath, isNull); + }); + + test('nenhum em production com TLS é erro', () { + expect( + () => AppConfig.fromMap(producao()), + throwsA(isA().having( + (e) => e.message, 'message', contains('MQTT_CLIENT_CERT_PATH'))), + ); + }); + + test('nenhum em production sem TLS é aceito (broker de teste do CI)', () { + // Fora de development nenhuma configuração é construível hoje (o único + // SMS_GATEWAY implementado é `log`); o que importa aqui é que o erro, + // se houver, NÃO seja o do certificado de cliente. + expect( + () => AppConfig.fromMap(producao(tls: false)), + throwsA(isA().having( + (e) => e.message, 'message', isNot(contains('MQTT_CLIENT_CERT_PATH')))), + ); + }); + }); + group('defaults', () { test('um ambiente vazio produz a configuração de desenvolvimento', () { final config = AppConfig.fromMap(const {}); @@ -383,4 +439,139 @@ void main() { expect(AppConfig.fromMap(const {'ENABLE_DEV_LOGIN': '1'}).enableDevLogin, isFalse); }); }); + + group('GORUSH_URL (RF14)', () { + AppConfig comGorush(String? url) => AppConfig.fromMap({ + 'APP_ENV': 'development', + 'GORUSH_URL': ?url, + }); + + test('ausente desliga o envio', () { + expect(comGorush(null).gorushUrl, isNull); + expect(comGorush(' ').gorushUrl, isNull); + }); + + test('presente é lida sem espaços e sem barra final', () { + expect(comGorush(' http://gorush:8088/ ').gorushUrl, 'http://gorush:8088'); + }); + + test('fora de http(s) é recusada', () { + expect(() => comGorush('ftp://x'), throwsA(isA())); + expect(() => comGorush('gorush:8088'), throwsA(isA())); + }); + + test('o tempo limite do envio é de 5 segundos', () { + expect(comGorush(null).gorushTimeout, const Duration(seconds: 5)); + }); + }); + + group('REQUIRE_ACS_MFA (RF07)', () { + // Fora de `development` nenhuma configuração inteira pode ser montada + // (ver o cabeçalho: `SMS_GATEWAY`), então a regra de produção é provada + // no resolvedor que o `fromMap` usa; o caso de development, pelo `fromMap`. + test('development sem a variável: desligado', () { + expect(AppConfig.fromMap(const {'APP_ENV': 'development'}).requireAcsMfa, isFalse); + expect(AppConfig.resolveRequireAcsMfa(value: null, appEnv: 'development'), isFalse); + }); + + test('production (ou qualquer ambiente fora de development) sem a variável: ligado', () { + expect(AppConfig.resolveRequireAcsMfa(value: null, appEnv: 'production'), isTrue); + expect(AppConfig.resolveRequireAcsMfa(value: null, appEnv: 'staging'), isTrue); + }); + + test('REQUIRE_ACS_MFA=false explícito desliga mesmo em produção', () { + expect(AppConfig.resolveRequireAcsMfa(value: 'false', appEnv: 'production'), isFalse); + }); + + test('REQUIRE_ACS_MFA=true explícito liga em development', () { + expect( + AppConfig.fromMap(const {'APP_ENV': 'development', 'REQUIRE_ACS_MFA': 'true'}).requireAcsMfa, + isTrue, + ); + }); + }); + + group('RETENTION_*_DAYS (LGPD-RF07)', () { + AppConfig buildRetention({ + String? alert, + String? visit, + String? triage, + }) => + AppConfig.fromMap({ + 'APP_ENV': 'development', + 'RETENTION_ALERT_DAYS': ?alert, + 'RETENTION_VISIT_DAYS': ?visit, + 'RETENTION_TRIAGE_DAYS': ?triage, + }); + + test('ausentes: os defaults da tabela de lgpd_design §5.6', () { + final config = buildRetention(); + expect(config.retentionAlertDays, 730); + expect(config.retentionVisitDays, 1825); + expect(config.retentionTriageDays, 1825); + }); + + test('valores válidos são aceitos', () { + final config = buildRetention(alert: '365', visit: '2555', triage: '1825'); + expect(config.retentionAlertDays, 365); + expect(config.retentionVisitDays, 2555); + expect(config.retentionTriageDays, 1825); + }); + + test('valor vazio cai no default (variável declarada mas em branco)', () { + expect(buildRetention(alert: ' ').retentionAlertDays, 730); + }); + + for (final invalido in ['0', '-5', 'abc', '1.5', '36501']) { + test('"$invalido" é recusado no boot', () { + expect( + () => buildRetention(alert: invalido), + throwsA(isA().having( + (error) => error.message, + 'message', + contains('RETENTION_ALERT_DAYS inválido'), + )), + ); + }); + } + }); + + group('RED_ALERT_ESCALATION_SECONDS (issue #44)', () { + const baseEnv = {'APP_ENV': 'development'}; + + test('ausente vale 300', () { + expect(AppConfig.fromMap(baseEnv).redAlertEscalationSeconds, 300); + }); + + test('valor vazio cai no default', () { + expect( + AppConfig.fromMap({...baseEnv, 'RED_ALERT_ESCALATION_SECONDS': ' '}) + .redAlertEscalationSeconds, + 300, + ); + }); + + test('limites 30 e 3600 são aceitos', () { + expect( + AppConfig.fromMap({...baseEnv, 'RED_ALERT_ESCALATION_SECONDS': '30'}) + .redAlertEscalationSeconds, + 30, + ); + expect( + AppConfig.fromMap({...baseEnv, 'RED_ALERT_ESCALATION_SECONDS': '3600'}) + .redAlertEscalationSeconds, + 3600, + ); + }); + + test('fora de 30..3600 ou não numérico falha o boot', () { + for (final invalido in ['5', '29', '3601', '-1', 'abc', '1.5']) { + expect( + () => AppConfig.fromMap({...baseEnv, 'RED_ALERT_ESCALATION_SECONDS': invalido}), + throwsArgumentError, + reason: invalido, + ); + } + }); + }); } diff --git a/backend/sinalacs_server/test/unit/audit_chain_status_service_test.dart b/backend/sinalacs_server/test/unit/audit_chain_status_service_test.dart new file mode 100644 index 00000000..4db8b785 --- /dev/null +++ b/backend/sinalacs_server/test/unit/audit_chain_status_service_test.dart @@ -0,0 +1,996 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:sinalacs_server/src/application/admin/audit_chain_status_service.dart'; +import 'package:sinalacs_server/src/application/audit/audit_chain.dart'; +import 'package:sinalacs_server/src/application/audit/audit_chain_checkpoint_store.dart'; +import 'package:sinalacs_server/src/application/audit/audit_chain_verifier.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/generated/protocol.dart'; +import 'package:test/test.dart'; + +/// `AuditChainStatusService` (#45, Task 6) sem banco: reader, store, trilha e +/// relógio falsos. Prova o mapeamento verificador → estado (D3), a projeção +/// por papel (D9: o coordenador não aprende nada de outra UBS), o +/// single-flight e o cache de 60 s, e que nada além de uma cadeia íntegra e +/// não vazia vira `verified`. Dados sintéticos. +const _segredo = 'segredo-sintetico-da-task-6'; +const _ubsA = 'ubs-a'; + +final _ordem = []; + +class _Cadeia { + _Cadeia() : _chain = AuditChain(secret: _segredo); + + final AuditChain _chain; + final entries = []; + + void append({String userId = 'autor-1', String result = 'success'}) { + final sequence = entries.length + 1; + final fields = AuditChainFields( + sequence: sequence, + previousHash: entries.isEmpty + ? AuditChain.genesisHash + : entries.last.entryHash, + userId: userId, + actionType: 'read', + resourceType: 'recurso_sintetico', + resourceId: null, + timestamp: DateTime.utc( + 2026, + 10, + 13, + 12, + ).add(Duration(seconds: sequence)), + ipHash: 'ip-$sequence', + result: result, + ); + entries.add( + AuditChainEntry( + fields: fields, + entryHash: _chain.computeEntryHash(fields), + ), + ); + } +} + +AuditChainFields _copia( + AuditChainFields f, { + int? sequence, + String? previousHash, + String? result, +}) => AuditChainFields( + sequence: sequence ?? f.sequence, + previousHash: previousHash ?? f.previousHash, + userId: f.userId, + actionType: f.actionType, + resourceType: f.resourceType, + resourceId: f.resourceId, + timestamp: f.timestamp, + ipHash: f.ipHash, + result: result ?? f.result, +); + +class _Reader implements AuditChainReader { + _Reader(List entries) + : entries = List.of(entries) + ..sort((a, b) => a.fields.sequence.compareTo(b.fields.sequence)); + + List entries; + final lotes = []; + + /// Se não nulo, o primeiro lote espera por ele (single-flight). + Completer? portao; + Object? lancar; + bool travar = false; + + @override + Future> readBatch({ + required int afterSequence, + required int limit, + }) async { + lotes.add(afterSequence); + _ordem.add('reader:readBatch'); + final p = portao; + if (p != null) await p.future; + if (travar) await Completer().future; + final erro = lancar; + if (erro != null) throw erro; + return entries + .where((e) => e.fields.sequence > afterSequence) + .take(limit) + .toList(); + } + + @override + Future entryAt(int sequence) async { + for (final e in entries) { + if (e.fields.sequence == sequence) return e; + } + return null; + } +} + +/// Leitura em lotes que para na linha 5, enquanto `entryAt` já vê as linhas +/// gravadas depois: a cadeia cresceu durante a verificação. +class _ReaderAteCinco implements AuditChainReader { + _ReaderAteCinco(this._todas); + + final _Reader _todas; + + @override + Future> readBatch({ + required int afterSequence, + required int limit, + }) async => (await _todas.readBatch( + afterSequence: afterSequence, + limit: limit, + )).where((e) => e.fields.sequence <= 5).toList(); + + @override + Future entryAt(int sequence) => _todas.entryAt(sequence); +} + +class _Store implements AuditChainScopeStore { + final Map ubs = { + 'coord-a': _ubsA, + 'coord-b': 'ubs-b', + 'coord-sem-ubs': null, + }; + + /// `sequence → UBS do autor`. + final Map donoDa = {}; + bool falharPertencimento = false; + final consultas = <(int, String)>[]; + + @override + Future ubsOf(String staffId) async => ubs[staffId]; + + @override + Future auditRowInUbs(int sequence, String ubsId) async { + consultas.add((sequence, ubsId)); + if (falharPertencimento) throw StateError('banco fora do ar (sintético)'); + return donoDa[sequence] == ubsId; + } +} + +class _Audit extends AuditTrail { + final events = []; + bool falhar = false; + + @override + Future record(AuditEvent event) async { + if (falhar) throw StateError('auditoria indisponível'); + _ordem.add( + 'audit:${event.actionType}:${event.resourceType}:${event.result}', + ); + events.add(event); + } +} + +/// Checkpoints em memória, com a mesma regra do store ORM: só grava se +/// avança (nenhuma `sequence` >= já existe). +class _Checkpoints implements AuditChainCheckpointStore { + final linhas = []; + Object? falharLeitura; + Object? falharEscrita; + bool travarLeitura = false; + final tentativas = []; + + void assinado(int sequence, String entryHash, {String segredo = _segredo}) => + linhas.add( + AuditChainCheckpointRecord( + sequence: sequence, + entryHash: entryHash, + signature: AuditChain( + secret: segredo, + ).computeCheckpointSignature(sequence, entryHash), + ), + ); + + @override + Future> latest({required int limit}) async { + _ordem.add('checkpoints:latest'); + if (travarLeitura) await Completer().future; + final erro = falharLeitura; + if (erro != null) throw erro; + final ordenadas = List.of(linhas) + ..sort((a, b) => b.sequence.compareTo(a.sequence)); + return ordenadas.take(limit).toList(); + } + + @override + Future advanceTo(AuditChainCheckpointRecord c) async { + tentativas.add(c.sequence); + final erro = falharEscrita; + if (erro != null) throw erro; + if (linhas.any((l) => l.sequence >= c.sequence)) return; + linhas.add(c); + } +} + +AuthenticatedUser _u(String id, UserRole role, {String? ma}) => + AuthenticatedUser( + id: id, + role: role, + microAreaId: ma, + deviceId: 'sem-aparelho', + ); + +void main() { + late _Cadeia cadeia; + late _Store store; + late _Audit audit; + late _Checkpoints checkpoints; + late List falhasDeGravacao; + late AuditChainStatusCache cache; + late DateTime agora; + final t0 = DateTime.utc(2026, 10, 13, 15); + final admin = _u('admin-1', UserRole.admin); + final coordA = _u('coord-a', UserRole.coordinator); + final coordB = _u('coord-b', UserRole.coordinator); + + setUp(() { + _ordem.clear(); + cadeia = _Cadeia(); + for (var i = 0; i < 5; i++) { + cadeia.append(); + } + store = _Store(); + audit = _Audit(); + checkpoints = _Checkpoints(); + falhasDeGravacao = []; + cache = AuditChainStatusCache(); + agora = t0; + }); + + AuditChainStatusService servico( + AuditChainReader reader, { + String segredo = _segredo, + Duration budget = AuditChainStatusService.defaultBudget, + Duration readTimeout = AuditChainStatusService.defaultReadTimeout, + DateTime Function()? clock, + }) => AuditChainStatusService( + store: store, + audit: audit, + reader: reader, + secret: segredo, + checkpoints: checkpoints, + onCheckpointWriteFailure: falhasDeGravacao.add, + cache: cache, + budget: budget, + readTimeout: readTimeout, + clock: clock ?? () => agora, + ); + + group('estado (D3)', () { + test('cadeia íntegra ⇒ verified, com checkedRows e headSequence', () async { + final s = await servico(_Reader(cadeia.entries)).verify(admin); + expect(s.state, AuditIntegrityState.verified); + expect(s.checkedRows, 5); + expect(s.headSequence, 5); + expect(s.firstBrokenSequence, isNull); + expect(s.brokenInScope, isNull); + expect(s.reasonCode, isNull); + expect(s.verifiedAt, t0); + }); + + final adulteracoes = + < + String, + ( + List Function(List), + AuditChainReason, + int, + String?, + ) + >{ + 'edição do conteúdo da linha 3': ( + (e) => [ + ...e.take(2), + AuditChainEntry( + fields: _copia(e[2].fields, result: 'x'), + entryHash: e[2].entryHash, + ), + ...e.skip(3), + ], + AuditChainReason.hashMismatch, + 3, + null, + ), + 'remoção da linha 3': ( + (e) => [...e.take(2), ...e.skip(3)], + AuditChainReason.sequenceGap, + 4, + null, + ), + 'renumeração da 4 para ocupar a 3 apagada': ( + (e) => [ + ...e.take(2), + AuditChainEntry( + fields: _copia(e[3].fields, sequence: 3), + entryHash: e[3].entryHash, + ), + ...e.skip(4), + ], + AuditChainReason.linkBroken, + 3, + null, + ), + // Review Focus 3: segredo trocado quebra já na 1 — "falha", nunca + // "íntegra". + 'segredo trocado no servidor': ( + (e) => e, + AuditChainReason.hashMismatch, + 1, + 'outro-segredo', + ), + }; + + for (final MapEntry(key: nome, value: (mutar, motivo, seq, segredo)) + in adulteracoes.entries) { + test('$nome ⇒ broken/${motivo.name} na sequence $seq', () async { + final s = await servico( + _Reader(mutar(cadeia.entries)), + segredo: segredo ?? _segredo, + ).verify(admin); + expect(s.state, AuditIntegrityState.broken); + expect(s.reasonCode, motivo); + expect(s.firstBrokenSequence, seq); + expect(s.brokenInScope, isTrue); + expect(s.headSequence, isNull); + }); + } + + test('reader lança Exception ⇒ unavailable/databaseError', () async { + final r = _Reader(cadeia.entries)..lancar = Exception('fora do ar'); + final s = await servico(r).verify(admin); + expect(s.state, AuditIntegrityState.unavailable); + expect(s.reasonCode, AuditChainReason.databaseError); + }); + + test( + 'reader lança Error (não Exception) ⇒ unavailable/databaseError, não 500', + () async { + final r = _Reader(cadeia.entries)..lancar = StateError('pool fechado'); + final s = await servico(r).verify(admin); + expect(s.state, AuditIntegrityState.unavailable); + expect(s.reasonCode, AuditChainReason.databaseError); + }, + ); + + test( + 'leitura que não responde ⇒ timeout ⇒ unavailable/databaseError', + () async { + final r = _Reader(cadeia.entries)..travar = true; + final s = await servico( + r, + readTimeout: const Duration(milliseconds: 20), + ).verify(admin); + expect(s.state, AuditIntegrityState.unavailable); + expect(s.reasonCode, AuditChainReason.databaseError); + }, + ); + + test( + 'segredo vazio ⇒ unavailable/secretUnavailable, sem ler o banco', + () async { + for (final vazio in ['', ' ']) { + cache.reset(); + final r = _Reader(cadeia.entries); + final s = await servico(r, segredo: vazio).verify(admin); + expect(s.state, AuditIntegrityState.unavailable, reason: '"$vazio"'); + expect(s.reasonCode, AuditChainReason.secretUnavailable); + expect(r.lotes, isEmpty); + } + }, + ); + + test( + 'cadeia vazia ⇒ inconclusive (nada a atestar), nunca verified', + () async { + final s = await servico(_Reader(const [])).verify(admin); + expect(s.state, AuditIntegrityState.inconclusive); + expect(s.reasonCode, isNull); + expect(s.checkedRows, 0); + expect(s.headSequence, isNull); + }, + ); + + test('orçamento esgotado ⇒ inconclusive/timeBudget', () async { + final grande = _Cadeia(); + for (var i = 0; i < AuditChainVerifier.batchSize + 10; i++) { + grande.append(); + } + var tique = t0; + final s = await servico( + _Reader(grande.entries), + budget: Duration.zero, + clock: () => tique = tique.add(const Duration(milliseconds: 1)), + ).verify(admin); + expect(s.state, AuditIntegrityState.inconclusive); + expect(s.reasonCode, AuditChainReason.timeBudget); + expect(s.firstBrokenSequence, isNull); + }); + }); + + group('propriedade negativa: não-verified nunca é verified', () { + for (final motivo in AuditChainReason.values) { + test('ok:false com ${motivo.name} ⇒ state != verified', () { + for (final checked in [0, 1, 5, 1000]) { + final estado = AuditChainStatusService.stateOf( + AuditChainVerification( + ok: false, + checked: checked, + brokenAtSequence: checked + 1, + reasonCode: motivo, + ), + ); + expect(estado, isNot(AuditIntegrityState.verified)); + } + }); + } + + test('ok:false sem reasonCode ⇒ unavailable (defensivo)', () { + expect( + AuditChainStatusService.stateOf( + const AuditChainVerification(ok: false, checked: 9), + ), + AuditIntegrityState.unavailable, + ); + }); + + test('só ok:true com linhas ⇒ verified', () { + expect( + AuditChainStatusService.stateOf( + const AuditChainVerification(ok: true, checked: 1), + ), + AuditIntegrityState.verified, + ); + expect( + AuditChainStatusService.stateOf( + const AuditChainVerification(ok: true, checked: 0), + ), + AuditIntegrityState.inconclusive, + ); + }); + }); + + group('papel e escopo (D9, Review Focus 7)', () { + List editada3(List e) => [ + ...e.take(2), + AuditChainEntry( + fields: _copia(e[2].fields, result: 'x'), + entryHash: e[2].entryHash, + ), + ...e.skip(3), + ]; + + test( + 'coordenador com quebra de outra UBS ⇒ broken, brokenInScope=false, ' + 'sem sequence, sem motivo e sem volume', + () async { + store.donoDa[3] = 'ubs-b'; + final s = await servico( + _Reader(editada3(cadeia.entries)), + ).verify(coordA); + expect(s.state, AuditIntegrityState.broken); + expect(s.brokenInScope, isFalse); + expect(s.firstBrokenSequence, isNull); + expect(s.reasonCode, isNull); + expect(s.checkedRows, isNull); + expect(s.headSequence, isNull); + expect(store.consultas, [(3, _ubsA)]); + }, + ); + + test( + 'coordenador com quebra na própria UBS ⇒ broken, brokenInScope=true, ' + 'sequence e motivo', + () async { + store.donoDa[3] = _ubsA; + final s = await servico( + _Reader(editada3(cadeia.entries)), + ).verify(coordA); + expect(s.state, AuditIntegrityState.broken); + expect(s.brokenInScope, isTrue); + expect(s.firstBrokenSequence, 3); + expect(s.reasonCode, AuditChainReason.hashMismatch); + expect(s.checkedRows, isNull); + }, + ); + + test( + 'pertencimento que falha ⇒ broken, brokenInScope nulo, sem sequence', + () async { + store.falharPertencimento = true; + final s = await servico( + _Reader(editada3(cadeia.entries)), + ).verify(coordA); + expect(s.state, AuditIntegrityState.broken); + expect(s.brokenInScope, isNull); + expect(s.firstBrokenSequence, isNull); + }, + ); + + test('coordenador com cadeia íntegra ⇒ verified, sem volume', () async { + final s = await servico(_Reader(cadeia.entries)).verify(coordB); + expect(s.state, AuditIntegrityState.verified); + expect(s.checkedRows, isNull); + expect(s.headSequence, isNull); + expect(store.consultas, isEmpty); + }); + + test('coordenador com orçamento esgotado ⇒ inconclusive SEM reasonCode ' + '(não revela o tamanho da cadeia global)', () async { + final grande = _Cadeia(); + for (var i = 0; i < AuditChainVerifier.batchSize + 10; i++) { + grande.append(); + } + var tique = t0; + final s = await servico( + _Reader(grande.entries), + budget: Duration.zero, + clock: () => tique = tique.add(const Duration(milliseconds: 1)), + ).verify(coordA); + expect(s.state, AuditIntegrityState.inconclusive); + expect(s.reasonCode, isNull); + }); + + test('coordenador vê databaseError (unavailable)', () async { + final r = _Reader(cadeia.entries)..lancar = Exception('fora do ar'); + final s = await servico(r).verify(coordA); + expect(s.state, AuditIntegrityState.unavailable); + expect(s.reasonCode, AuditChainReason.databaseError); + }); + + test('coordenador vê o motivo de unavailable/inconclusive', () async { + final s = await servico( + _Reader(cadeia.entries), + segredo: '', + ).verify(coordA); + expect(s.state, AuditIntegrityState.unavailable); + expect(s.reasonCode, AuditChainReason.secretUnavailable); + }); + + test( + 'ACS, paciente e coordenador sem UBS ⇒ AlertPermissionException e ' + 'denied, sem ler a cadeia', + () async { + final r = _Reader(cadeia.entries); + for (final u in [ + _u('acs-1', UserRole.acs, ma: 'ma-1'), + _u('pac-1', UserRole.patient, ma: 'ma-1'), + _u('coord-sem-ubs', UserRole.coordinator), + ]) { + await expectLater( + servico(r).verify(u), + throwsA(isA()), + ); + } + expect(r.lotes, isEmpty); + expect(audit.events.map((e) => e.result), [ + 'denied', + 'denied', + 'denied', + ]); + expect( + audit.events.map((e) => e.resourceType).toSet(), + {'admin_audit_chain'}, + ); + }, + ); + }); + + group('auditoria antes de verificar', () { + test('grava read/admin_audit_chain/success ANTES de ler', () async { + await servico(_Reader(cadeia.entries)).verify(admin); + expect(_ordem.first, 'audit:read:admin_audit_chain:success'); + // A cadeia primeiro, a âncora de cauda (Task 7) depois. + expect(_ordem.skip(1), [ + 'reader:readBatch', + 'checkpoints:latest', + ]); + }); + + test('trilha fora do ar ⇒ a exceção sobe e nada é lido', () async { + audit.falhar = true; + final r = _Reader(cadeia.entries); + await expectLater(servico(r).verify(admin), throwsA(isA())); + expect(r.lotes, isEmpty); + }); + + test('resposta do cache também é auditada', () async { + final r = _Reader(cadeia.entries); + await servico(r).verify(admin); + await servico(r).verify(admin); + expect(audit.events.where((e) => e.result == 'success'), hasLength(2)); + }); + }); + + group('single-flight e cache (60 s)', () { + test('duas chamadas concorrentes ⇒ um só readBatch inicial', () async { + final r = _Reader(cadeia.entries)..portao = Completer(); + final a = servico(r).verify(admin); + final b = servico(r).verify(coordB); + // Deixa as duas chegarem ao verificador antes de liberar a leitura. + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + r.portao!.complete(); + final (sa, sb) = await (a, b).wait; + expect(r.lotes, [r.lotes.first], reason: 'um único lote: 5 linhas'); + expect(sa.state, AuditIntegrityState.verified); + expect(sb.state, AuditIntegrityState.verified); + expect(sa.verifiedAt, sb.verifiedAt); + }); + + test('segunda chamada em 60 s não relê; depois de 60 s, relê', () async { + final r = _Reader(cadeia.entries); + final primeira = await servico(r).verify(admin); + expect(r.lotes, hasLength(1)); + + agora = t0.add(const Duration(seconds: 59)); + final segunda = await servico(r).verify(admin); + expect(r.lotes, hasLength(1)); + expect(segunda.verifiedAt, primeira.verifiedAt); + + agora = t0.add(const Duration(seconds: 61)); + final terceira = await servico(r).verify(admin); + expect(r.lotes, hasLength(2)); + expect(terceira.verifiedAt, agora); + }); + + test('o cache vale para todos e a projeção continua por papel', () async { + store.donoDa[3] = 'ubs-b'; + final r = _Reader([ + ...cadeia.entries.take(2), + AuditChainEntry( + fields: _copia(cadeia.entries[2].fields, result: 'x'), + entryHash: cadeia.entries[2].entryHash, + ), + ...cadeia.entries.skip(3), + ]); + final doAdmin = await servico(r).verify(admin); + final doCoord = await servico(r).verify(coordA); + expect(r.lotes, hasLength(1)); + expect(doAdmin.firstBrokenSequence, 3); + expect(doCoord.firstBrokenSequence, isNull); + expect(doCoord.brokenInScope, isFalse); + }); + + test( + 'currentState(notBefore): retrato verified de 30 s atrás é ignorado; ' + 'nova verificação grava o resultado novo (broken)', + () async { + final r = _Reader(cadeia.entries); + expect( + (await servico(r).verify(admin)).state, + AuditIntegrityState.verified, + ); + expect(r.lotes, hasLength(1)); + + // Adulteração depois do retrato em cache. + r.entries = [ + ...cadeia.entries.take(2), + AuditChainEntry( + fields: _copia(cadeia.entries[2].fields, result: 'x'), + entryHash: cadeia.entries[2].entryHash, + ), + ...cadeia.entries.skip(3), + ]; + agora = t0.add(const Duration(seconds: 30)); + // Sem notBefore o cache ainda serviria verified (controle). + expect( + (await servico(r).verify(admin)).state, + AuditIntegrityState.verified, + ); + expect(r.lotes, hasLength(1)); + + final estado = await servico(r).currentState(notBefore: agora); + expect(estado, AuditIntegrityState.broken); + expect(r.lotes, hasLength(2)); + // O cache passa a ser o retrato novo. + expect( + (await servico(r).verify(admin)).state, + AuditIntegrityState.broken, + ); + expect(r.lotes, hasLength(2)); + }, + ); + + test( + 'currentState(notBefore) não reaproveita single-flight iniciado antes; ' + 'reaproveita o iniciado depois', + () async { + final r = _Reader(cadeia.entries)..portao = Completer(); + final antiga = servico(r).verify(admin); + await Future.delayed(Duration.zero); + agora = t0.add(const Duration(seconds: 1)); + final fresca = servico(r).currentState(notBefore: agora); + final mesma = servico(r).currentState(notBefore: agora); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + r.portao!.complete(); + await antiga; + expect(await fresca, AuditIntegrityState.verified); + expect(await mesma, AuditIntegrityState.verified); + // Uma leitura da verificação antiga + UMA da fresca (compartilhada). + expect(r.lotes, hasLength(2)); + }, + ); + + test('currentState(notBefore) com leitura falhando ⇒ unavailable, ' + 'nunca verified do cache', () async { + final r = _Reader(cadeia.entries); + await servico(r).verify(admin); + r.lancar = Exception('banco fora'); + agora = t0.add(const Duration(seconds: 5)); + expect( + await servico(r).currentState(notBefore: agora), + AuditIntegrityState.unavailable, + ); + }); + + test( + 'currentState(notBefore) não grava linha de auditoria própria', + () async { + final r = _Reader(cadeia.entries); + await servico(r).currentState(notBefore: agora); + expect(_ordem.where((o) => o.startsWith('audit:')), isEmpty); + }, + ); + + test('relógio voltando no tempo não estende o cache para sempre', () async { + final r = _Reader(cadeia.entries); + await servico(r).verify(admin); + agora = t0.subtract(const Duration(minutes: 5)); + await servico(r).verify(admin); + expect(r.lotes, hasLength(2)); + }); + }); + + group('checkpoint de cauda (Task 7, D4)', () { + String hashDa(int sequence) => cadeia.entries[sequence - 1].entryHash; + + test('verified grava (headSequence, headEntryHash) com assinatura da ' + 'mesma AuditChain', () async { + final s = await servico(_Reader(cadeia.entries)).verify(admin); + expect(s.state, AuditIntegrityState.verified); + expect(checkpoints.linhas, hasLength(1)); + final c = checkpoints.linhas.single; + expect(c.sequence, 5); + expect(c.entryHash, hashDa(5)); + expect( + c.signature, + AuditChain(secret: _segredo).computeCheckpointSignature(5, hashDa(5)), + ); + expect(falhasDeGravacao, isEmpty); + }); + + test('cauda apagada (3 últimas) depois do checkpoint ⇒ ' + 'broken/tailTruncated na sequence do checkpoint', () async { + checkpoints.assinado(5, hashDa(5)); + final s = await servico( + _Reader(cadeia.entries.take(2).toList()), + ).verify(admin); + expect(s.state, AuditIntegrityState.broken); + expect(s.reasonCode, AuditChainReason.tailTruncated); + expect(s.firstBrokenSequence, 5); + expect(s.brokenInScope, isTrue); + expect(s.headSequence, isNull); + // Nada é gravado sobre uma cadeia quebrada: o checkpoint antigo fica. + expect(checkpoints.tentativas, isEmpty); + expect(checkpoints.linhas.single.sequence, 5); + }); + + test('cauda apagada e regravada por cima com HMAC válido ⇒ ' + 'entryHash diverge no checkpoint ⇒ tailTruncated', () async { + checkpoints.assinado(5, hashDa(5)); + // A aplicação continua gravando depois do truncamento: as linhas 3..5 + // existem de novo, legítimas, mas com outro conteúdo. + final outra = _Cadeia() + ..entries.addAll(cadeia.entries.take(2)) + ..append(userId: 'outro') + ..append(userId: 'outro') + ..append(userId: 'outro'); + final s = await servico(_Reader(outra.entries)).verify(admin); + expect(s.state, AuditIntegrityState.broken); + expect(s.reasonCode, AuditChainReason.tailTruncated); + expect(s.firstBrokenSequence, 5); + expect(checkpoints.tentativas, isEmpty); + }); + + test('todas as linhas apagadas com checkpoint ⇒ tailTruncated, ' + 'não inconclusive', () async { + checkpoints.assinado(5, hashDa(5)); + final s = await servico(_Reader(const [])).verify(admin); + expect(s.state, AuditIntegrityState.broken); + expect(s.reasonCode, AuditChainReason.tailTruncated); + }); + + test('linha nova por cima ⇒ verified e o checkpoint avança', () async { + checkpoints.assinado(5, hashDa(5)); + cadeia.append(); + final s = await servico(_Reader(cadeia.entries)).verify(admin); + expect(s.state, AuditIntegrityState.verified); + expect(checkpoints.linhas.map((c) => c.sequence), [5, 6]); + expect(checkpoints.linhas.last.entryHash, hashDa(6)); + }); + + test('cabeça igual ao checkpoint ⇒ verified, sem gravar de novo', () async { + checkpoints.assinado(5, hashDa(5)); + final s = await servico(_Reader(cadeia.entries)).verify(admin); + expect(s.state, AuditIntegrityState.verified); + expect(checkpoints.tentativas, isEmpty); + }); + + test('checkpoint à frente da leitura, mas a linha existe com o mesmo ' + 'hash (cadeia cresceu durante a verificação) ⇒ verified, nunca grava ' + 'sequence menor', () async { + cadeia.append(); + cadeia.append(); + checkpoints.assinado(7, hashDa(7)); + // readBatch enxerga 5; entryAt(7) enxerga a linha que chegou depois. + final lento = _ReaderAteCinco(_Reader(cadeia.entries)); + final s = await servico(lento).verify(admin); + expect(s.state, AuditIntegrityState.verified); + expect(s.checkedRows, 5); + expect(checkpoints.tentativas, isEmpty); + expect(checkpoints.linhas.map((c) => c.sequence), [7]); + }); + + test('checkpoint com assinatura inválida ⇒ ignorado e sinalizado: ' + 'inconclusive/checkpointInvalid, nada gravado, nada apagado', () async { + checkpoints.linhas.add( + AuditChainCheckpointRecord( + sequence: 50, + entryHash: hashDa(5), + signature: 'f' * 64, + ), + ); + final s = await servico(_Reader(cadeia.entries)).verify(admin); + expect(s.state, AuditIntegrityState.inconclusive); + expect(s.reasonCode, AuditChainReason.checkpointInvalid); + expect(s.headSequence, isNull); + // Não foi aceito (a sequence 50 não existe e não virou tailTruncated) e + // não foi descartado em silêncio (não virou verified). + expect(checkpoints.tentativas, isEmpty); + expect(checkpoints.linhas, hasLength(1)); + }); + + test('checkpoint assinado com outro segredo ⇒ inconclusive', () async { + checkpoints.assinado(5, hashDa(5), segredo: 'outro-segredo'); + final s = await servico(_Reader(cadeia.entries)).verify(admin); + expect(s.state, AuditIntegrityState.inconclusive); + expect(s.reasonCode, AuditChainReason.checkpointInvalid); + }); + + test('inválido mais novo não esconde o válido mais antigo: truncamento ' + 'continua broken/tailTruncated', () async { + checkpoints.assinado(5, hashDa(5)); + checkpoints.linhas.add( + AuditChainCheckpointRecord( + sequence: 9, + entryHash: 'x', + signature: 'y', + ), + ); + final s = await servico( + _Reader(cadeia.entries.take(3).toList()), + ).verify(admin); + expect(s.state, AuditIntegrityState.broken); + expect(s.reasonCode, AuditChainReason.tailTruncated); + expect(s.firstBrokenSequence, 5); + }); + + test('coordenador: motivo visível no inconclusive do checkpoint', () async { + checkpoints.linhas.add( + const AuditChainCheckpointRecord( + sequence: 1, + entryHash: 'x', + signature: 'y', + ), + ); + final s = await servico(_Reader(cadeia.entries)).verify(coordA); + expect(s.state, AuditIntegrityState.inconclusive); + expect(s.reasonCode, AuditChainReason.checkpointInvalid); + expect(s.checkedRows, isNull); + }); + + test('falha ao LER checkpoints ⇒ unavailable/databaseError, nunca ' + 'verified', () async { + for (final erro in [ + Exception('banco fora'), + StateError('erro'), + ]) { + cache.reset(); + checkpoints.falharLeitura = erro; + final s = await servico(_Reader(cadeia.entries)).verify(admin); + expect(s.state, AuditIntegrityState.unavailable); + expect(s.reasonCode, AuditChainReason.databaseError); + expect(checkpoints.tentativas, isEmpty); + } + }); + + test('leitura de checkpoints travada ⇒ prazo ⇒ unavailable', () async { + checkpoints.travarLeitura = true; + final s = await servico( + _Reader(cadeia.entries), + readTimeout: const Duration(milliseconds: 20), + clock: DateTime.now, + ).verify(admin); + expect(s.state, AuditIntegrityState.unavailable); + expect(s.reasonCode, AuditChainReason.databaseError); + }); + + test('falha ao GRAVAR o checkpoint ⇒ continua verified, sem 500, e a ' + 'falha é reportada (não mascarada)', () async { + for (final erro in [Exception('unique'), StateError('erro')]) { + cache.reset(); + falhasDeGravacao.clear(); + checkpoints.falharEscrita = erro; + final s = await servico(_Reader(cadeia.entries)).verify(admin); + expect(s.state, AuditIntegrityState.verified); + expect(s.checkedRows, 5); + expect(falhasDeGravacao, [erro]); + } + }); + + test('callback de falha que também lança não derruba a resposta', () async { + checkpoints.falharEscrita = Exception('x'); + final s = await AuditChainStatusService( + store: store, + audit: audit, + reader: _Reader(cadeia.entries), + secret: _segredo, + checkpoints: checkpoints, + onCheckpointWriteFailure: (_) => throw StateError('log fora'), + cache: cache, + clock: () => agora, + ).verify(admin); + expect(s.state, AuditIntegrityState.verified); + }); + + test( + 'quebra e cadeia vazia sem checkpoint não gravam checkpoint', + () async { + final quebrada = [ + ...cadeia.entries.take(2), + ...cadeia.entries.skip(3), + ]; + expect( + (await servico(_Reader(quebrada)).verify(admin)).state, + AuditIntegrityState.broken, + ); + cache.reset(); + expect( + (await servico(_Reader(const [])).verify(admin)).state, + AuditIntegrityState.inconclusive, + ); + expect(checkpoints.tentativas, isEmpty); + expect(checkpoints.linhas, isEmpty); + }, + ); + + test('resposta não carrega hash nem assinatura do checkpoint', () async { + checkpoints.assinado(5, hashDa(5)); + final s = await servico( + _Reader(cadeia.entries.take(2).toList()), + ).verify(admin); + final texto = jsonEncode(s.toJson()); + expect(texto, isNot(contains(hashDa(5)))); + expect(texto, isNot(contains(checkpoints.linhas.single.signature))); + }); + }); + + test('a resposta serializada não tem hash nem segredo', () async { + final s = await servico(_Reader(cadeia.entries)).verify(admin); + final texto = jsonEncode(s.toJson()); + expect(texto, isNot(contains(_segredo))); + expect(texto, isNot(contains('Hash'))); + for (final e in cadeia.entries) { + expect(texto, isNot(contains(e.entryHash))); + } + }); +} diff --git a/backend/sinalacs_server/test/unit/audit_chain_test.dart b/backend/sinalacs_server/test/unit/audit_chain_test.dart index 6e00aef6..65fbe165 100644 --- a/backend/sinalacs_server/test/unit/audit_chain_test.dart +++ b/backend/sinalacs_server/test/unit/audit_chain_test.dart @@ -1,6 +1,7 @@ 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:sinalacs_server/src/generated/protocol.dart'; import 'package:test/test.dart'; class _ThrowingAuditTrail extends AuditTrail { @@ -50,13 +51,63 @@ class _ChainBuilder { List build() => List.of(_entries); } +/// Leitor em memória com o contrato de `readBatch`: `sequence > +/// afterSequence`, ordem crescente, no máximo `limit`. Conta as chamadas para +/// provar a leitura em lotes. As entradas são ordenadas por `sequence`, como o +/// `ORDER BY` do banco faria. class _FakeReader implements AuditChainReader { - _FakeReader(this.entries); + _FakeReader(List entries, {this.falharNoLote}) + : entries = List.of(entries) + ..sort((a, b) => a.fields.sequence.compareTo(b.fields.sequence)); final List entries; + final int? falharNoLote; + final lotes = <({int afterSequence, int limit})>[]; @override - Future> readInOrder() async => entries; + Future> readBatch({ + required int afterSequence, + required int limit, + }) async { + lotes.add((afterSequence: afterSequence, limit: limit)); + if (falharNoLote == lotes.length) { + throw Exception('banco fora do ar (sintético)'); + } + return entries + .where((e) => e.fields.sequence > afterSequence) + .take(limit) + .toList(); + } + + @override + Future entryAt(int sequence) async { + for (final e in entries) { + if (e.fields.sequence == sequence) return e; + } + return null; + } +} + +/// A versão antiga do verificador (lia tudo de uma vez), mantida no teste como +/// oráculo: a leitura em lotes tem de dar o MESMO resultado. +Future<({bool ok, int checked, int? brokenAt})> _verificarDeUmaVez( + List entries, + String secret, +) async { + final chain = AuditChain(secret: secret); + var expectedSequence = 1; + var expectedPreviousHash = AuditChain.genesisHash; + for (final entry in entries) { + final f = entry.fields; + if (f.sequence != expectedSequence || + f.previousHash != expectedPreviousHash || + !chain.matches(f, entry.entryHash)) { + return (ok: false, checked: expectedSequence - 1, brokenAt: f.sequence); + } + expectedPreviousHash = entry.entryHash; + expectedSequence++; + } + return (ok: true, checked: entries.length, brokenAt: null); } AuditChainFields _withResult(AuditChainFields fields, String result) => @@ -147,6 +198,71 @@ void main() { }); }); + group('AuditChain — assinatura de checkpoint (#45, Task 7, D4)', () { + final hash = 'a' * 64; + + test('é HMAC-SHA256 de "checkpoint" ␟ sequence ␟ entryHash', () { + final chain = AuditChain(secret: secret); + final assinatura = chain.computeCheckpointSignature(7, hash); + // Hex de 64 dígitos, determinística. + expect(assinatura, matches(RegExp(r'^[0-9a-f]{64}$'))); + expect(chain.computeCheckpointSignature(7, hash), assinatura); + expect(chain.checkpointMatches(7, hash, assinatura), isTrue); + }); + + test('muda com sequence, entryHash e segredo', () { + final chain = AuditChain(secret: secret); + final base = chain.computeCheckpointSignature(7, hash); + expect(chain.computeCheckpointSignature(8, hash), isNot(base)); + expect(chain.computeCheckpointSignature(7, 'b' * 64), isNot(base)); + expect( + AuditChain(secret: 'outro').computeCheckpointSignature(7, hash), + isNot(base), + ); + }); + + test('assinatura adulterada, de outro sequence ou de outro segredo ' + 'não confere', () { + final chain = AuditChain(secret: secret); + final boa = chain.computeCheckpointSignature(7, hash); + expect(chain.checkpointMatches(8, hash, boa), isFalse); + expect(chain.checkpointMatches(7, 'b' * 64, boa), isFalse); + expect( + chain.checkpointMatches( + 7, + hash, + AuditChain(secret: 'outro').computeCheckpointSignature(7, hash), + ), + isFalse, + ); + expect(chain.checkpointMatches(7, hash, ''), isFalse); + expect(chain.checkpointMatches(7, hash, boa.substring(1)), isFalse); + final trocada = '${boa[0] == '0' ? '1' : '0'}${boa.substring(1)}'; + expect(chain.checkpointMatches(7, hash, trocada), isFalse); + }); + + test('não colide com o entryHash de uma linha (domínio separado)', () { + // O payload de linha começa por um inteiro; o de checkpoint, pela + // palavra "checkpoint" — uma assinatura nunca serve de entryHash. + final chain = AuditChain(secret: secret); + final fields = AuditChainFields( + sequence: 7, + previousHash: hash, + userId: 'user-1', + actionType: 'read', + resourceType: 'r', + resourceId: null, + timestamp: DateTime.utc(2026, 1, 1), + ipHash: 'ip', + result: 'success', + ); + expect( + chain.computeCheckpointSignature(7, hash), + isNot(chain.computeEntryHash(fields)), + ); + }); + }); + group('AuditChainVerifier', () { test('uma cadeia vazia é íntegra por vacuidade', () async { final verifier = @@ -207,6 +323,7 @@ void main() { expect(result.brokenAtSequence, 2); expect(result.checked, 1); expect(result.reason, contains('adulteração')); + expect(result.reasonCode, AuditChainReason.hashMismatch); }); test('apagar uma linha do meio quebra a contiguidade', () async { @@ -226,6 +343,7 @@ void main() { expect(result.brokenAtSequence, 3); expect(result.checked, 1); expect(result.reason, contains('descontínua')); + expect(result.reasonCode, AuditChainReason.sequenceGap); }); test('renumerar uma linha para ocupar o lugar de outra apagada quebra o elo', @@ -255,6 +373,7 @@ void main() { expect(result.ok, isFalse); expect(result.checked, 1); expect(result.reason, contains('elo quebrado')); + expect(result.reasonCode, AuditChainReason.linkBroken); }); test('verificar com o segredo errado rejeita uma cadeia legítima', @@ -270,6 +389,191 @@ void main() { expect(result.ok, isFalse); expect(result.brokenAtSequence, 1); expect(result.reason, contains('adulteração')); + expect(result.reasonCode, AuditChainReason.hashMismatch); + }); + }); + + group('AuditChainVerifier em lotes (#45, G5)', () { + List cadeia(int n) { + final b = _ChainBuilder(secret: secret); + for (var i = 0; i < n; i++) { + b.append(result: i.isEven ? 'granted' : 'denied'); + } + return b.build(); + } + + test('2500 linhas ⇒ 3 lotes de até 1000, mesmo resultado da versão antiga', + () async { + final entries = cadeia(2500); + final reader = _FakeReader(entries); + final result = + await AuditChainVerifier(reader: reader, secret: secret).verify(); + + expect(AuditChainVerifier.batchSize, 1000); + expect(reader.lotes, hasLength(3)); + expect(reader.lotes.map((l) => l.limit).toSet(), {1000}); + expect(reader.lotes[1].afterSequence, 1000); + expect(reader.lotes[2].afterSequence, 2000); + + final antigo = await _verificarDeUmaVez(entries, secret); + expect(result.ok, antigo.ok); + expect(result.ok, isTrue); + expect(result.checked, antigo.checked); + expect(result.checked, 2500); + expect(result.reasonCode, isNull); + expect(result.brokenAtSequence, isNull); + }); + + test('múltiplo exato do lote ⇒ lê um lote vazio e termina íntegro', + () async { + final reader = _FakeReader(cadeia(2000)); + final result = + await AuditChainVerifier(reader: reader, secret: secret).verify(); + expect(result.ok, isTrue); + expect(result.checked, 2000); + expect(reader.lotes, hasLength(3)); + }); + + test('remoção DENTRO de um lote é detectada', () async { + final entries = cadeia(2500)..removeAt(1499); // apaga a sequence 1500 + final result = + await AuditChainVerifier(reader: _FakeReader(entries), secret: secret) + .verify(); + final antigo = await _verificarDeUmaVez(entries, secret); + + expect(result.ok, isFalse); + expect(result.reasonCode, AuditChainReason.sequenceGap); + expect(result.brokenAtSequence, 1501); + expect(result.checked, 1499); + expect( + (result.ok, result.checked, result.brokenAtSequence), + (antigo.ok, antigo.checked, antigo.brokenAt), + ); + }); + + test('remoção NA FRONTEIRA entre lotes é detectada', () async { + // Apaga a sequence 1001: o 1º lote termina íntegro em 1000 e o 2º + // começa em 1002. + final entries = cadeia(2500)..removeAt(1000); + final result = + await AuditChainVerifier(reader: _FakeReader(entries), secret: secret) + .verify(); + + expect(result.ok, isFalse); + expect(result.reasonCode, AuditChainReason.sequenceGap); + expect(result.brokenAtSequence, 1002); + expect(result.checked, 1000); + }); + + test('remoção da ÚLTIMA linha de um lote é detectada', () async { + final entries = cadeia(2500)..removeAt(999); // apaga a sequence 1000 + final result = + await AuditChainVerifier(reader: _FakeReader(entries), secret: secret) + .verify(); + + expect(result.ok, isFalse); + expect(result.reasonCode, AuditChainReason.sequenceGap); + expect(result.brokenAtSequence, 1001); + expect(result.checked, 999); + }); + + test('edição no 3º lote ⇒ hashMismatch na sequence certa', () async { + final entries = cadeia(2500); + entries[2100] = AuditChainEntry( + fields: _withResult(entries[2100].fields, 'adulterado'), + entryHash: entries[2100].entryHash, + ); + final result = + await AuditChainVerifier(reader: _FakeReader(entries), secret: secret) + .verify(); + + expect(result.ok, isFalse); + expect(result.reasonCode, AuditChainReason.hashMismatch); + expect(result.brokenAtSequence, 2101); + expect(result.checked, 2100); + }); + + test('linha com sequence 0 inserida antes da gênese é detectada', () async { + final entries = cadeia(3); + final intrusa = AuditChainEntry( + fields: _withSequence(entries[0].fields, 0), + entryHash: entries[0].entryHash, + ); + final result = await AuditChainVerifier( + reader: _FakeReader([intrusa, ...entries]), + secret: secret, + ).verify(); + + expect(result.ok, isFalse); + expect(result.reasonCode, AuditChainReason.sequenceGap); + expect(result.brokenAtSequence, 0); + }); + + test('orçamento esgotado ⇒ ok:false com timeBudget, NUNCA ok:true', + () async { + // Relógio que anda 1 s a cada leitura: o orçamento de 1500 ms acaba + // depois do 1º lote, com 1500 linhas ainda por ler. + var agora = DateTime.utc(2026, 10, 13, 12); + DateTime relogio() { + final t = agora; + agora = agora.add(const Duration(seconds: 1)); + return t; + } + + final reader = _FakeReader(cadeia(2500)); + final result = await AuditChainVerifier(reader: reader, secret: secret) + .verify(budget: const Duration(milliseconds: 1500), clock: relogio); + + expect(result.ok, isFalse); + expect(result.reasonCode, AuditChainReason.timeBudget); + expect(result.brokenAtSequence, isNull); + expect(result.checked, lessThan(2500)); + expect(result.checked, greaterThan(0)); + expect(reader.lotes.length, lessThan(3)); + }); + + test('orçamento folgado ⇒ íntegro normalmente', () async { + final result = await AuditChainVerifier( + reader: _FakeReader(cadeia(1500)), + secret: secret, + ).verify(budget: const Duration(minutes: 1)); + expect(result.ok, isTrue); + expect(result.checked, 1500); + }); + + test('quebra achada antes do fim do orçamento vence o timeBudget', + () async { + final entries = cadeia(1500)..removeAt(10); + var agora = DateTime.utc(2026, 10, 13, 12); + final result = await AuditChainVerifier( + reader: _FakeReader(entries), + secret: secret, + ).verify( + budget: const Duration(milliseconds: 1500), + clock: () => agora = agora.add(const Duration(seconds: 1)), + ); + expect(result.reasonCode, AuditChainReason.sequenceGap); + expect(result.brokenAtSequence, 12); + }); + + test('falha ao ler o banco ⇒ ok:false com databaseError', () async { + final reader = _FakeReader(cadeia(2500), falharNoLote: 2); + final result = + await AuditChainVerifier(reader: reader, secret: secret).verify(); + expect(result.ok, isFalse); + expect(result.reasonCode, AuditChainReason.databaseError); + expect(result.checked, 1000); + expect(result.brokenAtSequence, isNull); + }); + + test('segredo vazio ⇒ ok:false com secretUnavailable, sem ler o banco', + () async { + final reader = _FakeReader(cadeia(3)); + final result = + await AuditChainVerifier(reader: reader, secret: '').verify(); + expect(result.ok, isFalse); + expect(result.reasonCode, AuditChainReason.secretUnavailable); + expect(reader.lotes, isEmpty); }); }); diff --git a/backend/sinalacs_server/test/unit/audit_csv_test.dart b/backend/sinalacs_server/test/unit/audit_csv_test.dart new file mode 100644 index 00000000..26f53797 --- /dev/null +++ b/backend/sinalacs_server/test/unit/audit_csv_test.dart @@ -0,0 +1,182 @@ +import 'dart:io'; + +import 'package:sinalacs_server/src/application/audit/audit_csv.dart'; +import 'package:test/test.dart'; + +AuditExportRow _row({ + int sequence = 1, + DateTime? timestamp, + String autor = 'Agente Teste', + String papelAutor = 'acs', + String acao = 'read', + String recurso = 'patients', + String rotuloRecurso = 'Paciente #A1B2', + String resultado = 'success', + String selo = 'valid', +}) => AuditExportRow( + sequence: sequence, + timestampUtc: timestamp ?? DateTime.utc(2026, 10, 13, 12, 30, 5), + autor: autor, + papelAutor: papelAutor, + acao: acao, + recurso: recurso, + rotuloRecurso: rotuloRecurso, + resultado: resultado, + selo: selo, +); + +void main() { + const cabecalho = + 'sequence,timestamp_utc,autor,papel_autor,acao,recurso,rotulo_recurso,resultado,selo'; + + group('lista fechada', () { + test('colunas exatas', () { + expect(AuditCsv.columns, [ + 'sequence', + 'timestamp_utc', + 'autor', + 'papel_autor', + 'acao', + 'recurso', + 'rotulo_recurso', + 'resultado', + 'selo', + ]); + }); + + test('AuditExportRow tem exatamente nove campos', () { + final fonte = File( + 'lib/src/application/audit/audit_csv.dart', + ).readAsStringSync(); + final inicio = fonte.indexOf('class AuditExportRow'); + expect(inicio, greaterThanOrEqualTo(0)); + final bloco = fonte.substring(inicio); + final fim = bloco.indexOf('\n}\n'); + final campos = RegExp( + r'^\s+final\s+\S+\s+\w+;', + multiLine: true, + ).allMatches(bloco.substring(0, fim)).length; + expect(campos, AuditCsv.columns.length); + expect(campos, 9); + }); + }); + + group('formato', () { + test('cabecalho + CRLF', () { + final csv = AuditCsv.build([_row()]); + expect( + csv, + '$cabecalho\r\n' + '1,2026-10-13T12:30:05.000Z,Agente Teste,acs,read,patients,' + 'Paciente #A1B2,success,valid\r\n', + ); + }); + + test('vazio => so cabecalho', () { + expect(AuditCsv.build(const []), '$cabecalho\r\n'); + }); + + test('timestamp local vira UTC com Z', () { + final csv = AuditCsv.build([ + _row(timestamp: DateTime.parse('2026-10-13T09:00:00-03:00')), + ]); + expect(csv, contains(',2026-10-13T12:00:00.000Z,')); + }); + + test('aspas duplas escapadas', () { + final csv = AuditCsv.build([_row(rotuloRecurso: 'diz "oi"')]); + expect(csv, contains(',"diz ""oi""",')); + }); + + test('virgula e quebra de linha dentro do campo ficam entre aspas', () { + final csv = AuditCsv.build([ + _row(autor: 'Silva, Ana', rotuloRecurso: 'linha1\nlinha2'), + ]); + expect(csv, contains('"Silva, Ana"')); + expect(csv, contains('"linha1\nlinha2"')); + }); + }); + + group('injecao de formula', () { + for (final entrada in ['=HYPERLINK("x")', '+1', '-1', '@a']) { + test('autor $entrada recebe prefixo apostrofo', () { + final csv = AuditCsv.build([_row(autor: entrada)]); + final linha = csv.split('\r\n')[1]; + final esperado = entrada.contains('"') + ? '"\'${entrada.replaceAll('"', '""')}"' + : "'$entrada"; + expect(linha, contains(',$esperado,')); + }); + } + + test('apos espaco, TAB, CR ou LF iniciais tambem recebe prefixo', () { + expect(AuditCsv.build([_row(autor: ' =1')]), contains(",' =1,")); + expect(AuditCsv.build([_row(autor: '\t@x')]), contains(",'\t@x,")); + expect(AuditCsv.build([_row(autor: ' +1')]), contains(",' +1,")); + expect(AuditCsv.build([_row(autor: '\n=1')]), contains(",\"'\n=1\",")); + expect(AuditCsv.build([_row(autor: '\r+1')]), contains(",\"'\r+1\",")); + }); + + test('espaco inicial sem fórmula e "-" no meio nao recebem prefixo', () { + final csv = AuditCsv.build([_row(autor: ' abc', rotuloRecurso: 'a-b')]); + expect(csv, contains(', abc,')); + expect(csv, contains(',a-b,')); + expect(csv, isNot(contains("'"))); + }); + + test('sequence e timestamp numericos nao sofrem prefixo', () { + final csv = AuditCsv.build([_row(sequence: 42)]); + expect(csv.split('\r\n')[1], startsWith('42,2026-')); + expect(csv, isNot(contains("'42"))); + }); + + test('texto comum nao recebe prefixo', () { + final csv = AuditCsv.build([_row()]); + expect(csv, isNot(contains("'"))); + }); + }); + + group('PII', () { + const uuid = '3f2b8c1e-9d4a-4e7b-8a61-5c0d2f9e7a11'; + final hash64 = 'a1b2c3d4' * 8; + const ip = '192.168.10.77'; + const nome = 'Fulano Sintetico da Silva'; + + int ocorrencias(String texto, String trecho) => + trecho.allMatches(texto).length; + + test('o gerador nao inventa UUID, hash de 64 hex nem IP', () { + final csv = AuditCsv.build([_row(), _row(sequence: 2)]); + expect( + RegExp( + r'[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}', + ).hasMatch(csv), + isFalse, + ); + expect(RegExp(r'[0-9a-fA-F]{64}').hasMatch(csv), isFalse); + expect(RegExp(r'\b\d{1,3}(\.\d{1,3}){3}\b').hasMatch(csv), isFalse); + }); + + test('valores sensiveis em autor/rotulo saem so onde foram postos', () { + final csv = AuditCsv.build([ + _row(autor: nome, rotuloRecurso: '$uuid $hash64 $ip'), + ]); + final linhas = csv.split('\r\n'); + expect(linhas[0], isNot(contains(nome))); + for (final v in [nome, uuid, hash64, ip]) { + expect(ocorrencias(csv, v), 1, reason: v); + } + // autor e a 3a coluna, rotulo a 7a: nenhuma outra coluna os repete. + final campos = linhas[1].split(','); + expect(campos[2], nome); + expect(campos[6], contains(uuid)); + for (final i in [0, 1, 3, 4, 5, 7, 8]) { + expect(campos[i], isNot(contains(uuid))); + expect(campos[i], isNot(contains(nome))); + } + }); + + // A garantia estrutural contra vazamento de ipHash/entryHash/resourceId e + // o teste "lista fechada" acima: o gerador so conhece nove campos. + }); +} diff --git a/backend/sinalacs_server/test/unit/audit_export_service_test.dart b/backend/sinalacs_server/test/unit/audit_export_service_test.dart new file mode 100644 index 00000000..afa60e2b --- /dev/null +++ b/backend/sinalacs_server/test/unit/audit_export_service_test.dart @@ -0,0 +1,502 @@ +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; +import 'package:sinalacs_server/src/application/admin/admin_read_service.dart'; +import 'package:sinalacs_server/src/application/admin/audit_export_service.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/application/audit/audit_csv.dart'; +import 'package:sinalacs_server/src/application/audit/audit_row_sealer.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/generated/protocol.dart'; +import 'package:test/test.dart'; + +/// `AuditExportService` (#45, Task 10; D5–D8) sem banco: papel (só admin), +/// período obrigatório, teto de linhas, ordem fixa +/// autoriza → valida → head → conta → lê → gera → transação → devolve, e a +/// transação que falha não devolve CSV nem deixa `audit_exports`. +/// Dados sintéticos. +const _segredo = 'segredo-sintetico-da-exportacao'; +const _admin = '00000000-0000-4000-8000-00000000a001'; +const _pacienteRecurso = '00000000-0000-4000-8000-0000000b18f0'; +const _exportId = '00000000-0000-4000-8000-00000000e001'; + +final _agora = DateTime.utc(2026, 10, 13, 15, 30); +final _de = DateTime.utc(2026, 10, 1); +final _ate = DateTime.utc(2026, 10, 13); + +AdminAuditFilter _filtro({DateTime? from, DateTime? to, String? result}) => + AdminAuditFilter(from: from ?? _de, to: to ?? _ate, result: result); + +AuthenticatedUser _user(UserRole role, {String id = _admin}) => + AuthenticatedUser( + id: id, + role: role, + microAreaId: role == UserRole.acs || role == UserRole.patient + ? 'ma-1' + : null, + deviceId: 'sem-aparelho', + ); + +/// Cadeia sintética e íntegra de [n] linhas, com o vizinho certo em cada uma. +List _cadeia(int n) { + final chain = AuditChain(secret: _segredo); + final linhas = []; + var anterior = AuditChain.genesisHash; + for (var seq = 1; seq <= n; seq++) { + final campos = AuditChainFields( + sequence: seq, + previousHash: anterior, + userId: seq.isEven ? _admin : '00000000-0000-4000-8000-0000000c0a7e', + actionType: 'read', + resourceType: seq == 2 ? 'patient_directory' : 'admin_audit_logs', + resourceId: seq == 2 ? _pacienteRecurso : null, + timestamp: DateTime.utc(2026, 10, 5, 10, seq), + ipHash: 'ip-sintetico-$seq', + result: 'success', + ); + final hash = chain.computeEntryHash(campos); + linhas.add( + AdminAuditRow( + entry: AdminAuditEntry( + id: 'id-$seq', + sequence: seq, + userLabel: 'rotulo-da-tela', + actionType: campos.actionType, + resourceType: campos.resourceType, + timestamp: campos.timestamp, + result: campos.result, + ), + seal: AuditSealInput( + row: AuditChainEntry(fields: campos, entryHash: hash), + previousEntryHash: seq == 1 ? null : anterior, + ), + actorRole: seq.isEven ? 'admin' : 'patient', + actorEnrollmentId: seq.isEven ? 'ADM-EXP-1' : null, + ), + ); + anterior = hash; + } + return linhas; +} + +final _ordem = []; + +class _Audit extends AuditTrail { + final eventos = []; + bool falhar = false; + + @override + Future record(AuditEvent event) async { + _ordem.add('audit:${event.result}'); + if (falhar) throw StateError('trilha fora do ar'); + eventos.add(event); + } +} + +/// Store transacional falso: `commitExport` só "comita" (`exports` e +/// `eventosDaTransacao`) se o apêndice da auditoria dentro da transação passa. +class _Store implements AuditExportStore { + _Store(this.linhas); + + final List linhas; + int head = 0; + int leituras = 0; + bool falharLeitura = false; + bool falharApendiceNaTransacao = false; + int? contagemForcada; + int? headPedido; + + final exports = []; + final eventosDaTransacao = []; + + @override + Future ubsOf(String staffId) async => null; + + @override + Future headSequence() async { + leituras++; + _ordem.add('store:head'); + return head; + } + + @override + Future countUpTo( + AdminAuditFilter filter, { + required int headSequence, + required int cap, + }) async { + leituras++; + _ordem.add('store:count'); + headPedido = headSequence; + final n = + contagemForcada ?? + linhas.where((l) => l.entry.sequence <= headSequence).length; + return n > cap ? cap : n; + } + + @override + Future> readUpTo( + AdminAuditFilter filter, { + required int headSequence, + required int limit, + }) async { + leituras++; + _ordem.add('store:read'); + if (falharLeitura) { + throw Exception('relation "audit_logs" texto-do-banco-secreto'); + } + return linhas + .where((l) => l.entry.sequence <= headSequence) + .take(limit) + .toList(); + } + + @override + Future commitExport(AuditExport registro, AuditEvent evento) async { + _ordem.add('tx:export+audit'); + // Transação: os dois entram juntos ou nenhum. + final pendenteExport = registro; + if (falharApendiceNaTransacao) { + throw StateError('falha no apêndice da cadeia'); + } + exports.add(pendenteExport); + eventosDaTransacao.add(evento); + } +} + +void main() { + late _Audit audit; + late _Store store; + var estadoDaCadeia = AuditIntegrityState.verified; + final falhasLogadas = []; + final pedidosDeEstado = []; + + AuditExportService servico() => AuditExportService( + store: store, + audit: audit, + auditChain: AuditChain(secret: _segredo), + chainState: (notBefore) async { + _ordem.add('chain:state'); + pedidosDeEstado.add(notBefore); + return estadoDaCadeia; + }, + clock: () => _agora, + newId: () => _exportId, + onFailure: falhasLogadas.add, + ); + + setUp(() { + _ordem.clear(); + falhasLogadas.clear(); + pedidosDeEstado.clear(); + audit = _Audit(); + store = _Store(_cadeia(4))..head = 4; + estadoDaCadeia = AuditIntegrityState.verified; + }); + + group('papel: só admin exporta', () { + for (final role in [UserRole.acs, UserRole.patient, UserRole.coordinator]) { + test( + '$role ⇒ AlertPermissionException + denied, nenhuma leitura', + () async { + await expectLater( + servico().export(_user(role, id: 'u-${role.name}'), _filtro()), + throwsA(isA()), + ); + expect(store.leituras, 0); + expect(store.exports, isEmpty); + expect(_ordem, ['audit:denied']); + final e = audit.eventos.single; + expect(e.actionType, 'export'); + expect(e.resourceType, 'admin_audit_export'); + expect(e.result, 'denied'); + expect(e.userId, 'u-${role.name}'); + }, + ); + } + }); + + group('filtro: período obrigatório, sem auditar', () { + final invalidos = { + 'nulo': null, + 'sem from': AdminAuditFilter(to: _ate), + 'sem to': AdminAuditFilter(from: _de), + 'sem período': AdminAuditFilter(result: 'success'), + 'from == to': _filtro(from: _ate, to: _ate), + 'from > to': _filtro(from: _ate, to: _de), + 'janela > 366 d': _filtro( + from: _de, + to: _de.add(const Duration(days: 367)), + ), + 'result inválido': _filtro(result: 'X;DROP'), + }; + invalidos.forEach((nome, f) { + test( + '$nome ⇒ AdminInvalidRequestException, sem auditoria/leitura', + () async { + await expectLater( + servico().export(_user(UserRole.admin), f), + throwsA(isA()), + ); + expect(audit.eventos, isEmpty); + expect(store.leituras, 0); + }, + ); + }); + + test('janela de exatamente 366 dias é aceita', () async { + final r = await servico().export( + _user(UserRole.admin), + _filtro(from: _de, to: _de.add(const Duration(days: 366))), + ); + expect(r.rowCount, 4); + }); + }); + + test( + 'recorte > maxRows ⇒ refused_limit e AdminInvalidRequestException; ' + 'nada lido nem gerado', + () async { + store.contagemForcada = AuditExportService.maxRows + 1; + await expectLater( + servico().export(_user(UserRole.admin), _filtro()), + throwsA(isA()), + ); + expect(_ordem, ['store:head', 'store:count', 'audit:refused_limit']); + expect(store.exports, isEmpty); + final e = audit.eventos.single; + expect(e.actionType, 'export'); + expect(e.resourceType, 'admin_audit_export'); + expect(e.result, 'refused_limit'); + }, + ); + + test('recorte == maxRows é aceito (o teto é inclusivo)', () async { + store.contagemForcada = AuditExportService.maxRows; + final r = await servico().export(_user(UserRole.admin), _filtro()); + expect(r.rowCount, 4); + }); + + test( + 'admin: ordem fixa head/count/read/chain/tx e resposta coerente', + () async { + final r = await servico().export(_user(UserRole.admin), _filtro()); + + expect(_ordem, [ + 'store:head', + 'store:count', + 'store:read', + 'chain:state', + 'tx:export+audit', + ]); + // O estado da cadeia é pedido FRESCO: verificação iniciada a partir do + // início desta exportação, nunca um retrato anterior a ela. + expect(pedidosDeEstado, [_agora]); + // Sem linha de auditoria FORA da transação no caminho feliz. + expect(audit.eventos, isEmpty); + expect(store.headPedido, 4); + + expect(r.rowCount, 4); + expect(r.firstSequence, 1); + expect(r.lastSequence, 4); + expect(r.contentSha256, sha256.convert(utf8.encode(r.csv)).toString()); + expect(r.chainState, AuditIntegrityState.verified); + expect(r.generatedAt, _agora); + expect(r.fileName, 'auditoria_20261001T000000Z_20261013T000000Z.csv'); + + // CSV: cabeçalho fixo, ordem crescente, rótulos minimizados, selo. + final linhas = r.csv.split('\r\n'); + expect(linhas.first, AuditCsv.columns.join(',')); + expect(linhas[1], startsWith('1,')); + expect(linhas[4], startsWith('4,')); + expect(linhas[1], contains(',Paciente #0A7E,patient,')); + expect(linhas[2], contains(',ADM-EXP-1,admin,')); + expect(linhas[2], contains(',patient_directory,#18F0,success,verified')); + expect(linhas[1], endsWith(',admin_audit_logs,,success,verified')); + expect(r.csv, isNot(contains(_pacienteRecurso))); + expect(r.csv, isNot(contains('ip-sintetico'))); + expect(r.csv, isNot(contains(_admin))); + expect(r.csv, isNot(contains('rotulo-da-tela'))); + + // audit_exports e evento, na MESMA transação, ligados pelo id. + final x = store.exports.single; + final e = store.eventosDaTransacao.single; + expect(x.id!.uuid, _exportId); + expect(e.resourceId, _exportId); + expect(e.actionType, 'export'); + expect(e.resourceType, 'admin_audit_logs'); + expect(e.result, 'success'); + expect(e.userId, _admin); + expect(x.requestedBy.uuid, _admin); + expect(x.requestedAt, _agora); + expect(x.format, 'csv'); + expect(x.rowCount, 4); + expect(x.firstSequence, 1); + expect(x.lastSequence, 4); + expect(x.contentSha256, r.contentSha256); + expect(x.chainState, 'verified'); + + // filtersJson: só o filtro. + final f = jsonDecode(x.filtersJson) as Map; + expect(f.keys.toSet(), {'from', 'to'}); + expect(x.filtersJson, isNot(contains('success'))); + expect(x.filtersJson, isNot(contains('ADM-EXP-1'))); + }, + ); + + test('seleciona o vizinho: linha adulterada sai com selo invalid', () async { + final linhas = _cadeia(3); + final orig = linhas[1].seal.row; + final campos = orig.fields; + linhas[1] = AdminAuditRow( + entry: linhas[1].entry, + seal: AuditSealInput( + row: AuditChainEntry( + fields: AuditChainFields( + sequence: campos.sequence, + previousHash: campos.previousHash, + userId: campos.userId, + actionType: campos.actionType, + resourceType: campos.resourceType, + resourceId: campos.resourceId, + timestamp: campos.timestamp, + ipHash: campos.ipHash, + result: 'adulterado', + ), + entryHash: orig.entryHash, + ), + previousEntryHash: linhas[1].seal.previousEntryHash, + ), + actorRole: linhas[1].actorRole, + actorEnrollmentId: linhas[1].actorEnrollmentId, + ); + store = _Store(linhas)..head = 3; + final r = await servico().export(_user(UserRole.admin), _filtro()); + final csv = r.csv.split('\r\n'); + expect(csv[1], endsWith(',verified')); + expect(csv[2], endsWith(',invalid')); + }); + + test('recorte vazio: CSV só com cabeçalho, sequences nulas, exportação ' + 'registrada', () async { + store = _Store(const [])..head = 7; + final r = await servico().export(_user(UserRole.admin), _filtro()); + expect(r.rowCount, 0); + expect(r.firstSequence, isNull); + expect(r.lastSequence, isNull); + expect(r.csv, '${AuditCsv.columns.join(',')}\r\n'); + expect(store.exports.single.rowCount, 0); + expect(store.eventosDaTransacao, hasLength(1)); + }); + + test( + 'estado da cadeia ≠ verified é registrado e devolvido como está', + () async { + for (final estado in [ + AuditIntegrityState.broken, + AuditIntegrityState.unavailable, + AuditIntegrityState.inconclusive, + ]) { + estadoDaCadeia = estado; + store = _Store(_cadeia(2))..head = 2; + final r = await servico().export(_user(UserRole.admin), _filtro()); + expect(r.chainState, estado); + expect(store.exports.single.chainState, estado.name); + } + }, + ); + + test( + 'falha do apêndice DENTRO da transação ⇒ nenhum CSV devolvido, nada em ' + 'audit_exports, failed registrado e exceção genérica', + () async { + store.falharApendiceNaTransacao = true; + Object? erro; + AdminAuditExport? r; + try { + r = await servico().export(_user(UserRole.admin), _filtro()); + } catch (e) { + erro = e; + } + expect(r, isNull); + expect(store.exports, isEmpty); + expect(store.eventosDaTransacao, isEmpty); + expect(erro, isA()); + expect( + (erro! as AuditExportFailedException).message, + AuditExportFailedException.mensagem, + ); + expect(_ordem.last, 'audit:failed'); + expect(audit.eventos.single.result, 'failed'); + expect(audit.eventos.single.resourceType, 'admin_audit_export'); + expect(falhasLogadas, hasLength(1)); + }, + ); + + test( + 'falha de leitura ⇒ failed fora da transação, mensagem fixa sem texto do ' + 'banco, sem transação', + () async { + store.falharLeitura = true; + await expectLater( + servico().export(_user(UserRole.admin), _filtro()), + throwsA( + isA() + .having( + (e) => e.message, + 'message', + AuditExportFailedException.mensagem, + ) + .having( + (e) => e.toString(), + 'toString', + isNot(contains('texto-do-banco')), + ), + ), + ); + expect(_ordem, [ + 'store:head', + 'store:count', + 'store:read', + 'audit:failed', + ]); + expect(store.exports, isEmpty); + expect(audit.eventos.single.actionType, 'export'); + }, + ); + + test('falha de leitura com a trilha também fora do ar ⇒ ainda a exceção ' + 'genérica (nunca o texto do banco)', () async { + store.falharLeitura = true; + audit.falhar = true; + await expectLater( + servico().export(_user(UserRole.admin), _filtro()), + throwsA(isA()), + ); + expect(store.exports, isEmpty); + }); + + test( + 'trilha fora do ar na recusa de papel ⇒ exceção sobe, nada lido', + () async { + audit.falhar = true; + await expectLater( + servico().export(_user(UserRole.coordinator), _filtro()), + throwsA(isA()), + ); + expect(store.leituras, 0); + }, + ); + + test('a resposta não carrega hash, ipHash nem segredo', () async { + final r = await servico().export(_user(UserRole.admin), _filtro()); + final texto = jsonEncode(r.toJson()); + expect(texto, isNot(contains(_segredo))); + expect(texto, isNot(contains('ip-sintetico'))); + for (final l in store.linhas) { + expect(texto, isNot(contains(l.seal.row.entryHash))); + } + }); +} diff --git a/backend/sinalacs_server/test/unit/audit_row_sealer_test.dart b/backend/sinalacs_server/test/unit/audit_row_sealer_test.dart new file mode 100644 index 00000000..b10151f3 --- /dev/null +++ b/backend/sinalacs_server/test/unit/audit_row_sealer_test.dart @@ -0,0 +1,161 @@ +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_row_sealer.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:test/test.dart'; + +/// Selo por linha (#45, D3): cada linha confere o próprio HMAC e o elo com o +/// `entryHash` da linha `sequence - 1` que o BANCO devolve — nunca com a linha +/// vizinha da página. Dados sintéticos. +const _segredo = 'segredo-sintetico-do-selo'; + +AuditChainFields _campos({ + required int sequence, + required String previousHash, + String result = 'success', +}) => AuditChainFields( + sequence: sequence, + previousHash: previousHash, + userId: '00000000-0000-4000-8000-00000000a001', + actionType: 'read', + resourceType: 'admin_audit_logs', + resourceId: null, + timestamp: DateTime.utc(2026, 10, 13, 12, sequence), + ipHash: 'ip-sintetico-$sequence', + result: result, +); + +/// Cadeia íntegra de [n] linhas, encadeada como `OrmAuditTrail` encadeia. +List _cadeia(int n) { + final chain = AuditChain(secret: _segredo); + final linhas = []; + for (var s = 1; s <= n; s++) { + final campos = _campos( + sequence: s, + previousHash: linhas.isEmpty + ? AuditChain.genesisHash + : linhas.last.entryHash, + ); + linhas.add( + AuditChainEntry( + fields: campos, + entryHash: chain.computeEntryHash(campos), + ), + ); + } + return linhas; +} + +AuditChainEntry _comCampos(AuditChainEntry e, AuditChainFields f) => + AuditChainEntry(fields: f, entryHash: e.entryHash); + +void main() { + final selador = AuditRowSealer(AuditChain(secret: _segredo)); + final linhas = _cadeia(3); + + test('linha íntegra com o vizinho certo ⇒ verified', () { + expect( + selador.seal(linhas[1], previousEntryHash: linhas[0].entryHash), + AuditRowSeal.verified, + ); + }); + + test('sequence 1 com previousHash de gênese ⇒ verified', () { + expect( + selador.seal(linhas[0], previousEntryHash: null), + AuditRowSeal.verified, + ); + }); + + test('sequence 1 com previousHash diferente da gênese ⇒ invalid', () { + final chain = AuditChain(secret: _segredo); + final campos = _campos(sequence: 1, previousHash: 'f' * 64); + final linha = AuditChainEntry( + fields: campos, + entryHash: chain.computeEntryHash(campos), + ); + expect(selador.seal(linha, previousEntryHash: null), AuditRowSeal.invalid); + }); + + test('conteúdo editado (sem recalcular o hash) ⇒ invalid', () { + final editada = _comCampos( + linhas[1], + _campos( + sequence: 2, + previousHash: linhas[1].fields.previousHash, + result: 'denied', + ), + ); + expect( + selador.seal(editada, previousEntryHash: linhas[0].entryHash), + AuditRowSeal.invalid, + ); + }); + + test('previousHash editado ⇒ invalid', () { + final editada = _comCampos( + linhas[1], + _campos(sequence: 2, previousHash: 'a' * 64), + ); + expect( + selador.seal(editada, previousEntryHash: linhas[0].entryHash), + AuditRowSeal.invalid, + ); + }); + + test('elo com outro vizinho (hash não confere com sequence-1) ⇒ invalid', () { + // A linha está íntegra, mas o banco diz que a `sequence - 1` tem outro + // `entryHash`: a anterior foi trocada. + expect( + selador.seal(linhas[1], previousEntryHash: linhas[2].entryHash), + AuditRowSeal.invalid, + ); + }); + + test( + 'vizinho ausente (sequence > 1, previousEntryHash nulo) ⇒ unverifiable, ' + 'NUNCA verified', + () { + final selo = selador.seal(linhas[2], previousEntryHash: null); + expect(selo, AuditRowSeal.unverifiable); + expect(selo, isNot(AuditRowSeal.verified)); + }, + ); + + test('vizinho ausente e conteúdo editado ⇒ invalid (o HMAC já prova)', () { + final editada = _comCampos( + linhas[2], + _campos( + sequence: 3, + previousHash: linhas[2].fields.previousHash, + result: 'denied', + ), + ); + expect( + selador.seal(editada, previousEntryHash: null), + AuditRowSeal.invalid, + ); + }); + + test('segredo errado ⇒ invalid em todas as linhas', () { + final outro = AuditRowSealer(AuditChain(secret: 'outro-segredo')); + expect( + outro.seal(linhas[0], previousEntryHash: null), + AuditRowSeal.invalid, + ); + expect( + outro.seal(linhas[1], previousEntryHash: linhas[0].entryHash), + AuditRowSeal.invalid, + ); + }); + + test('sequence menor que 1 ⇒ invalid', () { + final chain = AuditChain(secret: _segredo); + final campos = _campos(sequence: 0, previousHash: AuditChain.genesisHash); + final linha = AuditChainEntry( + fields: campos, + entryHash: chain.computeEntryHash(campos), + ); + expect(selador.seal(linha, previousEntryHash: null), AuditRowSeal.invalid); + }); +} diff --git a/backend/sinalacs_server/test/unit/authorization_test.dart b/backend/sinalacs_server/test/unit/authorization_test.dart index 44d2bc97..7782ab59 100644 --- a/backend/sinalacs_server/test/unit/authorization_test.dart +++ b/backend/sinalacs_server/test/unit/authorization_test.dart @@ -99,5 +99,32 @@ void main() { ); } }); + + test('staffRoles contém coordinator e admin, e só eles', () { + expect(Authorization.staffRoles, {UserRole.coordinator, UserRole.admin}); + }); + + test('token de admin (sem microárea) é recusado onde só o ACS pode', () { + expect( + () => Authorization.require( + _user(UserRole.admin, microAreaId: null), + roles: {UserRole.acs}, + onDenied: () => StateError('Somente ACS podem fazer isto.'), + ), + throwsA(isA()), + ); + }); + + test('requireMicroArea:false com staffRoles aceita admin sem território', () { + expect( + () => Authorization.require( + _user(UserRole.admin, microAreaId: null), + roles: Authorization.staffRoles, + requireMicroArea: false, + onDenied: () => StateError('x'), + ), + returnsNormally, + ); + }); }); } diff --git a/backend/sinalacs_server/test/unit/data_subject_case_service_test.dart b/backend/sinalacs_server/test/unit/data_subject_case_service_test.dart new file mode 100644 index 00000000..e48a940a --- /dev/null +++ b/backend/sinalacs_server/test/unit/data_subject_case_service_test.dart @@ -0,0 +1,606 @@ +import 'package:sinalacs_server/src/application/admin/admin_read_service.dart'; +import 'package:sinalacs_server/src/application/admin/data_subject_case_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/generated/protocol.dart'; +import 'package:test/test.dart'; + +/// Regras do atendimento de pedidos do titular (#42) sem banco: papel, escopo +/// por UBS, auditoria antes do dado, transições e validação de nota. Dados +/// sintéticos; o store é um falso em memória. +const _ubsA = 'ubs-a'; +const _agora = '2026-10-08T12:00:00Z'; + +final _ordem = []; + +class _Pedido { + _Pedido(this.ubs, this.detalhe); + final String ubs; + AdminDataSubjectRequestDetail detalhe; +} + +class _DecisaoChamada { + _DecisaoChamada({ + required this.id, + required this.from, + required this.to, + required this.resolution, + required this.decidedBy, + required this.anonymize, + required this.audit, + }); + final String id; + final Set from; + final DataSubjectRequestStatus to; + final String? resolution; + final String decidedBy; + final bool anonymize; + final AuditEvent audit; +} + +class _Store implements DataSubjectCaseStore { + final Map ubs = {'coord-a': _ubsA, 'coord-sem-ubs': null}; + final Map pedidos = {}; + final decisoes = <_DecisaoChamada>[]; + AdminScope? ultimoEscopo; + bool decideDevolve = true; + bool decideForaDoEscopo = false; + StateError? decideLanca; + int chamadas = 0; + + @override + Future ubsOf(String staffId) async => ubs[staffId]; + + @override + Future subjectOf(String requestId) async => 'titular-$requestId'; + + @override + Future list( + AdminScope scope, { + DataSubjectRequestStatus? status, + required int limit, + required int offset, + required DateTime now, + }) async { + chamadas++; + ultimoEscopo = scope; + _ordem.add('store:list'); + return AdminDataSubjectRequestPage( + items: [ + for (final p in pedidos.values) + if (scope.ubsId == null || scope.ubsId == p.ubs) + AdminDataSubjectRequest( + id: p.detalhe.id, + type: p.detalhe.type, + status: p.detalhe.status, + createdAt: p.detalhe.createdAt, + dueAt: p.detalhe.dueAt, + overdue: false, + patientLabel: p.detalhe.patientLabel, + ), + ], + ); + } + + @override + Future find( + AdminScope scope, + String id, { + required DateTime now, + }) async { + chamadas++; + ultimoEscopo = scope; + _ordem.add('store:find'); + final p = pedidos[id]; + if (p == null) return null; + if (scope.ubsId != null && scope.ubsId != p.ubs) return null; + return p.detalhe; + } + + @override + Future decide( + AdminScope scope, + String id, { + required Set from, + required DataSubjectRequestStatus to, + required String? resolution, + required String decidedBy, + required bool anonymize, + required AuditEvent audit, + required DateTime now, + }) async { + _ordem.add('store:decide'); + if (decideLanca != null) throw decideLanca!; + // Simula o pedido que saiu do escopo entre o find e o decide. + if (decideForaDoEscopo) return false; + decisoes.add( + _DecisaoChamada( + id: id, + from: from, + to: to, + resolution: resolution, + decidedBy: decidedBy, + anonymize: anonymize, + audit: audit, + ), + ); + final p = pedidos[id]; + if (!decideDevolve || p == null || !from.contains(p.detalhe.status)) { + return false; + } + final d = p.detalhe; + p.detalhe = AdminDataSubjectRequestDetail( + id: d.id, + type: d.type, + status: to, + createdAt: d.createdAt, + dueAt: d.dueAt, + overdue: d.overdue, + patientLabel: d.patientLabel, + details: d.details, + resolution: resolution, + ); + return true; + } +} + +class _Audit extends AuditTrail { + final events = []; + bool falhar = false; + + @override + Future record(AuditEvent event) async { + if (falhar) throw StateError('auditoria indisponível'); + _ordem.add('audit:${event.resourceType}:${event.result}'); + events.add(event); + } +} + +class _Notificador implements DataSubjectNotifier { + final chamadas = <(String, DataSubjectRequestStatus)>[]; + Object? lanca; + + @override + Future decided(String userId, DataSubjectRequestStatus status) async { + chamadas.add((userId, status)); + if (lanca != null) throw lanca!; + } +} + +AuthenticatedUser _u(String id, UserRole role) => AuthenticatedUser( + id: id, + role: role, + microAreaId: null, + deviceId: 'sem-aparelho', +); + +AdminDataSubjectRequestDetail _detalhe( + String id, { + DataSubjectRequestType type = DataSubjectRequestType.deletion, + DataSubjectRequestStatus status = DataSubjectRequestStatus.open, + DateTime? dueAt, +}) => AdminDataSubjectRequestDetail( + id: id, + type: type, + status: status, + createdAt: DateTime.utc(2026, 9, 30), + dueAt: dueAt ?? DateTime.utc(2026, 10, 15), + overdue: false, + patientLabel: '#A18F', + details: type == DataSubjectRequestType.correction + ? 'texto sintético do pedido' + : null, +); + +void main() { + late _Store store; + late _Audit audit; + late DataSubjectCaseService servico; + final agora = DateTime.parse(_agora); + final admin = _u('admin-1', UserRole.admin); + final coordA = _u('coord-a', UserRole.coordinator); + final coordB = _u('coord-b', UserRole.coordinator); + final coordSemUbs = _u('coord-sem-ubs', UserRole.coordinator); + + setUp(() { + _ordem.clear(); + store = _Store()..ubs['coord-b'] = 'ubs-b'; + audit = _Audit(); + servico = DataSubjectCaseService( + store: store, + audit: audit, + clock: () => agora, + ); + store.pedidos['del-1'] = _Pedido(_ubsA, _detalhe('del-1')); + store.pedidos['cor-1'] = _Pedido( + _ubsA, + _detalhe('cor-1', type: DataSubjectRequestType.correction), + ); + }); + + test('acs e patient são recusados e a recusa é auditada', () async { + for (final papel in [UserRole.acs, UserRole.patient]) { + final u = _u('u-${papel.name}', papel); + audit.events.clear(); + for (final chamada in Function()>[ + () => servico.list(u), + () => servico.get(u, 'del-1'), + () => servico.startReview(u, 'del-1'), + () => servico.complete(u, 'del-1'), + () => servico.reject(u, 'del-1', reason: 'motivo válido'), + ]) { + await expectLater(chamada(), throwsA(isA())); + } + expect(audit.events, hasLength(5)); + expect(audit.events.every((e) => e.result == 'denied'), isTrue); + } + expect(store.chamadas, 0); + expect(store.decisoes, isEmpty); + }); + + test('coordenador sem UBS é recusado em todos os métodos', () async { + for (final chamada in Function()>[ + () => servico.list(coordSemUbs), + () => servico.get(coordSemUbs, 'del-1'), + () => servico.startReview(coordSemUbs, 'del-1'), + () => servico.complete(coordSemUbs, 'del-1'), + () => servico.reject(coordSemUbs, 'del-1', reason: 'motivo válido'), + ]) { + await expectLater(chamada(), throwsA(isA())); + } + expect(audit.events, hasLength(5)); + expect(audit.events.every((e) => e.result == 'denied'), isTrue); + expect(store.chamadas, 0); + expect(store.decisoes, isEmpty); + }); + + test( + 'coordenador de outra UBS recebe "não encontrado", igual a id inexistente', + () async { + Future mensagem(Future Function() chamada) async { + try { + await chamada(); + } on AdminInvalidRequestException catch (e) { + return e.message; + } + fail('deveria ter lançado AdminInvalidRequestException'); + } + + final outraUbs = await mensagem(() => servico.get(coordB, 'del-1')); + final inexistente = await mensagem(() => servico.get(coordB, 'nada')); + expect(outraUbs, inexistente); + + final decOutra = await mensagem( + () => servico.startReview(coordB, 'del-1'), + ); + final decNada = await mensagem(() => servico.startReview(coordB, 'nada')); + expect(decOutra, decNada); + expect(store.decisoes, isEmpty); + expect( + store.pedidos['del-1']!.detalhe.status, + DataSubjectRequestStatus.open, + ); + }, + ); + + test( + 'get audita read ANTES de devolver o dado, e falha de auditoria impede a leitura', + () async { + final d = await servico.get(coordA, 'cor-1'); + expect(d.details, 'texto sintético do pedido'); + expect(store.ultimoEscopo!.ubsId, _ubsA); + expect(_ordem, [ + 'audit:admin_data_subject_requests:success', + 'store:find', + ]); + expect(audit.events.single.actionType, 'read'); + expect(audit.events.single.resourceId, 'cor-1'); + + _ordem.clear(); + store.chamadas = 0; + audit.falhar = true; + await expectLater( + servico.get(coordA, 'cor-1'), + throwsA(isA()), + ); + await expectLater(servico.list(coordA), throwsA(isA())); + expect(store.chamadas, 0); + }, + ); + + test('overdue só para open/inReview com now > dueAt', () async { + store.pedidos.clear(); + store.pedidos['a'] = _Pedido( + _ubsA, + _detalhe('a', dueAt: agora.subtract(const Duration(seconds: 1))), + ); + store.pedidos['b'] = _Pedido( + _ubsA, + _detalhe( + 'b', + status: DataSubjectRequestStatus.completed, + dueAt: agora.subtract(const Duration(days: 3)), + ), + ); + store.pedidos['c'] = _Pedido(_ubsA, _detalhe('c', dueAt: agora)); + store.pedidos['d'] = _Pedido( + _ubsA, + _detalhe( + 'd', + status: DataSubjectRequestStatus.inReview, + dueAt: agora.subtract(const Duration(days: 1)), + ), + ); + + final pagina = await servico.list(admin); + final porId = {for (final i in pagina.items) i.id: i.overdue}; + expect(porId, {'a': true, 'b': false, 'c': false, 'd': true}); + + expect((await servico.get(admin, 'a')).overdue, isTrue); + expect((await servico.get(admin, 'b')).overdue, isFalse); + expect((await servico.get(admin, 'c')).overdue, isFalse); + }); + + test( + 'startReview: open→inReview; repetir devolve erro de transição', + () async { + await servico.startReview(coordA, 'del-1'); + expect( + store.pedidos['del-1']!.detalhe.status, + DataSubjectRequestStatus.inReview, + ); + final c = store.decisoes.single; + expect(c.from, {DataSubjectRequestStatus.open}); + expect(c.to, DataSubjectRequestStatus.inReview); + expect(c.decidedBy, 'coord-a'); + expect(c.anonymize, isFalse); + expect(c.audit.actionType, 'write'); + expect(c.audit.resourceType, 'data_subject_request'); + expect(c.audit.resourceId, 'del-1'); + expect(c.audit.result, 'in_review'); + + final antes = store.decisoes.length; + await expectLater( + servico.startReview(coordA, 'del-1'), + throwsA(isA()), + ); + expect(store.decisoes.length, antes); // nem chega a travar o pedido + }, + ); + + test( + 'complete de correção sem nota é recusado; com nota de 501 caracteres também', + () async { + await expectLater( + servico.complete(coordA, 'cor-1'), + throwsA(isA()), + ); + await expectLater( + servico.complete(coordA, 'cor-1', note: ' '), + throwsA(isA()), + ); + await expectLater( + servico.complete(coordA, 'cor-1', note: 'x' * 501), + throwsA(isA()), + ); + expect(store.decisoes, isEmpty); + + await servico.complete(coordA, 'cor-1', note: ' Dado corrigido. '); + expect(store.decisoes.single.resolution, 'Dado corrigido.'); + expect(store.decisoes.single.audit.result, 'completed'); + }, + ); + + test( + 'complete de exclusão chama decide(anonymize: true); de correção, anonymize: false', + () async { + await servico.complete(coordA, 'del-1'); + final del = store.decisoes.single; + expect(del.anonymize, isTrue); + expect(del.to, DataSubjectRequestStatus.completed); + expect(del.from, { + DataSubjectRequestStatus.open, + DataSubjectRequestStatus.inReview, + }); + expect(del.resolution, isNull); + + await servico.complete( + coordA, + 'cor-1', + note: 'Corrigido conforme pedido.', + ); + expect(store.decisoes.last.anonymize, isFalse); + + // reject nunca anonimiza, nem em pedido de exclusão. + store.pedidos['del-2'] = _Pedido(_ubsA, _detalhe('del-2')); + await servico.reject( + coordA, + 'del-2', + reason: 'Titular não identificado.', + ); + expect(store.decisoes.last.anonymize, isFalse); + expect(store.decisoes.last.audit.result, 'rejected'); + }, + ); + + test('reject exige motivo de 3 a 500 caracteres após trim', () async { + for (final ruim in ['', ' ', 'ab', ' ab ', 'x' * 501]) { + await expectLater( + servico.reject(coordA, 'del-1', reason: ruim), + throwsA(isA()), + ); + } + expect(store.decisoes, isEmpty); + + await servico.reject(coordA, 'del-1', reason: ' abc '); + expect(store.decisoes.single.resolution, 'abc'); + + store.pedidos['del-3'] = _Pedido(_ubsA, _detalhe('del-3')); + await servico.reject(coordA, 'del-3', reason: 'x' * 500); + expect(store.decisoes.last.resolution, hasLength(500)); + }); + + test( + 'decide devolvendo false vira erro de transição inválida e não audita sucesso', + () async { + store.decideDevolve = false; + await expectLater( + servico.complete(coordA, 'del-1'), + throwsA(isA()), + ); + await expectLater( + servico.startReview(coordA, 'del-1'), + throwsA(isA()), + ); + // a auditoria de sucesso viaja dentro de decide (mesma transação); o + // serviço nunca a grava por fora. + expect(audit.events.where((e) => e.result != 'denied'), isEmpty); + expect( + store.pedidos['del-1']!.detalhe.status, + DataSubjectRequestStatus.open, + ); + }, + ); + + test( + 'corrida perdida (decide == false) dá mensagem neutra: não fala de estado e ' + 'é a mesma para pedido que existe e para pedido que sumiu do escopo', + () async { + Future mensagem(Future Function() chamada) async { + try { + await chamada(); + } on AdminInvalidRequestException catch (e) { + return e.message; + } + fail('deveria ter lançado AdminInvalidRequestException'); + } + + // Pedido no escopo, outro analista decidiu entre o find e o lock. + store.decideDevolve = false; + final corrida = await mensagem(() => servico.complete(coordA, 'del-1')); + // Pedido que o find viu, mas que saiu do escopo antes do decide (o store + // devolve false para fora do escopo): mesma mensagem. + store.decideDevolve = true; + store.decideForaDoEscopo = true; + final foraDoEscopo = await mensagem( + () => servico.startReview(coordA, 'del-1'), + ); + expect(corrida, foraDoEscopo); + expect(corrida, isNot(contains('estado'))); + // A mensagem de estado inválido segue existindo só para quem viu o + // pedido (find) num estado final. + store.pedidos['del-9'] = _Pedido( + _ubsA, + _detalhe('del-9', status: DataSubjectRequestStatus.completed), + ); + store.decideForaDoEscopo = false; + final finalizado = await mensagem( + () => servico.complete(coordA, 'del-9'), + ); + expect(finalizado, isNot(corrida)); + }, + ); + + test( + 'StateError do store (dado inconsistente) vira AdminInvalidRequestException ' + 'genérica, sem o texto do erro', + () async { + store.decideLanca = StateError( + 'titular do pedido de exclusão não é paciente', + ); + try { + await servico.complete(coordA, 'del-1'); + fail('deveria recusar'); + } on AdminInvalidRequestException catch (e) { + expect(e.message, isNot(contains('paciente'))); + expect(e.message, isNot(contains('titular'))); + } + expect(audit.events.where((e) => e.result != 'denied'), isEmpty); + }, + ); + + test('nota nunca aparece na mensagem de exceção nem no AuditEvent', () async { + const segredo = 'SEGREDO-sintetico-xyz'; + // exceção de validação (nota longa demais contendo o segredo) + try { + await servico.complete(coordA, 'cor-1', note: segredo * 40); + fail('deveria recusar'); + } on AdminInvalidRequestException catch (e) { + expect(e.message, isNot(contains(segredo))); + } + // exceção de transição (decide devolve false) + store.decideDevolve = false; + try { + await servico.reject(coordA, 'del-1', reason: 'motivo $segredo'); + fail('deveria recusar'); + } on AdminInvalidRequestException catch (e) { + expect(e.message, isNot(contains(segredo))); + } + // evento de auditoria da decisão e os gravados pelo serviço + final evento = store.decisoes.single.audit; + for (final e in [evento, ...audit.events]) { + expect( + '${e.userId}|${e.actionType}|${e.resourceType}|${e.resourceId}|${e.result}', + isNot(contains(segredo)), + ); + } + }); + + group('aviso', () { + late _Notificador notificador; + late DataSubjectCaseService comAviso; + + setUp(() { + notificador = _Notificador(); + comAviso = DataSubjectCaseService( + store: store, + audit: audit, + clock: () => agora, + notifier: notificador, + ); + }); + + test('correção atendida chama decided uma vez, depois do commit', () async { + await comAviso.complete(coordA, 'cor-1', note: 'Dado corrigido.'); + expect(notificador.chamadas, [ + ('titular-cor-1', DataSubjectRequestStatus.completed), + ]); + }); + + test('recusa chama decided com rejected', () async { + await comAviso.reject(coordA, 'del-1', reason: 'Pedido sem fundamento.'); + expect(notificador.chamadas, [ + ('titular-del-1', DataSubjectRequestStatus.rejected), + ]); + }); + + test('exclusão atendida e início de análise não chamam decided', () async { + await comAviso.startReview(coordA, 'cor-1'); + await comAviso.complete(coordA, 'del-1'); + expect(notificador.chamadas, isEmpty); + }); + + test('decisão perdida (decide false) não avisa', () async { + store.decideDevolve = false; + await expectLater( + comAviso.complete(coordA, 'cor-1', note: 'Dado corrigido.'), + throwsA(isA()), + ); + expect(notificador.chamadas, isEmpty); + }); + + test('decided lançando erro não altera o resultado de complete', () async { + notificador.lanca = StateError('relé fora do ar'); + await comAviso.complete(coordA, 'cor-1', note: 'Dado corrigido.'); + expect( + store.pedidos['cor-1']!.detalhe.status, + DataSubjectRequestStatus.completed, + ); + await comAviso.reject(coordA, 'del-1', reason: 'Pedido sem fundamento.'); + expect( + store.pedidos['del-1']!.detalhe.status, + DataSubjectRequestStatus.rejected, + ); + }); + }); +} diff --git a/backend/sinalacs_server/test/unit/data_subject_push_notifier_test.dart b/backend/sinalacs_server/test/unit/data_subject_push_notifier_test.dart new file mode 100644 index 00000000..a108d405 --- /dev/null +++ b/backend/sinalacs_server/test/unit/data_subject_push_notifier_test.dart @@ -0,0 +1,86 @@ +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/infrastructure/push/data_subject_push_notifier.dart'; +import 'package:sinalacs_server/src/infrastructure/push/gorush_client.dart'; +import 'package:test/test.dart'; + +/// Aviso push ao titular (#42): só com consentimento `segmentedPush` e token, +/// payload genérico, falha engolida. Dados sintéticos. +class _Alvos implements DataSubjectPushTargets { + List alvos = const [PushTarget(token: 'tok-a', platform: 'android')]; + final consultas = []; + + @override + Future> consentedTargetsOf(String userId) async { + consultas.add(userId); + return alvos; + } +} + +class _Sender implements PushSender { + final enviados = <(PushMessage, List)>[]; + Object? lanca; + + @override + Future send(PushMessage message, List targets) async { + if (lanca != null) throw lanca!; + enviados.add((message, targets)); + return const PushSendReport(accepted: 1, invalidTokens: []); + } +} + +void main() { + late _Alvos alvos; + late _Sender sender; + late GorushDataSubjectNotifier notificador; + + setUp(() { + alvos = _Alvos(); + sender = _Sender(); + notificador = GorushDataSubjectNotifier(targets: alvos, sender: sender); + }); + + test('consentimento e token: envia uma vez, com título genérico e só a tela', () async { + await notificador.decided('u-1', DataSubjectRequestStatus.completed); + expect(sender.enviados, hasLength(1)); + final (msg, destinos) = sender.enviados.single; + expect(destinos.single.token, 'tok-a'); + expect(msg.data, {'screen': 'my_data'}); + expect(msg.title, isNotEmpty); + expect(msg.body, isNotEmpty); + }); + + test('o texto do aviso não carrega nota nem tipo do pedido', () async { + await notificador.decided('u-1', DataSubjectRequestStatus.rejected); + final msg = sender.enviados.single.$1; + final texto = '${msg.title} ${msg.body} ${msg.data}'.toLowerCase(); + for (final proibido in ['exclus', 'correç', 'corrig', 'motivo', 'nota']) { + expect(texto.contains(proibido), isFalse, reason: proibido); + } + }); + + test('sem consentimento ou sem token (lista vazia): não envia', () async { + alvos.alvos = const []; + await notificador.decided('u-1', DataSubjectRequestStatus.completed); + expect(sender.enviados, isEmpty); + }); + + test('status que não é decisão final não envia', () async { + await notificador.decided('u-1', DataSubjectRequestStatus.inReview); + expect(sender.enviados, isEmpty); + expect(alvos.consultas, isEmpty); + }); + + test('falha do relé é engolida', () async { + sender.lanca = const PushGatewayException('fora do ar'); + await notificador.decided('u-1', DataSubjectRequestStatus.completed); + alvos.consultas.clear(); + sender.lanca = StateError('qualquer'); + await notificador.decided('u-1', DataSubjectRequestStatus.completed); + }); + + test('sem remetente configurado: no-op', () async { + final sem = GorushDataSubjectNotifier(targets: alvos, sender: null); + await sem.decided('u-1', DataSubjectRequestStatus.completed); + expect(alvos.consultas, isEmpty); + }); +} diff --git a/backend/sinalacs_server/test/unit/data_subject_rights_service_test.dart b/backend/sinalacs_server/test/unit/data_subject_rights_service_test.dart new file mode 100644 index 00000000..a6b3c5d8 --- /dev/null +++ b/backend/sinalacs_server/test/unit/data_subject_rights_service_test.dart @@ -0,0 +1,636 @@ +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/onboarding/onboarding_service.dart'; +import 'package:sinalacs_server/src/application/patients/data_subject_rights_service.dart'; +import 'package:sinalacs_server/src/application/patients/patient_data_overview_service.dart'; +import 'package:sinalacs_server/src/application/patients/terms_change_schedule.dart'; +import 'package:sinalacs_server/src/application/patients/terms_publication_service.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:test/test.dart'; + +const _patientId = '00000000-0000-4000-8000-000000000001'; +const _microAreaId = '00000000-0000-4000-8000-000000000003'; + +const _patient = AuthenticatedUser( + id: _patientId, + role: UserRole.patient, + microAreaId: _microAreaId, + deviceId: 'patient-device-001', +); + +const _acs = AuthenticatedUser( + id: '00000000-0000-4000-8000-000000000002', + role: UserRole.acs, + microAreaId: _microAreaId, + deviceId: 'acs-device-001', +); + +final _now = DateTime.utc(2026, 9, 28, 12); + +/// Faz o papel de `currentPublished`: só devolve a linha quando publicada e com +/// vigência ainda por vir, como o SQL do store real. +class _StoreDeTermos implements TermsVersionStore { + _StoreDeTermos(this.linha); + final TermsVersionSnapshot? linha; + + @override + Future currentPublished(DateTime now) async { + final l = linha; + if (l == null || l.publishedAt == null) return null; + if (!l.effectiveFrom.isAfter(now) || l.publishedAt!.isAfter(now)) return null; + return l; + } + + @override + dynamic noSuchMethod(Invocation invocation) => throw UnimplementedError(); +} + +class FakeDataSubjectRightsStore implements DataSubjectRightsStore { + final consents = []; + final requests = <({String userId, DataSubjectRequestSnapshot snapshot})>[]; + var _nextId = 1; + var unlessCurrentCalls = 0; + + @override + Future<({String? id, ConsentRecordSnapshot? existing})> recordConsentUnlessCurrent( + ConsentLogEntry entry, + ) async { + unlessCurrentCalls++; + final latest = await latestConsent(entry.userId, entry.purpose); + if (latest != null && latest.action == 'granted' && latest.version == entry.version) { + return (id: null, existing: latest); + } + return (id: await recordConsent(entry), existing: null); + } + + var revokingPushCalls = 0; + + @override + Future recordConsentRevokingPush(ConsentLogEntry entry) async { + revokingPushCalls++; + return recordConsent(entry); + } + + @override + Future recordConsent(ConsentLogEntry entry) async { + consents.add(entry); + return 'consentimento-${consents.length}'; + } + + @override + Future latestConsent(String userId, ConsentPurpose purpose) async { + ConsentLogEntry? latest; + for (final e in consents) { + if (e.userId != userId || e.purpose != purpose) continue; + if (latest == null || e.timestamp.isAfter(latest.timestamp)) latest = e; + } + return latest == null + ? null + : ConsentRecordSnapshot( + purpose: latest.purpose.name, + action: latest.action, + version: latest.version, + timestamp: latest.timestamp, + ); + } + + @override + Future<({DataSubjectRequestSnapshot request, bool created})> createDeletionRequestIfNoneOpen({ + required String userId, + required DateTime createdAt, + required DateTime dueAt, + }) async { + for (final r in requests.reversed) { + if (r.userId == userId && + r.snapshot.type == DataSubjectRequestType.deletion && + r.snapshot.status == DataSubjectRequestStatus.open) { + return (request: r.snapshot, created: false); + } + } + final created = await createRequest( + userId: userId, + type: DataSubjectRequestType.deletion, + details: null, + createdAt: createdAt, + dueAt: dueAt, + ); + return (request: created, created: true); + } + + @override + Future createRequest({ + required String userId, + required DataSubjectRequestType type, + required String? details, + required DateTime createdAt, + required DateTime dueAt, + }) async { + final snapshot = DataSubjectRequestSnapshot( + id: 'pedido-${_nextId++}', + type: type, + status: DataSubjectRequestStatus.open, + details: details, + createdAt: createdAt, + dueAt: dueAt, + ); + requests.add((userId: userId, snapshot: snapshot)); + return snapshot; + } +} + +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 FakeDataSubjectRightsStore store; + late FakeAuditTrail audit; + late DataSubjectRightsService service; + + setUp(() { + store = FakeDataSubjectRightsStore(); + audit = FakeAuditTrail(); + service = DataSubjectRightsService(store: store, audit: audit, clock: () => _now); + }); + + group('revogar segmentedPush (RF14)', () { + test('a revogação usa a gravação atômica; o resto usa recordConsent', () async { + await service.updateConsent(_patient, purpose: ConsentPurpose.segmentedPush, granted: true); + await service.updateConsent(_patient, purpose: ConsentPurpose.localReminders, granted: false); + expect(store.revokingPushCalls, 0); + + await service.updateConsent(_patient, purpose: ConsentPurpose.segmentedPush, granted: false); + expect(store.revokingPushCalls, 1); + expect(store.consents.last.action, 'denied'); + expect(store.consents.last.purpose, ConsentPurpose.segmentedPush); + expect(audit.events.last.resourceType, 'consent_log'); + }); + }); + + group('termsChangeNotice (LGPD-RF18, aviso de 15 dias)', () { + final agenda = TermsChangeSchedule( + version: '2026.2', + publishedAt: _now.subtract(const Duration(days: 1)), + effectiveFrom: _now.add(const Duration(days: 14)), + summary: 'Novo canal de dúvidas.', + ); + + DataSubjectRightsService comAgenda(DateTime Function() clock) => DataSubjectRightsService( + store: store, + audit: audit, + clock: clock, + termsChangeReader: () async => agenda, + ); + + test('um leitor que devolve null vence a constante do repositório', () async { + final svc = DataSubjectRightsService( + store: store, + audit: audit, + clock: () => _now, + termsChangeReader: () async => null, + ); + expect(await svc.termsChangeNotice(_patient), isNull); + }); + + test('sem agenda, não há aviso', () async { + expect(await service.termsChangeNotice(_patient), isNull); + }); + + test('com agenda ativa, devolve versão, vigência e resumo', () async { + final notice = (await comAgenda(() => _now).termsChangeNotice(_patient))!; + expect(notice.version, '2026.2'); + expect(notice.effectiveFrom, agenda.effectiveFrom); + expect(notice.summary, 'Novo canal de dúvidas.'); + }); + + test('exatamente na vigência não há mais aviso', () async { + expect(await comAgenda(() => agenda.effectiveFrom).termsChangeNotice(_patient), isNull); + }); + + test('antes da publicação não há aviso', () async { + expect( + await comAgenda(() => agenda.publishedAt.subtract(const Duration(seconds: 1))) + .termsChangeNotice(_patient), + isNull, + ); + }); + + test('só paciente', () async { + await expectLater(comAgenda(() => _now).termsChangeNotice(_acs), throwsA(isA())); + }); + + test('não grava nada nem audita (leitura sem I/O)', () async { + await comAgenda(() => _now).termsChangeNotice(_patient); + expect(store.consents, isEmpty); + expect(audit.events, isEmpty); + }); + + group('lendo a agenda publicada do store (#46)', () { + final pub = _now.subtract(const Duration(days: 1)); + final vig = _now.add(const Duration(days: 14)); + + TermsVersionSnapshot linha({ + DateTime? publishedAt, + DateTime? effectiveFrom, + DateTime? reviewedAt, + }) => + TermsVersionSnapshot( + id: 't1', + version: '2026.2', + summary: 'Novo canal.', + effectiveFrom: effectiveFrom ?? vig, + createdAt: _now.subtract(const Duration(days: 3)), + legallyReviewedAt: reviewedAt, + publishedAt: publishedAt, + ); + + DataSubjectRightsService comStore(_StoreDeTermos termos, DateTime now) => + DataSubjectRightsService( + store: store, + audit: audit, + clock: () => now, + termsVersionStore: termos, + ); + + test('publicada e now entre publishedAt e effectiveFrom devolve o aviso', () async { + final n = (await comStore(_StoreDeTermos(linha(publishedAt: pub)), _now) + .termsChangeNotice(_patient))!; + expect(n.version, '2026.2'); + expect(n.effectiveFrom, vig); + expect(n.summary, 'Novo canal.'); + }); + + test('antes de publishedAt não há aviso', () async { + final svc = comStore(_StoreDeTermos(linha(publishedAt: pub)), pub.subtract(const Duration(seconds: 1))); + expect(await svc.termsChangeNotice(_patient), isNull); + }); + + test('na vigência ou depois não há aviso', () async { + expect(await comStore(_StoreDeTermos(linha(publishedAt: pub)), vig).termsChangeNotice(_patient), isNull); + }); + + test('rascunho ou só revisada não têm aviso', () async { + expect(await comStore(_StoreDeTermos(linha()), _now).termsChangeNotice(_patient), isNull); + expect( + await comStore(_StoreDeTermos(linha(reviewedAt: pub)), _now).termsChangeNotice(_patient), + isNull, + ); + }); + + test('sem linhas não há aviso', () async { + expect(await comStore(_StoreDeTermos(null), _now).termsChangeNotice(_patient), isNull); + }); + + test('linha inconsistente (menos de 15 dias) vira null, sem lançar', () async { + final ruim = linha(publishedAt: pub, effectiveFrom: pub.add(const Duration(days: 3))); + expect(await comStore(_StoreDeTermos(ruim), _now).termsChangeNotice(_patient), isNull); + }); + + test('outro papel continua recusado', () async { + await expectLater( + comStore(_StoreDeTermos(linha(publishedAt: pub)), _now).termsChangeNotice(_acs), + throwsA(isA()), + ); + }); + }); + }); + +group('acceptTermsOfUse (LGPD-RF18)', () { + test('grava "granted" para termsOfUse com a versão vigente e audita', () async { + final record = await service.acceptTermsOfUse(_patient); + + final entry = store.consents.single; + expect(entry.userId, _patientId); + expect(entry.purpose, ConsentPurpose.termsOfUse); + expect(entry.action, 'granted'); + expect(entry.version, consentPolicyVersion); + expect(entry.timestamp, _now); + expect(record.purpose, 'termsOfUse'); + expect(record.action, 'granted'); + expect(audit.events.single.resourceType, 'consent_log'); + }); + + test('usa a gravação condicional e não audita a repetição', () async { + await service.acceptTermsOfUse(_patient); + await service.acceptTermsOfUse(_patient); + + expect(store.unlessCurrentCalls, 2); + expect(store.consents.where((c) => c.purpose == ConsentPurpose.termsOfUse), hasLength(1)); + expect(audit.events.where((e) => e.resourceType == 'consent_log'), hasLength(1)); + }); + + test('só paciente aceita: ACS é recusado sem gravar nada', () async { + await expectLater(service.acceptTermsOfUse(_acs), throwsA(isA())); + expect(store.consents, isEmpty); + expect(audit.events, isEmpty); + }); + + test('aceitar de novo com o aceite vigente não grava nada', () async { + await service.acceptTermsOfUse(_patient); + final again = await service.acceptTermsOfUse(_patient); + + expect(store.consents, hasLength(1)); + expect(audit.events, hasLength(1)); + expect(again.action, 'granted'); + expect(again.version, consentPolicyVersion); + }); + + test('aceite de versão anterior não conta: grava de novo', () async { + store.consents.add(ConsentLogEntry( + userId: _patientId, + purpose: ConsentPurpose.termsOfUse, + action: 'granted', + version: '2025.9', + timestamp: _now.subtract(const Duration(days: 30)), + )); + + await service.acceptTermsOfUse(_patient); + + expect(store.consents, hasLength(2)); + expect(store.consents.last.version, consentPolicyVersion); + }); + }); + + group('hasAcceptedCurrentTerms (LGPD-RF18)', () { + ConsentLogEntry linha(String action, String version, int minutos, + {ConsentPurpose purpose = ConsentPurpose.termsOfUse}) => + ConsentLogEntry( + userId: _patientId, + purpose: purpose, + action: action, + version: version, + timestamp: _now.add(Duration(minutes: minutos)), + ); + + test('sem nenhuma linha de termsOfUse, não aceitou', () async { + expect(await service.hasAcceptedCurrentTerms(_patient), isFalse); + store.consents.add(linha('granted', consentPolicyVersion, 0, purpose: ConsentPurpose.localReminders)); + expect(await service.hasAcceptedCurrentTerms(_patient), isFalse); + }); + + test('aceite da versão vigente conta', () async { + store.consents.add(linha('granted', consentPolicyVersion, 0)); + expect(await service.hasAcceptedCurrentTerms(_patient), isTrue); + }); + + test('aceite de versão anterior não conta', () async { + store.consents.add(linha('granted', '2025.9', 0)); + expect(await service.hasAcceptedCurrentTerms(_patient), isFalse); + }); + + test('vale a linha mais recente, seja qual for a ordem em que foram gravadas', () async { + store.consents.add(linha('granted', consentPolicyVersion, 5)); + store.consents.add(linha('granted', '2025.9', 0)); + expect(await service.hasAcceptedCurrentTerms(_patient), isTrue); + }); + + test('linha mais recente que não é "granted" não conta', () async { + store.consents.add(linha('granted', consentPolicyVersion, 0)); + store.consents.add(linha('denied', consentPolicyVersion, 5)); + expect(await service.hasAcceptedCurrentTerms(_patient), isFalse); + }); + + test('só paciente consulta: ACS é recusado', () async { + await expectLater(service.hasAcceptedCurrentTerms(_acs), throwsA(isA())); + }); + }); + + group('hasGrantedConsent (LGPD-RF05, RF14)', () { + ConsentLogEntry linha(String action, int minutos, {ConsentPurpose purpose = ConsentPurpose.segmentedPush}) => + ConsentLogEntry( + userId: _patientId, + purpose: purpose, + action: action, + version: consentPolicyVersion, + timestamp: _now.add(Duration(minutes: minutos)), + ); + + test('sem nenhuma linha da finalidade, não concedeu (nem com outra finalidade concedida)', () async { + expect(await service.hasGrantedConsent(_patient, ConsentPurpose.segmentedPush), isFalse); + store.consents.add(linha('granted', 0, purpose: ConsentPurpose.localReminders)); + expect(await service.hasGrantedConsent(_patient, ConsentPurpose.segmentedPush), isFalse); + }); + + test('a linha mais recente "granted" conta, qualquer que seja a ordem de gravação', () async { + store.consents.add(linha('granted', 5)); + store.consents.add(linha('denied', 0)); + expect(await service.hasGrantedConsent(_patient, ConsentPurpose.segmentedPush), isTrue); + }); + + test('revogado depois de concedido não conta', () async { + store.consents.add(linha('granted', 0)); + store.consents.add(linha('denied', 5)); + expect(await service.hasGrantedConsent(_patient, ConsentPurpose.segmentedPush), isFalse); + }); + + test('só paciente consulta: ACS é recusado', () async { + await expectLater( + service.hasGrantedConsent(_acs, ConsentPurpose.segmentedPush), + throwsA(isA()), + ); + }); + }); + + group('updateConsent (LGPD-RF05)', () { + test('a linha de auditoria aponta para a linha de consentimento gravada', () async { + await service.updateConsent(_patient, purpose: ConsentPurpose.localReminders, granted: false); + + expect(audit.events.single.resourceId, 'consentimento-1'); + }); + + test('revogar grava uma linha nova "denied", com a versão vigente e o relógio do servidor', () async { + final record = await service.updateConsent( + _patient, + purpose: ConsentPurpose.localReminders, + granted: false, + ); + + final entry = store.consents.single; + expect(entry.userId, _patientId); + expect(entry.purpose, ConsentPurpose.localReminders); + expect(entry.action, 'denied'); + expect(entry.version, consentPolicyVersion); + expect(entry.timestamp, _now); + + expect(record.purpose, 'localReminders'); + expect(record.action, 'denied'); + expect(record.timestamp, _now); + }); + + test('conceder de novo grava "granted"', () async { + await service.updateConsent(_patient, purpose: ConsentPurpose.segmentedPush, granted: true); + + expect(store.consents.single.action, 'granted'); + }); + + test('recusa mexer no consentimento obrigatório, em qualquer direção, sem gravar nada', () async { + for (final granted in [false, true]) { + await expectLater( + service.updateConsent( + _patient, + purpose: ConsentPurpose.healthDataProcessing, + granted: granted, + ), + throwsA(isA()), + ); + } + expect(store.consents, isEmpty); + expect(audit.events, isEmpty); + }); + + test('recusa mexer no aceite do Termo de Uso pelo painel', () async { + await expectLater( + service.updateConsent(_patient, purpose: ConsentPurpose.termsOfUse, granted: false), + throwsA(isA()), + ); + expect(store.consents, isEmpty); + expect(audit.events, isEmpty); + }); + + test('um ACS não altera consentimento de ninguém', () async { + await expectLater( + service.updateConsent(_acs, purpose: ConsentPurpose.localReminders, granted: false), + throwsA(isA()), + ); + expect(store.consents, isEmpty); + }); + + test('grava uma linha de escrita em audit_logs', () async { + await service.updateConsent(_patient, purpose: ConsentPurpose.localReminders, granted: false); + + final event = audit.events.single; + expect(event.userId, _patientId); + expect(event.actionType, 'write'); + expect(event.resourceType, 'consent_log'); + expect(event.result, 'granted'); + }); + + test('não exige microárea: o escopo é o próprio titular', () async { + const semArea = AuthenticatedUser( + id: _patientId, + role: UserRole.patient, + microAreaId: null, + deviceId: 'patient-device-001', + ); + + await service.updateConsent(semArea, purpose: ConsentPurpose.localReminders, granted: true); + expect(store.consents, hasLength(1)); + }); + }); + + group('requestDeletion (LGPD-RF08)', () { + test('abre um pedido em análise com prazo de 15 dias', () async { + final request = await service.requestDeletion(_patient); + + expect(request.type, DataSubjectRequestType.deletion); + expect(request.status, DataSubjectRequestStatus.open); + expect(request.details, isNull); + expect(request.createdAt, _now); + expect(request.dueAt, _now.add(const Duration(days: 15))); + expect(store.requests.single.userId, _patientId); + }); + + test('pedir de novo com um pedido aberto devolve o mesmo, sem duplicar', () async { + final first = await service.requestDeletion(_patient); + final second = await service.requestDeletion(_patient); + + expect(second.id, first.id); + expect(store.requests, hasLength(1)); + // A repetição também é auditada, com o resultado `repeated`. + expect(audit.events, hasLength(2)); + expect(audit.events.last.resourceId, first.id); + expect(audit.events.last.result, 'repeated'); + }); + + test('o pedido novo vai para audit_logs com o id do pedido', () async { + final request = await service.requestDeletion(_patient); + + final event = audit.events.single; + expect(event.actionType, 'write'); + expect(event.resourceType, 'data_subject_request'); + expect(event.resourceId, request.id); + expect(event.result, 'granted'); + }); + + test('um ACS não pede exclusão em nome de ninguém', () async { + await expectLater(service.requestDeletion(_acs), throwsA(isA())); + expect(store.requests, isEmpty); + }); + + test('uma trilha de auditoria fora do ar não impede o pedido', () async { + service = DataSubjectRightsService( + store: store, + audit: FakeAuditTrail(failOnRecord: true), + clock: () => _now, + ); + + final request = await service.requestDeletion(_patient); + expect(request.status, DataSubjectRequestStatus.open); + }); + }); + + group('requestCorrection (LGPD-RF08)', () { + test('grava o texto sem os espaços das pontas', () async { + final request = await service.requestCorrection( + _patient, + details: ' Meu contato de emergência mudou. ', + ); + + expect(request.type, DataSubjectRequestType.correction); + expect(request.details, 'Meu contato de emergência mudou.'); + expect(request.dueAt, _now.add(const Duration(days: 15))); + }); + + test('um segundo pedido com outro aberto é pedido NOVO — o texto novo não se perde', () async { + await service.requestCorrection(_patient, details: 'Contato errado.'); + final second = await service.requestCorrection(_patient, details: 'Nome com grafia errada.'); + + expect(store.requests, hasLength(2)); + expect(second.details, 'Nome com grafia errada.'); + }); + + test('texto vazio ou só espaços é recusado', () async { + for (final details in ['', ' ', '\n\t']) { + await expectLater( + service.requestCorrection(_patient, details: details), + throwsA(isA()), + ); + } + expect(store.requests, isEmpty); + }); + + test('aceita 500 caracteres e recusa 501', () async { + await service.requestCorrection(_patient, details: 'a' * 500); + await expectLater( + service.requestCorrection(_patient, details: 'a' * 501), + throwsA(isA()), + ); + expect(store.requests, hasLength(1)); + }); + + test('o texto do pedido nunca vai para audit_logs', () async { + await service.requestCorrection(_patient, details: 'Tenho hipertensão, não diabetes.'); + + final event = audit.events.single; + expect(event.resourceType, 'data_subject_request'); + expect(event.resourceId, isNot(contains('hipertensão'))); + expect(event.result, 'granted'); + }); + + test('um ACS não pede correção em nome de ninguém', () async { + await expectLater( + service.requestCorrection(_acs, details: 'Qualquer coisa.'), + throwsA(isA()), + ); + }); + }); +} diff --git a/backend/sinalacs_server/test/unit/e2e_fixtures_test.dart b/backend/sinalacs_server/test/unit/e2e_fixtures_test.dart new file mode 100644 index 00000000..152cd62b --- /dev/null +++ b/backend/sinalacs_server/test/unit/e2e_fixtures_test.dart @@ -0,0 +1,215 @@ +import 'dart:io'; +import 'dart:math'; + +import 'package:sinalacs_server/src/application/auth/cpf.dart'; +import 'package:sinalacs_server/src/infrastructure/testing/e2e_fixtures.dart'; +import 'package:test/test.dart'; + +void main() { + test('todo CPF gerado tem dígito verificador válido (mil amostras)', () { + final random = Random(1); + for (var i = 0; i < 1000; i++) { + expect(Cpf.tryParse(generateValidCpfDigits(random)), isNotNull); + } + }); + + test('duas execuções não compartilham UUID nem CPF', () { + final a = generateE2eFixtures(Random(1)); + final b = generateE2eFixtures(Random(2)); + final idsA = {a.microAreaId, a.acs.id, a.secondAcs.id, ...a.patients.map((p) => p.id)}; + final idsB = {b.microAreaId, b.acs.id, b.secondAcs.id, ...b.patients.map((p) => p.id)}; + expect(idsA.intersection(idsB), isEmpty); + expect(a.patients.map((p) => p.cpf).toSet().intersection(b.patients.map((p) => p.cpf).toSet()), isEmpty); + }); + + test('dentro de uma execução tudo é único e o forasteiro está em outra microárea', () { + final f = generateE2eFixtures(Random(7)); + expect(f.patients.map((p) => p.cpf).toSet(), hasLength(f.patients.length)); + expect(f.patients.map((p) => p.id).toSet(), hasLength(f.patients.length)); + final outsider = f.byRole('outsider'); + expect(outsider.microAreaId, f.otherMicroAreaId); + expect(f.patients.where((p) => p.role != 'outsider').every((p) => p.microAreaId == f.microAreaId), isTrue); + }); + + test('nenhum UUID é do formato fixo do seed de desenvolvimento', () { + final f = generateE2eFixtures(Random(3)); + for (final id in [f.ubsId, f.microAreaId, f.otherMicroAreaId, f.acs.id, f.secondAcs.id, f.staff.id, ...f.patients.map((p) => p.id)]) { + expect(id.startsWith('00000000-0000-4000-8000'), isFalse); + expect(RegExp(r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$').hasMatch(id), isTrue); + } + }); + + test('há um paciente por consumidor: jornada, api, push e forasteiro', () { + final f = generateE2eFixtures(Random(5)); + expect(f.patients.map((p) => p.role).toSet(), {'main', 'chronic', 'api', 'push', 'outsider'}); + expect(f.patients.singleWhere((p) => p.role == 'chronic').chronic, isTrue); + }); + + test('o manifesto ida e volta é idêntico', () { + final f = generateE2eFixtures(Random(9)); + expect(E2eFixtures.fromJson(f.toJson()).toJson(), f.toJson()); + }); + + test('toString não vaza CPF nem senha', () { + final f = generateE2eFixtures(Random(4)); + final texto = '$f ${f.patients.first} ${f.acs} ${f.secondAcs}'; + for (final p in f.patients) { + expect(texto.contains(p.cpf), isFalse); + } + expect(texto.contains(f.acs.password), isFalse); + expect(texto.contains(f.secondAcs.password), isFalse); + }); + + test('há um segundo ACS, distinto do primeiro (id, matrícula e senha), para a fila por dono', () { + for (var seed = 0; seed < 200; seed++) { + final f = generateE2eFixtures(Random(seed)); + expect(f.secondAcs.id, isNot(f.acs.id)); + expect(f.secondAcs.matricula, isNot(f.acs.matricula), reason: 'seed $seed'); + expect(f.secondAcs.password, isNot(f.acs.password)); + expect(f.secondAcs.matricula, startsWith('E2E-')); + } + final f = generateE2eFixtures(Random(11)); + expect(f.toJson()['acsB'], f.secondAcs.toJson()); + }); + + test('há um admin do backoffice, distinto dos ACS, com matrícula própria e sem vazar a senha', () { + for (var seed = 0; seed < 200; seed++) { + final f = generateE2eFixtures(Random(seed)); + expect({f.staff.id, f.acs.id, f.secondAcs.id}, hasLength(3), reason: 'seed $seed'); + expect(f.staff.matricula, startsWith('E2E-ADM-')); + expect({f.staff.matricula, f.acs.matricula, f.secondAcs.matricula}, hasLength(3)); + expect({f.staff.password, f.acs.password, f.secondAcs.password}, hasLength(3)); + } + final f = generateE2eFixtures(Random(12)); + expect(f.toJson()['staff'], f.staff.toJson()); + expect(E2eFixtures.fromJson(f.toJson()).staff.matricula, f.staff.matricula); + expect('$f ${f.staff}'.contains(f.staff.password), isFalse); + }); + + test('há um coordenador do backoffice (#43), distinto do admin e dos ACS, com código próprio', () { + for (var seed = 0; seed < 200; seed++) { + final f = generateE2eFixtures(Random(seed)); + expect({f.coordinator.id, f.staff.id, f.acs.id, f.secondAcs.id}, hasLength(4), reason: 'seed $seed'); + // Prefixo próprio: as matrículas de staff vivem na MESMA tabela, com + // índice único — colidir aqui seria um seed que falha no meio. + expect(f.coordinator.matricula, startsWith('E2E-COORD-'), reason: 'seed $seed'); + expect( + {f.coordinator.matricula, f.staff.matricula, f.acs.matricula, f.secondAcs.matricula}, + hasLength(4), + reason: 'seed $seed', + ); + expect({f.coordinator.password, f.staff.password, f.acs.password, f.secondAcs.password}, hasLength(4)); + expect(f.coordinator.activationCode, isNot(f.staff.activationCode)); + } + final f = generateE2eFixtures(Random(41)); + expect(f.toJson()['coordinator'], f.coordinator.toJson()); + expect(E2eFixtures.fromJson(f.toJson()).coordinator.matricula, f.coordinator.matricula); + // Nem a senha nem o código de ativação saem no toString. + expect('$f ${f.coordinator}'.contains(f.coordinator.password), isFalse); + expect('${f.coordinator}'.contains(f.coordinator.activationCode), isFalse); + }); + + test('o staff traz um código de ativação (#48) no formato da CLI, novo a cada execução e fora do toString', () { + final a = generateE2eFixtures(Random(21)).staff; + final b = generateE2eFixtures(Random(22)).staff; + expect(a.activationCode, matches(RegExp(r'^([A-Z2-7]{4}-){6}[A-Z2-7]{2}$'))); + expect(a.activationCode, isNot(b.activationCode)); + expect(a.toJson()['activationCode'], a.activationCode); + expect(E2eStaff.fromJson(a.toJson()).activationCode, a.activationCode); + expect('$a'.contains(a.activationCode), isFalse); + }); + + test('o painel do admin (#40) tem 3 alertas de fixture, com ids v4 distintos e que sobrevivem ao JSON', () { + final f = generateE2eFixtures(Random(31)); + expect(f.adminAlertIds, hasLength(3)); + expect(f.adminAlertIds.toSet(), hasLength(3)); + for (final id in f.adminAlertIds) { + expect(id.startsWith('00000000-0000-4000-8000'), isFalse); + expect(RegExp(r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$').hasMatch(id), isTrue); + } + expect(E2eFixtures.fromJson(f.toJson()).adminAlertIds, f.adminAlertIds); + final ids = {f.staff.id, f.acs.id, f.secondAcs.id, ...f.patients.map((p) => p.id), ...f.adminAlertIds}; + expect(ids, hasLength(3 + f.patients.length + 3), reason: 'nenhum id repetido entre as entidades'); + }); + + test('o escalonamento (#44) tem 2 alertas de fixture — vencido e novo —, distintos de todo o resto', () { + final f = generateE2eFixtures(Random(44)); + final ids = [f.escalationAlerts.overdueId, f.escalationAlerts.freshId]; + expect(ids.toSet(), hasLength(2)); + for (final id in ids) { + expect(id.startsWith('00000000-0000-4000-8000'), isFalse); + expect(RegExp(r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$').hasMatch(id), isTrue); + } + final todos = { + f.ubsId, + f.microAreaId, + f.otherMicroAreaId, + f.staff.id, + f.coordinator.id, + f.acs.id, + f.secondAcs.id, + ...f.patients.map((p) => p.id), + ...f.adminAlertIds, + ...ids, + }; + expect(todos, hasLength(7 + f.patients.length + 3 + 2), reason: 'nenhum id repetido entre as entidades'); + expect(f.toJson()['escalationAlerts'], {'overdueId': ids[0], 'freshId': ids[1]}); + final volta = E2eFixtures.fromJson(f.toJson()).escalationAlerts; + expect((volta.overdueId, volta.freshId), (ids[0], ids[1])); + }); + + test('o alerta vencido do escalonamento nasce além do prazo da stack de e2e, com folga', () { + // A stack de e2e fixa RED_ALERT_ESCALATION_SECONDS=30 (docker-compose.e2e.yml); + // o vencido precisa estar além dele já na abertura da tela, depois do build + // e do login (minutos), e o novo precisa ficar AQUÉM do prazo por mais de um + // pulso (30 s), para a tela mostrá-lo sem o botão antes de vencer. + expect(E2eEscalationAlerts.overdueAge, greaterThan(const Duration(seconds: e2eEscalationThresholdSeconds))); + expect(E2eEscalationAlerts.overdueAge, greaterThanOrEqualTo(const Duration(minutes: 10))); + expect( + E2eEscalationAlerts.freshLead + const Duration(seconds: e2eEscalationThresholdSeconds), + greaterThan(const Duration(seconds: 30 + 15)), + reason: 'um pulso (30 s) mais a recarga da lista antes de o novo vencer', + ); + expect(e2eEscalationThresholdSeconds, inInclusiveRange(30, 3600), reason: 'a faixa que o AppConfig aceita'); + }); + + group('guarda do seeder (e2eSeedRefusal)', () { + const ok = { + 'APP_ENV': 'development', + 'SERVERPOD_DATABASE_NAME': 'sinalacs_e2e', + 'SERVERPOD_DATABASE_HOST': 'localhost', + 'SERVERPOD_DATABASE_PORT': '9090', + 'SERVERPOD_DATABASE_PASSWORD': 'x', + }; + + test('o ambiente esperado passa', () => expect(e2eSeedRefusal(ok), isNull)); + + test('recusa outro APP_ENV, outro banco e senha ausente', () { + expect(e2eSeedRefusal({...ok, 'APP_ENV': 'production'}), contains('APP_ENV')); + expect(e2eSeedRefusal({...ok, 'SERVERPOD_DATABASE_NAME': 'sinalacs_db'}), contains('sinalacs_e2e')); + expect(e2eSeedRefusal({...ok}..remove('SERVERPOD_DATABASE_PASSWORD')), contains('PASSWORD')); + }); + + test('recusa um banco de mesmo nome em OUTRO servidor, e a porta do banco de desenvolvimento', () { + expect(e2eSeedRefusal({...ok, 'SERVERPOD_DATABASE_HOST': 'db.exemplo.interno'}), contains('host')); + expect(e2eSeedRefusal({...ok, 'SERVERPOD_DATABASE_HOST': 'postgres'}), contains('host')); + expect(e2eSeedRefusal({...ok, 'SERVERPOD_DATABASE_PORT': '5432'}), contains('9090')); + }); + + test('aceita 127.0.0.1 além de localhost', () { + expect(e2eSeedRefusal({...ok, 'SERVERPOD_DATABASE_HOST': '127.0.0.1'}), isNull); + }); + }); + + test('o manifesto nasce privado: arquivo 600 e diretório 700, nunca legível por outros', () { + final dir = Directory.systemTemp.createTempSync('e2e_manifest_'); + addTearDown(() => dir.deleteSync(recursive: true)); + final file = File('${dir.path}/sub/fixtures.json'); + + writeManifestPrivately(file, '{"a":1}'); + + expect(file.readAsStringSync(), '{"a":1}'); + expect(file.statSync().mode & 0x1ff, 384, reason: '0600'); + expect(file.parent.statSync().mode & 0x1ff, 448, reason: '0700'); + }); +} diff --git a/backend/sinalacs_server/test/unit/endpoint_auth_posture_test.dart b/backend/sinalacs_server/test/unit/endpoint_auth_posture_test.dart index 1b7ec9b6..a7bd74df 100644 --- a/backend/sinalacs_server/test/unit/endpoint_auth_posture_test.dart +++ b/backend/sinalacs_server/test/unit/endpoint_auth_posture_test.dart @@ -35,6 +35,21 @@ const _publicMethodsByDesign = { 'AuthEndpoint.loginInstitutional': 'é o próprio emissor de token: quem chama o login institucional (RF07) ' 'ainda não tem credencial — a matrícula e a senha SÃO a credencial', + 'AuthEndpoint.loginStaff': + 'é o próprio emissor de token do backoffice: quem chama ainda não tem ' + 'credencial — a matrícula e a senha SÃO a credencial (mais o TOTP)', + 'AuthEndpoint.beginStaffTotpEnrollment': + 'ativação da MFA do staff: não há token antes da MFA, como a do ACS; ' + 'matrícula e senha são a prova', + 'AuthEndpoint.confirmStaffTotpEnrollment': + 'ativação da MFA do staff: não há token antes da MFA, como a do ACS; ' + 'matrícula, senha e o primeiro código são a prova', + 'AuthEndpoint.refreshSession': + 'renovação por refresh token (LGPD-RT06): quem chama já perdeu o JWT; o ' + 'token opaco, rotativo e amarrado ao aparelho É a credencial', + 'AuthEndpoint.logout': + 'revoga pela posse do refresh token; idempotente e sem efeito sobre ' + 'token desconhecido', 'OnboardingEndpoint.completeEnrollment': 'público por desenho: quem conclui o onboarding ainda não tem sessão — o ' 'convite de uso único é a credencial', @@ -42,6 +57,21 @@ const _publicMethodsByDesign = { 'login por desenho: quem pede o código ainda não tem sessão', 'AuthEndpoint.verifyOtp': 'login por desenho: é esta chamada que emite a sessão do paciente', + 'AuthEndpoint.beginTotpEnrollment': + 'ativação da MFA (RF07) acontece ANTES do primeiro token: com ' + 'REQUIRE_ACS_MFA o ACS sem MFA não recebe sessão — matrícula e senha SÃO ' + 'a credencial, e a tentativa errada conta no bloqueio como no login', + 'VisitsEndpoint.syncDeferred': + 'envio diferido (D7 do plano 2026-10-03): roda depois de o ACS sair, sem ' + 'JWT — o token de envio opaco, amarrado ao aparelho, de 7 dias e escopo ' + 'único É a credencial; o usuário vem de UploadTokenService.resolve (dono ' + 'do token, microárea relida do banco), nunca de parâmetro', + 'VisitsEndpoint.revokeUploadToken': + 'revoga pela posse do token de envio diferido, como AuthEndpoint.logout; ' + 'idempotente e sem efeito sobre token desconhecido', + 'AuthEndpoint.confirmTotpEnrollment': + 'mesma razão de beginTotpEnrollment: matrícula + senha + código válido ' + 'do segredo pendente são a credencial; não há token a exigir ainda', }; /// A classe que o teste protege — e cuja declaração ele precisa ignorar. @@ -132,6 +162,32 @@ void main() { expect(offenders, isEmpty, reason: offenders.join('\n')); }); + // Os cinco métodos do atendimento de pedidos do titular (#42) mexem em dado + // pessoal e podem anonimizar uma conta: além da regra genérica acima, este + // caso exige nominalmente que existam, que `AdminEndpoint` estenda a base e + // que nenhum deles esteja isento. + test('os cinco métodos do atendimento de pedidos do titular autenticam', () { + const metodos = [ + 'dataSubjectRequests', + 'dataSubjectRequest', + 'startDataSubjectReview', + 'completeDataSubjectRequest', + 'rejectDataSubjectRequest', + ]; + final admin = _scan().singleWhere((e) => e.name == 'AdminEndpoint'); + expect(admin.extendsBase, isTrue); + final porNome = {for (final m in admin.methods) m.name: m}; + for (final nome in metodos) { + expect(porNome[nome], isNotNull, reason: 'AdminEndpoint.$nome não existe'); + expect( + porNome[nome]!.body, + contains('authenticate(accessToken)'), + reason: 'AdminEndpoint.$nome precisa chamar authenticate(accessToken)', + ); + expect(_publicMethodsByDesign.containsKey('AdminEndpoint.$nome'), isFalse); + } + }); + test('a allowlist não guarda endpoint que já virou autenticado', () { // Entrada obsoleta na allowlist é pior que entrada ausente: ela documenta // uma postura pública que não existe mais e esconde a hora de removê-la. diff --git a/backend/sinalacs_server/test/unit/expurge_service_test.dart b/backend/sinalacs_server/test/unit/expurge_service_test.dart new file mode 100644 index 00000000..061bfc2f --- /dev/null +++ b/backend/sinalacs_server/test/unit/expurge_service_test.dart @@ -0,0 +1,172 @@ +import 'package:sinalacs_server/src/application/retention/expurge_service.dart'; +import 'package:test/test.dart'; + +class _FakeRetentionStore implements RetentionStore { + _FakeRetentionStore([this.result]); + + ExpurgeCounts? result; + RetentionCutoffs? received; + + @override + Future purgeDue(RetentionCutoffs cutoffs) async { + received = cutoffs; + return result ?? const ExpurgeCounts( + alerts: 0, + alertDeliveries: 0, + alertIdempotencyKeys: 0, + alertOutbox: 0, + visits: 0, + triageSessions: 0, + alertsByRisk: {}, + ); + } +} + +const _counts = ExpurgeCounts( + alerts: 3, + alertDeliveries: 2, + alertIdempotencyKeys: 1, + alertOutbox: 2, + visits: 4, + triageSessions: 5, + alertsByRisk: {'red': 2, 'yellow': 1}, +); + +void main() { + group('RetentionPolicy', () { + test('calcula os cortes por tabela a partir de agora (UTC)', () { + final policy = const RetentionPolicy( + alertsDays: 730, + visitsDays: 1825, + triageSessionsDays: 1825, + ); + final now = DateTime.utc(2026, 10, 7, 12); + + final cutoffs = policy.cutoffsAt(now); + + expect(cutoffs.alerts, DateTime.utc(2024, 10, 7, 12)); + // 1825 dias atrás cruzam o 29/02/2024 (ano bissexto): +1 dia civil. + expect(cutoffs.visits, DateTime.utc(2021, 10, 8, 12)); + expect(cutoffs.triageSessions, DateTime.utc(2021, 10, 8, 12)); + }); + + test('normaliza "agora" para UTC antes de calcular', () { + final policy = const RetentionPolicy( + alertsDays: 1, + visitsDays: 2, + triageSessionsDays: 3, + ); + + final cutoffs = policy.cutoffsAt(DateTime.utc(2026, 10, 7, 12)); + + expect(cutoffs.alerts, DateTime.utc(2026, 10, 6, 12)); + expect(cutoffs.visits, DateTime.utc(2026, 10, 5, 12)); + expect(cutoffs.triageSessions, DateTime.utc(2026, 10, 4, 12)); + }); + }); + + group('nextDailyRun', () { + test('antes das 03:00 UTC: a janela de hoje', () { + expect( + nextDailyRun(DateTime.utc(2026, 10, 7, 2, 59)), + DateTime.utc(2026, 10, 7, 3), + ); + }); + + test('exatamente às 03:00 UTC: a janela de amanhã (não reprocessa agora)', () { + expect( + nextDailyRun(DateTime.utc(2026, 10, 7, 3)), + DateTime.utc(2026, 10, 8, 3), + ); + }); + + test('depois das 03:00 UTC: a janela de amanhã', () { + expect( + nextDailyRun(DateTime.utc(2026, 10, 7, 23)), + DateTime.utc(2026, 10, 8, 3), + ); + }); + + test('virada de mês e de ano acontece naturalmente', () { + expect( + nextDailyRun(DateTime.utc(2026, 12, 31, 4)), + DateTime.utc(2027, 1, 1, 3), + ); + }); + }); + + group('ExpurgeService', () { + test('passa os cortes da política ao store e devolve o resultado', () async { + final store = _FakeRetentionStore(_counts); + final service = ExpurgeService( + store: store, + policy: const RetentionPolicy( + alertsDays: 730, + visitsDays: 1825, + triageSessionsDays: 1825, + ), + clock: () => DateTime.utc(2026, 10, 7, 12), + ); + + final result = await service.run(); + + expect(store.received?.alerts, DateTime.utc(2024, 10, 7, 12)); + expect(result.counts, same(_counts)); + expect(result.ranAtUtc, DateTime.utc(2026, 10, 7, 12)); + }); + }); + + group('ExpurgeResult.describe', () { + test('devolve contagens e cortes, sem dado de paciente', () { + final policy = const RetentionPolicy( + alertsDays: 730, + visitsDays: 1825, + triageSessionsDays: 1825, + ); + final result = ExpurgeResult( + ranAtUtc: DateTime.utc(2026, 10, 7, 3), + cutoffs: policy.cutoffsAt(DateTime.utc(2026, 10, 7, 3)), + counts: _counts, + ); + + final line = result.describe(); + + expect(line, contains('alertas=3')); + expect(line, contains('risco: red=2, yellow=1')); + expect(line, contains('entregas=2')); + expect(line, contains('idempotencia=1')); + expect(line, contains('outbox=2')); + expect(line, contains('visitas=4')); + expect(line, contains('triagens=5')); + expect(line, contains('cortes:')); + }); + + test('aviso de retidos só existe com vermelho pendente retido, sem identidade', () { + final cutoffs = const RetentionPolicy( + alertsDays: 730, + visitsDays: 1825, + triageSessionsDays: 1825, + ).cutoffsAt(DateTime.utc(2026, 10, 7, 3)); + ExpurgeResult com(int n) => ExpurgeResult( + ranAtUtc: DateTime.utc(2026, 10, 7, 3), + cutoffs: cutoffs, + counts: ExpurgeCounts( + alerts: 0, + alertDeliveries: 0, + alertIdempotencyKeys: 0, + alertOutbox: 0, + visits: 0, + triageSessions: 0, + alertsByRisk: const {}, + skippedPendingRed: n, + ), + ); + + expect(com(0).describeRetained(), isNull); + expect( + com(3).describeRetained(), + 'Expurgo: 3 alerta(s) vermelho(s) pendente(s) retido(s)', + ); + }); + }); +} diff --git a/backend/sinalacs_server/test/unit/fixtures/gorush_invalid_token.json b/backend/sinalacs_server/test/unit/fixtures/gorush_invalid_token.json new file mode 100644 index 00000000..4e6d3853 --- /dev/null +++ b/backend/sinalacs_server/test/unit/fixtures/gorush_invalid_token.json @@ -0,0 +1,16 @@ +{ + "status": 200, + "body": { + "counts": 1, + "logs": [ + { + "type": "failed-push", + "platform": "android", + "token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "message": "(message redacted)", + "error": "The registration token is not a valid FCM registration token" + } + ], + "success": "ok" + } +} \ No newline at end of file diff --git a/backend/sinalacs_server/test/unit/fixtures/gorush_invalid_token_masked.json b/backend/sinalacs_server/test/unit/fixtures/gorush_invalid_token_masked.json new file mode 100644 index 00000000..a9b81459 --- /dev/null +++ b/backend/sinalacs_server/test/unit/fixtures/gorush_invalid_token_masked.json @@ -0,0 +1,16 @@ +{ + "status": 200, + "body": { + "counts": 1, + "logs": [ + { + "type": "failed-push", + "platform": "android", + "token": "******************************************************************************************************************************************************xx", + "message": "(message redacted)", + "error": "The registration token is not a valid FCM registration token" + } + ], + "success": "ok" + } +} \ No newline at end of file diff --git a/backend/sinalacs_server/test/unit/gorush_client_test.dart b/backend/sinalacs_server/test/unit/gorush_client_test.dart new file mode 100644 index 00000000..e181ba1e --- /dev/null +++ b/backend/sinalacs_server/test/unit/gorush_client_test.dart @@ -0,0 +1,308 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:sinalacs_server/src/infrastructure/push/gorush_client.dart'; +import 'package:test/test.dart'; + +/// Gorush de mentira: um `HttpServer` local que grava o que recebe e responde o +/// que o teste mandar. É o que dá para provar sem credenciais FCM/APNs. +class FakeGorush { + FakeGorush._(this._server, this.status, this.response, this.hang, this.rawBody) { + _server.listen((request) async { + requests++; + final raw = await utf8.decoder.bind(request).join(); + lastPath = request.uri.path; + lastBody = raw.isEmpty ? {} : jsonDecode(raw) as Map; + if (hang) return; + request.response + ..statusCode = status + ..headers.contentType = ContentType.json + ..write(rawBody ?? jsonEncode(response)); + await request.response.close(); + }); + } + + static Future start({ + int status = 200, + Map response = const {}, + bool hang = false, + String? rawBody, + }) async => + FakeGorush._( + await HttpServer.bind(InternetAddress.loopbackIPv4, 0), + status, + response, + hang, + rawBody, + ); + + final HttpServer _server; + final int status; + final Map response; + final bool hang; + + /// Se presente, é escrito no corpo no lugar do JSON de [response]. + final String? rawBody; + int requests = 0; + String lastPath = ''; + Map lastBody = {}; + + String get url => 'http://127.0.0.1:${_server.port}'; + + Future close() => _server.close(force: true); +} + +const _msg = PushMessage(title: 't', body: 'b'); + +/// `HttpClient` cuja conexão nunca se completa: é o que o Docker faz quando o +/// container do Gorush está parado (o nome não resolve e a chamada fica pendurada). +class _NeverConnectsHttpClient implements HttpClient { + @override + Future postUrl(Uri url) => Completer().future; + + @override + void close({bool force = false}) {} + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + test('agrupa por plataforma e usa os códigos do Gorush (1 iOS, 2 Android)', () async { + final gw = await FakeGorush.start(response: {'counts': 3, 'logs': []}); + addTearDown(gw.close); + final client = GorushClient(baseUrl: gw.url, timeout: const Duration(seconds: 2)); + + final report = await client.send( + const PushMessage(title: 'Vacina', body: 'Amanhã, na UBS.', data: {'screen': 'notices'}), + const [ + PushTarget(token: 'a1', platform: 'android'), + PushTarget(token: 'a2', platform: 'android'), + PushTarget(token: 'i1', platform: 'ios'), + ], + ); + + expect(gw.lastPath, '/api/push'); + final sent = gw.lastBody['notifications'] as List; + expect(sent, hasLength(2)); + expect(sent.firstWhere((n) => n['platform'] == 2)['tokens'], ['a1', 'a2']); + expect(sent.firstWhere((n) => n['platform'] == 1)['tokens'], ['i1']); + expect(sent.first['title'], 'Vacina'); + expect(sent.first['message'], 'Amanhã, na UBS.'); + expect(sent.first['data'], {'screen': 'notices'}); + expect(report.accepted, 3); + expect(report.invalidTokens, isEmpty); + }); + + test('tokens que o provedor recusa como inválidos voltam em invalidTokens', () async { + final gw = await FakeGorush.start(response: { + 'counts': 1, + 'logs': [ + {'type': 'failed-push', 'platform': 'android', 'token': 'a2', 'error': 'NotRegistered'}, + {'type': 'failed-push', 'platform': 'ios', 'token': 'i1', 'error': 'BadDeviceToken'}, + {'type': 'failed-push', 'platform': 'ios', 'token': 'i2', 'error': 'ServiceUnavailable'}, + ], + }); + addTearDown(gw.close); + final client = GorushClient(baseUrl: gw.url, timeout: const Duration(seconds: 2)); + + final report = await client.send(_msg, const [ + PushTarget(token: 'a2', platform: 'android'), + PushTarget(token: 'i1', platform: 'ios'), + PushTarget(token: 'i2', platform: 'ios'), + ]); + + expect(report.invalidTokens.toSet(), {'a2', 'i1'}); // erro transitório não apaga token + }); + + test('"counts" não é confiável: aceitos = alvos menos as falhas dos logs', () async { + final gw = await FakeGorush.start(response: { + 'counts': 3, + 'logs': [ + {'type': 'failed-push', 'token': 'a2', 'error': 'ServiceUnavailable'}, + ], + }); + addTearDown(gw.close); + final client = GorushClient(baseUrl: gw.url, timeout: const Duration(seconds: 2)); + final report = await client.send(_msg, const [ + PushTarget(token: 'a1', platform: 'android'), + PushTarget(token: 'a2', platform: 'android'), + PushTarget(token: 'a3', platform: 'android'), + ]); + expect(report.accepted, 2); + }); + + test('MismatchSenderId é erro de configuração do servidor e não apaga token', () async { + final gw = await FakeGorush.start(response: { + 'logs': [ + {'type': 'failed-push', 'token': 'a1', 'error': 'MismatchSenderId'}, + {'type': 'failed-push', 'token': 'a2', 'error': 'Requested entity was not found.'}, + ], + }); + addTearDown(gw.close); + final client = GorushClient(baseUrl: gw.url, timeout: const Duration(seconds: 2)); + final report = await client.send(_msg, const [ + PushTarget(token: 'a1', platform: 'android'), + PushTarget(token: 'a2', platform: 'android'), + ]); + expect(report.invalidTokens, ['a2']); + }); + + test('status 5xx vira PushGatewayException', () async { + final gw = await FakeGorush.start(status: 503); + addTearDown(gw.close); + final client = GorushClient(baseUrl: gw.url, timeout: const Duration(seconds: 2)); + await expectLater( + client.send(_msg, const [PushTarget(token: 'a', platform: 'android')]), + throwsA(isA().having((e) => e.outcomeUnknown, 'outcomeUnknown', isFalse)), + ); + }); + + test('servidor que não responde estoura o tempo limite com resultado desconhecido', () async { + final gw = await FakeGorush.start(hang: true); + addTearDown(gw.close); + final client = GorushClient(baseUrl: gw.url, timeout: const Duration(milliseconds: 300)); + await expectLater( + client.send(_msg, const [PushTarget(token: 'a', platform: 'android')]), + throwsA(isA().having((e) => e.outcomeUnknown, 'outcomeUnknown', isTrue)), + ); + }); + + test('estourar ANTES de conectar não é "resultado desconhecido": nada foi enviado', () async { + final client = GorushClient( + baseUrl: 'http://gorush:8088', + timeout: const Duration(seconds: 5), + connectTimeout: const Duration(milliseconds: 200), + httpClient: _NeverConnectsHttpClient(), + ); + final watch = Stopwatch()..start(); + + await expectLater( + client.send(_msg, const [PushTarget(token: 'a', platform: 'android')]), + throwsA(isA().having((e) => e.outcomeUnknown, 'outcomeUnknown', isFalse)), + ); + + expect(watch.elapsed, lessThan(const Duration(seconds: 2)), + reason: 'falha no tempo da conexão, não no tempo total do envio'); + }); + + test('servidor fora do ar vira PushGatewayException sem vazar o token', () async { + final gw = await FakeGorush.start(); + final url = gw.url; + await gw.close(); + final client = GorushClient(baseUrl: url, timeout: const Duration(seconds: 1)); + try { + await client.send(_msg, const [PushTarget(token: 'segredo-do-aparelho', platform: 'android')]); + fail('deveria lançar'); + } on PushGatewayException catch (e) { + expect(e.message.contains('segredo-do-aparelho'), isFalse); + } + }); + + test('sem "counts" na resposta, aceitos = alvos menos as falhas', () async { + final gw = await FakeGorush.start(response: { + 'logs': [ + {'type': 'failed-push', 'token': 'a2', 'error': 'ServiceUnavailable'}, + ], + }); + addTearDown(gw.close); + final client = GorushClient(baseUrl: gw.url, timeout: const Duration(seconds: 2)); + final report = await client.send(_msg, const [ + PushTarget(token: 'a1', platform: 'android'), + PushTarget(token: 'a2', platform: 'android'), + ]); + expect(report.accepted, 1); + }); + + test('corpo 2xx ilegível: resultado desconhecido, nunca "tente de novo"', () async { + for (final raw in ['isto não é json', '[1,2,3]']) { + final gw = await FakeGorush.start(rawBody: raw); + addTearDown(gw.close); + final client = GorushClient(baseUrl: gw.url, timeout: const Duration(seconds: 2)); + await expectLater( + client.send(_msg, const [PushTarget(token: 'a', platform: 'android')]), + throwsA(isA().having((e) => e.outcomeUnknown, 'outcomeUnknown', isTrue)), + reason: raw, + ); + } + }); + + test('"logs" que não é lista é tolerado: sem falhas relatadas', () async { + final gw = await FakeGorush.start(rawBody: '{"logs":"não-é-lista"}'); + addTearDown(gw.close); + final client = GorushClient(baseUrl: gw.url, timeout: const Duration(seconds: 2)); + final report = await client.send(_msg, const [PushTarget(token: 'a', platform: 'android')]); + expect(report.accepted, 1); + expect(report.invalidTokens, isEmpty); + }); + + test('depois de close(), send falha com PushGatewayException e não com StateError', () async { + final gw = await FakeGorush.start(response: {}); + addTearDown(gw.close); + final client = GorushClient(baseUrl: gw.url, timeout: const Duration(seconds: 2))..close(); + await expectLater( + client.send(_msg, const [PushTarget(token: 'a', platform: 'android')]), + throwsA(isA()), + ); + }); + + Map fixture(String nome) => + jsonDecode(File('test/unit/fixtures/$nome').readAsStringSync()) as Map; + + Future fakeDa(Map f) { + final body = f['body']; + return FakeGorush.start( + status: f['status'] as int, + rawBody: body is String ? body : jsonEncode(body), + ); + } + + test('resposta REAL do Gorush/FCM a um token inválido: o token é reconhecido e podado', () async { + final gw = await fakeDa(fixture('gorush_invalid_token.json')); + addTearDown(gw.close); + final client = GorushClient(baseUrl: gw.url, timeout: const Duration(seconds: 2)); + final falso = 'x' * 152; + + final report = await client.send(_msg, [ + PushTarget(token: falso, platform: 'android'), + const PushTarget(token: 'tok-bom', platform: 'android'), + ]); + + // O erro do FCM é sobre o TOKEN: só ele pode ser apagado, e nunca o bom. + expect(report.invalidTokens, [falso]); + expect(report.accepted, 1); // total 2 menos 1 falha; `counts: 1` da resposta não conta + }); + + test('resposta REAL com o token MASCARADO (hide_token padrão): nada é apagado', () async { + final gw = await fakeDa(fixture('gorush_invalid_token_masked.json')); + addTearDown(gw.close); + final client = GorushClient(baseUrl: gw.url, timeout: const Duration(seconds: 2)); + + final report = await client.send(_msg, [PushTarget(token: 'x' * 152, platform: 'android')]); + + // `***…xx` não é um token que enviamos: devolvê-lo à poda seria apagar às cegas. + expect(report.invalidTokens, isEmpty); + }); + + test('só devolve para a poda tokens que foram ENVIADOS', () async { + final gw = await FakeGorush.start(response: { + 'logs': [ + {'type': 'failed-push', 'token': 'nunca-enviado', 'error': 'NotRegistered'}, + ], + }); + addTearDown(gw.close); + final client = GorushClient(baseUrl: gw.url, timeout: const Duration(seconds: 2)); + final report = await client.send(_msg, const [PushTarget(token: 'a', platform: 'android')]); + expect(report.invalidTokens, isEmpty); + }); + + test('lista vazia não chama o Gorush', () async { + final gw = await FakeGorush.start(); + addTearDown(gw.close); + final client = GorushClient(baseUrl: gw.url, timeout: const Duration(seconds: 2)); + final report = await client.send(_msg, const []); + expect(report.accepted, 0); + expect(gw.requests, 0); + }); +} diff --git a/backend/sinalacs_server/test/unit/institutional_auth_mfa_test.dart b/backend/sinalacs_server/test/unit/institutional_auth_mfa_test.dart new file mode 100644 index 00000000..0e27f8c2 --- /dev/null +++ b/backend/sinalacs_server/test/unit/institutional_auth_mfa_test.dart @@ -0,0 +1,245 @@ +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:sinalacs_server/src/application/auth/institutional_auth_service.dart'; +import 'package:sinalacs_server/src/application/auth/totp.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/infrastructure/crypto/argon2_password_hasher.dart'; +import 'package:test/test.dart'; + +import '../support/institutional_auth_fixtures.dart'; + +const _acsId = '00000000-0000-4000-8000-000000000002'; +const _microAreaId = '00000000-0000-4000-8000-000000000003'; +const _senha = 'senha-sintetica-de-teste'; + +void main() { + final hasher = Argon2PasswordHasher(memoryKb: 512, iterations: 1, parallelism: 1); + final cofre = FakeTotpVault(); + final segredo = Uint8List.fromList(List.generate(20, (i) => i + 1)); + final t0 = DateTime.utc(2026, 10, 2, 12); + + Future<({InstitutionalAuthService servico, FakeCredentialStore store})> montar({ + bool comMfa = true, + bool exigir = false, + }) async { + final digest = await hasher.derive(_senha); + final sealed = await cofre.seal(segredo); + final store = FakeCredentialStore(AcsCredentialRecord( + acsId: _acsId, + microAreaId: _microAreaId, + active: true, + digest: digest, + failedAttempts: 0, + lockedUntil: null, + totp: comMfa ? TotpEnrollment(sealed: sealed, enabled: true, lastStep: null) : null, + )); + final servico = InstitutionalAuthService( + store: store, + hasher: hasher, + audit: RecordingAudit(), + totpStore: store, + vault: cofre, + requireMfa: exigir, + random: Random(7), + ); + return (servico: servico, store: store); + } + + group('login com MFA ativa', () { + test('senha certa sem código → MfaRequiredException, sem contar tentativa', () async { + final m = await montar(); + await expectLater( + m.servico.login(matricula: 'ACS-001', password: _senha, now: t0), + throwsA(isA()), + ); + expect(m.store.record.failedAttempts, 0); + }); + + test('senha certa + código certo → entra e grava o passo usado', () async { + final m = await montar(); + final user = await m.servico + .login(matricula: 'ACS-001', password: _senha, totpCode: Totp.code(segredo, t0), now: t0); + expect(user.id, _acsId); + expect(m.store.lastRegisteredStep, Totp.stepOf(t0)); + }); + + test('o login do ACS com MFA continua auditado no recurso session (não staff_session)', + () async { + final m = await montar(); + await expectLater( + m.servico.login(matricula: 'ACS-001', password: _senha, totpCode: '000000', now: t0), + throwsA(isA()), + ); + await m.servico + .login(matricula: 'ACS-001', password: _senha, totpCode: Totp.code(segredo, t0), now: t0); + final audit = m.servico.audit as RecordingAudit; + expect(audit.results, ['denied_totp', 'granted']); + expect(audit.resourceTypes, everyElement('session')); + }); + + test('recusa o mesmo código usado duas vezes (replay)', () async { + final m = await montar(); + final codigo = Totp.code(segredo, t0); + await m.servico.login(matricula: 'ACS-001', password: _senha, totpCode: codigo, now: t0); + + await expectLater( + m.servico.login(matricula: 'ACS-001', password: _senha, totpCode: codigo, now: t0), + throwsA(isA()), + ); + }); + + test('código do passo SEGUINTE ainda entra depois de um login (relógio adiantado)', () async { + final m = await montar(); + await m.servico.login(matricula: 'ACS-001', password: _senha, totpCode: Totp.code(segredo, t0), now: t0); + final proximo = t0.add(const Duration(seconds: 30)); + final user = await m.servico + .login(matricula: 'ACS-001', password: _senha, totpCode: Totp.code(segredo, proximo), now: proximo); + expect(user.id, _acsId); + }); + + test('código errado conta tentativa e tranca no 5º erro', () async { + final m = await montar(); + for (var i = 0; i < 4; i++) { + await expectLater( + m.servico.login(matricula: 'ACS-001', password: _senha, totpCode: '000000', now: t0), + throwsA(isA()), + ); + } + expect(m.store.record.failedAttempts, 4); + await expectLater( + m.servico.login(matricula: 'ACS-001', password: _senha, totpCode: '000000', now: t0), + throwsA(isA()), + ); + expect(m.store.record.lockedUntil, isNotNull); + }); + + test('código errado não consome o passo (o certo logo depois entra)', () async { + final m = await montar(); + await expectLater( + m.servico.login(matricula: 'ACS-001', password: _senha, totpCode: '000000', now: t0), + throwsA(isA()), + ); + expect(m.store.lastRegisteredStep, isNull); + final user = await m.servico + .login(matricula: 'ACS-001', password: _senha, totpCode: Totp.code(segredo, t0), now: t0); + expect(user.id, _acsId); + }); + + test('o store não avançou o passo (outra requisição gravou antes) → recusa', () async { + // Duas requisições com o mesmo código leram o mesmo lastStep e as duas + // passaram no verify; só a primeira avança a linha. A segunda não pode + // receber sessão (RFC 6238 §5.2: código já validado não vale de novo). + final m = await montar(); + m.store.perdeCorrida = true; + await expectLater( + m.servico.login(matricula: 'ACS-001', password: _senha, totpCode: Totp.code(segredo, t0), now: t0), + throwsA(isA()), + ); + expect(m.store.successfulLogins, 0); + expect(m.store.record.failedAttempts, 1); + }); + + test('senha errada continua dando a mensagem única, com ou sem código', () async { + final m = await montar(); + await expectLater( + m.servico.login(matricula: 'ACS-001', password: 'errada', totpCode: Totp.code(segredo, t0), now: t0), + throwsA(isA()), + ); + }); + }); + + group('login sem MFA ativa', () { + test('com REQUIRE_ACS_MFA desligado, entra só com a senha', () async { + final m = await montar(comMfa: false); + expect((await m.servico.login(matricula: 'ACS-001', password: _senha, now: t0)).id, _acsId); + }); + + test('com REQUIRE_ACS_MFA ligado, a senha certa recebe MfaEnrollmentRequiredException', () async { + final m = await montar(comMfa: false, exigir: true); + await expectLater( + m.servico.login(matricula: 'ACS-001', password: _senha, now: t0), + throwsA(isA()), + ); + }); + + test('enrollment começado e NÃO confirmado não vale: entra só com a senha', () async { + final m = await montar(comMfa: false); + await m.servico.beginTotpEnrollment(matricula: 'ACS-001', password: _senha); + expect(m.store.record.totp!.enabled, isFalse); + expect((await m.servico.login(matricula: 'ACS-001', password: _senha, now: t0)).id, _acsId); + }); + }); + + group('ativação', () { + test('begin devolve segredo e URI; confirm com o código certo ativa', () async { + final m = await montar(comMfa: false); + final inicio = await m.servico.beginTotpEnrollment(matricula: 'ACS-001', password: _senha); + expect(inicio.otpauthUri, startsWith('otpauth://totp/SinalACS:ACS-001?secret=${inicio.secretBase32}')); + + final segredoSorteado = await cofre.open(m.store.record.totp!.sealed); + await m.servico.confirmTotpEnrollment( + matricula: 'ACS-001', + password: _senha, + code: Totp.code(segredoSorteado, t0), + now: t0, + ); + expect(m.store.enabledCalls, 1); + expect(m.store.record.totp!.enabled, isTrue); + }); + + test('confirm com código errado não ativa e conta tentativa', () async { + final m = await montar(comMfa: false); + await m.servico.beginTotpEnrollment(matricula: 'ACS-001', password: _senha); + await expectLater( + m.servico.confirmTotpEnrollment(matricula: 'ACS-001', password: _senha, code: '000000', now: t0), + throwsA(isA()), + ); + expect(m.store.enabledCalls, 0); + expect(m.store.record.failedAttempts, 1); + }); + + test('confirm que perdeu a corrida (ativação já gravada por outra) é recusado', () async { + final m = await montar(comMfa: false); + await m.servico.beginTotpEnrollment(matricula: 'ACS-001', password: _senha); + final segredoSorteado = await cofre.open(m.store.record.totp!.sealed); + m.store.perdeCorrida = true; + await expectLater( + m.servico.confirmTotpEnrollment( + matricula: 'ACS-001', + password: _senha, + code: Totp.code(segredoSorteado, t0), + now: t0, + ), + throwsA(isA()), + ); + expect(m.store.enabledCalls, 0); + expect(m.store.record.failedAttempts, 1); + }); + + test('begin que perdeu a corrida para uma ativação concorrente é recusado', () async { + final m = await montar(comMfa: false); + m.store.perdeCorrida = true; + await expectLater( + m.servico.beginTotpEnrollment(matricula: 'ACS-001', password: _senha), + throwsA(isA()), + ); + }); + + test('begin com senha errada é recusado como o login (mensagem única)', () async { + final m = await montar(comMfa: false); + await expectLater( + m.servico.beginTotpEnrollment(matricula: 'ACS-001', password: 'errada'), + throwsA(isA()), + ); + }); + + test('begin com MFA já ativa é recusado (redefinir exige a coordenação)', () async { + final m = await montar(); + await expectLater( + m.servico.beginTotpEnrollment(matricula: 'ACS-001', password: _senha), + throwsA(isA()), + ); + }); + }); +} diff --git a/backend/sinalacs_server/test/unit/institutional_auth_service_test.dart b/backend/sinalacs_server/test/unit/institutional_auth_service_test.dart index 9fcb1d0b..539cac49 100644 --- a/backend/sinalacs_server/test/unit/institutional_auth_service_test.dart +++ b/backend/sinalacs_server/test/unit/institutional_auth_service_test.dart @@ -57,6 +57,9 @@ class _FakeStore implements AcsCredentialStore { fieldsWritten['restartCounter'] = restartCounter; final atual = record; if (atual != null) { + // Espelha o `CASE` de `lockStreak` do SQL: sobe só quando o bloqueio é + // aplicado; o reinício do contador preserva a escalada. + final streak = atual.lockStreak + (nextLockedUntil != null ? 1 : 0); record = AcsCredentialRecord( acsId: atual.acsId, microAreaId: atual.microAreaId, @@ -64,6 +67,7 @@ class _FakeStore implements AcsCredentialStore { digest: atual.digest, failedAttempts: next, lockedUntil: nextLockedUntil, + lockStreak: streak, ); } } @@ -71,6 +75,18 @@ class _FakeStore implements AcsCredentialStore { @override Future registerSuccessfulLogin(String acsId, DateTime at) async { fieldsWritten['resetAt'] = at; + final atual = record; + if (atual != null) { + record = AcsCredentialRecord( + acsId: atual.acsId, + microAreaId: atual.microAreaId, + active: atual.active, + digest: atual.digest, + failedAttempts: 0, + lockedUntil: null, + lockStreak: 0, + ); + } } @override @@ -141,7 +157,55 @@ void main() { ); } + group('lockDurationFor (bloqueio progressivo)', () { + test('dobra a cada rodada de bloqueio, a partir de 15 min', () { + expect(InstitutionalAuthService.lockDurationFor(0), const Duration(minutes: 15)); + expect(InstitutionalAuthService.lockDurationFor(1), const Duration(minutes: 30)); + expect(InstitutionalAuthService.lockDurationFor(2), const Duration(hours: 1)); + expect(InstitutionalAuthService.lockDurationFor(3), const Duration(hours: 2)); + }); + + test('nunca passa de 24 h, mesmo com sequência enorme', () { + expect(InstitutionalAuthService.lockDurationFor(7), const Duration(hours: 24)); + expect(InstitutionalAuthService.lockDurationFor(500), const Duration(hours: 24)); + }); + + test('sequência negativa (linha corrompida) vale como zero', () { + expect(InstitutionalAuthService.lockDurationFor(-3), const Duration(minutes: 15)); + }); + }); + group('login institucional', () { + test('a segunda rodada de bloqueio dura o dobro; o login válido zera a escalada', () async { + final service = await build(); + final store = service.store as _FakeStore; + final t0 = DateTime.utc(2026, 10, 5, 12); + for (var i = 0; i < InstitutionalAuthService.maxFailedAttempts; i++) { + await expectLater( + service.login(matricula: 'ACS-001', password: 'errada', now: t0), + throwsA(isA()), + ); + } + expect(store.record!.lockedUntil, t0.add(const Duration(minutes: 15))); + + // 16 min depois: o bloqueio venceu; mais cinco falhas bloqueiam por 30 min. + final t1 = t0.add(const Duration(minutes: 16)); + for (var i = 0; i < InstitutionalAuthService.maxFailedAttempts; i++) { + await expectLater( + service.login(matricula: 'ACS-001', password: 'errada', now: t1), + throwsA(isA()), + ); + } + expect(store.record!.lockedUntil, t1.add(const Duration(minutes: 30))); + expect(store.record!.lockStreak, 2); + + // Vencido o segundo bloqueio, a senha certa entra e zera a escalada. + final t2 = t1.add(const Duration(minutes: 31)); + await service.login(matricula: 'ACS-001', password: _senha, now: t2); + expect(store.record!.lockStreak, 0); + expect(store.record!.lockedUntil, isNull); + }); + test('aceita matrícula e senha corretas e devolve ACS territorializado', () async { final service = await build(); diff --git a/backend/sinalacs_server/test/unit/institutional_auth_staff_test.dart b/backend/sinalacs_server/test/unit/institutional_auth_staff_test.dart new file mode 100644 index 00000000..f359d58e --- /dev/null +++ b/backend/sinalacs_server/test/unit/institutional_auth_staff_test.dart @@ -0,0 +1,481 @@ +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:sinalacs_server/src/application/auth/institutional_auth_service.dart'; +import 'package:sinalacs_server/src/application/auth/password_hasher.dart'; +import 'package:sinalacs_server/src/application/auth/staff_activation_code.dart'; +import 'package:sinalacs_server/src/application/auth/totp.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/infrastructure/crypto/argon2_password_hasher.dart'; +import 'package:test/test.dart'; + +import '../support/institutional_auth_fixtures.dart'; + +/// Login institucional por audiência (#39): o mesmo serviço atende o ACS +/// (com território) e o staff do backoffice (coordenador/administrador, sem +/// território e com MFA sempre obrigatória). Dados sintéticos. +const _codigo = 'ABCD-EFGH-JKLM-NPQR-STUV-WXYZ-23'; +const _mensagemCodigo = 'Código de ativação inválido ou expirado.'; +const _staffId = '00000000-0000-4000-8000-000000000090'; +const _acsId = '00000000-0000-4000-8000-000000000002'; +const _microAreaId = '00000000-0000-4000-8000-000000000003'; +const _matricula = 'ADM-001'; +const _senha = 'senha-sintetica-de-teste'; +const _mensagemGenerica = 'Matrícula ou senha inválidos.'; + +void main() { + final hasher = Argon2PasswordHasher(memoryKb: 512, iterations: 1, parallelism: 1); + final cofre = FakeTotpVault(); + final segredo = Uint8List.fromList(List.generate(20, (i) => i + 1)); + final t0 = DateTime.utc(2026, 10, 6, 12); + + Future<({InstitutionalAuthService servico, FakeCredentialStore store, RecordingAudit audit, FakeStaffActivationStore ativacao})> + montar({ + bool comCodigo = true, + CredentialAudience audience = CredentialAudience.staff, + UserRole role = UserRole.admin, + String? microAreaId, + bool active = true, + bool comMfa = true, + bool exigir = false, + String acsId = _staffId, + }) async { + final store = FakeCredentialStore( + AcsCredentialRecord( + acsId: acsId, + microAreaId: microAreaId, + active: active, + role: role, + digest: await hasher.derive(_senha), + failedAttempts: 0, + lockedUntil: null, + totp: comMfa + ? TotpEnrollment(sealed: await cofre.seal(segredo), enabled: true, lastStep: null) + : null, + ), + matricula: _matricula, + ); + final audit = RecordingAudit(); + final ativacao = FakeStaffActivationStore(); + if (comCodigo) { + await ativacao.issue( + acsId, + codeHash: StaffActivationCode.hash(_codigo), + expiresAt: t0.add(StaffActivationCode.defaultValidity), + issuedBy: 'operador', + at: t0, + ); + } + final servico = InstitutionalAuthService( + store: store, + hasher: hasher, + audit: audit, + totpStore: store, + activationStore: ativacao, + vault: cofre, + requireMfa: exigir, + audience: audience, + random: Random(7), + ); + return (servico: servico, store: store, audit: audit, ativacao: ativacao); + } + + test('o papel do registro vale acs por omissão (os fakes antigos não mudam)', () { + const record = AcsCredentialRecord( + acsId: _acsId, + microAreaId: _microAreaId, + active: true, + digest: PasswordDigestStub.value, + failedAttempts: 0, + lockedUntil: null, + ); + expect(record.role, UserRole.acs); + }); + + group('audiência staff', () { + test('sem MFA ativa recebe MfaEnrollmentRequired mesmo com requireMfa=false', () async { + final m = await montar(comMfa: false, exigir: false); + await expectLater( + m.servico.login(matricula: _matricula, password: _senha, now: t0), + throwsA(isA()), + ); + expect(m.audit.results, contains('denied_mfa_not_enrolled')); + expect(m.store.successfulLogins, 0); + }); + + test('com MFA ativa e código válido recebe sessão sem microárea e com o papel da conta', + () async { + for (final role in [UserRole.admin, UserRole.coordinator]) { + final m = await montar(role: role); + final user = await m.servico.login( + matricula: _matricula, + password: _senha, + totpCode: Totp.code(segredo, t0), + now: t0, + ); + expect(user.id, _staffId); + expect(user.role, role); + expect(user.microAreaId, isNull); + expect(m.audit.results, contains('granted')); + } + }); + + test('eventos de login do staff gravam o recurso staff_session', () async { + final m = await montar(); + await expectLater( + m.servico.login(matricula: _matricula, password: 'outra', now: t0), + throwsA(isA()), + ); + await m.servico.login( + matricula: _matricula, + password: _senha, + totpCode: Totp.code(segredo, t0), + now: t0, + ); + expect(m.audit.results, ['denied_credentials', 'granted']); + expect(m.audit.resourceTypes, everyElement('staff_session')); + expect(m.audit.events.map((e) => e.actionType), everyElement('login')); + }); + + test('não exige microárea (staff não tem território)', () async { + final m = await montar(microAreaId: null); + final user = await m.servico.login( + matricula: _matricula, + password: _senha, + totpCode: Totp.code(segredo, t0), + now: t0, + ); + expect(user.microAreaId, isNull); + expect(m.audit.results, isNot(contains('denied_no_territory'))); + }); + + test('mesmo com microárea na linha, a sessão do staff sai sem microárea', () async { + final m = await montar(microAreaId: _microAreaId); + final user = await m.servico.login( + matricula: _matricula, + password: _senha, + totpCode: Totp.code(segredo, t0), + now: t0, + ); + expect(user.microAreaId, isNull); + }); + + test('conta cujo papel não é de staff é recusada com a mensagem genérica', () async { + for (final role in [UserRole.acs, UserRole.patient]) { + final m = await montar(role: role); + await expectLater( + m.servico.login( + matricula: _matricula, + password: _senha, + totpCode: Totp.code(segredo, t0), + now: t0, + ), + throwsA(isA() + .having((e) => e.message, 'message', _mensagemGenerica)), + ); + expect(m.audit.results, contains('denied_role')); + expect(m.store.successfulLogins, 0); + } + }); + + test('papel errado com senha errada não revela o papel (só denied_credentials)', () async { + final m = await montar(role: UserRole.acs); + await expectLater( + m.servico.login(matricula: _matricula, password: 'errada', now: t0), + throwsA(isA() + .having((e) => e.message, 'message', _mensagemGenerica)), + ); + expect(m.audit.results, ['denied_credentials']); + expect(m.store.record.failedAttempts, 1); + }); + + test('conta de staff inativa é recusada', () async { + final m = await montar(active: false); + await expectLater( + m.servico.login( + matricula: _matricula, + password: _senha, + totpCode: Totp.code(segredo, t0), + now: t0, + ), + throwsA(isA()), + ); + expect(m.audit.results, contains('denied_inactive')); + expect(m.store.successfulLogins, 0); + }); + + test('código TOTP errado conta tentativa também para o staff', () async { + final m = await montar(); + await expectLater( + m.servico.login(matricula: _matricula, password: _senha, totpCode: '000000', now: t0), + throwsA(isA()), + ); + expect(m.store.record.failedAttempts, 1); + expect(m.audit.results, contains('denied_totp')); + }); + + test('a ativação da MFA também recusa papel que não é de staff', () async { + final m = await montar(role: UserRole.acs, comMfa: false); + await expectLater( + m.servico.beginTotpEnrollment(matricula: _matricula, password: _senha, now: t0), + throwsA(isA() + .having((e) => e.message, 'message', _mensagemGenerica)), + ); + expect(m.store.record.totp, isNull); + }); + + test('a ativação da MFA do staff não exige microárea', () async { + final m = await montar(comMfa: false); + final inicio = + await m.servico.beginTotpEnrollment( + matricula: _matricula, password: _senha, activationCode: _codigo, now: t0); + expect(inicio.secretBase32, isNotEmpty); + final segredoSorteado = await cofre.open(m.store.record.totp!.sealed); + await m.servico.confirmTotpEnrollment( + matricula: _matricula, + password: _senha, + activationCode: _codigo, + code: Totp.code(segredoSorteado, t0), + now: t0, + ); + expect(m.store.record.totp!.enabled, isTrue); + }); + }); + + group('código de ativação do staff (#48)', () { + Matcher recusa() => throwsA(isA() + .having((e) => e.message, 'message', _mensagemCodigo)); + + test('begin sem código é recusado com a mensagem única e conta falha', () async { + final m = await montar(comMfa: false); + await expectLater( + m.servico.beginTotpEnrollment(matricula: _matricula, password: _senha, now: t0), + recusa(), + ); + expect(m.store.record.failedAttempts, 1); + expect(m.store.record.totp, isNull); + expect(m.audit.results, contains('denied_staff_activation_code')); + }); + + test('begin com código errado é recusado e não grava segredo', () async { + final m = await montar(comMfa: false); + await expectLater( + m.servico.beginTotpEnrollment( + matricula: _matricula, password: _senha, activationCode: 'ZZZZ-ZZZZ', now: t0), + recusa(), + ); + expect(m.store.record.totp, isNull); + }); + + test('begin com código expirado (1 s depois) é recusado', () async { + final m = await montar(comMfa: false); + await expectLater( + m.servico.beginTotpEnrollment( + matricula: _matricula, + password: _senha, + activationCode: _codigo, + now: t0.add(StaffActivationCode.defaultValidity).add(const Duration(seconds: 1)), + ), + recusa(), + ); + }); + + test('begin sem código emitido (nunca emitido ou já consumido) é recusado', () async { + final m = await montar(comMfa: false, comCodigo: false); + await expectLater( + m.servico.beginTotpEnrollment( + matricula: _matricula, password: _senha, activationCode: _codigo, now: t0), + recusa(), + ); + }); + + test('begin com o código certo devolve segredo; repetir com o mesmo código também', () async { + final m = await montar(comMfa: false); + final a = await m.servico.beginTotpEnrollment( + matricula: _matricula, password: _senha, activationCode: _codigo, now: t0); + final b = await m.servico.beginTotpEnrollment( + matricula: _matricula, password: _senha, activationCode: _codigo, now: t0); + expect(a.secretBase32, isNotEmpty); + expect(b.secretBase32, isNotEmpty); + }); + + test('código com minúsculas, espaços e hífens vale como o original', () async { + final m = await montar(comMfa: false); + final inicio = await m.servico.beginTotpEnrollment( + matricula: _matricula, + password: _senha, + activationCode: _codigo.toLowerCase().replaceAll('-', ' '), + now: t0, + ); + expect(inicio.secretBase32, isNotEmpty); + }); + + test('confirm sem o código é recusado, mesmo com o TOTP certo', () async { + final m = await montar(comMfa: false); + await m.servico.beginTotpEnrollment( + matricula: _matricula, password: _senha, activationCode: _codigo, now: t0); + final segredoSorteado = await cofre.open(m.store.record.totp!.sealed); + await expectLater( + m.servico.confirmTotpEnrollment( + matricula: _matricula, + password: _senha, + code: Totp.code(segredoSorteado, t0), + now: t0, + ), + recusa(), + ); + expect(m.store.record.totp!.enabled, isFalse); + }); + + test('confirm com código e TOTP certos ativa a MFA e apaga o código', () async { + final m = await montar(comMfa: false); + await m.servico.beginTotpEnrollment( + matricula: _matricula, password: _senha, activationCode: _codigo, now: t0); + final segredoSorteado = await cofre.open(m.store.record.totp!.sealed); + await m.servico.confirmTotpEnrollment( + matricula: _matricula, + password: _senha, + activationCode: _codigo, + code: Totp.code(segredoSorteado, t0), + now: t0, + ); + expect(m.store.record.totp!.enabled, isTrue); + expect(await m.ativacao.find(_staffId), isNull); + }); + + test('depois da ativação o código não serve: a conta já tem MFA', () async { + final m = await montar(comMfa: false); + await m.servico.beginTotpEnrollment( + matricula: _matricula, password: _senha, activationCode: _codigo, now: t0); + final segredoSorteado = await cofre.open(m.store.record.totp!.sealed); + await m.servico.confirmTotpEnrollment( + matricula: _matricula, + password: _senha, + activationCode: _codigo, + code: Totp.code(segredoSorteado, t0), + now: t0, + ); + await expectLater( + m.servico.beginTotpEnrollment( + matricula: _matricula, password: _senha, activationCode: _codigo, now: t0), + throwsA(isA() + .having((e) => e.message, 'message', contains('já está ativa'))), + ); + }); + + test('conta com MFA ativa recebe "já está ativa" mesmo com código errado', () async { + final m = await montar(); + await expectLater( + m.servico.beginTotpEnrollment( + matricula: _matricula, password: _senha, activationCode: 'ERRADO', now: t0), + throwsA(isA() + .having((e) => e.message, 'message', contains('já está ativa'))), + ); + expect(m.audit.results, isNot(contains('denied_staff_activation_code'))); + }); + + test('reemitir invalida o código anterior', () async { + final m = await montar(comMfa: false); + await m.ativacao.issue( + _staffId, + codeHash: StaffActivationCode.hash('NOVO-CODIGO'), + expiresAt: t0.add(StaffActivationCode.defaultValidity), + issuedBy: 'operador-2', + at: t0, + ); + await expectLater( + m.servico.beginTotpEnrollment( + matricula: _matricula, password: _senha, activationCode: _codigo, now: t0), + recusa(), + ); + }); + + test('5 códigos errados bloqueiam a conta', () async { + final m = await montar(comMfa: false); + for (var i = 0; i < InstitutionalAuthService.maxFailedAttempts; i++) { + await expectLater( + m.servico.beginTotpEnrollment( + matricula: _matricula, password: _senha, activationCode: 'ERRADO', now: t0), + throwsA(isA()), + ); + } + expect(m.store.record.lockedUntil, isNotNull); + }); + }); + + group('audiência acs (regressão)', () { + test('continua exigindo microárea', () async { + final m = await montar( + audience: CredentialAudience.acs, + role: UserRole.acs, + microAreaId: null, + comMfa: false, + acsId: _acsId, + ); + await expectLater( + m.servico.login(matricula: _matricula, password: _senha, now: t0), + throwsA(isA()), + ); + expect(m.audit.results, contains('denied_no_territory')); + }); + + test('continua emitindo papel acs com a microárea, e sem MFA obrigatória por omissão', + () async { + final m = await montar( + audience: CredentialAudience.acs, + role: UserRole.acs, + microAreaId: _microAreaId, + comMfa: false, + acsId: _acsId, + ); + final user = await m.servico.login(matricula: _matricula, password: _senha, now: t0); + expect(user.role, UserRole.acs); + expect(user.microAreaId, _microAreaId); + }); + + test('eventos de login do ACS continuam gravando o recurso session', () async { + final m = await montar( + audience: CredentialAudience.acs, + role: UserRole.acs, + microAreaId: _microAreaId, + comMfa: false, + acsId: _acsId, + ); + await expectLater( + m.servico.login(matricula: _matricula, password: 'outra', now: t0), + throwsA(isA()), + ); + await m.servico.login(matricula: _matricula, password: _senha, now: t0); + expect(m.audit.results, ['denied_credentials', 'granted']); + expect(m.audit.resourceTypes, everyElement('session')); + }); + + test('a audiência por omissão do serviço é acs', () async { + final store = FakeCredentialStore( + AcsCredentialRecord( + acsId: _acsId, + microAreaId: null, + active: true, + digest: await hasher.derive(_senha), + failedAttempts: 0, + lockedUntil: null, + ), + matricula: _matricula, + ); + final servico = InstitutionalAuthService(store: store, hasher: hasher, audit: RecordingAudit()); + expect(servico.audience, CredentialAudience.acs); + }); + }); +} + +/// Digest constante só para o teste de construção do registro (nunca é +/// verificado contra senha nenhuma). +abstract final class PasswordDigestStub { + static const value = PasswordDigest( + hashBase64: 'AAAA', + saltBase64: 'AAAA', + memoryKb: 512, + iterations: 1, + parallelism: 1, + ); +} diff --git a/backend/sinalacs_server/test/unit/notice_dispatch_loop_test.dart b/backend/sinalacs_server/test/unit/notice_dispatch_loop_test.dart new file mode 100644 index 00000000..441e9515 --- /dev/null +++ b/backend/sinalacs_server/test/unit/notice_dispatch_loop_test.dart @@ -0,0 +1,94 @@ +import 'dart:async'; + +import 'package:sinalacs_server/src/runtime/notice_dispatch_loop.dart'; +import 'package:test/test.dart'; + +void main() { + test('o intervalo padrão é de 30 s', () { + expect(defaultNoticeDispatchInterval, const Duration(seconds: 30)); + }); + + test('a partida roda a varredura antes do primeiro ciclo', () async { + final order = []; + final loop = NoticeDispatchLoop( + startup: () async { + await Future.delayed(const Duration(milliseconds: 10)); + order.add('startup'); + }, + tick: () async { + order.add('tick'); + return 0; + }, + interval: const Duration(hours: 1), + )..start(); + await loop.runOnce(); + loop.stop(); + expect(order, ['startup', 'tick']); + }); + + test('erro no ciclo é registrado sem matar o laço', () async { + var calls = 0; + final errors = []; + final loop = NoticeDispatchLoop( + tick: () async { + calls++; + if (calls == 1) throw StateError('banco fora: texto do aviso'); + return 3; + }, + onError: errors.add, + interval: const Duration(hours: 1), + ); + expect(await loop.runOnce(), 0); + expect(await loop.runOnce(), 3); + expect(errors, hasLength(1)); + }); + + test('o timer injetado dispara ciclos e stop() o cancela', () async { + late void Function(Timer) fire; + final fake = _FakeTimer(); + var ticks = 0; + final loop = NoticeDispatchLoop( + tick: () async => ++ticks, + timerFactory: (d, cb) { + fire = cb; + return fake; + }, + )..start(); + expect(loop.isStarted, isTrue); + fire(fake); + await Future.delayed(Duration.zero); + expect(ticks, 1); + loop.stop(); + expect(fake.cancelled, isTrue); + expect(loop.isStarted, isFalse); + expect(await loop.runOnce(), 0, reason: 'parado não processa'); + }); + + test('ciclos não se sobrepõem', () async { + final gate = Completer(); + var ticks = 0; + final loop = NoticeDispatchLoop( + tick: () async { + ticks++; + await gate.future; + return 1; + }, + interval: const Duration(hours: 1), + ); + final first = loop.runOnce(); + expect(await loop.runOnce(), 0); + gate.complete(); + await first; + expect(ticks, 1); + }); +} + +class _FakeTimer implements Timer { + bool cancelled = false; + @override + void cancel() => cancelled = true; + @override + bool get isActive => !cancelled; + @override + int get tick => 0; +} diff --git a/backend/sinalacs_server/test/unit/notice_scheduling_service_test.dart b/backend/sinalacs_server/test/unit/notice_scheduling_service_test.dart new file mode 100644 index 00000000..b7dff482 --- /dev/null +++ b/backend/sinalacs_server/test/unit/notice_scheduling_service_test.dart @@ -0,0 +1,1210 @@ +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/notices/notice_scheduling_service.dart'; +import 'package:sinalacs_server/src/application/notices/notice_service.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/infrastructure/push/gorush_client.dart'; +import 'package:test/test.dart'; + +const _ma1 = '00000000-0000-4000-8000-000000000003'; +const _ma2 = '00000000-0000-4000-8000-000000000004'; +const _maX = '00000000-0000-4000-8000-000000000005'; +const _maInexistente = '00000000-0000-4000-8000-000000000006'; + +AuthenticatedUser _user(UserRole role, {String id = 'u-1', String? microAreaId}) => + AuthenticatedUser(id: id, role: role, microAreaId: microAreaId, deviceId: 'dev-1'); + +class _RecordingAudit extends AuditTrail { + final events = []; + + @override + Future record(AuditEvent event) async => events.add(event); +} + +class _ThrowingAudit extends AuditTrail { + var calls = 0; + + @override + Future record(AuditEvent event) async { + calls++; + throw StateError('auditoria fora do ar'); + } +} + +class _OrderAudit extends AuditTrail { + _OrderAudit(this.order); + final List order; + + @override + Future record(AuditEvent event) async => order.add('audit'); +} + +/// Embrulha o fake e conta chamadas a `history`. +class _CountingStore implements ScheduledNoticeStore { + _CountingStore(this._inner, this._onHistory); + final _FakeStore _inner; + final void Function() _onHistory; + + @override + Future> history({ + String? microAreaId, + String? ubsId, + required int limit, + required int offset, + }) { + _onHistory(); + return _inner.history( + microAreaId: microAreaId, + ubsId: ubsId, + limit: limit, + offset: offset, + ); + } + + @override + Future ubsOfStaff(String userId) => _inner.ubsOfStaff(userId); + @override + Future ubsOfMicroArea(String id) => _inner.ubsOfMicroArea(id); + @override + Future authorityOf(String userId) => _inner.authorityOf(userId); + @override + Future insert({ + required String createdBy, + required String microAreaId, + required String audience, + required String title, + required String message, + required DateTime scheduledFor, + required DateTime createdAt, + }) => _inner.insert( + createdBy: createdBy, + microAreaId: microAreaId, + audience: audience, + title: title, + message: message, + scheduledFor: scheduledFor, + createdAt: createdAt, + ); + @override + Future findById(String id) => _inner.findById(id); + @override + Future cancelIfPending(String id) => _inner.cancelIfPending(id); + @override + Future> claimDue( + DateTime now, { + int limit = 20, + }) => _inner.claimDue(now, limit: limit); + @override + Future finish( + String id, { + required ScheduledNoticeStatus status, + int? recipients, + int? accepted, + String? failureReason, + required DateTime at, + }) => _inner.finish( + id, + status: status, + recipients: recipients, + accepted: accepted, + failureReason: failureReason, + at: at, + ); + @override + Future> markStaleSendingUnknown({ + required DateTime before, + required String reason, + required DateTime at, + }) => _inner.markStaleSendingUnknown(before: before, reason: reason, at: at); +} + +Future _insertNotice(_FakeStore s) => s.insert( + createdBy: 'u', + microAreaId: _ma1, + audience: 'everyone', + title: 'T', + message: 'M', + scheduledFor: DateTime.utc(2030), + createdAt: DateTime.utc(2026), +); + +class _RecipientStore implements NoticeRecipientStore { + List targets = const [PushTarget(token: 'tok', platform: 'android')]; + + @override + Future> consentedTargets({ + required String microAreaId, + required bool chronicOnly, + }) async => + targets; + + @override + Future deleteTokens(List tokens) async => tokens.length; +} + +class _Sender implements PushSender { + PushSendReport report = const PushSendReport(accepted: 1, invalidTokens: []); + PushGatewayException? failure; + + @override + Future send(PushMessage message, List targets) async { + final f = failure; + if (f != null) throw f; + return report; + } +} + +class _FakeStore implements ScheduledNoticeStore { + final ubsOfStaffMap = {}; + final ubsOfMicroAreaMap = {}; + /// Autoridade do autor no disparo. Sem entrada: admin ativo, para os testes + /// antigos de `dispatchDue` (que agendam como `adm-1`) continuarem a enviar. + final authorityMap = {}; + var authorityThrows = false; + + @override + Future authorityOf(String userId) async { + if (authorityThrows) throw StateError('banco fora do ar'); + return authorityMap.containsKey(userId) + ? authorityMap[userId] + : const StaffAuthority(role: UserRole.admin, active: true); + } + final rows = {}; + var _seq = 0; + + @override + Future ubsOfStaff(String userId) async => ubsOfStaffMap[userId]; + + @override + Future ubsOfMicroArea(String microAreaId) async => ubsOfMicroAreaMap[microAreaId]; + + @override + Future insert({ + required String createdBy, + required String microAreaId, + required String audience, + required String title, + required String message, + required DateTime scheduledFor, + required DateTime createdAt, + }) async { + final id = '00000000-0000-4000-8000-${(++_seq).toString().padLeft(12, '0')}'; + rows[id] = ScheduledNoticeSnapshot( + id: id, + createdBy: createdBy, + microAreaId: microAreaId, + audience: audience, + title: title, + message: message, + scheduledFor: scheduledFor, + status: ScheduledNoticeStatus.pending, + createdAt: createdAt, + ); + return id; + } + + @override + Future> markStaleSendingUnknown({ + required DateTime before, + required String reason, + required DateTime at, + }) async { + final moved = []; + for (final r in rows.values.toList()) { + if (r.status == ScheduledNoticeStatus.sending && r.scheduledFor.isBefore(before)) { + await finish(r.id, status: ScheduledNoticeStatus.unknown, failureReason: reason, at: at); + moved.add(StuckNotice(id: r.id, createdBy: r.createdBy)); + } + } + return moved; + } + + @override + Future findById(String id) async => rows[id]; + + @override + Future cancelIfPending(String id) async { + final row = rows[id]; + if (row == null || row.status != ScheduledNoticeStatus.pending) return false; + rows[id] = _copy(row, status: ScheduledNoticeStatus.cancelled); + return true; + } + + @override + Future> claimDue(DateTime now, {int limit = 20}) async { + final due = rows.values + .where((r) => r.status == ScheduledNoticeStatus.pending && !r.scheduledFor.isAfter(now)) + .take(limit) + .toList(); + for (final r in due) { + rows[r.id] = _copy(r, status: ScheduledNoticeStatus.sending); + } + return due; + } + + @override + Future finish( + String id, { + required ScheduledNoticeStatus status, + int? recipients, + int? accepted, + String? failureReason, + required DateTime at, + }) async { + final r = rows[id]!; + rows[id] = ScheduledNoticeSnapshot( + id: r.id, + createdBy: r.createdBy, + microAreaId: r.microAreaId, + audience: r.audience, + title: r.title, + message: r.message, + scheduledFor: r.scheduledFor, + status: status, + createdAt: r.createdAt, + recipients: recipients, + accepted: accepted, + failureReason: failureReason, + sentAt: status == ScheduledNoticeStatus.sent ? at : null, + ); + } + + @override + Future> history({ + String? microAreaId, + String? ubsId, + required int limit, + required int offset, + }) async { + lastHistoryUbsId = ubsId; + lastHistoryLimit = limit; + lastHistoryOffset = offset; + return rows.values + .where((r) => microAreaId == null || r.microAreaId == microAreaId) + .where((r) => ubsId == null || ubsOfMicroAreaMap[r.microAreaId] == ubsId) + .toList(); + } + + String? lastHistoryUbsId; + int? lastHistoryLimit; + int? lastHistoryOffset; + + static ScheduledNoticeSnapshot _copy(ScheduledNoticeSnapshot r, {required ScheduledNoticeStatus status}) => + ScheduledNoticeSnapshot( + id: r.id, + createdBy: r.createdBy, + microAreaId: r.microAreaId, + audience: r.audience, + title: r.title, + message: r.message, + scheduledFor: r.scheduledFor, + status: status, + createdAt: r.createdAt, + ); +} + +void main() { + final now = DateTime.utc(2026, 10, 14, 12); + late _FakeStore store; + late _RecordingAudit audit; + late NoticeSchedulingService service; + var sends = 0; + late Future Function(String microAreaId) onSend; + + Future schedule( + AuthenticatedUser user, { + String microAreaId = _ma1, + String audience = 'everyone', + String title = 'Título', + String message = 'Mensagem secreta', + DateTime? scheduledFor, + }) => + service.schedule( + user, + microAreaId: microAreaId, + audience: audience, + title: title, + message: message, + scheduledFor: scheduledFor ?? now.add(const Duration(hours: 2)), + ); + + setUp(() { + store = _FakeStore(); + store.ubsOfMicroAreaMap[_ma1] = 'ubs-1'; + audit = _RecordingAudit(); + sends = 0; + onSend = (_) async => const NoticeSendSnapshot(recipients: 0, accepted: 0); + service = NoticeSchedulingService( + store: store, + audit: audit, + clock: () => now, + sendNow: ({ + required microAreaId, + required audience, + required title, + required message, + required actorId, + auditResourceId, + }) async { + sends++; + return onSend(microAreaId); + }, + ); + }); + + test('paciente e ACS não agendam: erro e linha denied_role', () async { + for (final role in [UserRole.patient, UserRole.acs]) { + await expectLater( + schedule(_user(role, microAreaId: _ma1)), + throwsA(isA()), + ); + expect(audit.events.last.result, 'denied_role'); + expect(audit.events.last.resourceType, 'community_notice'); + } + expect(store.rows, isEmpty); + expect(sends, 0); + }); + + test('coordenador não agenda para microárea de outra UBS: denied_territory', () async { + store.ubsOfStaffMap['c-1'] = 'ubs-1'; + store.ubsOfMicroAreaMap[_maX] = 'ubs-2'; + await expectLater( + schedule(_user(UserRole.coordinator, id: 'c-1'), microAreaId: _maX), + throwsA(isA()), + ); + expect(audit.events.last.result, 'denied_territory'); + expect(audit.events.last.resourceType, 'community_notice'); + expect(store.rows, isEmpty); + }); + + test('coordenador sem UBS é recusado (fail-closed)', () async { + await expectLater( + schedule(_user(UserRole.coordinator, id: 'c-sem-ubs')), + throwsA(isA()), + ); + expect(audit.events.last.result, 'denied_territory'); + // Ambos nulos: só a checagem `own == null` não basta para passar. + store.ubsOfMicroAreaMap[_ma1] = null; + await expectLater( + schedule(_user(UserRole.coordinator, id: 'c-sem-ubs')), + throwsA(isA()), + ); + expect(audit.events.last.result, 'denied_territory'); + }); + + test('admin com microárea inexistente: entrada inválida, sem auditoria', () async { + await expectLater( + schedule(_user(UserRole.admin), microAreaId: _maInexistente), + throwsA(isA()), + ); + expect(audit.events, isEmpty); + expect(store.rows, isEmpty); + }); + + test('datas gravadas no store em UTC', () async { + final local = DateTime(2026, 10, 14, 15); + final id = await service.schedule( + _user(UserRole.admin), + microAreaId: _ma1, + audience: 'everyone', + title: 'T', + message: 'M', + scheduledFor: local.add(const Duration(days: 1)), + ); + expect(store.rows[id]!.scheduledFor.isUtc, isTrue); + expect(store.rows[id]!.createdAt.isUtc, isTrue); + }); + + test('coordenador agenda na própria UBS', () async { + store.ubsOfStaffMap['c-1'] = 'ubs-1'; + store.ubsOfMicroAreaMap[_ma1] = 'ubs-1'; + final id = await service.schedule( + _user(UserRole.coordinator, id: 'c-1'), + microAreaId: _ma1, + audience: 'everyone', + title: 'T', + message: 'M', + scheduledFor: now.add(const Duration(hours: 1)), + ); + expect(store.rows[id]!.status, ScheduledNoticeStatus.pending); + expect(store.rows[id]!.createdBy, 'c-1'); + expect(audit.events.last.result, 'scheduled'); + }); + + test('agendar no passado ou a menos de 1 minuto recusa', () async { + final admin = _user(UserRole.admin); + for (final delta in [ + Duration.zero, + const Duration(hours: -1), + const Duration(seconds: 59), + ]) { + await expectLater( + schedule(admin, scheduledFor: now.add(delta)), + throwsA(isA()), + ); + } + expect(store.rows, isEmpty); + }); + + test('exatamente 1 minuto à frente é aceito', () async { + final id = await service.schedule( + _user(UserRole.admin), + microAreaId: _ma1, + audience: 'everyone', + title: 'T', + message: 'M', + scheduledFor: now.add(const Duration(minutes: 1)), + ); + expect(store.rows.containsKey(id), isTrue); + }); + + test('título, mensagem e público inválidos recusam', () async { + final admin = _user(UserRole.admin); + final casos = >[ + schedule(admin, title: ' '), + schedule(admin, message: ''), + schedule(admin, title: 'x' * (noticeTitleMaxLength + 1)), + schedule(admin, message: 'x' * (noticeMessageMaxLength + 1)), + schedule(admin, audience: 'todos'), + ]; + for (final caso in casos) { + await expectLater(caso, throwsA(isA())); + } + expect(store.rows, isEmpty); + }); + + test('admin agenda: grava pending e audita scheduled com resourceId = id, sem o texto', () async { + final id = await service.schedule( + _user(UserRole.admin), + microAreaId: _ma1, + audience: 'chronic', + title: 'TituloUnico', + message: 'MensagemUnica', + scheduledFor: now.add(const Duration(hours: 2)), + ); + expect(store.rows[id]!.status, ScheduledNoticeStatus.pending); + expect(store.rows[id]!.microAreaId, _ma1); + final event = audit.events.last; + expect(event.result, 'scheduled'); + expect(event.actionType, 'write'); + expect(event.resourceType, 'community_notice'); + expect(event.resourceId, id); + // AuditEvent não sobrescreve toString: monta-se o dump pelos campos. + final dump = audit.events + .map((e) => '${e.userId}|${e.actionType}|${e.resourceType}|${e.resourceId}|${e.result}') + .join('\n'); + expect(dump, isNot(contains('TituloUnico'))); + expect(dump, isNot(contains('MensagemUnica'))); + expect(sends, 0); + }); + + group('cancel', () { + Future pendente() => service.schedule( + _user(UserRole.admin), + microAreaId: _ma1, + audience: 'everyone', + title: 'T', + message: 'MensagemSecreta', + scheduledFor: now.add(const Duration(hours: 2)), + ); + + test('admin cancela aviso pending e audita cancelled com resourceId = id', () async { + final id = await pendente(); + await service.cancel(_user(UserRole.admin), id); + expect(store.rows[id]!.status, ScheduledNoticeStatus.cancelled); + final e = audit.events.last; + expect(e.result, 'cancelled'); + expect(e.resourceType, 'community_notice'); + expect(e.resourceId, id); + }); + + test('coordenador cancela aviso da própria UBS', () async { + final id = await pendente(); + store.ubsOfStaffMap['c-1'] = 'ubs-1'; + await service.cancel(_user(UserRole.coordinator, id: 'c-1'), id); + expect(store.rows[id]!.status, ScheduledNoticeStatus.cancelled); + }); + + test('aviso que não está pending recusa', () async { + final id = await pendente(); + store.rows[id] = _FakeStore._copy(store.rows[id]!, status: ScheduledNoticeStatus.sent); + await expectLater( + service.cancel(_user(UserRole.admin), id), + throwsA(isA()), + ); + expect(store.rows[id]!.status, ScheduledNoticeStatus.sent); + }); + + test('aviso inexistente recusa', () async { + await expectLater( + service.cancel(_user(UserRole.admin), '00000000-0000-4000-8000-0000000000ff'), + throwsA(isA()), + ); + }); + + test('paciente e ACS: denied_role auditado', () async { + final id = await pendente(); + for (final role in [UserRole.patient, UserRole.acs]) { + await expectLater( + service.cancel(_user(role, microAreaId: _ma1), id), + throwsA(isA()), + ); + expect(audit.events.last.result, 'denied_role'); + expect(audit.events.last.resourceType, 'community_notice'); + } + expect(store.rows[id]!.status, ScheduledNoticeStatus.pending); + }); + + test('coordenador de outra UBS e sem UBS: denied_territory, nada cancelado', () async { + final id = await pendente(); + store.ubsOfStaffMap['c-2'] = 'ubs-2'; + for (final uid in ['c-2', 'c-sem-ubs']) { + await expectLater( + service.cancel(_user(UserRole.coordinator, id: uid), id), + throwsA(isA()), + ); + expect(audit.events.last.result, 'denied_territory'); + } + expect(store.rows[id]!.status, ScheduledNoticeStatus.pending); + }); + }); + + group('history', () { + setUp(() { + store.ubsOfMicroAreaMap[_ma2] = 'ubs-2'; + }); + + Future seed() async { + for (final ma in [_ma1, _ma2]) { + await service.schedule( + _user(UserRole.admin), + microAreaId: ma, + audience: 'everyone', + title: 'Titulo-$ma', + message: 'MensagemSecreta-$ma', + scheduledFor: now.add(const Duration(hours: 2)), + ); + } + } + + test('coordenador só vê a própria UBS; admin vê todas', () async { + await seed(); + store.ubsOfStaffMap['c-1'] = 'ubs-1'; + final doCoord = await service.history(_user(UserRole.coordinator, id: 'c-1')); + expect(doCoord.map((i) => i.microAreaId), [_ma1]); + expect(store.lastHistoryUbsId, 'ubs-1'); + final doAdmin = await service.history(_user(UserRole.admin)); + expect(doAdmin, hasLength(2)); + expect(store.lastHistoryUbsId, isNull); + }); + + test('paciente e ACS: denied_role auditado', () async { + for (final role in [UserRole.patient, UserRole.acs]) { + await expectLater( + service.history(_user(role, microAreaId: _ma1)), + throwsA(isA()), + ); + expect(audit.events.last.result, 'denied_role'); + } + }); + + test('coordenador sem UBS ou filtrando microárea de outra UBS: denied_territory', () async { + await seed(); + await expectLater( + service.history(_user(UserRole.coordinator, id: 'c-sem-ubs')), + throwsA(isA()), + ); + expect(audit.events.last.result, 'denied_territory'); + store.ubsOfStaffMap['c-1'] = 'ubs-1'; + await expectLater( + service.history(_user(UserRole.coordinator, id: 'c-1'), microAreaId: _ma2), + throwsA(isA()), + ); + expect(audit.events.last.result, 'denied_territory'); + }); + + test('filtro por microárea própria funciona', () async { + await seed(); + final r = await service.history(_user(UserRole.admin), microAreaId: _ma2); + expect(r.map((i) => i.microAreaId), [_ma2]); + }); + + test('o item não carrega a mensagem e a leitura é auditada sem texto', () async { + await seed(); + final r = await service.history(_user(UserRole.admin)); + final item = r.first; + expect(item.title, startsWith('Titulo-')); + expect(item.status, ScheduledNoticeStatus.pending); + expect(item.createdBy, 'u-1'); + expect(item.toString(), isNot(contains('MensagemSecreta'))); + final e = audit.events.last; + expect(e.actionType, 'read'); + expect(e.resourceType, 'community_notice'); + expect(e.result, 'granted'); + final dump = audit.events + .map((e) => '${e.userId}|${e.actionType}|${e.resourceType}|${e.resourceId}|${e.result}') + .join('\n'); + expect(dump, isNot(contains('MensagemSecreta'))); + }); + + test('limit é limitado a 1..100 (padrão 50) e offset a >= 0', () async { + final admin = _user(UserRole.admin); + await service.history(admin); + expect(store.lastHistoryLimit, 50); + await service.history(admin, limit: 0); + expect(store.lastHistoryLimit, 1); + await service.history(admin, limit: 1000); + expect(store.lastHistoryLimit, 100); + await service.history(admin, limit: 20, offset: -5); + expect(store.lastHistoryLimit, 20); + expect(store.lastHistoryOffset, 0); + }); + }); + + group('dispatchDue', () { + final admin = _user(UserRole.admin, id: 'adm-1'); + Future due({String title = 'Titulo', String message = 'TextoSecretoDoAviso'}) async { + final id = await store.insert( + createdBy: 'adm-1', + microAreaId: _ma1, + audience: 'everyone', + title: title, + message: message, + scheduledFor: now.subtract(const Duration(minutes: 5)), + createdAt: now.subtract(const Duration(hours: 1)), + ); + return id; + } + + String dump() => audit.events + .map((e) => '${e.userId}|${e.actionType}|${e.resourceType}|${e.resourceId}|${e.result}') + .join('\n'); + + test('2 vencidos enviam 2 e viram sent com contagens', () async { + final a = await due(); + final b = await due(); + onSend = (_) async => const NoticeSendSnapshot(recipients: 5, accepted: 4); + expect(await service.dispatchDue(), 2); + expect(sends, 2); + for (final id in [a, b]) { + final r = store.rows[id]!; + expect(r.status, ScheduledNoticeStatus.sent); + expect(r.recipients, 5); + expect(r.accepted, 4); + expect(r.sentAt, now); + } + }); + + test('segunda chamada não reenvia', () async { + await due(); + expect(await service.dispatchDue(), 1); + expect(await service.dispatchDue(), 0); + expect(sends, 1); + }); + + test('futuro não é disparado', () async { + await service.schedule(admin, + microAreaId: _ma1, + audience: 'everyone', + title: 'T', + message: 'M', + scheduledFor: now.add(const Duration(hours: 1))); + expect(await service.dispatchDue(), 0); + expect(sends, 0); + }); + + test('NoticeDeliveryException simples vira failed e não repete', () async { + final id = await due(); + onSend = (_) async => throw NoticeDeliveryException(message: 'Gorush fora'); + expect(await service.dispatchDue(), 1); + expect(store.rows[id]!.status, ScheduledNoticeStatus.failed); + expect(store.rows[id]!.failureReason, 'Gorush fora'); + await service.dispatchDue(); + expect(sends, 1); + final e = audit.events.last; + expect(e.result, 'failed'); + expect(e.resourceType, 'community_notice'); + expect(e.resourceId, id); + expect(e.userId, 'adm-1'); + }); + + test('resultado desconhecido vira unknown e não repete', () async { + final id = await due(); + onSend = (_) async => throw NoticeDeliveryException(message: noticeOutcomeUnknownMessage); + await service.dispatchDue(); + expect(store.rows[id]!.status, ScheduledNoticeStatus.unknown); + await service.dispatchDue(); + expect(sends, 1); + }); + + test('erro inesperado de um aviso não impede o seguinte', () async { + final a = await due(); + final b = await due(); + var n = 0; + onSend = (_) async { + if (++n == 1) throw StateError('boom TextoSecretoDoAviso'); + return const NoticeSendSnapshot(recipients: 1, accepted: 1); + }; + expect(await service.dispatchDue(), 2); + expect(store.rows[a]!.status, ScheduledNoticeStatus.failed); + expect(store.rows[a]!.failureReason, isNot(contains('boom'))); + expect(store.rows[b]!.status, ScheduledNoticeStatus.sent); + }); + + test('auditoria de failed usa createdBy e o id do aviso, sem texto', () async { + final id = await due(); + onSend = (_) async => throw StateError('x'); + await service.dispatchDue(); + expect(audit.events, hasLength(1)); + expect(audit.events.single.resourceId, id); + expect(audit.events.single.userId, 'adm-1'); + expect(dump(), isNot(contains('TextoSecretoDoAviso'))); + }); + + group('autoridade de quem agendou no disparo', () { + Future dueBy(String author) => store.insert( + createdBy: author, + microAreaId: _ma1, + audience: 'everyone', + title: 'Titulo', + message: 'TextoSecretoDoAviso', + scheduledFor: now.subtract(const Duration(minutes: 5)), + createdAt: now.subtract(const Duration(hours: 1)), + ); + + Future expectRefused(String id, String author) async { + expect(await service.dispatchDue(), 1); + expect(sends, 0, reason: 'o aviso não pode sair'); + final r = store.rows[id]!; + expect(r.status, ScheduledNoticeStatus.failed); + expect(r.failureReason, noticeAuthorRevokedReason); + final e = audit.events.last; + expect(e.result, 'denied_author_revoked'); + expect(e.resourceType, 'community_notice'); + expect(e.resourceId, id); + expect(e.userId, author); + expect(dump(), isNot(contains('TextoSecretoDoAviso'))); + } + + test('coordenador desativado depois de agendar: não envia', () async { + store.authorityMap['c-1'] = + const StaffAuthority(role: UserRole.coordinator, active: false, ubsId: 'ubs-1'); + await expectRefused(await dueBy('c-1'), 'c-1'); + }); + + test('coordenador trocado de UBS depois de agendar: não envia', () async { + store.authorityMap['c-1'] = + const StaffAuthority(role: UserRole.coordinator, active: true, ubsId: 'ubs-2'); + await expectRefused(await dueBy('c-1'), 'c-1'); + }); + + test('coordenador sem UBS: não envia (fail-closed)', () async { + store.authorityMap['c-1'] = const StaffAuthority(role: UserRole.coordinator, active: true); + await expectRefused(await dueBy('c-1'), 'c-1'); + }); + + test('autor sem conta de staff: não envia', () async { + store.authorityMap['x-1'] = null; + await expectRefused(await dueBy('x-1'), 'x-1'); + }); + + test('autor rebaixado a ACS: não envia', () async { + store.authorityMap['c-1'] = + const StaffAuthority(role: UserRole.acs, active: true, ubsId: 'ubs-1'); + await expectRefused(await dueBy('c-1'), 'c-1'); + }); + + test('admin desativado: não envia', () async { + store.authorityMap['adm-1'] = const StaffAuthority(role: UserRole.admin, active: false); + await expectRefused(await dueBy('adm-1'), 'adm-1'); + }); + + test('coordenador ativo da mesma UBS: envia', () async { + store.authorityMap['c-1'] = + const StaffAuthority(role: UserRole.coordinator, active: true, ubsId: 'ubs-1'); + final id = await dueBy('c-1'); + expect(await service.dispatchDue(), 1); + expect(sends, 1); + expect(store.rows[id]!.status, ScheduledNoticeStatus.sent); + }); + + test('consulta de autoridade que lança: failed, sem envio, laço segue', () async { + store.authorityThrows = true; + final a = await dueBy('adm-1'); + final b = await dueBy('adm-1'); + expect(await service.dispatchDue(), 2); + expect(sends, 0); + for (final id in [a, b]) { + expect(store.rows[id]!.status, ScheduledNoticeStatus.failed); + } + expect(audit.events.where((e) => e.result == 'failed'), hasLength(2)); + }); + + test('recusa de um não impede o envio do outro', () async { + store.authorityMap['c-1'] = + const StaffAuthority(role: UserRole.coordinator, active: false, ubsId: 'ubs-1'); + final recusado = await dueBy('c-1'); + final enviado = await dueBy('adm-1'); + expect(await service.dispatchDue(), 2); + expect(sends, 1); + expect(store.rows[recusado]!.status, ScheduledNoticeStatus.failed); + expect(store.rows[enviado]!.status, ScheduledNoticeStatus.sent); + }); + }); + }); + + group('recoverStuckSending (varredura de partida)', () { + Future row(Duration age, ScheduledNoticeStatus status) async { + final id = await store.insert( + createdBy: 'adm-1', + microAreaId: _ma1, + audience: 'everyone', + title: 't', + message: 'm', + scheduledFor: now.subtract(age), + createdAt: now.subtract(const Duration(days: 1)), + ); + if (status == ScheduledNoticeStatus.sending) { + await store.claimDue(now); + } + return id; + } + + test('sending há mais de 10 min vira unknown com motivo fixo, nunca pending', () async { + final old = await row(const Duration(minutes: 11), ScheduledNoticeStatus.sending); + final recent = await row(const Duration(minutes: 9), ScheduledNoticeStatus.sending); + final pending = await store.insert( + createdBy: 'adm-1', + microAreaId: _ma1, + audience: 'everyone', + title: 't', + message: 'm', + scheduledFor: now.add(const Duration(hours: 1)), + createdAt: now, + ); + expect(await service.recoverStuckSending(), 1); + expect(store.rows[old]!.status, ScheduledNoticeStatus.unknown); + expect(store.rows[old]!.failureReason, noticeStuckSendingReason); + expect(store.rows[recent]!.status, ScheduledNoticeStatus.sending); + expect(store.rows[pending]!.status, ScheduledNoticeStatus.pending); + expect(sends, 0, reason: 'a varredura nunca reenvia'); + }); + + test('grava uma linha de auditoria por aviso movido, sem o texto', () async { + final a = await row(const Duration(minutes: 11), ScheduledNoticeStatus.sending); + final b = await row(const Duration(minutes: 30), ScheduledNoticeStatus.sending); + await row(const Duration(minutes: 2), ScheduledNoticeStatus.sending); + expect(await service.recoverStuckSending(), 2); + expect(audit.events.map((e) => e.resourceId), unorderedEquals([a, b])); + for (final e in audit.events) { + expect(e.result, 'unknown_after_restart'); + expect(e.resourceType, 'community_notice'); + expect(e.actionType, 'write'); + expect(e.userId, 'adm-1'); + } + expect(audit.events.map((e) => '${e.userId}${e.resourceId}${e.result}').join(), + isNot(contains('Mensagem secreta'))); + audit.events.clear(); + expect(await service.recoverStuckSending(), 0); + expect(audit.events, isEmpty); + }); + + test('auditoria fora do ar não impede a varredura (recordSafely)', () async { + await row(const Duration(minutes: 11), ScheduledNoticeStatus.sending); + final svc = NoticeSchedulingService( + store: store, + audit: _ThrowingAudit(), + clock: () => now, + sendNow: ({ + required microAreaId, + required audience, + required title, + required message, + required actorId, + auditResourceId, + }) async => const NoticeSendSnapshot(recipients: 0, accepted: 0), + ); + expect(await svc.recoverStuckSending(), 1); + }); + }); + + group('negação com resourceId do cliente', () { + test('microAreaId vazio ou malformado grava evento com resourceId nulo', () async { + for (final bad in ['', 'nao-e-uuid']) { + audit.events.clear(); + await expectLater( + service.schedule(_user(UserRole.acs, microAreaId: _ma1), + microAreaId: bad, + audience: 'everyone', + title: 'T', + message: 'M', + scheduledFor: now.add(const Duration(hours: 2))), + throwsA(isA()), + ); + expect(audit.events, hasLength(1)); + expect(audit.events.single.resourceId, isNull); + expect(audit.events.single.result, 'denied_role'); + } + }); + + test('UUID válido é preservado e a negação de history é read', () async { + await expectLater(service.history(_user(UserRole.acs), microAreaId: _ma1), throwsA(isA())); + expect(audit.events.single.resourceId, _ma1); + expect(audit.events.single.actionType, 'read'); + audit.events.clear(); + await expectLater(service.history(_user(UserRole.patient)), throwsA(isA())); + expect(audit.events.single.resourceId, isNull); + expect(audit.events.single.actionType, 'read'); + }); + }); + + group('auditoria do disparo com o NoticeService real', () { + late _RecipientStore recipients; + late _Sender sender; + late NoticeSchedulingService real; + + setUp(() { + recipients = _RecipientStore(); + sender = _Sender(); + final notices = NoticeService(store: recipients, sender: sender, audit: audit); + real = NoticeSchedulingService( + store: store, + audit: audit, + clock: () => now, + sendNow: notices.sendToMicroArea, + ); + }); + + Future due() => store.insert( + createdBy: 'adm-1', + microAreaId: _ma1, + audience: 'everyone', + title: 'T', + message: 'M', + scheduledFor: now.subtract(const Duration(minutes: 1)), + createdAt: now.subtract(const Duration(hours: 1)), + ); + + Future expectResult(String audit0, ScheduledNoticeStatus status) async { + final id = await due(); + await real.dispatchDue(); + final e = audit.events.single; + expect(e.result, audit0); + expect(e.resourceType, 'community_notice'); + expect(e.resourceId, id); + expect(e.userId, 'adm-1'); + expect(store.rows[id]!.status, status); + } + + test('granted', () => expectResult('granted', ScheduledNoticeStatus.sent)); + + test('no_recipients', () { + recipients.targets = const []; + return expectResult('no_recipients', ScheduledNoticeStatus.sent); + }); + + test('not_delivered', () { + sender.report = const PushSendReport(accepted: 0, invalidTokens: []); + return expectResult('not_delivered', ScheduledNoticeStatus.sent); + }); + + test('unknown', () { + sender.failure = const PushGatewayException('demorou', outcomeUnknown: true); + return expectResult('unknown', ScheduledNoticeStatus.unknown); + }); + }); + + group('auditoria fail-closed (record, nunca recordSafely)', () { + late _ThrowingAudit broken; + late _FakeStore s2; + late NoticeSchedulingService svc; + var storeReads = 0; + + setUp(() { + broken = _ThrowingAudit(); + s2 = _FakeStore()..ubsOfMicroAreaMap[_ma1] = 'ubs-1'; + storeReads = 0; + svc = NoticeSchedulingService( + store: _CountingStore(s2, () => storeReads++), + audit: broken, + clock: () => now, + sendNow: + ({ + required microAreaId, + required audience, + required title, + required message, + required actorId, + auditResourceId, + }) async => const NoticeSendSnapshot(recipients: 0, accepted: 0), + ); + }); + + test( + 'negação por papel: se a auditoria falha, a exceção da trilha sobe (não vira sucesso)', + () async { + await expectLater( + svc.schedule( + _user(UserRole.acs, microAreaId: _ma1), + microAreaId: _ma1, + audience: 'everyone', + title: 'T', + message: 'M', + scheduledFor: now.add(const Duration(hours: 2)), + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'auditoria fora do ar', + ), + ), + ); + expect(broken.calls, 1); + expect(s2.rows, isEmpty); + }, + ); + + test('schedule: escrita ok mas auditoria falha propaga o erro', () async { + await expectLater( + svc.schedule( + _user(UserRole.admin), + microAreaId: _ma1, + audience: 'everyone', + title: 'T', + message: 'M', + scheduledFor: now.add(const Duration(hours: 2)), + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'auditoria fora do ar', + ), + ), + ); + expect(broken.calls, 1); + }); + + test( + 'cancel: auditoria falha propaga o erro (e negação continua negando)', + () async { + final id = await _insertNotice(s2); + await expectLater( + svc.cancel(_user(UserRole.admin), id), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'auditoria fora do ar', + ), + ), + ); + await expectLater( + svc.cancel(_user(UserRole.patient), id), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'auditoria fora do ar', + ), + ), + ); + }, + ); + + test('history: se a leitura não grava, o store NÃO é chamado', () async { + await expectLater( + svc.history(_user(UserRole.admin)), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'auditoria fora do ar', + ), + ), + ); + expect(storeReads, 0); + expect(s2.lastHistoryLimit, isNull); + }); + + test( + 'history: negação com auditoria fora do ar propaga o erro da trilha', + () async { + await expectLater( + svc.history(_user(UserRole.patient)), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'auditoria fora do ar', + ), + ), + ); + expect(s2.lastHistoryLimit, isNull); + }, + ); + }); + + test('history audita ANTES de consultar o store', () async { + final order = []; + final svc = NoticeSchedulingService( + store: _CountingStore(store, () => order.add('store')), + audit: _OrderAudit(order), + clock: () => now, + sendNow: + ({ + required microAreaId, + required audience, + required title, + required message, + required actorId, + auditResourceId, + }) async => const NoticeSendSnapshot(recipients: 0, accepted: 0), + ); + await svc.history(_user(UserRole.admin)); + expect(order, ['audit', 'store']); + }); + + group('identificador que não é UUID (entrada inválida, antes do store)', () { + const ruins = ['', 'abc', 'notice-1', '00000000-0000-4000-8000-00000000000g', "x'; drop"]; + + Matcher invalido() => throwsA(isA() + .having((e) => e.message, 'message', 'Identificador inválido.')); + + test('schedule, cancel e history recusam sem chamar o store nem auditar', () async { + final admin = _user(UserRole.admin); + for (final ruim in ruins) { + await expectLater(schedule(admin, microAreaId: ruim), invalido()); + await expectLater(service.cancel(admin, ruim), invalido()); + await expectLater(service.history(admin, microAreaId: ruim), invalido()); + } + expect(store.rows, isEmpty); + expect(store.lastHistoryLimit, isNull); + expect(audit.events, isEmpty); + }); + + test('coordenador também recebe entrada inválida (não vira denied_territory)', () async { + store.ubsOfStaffMap['c-1'] = 'ubs-1'; + final c = _user(UserRole.coordinator, id: 'c-1'); + await expectLater(schedule(c, microAreaId: 'abc'), invalido()); + await expectLater(service.cancel(c, 'abc'), invalido()); + await expectLater(service.history(c, microAreaId: 'abc'), invalido()); + expect(audit.events, isEmpty); + }); + + test('a autorização de papel vem ANTES: paciente e ACS recebem denied_role auditado', () async { + for (final role in [UserRole.patient, UserRole.acs]) { + await expectLater( + schedule(_user(role, microAreaId: _ma1), microAreaId: 'abc'), throwsA(isA())); + expect(audit.events.last.result, 'denied_role'); + await expectLater(service.cancel(_user(role), 'abc'), throwsA(isA())); + expect(audit.events.last.result, 'denied_role'); + await expectLater( + service.history(_user(role), microAreaId: 'abc'), throwsA(isA())); + expect(audit.events.last.result, 'denied_role'); + expect(audit.events.last.actionType, 'read'); + } + }); + }); +} diff --git a/backend/sinalacs_server/test/unit/notice_service_test.dart b/backend/sinalacs_server/test/unit/notice_service_test.dart new file mode 100644 index 00000000..41920c7b --- /dev/null +++ b/backend/sinalacs_server/test/unit/notice_service_test.dart @@ -0,0 +1,228 @@ +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/notices/notice_service.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/infrastructure/push/gorush_client.dart'; +import 'package:test/test.dart'; + +const _microAreaId = '00000000-0000-4000-8000-000000000003'; + +const _acs = AuthenticatedUser( + id: '00000000-0000-4000-8000-000000000002', + role: UserRole.acs, + microAreaId: _microAreaId, + deviceId: 'acs-device-001', +); + +const _patient = AuthenticatedUser( + id: '00000000-0000-4000-8000-000000000001', + role: UserRole.patient, + microAreaId: _microAreaId, + deviceId: 'patient-device-001', +); + +class _FakeRecipientStore implements NoticeRecipientStore { + List targets = const [ + PushTarget(token: 'tok-a', platform: 'android'), + PushTarget(token: 'tok-b', platform: 'ios'), + ]; + String? lastMicroAreaId; + bool? lastChronicOnly; + final deleted = []; + Object? deleteFailure; + + @override + Future> consentedTargets({ + required String microAreaId, + required bool chronicOnly, + }) async { + lastMicroAreaId = microAreaId; + lastChronicOnly = chronicOnly; + return targets; + } + + @override + Future deleteTokens(List tokens) async { + final failure = deleteFailure; + if (failure != null) throw failure; + deleted.addAll(tokens); + return tokens.length; + } +} + +class _FakeSender implements PushSender { + int calls = 0; + PushMessage? lastMessage; + List? lastTargets; + PushSendReport report = const PushSendReport(accepted: 2, invalidTokens: []); + PushGatewayException? failure; + + @override + Future send(PushMessage message, List targets) async { + calls++; + lastMessage = message; + lastTargets = targets; + final f = failure; + if (f != null) throw f; + return report; + } +} + +class _FakeAudit extends AuditTrail { + final events = []; + + @override + Future record(AuditEvent event) async => events.add(event); +} + +void main() { + late _FakeRecipientStore store; + late _FakeSender sender; + late _FakeAudit audit; + late NoticeService service; + + setUp(() { + store = _FakeRecipientStore(); + sender = _FakeSender(); + audit = _FakeAudit(); + service = NoticeService(store: store, sender: sender, audit: audit); + }); + + test('só ACS envia; paciente é recusado sem chamar o provedor', () async { + await expectLater( + service.sendSegmented(_patient, title: 't', message: 'm', audience: 'everyone'), + throwsA(isA()), + ); + expect(sender.calls, 0); + }); + + test('a consulta usa a microárea do token e o filtro de crônicos', () async { + await service.sendSegmented(_acs, title: 'Vacina', message: 'Amanhã.', audience: 'chronic'); + expect(store.lastMicroAreaId, _microAreaId); + expect(store.lastChronicOnly, isTrue); + + await service.sendSegmented(_acs, title: 'Vacina', message: 'Amanhã.', audience: 'everyone'); + expect(store.lastChronicOnly, isFalse); + }); + + test('sem destinatário consentido: 0 enviados, sem chamar o provedor', () async { + store.targets = const []; + final r = await service.sendSegmented(_acs, title: 't', message: 'm', audience: 'everyone'); + expect((r.recipients, r.accepted), (0, 0)); + expect(sender.calls, 0); + }); + + test('o payload não leva dado do paciente, só título, mensagem e tela', () async { + await service.sendSegmented(_acs, title: ' Vacina ', message: ' Amanhã. ', audience: 'everyone'); + expect(sender.lastMessage!.title, 'Vacina'); + expect(sender.lastMessage!.body, 'Amanhã.'); + expect(sender.lastMessage!.data, {'screen': 'notices'}); + expect(sender.lastTargets, hasLength(2)); + }); + + test('devolve destinatários e aceitos', () async { + final r = await service.sendSegmented(_acs, title: 't', message: 'm', audience: 'everyone'); + expect((r.recipients, r.accepted), (2, 2)); + }); + + test('tokens inválidos devolvidos pelo provedor são apagados', () async { + sender.report = const PushSendReport(accepted: 1, invalidTokens: ['tok-b']); + await service.sendSegmented(_acs, title: 't', message: 'm', audience: 'everyone'); + expect(store.deleted, ['tok-b']); + }); + + test('falha do Gorush vira erro tipado e não audita "enviado"', () async { + sender.failure = const PushGatewayException('fora do ar'); + await expectLater( + service.sendSegmented(_acs, title: 't', message: 'm', audience: 'everyone'), + throwsA(isA()), + ); + expect(audit.events.where((e) => e.result == 'granted'), isEmpty); + }); + + test('timeout: o resultado é desconhecido, a mensagem manda conferir antes de reenviar', () async { + sender.failure = const PushGatewayException('demorou', outcomeUnknown: true); + await expectLater( + service.sendSegmented(_acs, title: 't', message: 'm', audience: 'everyone'), + throwsA(isA() + .having((e) => e.message, 'message', contains('Confira antes de reenviar'))), + ); + expect(audit.events.single.result, 'unknown'); + }); + + test('falha ao apagar tokens inválidos não vira erro: o envio já aconteceu', () async { + sender.report = const PushSendReport(accepted: 1, invalidTokens: ['tok-b']); + store.deleteFailure = StateError('banco fora do ar'); + final r = await service.sendSegmented(_acs, title: 't', message: 'm', audience: 'everyone'); + expect((r.recipients, r.accepted), (2, 1)); + expect(audit.events.single.result, 'granted'); + }); + + test('nenhum aceito: não audita "granted"', () async { + sender.report = const PushSendReport(accepted: 0, invalidTokens: []); + final r = await service.sendSegmented(_acs, title: 't', message: 'm', audience: 'everyone'); + expect(r.accepted, 0); + expect(audit.events.single.result, 'not_delivered'); + }); + + test('sem Gorush configurado o envio é recusado com mensagem clara', () async { + final off = NoticeService(store: store, sender: null, audit: audit); + await expectLater( + off.sendSegmented(_acs, title: 't', message: 'm', audience: 'everyone'), + throwsA(isA()), + ); + }); + + test('título e mensagem vazios, longos demais ou público desconhecido são recusados', () async { + for (final a in [ + ('', 'm', 'everyone'), + ('t', ' ', 'everyone'), + ('x' * (noticeTitleMaxLength + 1), 'm', 'everyone'), + ('t', 'x' * (noticeMessageMaxLength + 1), 'everyone'), + ('t', 'm', 'todos'), + ]) { + await expectLater( + service.sendSegmented(_acs, title: a.$1, message: a.$2, audience: a.$3), + throwsA(isA()), + reason: '$a', + ); + } + expect(sender.calls, 0); + }); + + test('o texto da mensagem nunca vai para a trilha de auditoria', () async { + await service.sendSegmented(_acs, title: 'Vacina', message: 'texto sigiloso', audience: 'everyone'); + expect(audit.events, isNotEmpty); + for (final e in audit.events) { + expect('${e.resourceType} ${e.resourceId} ${e.result}'.contains('sigiloso'), isFalse); + expect(e.resourceType, 'community_notice'); + } + }); + + test('isNoticeOutcomeUnknown reconhece só a mensagem real de resultado desconhecido', () async { + sender.failure = const PushGatewayException('demorou', outcomeUnknown: true); + Object? unknown; + try { + await service.sendSegmented(_acs, title: 't', message: 'm', audience: 'everyone'); + } catch (e) { + unknown = e; + } + expect(unknown, isA()); + expect(isNoticeOutcomeUnknown(unknown!), isTrue); + + sender.failure = const PushGatewayException('recusou'); + Object? simple; + try { + await service.sendSegmented(_acs, title: 't', message: 'm', audience: 'everyone'); + } catch (e) { + simple = e; + } + expect(simple, isA()); + expect(isNoticeOutcomeUnknown(simple!), isFalse); + }); + + test('sendSegmented audita com resourceId = microAreaId', () async { + await service.sendSegmented(_acs, title: 't', message: 'm', audience: 'everyone'); + expect(audit.events.single.resourceId, _microAreaId); + }); +} diff --git a/backend/sinalacs_server/test/unit/onboarding_service_test.dart b/backend/sinalacs_server/test/unit/onboarding_service_test.dart index 72d58366..1ddb5b96 100644 --- a/backend/sinalacs_server/test/unit/onboarding_service_test.dart +++ b/backend/sinalacs_server/test/unit/onboarding_service_test.dart @@ -91,7 +91,7 @@ void main() { }); group('completeEnrollment', () { - test('consome o token, grava 3 consent_logs e emite sessão', () async { + test('consome o token, grava 4 consent_logs e emite sessão', () async { final generated = await service.generateToken(acs, patientId: 'patient-1'); final user = await service.completeEnrollment( @@ -100,14 +100,15 @@ void main() { ConsentPurpose.healthDataProcessing: true, ConsentPurpose.localReminders: false, ConsentPurpose.segmentedPush: true, + ConsentPurpose.termsOfUse: true, }, ); expect(user.id, 'patient-1'); expect(user.role, UserRole.patient); expect(user.microAreaId, 'area-1'); - expect(store.consentLogs, hasLength(3)); - // Asserção por finalidade, não por Set: um Set de 3 ações colapsa + expect(store.consentLogs, hasLength(4)); + // Asserção por finalidade, não por Set: um Set de 4 ações colapsa // 'granted'/'granted'/'denied' em {'granted', 'denied'} e não prova // qual finalidade recebeu qual ação — checar por chave é o que de fato // verifica que cada consentimento foi gravado com a ação correta. @@ -115,19 +116,26 @@ void main() { expect(byPurpose[ConsentPurpose.healthDataProcessing], 'granted'); expect(byPurpose[ConsentPurpose.localReminders], 'denied'); expect(byPurpose[ConsentPurpose.segmentedPush], 'granted'); + expect(byPurpose[ConsentPurpose.termsOfUse], 'granted'); }); test('um segundo uso do mesmo token falha, não reconsome em silêncio', () async { final generated = await service.generateToken(acs, patientId: 'patient-1'); await service.completeEnrollment( token: generated.token, - consents: const {ConsentPurpose.healthDataProcessing: true}, + consents: const { + ConsentPurpose.healthDataProcessing: true, + ConsentPurpose.termsOfUse: true, + }, ); expect( () => service.completeEnrollment( token: generated.token, - consents: const {ConsentPurpose.healthDataProcessing: true}, + consents: const { + ConsentPurpose.healthDataProcessing: true, + ConsentPurpose.termsOfUse: true, + }, ), throwsA(isA()), ); @@ -162,10 +170,58 @@ void main() { expect( () => laterService.completeEnrollment( token: generated.token, - consents: const {ConsentPurpose.healthDataProcessing: true}, + consents: const { + ConsentPurpose.healthDataProcessing: true, + ConsentPurpose.termsOfUse: true, + }, ), throwsA(isA()), ); }); + + test('recusa concluir sem aceitar o Termo de Uso, e o convite continua válido', () async { + final generated = await service.generateToken(acs, patientId: 'patient-1'); + + await expectLater( + () => service.completeEnrollment( + token: generated.token, + consents: const { + ConsentPurpose.healthDataProcessing: true, + ConsentPurpose.termsOfUse: false, + }, + ), + throwsA(isA().having( + (e) => e.message, + 'message', + 'É preciso aceitar o Termo de Uso e a Política de Privacidade.', + )), + ); + expect(store.consentLogs, isEmpty); + + // Mesma regra do consentimento obrigatório de saúde: a recusa não + // consome o convite, a pessoa pode aceitar e tentar de novo. + final user = await service.completeEnrollment( + token: generated.token, + consents: const { + ConsentPurpose.healthDataProcessing: true, + ConsentPurpose.termsOfUse: true, + }, + ); + expect(user.id, 'patient-1'); + }); + + test('o aceite do termo leva a versão vigente dos documentos', () async { + final generated = await service.generateToken(acs, patientId: 'patient-1'); + await service.completeEnrollment( + token: generated.token, + consents: const { + ConsentPurpose.healthDataProcessing: true, + ConsentPurpose.termsOfUse: true, + }, + ); + final terms = store.consentLogs.singleWhere((e) => e.purpose == ConsentPurpose.termsOfUse); + expect(terms.action, 'granted'); + expect(terms.version, consentPolicyVersion); + }); }); } diff --git a/backend/sinalacs_server/test/unit/patient_data_overview_service_test.dart b/backend/sinalacs_server/test/unit/patient_data_overview_service_test.dart index 6d10b148..cfcc56e2 100644 --- a/backend/sinalacs_server/test/unit/patient_data_overview_service_test.dart +++ b/backend/sinalacs_server/test/unit/patient_data_overview_service_test.dart @@ -125,6 +125,45 @@ void main() { expect(event.userId, _patientId); }); + test('devolve ao próprio titular a nota de resposta do pedido decidido (#42)', () async { + final comResposta = PatientDataSnapshot( + name: 'Paciente de Teste', + birthDate: DateTime.utc(1990, 1, 1), + emergencyContact: 'Contato de teste', + isChronic: false, + chronicConditions: const [], + consents: const [], + riskHistory: const [], + requests: [ + DataSubjectRequestSnapshot( + id: 'pedido-1', + type: DataSubjectRequestType.correction, + status: DataSubjectRequestStatus.completed, + details: 'texto sintético do pedido', + createdAt: DateTime.utc(2026, 9, 30), + dueAt: DateTime.utc(2026, 10, 15), + resolution: 'nota sintética de resposta', + ), + DataSubjectRequestSnapshot( + id: 'pedido-2', + type: DataSubjectRequestType.deletion, + status: DataSubjectRequestStatus.open, + details: null, + createdAt: DateTime.utc(2026, 10, 1), + dueAt: DateTime.utc(2026, 10, 16), + ), + ], + ); + store = FakePatientDataOverviewStore({_patientId: comResposta}); + service = PatientDataOverviewService(store: store, audit: audit); + + final result = await service.myData(_patient); + expect(result.requests.first.resolution, 'nota sintética de resposta'); + expect(result.requests.first.status, DataSubjectRequestStatus.completed); + // Pedido ainda aberto: sem nota. + expect(result.requests.last.resolution, isNull); + }); + test('uma trilha de auditoria fora do ar não impede a leitura', () async { audit = FakeAuditTrail(failOnRecord: true); service = PatientDataOverviewService(store: store, audit: audit); diff --git a/backend/sinalacs_server/test/unit/push_token_service_test.dart b/backend/sinalacs_server/test/unit/push_token_service_test.dart new file mode 100644 index 00000000..8da562f5 --- /dev/null +++ b/backend/sinalacs_server/test/unit/push_token_service_test.dart @@ -0,0 +1,130 @@ +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/push_token_service.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:test/test.dart'; + +const _microAreaId = '00000000-0000-4000-8000-000000000003'; + +const _patient = AuthenticatedUser( + id: '00000000-0000-4000-8000-000000000001', + role: UserRole.patient, + microAreaId: _microAreaId, + deviceId: 'patient-device-001', +); + +const _otherPatient = AuthenticatedUser( + id: '00000000-0000-4000-8000-000000000009', + role: UserRole.patient, + microAreaId: _microAreaId, + deviceId: 'patient-device-009', +); + +const _acs = AuthenticatedUser( + id: '00000000-0000-4000-8000-000000000002', + role: UserRole.acs, + microAreaId: _microAreaId, + deviceId: 'acs-device-001', +); + +class _FakePushTokenStore implements PushTokenStore { + var consent = true; + final rows = {}; + + @override + Future registerIfConsented({ + required String userId, + required String? microAreaId, + required String token, + required String platform, + required DateTime now, + }) async { + if (!consent) return const PushRegistrationResult(PushRegistration.refused); + final previous = rows[token]; + rows[token] = (userId: userId, platform: platform); + return previous != null && previous.userId != userId + ? PushRegistrationResult(PushRegistration.ownerChanged, previousOwnerId: previous.userId) + : const PushRegistrationResult(PushRegistration.registered); + } +} + +class _FakeAudit extends AuditTrail { + final events = []; + + @override + Future record(AuditEvent event) async => events.add(event); +} + +void main() { + late _FakePushTokenStore store; + late _FakeAudit audit; + late PushTokenService service; + + setUp(() { + store = _FakePushTokenStore(); + audit = _FakeAudit(); + service = PushTokenService(store: store, audit: audit, clock: () => DateTime.utc(2026, 9, 29)); + }); + + test('sem consentimento vigente, recusa e não grava', () async { + store.consent = false; + await expectLater( + service.register(_patient, token: 'tok-1', platform: 'android'), + throwsA(isA()), + ); + expect(store.rows, isEmpty); + }); + + test('registra e repete sem duplicar', () async { + await service.register(_patient, token: 'tok-1', platform: 'android'); + await service.register(_patient, token: 'tok-1', platform: 'android'); + expect(store.rows.keys, ['tok-1']); + }); + + test('o mesmo token de outro titular troca de dono', () async { + await service.register(_patient, token: 'tok-1', platform: 'android'); + await service.register(_otherPatient, token: 'tok-1', platform: 'android'); + expect(store.rows['tok-1']!.userId, _otherPatient.id); + }); + + test('recusa token vazio, longo demais e plataforma desconhecida', () async { + for (final args in [('', 'android'), ('x' * 4097, 'android'), ('tok', 'web')]) { + await expectLater( + service.register(_patient, token: args.$1, platform: args.$2), + throwsA(isA()), + ); + } + expect(store.rows, isEmpty); + }); + + test('ACS não registra token', () async { + await expectLater( + service.register(_acs, token: 'tok-1', platform: 'android'), + throwsA(isA()), + ); + }); + + test('troca de dono audita o novo dono E o anterior, sem o token', () async { + await service.register(_patient, token: 'tok-1', platform: 'android'); + await service.register(_patient, token: 'tok-1', platform: 'android'); + expect(audit.events, isEmpty); + + await service.register(_otherPatient, token: 'tok-1', platform: 'android'); + + expect(audit.events.map((e) => e.userId).toSet(), {_otherPatient.id, _patient.id}); + for (final e in audit.events) { + expect(e.resourceType, 'push_token'); + expect('${e.resourceId} ${e.result}'.contains('tok-1'), isFalse); + } + expect(audit.events.map((e) => e.result), containsAll(['granted', 'lost'])); + }); + + test('recusa por falta de consentimento lança e não audita', () async { + store.consent = false; + await expectLater( + service.register(_patient, token: 'tok-1', platform: 'android'), + throwsA(isA()), + ); + expect(audit.events, isEmpty); + }); +} diff --git a/backend/sinalacs_server/test/unit/red_alert_discard_paths_test.dart b/backend/sinalacs_server/test/unit/red_alert_discard_paths_test.dart new file mode 100644 index 00000000..dbad059d --- /dev/null +++ b/backend/sinalacs_server/test/unit/red_alert_discard_paths_test.dart @@ -0,0 +1,344 @@ +import 'dart:io'; + +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/domain/entities/alert_delivery.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:test/test.dart'; + +/// Inventário das rotas que podem escrever, apagar ou reclassificar um alerta +/// vermelho (#44, Task 3). O invariante provado: nenhum caminho descarta, +/// silencia ou reclassifica um alerta vermelho sem deixar registro de +/// auditoria. +/// +/// Este arquivo é hermético (fakes em memória). As rotas que dependem de +/// Postgres (ACK com auditoria, ACK por MQTT, retenção, anonimização, outbox +/// real) estão em `test/integration/red_alert_discard_paths_db_test.dart`. +/// +/// Os buracos reais que a Task 3 provou (ACK sem auditoria, expurgo de +/// vermelho pending) foram fechados na Task 4: todos os itens estão ativos. + +// --------------------------------------------------------------------------- +// Varredura do fonte: quem pode escrever em `alerts`. +// --------------------------------------------------------------------------- + +/// Mutações da tabela `alerts`: SQL cru (qualquer caixa, aspas opcionais, +/// quebra de linha entre UPDATE/DELETE/INSERT e o nome) e escrita do ORM +/// (`Alert.db.update*/delete*/insert*` e `db.update(…)` etc.). +/// +/// Escopo: SÓ `alerts`. `alert_deliveries`, `alert_outbox` e +/// `alert_idempotency_keys` ficam de fora de propósito (o brief fala do alerta +/// em si; o ACK também insere em `alert_deliveries`, o que não descarta nem +/// reclassifica alerta). `DTO status: AlertStatus.` também fica de fora: não +/// escreve em tabela; qualquer escrita de status passa por `Alert.db.update*` +/// ou SQL, já cobertos. +final _mutationPattern = RegExp( + r'(UPDATE|DELETE\s+FROM|INSERT\s+INTO)\s+"?alerts"?(?![\w])' + r'|\bAlert\.db\.(update|delete|insert)\w*' + r'|\.(update|delete|insert)\w*', + caseSensitive: false, + multiLine: true, +); + +/// Arquivos autorizados a casar com [_mutationPattern]: só escritores reais, +/// cada um com o motivo. Arquivos que apenas montam DTO ou leem não entram. +/// `escalate` (Task 6) entra como `orm_alert_escalation_store.dart`; enquanto o +/// arquivo não existe, a varredura exige só que todo escritor ENCONTRADO esteja +/// aqui (subconjunto). +const _allowedWriters = { + 'infrastructure/database/orm_alert_store.dart': + 'save() insere o alerta como pending; acknowledge() leva a acknowledged ' + '(única transição de status hoje)', + 'infrastructure/database/orm_retention_store.dart': + 'expurgo LGPD: DELETE FROM alerts por triggeredAt — hoje SEM guarda de ' + 'status/risco (buraco, corrigido na Task 4)', + 'infrastructure/database/orm_alert_escalation_store.dart': + 'Task 6: UPDATE só de escalatedAt/colunas de escalonamento, nunca status', +}; + +Map> _grepSources(RegExp pattern) { + final root = Directory('lib/src'); + final found = >{}; + for (final entity in root.listSync(recursive: true)) { + if (entity is! File || !entity.path.endsWith('.dart')) continue; + final relative = entity.path.substring('lib/src/'.length); + if (relative.startsWith('generated/')) continue; + // Texto inteiro (SQL pode quebrar linha), sem as linhas de comentário. + final code = entity + .readAsLinesSync() + .map((l) => l.trimLeft().startsWith('//') ? '' : l) + .join('\n'); + final hits = pattern + .allMatches(code) + .map((m) => m.group(0)!.replaceAll(RegExp(r'\s+'), ' ')) + .toList(); + if (hits.isNotEmpty) found[relative] = hits; + } + return found; +} + +// --------------------------------------------------------------------------- +// Fakes. +// --------------------------------------------------------------------------- + +class _MemoryStore implements AlertStore { + final Map alerts = {}; + final Map status = {}; + final Map keys = {}; + var saveCalls = 0; + final List audits = []; + + @override + Future save(AlertDelivery alert, {required String deviceId}) async { + saveCalls++; + alerts[alert.alertId] = alert; + status[alert.alertId] = 'pending'; + } + + @override + Future acknowledge({ + required String alertId, + required String acsId, + required String microAreaId, + required AuditEvent audit, + }) async { + audits.add(audit); + status[alertId] = 'acknowledged'; + return true; + } + + @override + Future findByIdempotencyKey(String k) async => keys[k]; + + @override + Future rememberIdempotencyKey(RedAlertRecord r) async => + keys[r.idempotencyKey] = r; + + @override + Future latestForPatient(String patientId) async => null; +} + +class _MemoryOutbox implements AlertOutbox { + final List enqueued = []; + final Set publishedIds = {}; + final Map attempts = {}; + final Map errors = {}; + + @override + Future enqueue(AlertDelivery alert) async => enqueued.add(alert); + + @override + Future> claimDue({int limit = 32}) async => [ + for (final a in enqueued) + if (!publishedIds.contains('entry-${a.alertId}')) + PendingDelivery( + entryId: 'entry-${a.alertId}', + delivery: a, + attempts: attempts.update( + 'entry-${a.alertId}', + (v) => v + 1, + ifAbsent: () => 1, + ), + ), + ]; + + @override + Future markPublished(String entryId) async => publishedIds.add(entryId); + + @override + Future markFailed(String entryId, String error, DateTime next) async => + errors[entryId] = error; +} + +class _DeadPublisher implements AlertPublisher { + @override + Future publish(AlertDelivery alert) async => + throw Exception('broker indisponível'); +} + +void main() { + final patient = AuthenticatedUser( + id: 'patient-001', + role: UserRole.patient, + microAreaId: 'area-12', + deviceId: 'device-001', + ); + + group('varredura do fonte', () { + test('o padrão de mutação pega os escritores realistas e só eles', () { + const mustMatch = [ + 'Alert.db.updateRow(s, a)', + 'Alert.db.updateWhere(s, where: w)', + 'Alert.db.updateColumn(s, id)', + 'Alert.db.deleteRow(s, a)', + 'Alert.db.deleteWhere(s, where: w)', + 'Alert.db.insertRow(s, a)', + 'session.db.update(rows)', + 'db.delete(rows)', + 'db.insert(rows)', + 'DELETE FROM alerts WHERE x', + 'delete from alerts', + 'UPDATE\n "alerts" SET status = 1', + 'INSERT INTO alerts (id)', + ]; + const mustNotMatch = [ + 'Alert.db.findById(s, id)', + 'Alert.db.find(s)', + 'Alert.db.count(s)', + // Fora de escopo de propósito (ver comentário do padrão). + 'AlertDeliveryRecord.db.insertRow(s, r)', + 'AlertOutboxEntry.db.updateRow(s, r)', + 'UPDATE alert_outbox SET attempts = 1', + 'DELETE FROM alert_deliveries WHERE x', + 'INSERT INTO alert_idempotency_keys (k)', + 'status: AlertStatus.pending,', + ]; + for (final sample in mustMatch) { + expect(_mutationPattern.hasMatch(sample), isTrue, reason: sample); + } + for (final sample in mustNotMatch) { + expect(_mutationPattern.hasMatch(sample), isFalse, reason: sample); + } + }); + + test( + 'só os escritores conhecidos mutam alerts (subconjunto da allowlist)', + () { + final unexpected = _grepSources( + _mutationPattern, + ).keys.where((file) => !_allowedWriters.containsKey(file)).toList(); + expect( + unexpected, + isEmpty, + reason: + 'Escritor novo em alerts: $unexpected. Decida por escrito ' + 'em _allowedWriters se ele pode alterar/apagar alerta vermelho, ' + 'e como a auditoria cobre.', + ); + }, + ); + + test('admin.* não escreve em alerts (só o futuro escalateAlert pode)', () { + final adminWriters = _grepSources(_mutationPattern).keys.where( + (f) => + f == 'endpoints/admin_endpoint.dart' || + f.startsWith('application/admin/') || + f == 'infrastructure/database/orm_admin_read_store.dart' || + f == 'infrastructure/database/orm_data_subject_case_store.dart', + ); + expect(adminWriters, isEmpty); + }); + }); + + group('alerts.createRedAlert repetido (idempotência)', () { + test('a mesma chave não substitui nem apaga o alerta original', () async { + final store = _MemoryStore(); + final outbox = _MemoryOutbox(); + final service = RedAlertService(store: store, outbox: outbox); + + final first = await service.create( + user: patient, + idempotencyKey: 'k1', + locationHash: 'hash', + ); + final second = await service.create( + user: patient, + idempotencyKey: 'k1', + locationHash: 'hash', + ); + + expect(second.delivery.alertId, first.delivery.alertId); + expect(store.saveCalls, 1, reason: 'a repetição não regrava o alerta'); + expect(store.alerts.keys, [first.delivery.alertId]); + expect(store.status[first.delivery.alertId], 'pending'); + expect(outbox.enqueued, hasLength(1)); + }); + + test( + 'mesma chave com outra localização é recusada e não toca o original', + () async { + final store = _MemoryStore(); + final service = RedAlertService(store: store, outbox: _MemoryOutbox()); + final first = await service.create( + user: patient, + idempotencyKey: 'k1', + locationHash: 'hash', + ); + + await expectLater( + service.create( + user: patient, + idempotencyKey: 'k1', + locationHash: 'outro', + ), + throwsStateError, + ); + expect(store.alerts[first.delivery.alertId]!.locationHash, 'hash'); + expect(store.status[first.delivery.alertId], 'pending'); + }, + ); + }); + + group('alerts.acknowledge (serviço)', () { + test('o ACK por MQTT entrega ao store o evento de auditoria do alerta, ' + 'marcado acknowledged_mqtt (não autenticado, F3)', () async { + final store = _MemoryStore(); + final service = RedAlertService(store: store, outbox: _MemoryOutbox()); + + final ok = await service.acknowledge( + user: const AuthenticatedUser( + id: 'acs-1', + role: UserRole.acs, + microAreaId: 'area-1', + deviceId: 'mqtt', + ), + alertId: 'alerta-1', + ); + + expect(ok, isTrue); + expect(store.status['alerta-1'], 'acknowledged'); + final e = store.audits.single; + expect(e.actionType, 'write'); + expect(e.resourceType, 'alert'); + expect(e.resourceId, 'alerta-1'); + expect(e.result, 'acknowledged_mqtt'); + expect(e.userId, 'acs-1'); + }); + }); + + group('outbox esgotando tentativas', () { + test( + 'falhas repetidas nunca removem o alerta nem o marcam entregue', + () async { + final store = _MemoryStore(); + final outbox = _MemoryOutbox(); + final service = RedAlertService(store: store, outbox: outbox); + final record = await service.create( + user: patient, + idempotencyKey: 'k1', + locationHash: 'hash', + ); + final dispatcher = AlertOutboxDispatcher( + outbox: outbox, + publisher: _DeadPublisher(), + clock: () => DateTime.utc(2026, 10, 11), + ); + + for (var i = 0; i < 50; i++) { + expect(await dispatcher.drainOnce(), 0); + } + + final id = record.delivery.alertId; + expect(outbox.publishedIds, isEmpty, reason: 'nunca marcado entregue'); + expect(outbox.errors['entry-$id'], contains('broker indisponível')); + expect(outbox.attempts['entry-$id'], 50); + // A entrega segue na fila (reclamável) e o alerta segue pending. + expect(await outbox.claimDue(), hasLength(1)); + expect(store.alerts.containsKey(id), isTrue); + expect(store.status[id], 'pending'); + }, + ); + }); +} diff --git a/backend/sinalacs_server/test/unit/red_alert_lifecycle_test.dart b/backend/sinalacs_server/test/unit/red_alert_lifecycle_test.dart index cdbf1e49..27ad9795 100644 --- a/backend/sinalacs_server/test/unit/red_alert_lifecycle_test.dart +++ b/backend/sinalacs_server/test/unit/red_alert_lifecycle_test.dart @@ -1,5 +1,6 @@ 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/domain/entities/alert_delivery.dart'; import 'package:sinalacs_server/src/generated/protocol.dart'; @@ -9,7 +10,7 @@ class RecordingPublisher implements AlertPublisher { final List published = []; @override - void publish(AlertDelivery alert) => published.add(alert); + Future publish(AlertDelivery alert) async => published.add(alert); } class RecordingStore implements AlertStore { @@ -20,7 +21,7 @@ class RecordingStore implements AlertStore { Future save(AlertDelivery alert, {required String deviceId}) async => saved.add(alert); @override - Future acknowledge({required String alertId, required String acsId, required String microAreaId}) async { + Future acknowledge({required String alertId, required String acsId, required String microAreaId, required AuditEvent audit}) async { if (alertId.trim().isEmpty) return false; acknowledgedAlertIds.add(alertId); return true; diff --git a/backend/sinalacs_server/test/unit/red_alert_service_test.dart b/backend/sinalacs_server/test/unit/red_alert_service_test.dart index 38ef9850..9af59cb2 100644 --- a/backend/sinalacs_server/test/unit/red_alert_service_test.dart +++ b/backend/sinalacs_server/test/unit/red_alert_service_test.dart @@ -1,4 +1,5 @@ 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/domain/entities/alert_delivery.dart'; import 'package:sinalacs_server/src/generated/protocol.dart'; @@ -8,7 +9,7 @@ class FakeAlertPublisher implements AlertPublisher { final List published = []; @override - void publish(AlertDelivery alert) => published.add(alert); + Future publish(AlertDelivery alert) async => published.add(alert); } class FakeAlertStore implements AlertStore { @@ -18,7 +19,12 @@ class FakeAlertStore implements AlertStore { Future save(AlertDelivery alert, {required String deviceId}) async => saved.add(alert); @override - Future acknowledge({required String alertId, required String acsId, required String microAreaId}) async => true; + Future acknowledge({required String alertId, required String acsId, required String microAreaId, required AuditEvent audit}) async { + audits.add(audit); + return true; + } + + final List audits = []; // Deduplicação agora vive no armazenamento, então o fake a implementa em // memória — mesma semântica, sem Postgres. @@ -252,5 +258,53 @@ void main() { throwsA(isA()), ); }); + + test('acknowledge entrega ao store o evento de auditoria, sem PII (#44)', () async { + final store = FakeAlertStore(); + final service = RedAlertService(store: store, outbox: FakeAlertOutbox()); + + final ok = await service.acknowledge( + user: const AuthenticatedUser( + id: 'acs-1', + role: UserRole.acs, + microAreaId: 'area-1', + deviceId: 'device-1', + ), + alertId: 'alerta-1', + ); + + expect(ok, isTrue); + final e = store.audits.single; + expect( + [e.userId, e.actionType, e.resourceType, e.resourceId, e.result], + ['acs-1', 'write', 'alert', 'alerta-1', 'acknowledged'], + ); + }); + + test( + 'ACK que chega por MQTT (sentinela de server.dart) é auditado como ' + 'acknowledged_mqtt, não como o ACK autenticado do RPC (F3)', + () async { + final store = FakeAlertStore(); + final service = RedAlertService(store: store, outbox: FakeAlertOutbox()); + + final ok = await service.acknowledge( + user: const AuthenticatedUser( + id: 'acs-1', + role: UserRole.acs, + microAreaId: 'area-1', + deviceId: 'mqtt', + ), + alertId: 'alerta-1', + ); + + expect(ok, isTrue); + final e = store.audits.single; + expect( + [e.userId, e.actionType, e.resourceType, e.resourceId, e.result], + ['acs-1', 'write', 'alert', 'alerta-1', 'acknowledged_mqtt'], + ); + }, + ); }); } \ No newline at end of file diff --git a/backend/sinalacs_server/test/unit/refresh_token_service_test.dart b/backend/sinalacs_server/test/unit/refresh_token_service_test.dart new file mode 100644 index 00000000..7c4b47bf --- /dev/null +++ b/backend/sinalacs_server/test/unit/refresh_token_service_test.dart @@ -0,0 +1,438 @@ +import 'dart:convert'; + +import 'package:crypto/crypto.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/auth/refresh_token_service.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:test/test.dart'; + +const _acsId = '00000000-0000-4000-8000-000000000091'; +const _area = '00000000-0000-4000-8000-000000000092'; +const _user = AuthenticatedUser( + id: _acsId, role: UserRole.acs, microAreaId: _area, deviceId: 'aparelho-A'); + +class _MemoryStore implements RefreshTokenStore { + final Map byId = {}; + final Map hashToId = {}; + final Map accounts = {}; + Future Function(String id)? onMarkRotated; + bool rejectInsert = false; + + @override + Future insert(RefreshTokenRecord record, String tokenHash) async { + if (rejectInsert || + byId.values + .any((r) => r.familyId == record.familyId && r.revokedAt != null)) { + return false; + } + byId[record.id] = record; + hashToId[tokenHash] = record.id; + return true; + } + + @override + Future findByHash(String tokenHash) async { + final id = hashToId[tokenHash]; + return id == null ? null : byId[id]; + } + + @override + Future markRotated(String id, DateTime at) async { + final hook = onMarkRotated; + if (hook != null) await hook(id); + final r = byId[id]!; + if (r.rotatedAt != null || r.revokedAt != null) return false; + byId[id] = _copy(r, rotatedAt: at); + return true; + } + + @override + Future revokeFamily(String familyId, DateTime at) async { + for (final e in byId.entries.toList()) { + if (e.value.familyId == familyId && e.value.revokedAt == null) { + byId[e.key] = _copy(e.value, revokedAt: at); + } + } + } + + /// Desativação por conta (#43): todas as famílias do usuário de uma vez. + final revogacoesTotais = <({String userId, DateTime at})>[]; + + @override + Future revokeAllForUser(String userId, DateTime at) async { + revogacoesTotais.add((userId: userId, at: at)); + for (final e in byId.entries.toList()) { + if (e.value.userId == userId && e.value.revokedAt == null) { + byId[e.key] = _copy(e.value, revokedAt: at); + } + } + } + + @override + Future findAccount(String userId) async => accounts[userId]; + + @override + Future deleteExpiredFor(String userId, DateTime before) async { + byId.removeWhere((_, r) => + r.userId == userId && !r.absoluteExpiresAt.isAfter(before)); + } + + RefreshTokenRecord _copy(RefreshTokenRecord r, + {DateTime? rotatedAt, DateTime? revokedAt}) => + RefreshTokenRecord( + id: r.id, + userId: r.userId, + familyId: r.familyId, + deviceId: r.deviceId, + issuedAt: r.issuedAt, + idleExpiresAt: r.idleExpiresAt, + absoluteExpiresAt: r.absoluteExpiresAt, + rotatedAt: rotatedAt ?? r.rotatedAt, + revokedAt: revokedAt ?? r.revokedAt, + ); +} + +class _AuditSpy extends AuditTrail { + final List events = []; + @override + Future record(AuditEvent event) async => events.add(event); + List get results => events.map((e) => e.result).toList(); +} + +Future _expectDenied(Future Function() call) async { + await expectLater(call, throwsA(isA())); +} + +void main() { + late _MemoryStore store; + late _AuditSpy audit; + late RefreshTokenService service; + final t0 = DateTime.utc(2026, 10, 3, 10); + + setUp(() { + store = _MemoryStore(); + audit = _AuditSpy(); + service = RefreshTokenService(store: store, audit: audit); + store.accounts[_acsId] = const RefreshAccount(active: true, microAreaId: _area); + }); + + test('issue grava só o hash e devolve token de 43+ caracteres', () async { + final token = await service.issue(_user, now: t0); + expect(token.length, greaterThanOrEqualTo(43)); + expect(token, isNot(contains('='))); + final hash = sha256.convert(utf8.encode(token)).toString(); + expect(store.hashToId.keys, [hash]); + expect(store.hashToId.keys, isNot(contains(token))); + final r = store.byId.values.single; + expect(r.userId, _acsId); + expect(r.deviceId, 'aparelho-A'); + expect(r.idleExpiresAt, t0.add(RefreshTokenService.idleWindow)); + expect(r.absoluteExpiresAt, t0.add(RefreshTokenService.absoluteWindow)); + expect(r.id, matches(RegExp(r'^[0-9a-f-]{36}$'))); + }); + + test('refresh rotaciona: devolve token novo, marca o antigo, mesma família', + () async { + final first = await service.issue(_user, now: t0); + final at = t0.add(const Duration(minutes: 10)); + final s = await service.refresh( + refreshToken: first, deviceId: 'aparelho-A', now: at); + expect(s.refreshToken, isNot(first)); + expect(s.user.id, _acsId); + expect(s.user.role, UserRole.acs); + expect(s.user.deviceId, 'aparelho-A'); + final old = await store.findByHash(sha256.convert(utf8.encode(first)).toString()); + final child = await store.findByHash( + sha256.convert(utf8.encode(s.refreshToken)).toString()); + expect(old!.rotatedAt, at); + expect(child!.rotatedAt, isNull); + expect(child.familyId, old.familyId); + // o novo token funciona + await service.refresh( + refreshToken: s.refreshToken, deviceId: 'aparelho-A', now: at); + }); + + test('refresh relê a microárea do banco, não a do token', () async { + final token = await service.issue(_user, now: t0); + const novaArea = '00000000-0000-4000-8000-000000000093'; + store.accounts[_acsId] = + const RefreshAccount(active: true, microAreaId: novaArea); + final s = await service.refresh( + refreshToken: token, + deviceId: 'aparelho-A', + now: t0.add(const Duration(minutes: 1))); + expect(s.user.microAreaId, novaArea); + }); + + test('conta desativada: recusa e revoga a família', () async { + final token = await service.issue(_user, now: t0); + store.accounts[_acsId] = + const RefreshAccount(active: false, microAreaId: _area); + await _expectDenied(() => service.refresh( + refreshToken: token, + deviceId: 'aparelho-A', + now: t0.add(const Duration(minutes: 1)))); + expect(store.byId.values.every((r) => r.revokedAt != null), isTrue); + expect(audit.results, contains('denied_inactive')); + }); + + test('conta sem microárea: recusa e revoga a família', () async { + final token = await service.issue(_user, now: t0); + store.accounts[_acsId] = + const RefreshAccount(active: true, microAreaId: null); + await _expectDenied(() => service.refresh( + refreshToken: token, + deviceId: 'aparelho-A', + now: t0.add(const Duration(minutes: 1)))); + expect(store.byId.values.every((r) => r.revokedAt != null), isTrue); + expect(audit.results, contains('denied_inactive')); + }); + + test('aparelho diferente: recusa e revoga a família', () async { + final token = await service.issue(_user, now: t0); + await _expectDenied(() => service.refresh( + refreshToken: token, + deviceId: 'aparelho-B', + now: t0.add(const Duration(minutes: 1)))); + expect(store.byId.values.every((r) => r.revokedAt != null), isTrue); + expect(audit.results, contains('denied_device')); + }); + + test('janela ociosa vencida: recusa', () async { + final token = await service.issue(_user, now: t0); + await _expectDenied(() => service.refresh( + refreshToken: token, + deviceId: 'aparelho-A', + now: t0.add(const Duration(hours: 2, minutes: 1)))); + expect(audit.results, contains('denied_expired')); + }); + + test('teto absoluto vencido mesmo com rotação recente: recusa', () async { + // idle no futuro, teto absoluto no passado: isola a checagem do teto. + await store.insert( + RefreshTokenRecord( + id: '00000000-0000-4000-8000-0000000000a1', + userId: _acsId, + familyId: '00000000-0000-4000-8000-0000000000a2', + deviceId: 'aparelho-A', + issuedAt: t0, + idleExpiresAt: t0.add(const Duration(hours: 10)), + absoluteExpiresAt: t0.add(const Duration(hours: 8)), + ), + sha256.convert(utf8.encode('token-no-teto')).toString()); + await _expectDenied(() => service.refresh( + refreshToken: 'token-no-teto', + deviceId: 'aparelho-A', + now: t0.add(const Duration(hours: 8, minutes: 1)))); + expect(audit.results, contains('denied_expired')); + }); + + test('filho nunca passa do teto absoluto do pai', () async { + final absolute = t0.add(RefreshTokenService.absoluteWindow); + var token = await service.issue(_user, now: t0); + var at = t0; + // longe do teto: idle == agora + 2h + at = at.add(const Duration(hours: 1, minutes: 50)); + var s = await service.refresh( + refreshToken: token, deviceId: 'aparelho-A', now: at); + var child = await store + .findByHash(sha256.convert(utf8.encode(s.refreshToken)).toString()); + expect(child!.idleExpiresAt, at.add(RefreshTokenService.idleWindow)); + expect(child.absoluteExpiresAt, absolute); + token = s.refreshToken; + // perto do teto (7h20 + 2h > 8h): idle == teto do pai + for (var i = 0; i < 3; i++) { + at = at.add(const Duration(hours: 1, minutes: 50)); + s = await service.refresh( + refreshToken: token, deviceId: 'aparelho-A', now: at); + token = s.refreshToken; + } + child = await store + .findByHash(sha256.convert(utf8.encode(token)).toString()); + expect(child!.absoluteExpiresAt, absolute); + expect(child.idleExpiresAt, absolute); + expect(child.idleExpiresAt, + isNot(at.add(RefreshTokenService.idleWindow))); + }); + + test('reuso dentro da tolerância (30 s): emite token novo e NÃO revoga', + () async { + final first = await service.issue(_user, now: t0); + final at = t0.add(const Duration(minutes: 5)); + await service.refresh(refreshToken: first, deviceId: 'aparelho-A', now: at); + final later = at.add(const Duration(seconds: 10)); + final s = await service.refresh( + refreshToken: first, deviceId: 'aparelho-A', now: later); + expect(store.byId.values.every((r) => r.revokedAt == null), isTrue); + expect(audit.results, isNot(contains('denied_reuse'))); + expect(audit.results, contains('refresh_granted_grace')); + final parent = + await store.findByHash(sha256.convert(utf8.encode(first)).toString()); + final child = await store + .findByHash(sha256.convert(utf8.encode(s.refreshToken)).toString()); + expect(child!.absoluteExpiresAt, parent!.absoluteExpiresAt); + await service.refresh( + refreshToken: s.refreshToken, + deviceId: 'aparelho-A', + now: later.add(const Duration(seconds: 1))); + }); + + test('reuso após a janela ociosa: revoga a família (reuso vence expiração)', + () async { + final first = await service.issue(_user, now: t0); + final at = t0.add(const Duration(minutes: 5)); + final s = await service.refresh( + refreshToken: first, deviceId: 'aparelho-A', now: at); + await _expectDenied(() => service.refresh( + refreshToken: first, + deviceId: 'aparelho-A', + now: t0.add(const Duration(hours: 3)))); + expect(audit.results, contains('denied_reuse')); + final child = await store + .findByHash(sha256.convert(utf8.encode(s.refreshToken)).toString()); + expect(child!.revokedAt, isNotNull); + }); + + test('revogada entre a leitura e o markRotated: recusa e não deixa filho vivo', + () async { + final first = await service.issue(_user, now: t0); + store.onMarkRotated = (id) async { + await store.revokeFamily(store.byId[id]!.familyId, t0); + }; + await _expectDenied(() => service.refresh( + refreshToken: first, + deviceId: 'aparelho-A', + now: t0.add(const Duration(minutes: 1)))); + expect(store.byId.values.every((r) => r.revokedAt != null), isTrue); + expect(audit.results, contains('denied_revoked')); + }); + + test('insert recusado (família revogada): recusa', () async { + final first = await service.issue(_user, now: t0); + store.rejectInsert = true; + await _expectDenied(() => service.refresh( + refreshToken: first, + deviceId: 'aparelho-A', + now: t0.add(const Duration(minutes: 1)))); + expect(audit.results, contains('denied_revoked')); + }); + + test('token já revogado: recusa com denied_revoked', () async { + final first = await service.issue(_user, now: t0); + await service.revoke(first, now: t0); + await _expectDenied(() => service.refresh( + refreshToken: first, deviceId: 'aparelho-A', now: t0)); + expect(audit.results, containsAll(['logout', 'denied_revoked'])); + }); + + test('issue recusa papel que não é ACS', () async { + expect( + () => service.issue( + const AuthenticatedUser( + id: _acsId, + role: UserRole.patient, + microAreaId: _area, + deviceId: 'x'), + now: t0), + throwsArgumentError); + }); + + test('reuso fora da tolerância: revoga a família inteira e recusa', () async { + final first = await service.issue(_user, now: t0); + final at = t0.add(const Duration(minutes: 5)); + final s = await service.refresh( + refreshToken: first, deviceId: 'aparelho-A', now: at); + await _expectDenied(() => service.refresh( + refreshToken: first, + deviceId: 'aparelho-A', + now: at.add(const Duration(seconds: 31)))); + expect(store.byId.values.every((r) => r.revokedAt != null), isTrue); + expect(audit.results, contains('denied_reuse')); + // o filho vigente também caiu + await _expectDenied(() => service.refresh( + refreshToken: s.refreshToken, + deviceId: 'aparelho-A', + now: at.add(const Duration(seconds: 40)))); + }); + + test('token desconhecido: recusa, sem auditoria (sem sujeito)', () async { + await _expectDenied(() => service.refresh( + refreshToken: 'nao-existe', deviceId: 'aparelho-A', now: t0)); + expect(audit.events, isEmpty); + }); + + test('revoke apaga a família; revoke de token desconhecido não lança', + () async { + final first = await service.issue(_user, now: t0); + final s = await service.refresh( + refreshToken: first, + deviceId: 'aparelho-A', + now: t0.add(const Duration(minutes: 1))); + await service.revoke(s.refreshToken, now: t0.add(const Duration(minutes: 2))); + expect(store.byId.values.every((r) => r.revokedAt != null), isTrue); + await _expectDenied(() => service.refresh( + refreshToken: s.refreshToken, + deviceId: 'aparelho-A', + now: t0.add(const Duration(minutes: 3)))); + await service.revoke('desconhecido', now: t0); + }); + + test('toda recusa lança a MESMA mensagem', () async { + final messages = []; + Future capture(Future Function() call) async { + try { + await call(); + fail('deveria recusar'); + } on SessionExpiredException catch (e) { + messages.add(e.message); + } + } + + final a = await service.issue(_user, now: t0); + await capture(() => service.refresh( + refreshToken: 'desconhecido', deviceId: 'aparelho-A', now: t0)); + await capture(() => service.refresh( + refreshToken: a, deviceId: 'aparelho-B', now: t0)); // aparelho + final b = await service.issue(_user, now: t0); + await capture(() => service.refresh( + refreshToken: b, + deviceId: 'aparelho-A', + now: t0.add(const Duration(hours: 3)))); // expirado + final c = await service.issue(_user, now: t0); + store.accounts[_acsId] = + const RefreshAccount(active: false, microAreaId: _area); + await capture(() => service.refresh( + refreshToken: c, deviceId: 'aparelho-A', now: t0)); // inativa + + expect(messages, hasLength(4)); + expect(messages.toSet(), {RefreshTokenService.deniedMessage}); + }); + + test( + 'cada desfecho relevante gera auditoria: refresh_granted, denied_reuse, denied_device, denied_inactive', + () async { + final a = await service.issue(_user, now: t0); + final at = t0.add(const Duration(minutes: 1)); + await service.refresh(refreshToken: a, deviceId: 'aparelho-A', now: at); + await _expectDenied(() => service.refresh( + refreshToken: a, + deviceId: 'aparelho-A', + now: at.add(const Duration(minutes: 1)))); // reuso + final b = await service.issue(_user, now: t0); + await _expectDenied(() => + service.refresh(refreshToken: b, deviceId: 'aparelho-B', now: at)); + final c = await service.issue(_user, now: t0); + store.accounts[_acsId] = + const RefreshAccount(active: false, microAreaId: _area); + await _expectDenied(() => + service.refresh(refreshToken: c, deviceId: 'aparelho-A', now: at)); + + expect( + audit.results, + containsAll( + ['refresh_granted', 'denied_reuse', 'denied_device', 'denied_inactive'])); + expect(audit.events.every((e) => e.userId == _acsId), isTrue); + expect(audit.events.every((e) => e.resourceType == 'session_refresh'), isTrue); + }); +} diff --git a/backend/sinalacs_server/test/unit/rotating_ip_hasher_test.dart b/backend/sinalacs_server/test/unit/rotating_ip_hasher_test.dart new file mode 100644 index 00000000..fd6a731c --- /dev/null +++ b/backend/sinalacs_server/test/unit/rotating_ip_hasher_test.dart @@ -0,0 +1,86 @@ +import 'package:sinalacs_server/src/infrastructure/crypto/rotating_ip_hasher.dart'; +import 'package:test/test.dart'; + +void main() { + group('RotatingIpHasher', () { + test('o mesmo IP no mesmo dia produz o mesmo hash (correlação da janela)', () { + final hasher = RotatingIpHasher( + secret: 'segredo-de-teste', + clock: () => DateTime.utc(2026, 10, 7, 14, 0, 0), + ); + + expect(hasher.hash('203.0.113.7'), hasher.hash('203.0.113.7')); + }); + + test('a chave roda entre dias: o mesmo IP muda de hash no dia seguinte', () { + final dia7 = RotatingIpHasher( + secret: 'segredo-de-teste', + clock: () => DateTime.utc(2026, 10, 7, 23, 59, 59), + ); + final dia8 = RotatingIpHasher( + secret: 'segredo-de-teste', + clock: () => DateTime.utc(2026, 10, 8, 0, 0, 0), + ); + + expect(dia7.hash('203.0.113.7'), isNot(dia8.hash('203.0.113.7'))); + }); + + test('a derivação é determinística: voltar ao dia anterior reencontra o hash', () { + final dia7DeManha = RotatingIpHasher( + secret: 'segredo-de-teste', + clock: () => DateTime.utc(2026, 10, 7, 8, 0, 0), + ); + final dia8 = RotatingIpHasher( + secret: 'segredo-de-teste', + clock: () => DateTime.utc(2026, 10, 8, 12, 0, 0), + ); + final dia7DeNoite = RotatingIpHasher( + secret: 'segredo-de-teste', + clock: () => DateTime.utc(2026, 10, 7, 20, 0, 0), + ); + + final hashNoDia7 = dia7DeManha.hash('203.0.113.7'); + dia8.hash('203.0.113.7'); // roda a chave para o dia 8 + expect(dia7DeNoite.hash('203.0.113.7'), hashNoDia7); + }); + + test('IPs diferentes produzem hashes diferentes', () { + final hasher = RotatingIpHasher( + secret: 'segredo-de-teste', + clock: () => DateTime.utc(2026, 10, 7), + ); + + expect(hasher.hash('203.0.113.7'), isNot(hasher.hash('198.51.100.9'))); + }); + + test('o resultado tem 64 caracteres hexadecimais', () { + final hasher = RotatingIpHasher( + secret: 'segredo-de-teste', + clock: () => DateTime.utc(2026, 10, 7), + ); + + expect(hasher.hash('203.0.113.7'), matches(RegExp(r'^[0-9a-f]{64}$'))); + }); + + test('um segredo diferente produz um hash diferente', () { + final a = RotatingIpHasher( + secret: 'segredo-a', + clock: () => DateTime.utc(2026, 10, 7), + ); + final b = RotatingIpHasher( + secret: 'segredo-b', + clock: () => DateTime.utc(2026, 10, 7), + ); + + expect(a.hash('203.0.113.7'), isNot(b.hash('203.0.113.7'))); + }); + + test('segredo vazio é recusado em vez de produzir hash sem chave', () { + expect(() => RotatingIpHasher(secret: ''), throwsA(isA())); + expect( + () => RotatingIpHasher(secret: ' '), + throwsA(isA()), + ); + }); + }); +} diff --git a/backend/sinalacs_server/test/unit/seed_birth_date_agreement_test.dart b/backend/sinalacs_server/test/unit/seed_birth_date_agreement_test.dart index a4d31e85..9a7a0152 100644 --- a/backend/sinalacs_server/test/unit/seed_birth_date_agreement_test.dart +++ b/backend/sinalacs_server/test/unit/seed_birth_date_agreement_test.dart @@ -37,6 +37,11 @@ const _usersForaDoSeedDeCpf = { 'faltar: `findByCpfHash` não filtra papel e `verifyOtp` crava ' '`role: patient`, então o login passwordless emitiria um token de ' 'PACIENTE com o id e a microárea do ACS', + '00000000-0000-4000-8000-000000000090': + 'o admin do backoffice (ADM-001) entra por matrícula, senha e TOTP ' + '(`auth.loginStaff`); o `cpfHash` dele é o literal `development-admin`, ' + 'e um CPF real aqui faria o login passwordless emitir token de PACIENTE ' + 'com o id do admin', }; void main() { diff --git a/backend/sinalacs_server/test/unit/seed_staff_agreement_test.dart b/backend/sinalacs_server/test/unit/seed_staff_agreement_test.dart new file mode 100644 index 00000000..bc34889a --- /dev/null +++ b/backend/sinalacs_server/test/unit/seed_staff_agreement_test.dart @@ -0,0 +1,68 @@ +import 'dart:io'; + +import 'package:test/test.dart'; + +/// O admin de desenvolvimento (ADM-001, issue #39) existe em DOIS processos que +/// não se enxergam: o `development.sql` (roda por `psql`) grava `users` e +/// `staff_accounts`, e o `seed_acs_credentials.dart` grava a credencial no UUID +/// que o SQL escolheu. Divergindo, o resultado é silencioso, no estilo do +/// `seed_birth_date_agreement_test.dart`: a credencial vai para um id sem +/// `staff_accounts` (ou a FK de `users` falha) e o admin simplesmente não entra. +/// +/// `DEV_ADMIN_PASSWORD` precisa estar em quatro lugares: sem ela no compose o +/// seed não recebe a senha; sem ela no `bootstrap_env.sh` ou no `.env.example` +/// um clone limpo sobe sem admin. +/// +/// Leitura por TEXTO-FONTE; nenhuma senha entra nestas mensagens. +const _adminId = '00000000-0000-4000-8000-000000000090'; +const _adminEnrollment = 'ADM-001'; +const _adminPasswordVar = 'DEV_ADMIN_PASSWORD'; + +const _sqlPath = 'lib/src/infrastructure/database/seeds/development.sql'; +const _seedPath = 'bin/seed_acs_credentials.dart'; +const _envFiles = [ + '../../docker-compose.yml', + '../../scripts/dev/bootstrap_env.sh', + '../../.env.example', +]; + +void main() { + test('o UUID e a matrícula do admin concordam entre o SQL e o seed de credencial', () { + final sql = File(_sqlPath).readAsStringSync(); + final seed = File(_seedPath).readAsStringSync(); + + final offenders = [ + if (!sql.contains(_adminId)) '$_sqlPath não grava o UUID $_adminId', + if (!sql.contains("'$_adminEnrollment'")) + '$_sqlPath não grava a matrícula $_adminEnrollment em staff_accounts', + if (!sql.contains('staff_accounts')) + '$_sqlPath não insere em staff_accounts', + if (!seed.contains(_adminId)) + '$_seedPath não grava credencial para o UUID $_adminId', + if (!seed.contains(_adminPasswordVar)) + '$_seedPath não lê $_adminPasswordVar', + ]; + expect(offenders, isEmpty, reason: offenders.join('\n')); + }); + + test('o admin do SQL é role admin, sem microárea', () { + final sql = File(_sqlPath).readAsStringSync(); + final linha = RegExp( + "\\('$_adminId'[^\\n]*", + ).allMatches(sql).map((m) => m.group(0)!).firstWhere( + (l) => l.contains("'admin'"), + orElse: () => '', + ); + expect(linha, isNotEmpty, reason: 'não há linha de users com role admin para $_adminId'); + expect(linha, contains('NULL'), reason: 'o admin não pertence a microárea (microAreaId NULL)'); + }); + + test('$_adminPasswordVar está no compose, no bootstrap e no .env.example', () { + final offenders = [ + for (final path in _envFiles) + if (!File(path).readAsStringSync().contains(_adminPasswordVar)) + '$path não menciona $_adminPasswordVar', + ]; + expect(offenders, isEmpty, reason: offenders.join('\n')); + }); +} diff --git a/backend/sinalacs_server/test/unit/signed_consent_log_test.dart b/backend/sinalacs_server/test/unit/signed_consent_log_test.dart new file mode 100644 index 00000000..074fdcfe --- /dev/null +++ b/backend/sinalacs_server/test/unit/signed_consent_log_test.dart @@ -0,0 +1,45 @@ +import 'package:sinalacs_server/src/application/onboarding/consent_signature.dart'; +import 'package:sinalacs_server/src/application/onboarding/onboarding_service.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/infrastructure/database/signed_consent_log.dart'; +import 'package:test/test.dart'; + +void main() { + final signature = ConsentSignature(secret: 'segredo-de-teste'); + final entry = ConsentLogEntry( + userId: '00000000-0000-4000-8000-000000000001', + purpose: ConsentPurpose.localReminders, + action: 'denied', + version: '2026.1', + timestamp: DateTime.utc(2026, 9, 28, 12), + ); + + test('assina exatamente os campos que ConsentSignature.compute recebe', () { + final row = signedConsentLog(entry, signature: signature, origin: 'painel-titular'); + + expect( + row.signature, + signature.compute( + userId: entry.userId, + purpose: 'localReminders', + action: 'denied', + version: '2026.1', + timestamp: entry.timestamp, + ), + ); + expect(row.userId.uuid, entry.userId); + expect(row.purpose, 'localReminders'); + expect(row.action, 'denied'); + expect(row.version, '2026.1'); + expect(row.timestamp, entry.timestamp); + }); + + test('ipHash e userAgent levam o marcador de ausência com a origem do evento', () { + final onboarding = signedConsentLog(entry, signature: signature, origin: 'onboarding'); + expect(onboarding.ipHash, 'nao-aplicavel-onboarding'); + expect(onboarding.userAgent, 'nao-aplicavel-onboarding'); + + final painel = signedConsentLog(entry, signature: signature, origin: 'painel-titular'); + expect(painel.ipHash, 'nao-aplicavel-painel-titular'); + }); +} diff --git a/backend/sinalacs_server/test/unit/staff_activation_code_test.dart b/backend/sinalacs_server/test/unit/staff_activation_code_test.dart new file mode 100644 index 00000000..20c7739d --- /dev/null +++ b/backend/sinalacs_server/test/unit/staff_activation_code_test.dart @@ -0,0 +1,31 @@ +import 'dart:math'; + +import 'package:sinalacs_server/src/application/auth/staff_activation_code.dart'; +import 'package:test/test.dart'; + +void main() { + test('generate devolve grupos de 4 base32 e sempre muda', () { + final a = StaffActivationCode.generate(Random(1)); + final b = StaffActivationCode.generate(Random(2)); + expect(a, matches(RegExp(r'^([A-Z2-7]{4}-){6}[A-Z2-7]{2}$'))); + expect(a, isNot(b)); + }); + + test('normalize ignora caixa, espaços e hífens', () { + expect(StaffActivationCode.normalize(' ab cd-ef '), 'ABCDEF'); + }); + + test('matches aceita o mesmo código em outra formatação e recusa o errado', () { + final c = StaffActivationCode.generate(Random(3)); + final h = StaffActivationCode.hash(c); + expect(StaffActivationCode.matches(c.toLowerCase().replaceAll('-', ' '), h), isTrue); + expect(StaffActivationCode.matches('${c}X', h), isFalse); + expect(StaffActivationCode.matches('', h), isFalse); + }); + + test('o hash não contém o código', () { + final c = StaffActivationCode.generate(Random(4)); + expect(StaffActivationCode.hash(c), isNot(contains(StaffActivationCode.normalize(c)))); + expect(StaffActivationCode.hash(c), hasLength(64)); + }); +} diff --git a/backend/sinalacs_server/test/unit/terms_change_schedule_test.dart b/backend/sinalacs_server/test/unit/terms_change_schedule_test.dart new file mode 100644 index 00000000..fc797170 --- /dev/null +++ b/backend/sinalacs_server/test/unit/terms_change_schedule_test.dart @@ -0,0 +1,54 @@ +import 'dart:io'; + +import 'package:sinalacs_server/src/application/onboarding/onboarding_service.dart' + show consentPolicyVersion; +import 'package:sinalacs_server/src/application/patients/terms_change_schedule.dart'; +import 'package:test/test.dart'; + +final _pub = DateTime.utc(2026, 10, 1); + +TermsChangeSchedule agenda({DateTime? vigencia, String versao = '2026.2'}) => TermsChangeSchedule( + version: versao, + publishedAt: _pub, + effectiveFrom: vigencia ?? _pub.add(const Duration(days: 15)), + summary: 'Resumo.', + ); + +void main() { + test('vigência com menos de 15 dias da publicação não existe', () { + expect( + () => agenda(vigencia: _pub.add(const Duration(days: 14, hours: 23))), + throwsA(isA()), + ); + }); + + test('a regra vale sem `assert` (binário de release não roda assert)', () { + // Prova que a recusa não depende de asserts: vem de um `throw` no corpo. + final source = File('lib/src/application/patients/terms_change_schedule.dart').readAsStringSync(); + expect(source.contains('assert('), isFalse, reason: 'assert some em release'); + expect(source.contains('ArgumentError'), isTrue); + }); + + test('exatamente 15 dias é aceito', () { + expect(agenda().effectiveFrom, _pub.add(const Duration(days: 15))); + }); + + test('a versão anunciada não pode ser a vigente', () { + expect(() => agenda(versao: consentPolicyVersion), throwsA(isA())); + }); + + test('ativa na publicação, inativa na vigência', () { + final a = agenda(); + expect(a.isActiveAt(_pub.subtract(const Duration(seconds: 1))), isFalse); + expect(a.isActiveAt(_pub), isTrue); + expect(a.isActiveAt(a.effectiveFrom.subtract(const Duration(seconds: 1))), isTrue); + expect(a.isActiveAt(a.effectiveFrom), isFalse); + }); + + test('a agenda real do repositório respeita a regra (vazia hoje)', () { + final real = upcomingTermsChange; + if (real != null) { + expect(real.effectiveFrom.difference(real.publishedAt) >= termsChangeNoticePeriod, isTrue); + } + }); +} diff --git a/backend/sinalacs_server/test/unit/terms_publication_service_test.dart b/backend/sinalacs_server/test/unit/terms_publication_service_test.dart new file mode 100644 index 00000000..f2392f8a --- /dev/null +++ b/backend/sinalacs_server/test/unit/terms_publication_service_test.dart @@ -0,0 +1,438 @@ +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/onboarding/onboarding_service.dart' + show consentPolicyVersion; +import 'package:sinalacs_server/src/application/patients/terms_publication_service.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:test/test.dart'; + +const _placeholder = '[TEXTO PLACEHOLDER]'; + +AuthenticatedUser _user(UserRole role, {String id = 'u-1'}) => + AuthenticatedUser(id: id, role: role, microAreaId: null, deviceId: 'dev-1'); + +class _RecordingAudit extends AuditTrail { + final events = []; + + @override + Future record(AuditEvent event) async => events.add(event); + + List get dump => [ + for (final e in events) + '${e.userId}|${e.actionType}|${e.resourceType}|${e.resourceId}|${e.result}', + ]; +} + +class _ThrowingAudit extends AuditTrail { + var calls = 0; + + @override + Future record(AuditEvent event) async { + calls++; + throw StateError('auditoria fora do ar'); + } +} + +class _FakeStore implements TermsVersionStore { + final rows = {}; + var _seq = 0; + + @override + Future insertDraft({ + required String version, + required String summary, + required DateTime effectiveFrom, + required String createdBy, + required DateTime createdAt, + }) async { + final id = '00000000-0000-4000-8000-${(++_seq).toString().padLeft(12, '0')}'; + rows[id] = TermsVersionSnapshot( + id: id, + version: version, + summary: summary, + effectiveFrom: effectiveFrom, + createdAt: createdAt, + ); + return id; + } + + @override + Future findById(String id) async => rows[id]; + + @override + Future versionExists(String version) async => + rows.values.any((r) => r.version == version); + + @override + Future markReviewed(String id, {required String reviewedBy, required DateTime at}) async { + final r = rows[id]; + if (r == null || r.legallyReviewedAt != null || r.publishedAt != null) return false; + rows[id] = _copy(r, reviewedAt: at, reviewedBy: reviewedBy); + return true; + } + + @override + Future markPublished(String id, {required String publishedBy, required DateTime at}) async { + final r = rows[id]; + if (r == null || r.legallyReviewedAt == null || r.publishedAt != null) return false; + rows[id] = _copy(r, publishedAt: at, publishedBy: publishedBy); + return true; + } + + @override + Future> list() async => rows.values.toList(); + + @override + Future currentPublished(DateTime now) async { + final c = rows.values + .where((r) => r.publishedAt != null && r.effectiveFrom.isAfter(now)) + .toList() + ..sort((a, b) => b.publishedAt!.compareTo(a.publishedAt!)); + return c.isEmpty ? null : c.first; + } + + TermsVersionSnapshot _copy( + TermsVersionSnapshot r, { + DateTime? reviewedAt, + String? reviewedBy, + DateTime? publishedAt, + String? publishedBy, + }) => + TermsVersionSnapshot( + id: r.id, + version: r.version, + summary: r.summary, + effectiveFrom: r.effectiveFrom, + createdAt: r.createdAt, + legallyReviewedAt: reviewedAt ?? r.legallyReviewedAt, + legallyReviewedBy: reviewedBy ?? r.legallyReviewedBy, + publishedAt: publishedAt ?? r.publishedAt, + publishedBy: publishedBy ?? r.publishedBy, + ); +} + +void main() { + late _FakeStore store; + late _RecordingAudit audit; + late DateTime now; + late TermsPublicationService service; + final admin = _user(UserRole.admin, id: 'adm-1'); + + setUp(() { + store = _FakeStore(); + audit = _RecordingAudit(); + now = DateTime.utc(2099, 1, 1, 12); + service = TermsPublicationService(store: store, audit: audit, clock: () => now); + }); + + Future draft({String version = '2099.1', int days = 20}) => + service.draft(admin, + version: version, + summary: _placeholder, + effectiveFrom: now.add(Duration(days: days))); + + test('publicar sem revisão jurídica: recusa e audita denied_not_reviewed', () async { + final d = await draft(); + await expectLater(service.publish(admin, d.id), throwsA(isA())); + expect(audit.events.last.result, 'denied_not_reviewed'); + expect(audit.events.last.resourceType, 'terms_version'); + expect(audit.events.last.resourceId, d.id); + expect(store.rows[d.id]!.publishedAt, isNull); + }); + + test('vigência a menos de 15 dias da publicação: recusa', () async { + final d = await draft(); + await service.markReviewed(admin, d.id); + now = d.effectiveFrom.subtract(const Duration(days: 15)).add(const Duration(seconds: 1)); + await expectLater(service.publish(admin, d.id), throwsA(isA())); + expect(audit.events.last.result, 'denied_notice_period'); + expect(store.rows[d.id]!.publishedAt, isNull); + }); + + test('vigência exatamente 15 dias após a publicação: aceita e audita published', () async { + final d = await draft(); + await service.markReviewed(admin, d.id); + now = d.effectiveFrom.subtract(const Duration(days: 15)); + final p = await service.publish(admin, d.id); + expect(p.publishedAt, now); + expect(p.publishedBy, 'adm-1'); + expect(audit.events.last.result, 'published'); + expect(audit.events.last.resourceId, d.id); + }); + + for (final role in [UserRole.coordinator, UserRole.acs, UserRole.patient]) { + test('papel $role: rascunhar, revisar, publicar e listar dão denied_role', () async { + final d = await draft(); + final u = _user(role, id: 'x-1'); + final before = audit.events.length; + await expectLater( + service.draft(u, version: '2099.2', summary: _placeholder, effectiveFrom: now.add(const Duration(days: 20))), + throwsA(isA())); + await expectLater(service.markReviewed(u, d.id), throwsA(isA())); + await expectLater(service.publish(u, d.id), throwsA(isA())); + await expectLater(service.list(u), throwsA(isA())); + final denied = audit.events.skip(before).toList(); + expect(denied, hasLength(4)); + expect(denied.map((e) => e.result).toSet(), {'denied_role'}); + expect(denied.every((e) => e.resourceType == 'terms_version'), isTrue); + expect(store.rows, hasLength(1)); + expect(store.rows[d.id]!.legallyReviewedAt, isNull); + }); + } + + test('negação com id que não é UUID grava resourceId nulo', () async { + await expectLater(service.publish(_user(UserRole.acs), 'não-uuid'), throwsA(isA())); + expect(audit.events.last.resourceId, isNull); + }); + + test('versão igual à vigente (consentPolicyVersion) é recusada no rascunho', () async { + await expectLater( + service.draft(admin, + version: consentPolicyVersion, + summary: _placeholder, + effectiveFrom: now.add(const Duration(days: 20))), + throwsA(isA()), + ); + expect(store.rows, isEmpty); + }); + + test('versão duplicada é recusada com mensagem própria', () async { + await draft(); + await expectLater( + draft(), + throwsA(isA() + .having((e) => e.message, 'message', 'Já existe uma versão com este número.')), + ); + }); + + test('versão vazia e resumo vazio ou acima de 280 são recusados', () async { + final eff = now.add(const Duration(days: 20)); + await expectLater(service.draft(admin, version: ' ', summary: _placeholder, effectiveFrom: eff), + throwsA(isA())); + await expectLater(service.draft(admin, version: 'v', summary: ' ', effectiveFrom: eff), + throwsA(isA())); + await expectLater( + service.draft(admin, version: 'v', summary: 'x' * 281, effectiveFrom: eff), + throwsA(isA())); + final ok = await service.draft(admin, version: 'v', summary: 'x' * 280, effectiveFrom: eff); + expect(ok.summary.length, 280); + }); + + test('rascunho com vigência no passado é recusado; com 14 dias é aceito', () async { + await expectLater( + service.draft(admin, + version: '2099.1', summary: _placeholder, effectiveFrom: now.subtract(const Duration(seconds: 1))), + throwsA(isA()), + ); + final d = await draft(days: 14); + await service.markReviewed(admin, d.id); + await expectLater(service.publish(admin, d.id), throwsA(isA())); + expect(audit.events.last.result, 'denied_notice_period'); + }); + + test('revisar de novo ou revisar versão publicada: recusa e não sobrescreve', () async { + final d = await draft(); + final r = await service.markReviewed(admin, d.id); + now = now.add(const Duration(hours: 1)); + await expectLater(service.markReviewed(_user(UserRole.admin, id: 'adm-2'), d.id), + throwsA(isA())); + expect(store.rows[d.id]!.legallyReviewedAt, r.legallyReviewedAt); + expect(store.rows[d.id]!.legallyReviewedBy, 'adm-1'); + await service.publish(admin, d.id); + await expectLater(service.markReviewed(admin, d.id), throwsA(isA())); + }); + + test('publicar de novo uma versão já publicada é recusado', () async { + final d = await draft(); + await service.markReviewed(admin, d.id); + final p = await service.publish(admin, d.id); + now = now.add(const Duration(hours: 1)); + await expectLater(service.publish(admin, d.id), throwsA(isA())); + expect(store.rows[d.id]!.publishedAt, p.publishedAt); + }); + + test('versão inexistente: Versão não encontrada', () async { + await expectLater( + service.publish(admin, '00000000-0000-4000-8000-0000000000ff'), + throwsA(isA() + .having((e) => e.message, 'message', 'Versão não encontrada.')), + ); + }); + + test('nova publicação enquanto outra ainda não vigente substitui a anterior (uma só agenda)', () async { + final a = await draft(version: '2099.1', days: 20); + await service.markReviewed(admin, a.id); + await service.publish(admin, a.id); + expect((await store.currentPublished(now))!.id, a.id); + + now = now.add(const Duration(hours: 1)); + final b = await draft(version: '2099.2', days: 30); + await service.markReviewed(admin, b.id); + await service.publish(admin, b.id); + expect((await store.currentPublished(now))!.id, b.id); + }); + + test('auditoria: dump completo e sem resumo/texto', () async { + final d = await draft(); + await service.markReviewed(admin, d.id); + await service.publish(admin, d.id); + await service.list(admin); + expect(audit.dump, [ + 'adm-1|write|terms_version|${d.id}|drafted', + 'adm-1|write|terms_version|${d.id}|reviewed', + 'adm-1|write|terms_version|${d.id}|published', + 'adm-1|read|terms_version|null|granted', + ]); + expect(audit.dump.join().contains('PLACEHOLDER'), isFalse); + }); + + test('identificador que não é UUID: Identificador inválido, sem tocar o store', () async { + for (final ruim in ['', 'abc', 'x; drop', '0000-4000']) { + await expectLater( + service.markReviewed(admin, ruim), + throwsA(isA() + .having((e) => e.message, 'message', 'Identificador inválido.')), + ); + await expectLater( + service.publish(admin, ruim), + throwsA(isA() + .having((e) => e.message, 'message', 'Identificador inválido.')), + ); + } + expect(audit.events, isEmpty); + }); + + test('identificador inválido: o papel vem antes (coordenador recebe denied_role)', () async { + await expectLater(service.publish(_user(UserRole.coordinator), 'abc'), throwsA(isA())); + expect(audit.events.last.result, 'denied_role'); + }); + + group('auditoria fail-closed (record, nunca recordSafely)', () { + late _ThrowingAudit broken; + late TermsPublicationService svc; + final falha = throwsA( + isA().having( + (e) => e.message, + 'message', + 'auditoria fora do ar', + ), + ); + + setUp(() { + broken = _ThrowingAudit(); + svc = TermsPublicationService( + store: store, + audit: broken, + clock: () => now, + ); + }); + + Future seeded({bool reviewed = false}) async { + final d = await draft(); + if (reviewed) await service.markReviewed(admin, d.id); + return d; + } + + test('draft: auditoria falha propaga o erro', () async { + await expectLater( + svc.draft( + admin, + version: '2099.9', + summary: _placeholder, + effectiveFrom: now.add(const Duration(days: 20)), + ), + falha, + ); + }); + + test('markReviewed: auditoria falha propaga o erro', () async { + final d = await seeded(); + await expectLater(svc.markReviewed(admin, d.id), falha); + }); + + test( + 'publish: auditoria falha propaga o erro (sucesso e denied_not_reviewed)', + () async { + final d = await seeded(); + await expectLater( + svc.publish(admin, d.id), + falha, + ); // denied_not_reviewed + await service.markReviewed(admin, d.id); + await expectLater(svc.publish(admin, d.id), falha); // published + }, + ); + + test( + 'negação por papel continua negando: erro da trilha sobe, nada é gravado', + () async { + final d = await seeded(); + await expectLater( + svc.publish(_user(UserRole.coordinator), d.id), + falha, + ); + expect(store.rows[d.id]!.publishedAt, isNull); + expect(broken.calls, 1); + }, + ); + + test('list: se a leitura não grava, o store NÃO é listado', () async { + await seeded(); + final listados = []; + final spy = _ListSpy(store, () => listados.add(1)); + final s = TermsPublicationService( + store: spy, + audit: broken, + clock: () => now, + ); + await expectLater(s.list(admin), falha); + expect(listados, isEmpty); + }); + }); +} + +class _ListSpy implements TermsVersionStore { + _ListSpy(this._i, this._onList); + final _FakeStore _i; + final void Function() _onList; + + @override + Future> list() { + _onList(); + return _i.list(); + } + + @override + Future insertDraft({ + required String version, + required String summary, + required DateTime effectiveFrom, + required String createdBy, + required DateTime createdAt, + }) => _i.insertDraft( + version: version, + summary: summary, + effectiveFrom: effectiveFrom, + createdBy: createdBy, + createdAt: createdAt, + ); + @override + Future findById(String id) => _i.findById(id); + @override + Future versionExists(String v) => _i.versionExists(v); + @override + Future markReviewed( + String id, { + required String reviewedBy, + required DateTime at, + }) => _i.markReviewed(id, reviewedBy: reviewedBy, at: at); + @override + Future markPublished( + String id, { + required String publishedBy, + required DateTime at, + }) => _i.markPublished(id, publishedBy: publishedBy, at: at); + @override + Future currentPublished(DateTime n) => + _i.currentPublished(n); +} diff --git a/backend/sinalacs_server/test/unit/totp_test.dart b/backend/sinalacs_server/test/unit/totp_test.dart new file mode 100644 index 00000000..5f290944 --- /dev/null +++ b/backend/sinalacs_server/test/unit/totp_test.dart @@ -0,0 +1,80 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:sinalacs_server/src/application/auth/totp.dart'; +import 'package:test/test.dart'; + +void main() { + // Segredo da RFC 6238 (Apêndice B): ASCII "12345678901234567890". + final segredo = Uint8List.fromList(ascii.encode('12345678901234567890')); + DateTime em(int segundos) => DateTime.fromMillisecondsSinceEpoch(segundos * 1000, isUtc: true); + + group('RFC 6238 — vetores do SHA-1 (6 últimos dígitos)', () { + final vetores = { + 59: '287082', + 1111111109: '081804', + 1111111111: '050471', + 1234567890: '005924', + 2000000000: '279037', + 20000000000: '353130', + }; + vetores.forEach((t, esperado) { + test('T=$t → $esperado', () => expect(Totp.code(segredo, em(t)), esperado)); + }); + }); + + test('base32 do segredo da RFC é o valor conhecido', () { + expect(Totp.base32(segredo), 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ'); + }); + + test('o passo muda a cada 30 s', () { + expect(Totp.stepOf(em(29)), 0); + expect(Totp.stepOf(em(30)), 1); + }); + + group('verify', () { + test('aceita o código do passo atual e devolve o passo', () { + final agora = em(1111111111); + expect(Totp.verify(segredo, Totp.code(segredo, agora), agora), Totp.stepOf(agora)); + }); + + test('aceita um passo antes e um depois (relógio fora por até 30 s)', () { + final agora = em(1111111111); + expect(Totp.verify(segredo, Totp.code(segredo, agora.subtract(const Duration(seconds: 30))), agora), + Totp.stepOf(agora) - 1); + expect(Totp.verify(segredo, Totp.code(segredo, agora.add(const Duration(seconds: 30))), agora), + Totp.stepOf(agora) + 1); + }); + + test('recusa dois passos de distância', () { + final agora = em(1111111111); + expect(Totp.verify(segredo, Totp.code(segredo, agora.add(const Duration(seconds: 60))), agora), isNull); + }); + + test('recusa passo já usado (replay): só aceita passo MAIOR que lastStep', () { + final agora = em(1111111111); + final passo = Totp.stepOf(agora); + expect(Totp.verify(segredo, Totp.code(segredo, agora), agora, lastStep: passo), isNull); + expect(Totp.verify(segredo, Totp.code(segredo, agora), agora, lastStep: passo - 1), passo); + }); + + test('recusa código com tamanho errado ou não numérico, sem lançar', () { + final agora = em(1111111111); + expect(Totp.verify(segredo, '12345', agora), isNull); + expect(Totp.verify(segredo, '1234567', agora), isNull); + expect(Totp.verify(segredo, 'abcdef', agora), isNull); + expect(Totp.verify(segredo, '', agora), isNull); + }); + + test('ignora espaços em volta do código (apps agrupam "123 456")', () { + final agora = em(1111111111); + final c = Totp.code(segredo, agora); + expect(Totp.verify(segredo, '${c.substring(0, 3)} ${c.substring(3)}', agora), Totp.stepOf(agora)); + }); + }); + + test('otpauthUri tem o formato que os autenticadores leem', () { + final uri = Totp.otpauthUri(secretBase32: 'ABC234', account: 'ACS-001'); + expect(uri, 'otpauth://totp/SinalACS:ACS-001?secret=ABC234&issuer=SinalACS&algorithm=SHA1&digits=6&period=30'); + }); +} diff --git a/backend/sinalacs_server/test/unit/ubs_contact_service_test.dart b/backend/sinalacs_server/test/unit/ubs_contact_service_test.dart new file mode 100644 index 00000000..5dd18c9c --- /dev/null +++ b/backend/sinalacs_server/test/unit/ubs_contact_service_test.dart @@ -0,0 +1,76 @@ +import 'package:sinalacs_server/src/application/auth/development_auth_service.dart'; +import 'package:sinalacs_server/src/application/ubs/ubs_contact_service.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:test/test.dart'; + +const _acsId = '00000000-0000-4000-8000-000000000002'; + +class _FakeStore implements UbsContactStore { + _FakeStore(this.record); + UbsContactRecord? record; + final consultados = []; + + @override + Future findForAcs(String acsId) async { + consultados.add(acsId); + return record; + } +} + +AuthenticatedUser _usuario(UserRole role) => AuthenticatedUser( + id: _acsId, + role: role, + microAreaId: '00000000-0000-4000-8000-000000000003', + deviceId: 'dispositivo-teste', +); + +void main() { + test('devolve nome e telefone da UBS do ACS', () async { + final service = UbsContactService( + store: _FakeStore( + const UbsContactRecord(name: 'UBS Teste', phone: '+55 11 5550-0100'), + ), + ); + + final contato = await service.contactFor(_usuario(UserRole.acs)); + + expect(contato.name, 'UBS Teste'); + expect(contato.phone, '+55 11 5550-0100'); + }); + + test('UBS sem telefone cadastrado devolve phone nulo, sem falhar', () async { + final service = UbsContactService( + store: _FakeStore( + const UbsContactRecord(name: 'UBS Sem Fone', phone: null), + ), + ); + final contato = await service.contactFor(_usuario(UserRole.acs)); + expect(contato.phone, isNull); + }); + + test('telefone só com espaços vale como não cadastrado', () async { + final service = UbsContactService( + store: _FakeStore(const UbsContactRecord(name: 'UBS', phone: ' ')), + ); + expect((await service.contactFor(_usuario(UserRole.acs))).phone, isNull); + }); + + test('paciente não consulta o contato (nem toca no banco)', () async { + final store = _FakeStore(const UbsContactRecord(name: 'UBS', phone: '1')); + final service = UbsContactService(store: store); + + expect( + () => service.contactFor(_usuario(UserRole.patient)), + throwsA(isA()), + ); + expect(store.consultados, isEmpty); + }); + + test('ACS sem linha na tabela acs é erro, não contato vazio', () async { + final service = UbsContactService(store: _FakeStore(null)); + expect( + () => service.contactFor(_usuario(UserRole.acs)), + throwsA(isA()), + ); + }); +} diff --git a/backend/sinalacs_server/test/unit/uncaught_error_policy_test.dart b/backend/sinalacs_server/test/unit/uncaught_error_policy_test.dart new file mode 100644 index 00000000..ede9cbd0 --- /dev/null +++ b/backend/sinalacs_server/test/unit/uncaught_error_policy_test.dart @@ -0,0 +1,66 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:mqtt_client/mqtt_client.dart'; +import 'package:sinalacs_server/src/runtime/uncaught_error_policy.dart'; +import 'package:test/test.dart'; + +void main() { + group('isTransientIoError', () { + test('reconhece os erros de E/S de queda de rede e de broker', () { + expect(isTransientIoError(const SocketException('x')), isTrue); + expect(isTransientIoError(const HandshakeException('x')), isTrue); + expect(isTransientIoError(const TlsException('x')), isTrue); + expect(isTransientIoError(TimeoutException('x')), isTrue); + expect( + isTransientIoError(ConnectionException(MqttConnectionState.faulted)), + isTrue, + ); + expect(isTransientIoError(NoConnectionException('x')), isTrue); + expect(isTransientIoError(StateError('socket is null')), isTrue); + }); + + test('não trata como transitório o que é defeito de programa', () { + expect(isTransientIoError(StateError('outro')), isFalse); + expect(isTransientIoError(ArgumentError('x')), isFalse); + expect(isTransientIoError(const FormatException('x')), isFalse); + expect(isTransientIoError(Exception('x')), isFalse); + }); + }); + + group('buildUncaughtErrorHandler', () { + late List logs; + late List exits; + late void Function(Object, StackTrace) handler; + + setUp(() { + logs = []; + exits = []; + handler = buildUncaughtErrorHandler(log: logs.add, exitFn: exits.add); + }); + + test('E/S transitória: registra tipo e errno, mantém vivo', () { + handler( + const SocketException( + 'segredo 10.0.0.1:8883', + osError: OSError('Connection reset by peer', 104), + ), + StackTrace.current, + ); + expect(exits, isEmpty); + expect(logs.single, contains('SocketException')); + expect(logs.single, contains('errno=104')); + expect(logs.single, isNot(contains('segredo'))); + expect(logs.single, isNot(contains('10.0.0.1'))); + }); + + test('outro erro: registra só o tipo e sai com código != 0', () { + handler(StateError('dado do paciente 123'), StackTrace.current); + expect(exits, [uncaughtFatalExitCode]); + expect(uncaughtFatalExitCode, isNot(0)); + expect(logs.single, contains('StateError')); + expect(logs.single, isNot(contains('paciente'))); + expect(logs.single, isNot(contains('#0'))); + }); + }); +} diff --git a/backend/sinalacs_server/test/unit/upload_token_service_test.dart b/backend/sinalacs_server/test/unit/upload_token_service_test.dart new file mode 100644 index 00000000..8851c076 --- /dev/null +++ b/backend/sinalacs_server/test/unit/upload_token_service_test.dart @@ -0,0 +1,348 @@ +import 'dart:convert'; + +import 'package:crypto/crypto.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/auth/institutional_auth_service.dart'; +import 'package:sinalacs_server/src/application/auth/refresh_token_service.dart'; +import 'package:sinalacs_server/src/application/auth/upload_token_service.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:test/test.dart'; + +/// Token de envio diferido do ACS (D7 do plano 2026-10-03), com store em +/// memória. Dados sintéticos. +const _acsId = '00000000-0000-4000-8000-0000000000c1'; +const _area = '00000000-0000-4000-8000-0000000000c2'; +const _novaArea = '00000000-0000-4000-8000-0000000000c3'; +const _user = AuthenticatedUser( + id: _acsId, role: UserRole.acs, microAreaId: _area, deviceId: 'aparelho-A'); + +String _sha(String t) => sha256.convert(utf8.encode(t)).toString(); + +class _MemoryStore implements UploadTokenStore { + final Map byId = {}; + final Map hashToId = {}; + final Map accounts = {}; + + @override + Future replace(UploadTokenRecord record, String tokenHash) async { + for (final e in byId.entries.toList()) { + final r = e.value; + if (r.userId == record.userId && + r.deviceId == record.deviceId && + r.revokedAt == null) { + byId[e.key] = _copy(r, revokedAt: record.issuedAt); + } + } + byId[record.id] = record; + hashToId[tokenHash] = record.id; + } + + @override + Future findByHash(String tokenHash) async { + final id = hashToId[tokenHash]; + return id == null ? null : byId[id]; + } + + @override + Future revoke(String id, DateTime at) async { + final r = byId[id]; + if (r != null && r.revokedAt == null) byId[id] = _copy(r, revokedAt: at); + } + + /// Desativação por conta (#43): todos os tokens do usuário, de uma vez. + final revogacoesTotais = <({String userId, DateTime at})>[]; + + @override + Future revokeAllForUser(String userId, DateTime at) async { + revogacoesTotais.add((userId: userId, at: at)); + for (final e in byId.entries.toList()) { + if (e.value.userId == userId && e.value.revokedAt == null) { + byId[e.key] = _copy(e.value, revokedAt: at); + } + } + } + + @override + Future findAccount(String userId) async => accounts[userId]; + + @override + Future deleteExpiredFor(String userId, DateTime before) async { + final ids = byId.entries + .where((e) => e.value.userId == userId && e.value.expiresAt.isBefore(before)) + .map((e) => e.key) + .toSet(); + byId.removeWhere((k, _) => ids.contains(k)); + hashToId.removeWhere((_, v) => ids.contains(v)); + } + + UploadTokenRecord _copy(UploadTokenRecord r, {DateTime? revokedAt}) => + UploadTokenRecord( + id: r.id, + userId: r.userId, + deviceId: r.deviceId, + issuedAt: r.issuedAt, + expiresAt: r.expiresAt, + revokedAt: revokedAt ?? r.revokedAt, + ); +} + +class _AuditSpy extends AuditTrail { + final List events = []; + @override + Future record(AuditEvent event) async => events.add(event); + List get results => events.map((e) => e.result).toList(); +} + +void main() { + late _MemoryStore store; + late _AuditSpy audit; + late UploadTokenService service; + final t0 = DateTime.utc(2026, 10, 3, 10); + + setUp(() { + store = _MemoryStore(); + audit = _AuditSpy(); + service = UploadTokenService(store: store, audit: audit); + store.accounts[_acsId] = const RefreshAccount(active: true, microAreaId: _area); + }); + + /// Toda recusa: a MESMA exceção com a MESMA mensagem. + Future expectDenied(Future Function() call) async { + try { + await call(); + fail('deveria recusar'); + } on SessionExpiredException catch (e) { + expect(e.message, UploadTokenService.deniedMessage); + } + } + + test('constantes do contrato', () { + expect(UploadTokenService.lifetime, const Duration(days: 7)); + expect(UploadTokenService.deniedMessage, 'Envio não autorizado. Entre novamente.'); + // Mensagem própria: não se confunde com a recusa do refresh token. + expect(UploadTokenService.deniedMessage, + isNot(RefreshTokenService.deniedMessage)); + }); + + test('issue guarda só o hash, amarra ao (usuário, aparelho) e vale 7 dias', + () async { + final token = await service.issue(_user, now: t0); + expect(token.length, greaterThanOrEqualTo(43)); + expect(token, isNot(contains('='))); + expect(store.hashToId.keys, [_sha(token)]); + expect(store.hashToId.keys, isNot(contains(token))); + final r = store.byId.values.single; + expect(r.userId, _acsId); + expect(r.deviceId, 'aparelho-A'); + expect(r.issuedAt, t0); + expect(r.expiresAt, t0.add(const Duration(days: 7))); + expect(r.revokedAt, isNull); + expect(r.id, matches(RegExp(r'^[0-9a-f-]{36}$'))); + expect(audit.results, ['upload_token_issued']); + expect(audit.events.single.userId, _acsId); + // Nada do token na trilha. + for (final e in audit.events) { + expect('${e.resourceId}${e.result}${e.resourceType}', isNot(contains(token))); + } + }); + + test('issue recusa papel que não é ACS e aparelho ausente/sentinela', () async { + expect( + () => service.issue( + const AuthenticatedUser( + id: _acsId, role: UserRole.patient, microAreaId: _area, deviceId: 'x'), + now: t0), + throwsArgumentError); + for (final device in ['', ' ', InstitutionalAuthService.deviceIdAbsent]) { + expect( + () => service.issue( + AuthenticatedUser( + id: _acsId, role: UserRole.acs, microAreaId: _area, deviceId: device), + now: t0), + throwsArgumentError, + reason: 'deviceId="$device"'); + } + expect(store.byId, isEmpty); + }); + + test('issue revoga o token anterior do MESMO (usuário, aparelho) e só ele', + () async { + final primeiro = await service.issue(_user, now: t0); + final outroAparelho = await service.issue( + const AuthenticatedUser( + id: _acsId, role: UserRole.acs, microAreaId: _area, deviceId: 'aparelho-B'), + now: t0); + final segundo = + await service.issue(_user, now: t0.add(const Duration(minutes: 1))); + + await expectDenied(() => service.resolve( + uploadToken: primeiro, deviceId: 'aparelho-A', now: t0.add(const Duration(minutes: 2)))); + final u = await service.resolve( + uploadToken: segundo, deviceId: 'aparelho-A', now: t0.add(const Duration(minutes: 2))); + expect(u.id, _acsId); + final b = await service.resolve( + uploadToken: outroAparelho, + deviceId: 'aparelho-B', + now: t0.add(const Duration(minutes: 2))); + expect(b.deviceId, 'aparelho-B'); + }); + + test('resolve devolve o dono como ACS, com a microárea RELIDA do banco', + () async { + final token = await service.issue(_user, now: t0); + store.accounts[_acsId] = const RefreshAccount(active: true, microAreaId: _novaArea); + final u = await service.resolve( + uploadToken: token, deviceId: 'aparelho-A', now: t0.add(const Duration(days: 6))); + expect(u.id, _acsId); + expect(u.role, UserRole.acs); + expect(u.microAreaId, _novaArea); + expect(u.deviceId, 'aparelho-A'); + }); + + test('resolve vale até 7 dias e recusa em 7 dias + 1 s (vencido)', () async { + final token = await service.issue(_user, now: t0); + await service.resolve( + uploadToken: token, + deviceId: 'aparelho-A', + now: t0.add(const Duration(days: 7)).subtract(const Duration(seconds: 1))); + await expectDenied(() => service.resolve( + uploadToken: token, + deviceId: 'aparelho-A', + now: t0.add(const Duration(days: 7, seconds: 1)))); + expect(audit.results, contains('upload_token_denied_expired')); + }); + + test('fronteira exata: em issuedAt + 7 d recusa; 1 s antes aceita', () async { + final token = await service.issue(_user, now: t0); + final limite = t0.add(UploadTokenService.lifetime); + final u = await service.resolve( + uploadToken: token, + deviceId: 'aparelho-A', + now: limite.subtract(const Duration(seconds: 1))); + expect(u.id, _acsId); + await expectDenied(() => + service.resolve(uploadToken: token, deviceId: 'aparelho-A', now: limite)); + }); + + test('aparelho diferente: recusa, audita e revoga o token', () async { + final token = await service.issue(_user, now: t0); + await expectDenied(() => service.resolve( + uploadToken: token, deviceId: 'aparelho-do-ladrao', now: t0)); + expect(audit.results, contains('upload_token_denied_device')); + expect(store.byId.values.single.revokedAt, isNotNull); + // Nem o aparelho certo usa mais. + await expectDenied(() => + service.resolve(uploadToken: token, deviceId: 'aparelho-A', now: t0)); + }); + + test('token revogado: recusa com upload_token_denied_revoked', () async { + final token = await service.issue(_user, now: t0); + await service.revoke(token, now: t0); + await expectDenied(() => + service.resolve(uploadToken: token, deviceId: 'aparelho-A', now: t0)); + expect(audit.results, containsAll(['upload_token_revoked', 'upload_token_denied_revoked'])); + }); + + test('conta inativa: recusa, audita e revoga', () async { + final token = await service.issue(_user, now: t0); + store.accounts[_acsId] = const RefreshAccount(active: false, microAreaId: _area); + await expectDenied(() => + service.resolve(uploadToken: token, deviceId: 'aparelho-A', now: t0)); + expect(audit.results, contains('upload_token_denied_inactive')); + // Reativar não reabre o token revogado. + store.accounts[_acsId] = const RefreshAccount(active: true, microAreaId: _area); + await expectDenied(() => + service.resolve(uploadToken: token, deviceId: 'aparelho-A', now: t0)); + }); + + test('conta sem microárea ou inexistente: recusa', () async { + final token = await service.issue(_user, now: t0); + store.accounts[_acsId] = const RefreshAccount(active: true, microAreaId: null); + await expectDenied(() => + service.resolve(uploadToken: token, deviceId: 'aparelho-A', now: t0)); + final token2 = await service.issue(_user, now: t0); + store.accounts.remove(_acsId); + await expectDenied(() => + service.resolve(uploadToken: token2, deviceId: 'aparelho-A', now: t0)); + expect(audit.results.where((r) => r == 'upload_token_denied_inactive'), + hasLength(2)); + }); + + test('token desconhecido: recusa sem auditoria (não há sujeito)', () async { + await expectDenied(() => service.resolve( + uploadToken: 'nao-existe', deviceId: 'aparelho-A', now: t0)); + expect(audit.events, isEmpty); + }); + + test('toda recusa é a MESMA exceção com a MESMA mensagem', () async { + final messages = []; + Future capture(Future Function() call) async { + try { + await call(); + fail('deveria recusar'); + } on SessionExpiredException catch (e) { + messages.add(e.message); + } + } + + await capture(() => + service.resolve(uploadToken: 'desconhecido', deviceId: 'aparelho-A', now: t0)); + final a = await service.issue(_user, now: t0); + await capture(() => + service.resolve(uploadToken: a, deviceId: 'aparelho-B', now: t0)); // aparelho + final b = await service.issue(_user, now: t0); + await capture(() => service.resolve( + uploadToken: b, + deviceId: 'aparelho-A', + now: t0.add(const Duration(days: 8)))); // vencido + final c = await service.issue(_user, now: t0); + await service.revoke(c, now: t0); + await capture(() => + service.resolve(uploadToken: c, deviceId: 'aparelho-A', now: t0)); // revogado + final d = await service.issue(_user, now: t0); + store.accounts[_acsId] = const RefreshAccount(active: false, microAreaId: _area); + await capture(() => + service.resolve(uploadToken: d, deviceId: 'aparelho-A', now: t0)); // inativa + store.accounts[_acsId] = const RefreshAccount(active: true, microAreaId: null); + final e = await service.issue(_user, now: t0); + await capture(() => + service.resolve(uploadToken: e, deviceId: 'aparelho-A', now: t0)); // sem área + + expect(messages, hasLength(6)); + expect(messages.toSet(), {UploadTokenService.deniedMessage}); + }); + + test('revoke é idempotente e ignora token desconhecido em silêncio', () async { + final token = await service.issue(_user, now: t0); + await service.revoke(token, now: t0); + final revogadoEm = store.byId.values.single.revokedAt; + await service.revoke(token, now: t0.add(const Duration(hours: 1))); + expect(store.byId.values.single.revokedAt, revogadoEm); + await service.revoke('desconhecido', now: t0); + // Uma linha de revogação só para o desfecho que mudou algo. + expect(audit.results.where((r) => r == 'upload_token_revoked'), hasLength(1)); + }); + + test('issue poda só os tokens VENCIDOS do próprio usuário', () async { + final velho = await service.issue(_user, now: t0); + await service.issue( + const AuthenticatedUser( + id: _acsId, role: UserRole.acs, microAreaId: _area, deviceId: 'aparelho-B'), + now: t0.add(const Duration(days: 8))); + expect(store.hashToId.containsKey(_sha(velho)), isFalse); + expect(store.byId, hasLength(1)); + }); + + test('a auditoria nunca carrega o token', () async { + final token = await service.issue(_user, now: t0); + await expectDenied(() => + service.resolve(uploadToken: token, deviceId: 'aparelho-X', now: t0)); + await service.revoke(token, now: t0); + for (final e in audit.events) { + expect([e.userId, e.actionType, e.resourceType, e.resourceId, e.result].join('|'), + allOf(isNot(contains(token)), isNot(contains(_sha(token))))); + expect(e.resourceType, 'upload_token'); + } + }); +} diff --git a/backend/sinalacs_server/test/unit/visit_sync_service_test.dart b/backend/sinalacs_server/test/unit/visit_sync_service_test.dart index 9ccb1ade..0c318f60 100644 --- a/backend/sinalacs_server/test/unit/visit_sync_service_test.dart +++ b/backend/sinalacs_server/test/unit/visit_sync_service_test.dart @@ -23,15 +23,31 @@ class FakeVisitStore implements VisitStore { final Map _microAreaByPatient; final Map rows = {}; + /// ACS cuja conta está inativa (`acs.active = false`). Vazio = todos ativos. + final Set inactiveAcs = {}; + + @override + Future isActiveAcs(UuidValue acsId) async => !inactiveAcs.contains(acsId.uuid); + @override Future findByLocalId(String localId) async => rows[localId]; @override Future insert(VisitRecord visit) async { - rows[visit.localId.uuid] = visit; - return visit; + if (++_inserts == failOnInsertNumber) throw StateError('falha simulada do banco'); + // Como o `defaultPersist=random` do banco: a linha nasce com id. + final row = visit.id == null ? visit.copyWith(id: UuidValue.fromString(_nextId())) : visit; + rows[row.localId.uuid] = row; + return row; } + /// Lança no N-ésimo insert (1-based), simulando falha de banco no meio do lote. + int? failOnInsertNumber; + int _inserts = 0; + + int _seq = 0; + String _nextId() => '00000000-0000-4000-9000-${(++_seq).toString().padLeft(12, '0')}'; + @override Future update(VisitRecord visit) async { rows[visit.localId.uuid] = visit; @@ -431,4 +447,458 @@ void main() { expect(result, isNotNull); }); }); + + group('syncLegacy (autoria desconhecida, D4)', () { + const deviceId = 'aparelho-legado-01'; + + setUp(() { + store = FakeVisitStore(microAreaByPatient: { + _patientId: _microAreaId, + _outroTerritorioPatientId: _otherMicroAreaId, + }); + audit = FakeAuditTrail(); + service = VisitSyncService( + store: store, + audit: audit, + clock: () => DateTime.utc(2026, 10, 3, 12), + ); + }); + + test('syncLegacy grava authorship legacyUnclaimed, acsId nulo e originDeviceId', () async { + final results = await service.syncLegacy( + transporter: _acs, + deviceId: deviceId, + entries: [entry()], + ); + + expect(results.single.syncStatus, SyncStatus.synced); + expect(results.single.serverVersion, 1); + final row = store.rows[_localId]!; + expect(row.authorship, VisitAuthorship.legacyUnclaimed); + expect(row.acsId, isNull); + expect(row.originDeviceId, deviceId); + expect(row.syncAt, DateTime.utc(2026, 10, 3, 12)); + }); + + test('syncLegacy NÃO grava o transportador como autor (acsId permanece nulo)', () async { + // Duas passagens (gravação + atualização a partir da versão corrente): + // em nenhuma delas quem transportou vira autor. + await service.syncLegacy(transporter: _acs, deviceId: deviceId, entries: [entry()]); + // Atualização: o dispositivo parte de `version = servidor - 1` (mesma + // regra do `sync`, ver 'visits.sync expõe synced, conflict e error'). + final update = await service.syncLegacy( + transporter: _acs, + deviceId: deviceId, + entries: [entry(version: 0, status: 'paciente ausente')], + ); + + expect(update.single.syncStatus, SyncStatus.synced); + expect(update.single.serverVersion, 2); + final row = store.rows[_localId]!; + expect(row.acsId, isNull); + expect(row.authorship, VisitAuthorship.legacyUnclaimed); + expect(row.status, 'paciente ausente'); + }); + + test('visita legada de paciente de OUTRA microárea: rejected, nada gravado', () async { + final results = await service.syncLegacy( + transporter: _acs, + deviceId: deviceId, + entries: [entry(patientId: _outroTerritorioPatientId)], + ); + + expect(results.single.syncStatus, SyncStatus.rejected); + expect(results.single.message, isNot(contains(_otherMicroAreaId))); + expect(store.rows, isEmpty); + final denied = audit.events.where((e) => e.result == 'denied_territory'); + expect(denied.single.resourceType, 'visit_legacy'); + expect(denied.single.userId, _acsId); + }); + + test('mesmo localId reenviado: idempotente (synced, sem duplicar, sem mudar autoria)', () async { + await service.syncLegacy(transporter: _acs, deviceId: deviceId, entries: [entry()]); + + // Outro ACS do mesmo território reenvia do mesmo aparelho (o primeiro + // envio caiu antes da resposta): continua sem autor. + const outroAcs = AuthenticatedUser( + id: _otherAcsId, + role: UserRole.acs, + microAreaId: _microAreaId, + deviceId: 'acs-device-002', + ); + final retry = await service.syncLegacy( + transporter: outroAcs, + deviceId: deviceId, + entries: [entry(version: 1)], + ); + + expect(retry.single.syncStatus, SyncStatus.synced); + expect(retry.single.serverVersion, 1); + expect(store.rows, hasLength(1)); + expect(store.rows[_localId]!.acsId, isNull); + expect(store.rows[_localId]!.authorship, VisitAuthorship.legacyUnclaimed); + }); + + test('versão divergente de uma visita legada vira conflito, sem sobrescrever', () async { + await service.syncLegacy(transporter: _acs, deviceId: deviceId, entries: [entry()]); + + final conflicted = await service.syncLegacy( + transporter: _acs, + deviceId: deviceId, + entries: [entry(version: 7, status: 'recusou atendimento')], + ); + + expect(conflicted.single.syncStatus, SyncStatus.conflict); + expect(conflicted.single.serverVersion, 1); + expect(store.rows[_localId]!.status, 'realizada'); + }); + + test('visita legada de OUTRO aparelho com o mesmo localId: rejected', () async { + await service.syncLegacy(transporter: _acs, deviceId: deviceId, entries: [entry()]); + + final results = await service.syncLegacy( + transporter: _acs, + deviceId: 'outro-aparelho', + entries: [entry(version: 1, status: 'paciente ausente')], + ); + + expect(results.single.syncStatus, SyncStatus.rejected); + expect(store.rows[_localId]!.originDeviceId, deviceId); + expect(store.rows[_localId]!.status, 'realizada'); + }); + + test('localId que já existe como visita COM autor ACS: conflito/rejected, autoria original preservada', + () async { + await service.sync(user: _acs, entries: [entry()]); + + final results = await service.syncLegacy( + transporter: _acs, + deviceId: deviceId, + entries: [entry(version: 0, status: 'paciente ausente')], + ); + + expect(results.single.syncStatus, SyncStatus.rejected); + expect(results.single.message, 'visita já registrada com autor — versão diferente'); + final row = store.rows[_localId]!; + expect(row.authorship, VisitAuthorship.acs); + expect(row.acsId, UuidValue.fromString(_acsId)); + expect(row.originDeviceId, isNull); + expect(row.status, 'realizada'); + }); + + test('ACS comum que reenvia o localId de uma visita sem autor NÃO a reivindica', () async { + await service.syncLegacy(transporter: _acs, deviceId: deviceId, entries: [entry()]); + + // Reenvio idêntico (mesma versão) e atualização (versão corrente): os + // dois são recusados — senão o `sync` comum viraria a porta para + // reivindicar a autoria de uma visita legada. + final same = await service.sync(user: _acs, entries: [entry(version: 1)]); + final update = await service.sync( + user: _acs, + entries: [entry(version: 0, status: 'paciente ausente')], + ); + + expect(same.single.syncStatus, SyncStatus.rejected); + expect(update.single.syncStatus, SyncStatus.rejected); + final row = store.rows[_localId]!; + expect(row.acsId, isNull); + expect(row.authorship, VisitAuthorship.legacyUnclaimed); + expect(row.status, 'realizada'); + expect(row.version, 1); + }); + + test('papel diferente de acs, sem microárea ou conta inativa: StateError (AlertPermissionException no endpoint)', + () async { + expect( + () => service.syncLegacy(transporter: _patient, deviceId: deviceId, entries: [entry()]), + throwsA(isA()), + ); + + const acsSemArea = AuthenticatedUser( + id: _acsId, + role: UserRole.acs, + microAreaId: null, + deviceId: 'acs-device-001', + ); + expect( + () => service.syncLegacy(transporter: acsSemArea, deviceId: deviceId, entries: [entry()]), + throwsA(isA()), + ); + + store.inactiveAcs.add(_acsId); + await expectLater( + service.syncLegacy(transporter: _acs, deviceId: deviceId, entries: [entry()]), + throwsA(isA()), + ); + expect(store.rows, isEmpty); + }); + + test('deviceId vazio: ArgumentError, nada gravado', () async { + await expectLater( + service.syncLegacy(transporter: _acs, deviceId: ' ', entries: [entry()]), + throwsA(isA()), + ); + expect(store.rows, isEmpty); + }); + + test('cada lote gera audit_logs visit_legacy_sync com o transportador, sem conteúdo clínico', + () async { + await service.syncLegacy(transporter: _acs, deviceId: deviceId, entries: [entry()]); + + final events = audit.events.where((e) => e.result == 'visit_legacy_sync').toList(); + expect(events, hasLength(1)); + expect(events.single.userId, _acsId); + expect(events.single.actionType, 'write'); + expect(events.single.resourceType, 'visit_legacy'); + // Nem paciente, nem nota, nem localId: o evento é o LOTE. + expect(events.single.resourceId, isNull); + }); + + test('uma trilha de auditoria fora do ar não impede o envio legado', () async { + audit = FakeAuditTrail(failOnRecord: true); + service = VisitSyncService(store: store, audit: audit); + + final results = + await service.syncLegacy(transporter: _acs, deviceId: deviceId, entries: [entry()]); + + expect(results.single.syncStatus, SyncStatus.synced); + }); + + test('pull entrega a visita legada à microárea sem expor autor (campo acsId ausente)', () async { + await service.syncLegacy(transporter: _acs, deviceId: deviceId, entries: [entry()]); + + final result = await service.pull(user: _acs, since: DateTime.utc(2026, 10, 1)); + + expect(result.single.localId, _localId); + // `VisitSyncEntry` não tem campo de autor — nem `acsId` nem + // `originDeviceId` atravessam o pull. + final json = result.single.toJson(); + expect(json.containsKey('acsId'), isFalse); + expect(json.containsKey('originDeviceId'), isFalse); + }); + }); + + group('syncLegacy — fix round 1', () { + const deviceId = 'aparelho-legado-01'; + const localB = '00000000-0000-4000-8000-0000000000b2'; + const localC = '00000000-0000-4000-8000-0000000000b3'; + + VisitSyncEntry legacyEntry(String localId, {int version = 0, String patientId = _patientId}) => + VisitSyncEntry( + localId: localId, + patientId: patientId, + scheduledAt: DateTime.utc(2026, 9, 11, 9), + status: 'realizada', + riskLevelBefore: RiskLevel.green, + notes: const {'campo': 'sem intercorrências'}, + version: version, + arrivalMethod: ArrivalMethod.manual, + ); + + List items() => + audit.events.where((e) => e.result == 'visit_legacy_sync_item').toList(); + + setUp(() { + store = FakeVisitStore(microAreaByPatient: { + _patientId: _microAreaId, + _outroTerritorioPatientId: _otherMicroAreaId, + }); + audit = FakeAuditTrail(); + service = VisitSyncService(store: store, audit: audit, clock: () => DateTime.utc(2026, 10, 3, 12)); + }); + + test('N visitas gravadas → N linhas de item + 1 de lote; nenhuma para rejected/no-op', () async { + await service.syncLegacy( + transporter: _acs, + deviceId: deviceId, + entries: [ + legacyEntry(_localId), + legacyEntry(localB), + legacyEntry(localC, patientId: _outroTerritorioPatientId), + ], + ); + + expect(items(), hasLength(2)); + for (final e in items()) { + expect(e.userId, _acsId); + expect(e.actionType, 'write'); + expect(e.resourceType, 'visit_legacy'); + } + expect(items().map((e) => e.resourceId).toSet(), + {store.rows[_localId]!.id!.uuid, store.rows[localB]!.id!.uuid}); + expect(audit.events.where((e) => e.result == 'visit_legacy_sync'), hasLength(1)); + + // Reenvio idêntico: no-op, nenhuma linha de item a mais. + audit.events.clear(); + await service.syncLegacy( + transporter: _acs, deviceId: deviceId, entries: [legacyEntry(_localId, version: 1)]); + expect(items(), isEmpty); + expect(audit.events.where((e) => e.result == 'visit_legacy_sync'), hasLength(1)); + + // Atualização a partir da versão corrente: muda a linha → 1 item. + await service.syncLegacy( + transporter: _acs, deviceId: deviceId, entries: [legacyEntry(_localId, version: 0)]); + expect(items().single.resourceId, store.rows[_localId]!.id!.uuid); + }); + + test('lote acima de maxLegacyBatch: ArgumentError antes de tocar o store', () async { + expect(VisitSyncService.maxLegacyBatch, 200); + store.inactiveAcs.add(_acsId); // se o store fosse consultado, viraria StateError + final entries = [ + for (var i = 0; i <= VisitSyncService.maxLegacyBatch; i++) + legacyEntry('00000000-0000-4000-8000-${i.toString().padLeft(12, '0')}'), + ]; + + await expectLater( + service.syncLegacy(transporter: _acs, deviceId: deviceId, entries: entries), + throwsA(isA()), + ); + expect(store.rows, isEmpty); + expect(audit.events, isEmpty); + }); + + test('exatamente maxLegacyBatch visitas: aceito', () async { + final entries = [ + for (var i = 0; i < VisitSyncService.maxLegacyBatch; i++) + legacyEntry('00000000-0000-4000-8000-${i.toString().padLeft(12, '0')}'), + ]; + final results = + await service.syncLegacy(transporter: _acs, deviceId: deviceId, entries: entries); + expect(results, hasLength(VisitSyncService.maxLegacyBatch)); + }); + + test('localId com autor ACS e MESMA versão: synced sem gravar nada', () async { + await service.sync(user: _acs, entries: [legacyEntry(_localId)]); + final before = store.rows[_localId]!; + + final results = await service.syncLegacy( + transporter: _acs, deviceId: deviceId, entries: [legacyEntry(_localId, version: 1)]); + + expect(results.single.syncStatus, SyncStatus.synced); + expect(results.single.serverVersion, 1); + expect(identical(store.rows[_localId], before), isTrue); + expect(store.rows[_localId]!.acsId, UuidValue.fromString(_acsId)); + expect(items(), isEmpty); + }); + + test('localId legado de outro aparelho, mesma versão: rejected', () async { + await service.syncLegacy(transporter: _acs, deviceId: deviceId, entries: [legacyEntry(_localId)]); + + final results = await service.syncLegacy( + transporter: _acs, deviceId: 'outro-aparelho', entries: [legacyEntry(_localId, version: 1)]); + + expect(results.single.syncStatus, SyncStatus.rejected); + expect(results.single.message, 'visita legada de outro aparelho'); + }); + + test('recusa territorial mantém a própria mensagem', () async { + final results = await service.syncLegacy( + transporter: _acs, + deviceId: deviceId, + entries: [legacyEntry(_localId, patientId: _outroTerritorioPatientId)]); + expect(results.single.message, 'paciente fora da sua microárea'); + }); + }); + + group('syncLegacy — fix round 2', () { + const deviceId = 'aparelho-legado-01'; + const outroPacienteMesmaArea = '00000000-0000-4000-8000-000000000021'; + const localB = '00000000-0000-4000-8000-0000000000b2'; + + VisitSyncEntry e(String localId, {int version = 0, String patientId = _patientId}) => VisitSyncEntry( + localId: localId, + patientId: patientId, + scheduledAt: DateTime.utc(2026, 9, 11, 9), + status: 'realizada', + riskLevelBefore: RiskLevel.green, + notes: const {}, + version: version, + arrivalMethod: ArrivalMethod.manual, + ); + + setUp(() { + store = FakeVisitStore(microAreaByPatient: { + _patientId: _microAreaId, + outroPacienteMesmaArea: _microAreaId, + _outroTerritorioPatientId: _otherMicroAreaId, + }); + audit = FakeAuditTrail(); + service = VisitSyncService(store: store, audit: audit, clock: () => DateTime.utc(2026, 10, 3, 12)); + }); + + test('lote cuja 2ª visita lança: nenhuma linha de item nem de lote', () async { + store.failOnInsertNumber = 2; + + await expectLater( + service.syncLegacy(transporter: _acs, deviceId: deviceId, entries: [e(_localId), e(localB)]), + throwsA(isA()), + ); + expect(audit.events.where((x) => x.result == 'visit_legacy_sync_item'), isEmpty); + expect(audit.events.where((x) => x.result == 'visit_legacy_sync'), isEmpty); + }); + + test('itens são gravados só depois do laço, logo antes da linha de lote', () async { + await service.syncLegacy(transporter: _acs, deviceId: deviceId, entries: [e(_localId), e(localB)]); + + expect(audit.events.map((x) => x.result).toList(), + ['visit_legacy_sync_item', 'visit_legacy_sync_item', 'visit_legacy_sync']); + }); + + test('ACS da microárea X + paciente de Y + localId com autor na mesma versão: ' + "rejected 'paciente fora da sua microárea'", () async { + const acsDeY = AuthenticatedUser( + id: _otherAcsId, + role: UserRole.acs, + microAreaId: _otherMicroAreaId, + deviceId: 'acs-device-y', + ); + await service.sync(user: acsDeY, entries: [e(_localId, patientId: _outroTerritorioPatientId)]); + + final results = await service.syncLegacy( + transporter: _acs, + deviceId: deviceId, + entries: [e(_localId, version: 1, patientId: _outroTerritorioPatientId)], + ); + + expect(results.single.syncStatus, SyncStatus.rejected); + expect(results.single.message, 'paciente fora da sua microárea'); + expect(results.single.serverVersion, isNull); + }); + + test('paciente da própria microárea mas localId de visita de OUTRO paciente (com autor, ' + 'mesma versão): recusa genérica', () async { + await service.sync(user: _acs, entries: [e(_localId, patientId: outroPacienteMesmaArea)]); + + final results = await service.syncLegacy( + transporter: _acs, + deviceId: deviceId, + entries: [e(_localId, version: 1)], + ); + + expect(results.single.syncStatus, SyncStatus.rejected); + expect(results.single.message, 'paciente fora da sua microárea'); + expect(results.single.serverVersion, isNull); + expect(store.rows[_localId]!.patientId, UuidValue.fromString(outroPacienteMesmaArea)); + }); + + test('localId legado de OUTRO paciente, mesmo aparelho e versão: recusa genérica', () async { + await service.syncLegacy( + transporter: _acs, deviceId: deviceId, entries: [e(_localId, patientId: outroPacienteMesmaArea)]); + + final results = + await service.syncLegacy(transporter: _acs, deviceId: deviceId, entries: [e(_localId, version: 1)]); + + expect(results.single.syncStatus, SyncStatus.rejected); + expect(results.single.message, 'paciente fora da sua microárea'); + }); + + test('paciente divergente com versão diferente também não revela "versão diferente"', () async { + await service.sync(user: _acs, entries: [e(_localId, patientId: outroPacienteMesmaArea)]); + + final results = await service.syncLegacy( + transporter: _acs, deviceId: deviceId, entries: [e(_localId, version: 5)]); + + expect(results.single.message, 'paciente fora da sua microárea'); + }); + }); } diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml new file mode 100644 index 00000000..41fcc0fc --- /dev/null +++ b/docker-compose.e2e.yml @@ -0,0 +1,23 @@ +# Stack de e2e: o mesmo backend, mas com o banco de TESTE (postgres-test, +# efêmero, sem volume) e sem login de desenvolvimento. Uso: +# ./scripts/qa/e2e_stack.sh up +# O banco `sinalacs_e2e` é separado de `sinalacs_test` (do `dart test`) para que +# a suíte do backend e a jornada no emulador não pisem uma na outra. +services: + serverpod: + environment: + SERVERPOD_DATABASE_HOST: postgres-test + SERVERPOD_DATABASE_PORT: "5432" + SERVERPOD_DATABASE_NAME: sinalacs_e2e + SERVERPOD_DATABASE_USER: postgres + SERVERPOD_DATABASE_PASSWORD: ${TEST_DATABASE_PASSWORD:?defina em .env — rode ./scripts/dev/bootstrap_env.sh} + ENABLE_DEV_LOGIN: "false" + # Explícito: o e2e entra com matrícula + senha sem ativar MFA. Não herdar + # o valor do .env impede que um REQUIRE_ACS_MFA=true local derrube a jornada. + REQUIRE_ACS_MFA: "false" + SMS_GATEWAY: log + # Prazo do alerta vermelho (#44): o mínimo aceito (30 s), para o e2e do + # escalonamento não esperar os 300 s do padrão da stack de desenvolvimento. + # Mesmo valor de `e2eEscalationThresholdSeconds` (e2e_fixtures.dart). + RED_ALERT_ESCALATION_SECONDS: "30" + GORUSH_URL: http://gorush:8088 diff --git a/docker-compose.yml b/docker-compose.yml index a29c4388..3ae68f28 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -80,6 +80,27 @@ services: - ./infra/docker/mosquitto/runtime:/mosquitto/runtime entrypoint: ["/bin/sh", "-c", "apk add --no-cache openssl && sh /init.sh"] + # Relé de push para FCM/APNs (RF14, decisão §3.2). Opcional: só sobe com + # `docker compose --profile push up`. Não publica porta — só o backend, na rede + # do Compose, fala com ele. As credenciais FCM/APNs são arquivos fornecidos pela + # organização e nunca são versionados (ver infra/docker/gorush/README.md). + gorush: + image: appleboy/gorush:1.22.0 + profiles: [push] + restart: unless-stopped + command: ["-c", "/config.yml"] + # A chave da conta de serviço tem de ser 0600 (push_e2e.sh exige) e, portanto, só o dono a + # lê. A imagem roda como uid 1000; no runner do CI o dono é o usuário `runner` (uid 1001), + # e o Gorush cairia com EACCES. O container roda com o uid/gid de quem montou a chave + # (ci_push_e2e.sh os exporta); fora do CI o padrão 1000 é o da imagem. + user: "${GORUSH_UID:-1000}:${GORUSH_GID:-1000}" + environment: + GORUSH_IOS_KEY_ID: ${GORUSH_IOS_KEY_ID:-} + GORUSH_IOS_TEAM_ID: ${GORUSH_IOS_TEAM_ID:-} + volumes: + - ./infra/docker/gorush/config.yml:/config.yml:ro + - ${GORUSH_CREDENTIALS_DIR:-./infra/docker/gorush/credentials}:/credentials:ro + serverpod: build: context: ./backend @@ -88,6 +109,10 @@ services: # declarar o mesmo `build:` e reconstruí-la. image: sinalacs/server:local container_name: sinalacs-serverpod + # Defesa em profundidade (#56): reinicia após saída != 0 (inclusive o + # exit(70) do handler global de bin/main.dart). Não substitui o outbox + # nem o PUBACK; os e2e falham se RestartCount > 0 para não mascarar crash. + restart: unless-stopped depends_on: postgres: condition: service_healthy @@ -113,6 +138,9 @@ services: 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 + # mTLS: o broker exige certificado de cliente (CN = usuário MQTT). + MQTT_CLIENT_CERT_PATH: /app/certs/backend.crt + MQTT_CLIENT_KEY_PATH: /app/certs/backend.key # 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} @@ -124,6 +152,9 @@ services: # fora dele. PRECISA ser a mesma do health-data-seed abaixo, ou o # diretório de pacientes não decifra o que o seed gravou. HEALTH_DATA_ENCRYPTION_KEY: ${HEALTH_DATA_ENCRYPTION_KEY:-} + # Relé de push (RF14). Vazio desliga o envio de avisos; com o perfil `push` + # ativo, use GORUSH_URL=http://gorush:8088 no .env. + GORUSH_URL: ${GORUSH_URL:-} # Pepper do HMAC de `users.cpfHash` (RF01). PRECISA ser o mesmo valor que o # `cpf-hash-seed` recebe, pela mesma razão do # HEALTH_DATA_ENCRYPTION_KEY acima: o seed deriva o hash e o servidor o @@ -136,6 +167,12 @@ services: CPF_HASH_PEPPER: ${CPF_HASH_PEPPER:-} APP_ENV: ${APP_ENV:-development} ENABLE_DEV_LOGIN: "${ENABLE_DEV_LOGIN:-true}" + # MFA (TOTP) obrigatória no login institucional do ACS. Em produção o + # padrão é `true`; este default é só da stack local. + REQUIRE_ACS_MFA: "${REQUIRE_ACS_MFA:-false}" + # Prazo (s) para reconhecer um alerta vermelho antes do escalonamento + # (issue #44). Faixa válida 30..3600; fora dela o servidor não sobe. + RED_ALERT_ESCALATION_SECONDS: "${RED_ALERT_ESCALATION_SECONDS:-300}" # O RPC só é alcançável por HTTPS em :443. A publicação de 8080 foi # REMOVIDA de propósito: enquanto ela existisse, o TLS seria decorativo — # RNF04 pede TLS em todas as comunicações, e um caminho em texto claro ao @@ -214,6 +251,8 @@ services: # Senha do ACS de desenvolvimento. Gerada por máquina — o script recusa # rodar fora de APP_ENV=development. DEV_ACS_PASSWORD: ${DEV_ACS_PASSWORD:?defina em .env — rode ./scripts/dev/bootstrap_env.sh} + # Senha do admin de desenvolvimento (ADM-001, backoffice). + DEV_ADMIN_PASSWORD: ${DEV_ADMIN_PASSWORD:?defina em .env — rode ./scripts/dev/bootstrap_env.sh} APP_ENV: ${APP_ENV:-development} entrypoint: ["./seed_acs_credentials"] diff --git a/docs/README.md b/docs/README.md index f14b44b8..dd1b1862 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,8 +5,11 @@ - [Telas do backoffice admin](telas-admin.md) - [Roteiro de vídeo para o sponsor](roteiro-video-sponsor.md) - [AVD Android para validação](android-avd.md) +- [Publicação na Google Play](publicacao-google-play.md) +- [Auditoria da CI (2026-09-28)](ci-audit/2026-09-28-avaliacao-ci-develop.md) +- [Apresentação (showcase)](showcase/slides.md) -As capturas de tela usam dados sintéticos e representam o estado atual de protótipo das interfaces Flutter. +As capturas de tela usam dados sintéticos e podem estar defasadas em relação às telas atuais; a data de cada captura é a do commit que a adicionou. **Duas CAs de desenvolvimento, de propósito.** A stack gera uma CA própria para o Mosquitto (broker MQTT em 8883) e outra para o RPC (HTTPS em 443, terminado pelo diff --git a/docs/ci-audit/2026-09-28-avaliacao-ci-develop.md b/docs/ci-audit/2026-09-28-avaliacao-ci-develop.md index 792e7e6f..f1ee836a 100644 --- a/docs/ci-audit/2026-09-28-avaliacao-ci-develop.md +++ b/docs/ci-audit/2026-09-28-avaliacao-ci-develop.md @@ -270,6 +270,9 @@ Plano: `docs/superpowers/plans/2026-09-28-ci-correcoes-avaliacao.md`. | FINDING-1 | Fechado | `32ddad5` (docs) e guarda `check_jobs` em `scripts/qa/ci_invariants.sh` | | FINDING-2 | Fechado | `push` em `develop` e `workflow_dispatch` no `ci.yml`; guarda `check_gatilhos` | | FINDING-7 | Fechado | bloco `concurrency` por PR/SHA; guarda `check_concorrencia` | +| FINDING-7 (refinamento, issue #23) | Fechado — conferido no GitHub em 2026-10-14: três runs no mesmo SHA `7d70671` (push em `develop`, dispatch em `develop`, dispatch em branch descartável) ficaram `in_progress` ao mesmo tempo, nenhum cancelado (runs 38638090029, 38638178138, 38638183952) | o grupo de push/dispatch passou de `sha` para `ref`+`sha`: `main` e `develop` no mesmo SHA não se cancelam mais; `check_concorrencia` exige o ref no ramo de push/dispatch; `scripts/qa/ci_concurrency_act.sh` avalia o grupo real com o `act` (que não implementa `concurrency`: o cancelamento de fato só se confere no GitHub) | +| FINDING-7 (acabamentos, issue #63) | Fechado | `check_concorrencia` exige `github.event_name` no prefixo e `github.ref` estrito (recusa `ref_name`); limitações por substring registradas no comentário e fixadas em `ci_invariants_test.sh`; `ci_concurrency_act.sh` sai com 2 sem pré-requisito e guarda o log do `act`, com teste hermético `ci_concurrency_act_test.sh` no `workflow-lint` | +| FINDING-7 (igualdade exata, issue #24) | Fechado | `check_concorrencia` compara o `concurrency.group` com `GRUPO_CONCORRENCIA` por igualdade, não por substring; os três falsos negativos da substring (ref num literal, argumento sobrando de `format()`, ramo nunca avaliado que vira `ci-push-x` em todo push) são casos positivos em `ci_invariants_test.sh` | | FINDING-4 | Fechado — a correção de `74d89c0` funciona | run `36494654391` sobre `9d84f80` (descendente de `74d89c0`, confirmado com `git merge-base --is-ancestor`), primeiro run de CI a cobrir `74d89c0`: o passo "E2E no emulador Android" passou (smoke do paciente 1/1, smoke do ACS 1/1, RNF04 OK, ciclo do alerta vermelho OK). O job ainda terminou `failure` em 13m28s, por um defeito diferente, só visível agora que o E2E chega ao fim: o pós-passo do `subosito/flutter-action` roda `hashFiles('**/pubspec.lock')` sobre o workspace e não consegue ler `pg_data/` (modo 700, dono do container do Postgres). Corrigido com um passo `if: always()` que apaga `pg_data/` depois do E2E, com guarda `check_limpeza_do_workspace` | | FINDING-4 (confirmação) | Fechado | run `36496121047` sobre `acb2478`: **9/9 jobs `success`**, `android-e2e` em 7m37s — o primeiro run totalmente verde desde, pelo menos, 2026-09-15 (FINDING-3) | | FINDING-6 (ações) | Fechado | run `36496121047`: 0 avisos de Node 20/setup-java v4 (eram emitidos em todos os jobs); guarda `check_versoes_de_acoes` | diff --git a/docs/publicacao-google-play.md b/docs/publicacao-google-play.md new file mode 100644 index 00000000..85ae7ecc --- /dev/null +++ b/docs/publicacao-google-play.md @@ -0,0 +1,928 @@ +# Publicação na Google Play + +Guia de boas práticas e passo a passo para publicar um app do SinalACS na +Google Play Store, do build assinado ao acompanhamento depois do lançamento. + +O SinalACS tem três apps Flutter com plataforma Android configurada: + +| App | Caminho | applicationId | +|----------|----------------|--------------------------------------| +| Paciente | `apps/patient` | `br.com.prismrr.sinalacs.patient` | +| ACS | `apps/acs` | `br.com.prismrr.sinalacs.acs` | +| Admin | `apps/admin` | `br.com.prismrr.sinalacs.admin` | + +Todos usam `minSdk = 24` e `targetSdk = 36`. Nenhum foi publicado ainda. + +> **Estado atual:** o projeto é um protótipo e não está pronto para produção. +> Este guia descreve o caminho de publicação; as lacunas que hoje o impedem +> estão na seção [Pós-análise](#pós-análise). + +## 1. Pré-requisitos + +### 1.1 Conta de desenvolvedor Google Play + +| Ponto | Organização | Pessoal | +|-------|-------------|---------| +| Indicada para | Apps institucionais | Desenvolvedor individual | +| Verificação | Identidade e, em geral, verificação da organização (D-U-N-S) — conferir no Play Console Help | Verificação de identidade | +| Teste fechado obrigatório | Não se aplica | Sim, se a conta foi criada depois de 13/11/2023 | + +- **Taxa:** o registro tem uma taxa única. Conferir o valor atual no + Play Console antes de abrir a conta. +- **Conta pessoal nova:** antes de pedir acesso à produção, é preciso rodar um + teste fechado com no mínimo 12 testers com adesão contínua de 14 dias. O + teste interno não conta para essa regra. Depois dos 14 dias, o Google avalia + um formulário de acesso à produção, então reserve cerca de três semanas. +- **Conta pessoal × organização:** a escolha decide se essa exigência se + aplica. A regra vale por conta e pode mudar; confirmar no + [Play Console Help](https://support.google.com/googleplay/android-developer/answer/14151465). +- **Quem é o titular:** o titular da conta aparece na ficha pública da loja. + Definir com os mantenedores quem será, antes de abrir a conta. + +### 1.2 Acesso ao Play Console + +- Quem publica precisa de uma conta Google com permissão de envio de versões + no Play Console (papéis e permissões em *Usuários e permissões*). +- Não compartilhar a conta do titular; conceder acesso por usuário. + +### 1.3 Ferramentas + +Usar as mesmas versões do CI (`.github/workflows/ci.yml`): + +| Ferramenta | Versão | Observação | +|------------|--------|------------| +| Flutter | 3.44.8 | fixada no CI | +| JDK | 17 (Temurin) | também é o alvo de `sourceCompatibility` e `jvmTarget` | +| Android SDK | plataforma 36 | `compileSdk = 36` | +| `keytool` | vem com o JDK | usado na seção de assinatura | + +O build de release do `apps/admin` foi testado localmente com Flutter 3.47.2 +e JDK 25 e compilou com avisos. Como isso foi um teste único, o recomendado +continua sendo o JDK 17 e o Flutter do CI. + +## 2. Preparação do app + +### 2.1 applicationId definitivo + +Cada app tem o seu `applicationId` definido em +`apps//android/app/build.gradle.kts` (mesmo valor do `namespace`): + +| App | applicationId | +|----------|------------------------------------| +| Paciente | `br.com.prismrr.sinalacs.patient` | +| ACS | `br.com.prismrr.sinalacs.acs` | +| Admin | `br.com.prismrr.sinalacs.admin` | + +**Depois que o app é publicado, o `applicationId` não pode mais mudar.** Ele +identifica o app na loja. Antes do primeiro envio, confirmar com os +mantenedores que os três valores são os definitivos. + +### 2.2 Versão (versionCode e versionName) + +Os três `build.gradle.kts` leem `versionCode` e `versionName` do Flutter +(`flutter.versionCode`, `flutter.versionName`), que vêm do `pubspec.yaml`: + +```yaml +version: 1.0.1+2 # versionName = 1.0.1, versionCode = 2 +``` + +- A parte antes do `+` é o `versionName` (o que o usuário vê). +- A parte depois do `+` é o `versionCode`. **O Play exige um `versionCode` + maior a cada arquivo enviado**, mesmo para os testes internos. + +Hoje os três apps estão com `version: 1.0.0`, sem o `+n`. Antes do primeiro +envio, passar para `1.0.0+1` e incrementar o número a cada novo AAB. + +### 2.3 Nome, ícone adaptativo e splash + +- **Nome exibido:** vem do `android:label` do `AndroidManifest.xml` (no Admin é + `SinalACS Admin`). Conferir os rótulos do Paciente e do ACS e usar nomes + curtos e distintos entre si. +- **Ícone:** o Admin e o Paciente usam só os PNGs de `mipmap-*`, sem a pasta + `mipmap-anydpi-v26`, ou seja, **sem ícone adaptativo**. Conferir o ACS. + Antes de publicar, gerar o ícone adaptativo (primeiro plano e fundo) e + verificar como ele aparece nos formatos de máscara de cada fabricante. +- **Splash:** conferir `res/drawable/launch_background.xml` e + `res/values/styles.xml` de cada app; o tema `LaunchTheme` é o que aparece + enquanto o Flutter inicia. + +### 2.4 Revisão do AndroidManifest.xml e das permissões + +Permissões declaradas hoje no manifest principal (`src/main`): + +| App | Permissões declaradas | +|----------|------------------------| +| Paciente | `ACCESS_COARSE_LOCATION`, `ACCESS_FINE_LOCATION`, `RECEIVE_BOOT_COMPLETED` | +| ACS | nenhuma | +| Admin | nenhuma | + +Pontos a revisar antes de publicar: + +- **`INTERNET`:** nos três apps ela não aparece no `main` (conferido no manifest `main` de cada um) e aparece só nos manifests de debug e profile(conferido no Admin e no ACS). + Um release + que precise acessar o backend precisa dela no manifest principal. Confirmar + olhando o manifest mesclado do release (ver 2.6). +- **`POST_NOTIFICATIONS`:** o Paciente depende de `flutter_local_notifications` + e chama `requestNotificationsPermission()` em `lib/main.dart`, mas o + manifest principal não a declara. Conferir no manifest mesclado. +- **`RECEIVE_BOOT_COMPLETED`:** não é habitual num app de paciente. Descobrir + de onde vem (possivelmente de um plugin) e remover se não for necessária. +- **Localização:** o Paciente usa `geolocator` com leitura pontual em primeiro + plano (`getCurrentPosition`); não foi encontrado uso em segundo plano. +- **Câmera:** nenhum dos três apps depende de plugin de câmera. +- **Toda permissão declarada precisa de justificativa** no Play Console (ver + a seção de Conformidade). Remover o que não for usado. + +### 2.5 Remover o que é de desenvolvimento + +- **`ENABLE_DEV_LOGIN`:** habilita `auth.developmentLogin` no backend. Não pode + estar ligado em produção. +- **CA de desenvolvimento:** `scripts/dev/sync_dev_ca.sh` copia duas CAs de + desenvolvimento para `apps/*/assets/certs/` (`dev_ca.crt` e + `dev_rpc_ca.crt`). Confirmar se um release apontando para um servidor com + certificado público precisa delas e retirá-las do pacote se não precisar. +- **Host padrão:** no Paciente, `lib/core/network/backend_config.dart` + define `defaultValue: 'https://10.0.2.2/'`, o endereço da máquina vista do + emulador. Em um celular real ele não funciona. O host de produção deve ser + informado no build: + +```bash + flutter build appbundle --release \ + --dart-define=SINALACS_HOST=https:/// +``` + + Conferir o mesmo arquivo em `apps/acs/lib/core/network/`. O Admin ainda não + faz requisições (roda sobre `MockAdminDataSource`), então não tem host + configurável. +- **ACS, segredos em `--dart-define`:** `SINALACS_MQTT_PASSWORD` não tem valor + padrão e `GOOGLE_MAPS_API_KEY` vira texto vazio se faltar, sem erro de + build. Nenhum valor real deve aparecer no repositório nem neste documento. +- **Admin, login:** o login é real (`auth.loginStaff`, matrícula + senha + TOTP) e + não há atalho de desenvolvimento; o painel só abre com a sessão devolvida pelo + servidor. O host do RPC tem de ser HTTPS (`SINALACS_HOST`). + +### 2.6 Conferir o manifest mesclado + +Depois de um build de release, confirmar as permissões finais, já com as que +os plugins adicionam: + +```bash +cd apps/ +find build -name AndroidManifest.xml -path "*release*" +grep -n "uses-permission" +``` + +## 3. Assinatura + +O Google Play só aceita pacotes assinados. Hoje os três apps assinam o release +com a chave de debug (`signingConfig = signingConfigs.getByName("debug")` no +bloco `release` de cada `build.gradle.kts`). **Um pacote assinado com a chave de +debug não deve ser enviado ao Play.** + +### 3.1 Gerar a upload key + +A *upload key* é a chave com a qual você assina o pacote que envia ao Play. Com +o Play App Signing (seção 3.4), a chave que assina o app para os usuários fica +com o Google. + +Gere a chave **fora do repositório** (por exemplo, no seu diretório pessoal): + +```bash +keytool -genkeypair -v \ + -keystore "$HOME/sinalacs--upload.jks" \ + -alias upload \ + -keyalg RSA -keysize 2048 \ + -validity 10000 +``` + +- `` é `patient`, `acs` ou `admin`. Usar uma chave por app evita que o + comprometimento de uma afete as outras (recomendação deste guia). +- `-validity 10000` são cerca de 27 anos. +- O `keytool` pergunta a senha do keystore e os dados do certificado. Use uma + senha forte e guarde-a conforme a seção 3.5. +- O formato padrão do `keytool` do JDK 17 e do 25 é PKCS12. + +Para conferir o conteúdo (alias, validade e impressões digitais SHA-1 e +SHA-256): + +```bash +keytool -list -v -keystore "$HOME/sinalacs--upload.jks" +``` + +### 3.2 key.properties fora do versionamento + +Crie `apps//android/key.properties` com os dados da chave. Use valores +seus; **nunca** escreva senhas reais neste documento nem no repositório: + +```properties +storePassword= +keyPassword= +keyAlias=upload +storeFile= +``` + +O `apps/admin/android/.gitignore` já ignora `key.properties`, `*.keystore` e +`*.jks`. Antes de qualquer commit, confirme o mesmo nos outros apps: + +```bash +git check-ignore -v apps/patient/android/key.properties apps/acs/android/key.properties +git status +``` + +O `git status` não pode listar `key.properties` nem arquivos `.jks`. + +### 3.3 signingConfigs de release no build.gradle.kts + +Em `apps//android/app/build.gradle.kts`, o bloco abaixo lê o +`key.properties` e assina o release com a upload key, no lugar da chave de +debug. O trecho segue o padrão da documentação do Flutter e **não foi aplicado +neste repositório**; adapte-o ao arquivo de cada app (o do ACS tem lógica +própria para as `--dart-define`): + +```kotlin +import java.io.FileInputStream +import java.util.Properties + +val keystoreProperties = Properties() +val keystorePropertiesFile = rootProject.file("key.properties") +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(FileInputStream(keystorePropertiesFile)) +} + +android { + signingConfigs { + create("release") { + keyAlias = keystoreProperties["keyAlias"] as String + keyPassword = keystoreProperties["keyPassword"] as String + storeFile = keystoreProperties["storeFile"]?.let { file(it) } + storePassword = keystoreProperties["storePassword"] as String + } + } + buildTypes { + release { + signingConfig = signingConfigs.getByName("release") + } + } +} +``` + +Com isso, um `flutter build appbundle --release` sem `key.properties` falha, o +que é desejável: evita gerar por engano um release assinado com a chave errada. +O job `admin-android-build` do CI compila só `--debug`, então não é afetado. + +### 3.4 Play App Signing + +Com o Play App Signing, o Google guarda a chave que assina o app entregue aos +usuários e você assina cada envio com a upload key. Para apps novos, é o modelo +exigido junto com o formato AAB (conferir no Play Console Help). Na primeira +publicação, o Play Console oferece a adesão ao criar a versão. + +Consequência para este projeto: o SHA-1 que vale para restringir a +`GOOGLE_MAPS_API_KEY` dos apps instalados pela loja é o do **certificado de +assinatura do app** (Play Console, em *Integridade do app*), e não o da upload +key. Ver a seção de Conformidade. + +### 3.5 Onde guardar a chave e o que fazer se ela for perdida + +- Guarde o `.jks` e as senhas em um cofre de senhas da instituição, com uma + cópia de segurança em um segundo local. +- Senha e arquivo ficam em lugares separados. +- Nunca envie a chave ou as senhas por chat, e-mail ou issue, e não as coloque + em repositório, nem mesmo privado. +- Defina duas pessoas com acesso, para que a saída de uma não deixe o projeto + sem a chave. + +**Se a upload key for perdida ou exposta:** com o Play App Signing ativo, o +titular pode pedir ao Google o reset da upload key pelo Play Console (*Integridade +do app*). Gera-se uma nova chave, envia-se o certificado público e, depois da +análise do Google, ela passa a valer. Sem o Play App Signing, perder a chave +impede novas atualizações do app. Confirmar o procedimento atual no Play +Console Help. + +## 4. Build de release + +### 4.1 AAB, não APK + +O Play recebe o pacote no formato **Android App Bundle (AAB)**, e não APK. A +loja gera a partir dele os APKs otimizados para cada aparelho. Conferir no +Play Console Help se o AAB continua sendo exigido para apps novos. + +```bash +cd apps/ +flutter pub get +flutter build appbundle --release +``` + +O arquivo sai em `build/app/outputs/bundle/release/app-release.aab`. + +**Teste local (`apps/admin`, Flutter 3.47.2, JDK 25):** o comando acima +concluiu em cerca de 56 segundos e gerou um AAB de 47,6 MB. Esse pacote foi +assinado com a chave de debug (estado atual do repositório) e **não deve ser +enviado ao Play**. Só passa a valer para envio depois da seção 3. + +O build modifica arquivos versionados. Depois de rodar `flutter pub get` e o +build, o Flutter alterou `apps/admin/pubspec.lock` e +`apps/admin/analysis_options.yaml`. Confira com `git status` e não inclua +essas mudanças em um commit que não seja sobre elas. + +### 4.2 Parâmetros de cada app + +| App | O que passar no build | +|----------|------------------------| +| Paciente | `--dart-define=SINALACS_HOST=https:///` | +| ACS | `SINALACS_MQTT_PASSWORD` e `GOOGLE_MAPS_API_KEY`, além do host; ver abaixo | +| Admin | nada (não há segredos nem host configurável) | + +- **ACS:** o `build.gradle.kts` faz falhar de propósito um build comum sem a + senha do broker MQTT, e o caminho usado no desenvolvimento é + `./scripts/dev/run_acs.sh --build`, que gera um **APK** de desenvolvimento. + Um AAB de release do ACS exige passar as mesmas `--dart-define` no + `flutter build appbundle`. Esse fluxo **não foi testado** neste guia. +- Os valores reais (senha do broker, chave do Maps) vêm do ambiente de quem + compila, nunca do repositório e nunca deste documento. A + `GOOGLE_MAPS_API_KEY` vira texto vazio se faltar e o build não falha; o mapa + quebra só em execução. + +### 4.3 Ofuscação e símbolos de depuração + +A ofuscação dificulta a engenharia reversa do código Dart e reduz o tamanho. +Ela exige `--split-debug-info`, que grava os símbolos fora do pacote: + +```bash +flutter build appbundle --release \ + --obfuscate \ + --split-debug-info= +``` + +- **Guarde a pasta de símbolos, por versão.** Sem ela, os relatórios de crash + ficam ilegíveis. Use um nome que inclua a versão (por exemplo, + `simbolos//1.0.0+1/`). +- Não guarde os símbolos dentro do repositório. Eles não são segredo, mas são + grandes e mudam a cada build; o lugar certo é o armazenamento de artefatos + da instituição. +- Para ler um stack trace ofuscado, use `flutter symbolize`, apontando para o + arquivo de símbolos da arquitetura correspondente (ver a documentação do + Flutter sobre ofuscação). + +**Teste local (`apps/admin`):** o build com `--obfuscate` e +`--split-debug-info` concluiu em cerca de 47 segundos e gerou um AAB de +45,2 MB. A pasta de símbolos recebeu três arquivos, um por arquitetura: +`app.android-arm64.symbols`, `app.android-arm.symbols` e +`app.android-x64.symbols`. O build também avisou que a biblioteca gerada +contém informação de depuração (DWARF) não ofuscada e sugeriu a opção +`--strip`; essa opção não foi testada neste guia. + +### 4.4 R8 / ProGuard + +Os `build.gradle.kts` dos três apps **não têm configuração explícita** de +`isMinifyEnabled` nem de `proguardFiles`. Mesmo assim, no build de release do +`apps/admin` (Flutter 3.47.2) o R8 rodou: `build/app/outputs/mapping/release/` +ficou com `mapping.txt`, `usage.txt`, `seeds.txt`, `configuration.txt`, +`resources.txt` e `mapping.prt`. Isso foi verificado só no Admin; para Paciente +e ACS, confira do mesmo modo depois do build: + +```bash +ls build/app/outputs/mapping/release/ +``` + +Guarde o `mapping.txt` junto com os símbolos da seção 4.3, por versão, e envie-o +ao Play Console ao publicar. Se o código minificado quebrar algum plugin, os +erros aparecem só no release; teste o AAB de release em um aparelho antes de +enviar. + +### 4.5 Tamanho do pacote + +Anote o tamanho de cada AAB a cada versão: + +```bash +ls -lh build/app/outputs/bundle/release/ +``` + +Referência: o AAB do `apps/admin` ficou com 47,6 MB. Com ofuscação, o mesmo app ficou com 45,2 MB. Esse valor é maior do +que o esperado para um app que só mostra dados mock, e ainda não foi +investigado. O AAB contém todas as arquiteturas, e o tamanho de download que o +usuário vê é menor. Conferir no Play Console o tamanho estimado de download e +os limites atuais de tamanho do pacote. + +## 5. Ficha no Play Console + +A ficha é o que o público vê na loja. Ela vale para os apps que forem +publicados na loja pública (ver a seção de particularidades por app). Os +limites abaixo devem ser conferidos no Play Console Help, porque mudam com o +tempo. + +### 5.1 Textos + +| Campo | Limite habitual | Observação | +|-------|-----------------|------------| +| Título | 30 caracteres | Nome do app na loja; pode diferir do `android:label` | +| Descrição curta | 80 caracteres | Aparece nos resultados de busca | +| Descrição longa | 4.000 caracteres | Explica o que o app faz e para quem | + +Orientações para o SinalACS: + +- Descreva apenas o que o app faz hoje. O SinalACS prioriza atendimentos a + partir de sinais clínicos estruturados; ele **não faz diagnóstico** nem + substitui atendimento de urgência. Deixe isso explícito na descrição longa. +- Não prometa resultados clínicos nem use termos como "diagnóstico" ou + "tratamento" para descrever a triagem. +- Nenhum texto, exemplo ou captura pode conter dado real de paciente. +- O texto da ficha deve ser coerente com a política de privacidade e com a + declaração de Data safety (seção de Conformidade). + +Rascunho de ponto de partida para o app do Paciente, a alinhar com os +mantenedores antes de publicar (baseado nos fluxos descritos em `AGENTS.md`: +login por código, alerta de urgência, triagem estruturada, acompanhamento do +pedido e painel "Meus Dados"): + +- *Descrição curta:* "Informe seus sinais de saúde e acompanhe o retorno da + equipe da sua área." (conferir o limite de 80 caracteres) +- *Descrição longa:* descrever cada fluxo em uma frase, incluir o aviso de que + o app não substitui o serviço de urgência (SAMU) e indicar o canal de + contato para dúvidas de privacidade. + +### 5.2 Imagens + +- **Capturas de tela:** `docs/screenshots/acs` e `docs/screenshots/admin` têm + cinco capturas cada, e `docs/screenshots/patient` tem apenas uma + (`01-emergencia.png`, a tela de alerta de urgência). O `docs/README.md` + informa que as capturas usam dados sintéticos. A Play Store exige um + número mínimo de capturas por tipo de aparelho (conferir o número atual no + Play Console Help), então, antes de publicar o app do Paciente, é preciso + produzir mais capturas dos fluxos dele, sempre com dados sintéticos. Confira + também as dimensões exigidas pela loja. +- **Ícone:** o da loja é enviado à parte do ícone do app. Ver a seção 2.3 sobre + o ícone adaptativo. +- **Feature graphic:** imagem de destaque, habitualmente de 1024 × 500 px + (conferir). Ainda não existe no repositório e precisa ser criada. +- Capturas de tela não podem expor dados pessoais, nomes reais, CPFs nem + localizações reais. + +### 5.3 Categoria + +Duas categorias são candidatas: **Medicina** e **Saúde e fitness**. A escolha +interage com a política de apps de saúde (seção de Conformidade), então deve +ser decidida junto com a declaração de apps de saúde. Escolher a categoria que +descreve a função real do app, sem inflar o escopo clínico. + +### 5.4 Classificação de conteúdo (IARC) + +O Play Console pede o preenchimento de um questionário do IARC, que gera a +classificação etária de cada região. Responda de acordo com o que o app de +fato contém (por exemplo, se há conteúdo gerado por usuários ou compras). +Respostas imprecisas podem levar à rejeição ou à remoção do app. + +### 5.5 Público-alvo + +Declare a faixa etária do público. O app do Paciente coleta CPF, data de +nascimento e dados de saúde, então a escolha do público-alvo precisa ser +coerente com a política de privacidade e com o tratamento de dados de +menores previsto na LGPD (`spec/lgpd_design.md`). Se o público incluir +crianças, valem exigências adicionais da loja (conferir no Play Console +Help). Decidir isso com os mantenedores antes de preencher. + +## 6. Conformidade + +Os três apps tratam dados de saúde e, no caso do Paciente, CPF, data de +nascimento e localização. Nenhum dado real de paciente pode aparecer em +capturas, contas de teste, exemplos ou neste documento. As regras do Google +mudam com frequência: confira cada ponto abaixo no Play Console Help antes de +enviar. + +### 6.1 Justificativa de permissões sensíveis + +A justificativa de cada permissão deve ser coerente com o que o app faz, com a +declaração de Data safety (6.4) e com a política de privacidade (6.5). + +| Permissão | App | Situação hoje | O que justificar | +|-----------|-----|---------------|------------------| +| `ACCESS_FINE_LOCATION`, `ACCESS_COARSE_LOCATION` | Paciente | Declaradas no `main`; uso em primeiro plano com `getCurrentPosition` | Por que o app lê a posição do paciente | +| `POST_NOTIFICATIONS` | Paciente | Não declarada no `main`; o app pede a permissão em execução | Por que o app envia notificações | +| `RECEIVE_BOOT_COMPLETED` | Paciente | Declarada no `main`; origem não investigada | Só se ficar; remover se não for necessária | +| Câmera | nenhum | Nenhum plugin de câmera nos três apps | Não se aplica; não declare câmera | + +**Localização (Paciente).** A leitura é pontual e em primeiro plano; não foi +encontrado `getPositionStream` no `lib/` do Paciente nem +`ACCESS_BACKGROUND_LOCATION` no manifest. Enquanto isso se mantiver, o +formulário específico de localização em segundo plano não deveria ser +necessário (conferir). O que o app faz com a posição está descrito em +`spec/lgpd_data_audit.md`: um digest SHA-256 sobre latitude e longitude +normalizadas em seis casas, truncado em 12 caracteres, guardado em campos como +`alerts.locationHash`. A auditoria trata isso como **pseudonimização, não +anonimização**, e registra que o digest truncado ainda pode ser correlacionado +por força bruta, principalmente em áreas rurais. Por isso, na ficha e na +política de privacidade, a localização deve ser declarada como dado coletado, +e não como "não coletada". A justificativa de uso (associar o alerta de +urgência à posição do paciente) deve ser confirmada no fluxo do app antes de +ser escrita na ficha. + +**Notificações (Paciente).** O app depende de `flutter_local_notifications` e +chama `requestNotificationsPermission()`, mas o manifest principal não declara +`POST_NOTIFICATIONS`. Se o plugin não a adicionar ao manifest mesclado, em +aparelhos com Android 13 ou superior as notificações não aparecem com +`targetSdk = 36`. Conferir no manifest mesclado do release (seção 2.6) e, se +faltar, declarar a permissão e justificar o uso. + +**ACS e Admin.** Nenhum dos dois declara permissão no `main`. Conferir o +manifest mesclado do release antes de preencher qualquer formulário. + +### 6.2 GOOGLE_MAPS_API_KEY + +Apenas o ACS usa mapa (`google_maps_flutter`). A chave entra no build por +`--dart-define=GOOGLE_MAPS_API_KEY=` e é injetada no manifest como +`com.google.android.geo.API_KEY`; ela não está versionada. Se a variável +faltar, o valor vira texto vazio e o build **não falha**: o mapa só quebra em +execução. Por isso, confirme em um aparelho que o mapa carrega no release. + +Restrinja a chave no Google Cloud Console por **nome do pacote** +(`br.com.prismrr.sinalacs.acs`) e por **SHA-1 do certificado de assinatura**. +Com o Play App Signing (seção 3.4), o SHA-1 que vale para o app instalado pela +loja é o do certificado de assinatura do app, que aparece no Play Console em +*Integridade do app*, e não o da upload key. Registre também o SHA-1 da upload +key e o de debug, se quiser testar AABs e builds locais com a mesma chave. +Nunca coloque o valor da chave neste documento nem no repositório. + +### 6.3 Credenciais de teste para o revisor do Google + +O Google precisa conseguir abrir o app para revisá-lo. Um app que pede login e +não dá acesso ao revisor costuma ser reprovado (conferir a regra atual de +*Acesso ao app* no Play Console Help). Regras deste projeto: + +- Use **contas fictícias**, criadas só para o revisor, em um ambiente de teste. + Jamais forneça dado real de paciente ou credencial institucional real. +- Não escreva usuário, senha ou código neste documento; eles vão apenas no + formulário do Play Console. + +Situação de cada app hoje: + +| App | Como entra hoje | Problema para o revisor | +|-----|-----------------|--------------------------| +| Paciente | CPF, data de nascimento e código OTP | Um revisor não recebe o código; é preciso uma conta de teste com um caminho de acesso definido (decisão em aberto) | +| ACS | Matrícula e senha (login institucional) | Precisa de uma matrícula fictícia e de um backend acessível pela internet | +| Admin | Login real do staff (matrícula + senha + TOTP) contra o backend; sem atalho de desenvolvimento | O revisor precisa de uma conta de staff com MFA ativada e de um backend acessível; sem isso não passa da primeira tela | + +Em todos os casos, o backend precisa estar acessível para o revisor. Hoje não +existe deploy de produção (`backend/DEPLOY.md` descreve apenas uma demo +free-tier). + +### 6.4 Data safety (declaração de segurança dos dados) + +O formulário de Data safety do Play Console pede, para cada tipo de dado, +se o app **coleta** e se **compartilha**, se o dado é tratado só de forma +efêmera, se é obrigatório ou opcional e para quê é usado. Ele também pergunta +se os dados trafegam criptografados e se o usuário pode pedir a exclusão. + +A fonte deste mapeamento é `spec/lgpd_data_audit.md`. Regras: + +- Declara-se o que o app **envia para fora do aparelho**, mesmo que o servidor + guarde só um hash. O CPF, por exemplo, é enviado no login do Paciente + (`auth.requestOtp`); o fato de o servidor persistir apenas o hash não tira o + CPF da declaração. +- Dado pseudonimizado continua sendo dado pessoal (a própria auditoria trata + o `locationHash` assim). Não declare como "não coletado". +- O formulário cobre também o que bibliotecas de terceiros coletam. O ACS usa + `google_maps_flutter`; revise o que o SDK do mapa coleta e como o Google + trata a transferência a prestadores de serviço (conferir no Play Console + Help o que conta como "compartilhamento"). + +Mapeamento em andamento (tabelas lidas até aqui): + +| Dado (tabela.coluna) | Categoria provável no formulário (conferir) | Observação da auditoria | +|----------------------|----------------------------------------------|--------------------------| +| `users.name` | Informações pessoais: nome | Texto claro; necessário para a identificação presencial pelo ACS | +| `users.birthDate` | Informações pessoais: outras (data de nascimento) | Timestamp exato; a auditoria sugere avaliar truncar para data ou idade | +| `users.cpfHash` | Identificadores de usuário ou outras informações (conferir) | Persistido como hash; o CPF em si é enviado no login | +| `users.role`, `users.microAreaId` | Identificadores de usuário | Perfil e microárea; base do controle de acesso por território | +| `user_credentials.*` (ACS) | Credenciais de login (conferir onde declarar) | A senha é enviada no login do ACS e persistida só como Argon2id com salt | +| `otp_challenges.*` (Paciente) | Dado de autenticação | O código não é persistido em claro, só o HMAC-SHA-256; validade de 5 minutos e teto de 5 tentativas | +| `enrollment_tokens.*` (Paciente, criado pelo ACS) | Dado de autenticação | O token é persistido só como hash (`tokenHash`) | +| `patients.emergencyContact` | Informações pessoais de terceiro (conferir a categoria) | Dado de outra pessoa; o conteúdo do campo (nome, telefone) precisa ser confirmado | +| `patients.isChronic`, `patients.chronicConditions`, `patients.lastTriageAt` | Saúde e fitness: informações de saúde | `chronicConditions` é persistida como texto cifrado com versão da chave | +| `triage_sessions.answers`, `resultRisk`, `resultDisplay` (Paciente) | Saúde e fitness: informações de saúde | `answers` é persistida como texto cifrado com versão da chave | +| `visits.riskLevelBefore`, `riskLevelAfter`, `notes` (ACS) | Saúde e fitness: informações de saúde | `notes` é persistida como texto cifrado com versão da chave | +| `alerts.riskLevel`, `status` e horários | Saúde e fitness: informações de saúde (nível de risco) | Alertas vermelhos não podem ser descartados em silêncio | +| `patients.lastLocationHash`, `alerts.locationHash` | Localização (conferir se aproximada ou precisa) | Digest SHA-256 truncado de latitude e longitude; pseudonimização, não anonimização | +| `triage_sessions.deviceId`, `alerts.deviceId` | Identificadores de dispositivo ou outros | Identificador do aparelho | + +**Ainda a mapear:** as tabelas seguintes do inventário (`alert_deliveries`, +`audit_logs` e as demais, incluindo as do Serverpod). + +**Uma declaração por app.** O formulário é preenchido para cada app. Antes +de enviar, separe esta tabela em uma versão para cada app publicado: + +- **Paciente:** CPF, data de nascimento, respostas de triagem, alerta de + urgência, dado de localização, identificador de dispositivo e contato de + emergência. +- **ACS:** matrícula e senha, visitas e notas, lista de pacientes da + microárea. +- **Admin:** hoje não envia nada (usa dados mock); a declaração muda quando o + backend existir. + +**Pontos a confirmar antes de preencher o formulário:** + +- O que `patients.emergencyContact` guarda e se o app do Paciente o envia. +- Como o código OTP chega ao paciente. A auditoria não mostra telefone nem + e-mail nas tabelas lidas. +- Se a coordenada de localização sai do aparelho ou só o digest. A auditoria + recomenda impedir que a coordenada crua saia do dispositivo, mas o que o app + envia hoje não foi verificado neste guia. + +**Em trânsito:** o RPC é HTTPS (TLS terminado no Traefik) e o broker MQTT usa +TLS na porta 8883. Isso vale para o ambiente de desenvolvimento descrito no +repositório; confirmar no ambiente de produção quando ele existir. + +**Exclusão de dados:** a auditoria (seção 4.1) registra a ausência de rotina +de expurgo. O formulário pergunta se o usuário pode pedir a exclusão, e a +resposta precisa refletir o que existe de fato. + +**Antes de enviar o formulário**, cada ponto abaixo precisa estar resolvido, +porque o Data safety é responsabilidade do desenvolvedor e deve ser coerente +com a política de privacidade: + +1. Separar o mapeamento por app (Paciente, ACS e, depois, Admin). +2. Ajustar os nomes de categoria à versão atual do formulário. +3. Fechar os três pontos em aberto: conteúdo de `patients.emergencyContact`, + canal de entrega do código OTP e o que sai do aparelho na localização. +4. Mapear as tabelas restantes do inventário (`spec/lgpd_data_audit.md`). +5. Revisar o que o SDK do mapa (ACS) coleta. + +### 6.5 Política de privacidade + +Todo app publicado precisa de uma política de privacidade, e ela deve ser +informada em dois lugares: no campo próprio do Play Console e dentro do app +(link ou texto). Requisitos da política do Google (conferir a versão atual): + +- URL **ativa, pública, sem restrição geográfica, não PDF e não editável**. +- Nome da entidade que aparece na ficha da loja deve constar na política, ou + o app deve ser citado nela. +- Deve descrever acesso, coleta, uso e compartilhamento de dados pessoais e + sensíveis de forma abrangente, **sem se limitar ao que foi declarado no Data + safety**. +- Deve ser coerente com o Data safety (6.4) e com as permissões (6.1). + +**Situação hoje:** não existe URL pública de política de privacidade, e o +repositório não a hospeda. Isso impede a publicação (ver Pós-análise). + +**Conteúdo mínimo sugerido**, a alinhar com `spec/lgpd_design.md` e com um +responsável jurídico/encarregado de dados: + +- quem é o controlador e como falar com o encarregado (LGPD); +- quais dados cada app coleta, para quê e com qual base legal; +- dados de saúde e de localização, tratados como sensíveis ou com risco + aumentado, incluindo que o `locationHash` é pseudonimização e não + anonimização (`spec/lgpd_data_audit.md`); +- com quem os dados são compartilhados (por exemplo, o prestador do serviço de + mapa, no ACS); +- por quanto tempo são guardados. A auditoria registra a **ausência de rotina + de expurgo**; a política não pode prometer prazos que o sistema não cumpre; +- direitos do titular e como exercê-los. O app do Paciente já tem o painel + "Meus Dados" (LGPD) descrito no `AGENTS.md`; confirmar o que ele permite de + fato antes de citá-lo; +- que o app **não substitui atendimento de urgência**. + +### 6.6 Declaração de apps de saúde e política de conteúdo médico + +O SinalACS trata dados de saúde e oferece uma triagem, então está no escopo da +política de saúde do Google. Passos no Play Console: + +1. Em *Política > Conteúdo do app* (App content), preencher a **declaração de + apps de saúde** (Health apps declaration), escolhendo as categorias que + descrevem o app. Uma declaração por app. +2. Manter a declaração coerente com a categoria da loja (seção 5.3), com o + Data safety e com as permissões. +3. Pedir só as permissões que a função principal exige e **remover as não + usadas** (ver seção 2.4: `RECEIVE_BOOT_COMPLETED` e `POST_NOTIFICATIONS`). + +**Conteúdo médico.** A política do Google proíbe funcionalidade de saúde +enganosa ou prejudicial e exige comprovação regulatória ou aviso para apps +com função médica. Orientações para o SinalACS: + +- A classificação de risco é determinística e segue o modelo do Protocolo de + Manchester, conforme o `CLAUDE.md`. A ficha e o app devem descrevê-la como + **apoio à priorização**, e não como diagnóstico. +- Exibir, no app e na ficha, um aviso de que o app não substitui avaliação + clínica nem o serviço de urgência (SAMU). +- Não foi avaliado neste guia se a triagem se enquadra como software com + finalidade médica sujeito a regulação sanitária. Essa pergunta deve ser + levada aos mantenedores e ao responsável regulatório antes de publicar. + +## 7. Trilhas de teste + +O Play Console oferece quatro trilhas. A ordem recomendada é a seguinte: + +| Ordem | Trilha | Para quê | Observação | +|-------|--------|----------|------------| +| 1 | Teste interno | Equipe de desenvolvimento e QA | Liberação rápida; limite de cerca de 100 testers (conferir) | +| 2 | Teste fechado | Grupo maior e controlado | Para conta pessoal nova, é a trilha que conta para a exigência de 12 testers por 14 dias (seção 1.1) | +| 3 | Teste aberto | Qualquer pessoa pode aderir | Opcional; em conta pessoal nova, só fica disponível depois do acesso à produção | +| 4 | Produção | Lançamento público | Usar rollout gradual | + +O mesmo AAB pode ser promovido de uma trilha para a seguinte. Cada envio +precisa de um `versionCode` maior que o anterior (seção 2.2). + +### 7.1 Como adicionar testadores + +- **Teste interno e fechado:** em *Testes*, escolha a trilha e crie uma lista + de testadores por e-mail (contas Google), ou associe um grupo do Google. +- Cada testador recebe um **link de adesão** e precisa aceitá-lo com a conta + cadastrada. Na conta pessoal nova, ele deve permanecer inscrito durante os + 14 dias seguidos. +- Use somente contas de testadores reais e **nunca dado real de paciente** + durante o teste. O app deve apontar para um ambiente de teste com seed + sintético. + +Os detalhes da tela mudam; confirmar no Play Console Help. + +### 7.2 Rollout gradual (staged rollout) + +Na produção, libere a versão para uma parcela dos usuários e aumente aos +poucos, acompanhando o Android vitals (seção de Pós-análise). Recomendação +deste guia: começar com uma parcela pequena (por exemplo, 10 a 20%), observar +travamentos e ANRs por alguns dias e só então ampliar. O Play Console permite +pausar o rollout se aparecer um problema. Os percentuais são uma sugestão e +não uma exigência do Google. + +## 8. Particularidades por app + +| App | Público | Distribuição sugerida neste guia | +|-----|---------|-----------------------------------| +| Paciente | Cidadão | Loja pública, candidato natural | +| ACS | Agentes comunitários de saúde | Avaliar distribuição privada | +| Admin | Gestão institucional | Avaliar distribuição privada | + +### 8.1 Paciente + +É o app destinado ao cidadão, então é o candidato à loja pública. É o que +mais exige da conformidade: CPF, data de nascimento, dados de saúde e +localização (seção 6), política de privacidade em URL pública e credenciais de +teste para o revisor. + +### 8.2 ACS e Admin + +São de uso institucional: o ACS é usado por agentes cadastrados (login com +matrícula e senha) e o Admin é um backoffice. Nenhum dos dois faz sentido como +app aberto ao público. O caminho a avaliar é a **distribuição privada pelo +Managed Google Play**, em que o app fica disponível apenas para a organização +que o gerencia. Pontos a verificar no Play Console Help: + +- se a conta de desenvolvedor precisa ser de organização para publicar um app + privado e que identificador da organização é necessário; +- se o app privado passa pelas mesmas verificações de política e de Data + safety (a expectativa é que sim); +- quem administra os dispositivos da instituição e como eles receberão o app. + +Observações do estado atual do repositório: + +- O **ACS** guarda visitas no aparelho, com banco cifrado, e depende de + backend e de broker MQTT acessíveis; sem produção, não há o que publicar. +- O **Admin** ainda roda sobre `MockAdminDataSource`, sem backend, e o login + fica desativado em release (seção 6.3). Ele não deve ser publicado antes de + ter um backend real. + +## 9. Checklist final antes de enviar para revisão + +Marque cada item e anote o app (Paciente, ACS ou Admin) a que se aplica. + +**Build e assinatura** + +- [ ] `applicationId` confirmado como definitivo (2.1) +- [ ] `version` no `pubspec.yaml` com `+n` maior que o último envio (2.2) +- [ ] Release assinado com a **upload key**, e não com a chave de debug (3.3) +- [ ] `key.properties` e `.jks` fora do Git (`git status` limpo) (3.2) +- [ ] AAB gerado com `flutter build appbundle --release` (4.1) +- [ ] Host de produção informado por `--dart-define`; sem `10.0.2.2` (2.5) +- [ ] `ENABLE_DEV_LOGIN` desligado no backend de produção (2.5) +- [ ] CA de desenvolvimento retirada do pacote, se não for necessária (2.5) +- [ ] Símbolos e `mapping.txt` guardados por versão (4.3 e 4.4) + +**Manifest e permissões** + +- [ ] Manifest mesclado do release conferido (2.6) +- [ ] `INTERNET` presente no release, se o app usa rede (2.4) +- [ ] `POST_NOTIFICATIONS` resolvida no Paciente (6.1) +- [ ] `RECEIVE_BOOT_COMPLETED` justificada ou removida (6.1) +- [ ] Nenhuma permissão sem uso (6.6) + +**Ficha e conformidade** + +- [ ] Título, descrições, capturas (com dados sintéticos) e feature graphic (5) +- [ ] Categoria, classificação IARC e público-alvo (5.3 a 5.5) +- [ ] Data safety separado por app e coerente com a auditoria LGPD (6.4) +- [ ] Política de privacidade em URL pública, informada no Console e no app (6.5) +- [ ] Declaração de apps de saúde preenchida (6.6) +- [ ] Aviso de que o app não substitui a urgência, no app e na ficha (6.6) +- [ ] `GOOGLE_MAPS_API_KEY` restrita por pacote e SHA-1 (6.2), no ACS +- [ ] Credenciais de teste fictícias e backend acessível ao revisor (6.3) + +**Lançamento** + +- [ ] Teste interno e fechado concluídos (7) +- [ ] Rollout gradual configurado (7.2) +- [ ] Nenhum segredo nem dado real de paciente em capturas, contas de teste ou + neste documento + +## 10. Referências + +- [Play Console Help](https://support.google.com/googleplay/android-developer) +- [Requisitos de teste para contas pessoais novas](https://support.google.com/googleplay/android-developer/answer/14151465) +- [Política de conteúdo e serviços de saúde](https://support.google.com/googleplay/android-developer/answer/16555673) +- [Flutter: Build and release an Android app](https://docs.flutter.dev/deployment/android) +- Documentos do repositório: `spec/lgpd_data_audit.md`, `spec/lgpd_design.md`, + `backend/DEPLOY.md`, `docs/README.md` + +## Pós-análise + +Análise feita em 30/09/2026 sobre a `develop` (commit `6a66c1b`). Cada lacuna +indica como foi verificada: **código** (lido nos arquivos do repositório) ou +**documentação** (afirmado em `CLAUDE.md`, `AGENTS.md` ou `apps/CLAUDE.md` e +não reconferido no código). + +### Lacunas do repositório que hoje impedem a publicação + +| # | Lacuna | Verificação | +|---|--------|-------------| +| 1 | Release assinado com a chave de debug nos três apps (`signingConfig = signingConfigs.getByName("debug")`) | código | +| 2 | `INTERNET` não declarada no manifest `main` dos três apps; falta conferir o manifest mesclado | código | +| 3 | Paciente aponta por padrão para `https://10.0.2.2/` (`backend_config.dart`, linha 24); não há host de produção | código | +| 4 | Não existe deploy de produção do backend; `backend/DEPLOY.md` descreve só uma demo free-tier | documentação | +| 5 | Não foi encontrada URL pública de política de privacidade nem link para ela nos apps; há menções só em planos e especificações em `docs/superpowers/` (busca em `README.md`, `docs/`, `backend/DEPLOY.md` e `apps/*/lib`) | código | +| 6 | Admin roda sobre `MockAdminDataSource` (só o login é real, `auth.loginStaff`); sem credencial de staff o revisor não passa do login | código | +| 7 | Paciente pede `POST_NOTIFICATIONS` em execução e não a declara no manifest `main` | código | +| 8 | `RECEIVE_BOOT_COMPLETED` declarada no Paciente sem justificativa conhecida | código | +| 9 | Os três apps estão com `version: 1.0.0`, sem `+n` | código | +| 10 | Paciente e Admin sem ícone adaptativo (sem `mipmap-anydpi-v26`); o ACS não foi verificado | código | +| 11 | O Paciente tem uma única captura de tela no repositório e não há feature graphic | código | +| 12 | O CI não cobre release: o job `admin-android-build` compila só `flutter build apk --debug`, sem AAB, assinatura nem ofuscação (os demais jobs não foram lidos a fundo) | código | +| 13 | Senha do broker MQTT do ACS é constante de compilação (`SINALACS_MQTT_PASSWORD`), ou seja, fica embutida no binário distribuído | documentação | +| 14 | MFA e refresh token adiados; sem mTLS no broker | documentação | +| 15 | Data safety com pendências: conteúdo de `patients.emergencyContact`, canal de entrega do OTP e o que sai do aparelho na localização | código e auditoria | +| 16 | A auditoria LGPD registra ausência de rotina de expurgo de dados | documentação (título da seção 4.1) | +| 17 | Não foi decidido se a conta do Play será pessoal ou de organização, nem quem é o titular | decisão em aberto | +| 18 | Não foi avaliado se a triagem se enquadra em regulação sanitária de software | decisão em aberto | + +Pontos positivos verificados: os três apps ignoram `key.properties`, `*.jks` e +`*.keystore` no Git; nenhum arquivo de segredo está versionado na pasta +`apps/acs/android`; a chave do Maps entra por `--dart-define` e não está no +repositório. + +### Riscos de reprovação na revisão do Google + +O Google não publica uma lista de motivos por app; os riscos abaixo são os +mais prováveis para o SinalACS. + +- **Dados de saúde.** O app está no escopo da política de saúde: exige + declaração de apps de saúde e política de privacidade em URL pública. Divergência entre Data safety, + política e o que o app faz é motivo de rejeição. +- **Localização.** O Paciente declara localização precisa. A justificativa + precisa ser clara, e a política deve dizer que o `locationHash` é + pseudonimização, e não anonimização. +- **Login obrigatório sem conta de teste.** Paciente (CPF, nascimento e OTP), + ACS (matrícula e senha) e Admin (login desativado em release) não dão ao + revisor um caminho de entrada hoje. +- **App que parece quebrado.** Um release apontando para `10.0.2.2`, sem + `INTERNET` no manifest ou sem backend acessível não funciona para o + revisor. +- **Permissões sem uso.** `RECEIVE_BOOT_COMPLETED` no Paciente e, se o plugin + não a adicionar, a falta de `POST_NOTIFICATIONS`. +- **Conteúdo médico.** Descrever a triagem como diagnóstico, ou omitir o aviso + de que o app não substitui a urgência. + +### Recomendação por app + +| App | Recomendação | Justificativa | +|-----|--------------|---------------| +| Paciente | Loja pública, **mas não publicar ainda** | É o destinatário natural da loja pública, porém depende das lacunas 1 a 5, 7, 11 e 15, e de um backend de produção | +| ACS | Distribuição privada (Managed Google Play), **não publicar ainda** | Uso institucional; exige backend e broker de produção e uma decisão sobre a senha do broker embutida no binário (lacuna 13) | +| Admin | **Não publicar ainda** | Roda sobre dados mock, o login fica desativado em release e não há backend (lacunas 4 e 6) | + +### Monitoramento pós-lançamento + +- **Android vitals:** acompanhar taxas de ANR e de travamento no Play Console e + usar o rollout gradual (seção 7.2) para pausar se passarem dos limites. +- **Pre-launch report:** o Play executa o app em aparelhos de teste ao enviar + para as trilhas de teste. Como o login é obrigatório, o relatório só + exercita o app se houver conta de teste (seção 6.3). +- **Avaliações dos usuários:** acompanhar e responder, sem pedir nem aceitar + dado de saúde nos comentários. +- **Prazo de `targetSdk`:** o Google exige atualização anual do `targetSdk` + dos apps. Hoje é 36. Conferir o prazo vigente no Play Console Help e fixar a + mudança por PR, já que o valor é literal nos `build.gradle.kts`. + +### Próximos passos (cada um como issue separada) + +1. Configurar `signingConfigs` de release com `key.properties` nos três apps. +2. Adicionar um job de release ao CI (AAB, ofuscação, assinatura por segredo + do repositório) e automatizar o envio com fastlane. +3. Declarar `INTERNET` e `POST_NOTIFICATIONS` no manifest `main` do Paciente e + conferir o manifest mesclado; decidir sobre `RECEIVE_BOOT_COMPLETED`. +4. Criar e hospedar a política de privacidade (URL pública) e linká-la nos apps. +5. Provisionar backend e broker de produção e remover o host de desenvolvimento + do release. +6. Definir o caminho de acesso do revisor do Google (contas fictícias). +7. Completar o Data safety por app, fechando as pendências da seção 6.4. +8. Produzir capturas do Paciente, feature graphic e ícone adaptativo. +9. Padronizar `version` com `+n` e documentar a regra de incremento. +10. Avaliar a senha do broker MQTT embutida no ACS. +11. Decidir conta pessoal ou de organização e o titular do Play Console. +12. Investigar o tamanho do AAB do Admin (47,6 MB). +13. Levar a pergunta de enquadramento regulatório aos mantenedores. \ No newline at end of file diff --git a/docs/roteiro-video-sponsor.md b/docs/roteiro-video-sponsor.md index a11e27a4..86ef6c98 100644 --- a/docs/roteiro-video-sponsor.md +++ b/docs/roteiro-video-sponsor.md @@ -150,8 +150,8 @@ Todos disparam um `SnackBar` de "não integrado" e queimam a demonstração na f | Tela | Controle | Mensagem que aparece | |------|----------|----------------------| | `MapScreen` | "Traçar rota eficiente" | "Traçado de rota depende da integração de mapas." — e a própria tela imprime **"Mapa demonstrativo"** | -| `EscalationScreen` | "Ligar para o SAMU (192)" | "Discagem não está integrada neste protótipo." | -| `EscalationScreen` | "Encaminhar para UBS Central" | "Encaminhamento será integrado à UBS." | +| `EscalationScreen` | "Ligar para o SAMU (192)" | abre o discador do aparelho com 192 (sem discador: "Ligue manualmente para 192") | +| `EscalationScreen` | "Ligar para a UBS" | abre o discador com o telefone da UBS (sem telefone cadastrado: "ainda não cadastrou um telefone") | | `GeofencingScreen` | "Abrir formulário da visita" | "Use a tela Visita para registrar o atendimento." | | `NoticesScreen` | "Preparar aviso" | "Envio depende da integração de notificações push." | | `TerritorializationScreen` | "Atualizar dados da microárea" | "Atualização será integrada à API central." — a tela pode aparecer, o botão não pode ser tocado | diff --git a/docs/screenshots/admin/01-login.png b/docs/screenshots/admin/01-login.png index 4a714dfe..d907922b 100644 Binary files a/docs/screenshots/admin/01-login.png and b/docs/screenshots/admin/01-login.png differ diff --git a/docs/screenshots/admin/01b-codigo-ativacao.png b/docs/screenshots/admin/01b-codigo-ativacao.png new file mode 100644 index 00000000..f2fc6570 Binary files /dev/null and b/docs/screenshots/admin/01b-codigo-ativacao.png differ diff --git a/docs/screenshots/admin/02-indicadores.png b/docs/screenshots/admin/02-indicadores.png index 4d371c21..4b6b55a5 100644 Binary files a/docs/screenshots/admin/02-indicadores.png 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 index 7aed8ca9..ab0d76d3 100644 Binary files a/docs/screenshots/admin/03-microareas.png 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 index 7d6ba4d7..ab6f8c5b 100644 Binary files a/docs/screenshots/admin/04-alertas.png 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 index b042a8a3..94bd36ad 100644 Binary files a/docs/screenshots/admin/05-auditoria.png 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 index e5d5c467..be0b29f3 100644 Binary files a/docs/screenshots/admin/06-android-retrato.png 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 index 03587f78..b1ca0454 100644 Binary files a/docs/screenshots/admin/07-android-paisagem.png 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 index 948bd343..7c77d8f5 100644 Binary files a/docs/screenshots/admin/08-tablet-rail.png and b/docs/screenshots/admin/08-tablet-rail.png differ diff --git a/docs/showcase/.marprc.yml b/docs/showcase/.marprc.yml new file mode 100644 index 00000000..66f2368d --- /dev/null +++ b/docs/showcase/.marprc.yml @@ -0,0 +1,6 @@ +# Configuração do Marp CLI para o deck de showcase. +# Sem isto, `marp slides.md` ignora o tema `sinalacs` (cai no tema default) +# e descarta o HTML inline (cards, grids e mockups de celular). +themeSet: ./sinalacs.css +html: true +allowLocalFiles: true diff --git a/docs/showcase/images/acs-acionamento.png b/docs/showcase/images/acs-acionamento.png new file mode 100644 index 00000000..ff063440 Binary files /dev/null and b/docs/showcase/images/acs-acionamento.png differ diff --git a/docs/showcase/images/acs-dashboard.png b/docs/showcase/images/acs-dashboard.png new file mode 100644 index 00000000..a9a23fed Binary files /dev/null and b/docs/showcase/images/acs-dashboard.png differ diff --git a/docs/showcase/images/acs-login.png b/docs/showcase/images/acs-login.png new file mode 100644 index 00000000..40e5999c Binary files /dev/null and b/docs/showcase/images/acs-login.png differ diff --git a/docs/showcase/images/acs-mapa.png b/docs/showcase/images/acs-mapa.png new file mode 100644 index 00000000..d7eb6d7a Binary files /dev/null and b/docs/showcase/images/acs-mapa.png differ diff --git a/docs/showcase/images/acs-territorializacao.png b/docs/showcase/images/acs-territorializacao.png new file mode 100644 index 00000000..45deb2ed Binary files /dev/null and b/docs/showcase/images/acs-territorializacao.png differ diff --git a/docs/showcase/images/acs-visita.png b/docs/showcase/images/acs-visita.png new file mode 100644 index 00000000..d08a1e3a Binary files /dev/null and b/docs/showcase/images/acs-visita.png differ diff --git a/docs/showcase/images/admin-alertas.png b/docs/showcase/images/admin-alertas.png new file mode 100644 index 00000000..1f55fc69 Binary files /dev/null and b/docs/showcase/images/admin-alertas.png differ diff --git a/docs/showcase/images/admin-auditoria.png b/docs/showcase/images/admin-auditoria.png new file mode 100644 index 00000000..8edc2102 Binary files /dev/null and b/docs/showcase/images/admin-auditoria.png differ diff --git a/docs/showcase/images/admin-indicadores.png b/docs/showcase/images/admin-indicadores.png new file mode 100644 index 00000000..3750f674 Binary files /dev/null and b/docs/showcase/images/admin-indicadores.png differ diff --git a/docs/showcase/images/admin-microarea.png b/docs/showcase/images/admin-microarea.png new file mode 100644 index 00000000..69deeacd Binary files /dev/null and b/docs/showcase/images/admin-microarea.png differ diff --git a/docs/showcase/images/paciente-acompanhamento.png b/docs/showcase/images/paciente-acompanhamento.png new file mode 100644 index 00000000..f8188d0b Binary files /dev/null and b/docs/showcase/images/paciente-acompanhamento.png differ diff --git a/docs/showcase/images/paciente-autenticacao.png b/docs/showcase/images/paciente-autenticacao.png new file mode 100644 index 00000000..4289426b Binary files /dev/null and b/docs/showcase/images/paciente-autenticacao.png differ diff --git a/docs/showcase/images/paciente-emergencia.png b/docs/showcase/images/paciente-emergencia.png new file mode 100644 index 00000000..8f39eec2 Binary files /dev/null and b/docs/showcase/images/paciente-emergencia.png differ diff --git a/docs/showcase/images/paciente-lembretes.png b/docs/showcase/images/paciente-lembretes.png new file mode 100644 index 00000000..605d735d Binary files /dev/null and b/docs/showcase/images/paciente-lembretes.png differ diff --git a/docs/showcase/images/paciente-perfil.png b/docs/showcase/images/paciente-perfil.png new file mode 100644 index 00000000..dc702faf Binary files /dev/null and b/docs/showcase/images/paciente-perfil.png differ diff --git a/docs/showcase/images/paciente-triagem.png b/docs/showcase/images/paciente-triagem.png new file mode 100644 index 00000000..4890c35a Binary files /dev/null and b/docs/showcase/images/paciente-triagem.png differ diff --git a/docs/showcase/preview.html b/docs/showcase/preview.html new file mode 100644 index 00000000..c302378b --- /dev/null +++ b/docs/showcase/preview.html @@ -0,0 +1,349 @@ +SinalACS
+

SinalACS

+

Priorização clínica de visitas domiciliares na Atenção Primária à Saúde

+

Showcase protótipo

+
+
+

A doença não segue o mapa.

+

O Agente Comunitário de Saúde visita as casas na ordem do roteiro geográfico rua por rua, sempre igual.
+ Quem está pior pode ser a última visita do dia,
+simplesmente por morar no fim da rua.

+
+
+

Sinais clínicos estruturados → fila ranqueada por risco

+

Uma triagem de três perguntas, com regra determinística inspirada no
+Protocolo de Manchester sem inteligência artificial, sem campo editável.
+A mesma resposta produz sempre a mesma cor, de forma auditável.

+
+
+

e-SUS APS

+

Registra o que já aconteceu.

+
+
+

WhatsApp

+

Desorganiza comunicação sem estrutura nem prioridade.

+
+
+

SinalACS

+

Prioriza, com regra fechada e reproduzível.

+
+
+
+
+

Arquitetura em um slide

+
+
+

3 apps Flutter

+

Paciente, ACS e Admin isomorfismo Dart de ponta a ponta com o backend.

+
+
+

Backend Dart/Serverpod

+

RPC tipado, ORM e migrações geradas não é REST.

+
+
+

PostgreSQL + MQTT/TLS

+

Persistência relacional e entrega de alerta com confirmação (ACK).

+
+
+

Traefik

+

Reverse proxy/edge gateway para tráfego RPC e WebSockets do MQTT.

+
+
+

+Offline-first: cache territorial em sqflite no app do ACS, com +sincronização em segundo plano assim que a rede volta. +

+
+
+
+
+
+ Login do paciente com CPF e data de nascimento +
+
+
+

Paciente: Autenticação inclusiva

+
    +
  • Acesso com CPF e data de nascimento, sem senha
  • +
  • Atalho de QR Code visível na tela (leitura ainda depende de integração)
  • +
  • Pensado para baixo letramento digital: fricção mínima no primeiro acesso
  • +
+
+
+

Paciente

+
+
+
+
+
+ Alerta de urgência +
+
+
+

Paciente: Alerta de urgência

+
    +
  • Botão circular de emergência domina a hierarquia visual
  • +
  • Confirmação explícita antes de qualquer mudança de estado
  • +
  • O alerta é registrado e enfileirado a entrega ponta a + ponta com confirmação (ACK) é a prova técnica do próximo bloco
  • +
+
+
+

Paciente

+
+
+
+
+
+ Triagem com resultado de risco +
+
+
+

Paciente: Triagem rápida

+
    +
  • Três perguntas, uma resposta por vez, com indicador de progresso
  • +
  • Classificação determinística, sem campo editável + nem para o paciente, nem para o ACS
  • +
  • Mesma resposta → mesma cor, sempre. Regra fechada e auditável
  • +
+
Risco: Vermelho
+
+
+

Paciente

+
+
+

Paciente: Acompanhamento, lembretes e meus dados

+
+
+
+ Status da solicitação +
+
Status da solicitação
+
+
+
+ Lembretes de medicamentos e rotina +
+
Lembretes
+
+
+
+ Perfil clínico e meus dados +
+
Perfil clínico · meus dados
+
+
+

Paciente

+
+
+
+
+
+ Login institucional do ACS +
+
+
+

ACS: Login institucional

+
    +
  • Matrícula/CNS e senha, com indicador de operação offline
  • +
  • Mesmo padrão visual do login do Admin
  • +
  • Autenticação institucional real (SSO/gov.br) é passo seguinte do roadmap
  • +
+
+
+

ACS

+
+
+
+
+
+ Territorialização +
+
+
+

ACS: Territorialização

+
    +
  • Microárea, quantidade de pacientes e status do cache local
  • +
  • O ACS só acessa dados do próprio território + invariante do sistema, não uma configuração (INV-01)
  • +
  • Cache territorial via sqflite, para operar sem rede
  • +
+
+
+

ACS

+
+
+
+
+
+ Fila priorizada por risco +
+
+
+

ACS: Fila de priorização

+
    +
  • Ordem por gravidade clínica: vermelho → amarelo → verde
  • +
  • Cor é exclusiva da classificação de risco avisos + técnicos (sem conexão, falha ao salvar) usam azul, nunca vermelho/amarelo
  • +
  • Falha transitória ganha botão "Tentar agora", mesmo com reconexão + automática já em curso
  • +
+
+
+

ACS

+
+
+
+
+
+ Registro de visita offline +
+
+
+

ACS: Registro de visita offline

+
    +
  • Desfecho e observações registrados na porta da casa, com ou sem sinal
  • +
  • Enfileiramento local; sincronização automática ao voltar a rede
  • +
  • Em zona rural, é a diferença entre ter o dado e perder o dia
  • +
+
+
+

ACS

+
+
+
+
+
+ Tela de acionamento e escalonamento +
+
+
+

ACS: Acionamento e escalonamento

+
    +
  • Resume paciente, risco e endereço para acionar SAMU ou UBS
  • +
  • Botões deixam explícito que discagem e encaminhamento reais ainda não + estão integrados
  • +
  • Integração SAMU roadmap
  • +
+
+
+

ACS

+
+
+
+
+
+
+ Painel de indicadores +
+
+ Microáreas e vínculo ACS +
+
+
+
+

Admin: Indicadores e microáreas

+
    +
  • Contadores por risco, alertas abertos vs. reconhecidos e o TMRAV
  • +
  • Listagem somente leitura das microáreas e do ACS vinculado
  • +
  • Desktop-first: NavigationRail em telas largas, + NavigationBar abaixo de 640px
  • +
+
+
+

Admin

+
+
+
+
+
+
+ Alertas da UBS +
+
+ Logs de auditoria +
+
+
+
+

Admin: Alertas, auditoria e layout responsivo

+
    +
  • Alertas filtráveis por microárea e status sem controle de + reclassificação (a classificação não é alterável por humano, INV-02)
  • +
  • Log de auditoria somente leitura; toda visita às telas sensíveis + também é registrada
  • +
  • Mesmo ThemeData se adapta de celular a tablet
  • +
+
+
+

Admin

+
+
+

Segurança e LGPD

+
+
+

Dados sensíveis

+

Colunas clínicas criptografadas (AES-256/pgcrypto) em repouso e TLS em trânsito.

+
+
+

Restrição por microárea

+

INV-01 reforçada no backend, não só na interface um ACS nunca lê dados + fora do próprio território.

+
+
+

Trilha de auditoria

+

Acesso a dado sensível registrado com quem, quando e o quê inclusive + no backoffice Admin.

+
+
+

+Conformidade LGPD documentada em spec/lgpd_design.md; análise de +cibersegurança baseada em NIST CSF 2.0 em spec/security_assessment.md. +

+
+
+

Estado atual e roadmap

+

Protótipo validado localmente ciclo de alerta vermelho roda ponta a ponta +com confirmação de entrega, CI verde nos quatro trilhos.

+
+
+
Concluída
+ Fase 1 Prova de conceito +
+
+
Concluída
+ Fase 2 Alpha/Beta +
+
+
Em curso
+ Fase 3 Disponibilidade geral +

Deploy monitorado, teste de + carga e auditoria de conformidade LGPD

+
+
+

+Mapa interativo, geofencing, integração SAMU e avisos push +roadmap v1.5 hoje são stubs no protótipo, +não capacidades ativas. +

+
+
+

O pedido: um piloto controlado

+
+
1
UBS
+
2
microáreas
+
50
pacientes
+
+

+Para medir a única métrica que importa: quanto tempo leva, hoje, até alguém +chegar em quem está pior. +alvo < 90s (TMRAV) +

+

+SinalACS · github.com/hbgit/SinalACS · contato: completar antes da apresentação +

+
+

Equipe / autoria: completar antes da apresentação final.

\ No newline at end of file diff --git a/docs/showcase/sinalacs-showcase.pdf b/docs/showcase/sinalacs-showcase.pdf new file mode 100644 index 00000000..35cccc13 Binary files /dev/null and b/docs/showcase/sinalacs-showcase.pdf differ diff --git a/docs/showcase/sinalacs-showcase.pptx b/docs/showcase/sinalacs-showcase.pptx new file mode 100644 index 00000000..b4535d67 Binary files /dev/null and b/docs/showcase/sinalacs-showcase.pptx differ diff --git a/docs/showcase/sinalacs.css b/docs/showcase/sinalacs.css new file mode 100644 index 00000000..5827ddd1 --- /dev/null +++ b/docs/showcase/sinalacs.css @@ -0,0 +1,360 @@ +/* @theme sinalacs + * + * Tema Marp para o deck de showcase do SinalACS — versão fundo claro, + * a pedido do orientador. + * + * Nota sobre a mudança de fundo escuro -> branco: os apps do SinalACS + * usam tema escuro nativamente, mas o deck de apresentação é uma peça + * separada — não precisa espelhar 1:1 os tokens de cada *_theme.dart. + * Por isso a distinção .app-patient (que antes ajustava surface/border + * para bater com o tom mais frio do tema escuro do app Paciente) foi + * removida: num fundo branco essa diferença deixa de fazer sentido. + * + * Todas as cores de texto e marcador de risco abaixo foram trocadas + * por variantes mais escuras (equivalentes a Tailwind *-700/-800) para + * manter contraste AA contra fundo branco — as cores originais do app + * (mais claras, pensadas para contraste contra fundo escuro) ficariam + * ilegíveis aqui. As cores "fill" saturadas (usadas em fundo de + * etiqueta com texto branco) foram mantidas. + */ + +section { + --bg: #ffffff; + --surface: #f3f4f6; + --surface-raised: #e5e7eb; + --border: #d1d5db; + --text: #111827; + --text-muted: #4b5563; + + --patient-fill: #0d9488; + --patient-text: #0f766e; + --acs-fill: #2563eb; + --acs-text: #1d4ed8; + --admin-fill: #4f46e5; + --admin-text: #4338ca; + + /* Marcador (borda/fundo saturado) vs. texto (variante escura, AA) */ + --risk-red: #dc2626; + --risk-red-text: #b91c1c; + --risk-yellow: #f59e0b; + --risk-yellow-text: #b45309; + --risk-green: #10b981; + --risk-green-text: #047857; + + background: var(--bg); + color: var(--text); + font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-size: 24px; + padding: 56px 64px; +} + +h1 { + font-size: 44px; + font-weight: 700; + color: var(--text); + margin: 0 0 8px 0; +} + +h2 { + font-size: 36px; + font-weight: 700; + color: var(--text); + margin: 0 0 16px 0; +} + +p, li { + font-size: 22px; + line-height: 1.5; +} + +strong { + color: var(--text); +} + +a { + color: var(--acs-text); +} + +/* ---------- Capa ---------- */ +section.cover { + display: flex; + flex-direction: column; + justify-content: center; + align-items: flex-start; + padding: 96px; +} +section.cover h1 { + font-size: 64px; + margin-bottom: 4px; +} +section.cover .tagline { + font-size: 26px; + color: var(--text-muted); + margin-bottom: 40px; +} +section.cover .kicker { + display: inline-block; + font-size: 16px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--acs-text); + border: 1px solid var(--border); + border-radius: 999px; + padding: 6px 16px; + margin-bottom: 24px; +} + +/* ---------- Cartela central (frases-chave, arquitetura, cartelas de bloco) ---------- */ +section.statement { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + text-align: center; +} +section.statement h1 { + font-size: 48px; + max-width: 900px; +} +section.statement p { + max-width: 760px; + color: var(--text-muted); +} + +/* ---------- Layout dividido: moldura de tela à esquerda, texto à direita ---------- */ +.split { + display: flex; + align-items: center; + gap: 56px; + height: 100%; +} +.split .frame-col { + flex: 0 0 38%; + display: flex; + justify-content: center; +} +.split .info-col { + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; +} +.split .info-col ul { + margin: 0; + padding-left: 24px; +} +.split .info-col li { + margin-bottom: 10px; +} + +/* Moldura de celular simplificada — usar mockuphone.com/Figma para a + versão final se quiser um bezel realista; isto já é suficiente para + revisão em PR. Bezel preto funciona igual em fundo claro ou escuro. */ +.phone-frame { + width: 260px; + border: 10px solid #000; + border-radius: 36px; + background: #000; + box-shadow: 0 12px 32px rgba(17, 24, 39, 0.18); + overflow: hidden; +} +.phone-frame img { + display: block; + width: 100%; + border-radius: 8px; +} + +/* Duas telas lado a lado (uso pontual, no máximo) */ +.phone-pair { + display: flex; + gap: 24px; +} +.phone-pair .phone-frame { + width: 200px; +} + +/* Placeholder para capturas ainda não feitas — visível de propósito, + para não deixar passar despercebido num export de PDF. */ +.placeholder { + width: 260px; + aspect-ratio: 9 / 19.5; + border: 2px dashed #9ca3af; + border-radius: 24px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 24px; + color: var(--text-muted); + font-size: 16px; + background: var(--surface); +} +.placeholder .icon { + font-size: 32px; + margin-bottom: 12px; +} + +/* Trio de placeholders menores, para slides que resumem 3 telas */ +.placeholder-row { + display: flex; + gap: 20px; +} +.placeholder-row .placeholder { + width: 150px; + font-size: 13px; +} + +/* ---------- Etiqueta de app (rodapé do slide de tela) ---------- */ +.app-tag { + position: absolute; + bottom: 32px; + left: 64px; + font-size: 14px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + padding: 6px 16px; + border-radius: 999px; +} +.app-tag.patient { + background: var(--patient-fill); + color: #ffffff; +} +.app-tag.acs { + background: var(--acs-fill); + color: #ffffff; +} +.app-tag.admin { + background: var(--admin-fill); + color: #ffffff; +} + +/* ---------- Chips de risco — reservados exclusivamente para + classificação clínica (ver spec/ui_design.md); nunca usar como + decoração genérica. Marcador (border-left) na cor saturada do app; + texto na variante escura, para manter contraste AA em fundo claro. */ +.risk-chip { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 15px; + font-weight: 700; + padding: 4px 12px; + border-radius: 8px; + border-left: 4px solid transparent; + background: var(--surface-raised); +} +.risk-chip.red { border-left-color: var(--risk-red); color: var(--risk-red-text); } +.risk-chip.yellow { border-left-color: var(--risk-yellow); color: var(--risk-yellow-text); } +.risk-chip.green { border-left-color: var(--risk-green); color: var(--risk-green-text); } + +/* ---------- Card genérico (arquitetura, colunas do slide 3, LGPD) ---------- */ +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 16px; + padding: 24px; +} +.card h3 { + font-size: 20px; + margin: 0 0 8px 0; + color: var(--text); +} +.card p { + font-size: 16px; + color: var(--text-muted); + margin: 0; +} +.card-grid { + display: grid; + gap: 20px; +} +.card-grid.cols-3 { grid-template-columns: repeat(3, 1fr); } +.card-grid.cols-4 { grid-template-columns: repeat(4, 1fr); } + +/* ---------- Roadmap (barra de fases) ---------- */ +.roadmap { + display: flex; + gap: 16px; + margin-top: 24px; +} +.roadmap .phase { + flex: 1; + border-radius: 12px; + padding: 16px; + border: 1px solid var(--border); +} +.roadmap .phase.done { + background: #ecfdf5; + border-color: var(--risk-green); +} +.roadmap .phase.pending { + background: var(--surface); + color: var(--text-muted); +} +.roadmap .phase .status { + font-size: 14px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; +} +.roadmap .phase.done .status { color: var(--risk-green-text); } + +/* Selo de "alvo"/"hipótese" — obrigatório em qualquer métrica ainda + não medida em campo (ver guardrails do roteiro de vídeo). */ +.badge-target { + display: inline-block; + font-size: 13px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--risk-yellow-text); + border: 1px solid var(--risk-yellow); + border-radius: 6px; + padding: 2px 8px; + margin-left: 8px; +} + +/* Selo de roadmap/stub — para mapa, geofencing, SAMU, push */ +.badge-roadmap { + display: inline-block; + font-size: 13px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + border: 1px solid var(--border); + border-radius: 6px; + padding: 2px 8px; + margin-left: 8px; +} + +/* ---------- Encerramento ---------- */ +section.closing { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + text-align: center; +} +section.closing .bignums { + display: flex; + gap: 48px; + margin: 32px 0; +} +section.closing .bignums .num { + font-size: 56px; + font-weight: 700; + color: var(--acs-text); +} +section.closing .bignums .label { + font-size: 16px; + color: var(--text-muted); +} + +/* Rodapé padrão do Marp (paginação) */ +section::after { + color: var(--text-muted); + font-size: 14px; +} diff --git a/docs/showcase/slides.md b/docs/showcase/slides.md new file mode 100644 index 00000000..2ee5ee24 --- /dev/null +++ b/docs/showcase/slides.md @@ -0,0 +1,412 @@ +--- +marp: true +theme: sinalacs +paginate: true +size: 16:9 +lang: pt-BR +--- + + + +# SinalACS + +

Priorização clínica de visitas domiciliares na Atenção Primária à Saúde

+ +Showcase — protótipo + + + +--- + + + +# A doença não segue o mapa. + +O Agente Comunitário de Saúde visita as casas na ordem do roteiro geográfico — +rua por rua, sempre igual. Quem está pior pode ser a última visita do dia, +simplesmente por morar no fim da rua. + +--- + + + +# Sinais clínicos estruturados → fila ranqueada por risco + +Uma triagem de três perguntas, com regra determinística inspirada no +Protocolo de Manchester — sem inteligência artificial, sem campo editável. +A mesma resposta produz sempre a mesma cor, de forma auditável. + +
+
+

e-SUS APS

+

Registra o que já aconteceu.

+
+
+

WhatsApp

+

Desorganiza — comunicação sem estrutura nem prioridade.

+
+
+

SinalACS

+

Prioriza, com regra fechada e reproduzível.

+
+
+ +--- + +# Arquitetura em um slide + +
+
+

3 apps Flutter

+

Paciente, ACS e Admin — isomorfismo Dart de ponta a ponta com o backend.

+
+
+

Backend Dart/Serverpod

+

RPC tipado, ORM e migrações geradas — não é REST.

+
+
+

PostgreSQL + MQTT/TLS

+

Persistência relacional e entrega de alerta com confirmação (ACK).

+
+
+

Traefik

+

Reverse proxy/edge gateway para tráfego RPC e WebSockets do MQTT.

+
+
+ +

+Offline-first: cache territorial em sqflite no app do ACS, com +sincronização em segundo plano assim que a rede volta. +

+ +--- + + +
+
+
+ Login do paciente com CPF e data de nascimento +
+
+
+

Paciente — Autenticação inclusiva

+
    +
  • Acesso com CPF e data de nascimento, sem senha
  • +
  • Atalho de QR Code visível na tela (leitura ainda depende de integração)
  • +
  • Pensado para baixo letramento digital: fricção mínima no primeiro acesso
  • +
+
+
+ +Paciente + +--- + + +
+
+
+ Alerta de urgência +
+
+
+

Paciente — Alerta de urgência

+
    +
  • Botão circular de emergência domina a hierarquia visual
  • +
  • Confirmação explícita antes de qualquer mudança de estado
  • +
  • O alerta é registrado e enfileirado — a entrega ponta a + ponta com confirmação (ACK) é a prova técnica do próximo bloco
  • +
+
+
+ +Paciente + +--- + + +
+
+
+ Triagem com resultado de risco +
+
+
+

Paciente — Triagem rápida

+
    +
  • Três perguntas, uma resposta por vez, com indicador de progresso
  • +
  • Classificação determinística, sem campo editável — + nem para o paciente, nem para o ACS
  • +
  • Mesma resposta → mesma cor, sempre. Regra fechada e auditável
  • +
+
Risco: Vermelho
+
+
+ +Paciente + +--- + + +

Paciente — Acompanhamento, lembretes e meus dados

+ +
+
+
+ Status da solicitação +
+
Status da solicitação
+
+
+
+ Lembretes de medicamentos e rotina +
+
Lembretes
+
+
+
+ Perfil clínico e meus dados +
+
Perfil clínico · meus dados
+
+
+ + +Paciente + +--- + +
+
+
+ Login institucional do ACS +
+
+
+

ACS — Login institucional

+
    +
  • Matrícula/CNS e senha, com indicador de operação offline
  • +
  • Mesmo padrão visual do login do Admin
  • +
  • Autenticação institucional real (SSO/gov.br) é passo seguinte do roadmap
  • +
+
+
+ +ACS + +--- + +
+
+
+ Territorialização +
+
+
+

ACS — Territorialização

+
    +
  • Microárea, quantidade de pacientes e status do cache local
  • +
  • O ACS só acessa dados do próprio território — + invariante do sistema, não uma configuração (INV-01)
  • +
  • Cache territorial via sqflite, para operar sem rede
  • +
+
+
+ +ACS + +--- + +
+
+
+ Fila priorizada por risco +
+
+
+

ACS — Fila de priorização

+
    +
  • Ordem por gravidade clínica: vermelho → amarelo → verde
  • +
  • Cor é exclusiva da classificação de risco — avisos + técnicos (sem conexão, falha ao salvar) usam azul, nunca vermelho/amarelo
  • +
  • Falha transitória ganha botão "Tentar agora", mesmo com reconexão + automática já em curso
  • +
+
+
+ +ACS + +--- + +
+
+
+ Registro de visita offline +
+
+
+

ACS — Registro de visita offline

+
    +
  • Desfecho e observações registrados na porta da casa, com ou sem sinal
  • +
  • Enfileiramento local; sincronização automática ao voltar a rede
  • +
  • Em zona rural, é a diferença entre ter o dado e perder o dia
  • +
+
+
+ +ACS + +--- + +
+
+
+ Tela de acionamento e escalonamento +
+
+
+

ACS — Acionamento e escalonamento

+
    +
  • Resume paciente, risco e endereço para acionar SAMU ou UBS
  • +
  • Botões deixam explícito que discagem e encaminhamento reais ainda não + estão integrados
  • +
  • Integração SAMU roadmap
  • +
+
+
+ +ACS + +--- + +
+
+
+
+ Painel de indicadores +
+
+ Microáreas e vínculo ACS +
+
+
+
+

Admin — Indicadores e microáreas

+
    +
  • Contadores por risco, alertas abertos vs. reconhecidos e o TMRAV
  • +
  • Listagem somente leitura das microáreas e do ACS vinculado
  • +
  • Desktop-first: NavigationRail em telas largas, + NavigationBar abaixo de 640px
  • +
+
+
+ +Admin + +--- + +
+
+
+
+ Alertas da UBS +
+
+ Logs de auditoria +
+
+
+
+

Admin — Alertas, auditoria e layout responsivo

+
    +
  • Alertas filtráveis por microárea e status — sem controle de + reclassificação (a classificação não é alterável por humano, INV-02)
  • +
  • Log de auditoria somente leitura; toda visita às telas sensíveis + também é registrada
  • +
  • Mesmo ThemeData se adapta de celular a tablet
  • +
+
+
+ +Admin + +--- + +# Segurança e LGPD + +
+
+

Dados sensíveis

+

Colunas clínicas criptografadas (AES-256/pgcrypto) em repouso e TLS em trânsito.

+
+
+

Restrição por microárea

+

INV-01 reforçada no backend, não só na interface — um ACS nunca lê dados + fora do próprio território.

+
+
+

Trilha de auditoria

+

Acesso a dado sensível registrado com quem, quando e o quê — inclusive + no backoffice Admin.

+
+
+ +

+Conformidade LGPD documentada em spec/lgpd_design.md; análise de +cibersegurança baseada em NIST CSF 2.0 em spec/security_assessment.md. +

+ +--- + +# Estado atual e roadmap + +

Protótipo validado localmente — ciclo de alerta vermelho roda ponta a ponta +com confirmação de entrega, CI verde nos quatro trilhos.

+ +
+
+
Concluída
+ Fase 1 — Prova de conceito +
+
+
Concluída
+ Fase 2 — Alpha/Beta +
+
+
Em curso
+ Fase 3 — Disponibilidade geral +

Deploy monitorado, teste de + carga e auditoria de conformidade LGPD

+
+
+ +

+Mapa interativo, geofencing, integração SAMU e avisos push +roadmap v1.5 — hoje são stubs no protótipo, +não capacidades ativas. +

+ +--- + + + +# O pedido: um piloto controlado + +
+
1
UBS
+
2
microáreas
+
50
pacientes
+
+ +

+Para medir a única métrica que importa: quanto tempo leva, hoje, até alguém +chegar em quem está pior. +alvo < 90s (TMRAV) +

+ +

+SinalACS · github.com/hbgit/SinalACS · contato: completar antes da apresentação +

diff --git a/docs/superpowers/plans/2026-09-28-direitos-do-titular-app-paciente.md b/docs/superpowers/plans/2026-09-28-direitos-do-titular-app-paciente.md new file mode 100644 index 00000000..ed080167 --- /dev/null +++ b/docs/superpowers/plans/2026-09-28-direitos-do-titular-app-paciente.md @@ -0,0 +1,2729 @@ +# Direitos do titular no app paciente (LGPD-RF05 / LGPD-RF08) — Plano de implementação + +> **Para agentes:** SUB-SKILL OBRIGATÓRIA: use superpowers:subagent-driven-development (recomendado) ou superpowers:executing-plans para executar este plano tarefa por tarefa. Os passos usam checkbox (`- [ ]`). + +**Objetivo:** dar ao paciente, no painel "Meus dados", duas coisas: revogar ou conceder de novo, por conta própria, as finalidades opcionais de consentimento; e registrar pedidos de exclusão e de correção dos próprios dados, acompanhando a situação de cada um. + +**Arquitetura:** o backend ganha três RPCs em `PatientsEndpoint` (`updateConsent`, `requestDataDeletion`, `requestDataCorrection`). Elas passam por um serviço novo de aplicação, `DataSubjectRightsService`, com a store por interface, no mesmo padrão de `PatientDataOverviewService`. O consentimento continua append-only em `consent_logs` (linha nova assinada, nunca edição). Os pedidos vão para a tabela nova `data_subject_requests`, com o texto livre cifrado (AES-256-GCM, mesmo `HealthDataCipher` de `visits.notes`). `patients.myData` passa a devolver também os pedidos. No app, `MyDataScreen` ganha os interruptores de consentimento e a seção de pedidos. O espelho local de consentimento de lembretes (`ConsentPreferences`) passa a ser alinhado ao servidor cada vez que "Meus dados" carrega. + +**Stack:** Serverpod 3.4.13 (Dart), Postgres 15, Flutter (Material 3), `sinalacs_client` gerado. + +**Spec:** [spec/lgpd_design.md](../../../spec/lgpd_design.md) — LGPD-RF05 (linhas 102-111), LGPD-RF07 (124-133), LGPD-RF08 (136-140) e a tabela de direitos do Art. 18 (linhas 581-610). Invariantes em [CLAUDE.md](../../../CLAUDE.md) e [spec/PRD_system.md](../../../spec/PRD_system.md). + +## Fora do escopo (decisão do usuário, 2026-09-28) + +- **Atender os pedidos.** Nesta versão ninguém muda `status` de `open` para `completed`/`rejected`: o backoffice (`apps/admin`) ainda roda sobre `MockAdminDataSource`. O pedido fica registrado e visível ao titular. O atendimento é trabalho futuro e vai registrado no `PROGRESS.md` (Tarefa 7). +- **Revogar `healthDataProcessing` por interruptor.** Essa finalidade é a base legal de tudo, inclusive do botão de emergência, e revogá-la exige a exclusão em até 15 dias (LGPD-RF07). Cortar o tratamento no meio de um alerta violaria "alerta vermelho nunca é descartado em silêncio". Por isso o caminho é o pedido de exclusão, e a RPC recusa essa finalidade com mensagem que aponta para ele. +- Edição direta do contato de emergência, leitura de QR no onboarding (RF02) e modo claro (RF18): não foram escolhidos nesta rodada. + +## Restrições globais + +- Texto de UI, comentários e mensagens em **português**, no tom do código ao redor. +- `patientId` / `userId` vêm **sempre** do token (`user.id`), nunca de parâmetro (INV-05, mesmo raciocínio de `triage.evaluate`). +- `Authorization.require(user, roles: {UserRole.patient}, onDenied: ..., requireMicroArea: false)` em toda operação do titular: o escopo é o próprio titular, não o território. +- `consent_logs` é append-only: revogar grava linha nova `action: 'denied'`, e nada é atualizado nem apagado. +- Versão do termo: `consentPolicyVersion` (`'2026.1'`, em `onboarding_service.dart`). Não criar literal novo. +- Prazo de resposta a pedido do titular: **15 dias** (`spec/lgpd_design.md`, linhas 597-599). +- Texto livre do pedido de correção: no máximo **500** caracteres depois de `trim()`. Nunca vazio. Nunca vai para `audit_logs` nem para log de processo. +- Nenhum dado real de paciente em teste, seed ou log: só os UUIDs sintéticos `00000000-0000-4000-8000-00000000000N` já usados. +- Nunca editar à mão `lib/src/generated/`, `backend/sinalacs_client/lib/src/protocol/` nem `migrations/`. Rodar `serverpod generate` e `serverpod create-migration`. +- Cor de clínica usada como texto: só os tokens `*OnSurface` de `PatientColors` (ver `apps/CLAUDE.md`). +- Todo `Text` de erro ou confirmação dinâmico vai dentro de `Semantics(liveRegion: true)` (SC 4.1.3), como os que já existem em `MyDataScreen`. + +## Review Focus + +1. **Segundo pedido de correção com outro ainda aberto:** precisa gravar um pedido **novo**. Devolver o antigo descartaria em silêncio o texto que a pessoa acabou de escrever. Teste: Tarefa 2 (serviço) e Tarefa 6 (botão continua habilitado). +2. **Toque duplo ou pedido de exclusão repetido:** tem de resultar em exatamente **um** pedido aberto. O servidor é idempotente para exclusão, e o botão fica desabilitado com o pedido em voo. Teste: Tarefa 2 e Tarefa 6. +3. **Revogar lembretes com lembrete já agendado:** a notificação tem de parar **na hora**, não só quando a pessoa abrir "Lembretes". Teste: Tarefa 5 (`scheduler.cancelled` contém o id e o store guarda `active: false`). +4. **Texto de correção só com espaços, ou com mais de 500 caracteres:** o servidor recusa com mensagem clara, e o app nem habilita o envio para texto vazio. Teste: Tarefa 2 e Tarefa 6. +5. **Aparelho que entrou pelo login OTP (RF01) sem passar pelo onboarding:** esse aparelho não tem espelho local. Abrir "Meus dados" alinha o espelho à decisão do servidor, e uma recusa vinda do servidor cancela os lembretes ativos. Teste: Tarefa 5. + +--- + +## Mapa de arquivos + +**Backend (`backend/sinalacs_server/`)** +- Criar `lib/src/models/enums/data_subject_request_type.spy.yaml` — enum `deletion | correction`. +- Criar `lib/src/models/enums/data_subject_request_status.spy.yaml` — enum `open | completed | rejected`. +- Criar `lib/src/models/data_subject_request.spy.yaml` — tabela `data_subject_requests`. +- Criar `lib/src/models/api/patient_data_subject_request_record.spy.yaml` — DTO para o app. +- Criar `lib/src/models/exceptions/data_rights_exception.spy.yaml` — recusa de negócio tipada. +- Modificar `lib/src/models/api/patient_data_overview.spy.yaml` — campo `requests`. +- Criar `lib/src/infrastructure/database/signed_consent_log.dart` — montagem única da linha assinada de `consent_logs`. +- Modificar `lib/src/infrastructure/database/orm_onboarding_store.dart` — passa a usar a função acima. +- Modificar `lib/src/application/patients/patient_data_overview_service.dart` — `DataSubjectRequestSnapshot` e `PatientDataSnapshot.requests`. +- Criar `lib/src/application/patients/data_subject_rights_service.dart` — regras de LGPD-RF05/RF08. +- Criar `lib/src/infrastructure/database/orm_data_subject_rights_store.dart` — ORM + cifra. +- Modificar `lib/src/infrastructure/database/orm_patient_data_overview_store.dart` — carrega os pedidos. +- Modificar `lib/src/runtime/alert_runtime.dart` — `dataSubjectRightsServiceFor`. +- Modificar `lib/src/endpoints/patients_endpoint.dart` — três RPCs e o mapeamento de `requests`. +- Testes: `test/unit/signed_consent_log_test.dart`, `test/unit/data_subject_rights_service_test.dart`, `test/integration/data_subject_rights_endpoint_test.dart`. + +**App (`apps/patient/`)** +- Modificar `lib/core/network/backend_client.dart` — três métodos em `PatientBackend`, `MisconfiguredBackend` e `BackendClient`, mais a cláusula `DataRightsException` em `_guard`. +- Criar `lib/core/consent/consent_decisions.dart` — decisão vigente por finalidade e rótulos. +- Modificar `lib/app/app.dart` — `deactivateAllReminders`, `MyDataScreen` (consentimentos e pedidos), `_CorrectionRequestDialog`, mensagem de recusa em `RemindersScreen`. +- Modificar `test/support/fake_patient_backend.dart`, `test/support/fake_rpc_server.dart`, `test/patient_app_mvp_test.dart`. +- Criar `test/consent_decisions_test.dart`, `test/backend_client_data_rights_test.dart`. + +**Docs:** `spec/lgpd_data_audit.md`, `backend/CLAUDE.md`, `CLAUDE.md`, `PROGRESS.md`. + +## Pré-requisitos do ambiente + +```bash +./scripts/dev/bootstrap_env.sh # só se .env / config/passwords.yaml não existirem +docker compose --profile test up -d postgres-test # Postgres de teste em localhost:9090 +``` + +--- + +### Tarefa 1: Schema, contrato e a linha de consentimento assinada num lugar só + +**Arquivos:** +- Criar: os cinco `.spy.yaml` listados no mapa +- Modificar: `lib/src/models/api/patient_data_overview.spy.yaml` +- Criar: `lib/src/infrastructure/database/signed_consent_log.dart` +- Modificar: `lib/src/infrastructure/database/orm_onboarding_store.dart` (método `recordConsent`) +- Modificar: `lib/src/application/patients/patient_data_overview_service.dart` +- Modificar: `lib/src/endpoints/patients_endpoint.dart` (método `myData`) +- Modificar: `apps/patient/test/support/fake_patient_backend.dart`, `apps/patient/test/patient_app_mvp_test.dart` (helper `overview`) +- Modificar: `spec/lgpd_data_audit.md` +- Teste: `test/unit/signed_consent_log_test.dart` + +**Interfaces:** +- Produz (gerado, servidor e cliente): `enum DataSubjectRequestType { deletion, correction }`, `enum DataSubjectRequestStatus { open, completed, rejected }`, `class DataSubjectRequest` (tabela), `class PatientDataSubjectRequestRecord { DataSubjectRequestType type; DataSubjectRequestStatus status; String? details; DateTime createdAt; DateTime dueAt; }`, `class DataRightsException { String message; }`, `PatientDataOverview.requests: List`. +- Produz: `ConsentLog signedConsentLog(ConsentLogEntry entry, {required ConsentSignature signature, required String origin})`. +- Produz: `class DataSubjectRequestSnapshot { String id; DataSubjectRequestType type; DataSubjectRequestStatus status; String? details; DateTime createdAt; DateTime dueAt; }` e `PatientDataSnapshot.requests` (padrão `const []`). + +- [ ] **Passo 1: Escrever o teste da linha assinada (falha: a função não existe)** + +`backend/sinalacs_server/test/unit/signed_consent_log_test.dart`: + +```dart +import 'package:sinalacs_server/src/application/onboarding/consent_signature.dart'; +import 'package:sinalacs_server/src/application/onboarding/onboarding_service.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/infrastructure/database/signed_consent_log.dart'; +import 'package:test/test.dart'; + +void main() { + final signature = ConsentSignature(secret: 'segredo-de-teste'); + final entry = ConsentLogEntry( + userId: '00000000-0000-4000-8000-000000000001', + purpose: ConsentPurpose.localReminders, + action: 'denied', + version: '2026.1', + timestamp: DateTime.utc(2026, 9, 28, 12), + ); + + test('assina exatamente os campos que ConsentSignature.compute recebe', () { + final row = signedConsentLog(entry, signature: signature, origin: 'painel-titular'); + + expect( + row.signature, + signature.compute( + userId: entry.userId, + purpose: 'localReminders', + action: 'denied', + version: '2026.1', + timestamp: entry.timestamp, + ), + ); + expect(row.userId.uuid, entry.userId); + expect(row.purpose, 'localReminders'); + expect(row.action, 'denied'); + expect(row.version, '2026.1'); + expect(row.timestamp, entry.timestamp); + }); + + test('ipHash e userAgent levam o marcador de ausência com a origem do evento', () { + final onboarding = signedConsentLog(entry, signature: signature, origin: 'onboarding'); + expect(onboarding.ipHash, 'nao-aplicavel-onboarding'); + expect(onboarding.userAgent, 'nao-aplicavel-onboarding'); + + final painel = signedConsentLog(entry, signature: signature, origin: 'painel-titular'); + expect(painel.ipHash, 'nao-aplicavel-painel-titular'); + }); +} +``` + +- [ ] **Passo 2: Rodar e ver falhar** + +Run: `cd backend/sinalacs_server && dart test test/unit/signed_consent_log_test.dart` +Expected: FAIL na compilação (`signed_consent_log.dart` não existe). + +- [ ] **Passo 3: Criar os modelos** + +`lib/src/models/enums/data_subject_request_type.spy.yaml`: + +```yaml +### Tipo de pedido do titular sobre os próprios dados (LGPD-RF08, Art. 18): +### exclusão/anonimização ou correção. Ver spec/lgpd_design.md linhas 583-597. +enum: DataSubjectRequestType +serialized: byName +values: + - deletion + - correction +``` + +`lib/src/models/enums/data_subject_request_status.spy.yaml`: + +```yaml +### Situação de um pedido do titular. Nesta versão só `open` tem escritor: +### quem atende o pedido (backoffice) ainda não existe — ver PROGRESS.md. +enum: DataSubjectRequestStatus +serialized: byName +values: + - open + - completed + - rejected +``` + +`lib/src/models/data_subject_request.spy.yaml`: + +```yaml +### Pedido do titular sobre os próprios dados (LGPD-RF08): exclusão ou +### correção, com prazo de resposta de 15 dias (spec/lgpd_design.md 596-597). +### +### `details` é texto livre do titular e pode citar condição de saúde — por +### isso é cifrado na aplicação (AES-256-GCM), pelo mesmo motivo e com o +### mesmo `HealthDataCipher` de `visits.notes` (RNF03/INV-04). Num pedido de +### exclusão guarda o JSON `null` cifrado. +class: DataSubjectRequest +table: data_subject_requests +fields: + id: UuidValue?, defaultPersist=random + userId: UuidValue, relation(parent=users) + requestType: DataSubjectRequestType + detailsEncrypted: String + detailsKeyVersion: int + status: DataSubjectRequestStatus + createdAt: DateTime + ### Prazo de resposta: `createdAt` + 15 dias. + dueAt: DateTime +indexes: + data_subject_requests_user_id_type_idx: + fields: userId, requestType +``` + +`lib/src/models/api/patient_data_subject_request_record.spy.yaml`: + +```yaml +### Um pedido do próprio titular, para o painel "Meus Dados". `details` já +### decifrado — é texto que o próprio titular escreveu (direito de acesso). +class: PatientDataSubjectRequestRecord +fields: + type: DataSubjectRequestType + status: DataSubjectRequestStatus + details: String? + createdAt: DateTime + dueAt: DateTime +``` + +`lib/src/models/exceptions/data_rights_exception.spy.yaml`: + +```yaml +### Recusa de negócio de uma operação do titular sobre os próprios dados +### (consentimento obrigatório, texto de correção vazio ou longo demais). +### Mensagem pronta para a pessoa ler; nunca cita dado do paciente. +exception: DataRightsException +fields: + message: String +``` + +Em `lib/src/models/api/patient_data_overview.spy.yaml`, acrescentar ao fim de `fields:`: + +```yaml + requests: List +``` + +- [ ] **Passo 4: Gerar código e migração** + +Run: `cd backend/sinalacs_server && serverpod generate && serverpod create-migration` +Expected: um diretório novo em `migrations/`, cujo `migration.sql` contém `CREATE TABLE "data_subject_requests"`, sem nenhum `DROP`. Se aparecer `DROP`, pare: algo foi alterado além do previsto. + +- [ ] **Passo 5: Criar `signed_consent_log.dart`** + +`lib/src/infrastructure/database/signed_consent_log.dart`: + +```dart +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/onboarding/consent_signature.dart'; +import 'package:sinalacs_server/src/application/onboarding/onboarding_service.dart' + show ConsentLogEntry; +import 'package:sinalacs_server/src/generated/protocol.dart'; + +/// A linha de `consent_logs` de [entry], assinada por [signature]. +/// +/// Há dois escritores de consentimento — a conclusão do onboarding e o painel +/// "Meus Dados" (LGPD-RF05) — e os dois gravam a mesma forma de linha. Esta +/// função é o único lugar que a define, para que a assinatura e os campos não +/// divirjam entre eles. +/// +/// IP e user agent não se aplicam a nenhum dos dois eventos de domínio — o +/// request HTTP em si já é auditado em `audit_logs` por outros caminhos —, então +/// os campos exigidos pelo schema levam um marcador explícito de ausência com a +/// [origin] do evento, não um valor fabricado. +ConsentLog signedConsentLog( + ConsentLogEntry entry, { + required ConsentSignature signature, + required String origin, +}) { + final marker = 'nao-aplicavel-$origin'; + return ConsentLog( + userId: UuidValue.fromString(entry.userId), + purpose: entry.purpose.name, + action: entry.action, + version: entry.version, + timestamp: entry.timestamp, + ipHash: marker, + userAgent: marker, + signature: signature.compute( + userId: entry.userId, + purpose: entry.purpose.name, + action: entry.action, + version: entry.version, + timestamp: entry.timestamp, + ), + ); +} +``` + +Em `orm_onboarding_store.dart`, trocar o corpo inteiro de `recordConsent` por: + +```dart + @override + Future recordConsent(ConsentLogEntry entry) async { + await ConsentLog.db.insertRow( + _session(), + signedConsentLog(entry, signature: _signature, origin: 'onboarding'), + transaction: _transaction, + ); + } +``` + +e acrescentar o import `package:sinalacs_server/src/infrastructure/database/signed_consent_log.dart`. O valor gravado continua `'nao-aplicavel-onboarding'`, idêntico ao de antes. + +- [ ] **Passo 6: Snapshot dos pedidos no serviço de "Meus Dados"** + +Em `lib/src/application/patients/patient_data_overview_service.dart`, logo depois da classe `RiskEventSnapshot`: + +```dart +/// Um pedido do próprio titular (LGPD-RF08: exclusão ou correção), do jeito +/// que a store enxerga — `details` já decifrado. +class DataSubjectRequestSnapshot { + const DataSubjectRequestSnapshot({ + required this.id, + required this.type, + required this.status, + required this.details, + required this.createdAt, + required this.dueAt, + }); + + final String id; + final DataSubjectRequestType type; + final DataSubjectRequestStatus status; + + /// Texto do pedido de correção; `null` num pedido de exclusão. + final String? details; + final DateTime createdAt; + final DateTime dueAt; +} +``` + +Em `PatientDataSnapshot`, acrescentar ao construtor `this.requests = const [],` e o campo: + +```dart + /// Mais recente primeiro. + final List requests; +``` + +- [ ] **Passo 7: Mapear `requests` em `patients.myData`** + +Em `patients_endpoint.dart`, acrescentar o import `package:sinalacs_server/src/application/patients/patient_data_overview_service.dart` e, no `PatientDataOverview(...)` de `myData`, depois de `riskHistory: [...]`: + +```dart + requests: [for (final r in snapshot.requests) _requestRecord(r)], +``` + +No fim da classe: + +```dart + static PatientDataSubjectRequestRecord _requestRecord(DataSubjectRequestSnapshot r) => + PatientDataSubjectRequestRecord( + type: r.type, + status: r.status, + details: r.details, + createdAt: r.createdAt, + dueAt: r.dueAt, + ); +``` + +- [ ] **Passo 8: Manter o app compilando com o campo novo obrigatório** + +Em `apps/patient/test/support/fake_patient_backend.dart`, no valor padrão de `myDataResult`, acrescentar `requests: const [],` depois de `riskHistory: const [],`. + +Em `apps/patient/test/patient_app_mvp_test.dart`, no helper `overview` do grupo `'Meus dados (LGPD)'`, trocar a assinatura e o corpo por: + +```dart + PatientDataOverview overview({ + List consents = const [], + List riskHistory = const [], + List requests = const [], + }) => + PatientDataOverview( + name: 'Fulano de Tal', + birthDate: DateTime.utc(1975, 3, 10), + emergencyContact: 'Ciclana, (11) 90000-0000', + isChronic: true, + chronicConditions: const ['hipertensão'], + consents: consents, + riskHistory: riskHistory, + requests: requests, + ); +``` + +Rode `grep -rn "PatientDataOverview(" apps/ backend/sinalacs_server/test` e acrescente `requests: const []` a qualquer outra construção que aparecer. + +- [ ] **Passo 9: Classificar a tabela nova em `spec/lgpd_data_audit.md`** + +Inserir imediatamente antes da linha que começa com `| **audit_logs** |`: + +```markdown +| **data_subject_requests** | `id` | `uuid` | Pseudonimizado | UUID v4 (`gen_random_uuid()`) | Identificador do pedido do titular (LGPD-RF08). | +| | `userId` | `uuid` | Pseudonimizado | Chave estrangeira (`users.id`) | Titular que fez o pedido — sempre o do token, nunca parâmetro (INV-05). | +| | `requestType` | `text` | Metadado de Conformidade | `deletion` \| `correction` | — | +| | `detailsEncrypted` / `detailsKeyVersion` | `text` / `bigint` | Potencialmente Sensível (texto livre do titular) | AES-256-GCM na aplicação, mesma `HEALTH_DATA_ENCRYPTION_KEY` do §2.3 | O pedido de correção é texto livre e pode citar condição de saúde; cifrado pelo mesmo motivo de `visits.notes`. Num pedido de exclusão guarda o JSON `null` cifrado. Nunca copiado para `audit_logs`. | +| | `status` | `text` | Metadado de Conformidade | `open` \| `completed` \| `rejected` | Só `open` tem escritor nesta versão — quem atende o pedido (backoffice) ainda não existe (ver `PROGRESS.md`). | +| | `createdAt` / `dueAt` | `timestamp without time zone` | Metadado de Conformidade | `dueAt` = `createdAt` + 15 dias | Prazo de resposta do Art. 18 (spec/lgpd_design.md, linhas 596-597). | +``` + +Na linha de `consent_logs` que descreve `ipHash`, acrescentar ao fim da última coluna: ` Linhas gravadas pelo painel "Meus Dados" (LGPD-RF05) usam o marcador \`nao-aplicavel-painel-titular\`.` + +- [ ] **Passo 10: Rodar tudo** + +Run: +```bash +cd backend/sinalacs_server && dart analyze && dart test +cd ../../apps/patient && flutter analyze && flutter test +cd ../acs && flutter analyze +``` +Expected: tudo verde. O teste novo passa, e `test/integration/onboarding_endpoint_test.dart` continua passando com o marcador de onboarding inalterado. O app ACS depende do mesmo `sinalacs_client` e tem de continuar compilando. + +- [ ] **Passo 11: Commit** + +```bash +git add backend/sinalacs_server/lib/src/models backend/sinalacs_server/lib/src/generated \ + backend/sinalacs_client backend/sinalacs_server/migrations \ + backend/sinalacs_server/lib/src/infrastructure/database/signed_consent_log.dart \ + backend/sinalacs_server/lib/src/infrastructure/database/orm_onboarding_store.dart \ + backend/sinalacs_server/lib/src/application/patients/patient_data_overview_service.dart \ + backend/sinalacs_server/lib/src/endpoints/patients_endpoint.dart \ + backend/sinalacs_server/test/unit/signed_consent_log_test.dart \ + backend/sinalacs_server/test/integration/test_tools \ + apps/patient/test spec/lgpd_data_audit.md +git commit -m "feat(lgpd): schema de pedidos do titular e linha de consentimento assinada num lugar só" +``` + +--- + +### Tarefa 2: `DataSubjectRightsService` — regras de LGPD-RF05 e RF08 + +**Arquivos:** +- Criar: `backend/sinalacs_server/lib/src/application/patients/data_subject_rights_service.dart` +- Teste: `backend/sinalacs_server/test/unit/data_subject_rights_service_test.dart` + +**Interfaces:** +- Consome: `ConsentLogEntry`, `consentPolicyVersion` (`onboarding_service.dart`); `ConsentRecordSnapshot`, `DataSubjectRequestSnapshot` (`patient_data_overview_service.dart`); `AuditTrail`, `AuditEvent`; `Authorization.require`; `DataRightsException` (gerado). +- Produz: + - `abstract interface class DataSubjectRightsStore { Future recordConsent(ConsentLogEntry entry); Future findOpenRequest(String userId, DataSubjectRequestType type); Future createRequest({required String userId, required DataSubjectRequestType type, required String? details, required DateTime createdAt, required DateTime dueAt}); }` + - `const Duration dataSubjectRequestDeadline = Duration(days: 15);` + - `const int correctionDetailsMaxLength = 500;` + - `class DataSubjectRightsService({required DataSubjectRightsStore store, required AuditTrail audit, DateTime Function()? clock})` com `Future updateConsent(AuthenticatedUser user, {required ConsentPurpose purpose, required bool granted})`, `Future requestDeletion(AuthenticatedUser user)` e `Future requestCorrection(AuthenticatedUser user, {required String details})`. + - Recusa de papel: `StateError`. Recusa de negócio: `DataRightsException`. + +- [ ] **Passo 1: Escrever os testes (falham: o serviço não existe)** + +`test/unit/data_subject_rights_service_test.dart`: + +```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/onboarding/onboarding_service.dart'; +import 'package:sinalacs_server/src/application/patients/data_subject_rights_service.dart'; +import 'package:sinalacs_server/src/application/patients/patient_data_overview_service.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:test/test.dart'; + +const _patientId = '00000000-0000-4000-8000-000000000001'; +const _microAreaId = '00000000-0000-4000-8000-000000000003'; + +const _patient = AuthenticatedUser( + id: _patientId, + role: UserRole.patient, + microAreaId: _microAreaId, + deviceId: 'patient-device-001', +); + +const _acs = AuthenticatedUser( + id: '00000000-0000-4000-8000-000000000002', + role: UserRole.acs, + microAreaId: _microAreaId, + deviceId: 'acs-device-001', +); + +final _now = DateTime.utc(2026, 9, 28, 12); + +class FakeDataSubjectRightsStore implements DataSubjectRightsStore { + final consents = []; + final requests = <({String userId, DataSubjectRequestSnapshot snapshot})>[]; + var _nextId = 1; + + @override + Future recordConsent(ConsentLogEntry entry) async => consents.add(entry); + + @override + Future findOpenRequest( + String userId, + DataSubjectRequestType type, + ) async { + for (final r in requests.reversed) { + if (r.userId == userId && + r.snapshot.type == type && + r.snapshot.status == DataSubjectRequestStatus.open) { + return r.snapshot; + } + } + return null; + } + + @override + Future createRequest({ + required String userId, + required DataSubjectRequestType type, + required String? details, + required DateTime createdAt, + required DateTime dueAt, + }) async { + final snapshot = DataSubjectRequestSnapshot( + id: 'pedido-${_nextId++}', + type: type, + status: DataSubjectRequestStatus.open, + details: details, + createdAt: createdAt, + dueAt: dueAt, + ); + requests.add((userId: userId, snapshot: snapshot)); + return snapshot; + } +} + +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 FakeDataSubjectRightsStore store; + late FakeAuditTrail audit; + late DataSubjectRightsService service; + + setUp(() { + store = FakeDataSubjectRightsStore(); + audit = FakeAuditTrail(); + service = DataSubjectRightsService(store: store, audit: audit, clock: () => _now); + }); + + group('updateConsent (LGPD-RF05)', () { + test('revogar grava uma linha nova "denied", com a versão vigente e o relógio do servidor', () async { + final record = await service.updateConsent( + _patient, + purpose: ConsentPurpose.localReminders, + granted: false, + ); + + final entry = store.consents.single; + expect(entry.userId, _patientId); + expect(entry.purpose, ConsentPurpose.localReminders); + expect(entry.action, 'denied'); + expect(entry.version, consentPolicyVersion); + expect(entry.timestamp, _now); + + expect(record.purpose, 'localReminders'); + expect(record.action, 'denied'); + expect(record.timestamp, _now); + }); + + test('conceder de novo grava "granted"', () async { + await service.updateConsent(_patient, purpose: ConsentPurpose.segmentedPush, granted: true); + + expect(store.consents.single.action, 'granted'); + }); + + test('recusa mexer no consentimento obrigatório, em qualquer direção, sem gravar nada', () async { + for (final granted in [false, true]) { + await expectLater( + service.updateConsent( + _patient, + purpose: ConsentPurpose.healthDataProcessing, + granted: granted, + ), + throwsA(isA()), + ); + } + expect(store.consents, isEmpty); + expect(audit.events, isEmpty); + }); + + test('um ACS não altera consentimento de ninguém', () async { + await expectLater( + service.updateConsent(_acs, purpose: ConsentPurpose.localReminders, granted: false), + throwsA(isA()), + ); + expect(store.consents, isEmpty); + }); + + test('grava uma linha de escrita em audit_logs', () async { + await service.updateConsent(_patient, purpose: ConsentPurpose.localReminders, granted: false); + + final event = audit.events.single; + expect(event.userId, _patientId); + expect(event.actionType, 'write'); + expect(event.resourceType, 'consent_log'); + expect(event.result, 'granted'); + }); + + test('não exige microárea: o escopo é o próprio titular', () async { + const semArea = AuthenticatedUser( + id: _patientId, + role: UserRole.patient, + microAreaId: null, + deviceId: 'patient-device-001', + ); + + await service.updateConsent(semArea, purpose: ConsentPurpose.localReminders, granted: true); + expect(store.consents, hasLength(1)); + }); + }); + + group('requestDeletion (LGPD-RF08)', () { + test('abre um pedido em análise com prazo de 15 dias', () async { + final request = await service.requestDeletion(_patient); + + expect(request.type, DataSubjectRequestType.deletion); + expect(request.status, DataSubjectRequestStatus.open); + expect(request.details, isNull); + expect(request.createdAt, _now); + expect(request.dueAt, _now.add(const Duration(days: 15))); + expect(store.requests.single.userId, _patientId); + }); + + test('pedir de novo com um pedido aberto devolve o mesmo, sem duplicar', () async { + final first = await service.requestDeletion(_patient); + final second = await service.requestDeletion(_patient); + + expect(second.id, first.id); + expect(store.requests, hasLength(1)); + expect(audit.events, hasLength(1)); + }); + + test('o pedido novo vai para audit_logs com o id do pedido', () async { + final request = await service.requestDeletion(_patient); + + final event = audit.events.single; + expect(event.actionType, 'write'); + expect(event.resourceType, 'data_subject_request'); + expect(event.resourceId, request.id); + expect(event.result, 'granted'); + }); + + test('um ACS não pede exclusão em nome de ninguém', () async { + await expectLater(service.requestDeletion(_acs), throwsA(isA())); + expect(store.requests, isEmpty); + }); + + test('uma trilha de auditoria fora do ar não impede o pedido', () async { + service = DataSubjectRightsService( + store: store, + audit: FakeAuditTrail(failOnRecord: true), + clock: () => _now, + ); + + final request = await service.requestDeletion(_patient); + expect(request.status, DataSubjectRequestStatus.open); + }); + }); + + group('requestCorrection (LGPD-RF08)', () { + test('grava o texto sem os espaços das pontas', () async { + final request = await service.requestCorrection( + _patient, + details: ' Meu contato de emergência mudou. ', + ); + + expect(request.type, DataSubjectRequestType.correction); + expect(request.details, 'Meu contato de emergência mudou.'); + expect(request.dueAt, _now.add(const Duration(days: 15))); + }); + + test('um segundo pedido com outro aberto é pedido NOVO — o texto novo não se perde', () async { + await service.requestCorrection(_patient, details: 'Contato errado.'); + final second = await service.requestCorrection(_patient, details: 'Nome com grafia errada.'); + + expect(store.requests, hasLength(2)); + expect(second.details, 'Nome com grafia errada.'); + }); + + test('texto vazio ou só espaços é recusado', () async { + for (final details in ['', ' ', '\n\t']) { + await expectLater( + service.requestCorrection(_patient, details: details), + throwsA(isA()), + ); + } + expect(store.requests, isEmpty); + }); + + test('aceita 500 caracteres e recusa 501', () async { + await service.requestCorrection(_patient, details: 'a' * 500); + await expectLater( + service.requestCorrection(_patient, details: 'a' * 501), + throwsA(isA()), + ); + expect(store.requests, hasLength(1)); + }); + + test('o texto do pedido nunca vai para audit_logs', () async { + await service.requestCorrection(_patient, details: 'Tenho hipertensão, não diabetes.'); + + final event = audit.events.single; + expect(event.resourceType, 'data_subject_request'); + expect(event.resourceId, isNot(contains('hipertensão'))); + expect(event.result, 'granted'); + }); + + test('um ACS não pede correção em nome de ninguém', () async { + await expectLater( + service.requestCorrection(_acs, details: 'Qualquer coisa.'), + throwsA(isA()), + ); + }); + }); +} +``` + +- [ ] **Passo 2: Rodar e ver falhar** + +Run: `cd backend/sinalacs_server && dart test test/unit/data_subject_rights_service_test.dart` +Expected: FAIL na compilação (`data_subject_rights_service.dart` não existe). + +- [ ] **Passo 3: Implementar o serviço** + +`lib/src/application/patients/data_subject_rights_service.dart`: + +```dart +import 'package:sinalacs_server/src/application/audit/audit_trail.dart'; +import 'package:sinalacs_server/src/application/auth/authorization.dart'; +import 'package:sinalacs_server/src/application/auth/development_auth_service.dart'; +import 'package:sinalacs_server/src/application/onboarding/onboarding_service.dart' + show ConsentLogEntry, consentPolicyVersion; +import 'package:sinalacs_server/src/application/patients/patient_data_overview_service.dart' + show ConsentRecordSnapshot, DataSubjectRequestSnapshot; +import 'package:sinalacs_server/src/generated/protocol.dart'; + +/// Persistência das operações do titular sobre os próprios dados. Interface +/// aqui, implementação ORM em `infrastructure/`, mesmo padrão de +/// `PatientDataOverviewStore`. +abstract interface class DataSubjectRightsStore { + /// Grava uma linha nova, assinada, em `consent_logs` — nunca edita uma + /// anterior (append-only, LGPD-RF04). + Future recordConsent(ConsentLogEntry entry); + + /// O pedido em aberto mais recente daquele tipo, ou `null`. + Future findOpenRequest( + String userId, + DataSubjectRequestType type, + ); + + Future createRequest({ + required String userId, + required DataSubjectRequestType type, + required String? details, + required DateTime createdAt, + required DateTime dueAt, + }); +} + +/// Prazo de resposta a um pedido do titular (spec/lgpd_design.md, 596-597). +const Duration dataSubjectRequestDeadline = Duration(days: 15); + +/// Teto do texto de um pedido de correção, contado depois do `trim()`. O app +/// usa o mesmo número no `maxLength` do campo. +const int correctionDetailsMaxLength = 500; + +/// Direitos do titular exercidos pelo próprio app (LGPD-RF05 e LGPD-RF08): +/// conceder/revogar finalidades opcionais e pedir exclusão ou correção. +/// +/// `userId` vem SEMPRE de `user.id` — nunca de parâmetro —, pelo mesmo motivo +/// de INV-05 em `triage.evaluate`. `requireMicroArea: false`, mesmo motivo de +/// `PatientDataOverviewService.myData`: o escopo é o titular, não o território. +class DataSubjectRightsService { + DataSubjectRightsService({ + required DataSubjectRightsStore store, + required AuditTrail audit, + DateTime Function()? clock, + }) : _store = store, + _audit = audit, + _clock = clock ?? DateTime.now; + + final DataSubjectRightsStore _store; + final AuditTrail _audit; + final DateTime Function() _clock; + + /// Concede ou revoga uma finalidade opcional. `healthDataProcessing` é + /// recusado nas duas direções: é a base legal do app inteiro — inclusive do + /// alerta de emergência — e retirá-lo exige a exclusão dos dados em até 15 + /// dias (LGPD-RF07), que é o que [requestDeletion] registra. + Future updateConsent( + AuthenticatedUser user, { + required ConsentPurpose purpose, + required bool granted, + }) async { + _requirePatient(user); + if (purpose == ConsentPurpose.healthDataProcessing) { + throw DataRightsException( + message: 'O consentimento para dados de saúde é obrigatório para usar o app. ' + 'Para retirá-lo, solicite a exclusão dos seus dados.', + ); + } + + final now = _clock().toUtc(); + final action = granted ? 'granted' : 'denied'; + await _store.recordConsent(ConsentLogEntry( + userId: user.id, + purpose: purpose, + action: action, + version: consentPolicyVersion, + timestamp: now, + )); + await _audit.recordSafely(AuditEvent( + userId: user.id, + actionType: 'write', + resourceType: 'consent_log', + result: 'granted', + )); + + return ConsentRecordSnapshot( + purpose: purpose.name, + action: action, + version: consentPolicyVersion, + timestamp: now, + ); + } + + /// Pede a exclusão/anonimização dos próprios dados. Idempotente enquanto + /// houver um pedido de exclusão em aberto: pedir de novo devolve o mesmo, em + /// vez de empilhar pedidos iguais para a equipe. + Future requestDeletion(AuthenticatedUser user) async { + _requirePatient(user); + final open = await _store.findOpenRequest(user.id, DataSubjectRequestType.deletion); + if (open != null) return open; + return _create(user, DataSubjectRequestType.deletion, null); + } + + /// Pede a correção de um dado. Ao contrário da exclusão, NÃO é idempotente: + /// cada pedido carrega um texto próprio, e devolver um pedido anterior + /// descartaria em silêncio o texto que a pessoa acabou de escrever. + Future requestCorrection( + AuthenticatedUser user, { + required String details, + }) async { + _requirePatient(user); + final trimmed = details.trim(); + if (trimmed.isEmpty) { + throw DataRightsException(message: 'Descreva o que precisa ser corrigido.'); + } + if (trimmed.length > correctionDetailsMaxLength) { + throw DataRightsException( + message: 'A descrição pode ter no máximo $correctionDetailsMaxLength caracteres.', + ); + } + return _create(user, DataSubjectRequestType.correction, trimmed); + } + + Future _create( + AuthenticatedUser user, + DataSubjectRequestType type, + String? details, + ) async { + final now = _clock().toUtc(); + final request = await _store.createRequest( + userId: user.id, + type: type, + details: details, + createdAt: now, + dueAt: now.add(dataSubjectRequestDeadline), + ); + // Só o id do pedido: o texto da correção pode citar condição de saúde e + // não entra na trilha (ver `AuditEvent.result`). + await _audit.recordSafely(AuditEvent( + userId: user.id, + actionType: 'write', + resourceType: 'data_subject_request', + resourceId: request.id, + result: 'granted', + )); + return request; + } + + void _requirePatient(AuthenticatedUser user) => Authorization.require( + user, + roles: {UserRole.patient}, + onDenied: () => + StateError('Somente o próprio paciente pode exercer direitos sobre os seus dados.'), + requireMicroArea: false, + ); +} +``` + +- [ ] **Passo 4: Rodar e ver passar** + +Run: `cd backend/sinalacs_server && dart test test/unit/data_subject_rights_service_test.dart && dart analyze` +Expected: PASS, todos os testes; analyze sem issues. + +- [ ] **Passo 5: Commit** + +```bash +git add backend/sinalacs_server/lib/src/application/patients/data_subject_rights_service.dart \ + backend/sinalacs_server/test/unit/data_subject_rights_service_test.dart +git commit -m "feat(lgpd): serviço de direitos do titular — revogação por finalidade e pedidos de exclusão/correção" +``` + +--- + +### Tarefa 3: Store ORM, runtime e as três RPCs, provados contra Postgres real + +**Arquivos:** +- Criar: `backend/sinalacs_server/lib/src/infrastructure/database/orm_data_subject_rights_store.dart` +- Modificar: `lib/src/infrastructure/database/orm_patient_data_overview_store.dart` +- Modificar: `lib/src/runtime/alert_runtime.dart` +- Modificar: `lib/src/endpoints/patients_endpoint.dart` +- Teste: `backend/sinalacs_server/test/integration/data_subject_rights_endpoint_test.dart` + +**Interfaces:** +- Consome: `DataSubjectRightsStore`, `DataSubjectRightsService` (Tarefa 2); `signedConsentLog` (Tarefa 1); `HealthDataCipher` com `encryptJson`/`decryptJson` (`infrastructure/crypto/encrypted_json.dart`). +- Produz: + - `Future dataSubjectRequestSnapshotOf(DataSubjectRequest row, HealthDataCipher cipher)` (top-level, usada pelas duas stores). + - `AlertRuntime.dataSubjectRightsServiceFor(Session session)`. + - RPCs: `patients.updateConsent({accessToken, ConsentPurpose purpose, bool granted}) → PatientConsentRecord`, `patients.requestDataDeletion({accessToken}) → PatientDataSubjectRequestRecord`, `patients.requestDataCorrection({accessToken, String details}) → PatientDataSubjectRequestRecord`. + +- [ ] **Passo 1: Escrever os testes de integração (falham: as RPCs não existem)** + +`test/integration/data_subject_rights_endpoint_test.dart`: + +```dart +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/onboarding/consent_signature.dart'; +import 'package:sinalacs_server/src/config/app_config.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/runtime/alert_runtime.dart'; +import 'package:test/test.dart'; + +import '../support/health_data_fixtures.dart'; +import 'test_tools/serverpod_test_tools.dart'; + +/// Prova, contra Postgres real, os direitos do titular exercidos pelo app +/// (LGPD-RF05/RF08): a linha assinada em `consent_logs`, o pedido cifrado em +/// `data_subject_requests` e o reflexo de ambos em `patients.myData`. +/// `data_subject_rights_service_test.dart` prova as regras com fakes. +const _patientId = '00000000-0000-4000-8000-000000000001'; +const _acsId = '00000000-0000-4000-8000-000000000002'; +const _microAreaId = '00000000-0000-4000-8000-000000000003'; +const _ubsId = '00000000-0000-4000-8000-000000000004'; +const _chainSecret = 'test-audit-chain-secret'; + +AppConfig _config() => AppConfig( + mqttBroker: 'localhost:1883', + jwtSecret: 'test-secret', + auditChainSecret: _chainSecret, + healthDataEncryptionKey: AppConfig.developmentHealthDataEncryptionKey, + cpfHashPepper: AppConfig.developmentCpfHashPepper, + smsGateway: 'log', + 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.insertRow( + session, + MicroArea( + id: UuidValue.fromString(_microAreaId), + name: 'Microárea 12', + ubsId: UuidValue.fromString(_ubsId), + geoJsonBoundary: '{}', + ), + ); + final now = DateTime.now().toUtc(); + await User.db.insert(session, [ + User( + id: UuidValue.fromString(_patientId), + cpfHash: 'development-patient', + name: 'Paciente de desenvolvimento', + birthDate: DateTime.utc(1990, 1, 1), + role: UserRole.patient, + microAreaId: UuidValue.fromString(_microAreaId), + createdAt: now, + updatedAt: now, + ), + User( + id: UuidValue.fromString(_acsId), + cpfHash: 'development-acs', + name: 'ACS de desenvolvimento', + birthDate: DateTime.utc(1980, 1, 1), + role: UserRole.acs, + microAreaId: UuidValue.fromString(_microAreaId), + 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.insertRow( + session, + await encryptedPatient( + id: _patientId, + emergencyContact: 'Contato de desenvolvimento', + isChronic: false, + chronicConditions: const [], + ), + ); +} + +void main() { + withServerpod('Dados os direitos do titular exercidos pelo app', (sessionBuilder, endpoints) { + setUp(() => AlertRuntime.instance.overrideConfig(_config())); + tearDown(() => AlertRuntime.instance.overrideConfig(null)); + + Future patientToken() async => + (await endpoints.auth.developmentLogin(sessionBuilder, role: 'patient')).accessToken; + + test('updateConsent grava linha assinada em consent_logs e myData passa a mostrá-la', () async { + final session = sessionBuilder.build(); + await _seed(session); + final token = await patientToken(); + + final record = await endpoints.patients.updateConsent( + sessionBuilder, + accessToken: token, + purpose: ConsentPurpose.localReminders, + granted: false, + ); + expect(record.action, 'denied'); + + final row = (await ConsentLog.db.find( + session, + where: (t) => t.userId.equals(UuidValue.fromString(_patientId)), + )) + .single; + expect(row.purpose, 'localReminders'); + expect(row.action, 'denied'); + expect(row.ipHash, 'nao-aplicavel-painel-titular'); + expect( + row.signature, + ConsentSignature(secret: _chainSecret).compute( + userId: _patientId, + purpose: row.purpose, + action: row.action, + version: row.version, + timestamp: row.timestamp, + ), + ); + + final overview = await endpoints.patients.myData(sessionBuilder, accessToken: token); + expect(overview.consents.last.purpose, 'localReminders'); + expect(overview.consents.last.action, 'denied'); + }); + + test('updateConsent recusa a finalidade obrigatória sem gravar nada', () async { + final session = sessionBuilder.build(); + await _seed(session); + final token = await patientToken(); + + await expectLater( + endpoints.patients.updateConsent( + sessionBuilder, + accessToken: token, + purpose: ConsentPurpose.healthDataProcessing, + granted: false, + ), + throwsA(isA()), + ); + expect(await ConsentLog.db.count(session), 0); + }); + + test('requestDataDeletion repetido deixa um pedido só, com prazo de 15 dias, visível em myData', () async { + final session = sessionBuilder.build(); + await _seed(session); + final token = await patientToken(); + + final first = await endpoints.patients.requestDataDeletion(sessionBuilder, accessToken: token); + final second = await endpoints.patients.requestDataDeletion(sessionBuilder, accessToken: token); + + expect(second.createdAt, first.createdAt); + expect(first.dueAt.difference(first.createdAt), const Duration(days: 15)); + expect(first.status, DataSubjectRequestStatus.open); + expect(await DataSubjectRequest.db.count(session), 1); + + final overview = await endpoints.patients.myData(sessionBuilder, accessToken: token); + expect(overview.requests.single.type, DataSubjectRequestType.deletion); + expect(overview.requests.single.status, DataSubjectRequestStatus.open); + }); + + test('requestDataCorrection guarda o texto cifrado e myData devolve o texto decifrado', () async { + final session = sessionBuilder.build(); + await _seed(session); + final token = await patientToken(); + + await endpoints.patients.requestDataCorrection( + sessionBuilder, + accessToken: token, + details: 'Meu contato de emergência mudou.', + ); + + final row = (await DataSubjectRequest.db.find(session)).single; + expect(row.detailsEncrypted, isNot(contains('contato'))); + expect(row.detailsEncrypted, isNotEmpty); + + final overview = await endpoints.patients.myData(sessionBuilder, accessToken: token); + expect(overview.requests.single.type, DataSubjectRequestType.correction); + expect(overview.requests.single.details, 'Meu contato de emergência mudou.'); + }); + + test('um ACS não chama nenhuma das três', () async { + await _seed(sessionBuilder.build()); + final token = + (await endpoints.auth.developmentLogin(sessionBuilder, role: 'acs')).accessToken; + + await expectLater( + endpoints.patients.updateConsent( + sessionBuilder, + accessToken: token, + purpose: ConsentPurpose.localReminders, + granted: false, + ), + throwsA(isA()), + ); + await expectLater( + endpoints.patients.requestDataDeletion(sessionBuilder, accessToken: token), + throwsA(isA()), + ); + await expectLater( + endpoints.patients.requestDataCorrection(sessionBuilder, accessToken: token, details: 'x'), + throwsA(isA()), + ); + }); + + test('cada escrita deixa linha real em audit_logs', () async { + final session = sessionBuilder.build(); + await _seed(session); + final token = await patientToken(); + + await endpoints.patients.updateConsent( + sessionBuilder, + accessToken: token, + purpose: ConsentPurpose.segmentedPush, + granted: true, + ); + await endpoints.patients.requestDataDeletion(sessionBuilder, accessToken: token); + + final consentRows = await AuditLog.db.find( + session, + where: (t) => t.resourceType.equals('consent_log'), + ); + final requestRows = await AuditLog.db.find( + session, + where: (t) => t.resourceType.equals('data_subject_request'), + ); + expect(consentRows, hasLength(1)); + expect(requestRows, hasLength(1)); + expect(requestRows.single.userId, UuidValue.fromString(_patientId)); + }); + }); +} +``` + +Antes de rodar, abra `test/integration/patient_data_overview_endpoint_test.dart` e confirme que `developmentLogin(role: 'patient')` emite o id `...0001` (o teste existente depende disso). Confirme também o nome da coluna de recurso em `AuditLog` (`resourceType`, como no teste existente). + +- [ ] **Passo 2: Rodar e ver falhar** + +Run: `cd backend/sinalacs_server && dart test test/integration/data_subject_rights_endpoint_test.dart` +Expected: FAIL na compilação (`endpoints.patients.updateConsent` não existe). + +- [ ] **Passo 3: Store ORM** + +`lib/src/infrastructure/database/orm_data_subject_rights_store.dart`: + +```dart +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/onboarding/consent_signature.dart'; +import 'package:sinalacs_server/src/application/onboarding/onboarding_service.dart' + show ConsentLogEntry; +import 'package:sinalacs_server/src/application/patients/data_subject_rights_service.dart'; +import 'package:sinalacs_server/src/application/patients/patient_data_overview_service.dart' + show DataSubjectRequestSnapshot; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/infrastructure/crypto/encrypted_json.dart'; +import 'package:sinalacs_server/src/infrastructure/crypto/health_data_cipher.dart'; +import 'package:sinalacs_server/src/infrastructure/database/signed_consent_log.dart'; + +/// Um pedido lido do banco, com `details` decifrado. Compartilhado com +/// `OrmPatientDataOverviewStore`, que lista os mesmos pedidos em "Meus Dados". +Future dataSubjectRequestSnapshotOf( + DataSubjectRequest row, + HealthDataCipher cipher, +) async => + DataSubjectRequestSnapshot( + id: row.id!.uuid, + type: row.requestType, + status: row.status, + details: await cipher.decryptJson(row.detailsEncrypted, row.detailsKeyVersion) as String?, + createdAt: row.createdAt, + dueAt: row.dueAt, + ); + +/// Implementação de [DataSubjectRightsStore] sobre o ORM do Serverpod. +/// +/// [chainSecret] é o mesmo `AUDIT_CHAIN_SECRET` que assina `consent_logs` no +/// onboarding (`OrmOnboardingStore`) — a linha sai de [signedConsentLog], a +/// mesma função, para as duas origens não divergirem. +class OrmDataSubjectRightsStore implements DataSubjectRightsStore { + OrmDataSubjectRightsStore({ + required Session Function() session, + required String chainSecret, + required HealthDataCipher cipher, + }) : _session = session, + _signature = ConsentSignature(secret: chainSecret), + _cipher = cipher; + + final Session Function() _session; + final ConsentSignature _signature; + final HealthDataCipher _cipher; + + @override + Future recordConsent(ConsentLogEntry entry) async { + await ConsentLog.db.insertRow( + _session(), + signedConsentLog(entry, signature: _signature, origin: 'painel-titular'), + ); + } + + @override + Future findOpenRequest( + String userId, + DataSubjectRequestType type, + ) async { + final row = await DataSubjectRequest.db.findFirstRow( + _session(), + where: (t) => + t.userId.equals(UuidValue.fromString(userId)) & + t.requestType.equals(type) & + t.status.equals(DataSubjectRequestStatus.open), + orderBy: (t) => t.createdAt, + orderDescending: true, + ); + return row == null ? null : dataSubjectRequestSnapshotOf(row, _cipher); + } + + @override + Future createRequest({ + required String userId, + required DataSubjectRequestType type, + required String? details, + required DateTime createdAt, + required DateTime dueAt, + }) async { + // Cifra sempre, inclusive o `null` da exclusão: a coluna vazia fica + // reservada para linha escrita fora do caminho Dart (ver `decryptJson`). + final encrypted = await _cipher.encryptJson(details); + final row = await DataSubjectRequest.db.insertRow( + _session(), + DataSubjectRequest( + userId: UuidValue.fromString(userId), + requestType: type, + detailsEncrypted: encrypted.ciphertextBase64, + detailsKeyVersion: encrypted.keyVersion, + status: DataSubjectRequestStatus.open, + createdAt: createdAt, + dueAt: dueAt, + ), + ); + return dataSubjectRequestSnapshotOf(row, _cipher); + } +} +``` + +- [ ] **Passo 4: `myData` carrega os pedidos** + +Em `orm_patient_data_overview_store.dart`, acrescentar o import `package:sinalacs_server/src/infrastructure/database/orm_data_subject_rights_store.dart` e, depois da consulta `alertRows`: + +```dart + final requestRows = await DataSubjectRequest.db.find( + session, + where: (t) => t.userId.equals(id), + orderBy: (t) => t.createdAt, + orderDescending: true, + ); + final requests = [ + for (final row in requestRows) await dataSubjectRequestSnapshotOf(row, _cipher), + ]; +``` + +No `return PatientDataSnapshot(...)`, acrescentar `requests: requests,` depois de `riskHistory: riskHistory,`. Atualizar o comentário da classe: "Agrega cinco consultas independentes — `patients`, `users`, `consent_logs`, o histórico de risco de `triage_sessions`/`alerts` e `data_subject_requests`…", e acrescentar que também decifra `detailsEncrypted`, texto que o próprio titular escreveu. + +- [ ] **Passo 5: Runtime** + +Em `lib/src/runtime/alert_runtime.dart`, logo depois de `patientDataOverviewServiceFor`: + +```dart + /// Constrói o serviço de direitos do titular (LGPD-RF05/RF08) para uma + /// requisição. + DataSubjectRightsService dataSubjectRightsServiceFor(Session session) => + DataSubjectRightsService( + store: OrmDataSubjectRightsStore( + session: () => session, + chainSecret: config.auditChainSecret, + cipher: healthDataCipher, + ), + audit: auditTrailFor(session), + ); +``` + +Acrescentar os imports de `data_subject_rights_service.dart` e `orm_data_subject_rights_store.dart`, no mesmo estilo dos imports vizinhos. + +- [ ] **Passo 6: As três RPCs** + +Em `patients_endpoint.dart`, depois de `myData`: + +```dart + /// Concede ou revoga, pelo próprio titular, uma finalidade opcional de + /// consentimento (LGPD-RF05): uma linha nova em `consent_logs`, nunca a + /// edição da anterior. `healthDataProcessing` volta como + /// [DataRightsException] — retirá-lo passa pelo pedido de exclusão. + Future updateConsent( + Session session, { + required String accessToken, + required ConsentPurpose purpose, + required bool granted, + }) async { + final user = authenticate(accessToken); + + try { + final record = await AlertRuntime.instance + .dataSubjectRightsServiceFor(session) + .updateConsent(user, purpose: purpose, granted: granted); + return PatientConsentRecord( + purpose: record.purpose, + action: record.action, + version: record.version, + timestamp: record.timestamp, + ); + } on StateError catch (error) { + throw AlertPermissionException(message: error.message); + } + } + + /// Pedido de exclusão/anonimização dos próprios dados (LGPD-RF08). + /// Idempotente enquanto houver um pedido de exclusão em aberto. + Future requestDataDeletion( + Session session, { + required String accessToken, + }) async { + final user = authenticate(accessToken); + + try { + return _requestRecord( + await AlertRuntime.instance.dataSubjectRightsServiceFor(session).requestDeletion(user), + ); + } on StateError catch (error) { + throw AlertPermissionException(message: error.message); + } + } + + /// Pedido de correção de um dado (LGPD-RF08). [details] é texto livre do + /// titular, gravado cifrado; vazio ou acima de 500 caracteres volta como + /// [DataRightsException]. + Future requestDataCorrection( + Session session, { + required String accessToken, + required String details, + }) async { + final user = authenticate(accessToken); + + try { + return _requestRecord( + await AlertRuntime.instance + .dataSubjectRightsServiceFor(session) + .requestCorrection(user, details: details), + ); + } on StateError catch (error) { + throw AlertPermissionException(message: error.message); + } + } +``` + +Atualizar o comentário de classe de `PatientsEndpoint`: além do diretório da microárea, ela agora serve o próprio paciente ("Perfil clínico", "Meus Dados" e os direitos do titular). + +- [ ] **Passo 7: Regenerar o cliente e rodar** + +Run: +```bash +cd backend/sinalacs_server && serverpod generate +dart analyze && dart test +``` +Expected: `generate` acrescenta os três métodos a `backend/sinalacs_client` e a `test/integration/test_tools/serverpod_test_tools.dart`, sem migração nova (nenhum `.spy.yaml` mudou). Suíte inteira verde, incluindo `endpoint_auth_posture_test.dart`: `PatientsEndpoint` já estende `AuthenticatedEndpoint`. + +- [ ] **Passo 8: Commit** + +```bash +git add backend/sinalacs_server/lib backend/sinalacs_client \ + backend/sinalacs_server/test/integration +git commit -m "feat(lgpd): RPCs de consentimento e pedidos do titular, com store ORM cifrada" +``` + +--- + +### Tarefa 4: Contrato no app — `PatientBackend`, `_guard` e a decisão vigente de consentimento + +**Arquivos:** +- Modificar: `apps/patient/lib/core/network/backend_client.dart` +- Criar: `apps/patient/lib/core/consent/consent_decisions.dart` +- Modificar: `apps/patient/test/support/fake_patient_backend.dart` +- Modificar: `apps/patient/test/support/fake_rpc_server.dart` +- Teste: `apps/patient/test/consent_decisions_test.dart`, `apps/patient/test/backend_client_data_rights_test.dart` + +**Interfaces:** +- Consome: RPCs da Tarefa 3 pelo cliente gerado (`_client.patients.updateConsent/requestDataDeletion/requestDataCorrection`). +- Produz: + - Em `PatientBackend`: `Future updateConsent({required ConsentPurpose purpose, required bool granted});`, `Future requestDataDeletion();` e `Future requestDataCorrection(String details);`. + - `Map currentConsentDecisions(List history)`, `String consentPurposeLabel(ConsentPurpose purpose)` e `String consentRecordLabel(String purpose)`. + - Em `FakePatientBackend`: `updateConsentCalls` (`List<({ConsentPurpose purpose, bool granted})>`), `updateConsentFailure`, `requestDataDeletionCount`, `correctionRequests` (`List`), `dataRequestFailure` e `dataRequestGate` (`Completer?`). Os três métodos acrescentam o registro a `myDataResult`, para que o recarregamento mostre o estado novo. + - Em `FakeRpcServer`: `String? rejectDataRightsWith`. + +- [ ] **Passo 1: Testes da decisão vigente (falham: o arquivo não existe)** + +`apps/patient/test/consent_decisions_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_client/sinalacs_client.dart'; +import 'package:sinalacs_patient/core/consent/consent_decisions.dart'; + +PatientConsentRecord _record(String purpose, String action, DateTime at) => + PatientConsentRecord(purpose: purpose, action: action, version: '2026.1', timestamp: at); + +void main() { + test('histórico vazio não tem decisão nenhuma — "nunca decidiu" não vira recusa', () { + expect(currentConsentDecisions(const []), isEmpty); + }); + + test('vale o registro mais recente, qualquer que seja a ordem da lista', () { + final decisions = currentConsentDecisions([ + _record('localReminders', 'denied', DateTime.utc(2026, 2, 1)), + _record('localReminders', 'granted', DateTime.utc(2026, 1, 1)), + ]); + + expect(decisions, {ConsentPurpose.localReminders: false}); + }); + + test('empate de horário: vale o último da lista', () { + final at = DateTime.utc(2026, 1, 1); + final decisions = currentConsentDecisions([ + _record('segmentedPush', 'granted', at), + _record('segmentedPush', 'denied', at), + ]); + + expect(decisions[ConsentPurpose.segmentedPush], isFalse); + }); + + test('as três finalidades do onboarding saem independentes', () { + final at = DateTime.utc(2026, 1, 1); + final decisions = currentConsentDecisions([ + _record('healthDataProcessing', 'granted', at), + _record('localReminders', 'granted', at), + _record('segmentedPush', 'denied', at), + ]); + + expect(decisions, { + ConsentPurpose.healthDataProcessing: true, + ConsentPurpose.localReminders: true, + ConsentPurpose.segmentedPush: false, + }); + }); + + test('finalidade que o app não conhece é ignorada', () { + final decisions = currentConsentDecisions([ + _record('finalidadeFutura', 'granted', DateTime.utc(2026, 1, 1)), + ]); + + expect(decisions, isEmpty); + }); + + test('rótulo de registro cai no nome cru só para finalidade desconhecida', () { + expect(consentRecordLabel('localReminders'), 'Lembretes neste aparelho'); + expect(consentRecordLabel('finalidadeFutura'), 'finalidadeFutura'); + }); +} +``` + +- [ ] **Passo 2: Rodar e ver falhar** + +Run: `cd apps/patient && flutter test test/consent_decisions_test.dart` +Expected: FAIL na compilação (import inexistente). + +- [ ] **Passo 3: Implementar `consent_decisions.dart`** + +```dart +import 'package:sinalacs_client/sinalacs_client.dart'; + +/// Decisão vigente de cada finalidade, a partir do histórico de +/// `consent_logs` que `patients.myData` devolve. +/// +/// O histórico é append-only (LGPD-RF04): revogar grava uma linha nova, nunca +/// apaga a anterior, então vale o registro mais recente de cada finalidade. +/// Empate de horário resolve pela ordem da lista, que o servidor entrega da +/// mais antiga para a mais nova. Finalidade que o app não conhece é ignorada, e +/// finalidade sem registro fica fora do mapa — quem lê distingue "nunca +/// decidiu" de "recusou", mesmo cuidado de `ConsentPreferences`. +Map currentConsentDecisions(List history) { + final known = ConsentPurpose.values.asNameMap(); + final latest = {}; + for (final record in history) { + final purpose = known[record.purpose]; + if (purpose == null) continue; + final previous = latest[purpose]; + if (previous == null || !record.timestamp.isBefore(previous.timestamp)) { + latest[purpose] = record; + } + } + return {for (final entry in latest.entries) entry.key: entry.value.action == 'granted'}; +} + +/// Nome de cada finalidade para a pessoa ler. +String consentPurposeLabel(ConsentPurpose purpose) => switch (purpose) { + ConsentPurpose.healthDataProcessing => 'Tratamento de dados de saúde', + ConsentPurpose.localReminders => 'Lembretes neste aparelho', + ConsentPurpose.segmentedPush => 'Avisos da equipe de saúde', + }; + +/// Rótulo de um registro do histórico, que carrega a finalidade como texto. +/// Uma finalidade desconhecida aparece crua, em vez de sumir do histórico. +String consentRecordLabel(String purpose) { + final known = ConsentPurpose.values.asNameMap()[purpose]; + return known == null ? purpose : consentPurposeLabel(known); +} +``` + +Run: `flutter test test/consent_decisions_test.dart` → PASS. + +- [ ] **Passo 4: Teste do `_guard` pelo cliente real (falha: métodos inexistentes)** + +Em `test/support/fake_rpc_server.dart`, acrescentar o campo depois de `rejectVerifyWith`: + +```dart + /// Definida, faz `updateConsent`/`requestDataDeletion`/`requestDataCorrection` + /// recusarem com esta mensagem, no formato de `DataRightsException`. + String? rejectDataRightsWith; +``` + +e, em `_handle`, logo depois do bloco `if (recusa != null) { ... }`: + +```dart + final recusaDeDireitos = switch (method) { + 'updateConsent' || 'requestDataDeletion' || 'requestDataCorrection' => + rejectDataRightsWith, + _ => null, + }; + if (recusaDeDireitos != null) { + await _respond(request, HttpStatus.badRequest, { + 'className': 'DataRightsException', + 'data': {'__className__': 'DataRightsException', 'message': recusaDeDireitos}, + }); + return; + } +``` + +`apps/patient/test/backend_client_data_rights_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_client/sinalacs_client.dart'; +import 'package:sinalacs_patient/core/network/backend_client.dart'; + +import 'support/fake_rpc_server.dart'; + +/// A tradução `DataRightsException → BackendFailure(mensagem do servidor, não +/// recuperável)` pelo [BackendClient] real. A suíte de widgets usa o +/// `FakePatientBackend`, que não tem `_guard`, e apagar a cláusula de lá não +/// deixaria nada vermelho sem este arquivo. +void main() { + late FakeRpcServer server; + late BackendClient backend; + + setUp(() async { + server = await FakeRpcServer.start(); + backend = BackendClient(host: server.host); + await backend.verifyOtp(cpf: '123.456.789-09', code: '123456'); + }); + + tearDown(() async { + backend.close(); + await server.stop(); + }); + + Matcher recusaDoServidor(String mensagem) => throwsA( + isA() + .having((falha) => falha.message, 'mensagem', mensagem) + .having((falha) => falha.isRecoverable, 'isRecoverable', isFalse), + ); + + test('a recusa de updateConsent chega com a mensagem do servidor', () async { + server.rejectDataRightsWith = 'O consentimento para dados de saúde é obrigatório.'; + + await expectLater( + backend.updateConsent(purpose: ConsentPurpose.healthDataProcessing, granted: false), + recusaDoServidor('O consentimento para dados de saúde é obrigatório.'), + ); + + final pedido = server.requests.last; + expect(pedido.endpoint, 'patients'); + expect(pedido.method, 'updateConsent'); + expect(pedido.args['purpose'], 'healthDataProcessing'); + expect(pedido.args['granted'], false); + }); + + test('a recusa de requestDataCorrection chega com a mensagem do servidor', () async { + server.rejectDataRightsWith = 'Descreva o que precisa ser corrigido.'; + + await expectLater( + backend.requestDataCorrection(' '), + recusaDoServidor('Descreva o que precisa ser corrigido.'), + ); + expect(server.requests.last.args['details'], ' '); + }); + + test('a recusa de requestDataDeletion chega com a mensagem do servidor', () async { + server.rejectDataRightsWith = 'Recusado.'; + + await expectLater(backend.requestDataDeletion(), recusaDoServidor('Recusado.')); + expect(server.requests.last.method, 'requestDataDeletion'); + }); +} +``` + +Se `verifyOtp` do fake exigir um `requestOtp` antes, confira em `backend_client_otp_test.dart` como aquele arquivo obtém a sessão e repita o mesmo passo no `setUp`. + +- [ ] **Passo 5: Implementar os métodos no app** + +Em `backend_client.dart`, na interface `PatientBackend`, depois de `myData()`: + +```dart + /// Concede ou revoga uma finalidade opcional de consentimento (LGPD-RF05). + /// O servidor grava uma linha nova em `consent_logs`; `healthDataProcessing` + /// é recusado com [BackendFailure] não recuperável. + Future updateConsent({ + required ConsentPurpose purpose, + required bool granted, + }); + + /// Pede a exclusão dos próprios dados (LGPD-RF08). Pedir de novo com um + /// pedido aberto devolve o mesmo. + Future requestDataDeletion(); + + /// Pede a correção de um dado (LGPD-RF08). O servidor faz o `trim` e recusa + /// texto vazio ou acima de 500 caracteres. + Future requestDataCorrection(String details); +``` + +Em `MisconfiguredBackend`, depois de `myData`: + +```dart + @override + Future updateConsent({ + required ConsentPurpose purpose, + required bool granted, + }) async => + _recusar(); + + @override + Future requestDataDeletion() async => _recusar(); + + @override + Future requestDataCorrection(String details) async => + _recusar(); +``` + +Em `BackendClient`, depois de `myData`: + +```dart + @override + Future updateConsent({ + required ConsentPurpose purpose, + required bool granted, + }) async { + final token = await _requireToken(); + return _guard( + () => _client.patients.updateConsent(accessToken: token, purpose: purpose, granted: granted), + ); + } + + @override + Future requestDataDeletion() async { + final token = await _requireToken(); + return _guard(() => _client.patients.requestDataDeletion(accessToken: token)); + } + + @override + Future requestDataCorrection(String details) async { + final token = await _requireToken(); + return _guard( + () => _client.patients.requestDataCorrection(accessToken: token, details: details), + ); + } +``` + +Em `_guard`, logo depois da cláusula `on OtpRequestException`: + +```dart + } on DataRightsException catch (error) { + // Recusa de negócio de um direito do titular (consentimento obrigatório, + // texto de correção inválido). A mensagem é do servidor, pronta para a + // pessoa ler; tentar de novo sem mudar nada dá a mesma recusa. + throw BackendFailure(error.message, isRecoverable: false); +``` + +- [ ] **Passo 6: Implementar no `FakePatientBackend`** + +Acrescentar `import 'dart:async';` no topo. Depois de `int myDataCallCount = 0;`: + +```dart + /// Chamadas a [updateConsent], na ordem. + final List<({ConsentPurpose purpose, bool granted})> updateConsentCalls = + <({ConsentPurpose purpose, bool granted})>[]; + BackendFailure? updateConsentFailure; + + int requestDataDeletionCount = 0; + final List correctionRequests = []; + BackendFailure? dataRequestFailure; + + /// Definido, segura [requestDataDeletion] até ser completado — é como o teste + /// observa a tela com o pedido ainda em voo. + Completer? dataRequestGate; +``` + +Depois do método `myData()`: + +```dart + /// Como no servidor: uma linha nova no histórico, que o próximo [myData] + /// devolve. + @override + Future updateConsent({ + required ConsentPurpose purpose, + required bool granted, + }) async { + updateConsentCalls.add((purpose: purpose, granted: granted)); + final failure = updateConsentFailure; + if (failure != null) throw failure; + final record = PatientConsentRecord( + purpose: purpose.name, + action: granted ? 'granted' : 'denied', + version: '2026.1', + timestamp: DateTime.now().toUtc(), + ); + myDataResult = myDataResult.copyWith(consents: [...myDataResult.consents, record]); + return record; + } + + /// Idempotente enquanto houver exclusão aberta, como o servidor. + @override + Future requestDataDeletion() async { + requestDataDeletionCount++; + await dataRequestGate?.future; + final failure = dataRequestFailure; + if (failure != null) throw failure; + for (final request in myDataResult.requests) { + if (request.type == DataSubjectRequestType.deletion && + request.status == DataSubjectRequestStatus.open) { + return request; + } + } + return _appendRequest(DataSubjectRequestType.deletion, null); + } + + @override + Future requestDataCorrection(String details) async { + correctionRequests.add(details); + final failure = dataRequestFailure; + if (failure != null) throw failure; + return _appendRequest(DataSubjectRequestType.correction, details); + } + + PatientDataSubjectRequestRecord _appendRequest(DataSubjectRequestType type, String? details) { + final now = DateTime.now().toUtc(); + final record = PatientDataSubjectRequestRecord( + type: type, + status: DataSubjectRequestStatus.open, + details: details, + createdAt: now, + dueAt: now.add(const Duration(days: 15)), + ); + myDataResult = myDataResult.copyWith(requests: [...myDataResult.requests, record]); + return record; + } +``` + +- [ ] **Passo 7: Rodar e ver passar** + +Run: `cd apps/patient && flutter analyze && flutter test` +Expected: tudo verde, incluindo os dois arquivos novos. + +- [ ] **Passo 8: Commit** + +```bash +git add apps/patient/lib/core apps/patient/test +git commit -m "feat(paciente): contrato de direitos do titular no cliente e decisão vigente de consentimento" +``` + +--- + +### Tarefa 5: "Meus dados" — interruptores de consentimento e espelho local alinhado ao servidor + +**Arquivos:** +- Modificar: `apps/patient/lib/app/app.dart` (`MyDataScreen`, `_RemindersScreenState._load`, `_consentDeniedMessage`, função nova `deactivateAllReminders`) +- Teste: `apps/patient/test/patient_app_mvp_test.dart` (grupo `'Meus dados (LGPD)'`) + +**Interfaces:** +- Consome: `PatientBackend.updateConsent` e `FakePatientBackend.updateConsentCalls/updateConsentFailure` (Tarefa 4); `currentConsentDecisions`, `consentPurposeLabel` e `consentRecordLabel` (Tarefa 4); `RemindersScope`. +- Produz: `Future deactivateAllReminders(RemindersScope scope)` (top-level em `app.dart`). Em `_MyDataScreenState`: `bool _busy`, `String _formatDate(DateTime)` e `Future _load()` (alinha o espelho local). A chave `Key('my_data_confirmation')` substitui `Key('my_data_export_confirmation')`. Chaves novas: `consent_switch_localReminders`, `consent_switch_segmentedPush`, `consent_health_data_notice`, `consent_revoke_confirm`, `consent_revoke_cancel`. + +- [ ] **Passo 1: Escrever os testes (falham)** + +Em `test/patient_app_mvp_test.dart`, primeiro renomeie a chave nos testes existentes: + +```bash +cd apps/patient && sed -i "s/my_data_export_confirmation/my_data_confirmation/g" test/patient_app_mvp_test.dart +``` + +No teste `'mostra consentimentos e histórico de risco devolvidos pelo servidor'` (o que usa `find.text('healthDataProcessing')`), trocar essa asserção por `expect(find.text('Tratamento de dados de saúde'), findsOneWidget);`. O histórico passa a usar o rótulo; o aviso da finalidade obrigatória é um texto mais longo e não casa com `find.text` exato. + +Dentro do grupo `'Meus dados (LGPD)'`, depois do helper `overview`, acrescentar os helpers e os testes: + +```dart + PatientConsentRecord consent(ConsentPurpose purpose, String action, DateTime at) => + PatientConsentRecord(purpose: purpose.name, action: action, version: '2026.1', timestamp: at); + + List onboardingConsents() { + final at = DateTime.utc(2026, 1, 1); + return [ + consent(ConsentPurpose.healthDataProcessing, 'granted', at), + consent(ConsentPurpose.localReminders, 'granted', at), + consent(ConsentPurpose.segmentedPush, 'denied', at), + ]; + } + + /// "Meus dados" com os duplos de lembretes: a tela agora lê e alinha o + /// espelho local, e sem eles o `SinalAcsApp` montaria o SQLite real. + Future pumpMyData( + WidgetTester tester, + FakePatientBackend backend, { + ReminderStore? store, + ReminderScheduler? scheduler, + ConsentPreferences? consentPreferences, + }) async { + await tester.pumpWidget(SinalAcsApp( + backend: backend, + reminderStore: store ?? _InMemoryReminderStore(), + reminderScheduler: scheduler ?? _RecordingReminderScheduler(), + consentPreferences: consentPreferences ?? _FixedConsentPreferences(), + )); + await login(tester); + await openMyData(tester); + } + + Future tapSwitch(WidgetTester tester, ConsentPurpose purpose) async { + final finder = find.byKey(Key('consent_switch_${purpose.name}')); + await tester.ensureVisible(finder); + await tester.pumpAndSettle(); + await tester.tap(finder); + await tester.pumpAndSettle(); + } + + bool switchValue(WidgetTester tester, ConsentPurpose purpose) => + tester.widget(find.byKey(Key('consent_switch_${purpose.name}'))).value; + + testWidgets('os interruptores mostram a decisão mais recente de cada finalidade', (tester) async { + final backend = FakePatientBackend() + ..myDataResult = overview(consents: [ + ...onboardingConsents(), + consent(ConsentPurpose.localReminders, 'denied', DateTime.utc(2026, 2, 1)), + ]); + await pumpMyData(tester, backend, consentPreferences: _FixedConsentPreferences(granted: false)); + + expect(switchValue(tester, ConsentPurpose.localReminders), isFalse); + expect(switchValue(tester, ConsentPurpose.segmentedPush), isFalse); + expect(find.byKey(const Key('consent_switch_healthDataProcessing')), findsNothing); + expect( + tester.widget(find.byKey(const Key('consent_health_data_notice'))).data, + contains('solicite a exclusão'), + ); + }); + + testWidgets('revogar pede confirmação; cancelar não chama o servidor', (tester) async { + final backend = FakePatientBackend()..myDataResult = overview(consents: onboardingConsents()); + await pumpMyData(tester, backend); + + await tapSwitch(tester, ConsentPurpose.localReminders); + expect(find.text('Revogar consentimento?'), findsOneWidget); + await tester.tap(find.byKey(const Key('consent_revoke_cancel'))); + await tester.pumpAndSettle(); + + expect(backend.updateConsentCalls, isEmpty); + expect(switchValue(tester, ConsentPurpose.localReminders), isTrue); + }); + + testWidgets( + 'confirmar a revogação de lembretes registra no servidor, atualiza o espelho e ' + 'cancela na hora o lembrete já agendado', (tester) async { + final store = _InMemoryReminderStore(); + final seeded = await store.save( + const Reminder(id: 0, label: 'Losartana 50 mg', hour: 8, minute: 0, active: true), + ); + final scheduler = _RecordingReminderScheduler(); + final prefs = _FixedConsentPreferences(granted: true); + final backend = FakePatientBackend()..myDataResult = overview(consents: onboardingConsents()); + await pumpMyData(tester, backend, store: store, scheduler: scheduler, consentPreferences: prefs); + + await tapSwitch(tester, ConsentPurpose.localReminders); + await tester.tap(find.byKey(const Key('consent_revoke_confirm'))); + await tester.pumpAndSettle(); + + expect(backend.updateConsentCalls.single, (purpose: ConsentPurpose.localReminders, granted: false)); + expect(prefs.granted, isFalse); + expect(scheduler.cancelled, [seeded.id]); + expect((await store.list()).single.active, isFalse); + expect(switchValue(tester, ConsentPurpose.localReminders), isFalse); + expect( + tester.widget(find.byKey(const Key('my_data_confirmation'))).data, + contains('revogado'), + ); + }); + + testWidgets('conceder não pede confirmação e não mexe nos lembretes', (tester) async { + final scheduler = _RecordingReminderScheduler(); + final backend = FakePatientBackend()..myDataResult = overview(consents: onboardingConsents()); + await pumpMyData(tester, backend, scheduler: scheduler); + + await tapSwitch(tester, ConsentPurpose.segmentedPush); + + expect(find.text('Revogar consentimento?'), findsNothing); + expect(backend.updateConsentCalls.single, (purpose: ConsentPurpose.segmentedPush, granted: true)); + expect(scheduler.cancelled, isEmpty); + expect(switchValue(tester, ConsentPurpose.segmentedPush), isTrue); + }); + + testWidgets('falha do servidor mostra o erro e o interruptor fica como estava', (tester) async { + final handle = tester.ensureSemantics(); + final backend = FakePatientBackend() + ..myDataResult = overview(consents: onboardingConsents()) + ..updateConsentFailure = const BackendFailure('Sem conexão com o servidor.'); + await pumpMyData(tester, backend); + + await tapSwitch(tester, ConsentPurpose.segmentedPush); + + expect(find.text('Sem conexão com o servidor.'), findsOneWidget); + expect(tester.getSemantics(find.byKey(const Key('my_data_error'))).flagsCollection.isLiveRegion, isTrue); + expect(switchValue(tester, ConsentPurpose.segmentedPush), isFalse); + handle.dispose(); + }); + + testWidgets('aparelho sem espelho local (login OTP) recebe a concessão do servidor ao abrir', (tester) async { + final prefs = _FixedConsentPreferences(granted: null); + final backend = FakePatientBackend()..myDataResult = overview(consents: onboardingConsents()); + await pumpMyData(tester, backend, consentPreferences: prefs); + + expect(prefs.granted, isTrue); + }); + + testWidgets('recusa vinda do servidor cancela lembretes ativos de um aparelho sem espelho', (tester) async { + final store = _InMemoryReminderStore(); + final seeded = await store.save( + const Reminder(id: 0, label: 'Metformina 850 mg', hour: 7, minute: 0, active: true), + ); + final scheduler = _RecordingReminderScheduler(); + final prefs = _FixedConsentPreferences(granted: null); + final backend = FakePatientBackend() + ..myDataResult = overview(consents: [ + ...onboardingConsents(), + consent(ConsentPurpose.localReminders, 'denied', DateTime.utc(2026, 2, 1)), + ]); + await pumpMyData(tester, backend, store: store, scheduler: scheduler, consentPreferences: prefs); + + expect(prefs.granted, isFalse); + expect(scheduler.cancelled, [seeded.id]); + }); + + testWidgets('os interruptores não criam nó de botão inerte', (tester) async { + final handle = tester.ensureSemantics(); + final backend = FakePatientBackend()..myDataResult = overview(consents: onboardingConsents()); + await pumpMyData(tester, backend); + await tester.ensureVisible(find.byKey(const Key('consent_switch_segmentedPush'))); + await tester.pumpAndSettle(); + + expectNenhumBotaoInerte(tester); + handle.dispose(); + }); +``` + +- [ ] **Passo 2: Rodar e ver falhar** + +Run: `cd apps/patient && flutter test test/patient_app_mvp_test.dart --plain-name "Meus dados"` +Expected: FAIL — `consent_switch_*`, `my_data_confirmation` e `Tratamento de dados de saúde` não existem. + +- [ ] **Passo 3: `deactivateAllReminders` e a refatoração de `RemindersScreen`** + +Em `app.dart`, logo antes de `class RemindersScreen extends StatefulWidget`: + +```dart +/// Cancela no sistema operacional e desativa no store todo lembrete ativo. +/// +/// É o que acontece quando o consentimento de lembretes não está (ou deixou de +/// estar) concedido: `RemindersScreen` chama ao abrir, e "Meus dados" chama ao +/// saber de uma revogação — sem isso, um lembrete agendado antes continuaria +/// disparando até a pessoa abrir "Lembretes" (LGPD-RF05). Devolve se algum +/// lembrete foi alterado. +Future deactivateAllReminders(RemindersScope scope) async { + final stillActive = (await scope.store.list()).where((r) => r.active).toList(); + for (final r in stillActive) { + await scope.scheduler.cancel(r.id); + await scope.store.save(r.copyWith(active: false)); + } + return stillActive.isNotEmpty; +} +``` + +Em `_RemindersScreenState._load`, trocar o bloco dentro de `if (consentGranted != true) { ... }` (mantendo o comentário) por: + +```dart + if (consentGranted != true) { + // Recusa (ou estado desconhecido, tratado como recusa por padrão de + // segurança): nenhum lembrete pode continuar agendado no sistema + // operacional depois disso — sem isso, um lembrete criado antes da + // recusa continuaria disparando mesmo depois dela (LGPD-RF05). + if (await deactivateAllReminders(scope)) { + reminders = await scope.store.list(); + } + } +``` + +Em `_consentDeniedMessage`, trocar o texto do ramo `granted == false` por: + +```dart + return 'Você recusou ou revogou o consentimento para lembretes locais — ' + 'reative em "Meus dados" para agendar notificações.'; +``` + +- [ ] **Passo 4: Estado, carga e alteração de consentimento em `MyDataScreen`** + +Acrescentar o import `package:sinalacs_patient/core/consent/consent_decisions.dart` ao topo de `app.dart`. + +Em `_MyDataScreenState`, acrescentar o campo `bool _busy = false;` e trocar `_load` por: + +```dart + Future _load() async { + final backend = BackendScope.of(context); + final reminders = RemindersScope.of(context); + try { + final data = await backend.myData(); + if (!mounted) return; + setState(() { + _data = data; + _error = null; + }); + await _alignLocalRemindersMirror(reminders, data); + } on BackendFailure catch (failure) { + if (!mounted) return; + setState(() => _error = failure.message); + } + } + + /// O servidor é a fonte da decisão de lembretes (`consent_logs`); o store + /// local é só o espelho que `RemindersScreen` consulta (ver + /// `ConsentPreferences`). Alinhar aqui cobre a revogação feita nesta tela e + /// também o aparelho que entrou pelo login OTP (RF01) sem passar pelo + /// onboarding, que nunca teve espelho. Sem decisão de lembretes no servidor, + /// não mexe em nada. + Future _alignLocalRemindersMirror(RemindersScope scope, PatientDataOverview data) async { + final granted = currentConsentDecisions(data.consents)[ConsentPurpose.localReminders]; + if (granted == null) return; + try { + if (await scope.consentPreferences.localRemindersGranted() != granted) { + await scope.consentPreferences.saveLocalRemindersConsent(granted); + } + if (!granted) await deactivateAllReminders(scope); + } catch (_) { + if (!mounted) return; + setState(() => _error = + 'Sua escolha foi registrada, mas os lembretes deste aparelho não puderam ser atualizados.'); + } + } + + Future _confirmRevocation(ConsentPurpose purpose) async { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Revogar consentimento?'), + content: Text( + '${consentPurposeLabel(purpose)}: seus dados deixam de ser usados para esta ' + 'finalidade a partir de agora. Você pode conceder de novo quando quiser.' + '${purpose == ConsentPurpose.localReminders ? ' Os lembretes agendados neste aparelho serão desativados.' : ''}', + ), + actions: [ + TextButton( + key: const Key('consent_revoke_cancel'), + onPressed: () => Navigator.pop(dialogContext, false), + child: const Text('Cancelar'), + ), + FilledButton( + key: const Key('consent_revoke_confirm'), + onPressed: () => Navigator.pop(dialogContext, true), + child: const Text('Revogar'), + ), + ], + ), + ); + return confirmed == true; + } + + /// LGPD-RF05: revogar pede confirmação explícita; conceder, não. O + /// interruptor segue a decisão devolvida pelo servidor no recarregamento, + /// então uma falha o deixa exatamente como estava. + Future _changeConsent(ConsentPurpose purpose, bool granted) async { + if (_busy) return; + if (!granted && !await _confirmRevocation(purpose)) return; + if (!mounted) return; + final backend = BackendScope.of(context); + setState(() { + _busy = true; + _error = null; + _confirmation = null; + }); + try { + final record = await backend.updateConsent(purpose: purpose, granted: granted); + if (!mounted) return; + setState(() => _confirmation = '${consentPurposeLabel(purpose)}: consentimento ' + '${granted ? 'concedido' : 'revogado'} em ${_formatDate(record.timestamp.toLocal())}.'); + await _load(); + } on BackendFailure catch (failure) { + if (!mounted) return; + setState(() => _error = failure.message); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + /// dd/mm/aaaa. Não converte fuso: quem passa um instante (consentimento, + /// pedido) chama `.toLocal()` antes; a data de nascimento é meia-noite UTC e + /// passa como está, senão cairia no dia anterior no Brasil. + String _formatDate(DateTime date) => '${date.day.toString().padLeft(2, '0')}/' + '${date.month.toString().padLeft(2, '0')}/${date.year}'; + + String _purposeDescription(ConsentPurpose purpose) => switch (purpose) { + ConsentPurpose.localReminders => + 'Notificações de remédios e cuidados que você agenda em "Lembretes".', + ConsentPurpose.segmentedPush => + 'Avisos da UBS para a sua microárea. Ainda não são enviados nesta versão.', + ConsentPurpose.healthDataProcessing => 'Obrigatório para usar o app.', + }; +``` + +Em `_export`, trocar a chave e o texto de confirmação não muda. Só a chave do `Text` muda, no `build` (passo 5). + +- [ ] **Passo 5: `build` de `MyDataScreen`** + +1. No `Text` de confirmação, trocar `key: const Key('my_data_export_confirmation')` por `key: const Key('my_data_confirmation')`. +2. No cartão, trocar a linha da data de nascimento por `Text('Data de nascimento: ${_formatDate(data.birthDate)}'),`. +3. Logo depois de `else if (data != null) ...[`, declarar a decisão vigente, transformando o spread em bloco de expressão. Como Dart não permite `final` dentro de lista, calcule no início do `build`, junto de `final data = _data;`: + +```dart + final decisions = data == null + ? const {} + : currentConsentDecisions(data.consents); +``` + +4. Trocar o trecho que vai de `const Text('Consentimentos', ...)` até o fim do `.map` dos consentimentos por: + +```dart + const Text('Suas escolhas de consentimento', style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + Text( + decisions[ConsentPurpose.healthDataProcessing] == true + ? '${consentPurposeLabel(ConsentPurpose.healthDataProcessing)}: concedido — ' + 'obrigatório para usar o app. Para retirá-lo, solicite a exclusão dos seus dados abaixo.' + : '${consentPurposeLabel(ConsentPurpose.healthDataProcessing)}: sem registro de consentimento.', + key: const Key('consent_health_data_notice'), + ), + for (final purpose in const [ConsentPurpose.localReminders, ConsentPurpose.segmentedPush]) + SwitchListTile( + key: Key('consent_switch_${purpose.name}'), + contentPadding: EdgeInsets.zero, + title: Text(consentPurposeLabel(purpose)), + subtitle: Text(_purposeDescription(purpose)), + value: decisions[purpose] ?? false, + onChanged: _busy ? null : (value) => _changeConsent(purpose, value), + ), + const SizedBox(height: 16), + const Text('Histórico de consentimentos', style: TextStyle(fontWeight: FontWeight.bold)), + if (data.consents.isEmpty) + const Padding( + padding: EdgeInsets.only(top: 8), + child: Text('Nenhum consentimento registrado.', style: TextStyle(color: Colors.white54)), + ) + else + ...data.consents.map( + (consent) => ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + title: Text(consentRecordLabel(consent.purpose)), + subtitle: Text('${consent.action == 'granted' ? 'Concedido' : 'Recusado'} · ' + 'v${consent.version} · ${_formatDate(consent.timestamp.toLocal())}'), + ), + ), +``` + +- [ ] **Passo 6: Rodar e ver passar** + +Run: `cd apps/patient && flutter analyze && flutter test` +Expected: tudo verde. Os testes de "Meus dados" que já existiam não injetam duplos de lembrete. Eles continuam verdes porque o histórico deles não traz `localReminders`, e `_alignLocalRemindersMirror` sai antes de tocar no SQLite. Se algum ficar pendurado, é esse o motivo: passe a usar `pumpMyData`. + +- [ ] **Passo 7: Commit** + +```bash +git add apps/patient/lib/app/app.dart apps/patient/test/patient_app_mvp_test.dart +git commit -m "feat(paciente): revogar e conceder consentimento em Meus dados (LGPD-RF05)" +``` + +--- + +### Tarefa 6: "Meus dados" — pedidos de exclusão e de correção + +**Arquivos:** +- Modificar: `apps/patient/lib/app/app.dart` (`MyDataScreen`; classe nova `_CorrectionRequestDialog`) +- Teste: `apps/patient/test/patient_app_mvp_test.dart` (grupo `'Meus dados (LGPD)'`) + +**Interfaces:** +- Consome: `PatientBackend.requestDataDeletion/requestDataCorrection`; `FakePatientBackend.requestDataDeletionCount/correctionRequests/dataRequestFailure/dataRequestGate` (Tarefa 4); `_busy`, `_formatDate`, `_load` e `pumpMyData` (Tarefa 5). +- Produz: chaves `request_deletion_button`, `request_correction_button`, `deletion_request_confirm`, `deletion_request_cancel`, `correction_details_field`, `correction_request_submit`, `correction_request_cancel`; chave `pedidos` no JSON exportado. + +- [ ] **Passo 1: Escrever os testes (falham)** + +No grupo `'Meus dados (LGPD)'`, depois dos testes da Tarefa 5: + +```dart + Future tapByKey(WidgetTester tester, String key) async { + final finder = find.byKey(Key(key)); + await tester.ensureVisible(finder); + await tester.pumpAndSettle(); + await tester.tap(finder); + await tester.pumpAndSettle(); + } + + OutlinedButton outlined(WidgetTester tester, String key) => + tester.widget(find.byKey(Key(key))); + + PatientDataSubjectRequestRecord openRequest(DataSubjectRequestType type, {String? details}) => + PatientDataSubjectRequestRecord( + type: type, + status: DataSubjectRequestStatus.open, + details: details, + // Meio-dia UTC: `toLocal()` mantém o dia em qualquer fuso entre + // UTC-11 e UTC+11 — meia-noite viraria 15/09 no Brasil. + createdAt: DateTime.utc(2026, 9, 1, 12), + dueAt: DateTime.utc(2026, 9, 16, 12), + ); + + testWidgets('sem pedidos, mostra o estado vazio e os dois botões habilitados', (tester) async { + final backend = FakePatientBackend()..myDataResult = overview(); + await pumpMyData(tester, backend); + + expect(find.text('Nenhum pedido feito.'), findsOneWidget); + expect(outlined(tester, 'request_deletion_button').onPressed, isNotNull); + expect(outlined(tester, 'request_correction_button').onPressed, isNotNull); + }); + + testWidgets('pedir exclusão explica prazo e retenção, registra e mostra o pedido em análise', (tester) async { + final backend = FakePatientBackend()..myDataResult = overview(); + await pumpMyData(tester, backend); + + await tapByKey(tester, 'request_deletion_button'); + expect(find.textContaining('15 dias'), findsOneWidget); + expect(find.textContaining('emergência'), findsOneWidget); + await tester.tap(find.byKey(const Key('deletion_request_confirm'))); + await tester.pumpAndSettle(); + + expect(backend.requestDataDeletionCount, 1); + expect(find.textContaining('Exclusão dos dados · Em análise'), findsOneWidget); + expect(outlined(tester, 'request_deletion_button').onPressed, isNull); + expect(find.text('Exclusão já solicitada — em análise'), findsOneWidget); + expect( + tester.widget(find.byKey(const Key('my_data_confirmation'))).data, + startsWith('Pedido de exclusão registrado'), + ); + }); + + testWidgets('cancelar o diálogo de exclusão não chama o servidor', (tester) async { + final backend = FakePatientBackend()..myDataResult = overview(); + await pumpMyData(tester, backend); + + await tapByKey(tester, 'request_deletion_button'); + await tester.tap(find.byKey(const Key('deletion_request_cancel'))); + await tester.pumpAndSettle(); + + expect(backend.requestDataDeletionCount, 0); + }); + + testWidgets('com o pedido em voo os botões ficam desabilitados — um pedido só', (tester) async { + final gate = Completer(); + final backend = FakePatientBackend() + ..myDataResult = overview() + ..dataRequestGate = gate; + await pumpMyData(tester, backend); + + await tapByKey(tester, 'request_deletion_button'); + await tester.tap(find.byKey(const Key('deletion_request_confirm'))); + await tester.pumpAndSettle(); + + expect(outlined(tester, 'request_deletion_button').onPressed, isNull); + expect(outlined(tester, 'request_correction_button').onPressed, isNull); + + gate.complete(); + await tester.pumpAndSettle(); + expect(backend.requestDataDeletionCount, 1); + }); + + testWidgets('pedido aberto vindo do servidor já desabilita o botão de exclusão', (tester) async { + final backend = FakePatientBackend() + ..myDataResult = overview(requests: [openRequest(DataSubjectRequestType.deletion)]); + await pumpMyData(tester, backend); + + expect(outlined(tester, 'request_deletion_button').onPressed, isNull); + expect(find.textContaining('resposta até 16/09/2026'), findsOneWidget); + }); + + testWidgets('correção: só envia com texto de verdade, e sem os espaços das pontas', (tester) async { + final backend = FakePatientBackend()..myDataResult = overview(); + await pumpMyData(tester, backend); + + await tapByKey(tester, 'request_correction_button'); + FilledButton submit() => + tester.widget(find.byKey(const Key('correction_request_submit'))); + expect(submit().onPressed, isNull); + + await tester.enterText(find.byKey(const Key('correction_details_field')), ' '); + await tester.pump(); + expect(submit().onPressed, isNull); + + await tester.enterText(find.byKey(const Key('correction_details_field')), ' Meu contato mudou. '); + await tester.pump(); + await tester.tap(find.byKey(const Key('correction_request_submit'))); + await tester.pumpAndSettle(); + + expect(backend.correctionRequests, ['Meu contato mudou.']); + expect(find.textContaining('Correção de dados · Em análise'), findsOneWidget); + expect(find.textContaining('"Meu contato mudou."'), findsOneWidget); + }); + + testWidgets('com uma correção aberta, pedir outra continua possível', (tester) async { + final backend = FakePatientBackend() + ..myDataResult = overview( + requests: [openRequest(DataSubjectRequestType.correction, details: 'Contato errado.')], + ); + await pumpMyData(tester, backend); + + expect(outlined(tester, 'request_correction_button').onPressed, isNotNull); + }); + + testWidgets('falha ao pedir exclusão mostra o erro e reabilita o botão', (tester) async { + final backend = FakePatientBackend() + ..myDataResult = overview() + ..dataRequestFailure = const BackendFailure('Sem conexão com o servidor.'); + await pumpMyData(tester, backend); + + await tapByKey(tester, 'request_deletion_button'); + await tester.tap(find.byKey(const Key('deletion_request_confirm'))); + await tester.pumpAndSettle(); + + expect(find.text('Sem conexão com o servidor.'), findsOneWidget); + expect(outlined(tester, 'request_deletion_button').onPressed, isNotNull); + }); + + testWidgets('os botões de pedido não criam nó de botão inerte', (tester) async { + final handle = tester.ensureSemantics(); + final backend = FakePatientBackend()..myDataResult = overview(); + await pumpMyData(tester, backend); + await tester.ensureVisible(find.byKey(const Key('request_correction_button'))); + await tester.pumpAndSettle(); + + expectNenhumBotaoInerte(tester); + handle.dispose(); + }); +``` + +No teste existente `'"Copiar meus dados" grava um JSON válido...'`, acrescentar `requests: [openRequest(DataSubjectRequestType.deletion)],` ao `overview(...)`. Depois da asserção de `consentimentos`, acrescentar: + +```dart + expect((decoded['pedidos'] as List).single, { + 'tipo': 'deletion', + 'situacao': 'open', + 'data': DateTime.utc(2026, 9, 1, 12).toIso8601String(), + 'prazo': DateTime.utc(2026, 9, 16, 12).toIso8601String(), + }); +``` + +Acrescentar `import 'dart:async';` no topo do arquivo de teste se ainda não houver (por causa do `Completer`). + +- [ ] **Passo 2: Rodar e ver falhar** + +Run: `cd apps/patient && flutter test test/patient_app_mvp_test.dart --plain-name "Meus dados"` +Expected: FAIL — `request_deletion_button` não existe. + +- [ ] **Passo 3: Diálogo de correção** + +Em `app.dart`, logo depois da classe `_MyDataScreenState`: + +```dart +/// Pede o texto de uma correção (LGPD-RF08). Devolve o texto já sem espaços +/// nas pontas, ou `null` se a pessoa cancelar. O limite de 500 é o mesmo que o +/// servidor impõe (`correctionDetailsMaxLength`); o servidor revalida, porque +/// o app não é a única origem possível da chamada. +class _CorrectionRequestDialog extends StatefulWidget { + const _CorrectionRequestDialog(); + + @override + State<_CorrectionRequestDialog> createState() => _CorrectionRequestDialogState(); +} + +class _CorrectionRequestDialogState extends State<_CorrectionRequestDialog> { + final _controller = TextEditingController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final details = _controller.text.trim(); + return AlertDialog( + title: const Text('Solicitar correção'), + content: TextField( + key: const Key('correction_details_field'), + controller: _controller, + maxLength: 500, + maxLines: 4, + onChanged: (_) => setState(() {}), + decoration: const InputDecoration( + labelText: 'O que precisa ser corrigido?', + helperText: 'Condições crônicas você mesmo atualiza em "Perfil clínico".', + helperMaxLines: 2, + ), + ), + actions: [ + TextButton( + key: const Key('correction_request_cancel'), + onPressed: () => Navigator.pop(context), + child: const Text('Cancelar'), + ), + FilledButton( + key: const Key('correction_request_submit'), + onPressed: details.isEmpty ? null : () => Navigator.pop(context, details), + child: const Text('Enviar pedido'), + ), + ], + ); + } +} +``` + +- [ ] **Passo 4: Ações de pedido em `_MyDataScreenState`** + +```dart + /// Envia um pedido e recarrega. Mesmo formato de [_changeConsent]: `_busy` + /// desabilita os controles enquanto a chamada está em voo, e é isso que + /// impede o segundo toque de virar segundo pedido. + Future _submitRequest( + Future Function(PatientBackend backend) call, + String done, + ) async { + final backend = BackendScope.of(context); + setState(() { + _busy = true; + _error = null; + _confirmation = null; + }); + try { + final record = await call(backend); + if (!mounted) return; + setState(() => _confirmation = '$done. Resposta até ${_formatDate(record.dueAt.toLocal())}.'); + await _load(); + } on BackendFailure catch (failure) { + if (!mounted) return; + setState(() => _error = failure.message); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _requestDeletion() async { + if (_busy) return; + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Solicitar exclusão dos seus dados?'), + content: const Text( + 'A equipe da UBS analisa o pedido em até 15 dias e a resposta aparece aqui. ' + 'Registros de saúde (alertas, triagens e visitas) podem ser mantidos pelo prazo ' + 'legal de 5 anos e anonimizados depois, em vez de apagados. Enquanto o pedido ' + 'estiver em análise, o app continua funcionando — inclusive o botão de emergência.', + ), + actions: [ + TextButton( + key: const Key('deletion_request_cancel'), + onPressed: () => Navigator.pop(dialogContext, false), + child: const Text('Cancelar'), + ), + FilledButton( + key: const Key('deletion_request_confirm'), + onPressed: () => Navigator.pop(dialogContext, true), + child: const Text('Solicitar exclusão'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + await _submitRequest((backend) => backend.requestDataDeletion(), 'Pedido de exclusão registrado'); + } + + Future _requestCorrection() async { + if (_busy) return; + final details = await showDialog( + context: context, + builder: (_) => const _CorrectionRequestDialog(), + ); + if (details == null || !mounted) return; + await _submitRequest( + (backend) => backend.requestDataCorrection(details), + 'Pedido de correção registrado', + ); + } + + String _requestTypeLabel(DataSubjectRequestType type) => switch (type) { + DataSubjectRequestType.deletion => 'Exclusão dos dados', + DataSubjectRequestType.correction => 'Correção de dados', + }; + + String _requestStatusLabel(DataSubjectRequestStatus status) => switch (status) { + DataSubjectRequestStatus.open => 'Em análise', + DataSubjectRequestStatus.completed => 'Atendido', + DataSubjectRequestStatus.rejected => 'Recusado', + }; +``` + +Em `_toJson`, depois de `'historicoDeClassificacaoDeRisco': [...]`: + +```dart + 'pedidos': [ + for (final request in data.requests) + { + 'tipo': request.type.name, + 'situacao': request.status.name, + if (request.details != null) 'detalhes': request.details, + 'data': request.createdAt.toIso8601String(), + 'prazo': request.dueAt.toIso8601String(), + }, + ], +``` + +- [ ] **Passo 5: Seção de pedidos no `build`** + +Acrescentar ao lado de `decisions`, no início do `build`: + +```dart + final openDeletion = data?.requests.any((r) => + r.type == DataSubjectRequestType.deletion && + r.status == DataSubjectRequestStatus.open) ?? + false; +``` + +Entre o fim do histórico de risco e o `const SizedBox(height: 16)` que precede o `FilledButton.icon` de exportação, inserir: + +```dart + const SizedBox(height: 16), + const Text('Pedidos sobre seus dados', style: TextStyle(fontWeight: FontWeight.bold)), + if (data.requests.isEmpty) + const Padding( + padding: EdgeInsets.only(top: 8), + child: Text('Nenhum pedido feito.', style: TextStyle(color: Colors.white54)), + ) + else + ...data.requests.map( + (request) => ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + title: Text('${_requestTypeLabel(request.type)} · ${_requestStatusLabel(request.status)}'), + subtitle: Text([ + 'Pedido em ${_formatDate(request.createdAt.toLocal())} · ' + 'resposta até ${_formatDate(request.dueAt.toLocal())}', + if (request.details != null) '"${request.details}"', + ].join('\n')), + ), + ), + const SizedBox(height: 8), + OutlinedButton.icon( + key: const Key('request_deletion_button'), + onPressed: _busy || openDeletion ? null : _requestDeletion, + icon: const Icon(Icons.delete_outline), + label: Text(openDeletion ? 'Exclusão já solicitada — em análise' : 'Solicitar exclusão dos dados'), + ), + const SizedBox(height: 8), + OutlinedButton.icon( + key: const Key('request_correction_button'), + onPressed: _busy ? null : _requestCorrection, + icon: const Icon(Icons.edit_note_outlined), + label: const Text('Solicitar correção'), + ), +``` + +Atualizar o texto introdutório da tela para: `'Confirmação de que seus dados pessoais estão sendo tratados pelo SinalACS, o que está cadastrado, suas escolhas de consentimento, pedidos de correção ou exclusão, e uma cópia para guardar.'` + +- [ ] **Passo 6: Rodar e ver passar** + +Run: `cd apps/patient && flutter analyze && flutter test` +Expected: tudo verde. + +- [ ] **Passo 7: Commit** + +```bash +git add apps/patient/lib/app/app.dart apps/patient/test/patient_app_mvp_test.dart +git commit -m "feat(paciente): pedidos de exclusão e correção em Meus dados (LGPD-RF08)" +``` + +--- + +### Tarefa 7: Documentação e verificação final + +**Arquivos:** +- Modificar: `PROGRESS.md`, `CLAUDE.md`, `backend/CLAUDE.md` + +- [ ] **Passo 1: Contagens de schema** + +Medir o número de tabelas de domínio no `definition.sql` da migração nova: + +```bash +cd backend/sinalacs_server && grep -c '^CREATE TABLE "' migrations/$(ls migrations | grep -v registry | sort | tail -1)/definition.sql +``` + +Em `backend/CLAUDE.md` (seção `models/`), trocar `16 tables, 6 enums (...)` pelo número medido de tabelas de domínio (esperado: 17). Acrescentar `DataSubjectRequestType` e `DataSubjectRequestStatus` à lista de enums, que passam a ser 8. Em `CLAUDE.md` (raiz), trocar `16 domain tables` pelo mesmo número. Na lista de RPCs do `Project overview` de `CLAUDE.md`, trocar `patients` (`listMicroArea`, `myData`, chronic conditions) por `patients` (`listMicroArea`, `myData`, chronic conditions, `updateConsent`, `requestDataDeletion`, `requestDataCorrection`). + +- [ ] **Passo 2: `PROGRESS.md`** + +Acrescentar uma seção no fim: + +```markdown +## Direitos do titular no app paciente — LGPD-RF05 e LGPD-RF08 (2026-09-28) + +"Meus dados" deixou de ser só leitura. O paciente agora: + +- **concede ou revoga** por conta própria as duas finalidades opcionais + (`localReminders`, `segmentedPush`) — `patients.updateConsent` grava uma + linha nova assinada em `consent_logs` (append-only; a assinatura sai de + `signedConsentLog`, a mesma função do onboarding). Revogar pede confirmação + explícita. Revogar lembretes cancela na hora os lembretes agendados no + aparelho, e o espelho local (`ConsentPreferences`) passa a ser alinhado ao + servidor sempre que "Meus dados" carrega — o que também resolve o aparelho que + entrou pelo login OTP sem passar pelo onboarding; +- **pede exclusão** (`patients.requestDataDeletion`, idempotente enquanto houver + uma aberta) ou **correção** (`patients.requestDataCorrection`, texto livre de + até 500 caracteres, cifrado com AES-256-GCM em `data_subject_requests`), e vê + a situação e o prazo (15 dias) de cada pedido. + +O que **não** foi feito, de propósito: + +- **Ninguém atende os pedidos.** `status` só é escrito como `open`: o backoffice + (`apps/admin`) ainda roda sobre `MockAdminDataSource`. O prazo de 15 dias é + exibido mas não é cumprido por sistema nenhum. **Dono:** quem der backend ao + admin. +- **`healthDataProcessing` não tem interruptor.** É a base legal do app inteiro, + inclusive do alerta de emergência; `updateConsent` recusa essa finalidade com + `DataRightsException` e aponta para o pedido de exclusão. Parar o tratamento + depois da exclusão atendida (LGPD-RF07, "em até 15 dias") depende do mesmo + atendimento acima. +- **Corrida de dois pedidos de exclusão simultâneos.** A idempotência é + "procura aberto, senão cria", sem índice único parcial (o Serverpod não + declara `WHERE` em índice). Dois pedidos concorrentes podem gerar duas linhas + abertas; no app, o botão desabilitado com o pedido em voo cobre o toque duplo. +- **`segmentedPush` é registrado mas não tem efeito**: não há projeto Firebase + (RF14). A descrição na tela diz isso. +``` + +- [ ] **Passo 3: Verificação completa** + +Run: +```bash +cd backend/sinalacs_server && dart analyze && dart test +cd ../../apps/patient && flutter analyze && flutter test +cd ../acs && flutter analyze && flutter test +cd ../.. && ./scripts/qa/ci_invariants.sh +``` +Expected: tudo verde. Registrar as contagens de testes impressas (backend, paciente) para a mensagem final. + +- [ ] **Passo 4: Grafo e commit** + +```bash +graphify update . +git add PROGRESS.md CLAUDE.md backend/CLAUDE.md graphify-out +git commit -m "docs(lgpd): registra os direitos do titular no app paciente e o que ficou de fora" +``` + +- [ ] **Passo 5 (opcional, se a stack local estiver de pé): verificação ponta a ponta** + +Use a skill `validacao-e2e`. Com `docker compose up --build`, abra o app paciente no emulador, entre com um paciente do seed, revogue "Lembretes neste aparelho" e peça uma correção. Confira no Postgres: `SELECT purpose, action, "ipHash" FROM consent_logs ORDER BY timestamp DESC LIMIT 1;` e `SELECT "requestType", status, left("detailsEncrypted", 12) FROM data_subject_requests;`. O texto da correção **não** pode aparecer em claro. diff --git a/docs/superpowers/plans/2026-09-29-aceite-do-termo-no-login-otp.md b/docs/superpowers/plans/2026-09-29-aceite-do-termo-no-login-otp.md new file mode 100644 index 00000000..ea678671 --- /dev/null +++ b/docs/superpowers/plans/2026-09-29-aceite-do-termo-no-login-otp.md @@ -0,0 +1,710 @@ +# Aceite do Termo de Uso no login OTP — Plano de Implementação + +> **Para agentes:** SUB-SKILL OBRIGATÓRIA: use superpowers:subagent-driven-development (recomendado) ou superpowers:executing-plans para executar este plano tarefa a tarefa. Os passos usam checkbox (`- [ ]`). + +**Objetivo:** todo paciente que entra por CPF + OTP (RF01) e ainda não aceitou a versão vigente do Termo de Uso e da Política de Privacidade é convidado a aceitar logo após o login — o que também dá o reaceite quando `legalDocumentsVersion` mudar. + +**Arquitetura:** o backend ganha `patients.acceptTermsOfUse`, que grava `ConsentPurpose.termsOfUse` = `granted` com `consentPolicyVersion`, na mesma trilha assinada de `updateConsent` (que continua recusando esse propósito). O app, depois de `verifyOtp`, lê `myData()`; se a última linha de `termsOfUse` não for `granted` na versão vigente, mostra `TermsAcceptanceScreen`. A tela **não bloqueia**: tem "Agora não" e, se `myData` falhar, o app entra direto — o alerta de emergência nunca fica atrás de um aceite. + +**Stack:** Serverpod 3.4.13 (backend), Flutter 3.44 (`apps/patient`). + +**Spec:** `spec/lgpd_design.md` (LGPD-RF18, ~linha 249) e `spec/PRD_system.md` (RF01). Invariante do projeto (`CLAUDE.md`): "Red alerts must never be silently dropped" — é por ele que o aceite não é um portão duro. + +**Escopo decidido:** o outro item pedido, "sessão do onboarding de 1h", **já está no código** (`onboarding_endpoint.dart` emite `AuthEndpoint.patientSessionLifetime`; `onboarding_endpoint_test.dart:319` o prende). Só os textos ficaram velhos; a Tarefa 3 os corrige. + +## Global Constraints + +- Nunca editar à mão `backend/sinalacs_server/lib/src/generated/`, `backend/sinalacs_client/`, `migrations/` nem `test/integration/test_tools/serverpod_test_tools.dart`; regenerar com `export PATH=$PATH:~/.pub-cache/bin; cd backend/sinalacs_server && serverpod generate`. +- Não há mudança de modelo (`.spy.yaml`), logo `serverpod create-migration` deve dizer "No changes detected" — se criar migração, algo saiu do plano. +- Sem dado real de paciente em testes, logs ou seed: só valores sintéticos. +- Texto de UI e docs em português. Commits terminam com `Co-Authored-By: Claude Sonnet 5.5 `. +- `flutter analyze` limpo (infos incluídas) em `apps/patient`; `dart analyze` do backend fica no baseline de 41 infos. +- Não rodar `dart format` em `apps/patient/lib/app/app.dart`. +- Banco de teste: `docker compose --profile test up -d postgres-test`. + +## Review Focus + +1. **`myData()` falha logo após o login** → o app entra na tela inicial, sem travar o paciente. Teste na Tarefa 2 (`falha ao ler os consentimentos não impede a entrada`). +2. **Paciente toca "Agora não"** → chega à tela inicial e o aviso volta no próximo login; nenhuma linha é gravada. Teste na Tarefa 2. +3. **Já aceitou a versão vigente** → nenhuma tela extra, uma só chamada a `myData()`. Teste na Tarefa 2 e teste puro na Tarefa 2 (`needsTermsAcceptance`). +4. **Aceitou uma versão anterior** → o aviso aparece de novo. Teste puro na Tarefa 2. +5. **Token de ACS chamando `acceptTermsOfUse`** → recusado, nenhuma linha em `consent_logs`. Testes na Tarefa 1 (unitário e integração). +6. **Falha de rede ao aceitar** → mensagem visível, botão volta a ficar utilizável, e "Agora não" continua disponível. Teste na Tarefa 2. + +--- + +### Tarefa 1: `patients.acceptTermsOfUse` no backend + +**Files:** +- Modify: `backend/sinalacs_server/lib/src/application/patients/data_subject_rights_service.dart` +- Modify: `backend/sinalacs_server/lib/src/endpoints/patients_endpoint.dart` +- Regenerar: `backend/sinalacs_server/lib/src/generated/`, `backend/sinalacs_client/`, `test/integration/test_tools/serverpod_test_tools.dart` +- Test: `backend/sinalacs_server/test/unit/data_subject_rights_service_test.dart`, `backend/sinalacs_server/test/integration/data_subject_rights_endpoint_test.dart` + +**Interfaces:** +- Consumes: `DataSubjectRightsStore.recordConsent`, `consentPolicyVersion`, `_requirePatient` (já existentes). +- Produces: `Future DataSubjectRightsService.acceptTermsOfUse(AuthenticatedUser user)`; RPC `Future patients.acceptTermsOfUse(Session, {required String accessToken})`, no cliente `client.patients.acceptTermsOfUse(accessToken: ...)`. + +- [ ] **Step 1: Testes que falham** + +Em `test/unit/data_subject_rights_service_test.dart`, dentro de `main()`, depois do grupo `updateConsent (LGPD-RF05)`: + +```dart + group('acceptTermsOfUse (LGPD-RF18)', () { + test('grava "granted" para termsOfUse com a versão vigente e audita', () async { + final record = await service.acceptTermsOfUse(_patient); + + final entry = store.consents.single; + expect(entry.userId, _patientId); + expect(entry.purpose, ConsentPurpose.termsOfUse); + expect(entry.action, 'granted'); + expect(entry.version, consentPolicyVersion); + expect(entry.timestamp, _now); + expect(record.purpose, 'termsOfUse'); + expect(record.action, 'granted'); + expect(audit.events.single.resourceType, 'consent_log'); + }); + + test('só paciente aceita: ACS é recusado sem gravar nada', () async { + await expectLater(service.acceptTermsOfUse(_acs), throwsA(isA())); + expect(store.consents, isEmpty); + expect(audit.events, isEmpty); + }); + }); +``` + +Em `test/integration/data_subject_rights_endpoint_test.dart`, depois do teste `updateConsent recusa a finalidade obrigatória sem gravar nada`: + +```dart + test('acceptTermsOfUse grava termsOfUse assinado e myData passa a mostrá-lo', () async { + final session = sessionBuilder.build(); + await _seed(session); + final token = await patientToken(); + + final record = await endpoints.patients.acceptTermsOfUse(sessionBuilder, accessToken: token); + expect(record.purpose, 'termsOfUse'); + expect(record.action, 'granted'); + + final row = (await ConsentLog.db.find( + session, + where: (t) => t.userId.equals(UuidValue.fromString(_patientId)), + )) + .single; + expect(row.purpose, 'termsOfUse'); + expect(row.version, consentPolicyVersion); + expect( + row.signature, + ConsentSignature(secret: _chainSecret).compute( + userId: _patientId, + purpose: row.purpose, + action: row.action, + version: row.version, + timestamp: row.timestamp, + ), + ); + + final overview = await endpoints.patients.myData(sessionBuilder, accessToken: token); + expect(overview.consents.last.purpose, 'termsOfUse'); + }); + + test('acceptTermsOfUse recusa token de ACS sem gravar nada', () async { + final session = sessionBuilder.build(); + await _seed(session); + final acsToken = + (await endpoints.auth.developmentLogin(sessionBuilder, role: 'acs')).accessToken; + + await expectLater( + endpoints.patients.acceptTermsOfUse(sessionBuilder, accessToken: acsToken), + throwsA(isA()), + ); + expect(await ConsentLog.db.count(session), 0); + }); +``` + +Se `consentPolicyVersion` ainda não estiver importado no teste de integração, acrescente `import 'package:sinalacs_server/src/application/onboarding/onboarding_service.dart' show consentPolicyVersion;`. + +- [ ] **Step 2: Ver falhar** + +Run: `cd backend/sinalacs_server && dart test test/unit/data_subject_rights_service_test.dart` +Expected: FAIL na compilação — `acceptTermsOfUse` não existe em `DataSubjectRightsService`. + +- [ ] **Step 3: Serviço** + +Em `data_subject_rights_service.dart`, extrair a gravação de `updateConsent` para um método privado e reusá-la. Substituir, em `updateConsent`, o trecho de `final now = ...` até o `return ConsentRecordSnapshot(...)` por `return _record(user, purpose: purpose, action: granted ? 'granted' : 'denied');` e acrescentar: + +```dart + /// Aceite explícito do Termo de Uso e da Política de Privacidade por quem + /// entrou por CPF + OTP sem passar pelo onboarding (LGPD-RF18) — ou que + /// aceitou uma versão anterior. É a única via de escrita de `termsOfUse` + /// fora do cadastro: [updateConsent] continua recusando esse propósito, para + /// que o termo não vire uma chave liga/desliga no painel. + Future acceptTermsOfUse(AuthenticatedUser user) async { + _requirePatient(user); + return _record(user, purpose: ConsentPurpose.termsOfUse, action: 'granted'); + } + + Future _record( + AuthenticatedUser user, { + required ConsentPurpose purpose, + required String action, + }) async { + final now = _clock().toUtc(); + await _store.recordConsent(ConsentLogEntry( + userId: user.id, + purpose: purpose, + action: action, + version: consentPolicyVersion, + timestamp: now, + )); + await _audit.recordSafely(AuditEvent( + userId: user.id, + actionType: 'write', + resourceType: 'consent_log', + result: 'granted', + )); + return ConsentRecordSnapshot( + purpose: purpose.name, + action: action, + version: consentPolicyVersion, + timestamp: now, + ); + } +``` + +Em `updateConsent`, `_requirePatient(user)` e as duas recusas continuam antes do `return _record(...)`. + +- [ ] **Step 4: Endpoint** + +Em `patients_endpoint.dart`, logo depois de `updateConsent`: + +```dart + /// Aceite do Termo de Uso e da Política de Privacidade vigentes (LGPD-RF18) + /// por quem entrou por OTP sem passar pelo onboarding, ou aceitou uma versão + /// anterior. Só paciente; grava uma linha nova e assinada em `consent_logs`. + Future acceptTermsOfUse( + Session session, { + required String accessToken, + }) async { + final user = authenticate(accessToken); + + try { + final record = await AlertRuntime.instance + .dataSubjectRightsServiceFor(session) + .acceptTermsOfUse(user); + return PatientConsentRecord( + purpose: record.purpose, + action: record.action, + version: record.version, + timestamp: record.timestamp, + ); + } on StateError catch (error) { + throw AlertPermissionException(message: error.message); + } + } +``` + +- [ ] **Step 5: Regenerar** + +Run: `export PATH=$PATH:~/.pub-cache/bin; cd backend/sinalacs_server && serverpod generate && serverpod create-migration` +Expected: geração sem erro; `create-migration` diz "No changes detected" e não cria pasta em `migrations/`. + +- [ ] **Step 6: Ver passar e suíte** + +Run: `cd backend/sinalacs_server && docker compose --profile test up -d postgres-test && dart test` +Expected: PASS, todos verdes (329 + 4 novos = 333). + +- [ ] **Step 7: Commit** + +```bash +git add backend/sinalacs_server backend/sinalacs_client +git commit -m "feat(backend): patients.acceptTermsOfUse para o aceite do termo fora do onboarding (LGPD-RF18) + +Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Tarefa 2: aceite depois do login OTP, no app paciente + +**Files:** +- Create: `apps/patient/lib/core/legal/terms_acceptance.dart` +- Modify: `apps/patient/lib/core/network/backend_client.dart` (interface, `MisconfiguredBackend`, `BackendClient`) +- Modify: `apps/patient/lib/app/legal_screens.dart` (`TermsAcceptanceScreen`) +- Modify: `apps/patient/lib/app/app.dart` (`_entrar`) +- Test: `apps/patient/test/terms_acceptance_test.dart` (novo), `apps/patient/test/support/fake_patient_backend.dart`, `apps/patient/test/terms_gate_flow_test.dart` (novo) + +**Interfaces:** +- Consumes: `PatientConsentRecord({purpose: String, action: String, version: String, timestamp: DateTime})`; `legalDocumentsVersion`; `LegalDocumentsScreen`; `BackendScope.of(context).myData()`. +- Produces: + - `bool needsTermsAcceptance(List consents)` — `true` se a linha mais recente (por `timestamp`) com `purpose == 'termsOfUse'` não existir, não for `granted` ou não tiver `version == legalDocumentsVersion`. + - `Future PatientBackend.acceptTermsOfUse()`. + - `TermsAcceptanceScreen({required VoidCallback onContinue})`, keys `terms_gate_read_button`, `terms_gate_checkbox`, `terms_gate_accept_button`, `terms_gate_later_button`, `terms_gate_error`. + - No fake: `acceptTermsCalls` (int), `acceptTermsFailure` (`BackendFailure?`). + +- [ ] **Step 1: Testes que falham** + +Criar `apps/patient/test/terms_acceptance_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_client/sinalacs_client.dart' show PatientConsentRecord; +import 'package:sinalacs_patient/core/legal/legal_documents.dart'; +import 'package:sinalacs_patient/core/legal/terms_acceptance.dart'; + +PatientConsentRecord linha(String action, String version, int minuto, {String purpose = 'termsOfUse'}) => + PatientConsentRecord( + purpose: purpose, + action: action, + version: version, + timestamp: DateTime.utc(2026, 9, 29, 12, minuto), + ); + +void main() { + test('sem nenhuma linha de termsOfUse, precisa aceitar', () { + expect(needsTermsAcceptance(const []), isTrue); + expect( + needsTermsAcceptance([linha('granted', '2026.1', 0, purpose: 'localReminders')]), + isTrue, + ); + }); + + test('aceite da versão vigente dispensa o aviso', () { + expect(needsTermsAcceptance([linha('granted', legalDocumentsVersion, 0)]), isFalse); + }); + + test('aceite de versão anterior pede de novo', () { + expect(needsTermsAcceptance([linha('granted', '2025.9', 0)]), isTrue); + }); + + test('vale a linha mais recente, seja qual for a ordem da lista', () { + final velha = linha('granted', '2025.9', 0); + final nova = linha('granted', legalDocumentsVersion, 5); + expect(needsTermsAcceptance([nova, velha]), isFalse); + expect(needsTermsAcceptance([velha, nova]), isFalse); + }); + + test('linha mais recente que não é "granted" pede de novo', () { + expect( + needsTermsAcceptance([ + linha('granted', legalDocumentsVersion, 0), + linha('denied', legalDocumentsVersion, 5), + ]), + isTrue, + ); + }); +} +``` + +Criar `apps/patient/test/terms_gate_flow_test.dart`. Antes de escrever, **abrir `test/patient_app_mvp_test.dart` e copiar o helper `login(tester)`** (o mesmo que o teste "menu Mais abre Privacidade e termos" usa) para dentro deste arquivo, pois helpers de teste não são importáveis entre arquivos: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_client/sinalacs_client.dart' show PatientConsentRecord; +import 'package:sinalacs_patient/app/app.dart'; +import 'package:sinalacs_patient/core/legal/legal_documents.dart'; +import 'package:sinalacs_patient/core/network/backend_client.dart'; + +import 'support/fake_patient_backend.dart'; + +// (colar aqui o helper `login(tester)` de patient_app_mvp_test.dart) + +Future tapKey(WidgetTester tester, String key) async { + final finder = find.byKey(Key(key)); + await tester.ensureVisible(finder); + await tester.pumpAndSettle(); + await tester.tap(finder); + await tester.pumpAndSettle(); +} + +/// Paciente que ainda não aceitou nada: é o de quem entrou por OTP sem onboarding. +FakePatientBackend semAceite() { + final backend = FakePatientBackend(); + backend.myDataResult = backend.myDataResult.copyWith(consents: const []); + return backend; +} + +void main() { + testWidgets('sem aceite registrado, o login leva à tela de aceite', (tester) async { + final backend = semAceite(); + await tester.pumpWidget(SinalAcsApp(backend: backend)); + await login(tester); + + expect(find.byKey(const Key('terms_gate_accept_button')), findsOneWidget); + expect(find.text('Registrar alerta de urgência'), findsNothing); + }); + + testWidgets('aceitar exige marcar a caixa, grava uma vez e segue para a tela inicial', (tester) async { + final backend = semAceite(); + await tester.pumpWidget(SinalAcsApp(backend: backend)); + await login(tester); + + final antes = tester.widget(find.byKey(const Key('terms_gate_accept_button'))); + expect(antes.onPressed, isNull); + + await tapKey(tester, 'terms_gate_checkbox'); + await tapKey(tester, 'terms_gate_accept_button'); + + expect(backend.acceptTermsCalls, 1); + expect(find.byKey(const Key('terms_gate_accept_button')), findsNothing); + }); + + testWidgets('"Agora não" entra sem gravar nada', (tester) async { + final backend = semAceite(); + await tester.pumpWidget(SinalAcsApp(backend: backend)); + await login(tester); + + await tapKey(tester, 'terms_gate_later_button'); + + expect(backend.acceptTermsCalls, 0); + expect(find.byKey(const Key('terms_gate_later_button')), findsNothing); + }); + + testWidgets('já aceitou a versão vigente: nenhuma tela extra, uma leitura', (tester) async { + final backend = FakePatientBackend(); + await tester.pumpWidget(SinalAcsApp(backend: backend)); + await login(tester); + + expect(find.byKey(const Key('terms_gate_accept_button')), findsNothing); + expect(backend.myDataCallCount, 1); + }); + + testWidgets('falha ao ler os consentimentos não impede a entrada', (tester) async { + // Emergência: o alerta de urgência não pode ficar atrás de um aceite. + final backend = semAceite()..myDataFailure = const BackendFailure('sem rede'); + await tester.pumpWidget(SinalAcsApp(backend: backend)); + await login(tester); + + expect(find.byKey(const Key('terms_gate_accept_button')), findsNothing); + expect(find.byType(NavigationBar), findsOneWidget); + }); + + testWidgets('falha ao aceitar mostra o erro, mantém "Agora não" e permite tentar de novo', (tester) async { + final backend = semAceite()..acceptTermsFailure = const BackendFailure('Sem conexão.'); + await tester.pumpWidget(SinalAcsApp(backend: backend)); + await login(tester); + + await tapKey(tester, 'terms_gate_checkbox'); + await tapKey(tester, 'terms_gate_accept_button'); + + expect(find.byKey(const Key('terms_gate_error')), findsOneWidget); + expect(find.byKey(const Key('terms_gate_later_button')), findsOneWidget); + + backend.acceptTermsFailure = null; + await tapKey(tester, 'terms_gate_accept_button'); + expect(backend.acceptTermsCalls, 2); + expect(find.byKey(const Key('terms_gate_accept_button')), findsNothing); + }); + + testWidgets('"Ler o Termo e a Política" abre os documentos', (tester) async { + await tester.pumpWidget(SinalAcsApp(backend: semAceite())); + await login(tester); + + await tapKey(tester, 'terms_gate_read_button'); + + expect(find.textContaining('Versão $legalDocumentsVersion'), findsWidgets); + }); +} +``` + +Ajustar `find.byType(NavigationBar)` para o widget que a tela inicial realmente usa, se `PatientHomeShell` não usar `NavigationBar` (conferir em `app.dart` antes de rodar). Se o construtor de `BackendFailure` tiver outra forma que `const BackendFailure('...')`, seguir o de `onboarding_flow_test.dart`. Se `myDataResult.copyWith` não aceitar `consents`, usar `myDataResult = PatientDataOverview(...)` copiando os campos do fake. + +Em `test/support/fake_patient_backend.dart`: no `myDataResult` padrão trocar `consents: const []` por uma linha de aceite vigente, para que os testes de login existentes não passem a ver a tela nova: + +```dart + consents: [ + PatientConsentRecord( + purpose: 'termsOfUse', + action: 'granted', + version: '2026.1', + timestamp: DateTime.utc(2026, 9, 1), + ), + ], +``` + +e, junto de `updateConsentCalls`: + +```dart + /// Chamadas a [acceptTermsOfUse]. + int acceptTermsCalls = 0; + BackendFailure? acceptTermsFailure; + + /// Como no servidor: uma linha `termsOfUse` `granted` a mais no histórico. + @override + Future acceptTermsOfUse() async { + acceptTermsCalls++; + final failure = acceptTermsFailure; + if (failure != null) throw failure; + final record = PatientConsentRecord( + purpose: 'termsOfUse', + action: 'granted', + version: '2026.1', + timestamp: DateTime.now().toUtc(), + ); + myDataResult = myDataResult.copyWith(consents: [...myDataResult.consents, record]); + return record; + } +``` + +- [ ] **Step 2: Ver falhar** + +Run: `cd apps/patient && flutter test test/terms_acceptance_test.dart test/terms_gate_flow_test.dart` +Expected: FAIL na compilação — `terms_acceptance.dart`, `acceptTermsOfUse` e `TermsAcceptanceScreen` não existem. + +- [ ] **Step 3: Regra pura** + +Criar `apps/patient/lib/core/legal/terms_acceptance.dart`: + +```dart +import 'package:sinalacs_client/sinalacs_client.dart' show PatientConsentRecord; +import 'package:sinalacs_patient/core/legal/legal_documents.dart'; + +/// `true` quando o paciente ainda precisa aceitar o Termo de Uso e a Política +/// de Privacidade vigentes (LGPD-RF18). +/// +/// Vale a linha **mais recente** de `termsOfUse`, pela data, e não a última da +/// lista: o histórico é append-only, mas a ordem em que o servidor o devolve +/// não é contrato. Só conta um `granted` na versão que o app exibe hoje +/// ([legalDocumentsVersion]) — é isso que faz o aviso voltar quando a versão +/// mudar. +bool needsTermsAcceptance(List consents) { + PatientConsentRecord? latest; + for (final record in consents) { + if (record.purpose != 'termsOfUse') continue; + if (latest == null || record.timestamp.isAfter(latest.timestamp)) latest = record; + } + return latest == null || + latest.action != 'granted' || + latest.version != legalDocumentsVersion; +} +``` + +- [ ] **Step 4: Camada de rede** + +Em `backend_client.dart`: na interface `PatientBackend`, depois de `updateConsent`: + +```dart + /// Aceita o Termo de Uso e a Política de Privacidade vigentes (LGPD-RF18), + /// para quem entrou por OTP sem passar pelo onboarding. O servidor grava uma + /// linha `termsOfUse` `granted` em `consent_logs`. + Future acceptTermsOfUse(); +``` + +Em `MisconfiguredBackend`, depois de `updateConsent`: `@override Future acceptTermsOfUse() async => _recusar();`. + +Em `BackendClient`, depois de `updateConsent`: + +```dart + @override + Future acceptTermsOfUse() async { + final token = await _requireToken(); + return _guard(() => _client.patients.acceptTermsOfUse(accessToken: token)); + } +``` + +- [ ] **Step 5: Tela** + +Em `legal_screens.dart`, acrescentar (ajustando imports se `LegalDocumentsScreen` estiver no mesmo arquivo, o que é o caso): + +```dart +/// Convite para aceitar o Termo de Uso e a Política de Privacidade vigentes, +/// mostrado depois do login por OTP a quem ainda não aceitou a versão atual +/// (LGPD-RF18). +/// +/// **Não é um portão.** "Agora não" segue para a tela inicial e o aviso volta +/// no próximo login: um paciente em emergência precisa chegar ao alerta de +/// urgência sem ler nada antes (invariante "red alerts never dropped"). +class TermsAcceptanceScreen extends StatefulWidget { + const TermsAcceptanceScreen({required this.onContinue, super.key}); + + /// Chamado depois do aceite gravado, ou ao escolher "Agora não". + final VoidCallback onContinue; + + @override + State createState() => _TermsAcceptanceScreenState(); +} + +class _TermsAcceptanceScreenState extends State { + bool _checked = false; + bool _busy = false; + String? _error; + + Future _accept() async { + setState(() { + _busy = true; + _error = null; + }); + try { + await BackendScope.of(context).acceptTermsOfUse(); + if (!mounted) return; + widget.onContinue(); + } on BackendFailure catch (failure) { + if (!mounted) return; + setState(() { + _busy = false; + _error = failure.message; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Termo de Uso e Privacidade')), + body: SafeArea( + child: ListView( + padding: const EdgeInsets.all(24), + children: [ + const Text( + 'Atualizamos o Termo de Uso e a Política de Privacidade ' + '(versão $legalDocumentsVersion). Leia e aceite para continuar ' + 'usando o app com tudo em dia.', + ), + const SizedBox(height: 16), + OutlinedButton.icon( + key: const Key('terms_gate_read_button'), + onPressed: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const LegalDocumentsScreen()), + ), + style: OutlinedButton.styleFrom(minimumSize: const Size(48, 52)), + icon: const Icon(Icons.description_outlined), + label: const Text('Ler o Termo e a Política'), + ), + CheckboxListTile( + key: const Key('terms_gate_checkbox'), + value: _checked, + onChanged: _busy ? null : (value) => setState(() => _checked = value ?? false), + controlAffinity: ListTileControlAffinity.leading, + title: const Text('Li e aceito o Termo de Uso e a Política de Privacidade.'), + ), + if (_error != null) + Semantics( + liveRegion: true, + child: Text( + _error!, + key: const Key('terms_gate_error'), + style: const TextStyle(color: PatientColors.dangerOnSurface), + ), + ), + const SizedBox(height: 16), + FilledButton( + key: const Key('terms_gate_accept_button'), + onPressed: _checked && !_busy ? _accept : null, + style: FilledButton.styleFrom(minimumSize: const Size(48, 52)), + child: const Text('Aceitar e continuar'), + ), + const SizedBox(height: 8), + TextButton( + key: const Key('terms_gate_later_button'), + onPressed: _busy ? null : widget.onContinue, + style: TextButton.styleFrom(minimumSize: const Size(48, 52)), + child: const Text('Agora não'), + ), + ], + ), + ), + ); + } +} +``` + +Conferir os imports já presentes em `legal_screens.dart` (`BackendScope`, `BackendFailure`, `PatientColors`) e acrescentar os que faltarem. Se `dangerOnSurface` não for o token de texto de erro que as telas vizinhas usam, seguir o token que `_PatientLoginScreenState` usa para `_error`. + +- [ ] **Step 6: Ligar ao login** + +Em `app.dart`, em `_PatientLoginScreenState._entrar`, trocar o `Navigator.of(context).pushReplacement(MaterialPageRoute(builder: (_) => const PatientHomeShell(...)))` que vem logo depois de `verifyOtp` por: + +```dart + final needsTerms = await _needsTerms(); + if (!mounted) return; + final home = MaterialPageRoute( + builder: (_) => const PatientHomeShell(initialDestination: PatientDestination.triage), + ); + Navigator.of(context).pushReplacement( + needsTerms + ? MaterialPageRoute( + builder: (routeContext) => TermsAcceptanceScreen( + onContinue: () => Navigator.of(routeContext).pushReplacement(home), + ), + ) + : home, + ); +``` + +e acrescentar ao `State`: + +```dart + /// Quem entrou por OTP sem onboarding, ou com aceite de versão anterior, + /// recebe o convite ao aceite (LGPD-RF18). Falhou a leitura → entra direto: + /// o alerta de emergência não espera por um aceite. + Future _needsTerms() async { + try { + final overview = await BackendScope.of(context).myData(); + return needsTermsAcceptance(overview.consents); + } on BackendFailure { + return false; + } + } +``` + +Imports: `package:sinalacs_patient/core/legal/terms_acceptance.dart`. Manter o `Semantics`/`_busy` existentes de `_entrar`: o botão continua ocupado durante `myData()`. + +- [ ] **Step 7: Ver passar** + +Run: `cd apps/patient && flutter test test/terms_acceptance_test.dart test/terms_gate_flow_test.dart` +Expected: PASS (5 + 7). + +- [ ] **Step 8: Suíte completa e commit** + +Run: `cd apps/patient && flutter analyze && flutter test` +Expected: `No issues found!`, tudo verde. Se testes antigos de "Meus Dados" contarem linhas de consentimento e quebrarem por causa da linha `termsOfUse` que o fake agora traz, ajuste a contagem esperada e registre no ledger — o histórico real do servidor também a mostra. + +```bash +git add apps/patient +git commit -m "feat(paciente): convite ao aceite do Termo de Uso depois do login por OTP (LGPD-RF18) + +Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Tarefa 3: Textos velhos da sessão do onboarding e registro + +**Files:** +- Modify: `backend/sinalacs_server/lib/src/endpoints/auth_endpoint.dart` (comentário ~linhas 154–160) +- Modify: `backend/sinalacs_server/lib/src/endpoints/onboarding_endpoint.dart` (comentário ~linhas 63–67) +- Modify: `apps/CLAUDE.md` (parágrafo do login do paciente), `PROGRESS.md`, `spec/lgpd_design.md` + +**Interfaces:** Consumes: tudo acima. Produces: nada de código. + +- [ ] **Step 1: Comentários** + +Em `auth_endpoint.dart`, trocar a frase "não é o TTL de toda sessão de paciente: há dois caminhos de emissão, e o de onboarding (`onboarding_endpoint.dart`) ainda emite o padrão de 15 minutos, lacuna do RF02 registrada no plano." por "os dois caminhos de emissão do paciente — este e `onboarding.completeEnrollment` — usam `patientSessionLifetime`; `onboarding_endpoint_test.dart` prende o segundo." + +Em `onboarding_endpoint.dart`, trocar o comentário "não há renovação silenciosa — 15 minutos padrão bastaria pouco. Ver PROGRESS.md 'Um defeito do RF02 que esta entrega mediu'." por "não há renovação silenciosa, então a sessão usa `patientSessionLifetime` (1 hora), como o login por OTP. O TTL de 15 minutos era um defeito do RF02, corrigido — ver PROGRESS.md." + +- [ ] **Step 2: apps/CLAUDE.md** + +No parágrafo "The patient login is passwordless now (RF01)…", remover a frase "One known gap: the onboarding path (`onboarding.completeEnrollment`, RF02) still issues the 15-minute default and has nothing to renew from — registered with an owner in [PROGRESS.md](../../../PROGRESS.md). One stale comment comes with it: the doc of `isExpired` … not the role." e acrescentar no lugar: "Both patient session paths — the OTP login and `onboarding.completeEnrollment` — issue the same 1-hour token (`AuthEndpoint.patientSessionLifetime`). After the OTP login the app reads `myData()` and, when the latest `termsOfUse` consent row is not `granted` in `legalDocumentsVersion`, shows `TermsAcceptanceScreen` (`legal_screens.dart`); it is a prompt, not a gate — \"Agora não\" and a failed `myData()` both go straight to the home, so the emergency alert never waits for an acceptance. `patients.acceptTermsOfUse` is the only writer of `termsOfUse` outside onboarding; `updateConsent` still refuses it." + +- [ ] **Step 3: PROGRESS.md e spec** + +Em `PROGRESS.md`, junto da linha ~991 (`onboarding_endpoint.dart:62 faz issueToken(user) — o default de 15 minutos`), acrescentar uma linha: "> **Resolvido:** `completeEnrollment` já emite `patientSessionLifetime`; o teste `onboarding_endpoint_test.dart` prende o valor. Esta seção descreve o defeito como foi medido." E, ao final do arquivo, uma seção `## Aceite do termo no login OTP (2026-09-29)` com: o que existe (`patients.acceptTermsOfUse`, `TermsAcceptanceScreen`, regra `needsTermsAcceptance`), o que ficou de fora de propósito (o aceite não é portão duro por causa do alerta de emergência; aviso com 15 dias de antecedência e revisão jurídica do texto seguem pendentes; toda chamada a `acceptTermsOfUse` grava uma linha nova, sem checar se já havia aceite da versão vigente — o app só chama quando `needsTermsAcceptance`), e as contagens reais de teste depois da entrega (rode as suítes e copie os números). + +Em `spec/lgpd_design.md`, na nota "Estado (2026-09-29)" logo após LGPD-RF18, acrescentar: "Pacientes que entram por OTP sem onboarding são convidados a aceitar no primeiro login (`patients.acceptTermsOfUse`); o convite não é obrigatório para acessar o alerta de emergência." + +- [ ] **Step 4: Verificações e commit** + +Run: +```bash +./scripts/qa/ci_invariants.sh +graphify update . +``` +Expected: invariantes OK; grafo atualizado. + +```bash +git add backend/sinalacs_server/lib/src/endpoints apps/CLAUDE.md PROGRESS.md spec/lgpd_design.md +git commit -m "docs: sessão do onboarding já é de 1h; registra o aceite do termo no login OTP + +Co-Authored-By: Claude Sonnet 5.5 " +``` diff --git a/docs/superpowers/plans/2026-09-29-fechamento-de-pendencias-do-paciente.md b/docs/superpowers/plans/2026-09-29-fechamento-de-pendencias-do-paciente.md new file mode 100644 index 00000000..891581f6 --- /dev/null +++ b/docs/superpowers/plans/2026-09-29-fechamento-de-pendencias-do-paciente.md @@ -0,0 +1,921 @@ +# Fechamento das pendências do app paciente — Plano de Implementação + +> **Para agentes:** SUB-SKILL OBRIGATÓRIA: use superpowers:subagent-driven-development (recomendado) ou superpowers:executing-plans para executar este plano tarefa a tarefa. Os passos usam checkbox (`- [ ]`). + +**Objetivo:** fechar os menores adiados das duas últimas entregas (QR do onboarding, aceite do termo) e as pendências dos direitos do titular (exclusão simultânea, texto de correção perdido, `resourceId` da auditoria de consentimento), sem criar feature nova. + +**Arquitetura:** o backend ganha `patients.hasAcceptedCurrentTerms` (um `bool`, sem ler o painel "Meus dados" inteiro), torna `acceptTermsOfUse` idempotente e serializa o pedido de exclusão com um advisory lock do Postgres; o app paciente troca a leitura de `myData()` no login por essa consulta e corrige três arestas de UI; o app do ACS esconde o convite quando ele expira. + +**Tech Stack:** Serverpod 3.4.13 (backend), Flutter 3.44 (`apps/patient`, `apps/acs`). + +**Spec:** `spec/lgpd_design.md` (LGPD-RF05, RF07, RF08, RF18) e `spec/PRD_system.md` (RF02). Invariante de `CLAUDE.md`: "Red alerts must never be silently dropped". + +**Fora do escopo, de propósito:** +- `ipHash`/`userAgent` `nao-aplicavel-` nas linhas de `consent_logs` do painel: é um marcador de ausência **deliberado** e documentado em `signed_consent_log.dart` (o request HTTP já é auditado em `audit_logs`). Guardar o IP do titular em `consent_logs` seria decisão de privacidade, não um conserto. A Tarefa 4 só corrige o texto do PROGRESS.md, que o chama de "placeholder". +- Backoffice que atende os pedidos, push (RF14) e `healthDataProcessing` sem interruptor: dependem de admin backend/Firebase. + +## Global Constraints + +- Nunca editar à mão `backend/sinalacs_server/lib/src/generated/`, `backend/sinalacs_client/`, `migrations/` nem `test/integration/test_tools/serverpod_test_tools.dart`; regenerar com `export PATH=$PATH:~/.pub-cache/bin; cd backend/sinalacs_server && serverpod generate`. +- Nenhuma mudança de modelo (`.spy.yaml`): `serverpod create-migration` deve dizer "No changes detected". Se criar migração, algo saiu do plano. +- Sem dado real de paciente em testes, logs ou seed: só valores sintéticos. +- Texto de UI e docs em português. Commits terminam com `Co-Authored-By: Claude Sonnet 5.5 `. +- `flutter analyze` limpo (infos incluídas) em `apps/patient` e `apps/acs`; `dart analyze` do backend fica no baseline de 41 infos. +- Não rodar `dart format` em nenhum `app.dart`. +- Banco de teste: `docker compose --profile test up -d postgres-test`. +- O alerta de urgência nunca espera por aceite, leitura ou timer desta entrega. + +## Review Focus + +1. **Dois pedidos de exclusão simultâneos** → exatamente uma linha aberta. Teste na Tarefa 1 (integração, `Future.wait`). +2. **Aceite de versão anterior, ou último registro `denied`** → o login mostra o aviso. Testes na Tarefa 1 (regra no serviço) e na Tarefa 2 (fluxo). +3. **`acceptTermsOfUse` chamado de novo já com aceite vigente** → nenhuma linha nova em `consent_logs`. Teste na Tarefa 1. +4. **Envio da correção falha** → ao reabrir o diálogo o texto digitado está lá; some depois de um envio bem-sucedido. Teste na Tarefa 2. +5. **Toque duplo em "Ler QR Code"** → uma leitura só. Teste na Tarefa 2. +6. **Convite expira com a tela aberta** → o QR some e a tela pede um novo; gerar outro zera o aviso. Teste na Tarefa 3. + +--- + +### Tarefa 1: backend — status do aceite, aceite idempotente, exclusão serializada, `resourceId` + +**Files:** +- Modify: `backend/sinalacs_server/lib/src/application/patients/data_subject_rights_service.dart` +- Modify: `backend/sinalacs_server/lib/src/infrastructure/database/orm_data_subject_rights_store.dart` +- Modify: `backend/sinalacs_server/lib/src/endpoints/patients_endpoint.dart` +- Regenerar: `lib/src/generated/`, `backend/sinalacs_client/`, `test/integration/test_tools/serverpod_test_tools.dart` +- Test: `backend/sinalacs_server/test/unit/data_subject_rights_service_test.dart`, `backend/sinalacs_server/test/integration/data_subject_rights_endpoint_test.dart` + +**Interfaces:** +- Consumes: `ConsentRecordSnapshot({purpose, action, version, timestamp})`, `DataSubjectRequestSnapshot`, `consentPolicyVersion`, `AuditEvent(resourceId:)`. +- Produces: + - `DataSubjectRightsStore.recordConsent(ConsentLogEntry) → Future` (id da linha gravada; era `Future`). + - `DataSubjectRightsStore.latestConsent(String userId, ConsentPurpose purpose) → Future` (a de maior `timestamp`). + - `DataSubjectRightsStore.createDeletionRequestIfNoneOpen({required String userId, required DateTime createdAt, required DateTime dueAt}) → Future<({DataSubjectRequestSnapshot request, bool created})>`. + - `Future DataSubjectRightsService.hasAcceptedCurrentTerms(AuthenticatedUser)`. + - RPC `Future patients.hasAcceptedCurrentTerms(Session, {required String accessToken})`; no cliente, `client.patients.hasAcceptedCurrentTerms(accessToken: ...)`. + +- [ ] **Step 1: Testes de unidade que falham** + +Em `test/unit/data_subject_rights_service_test.dart`, primeiro adapte o `FakeDataSubjectRightsStore` à nova interface (isto **não** compila até o Step 3, é esperado): + +```dart +class FakeDataSubjectRightsStore implements DataSubjectRightsStore { + final consents = []; + final requests = <({String userId, DataSubjectRequestSnapshot snapshot})>[]; + var _nextId = 1; + + @override + Future recordConsent(ConsentLogEntry entry) async { + consents.add(entry); + return 'consentimento-${consents.length}'; + } + + @override + Future latestConsent(String userId, ConsentPurpose purpose) async { + ConsentLogEntry? latest; + for (final e in consents) { + if (e.userId != userId || e.purpose != purpose) continue; + if (latest == null || e.timestamp.isAfter(latest.timestamp)) latest = e; + } + return latest == null + ? null + : ConsentRecordSnapshot( + purpose: latest.purpose.name, + action: latest.action, + version: latest.version, + timestamp: latest.timestamp, + ); + } + + @override + Future<({DataSubjectRequestSnapshot request, bool created})> createDeletionRequestIfNoneOpen({ + required String userId, + required DateTime createdAt, + required DateTime dueAt, + }) async { + final open = await findOpenRequest(userId, DataSubjectRequestType.deletion); + if (open != null) return (request: open, created: false); + final created = await createRequest( + userId: userId, + type: DataSubjectRequestType.deletion, + details: null, + createdAt: createdAt, + dueAt: dueAt, + ); + return (request: created, created: true); + } + + // findOpenRequest e createRequest: como já estão. +} +``` + +Depois, dentro do grupo `acceptTermsOfUse (LGPD-RF18)` acrescente, e em um grupo novo `hasAcceptedCurrentTerms`, estes testes: + +```dart + test('aceitar de novo com o aceite vigente não grava nada', () async { + await service.acceptTermsOfUse(_patient); + final again = await service.acceptTermsOfUse(_patient); + + expect(store.consents, hasLength(1)); + expect(audit.events, hasLength(1)); + expect(again.action, 'granted'); + expect(again.version, consentPolicyVersion); + }); + + test('aceite de versão anterior não conta: grava de novo', () async { + store.consents.add(ConsentLogEntry( + userId: _patientId, + purpose: ConsentPurpose.termsOfUse, + action: 'granted', + version: '2025.9', + timestamp: _now.subtract(const Duration(days: 30)), + )); + + await service.acceptTermsOfUse(_patient); + + expect(store.consents, hasLength(2)); + expect(store.consents.last.version, consentPolicyVersion); + }); +``` + +```dart + group('hasAcceptedCurrentTerms (LGPD-RF18)', () { + ConsentLogEntry linha(String action, String version, int minutos, + {ConsentPurpose purpose = ConsentPurpose.termsOfUse}) => + ConsentLogEntry( + userId: _patientId, + purpose: purpose, + action: action, + version: version, + timestamp: _now.add(Duration(minutes: minutos)), + ); + + test('sem nenhuma linha de termsOfUse, não aceitou', () async { + expect(await service.hasAcceptedCurrentTerms(_patient), isFalse); + store.consents.add(linha('granted', consentPolicyVersion, 0, purpose: ConsentPurpose.localReminders)); + expect(await service.hasAcceptedCurrentTerms(_patient), isFalse); + }); + + test('aceite da versão vigente conta', () async { + store.consents.add(linha('granted', consentPolicyVersion, 0)); + expect(await service.hasAcceptedCurrentTerms(_patient), isTrue); + }); + + test('aceite de versão anterior não conta', () async { + store.consents.add(linha('granted', '2025.9', 0)); + expect(await service.hasAcceptedCurrentTerms(_patient), isFalse); + }); + + test('vale a linha mais recente, seja qual for a ordem em que foram gravadas', () async { + store.consents.add(linha('granted', consentPolicyVersion, 5)); + store.consents.add(linha('granted', '2025.9', 0)); + expect(await service.hasAcceptedCurrentTerms(_patient), isTrue); + }); + + test('linha mais recente que não é "granted" não conta', () async { + store.consents.add(linha('granted', consentPolicyVersion, 0)); + store.consents.add(linha('denied', consentPolicyVersion, 5)); + expect(await service.hasAcceptedCurrentTerms(_patient), isFalse); + }); + + test('só paciente consulta: ACS é recusado', () async { + await expectLater(service.hasAcceptedCurrentTerms(_acs), throwsA(isA())); + }); + }); +``` + +No grupo `updateConsent (LGPD-RF05)`, acrescente: + +```dart + test('a linha de auditoria aponta para a linha de consentimento gravada', () async { + await service.updateConsent(_patient, purpose: ConsentPurpose.localReminders, granted: false); + + expect(audit.events.single.resourceId, 'consentimento-1'); + }); +``` + +E, no grupo do pedido de exclusão (procure `requestDeletion` no arquivo e ponha ao lado dos testes existentes): + +```dart + test('pedir exclusão de novo devolve o mesmo pedido e audita a repetição', () async { + final first = await service.requestDeletion(_patient); + final second = await service.requestDeletion(_patient); + + expect(second.id, first.id); + expect(store.requests, hasLength(1)); + expect(audit.events, hasLength(2)); + expect(audit.events.last.resourceId, first.id); + expect(audit.events.last.result, 'repeated'); + }); +``` + +- [ ] **Step 2: Ver falhar** + +Run: `cd backend/sinalacs_server && dart test test/unit/data_subject_rights_service_test.dart` +Expected: FAIL na compilação — o fake implementa métodos que a interface não tem e `hasAcceptedCurrentTerms` não existe. + +- [ ] **Step 3: Serviço** + +Em `data_subject_rights_service.dart`, na interface `DataSubjectRightsStore`: + +- trocar `Future recordConsent(ConsentLogEntry entry);` por `Future recordConsent(ConsentLogEntry entry);` e ajustar o doc: "…devolve o id da linha gravada, que a auditoria usa como `resourceId`."; +- acrescentar: + +```dart + /// A linha mais recente (por `timestamp`) daquela finalidade, ou `null`. + Future latestConsent(String userId, ConsentPurpose purpose); + + /// Cria o pedido de exclusão **só se** não houver um aberto, de forma atômica: + /// dois chamadores simultâneos resultam em uma única linha aberta, e o segundo + /// recebe a do primeiro com `created == false`. + Future<({DataSubjectRequestSnapshot request, bool created})> createDeletionRequestIfNoneOpen({ + required String userId, + required DateTime createdAt, + required DateTime dueAt, + }); +``` + +No serviço: + +```dart + bool _isCurrentAcceptance(ConsentRecordSnapshot? latest) => + latest != null && latest.action == 'granted' && latest.version == consentPolicyVersion; + + /// `true` quando a linha mais recente de `termsOfUse` é um `granted` na versão + /// vigente (LGPD-RF18). É o que o app consulta depois do login por OTP: um + /// `bool`, em vez do painel "Meus dados" inteiro — que também gravaria uma + /// linha de auditoria de leitura a cada login. + Future hasAcceptedCurrentTerms(AuthenticatedUser user) async { + _requirePatient(user); + return _isCurrentAcceptance(await _store.latestConsent(user.id, ConsentPurpose.termsOfUse)); + } +``` + +`acceptTermsOfUse` passa a ser idempotente: + +```dart + Future acceptTermsOfUse(AuthenticatedUser user) async { + _requirePatient(user); + final latest = await _store.latestConsent(user.id, ConsentPurpose.termsOfUse); + if (latest != null && _isCurrentAcceptance(latest)) return latest; + return _record(user, purpose: ConsentPurpose.termsOfUse, action: 'granted'); + } +``` + +Em `_record`, guardar o id e passá-lo à auditoria: `final id = await _store.recordConsent(...)` e `AuditEvent(..., resourceType: 'consent_log', resourceId: id, result: 'granted')`. + +`requestDeletion` passa a usar a operação atômica e a auditar também a repetição: + +```dart + Future requestDeletion(AuthenticatedUser user) async { + _requirePatient(user); + final now = _clock().toUtc(); + final result = await _store.createDeletionRequestIfNoneOpen( + userId: user.id, + createdAt: now, + dueAt: now.add(dataSubjectRequestDeadline), + ); + await _audit.recordSafely(AuditEvent( + userId: user.id, + actionType: 'write', + resourceType: 'data_subject_request', + resourceId: result.request.id, + result: result.created ? 'granted' : 'repeated', + )); + return result.request; + } +``` + +`_create` continua servindo só à correção. Se `findOpenRequest` ficar sem chamador no serviço, mantenha-o só se o fake ou o store ainda o usam; senão remova-o da interface, do ORM e do fake (rode `grep -rn findOpenRequest backend/`). + +- [ ] **Step 4: Store ORM** + +Em `orm_data_subject_rights_store.dart`: + +```dart + @override + Future recordConsent(ConsentLogEntry entry) async { + final row = await ConsentLog.db.insertRow( + _session(), + signedConsentLog(entry, signature: _signature, origin: 'painel-titular'), + ); + return row.id!.toString(); + } + + @override + Future latestConsent(String userId, ConsentPurpose purpose) async { + final row = await ConsentLog.db.findFirstRow( + _session(), + where: (t) => t.userId.equals(UuidValue.fromString(userId)) & t.purpose.equals(purpose.name), + orderBy: (t) => t.timestamp, + orderDescending: true, + ); + return row == null + ? null + : ConsentRecordSnapshot( + purpose: row.purpose, + action: row.action, + version: row.version, + timestamp: row.timestamp, + ); + } + + /// Serializa por titular com `pg_advisory_xact_lock`, o mesmo recurso que + /// `OrmAuditTrail` usa para a cadeia: o Serverpod não declara `WHERE` em + /// índice, então um índice único parcial ("um pedido aberto por titular") + /// não é possível. O lock solta sozinho no fim da transação. + @override + Future<({DataSubjectRequestSnapshot request, bool created})> createDeletionRequestIfNoneOpen({ + required String userId, + required DateTime createdAt, + required DateTime dueAt, + }) async { + final session = _session(); + final userUuid = UuidValue.fromString(userId); + final encrypted = await _cipher.encryptJson(null); + return session.db.transaction((transaction) async { + await session.db.unsafeExecute( + 'SELECT pg_advisory_xact_lock(hashtext(@key));', + parameters: QueryParameters.named({'key': 'exclusao:$userId'}), + transaction: transaction, + ); + final open = await DataSubjectRequest.db.findFirstRow( + session, + where: (t) => + t.userId.equals(userUuid) & + t.requestType.equals(DataSubjectRequestType.deletion) & + t.status.equals(DataSubjectRequestStatus.open), + orderBy: (t) => t.createdAt, + orderDescending: true, + transaction: transaction, + ); + if (open != null) { + return (request: dataSubjectRequestSnapshotOf(open, _cipher), created: false); + } + final row = await DataSubjectRequest.db.insertRow( + session, + DataSubjectRequest( + userId: userUuid, + requestType: DataSubjectRequestType.deletion, + detailsEncrypted: encrypted.ciphertextBase64, + detailsKeyVersion: encrypted.keyVersion, + status: DataSubjectRequestStatus.open, + createdAt: createdAt, + dueAt: dueAt, + ), + transaction: transaction, + ); + return (request: dataSubjectRequestSnapshotOf(row, _cipher), created: true); + }); + } +``` + +Se `dataSubjectRequestSnapshotOf` for assíncrono ou tiver outra assinatura, siga a que `findOpenRequest` usa hoje. `ConsentRecordSnapshot` já é importado ali? Se não, importe de `patient_data_overview_service.dart`. + +- [ ] **Step 5: Endpoint e regeneração** + +Em `patients_endpoint.dart`, logo depois de `acceptTermsOfUse`: + +```dart + /// Se o paciente já aceitou o Termo de Uso e a Política de Privacidade da + /// versão vigente (LGPD-RF18). O app consulta depois do login por OTP para + /// decidir se mostra o convite ao aceite — um `bool`, sem ler o painel + /// "Meus dados". Não grava auditoria: devolve ao próprio titular um fato + /// sobre o consentimento dele. + Future hasAcceptedCurrentTerms( + Session session, { + required String accessToken, + }) async { + final user = authenticate(accessToken); + + try { + return await AlertRuntime.instance + .dataSubjectRightsServiceFor(session) + .hasAcceptedCurrentTerms(user); + } on StateError catch (error) { + throw AlertPermissionException(message: error.message); + } + } +``` + +Run: `export PATH=$PATH:~/.pub-cache/bin; cd backend/sinalacs_server && serverpod generate && serverpod create-migration` +Expected: geração sem erro; "No changes detected". + +- [ ] **Step 6: Testes de integração** + +Em `test/integration/data_subject_rights_endpoint_test.dart`, junto dos testes de `acceptTermsOfUse` (mesmo `withServerpod` de rollback): + +```dart + test('hasAcceptedCurrentTerms: falso antes, verdadeiro depois de aceitar', () async { + final session = sessionBuilder.build(); + await _seed(session); + final token = await patientToken(); + + expect(await endpoints.patients.hasAcceptedCurrentTerms(sessionBuilder, accessToken: token), isFalse); + await endpoints.patients.acceptTermsOfUse(sessionBuilder, accessToken: token); + expect(await endpoints.patients.hasAcceptedCurrentTerms(sessionBuilder, accessToken: token), isTrue); + }); + + test('hasAcceptedCurrentTerms recusa token de ACS', () async { + final session = sessionBuilder.build(); + await _seed(session); + final acsToken = + (await endpoints.auth.developmentLogin(sessionBuilder, role: 'acs')).accessToken; + + await expectLater( + endpoints.patients.hasAcceptedCurrentTerms(sessionBuilder, accessToken: acsToken), + throwsA(isA()), + ); + }); + + test('acceptTermsOfUse duas vezes grava uma linha só', () async { + final session = sessionBuilder.build(); + await _seed(session); + final token = await patientToken(); + + await endpoints.patients.acceptTermsOfUse(sessionBuilder, accessToken: token); + await endpoints.patients.acceptTermsOfUse(sessionBuilder, accessToken: token); + + expect(await ConsentLog.db.count(session), 1); + }); +``` + +E, **no fim do arquivo**, um grupo novo com `rollbackDatabase: RollbackDatabase.disabled`, copiando o formato de `onboarding_endpoint_test.dart` (o grupo que termina na linha ~518: `_seedRace`/`_cleanupRace`, `try`/`finally` com limpeza manual). Leia aquele grupo antes e reproduza a semeadura e a limpeza, acrescentando à limpeza as linhas de `DataSubjectRequest` do paciente. O teste: + +```dart + test('dois pedidos de exclusão simultâneos deixam um só pedido aberto', () async { + final session = sessionBuilder.build(); + try { + // (semear como o grupo de corrida do onboarding: Ubs, MicroArea, User paciente) + final token = + (await endpoints.auth.developmentLogin(sessionBuilder, role: 'patient')).accessToken; + + Future attempt() async { + try { + return await endpoints.patients.requestDataDeletion(sessionBuilder, accessToken: token); + } catch (error) { + return error; + } + } + + final results = await Future.wait([attempt(), attempt(), attempt()]); + + expect(results.whereType(), hasLength(3), + reason: 'as três chamadas devem responder com um pedido, sem erro'); + final open = await DataSubjectRequest.db.find( + session, + where: (t) => + t.userId.equals(UuidValue.fromString(_patientId)) & + t.requestType.equals(DataSubjectRequestType.deletion) & + t.status.equals(DataSubjectRequestStatus.open), + ); + expect(open, hasLength(1)); + } finally { + // (limpeza manual, como o grupo de corrida do onboarding) + } + }); +``` + +Se o token de `developmentLogin` depender de uma linha semeada com id fixo (`_patientId`), use o mesmo `_seed` do arquivo dentro do `try` e limpe o que ele criou. + +- [ ] **Step 7: Ver o RED da corrida, depois passar** + +Antes do Step 4 aplicado (ou revertendo-o temporariamente com `git stash` no ORM), rode `dart test test/integration/data_subject_rights_endpoint_test.dart --name "simultâneos"` algumas vezes: sem o lock, o esperado é `Expected: <1>, Actual: <2 ou 3>`. **Se nunca falhar, registre no ledger que o RED da corrida não foi observável e siga — o teste fica como guarda de regressão.** + +Run: `cd backend/sinalacs_server && docker compose --profile test up -d postgres-test && dart test` +Expected: PASS, tudo verde (333 + os novos). + +- [ ] **Step 8: Commit** + +```bash +git add backend/sinalacs_server backend/sinalacs_client +git commit -m "feat(backend): status do aceite sem ler o painel, aceite idempotente, exclusão serializada (LGPD-RF08/RF18) + +Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Tarefa 2: app paciente — status do aceite, câmera, erro do QR, correção e testes + +**Files:** +- Modify: `apps/patient/lib/core/network/backend_client.dart` +- Modify: `apps/patient/lib/app/app.dart` +- Delete: `apps/patient/lib/core/legal/terms_acceptance.dart`, `apps/patient/test/terms_acceptance_test.dart` (a regra passou para o servidor e é testada na Tarefa 1) +- Modify: `apps/patient/test/support/fake_patient_backend.dart`, `apps/patient/test/terms_gate_flow_test.dart`, `apps/patient/test/patient_app_mvp_test.dart`, `apps/patient/test/onboarding_flow_test.dart` + +**Interfaces:** +- Consumes: `client.patients.hasAcceptedCurrentTerms(accessToken:)` da Tarefa 1; `legalDocumentsVersion`. +- Produces: `Future PatientBackend.hasAcceptedCurrentTerms()`; no fake `termsAccepted` (bool, padrão `true`), `termsStatusCalls`, `termsStatusFailure`, `termsStatusGate`. + +- [ ] **Step 1: Testes que falham** + +**Fake** (`test/support/fake_patient_backend.dart`), acrescente `import 'package:sinalacs_patient/core/legal/legal_documents.dart';` e: + +```dart + /// O que o servidor responderia a [hasAcceptedCurrentTerms]. + bool termsAccepted = true; + int termsStatusCalls = 0; + BackendFailure? termsStatusFailure; + + /// Quando definido, [hasAcceptedCurrentTerms] só responde depois que ele + /// completa — simula um backend lento ou pendurado. + Completer? termsStatusGate; + + @override + Future hasAcceptedCurrentTerms() async { + termsStatusCalls++; + await termsStatusGate?.future; + final failure = termsStatusFailure; + if (failure != null) throw failure; + return termsAccepted; + } +``` + +Ajustes no mesmo fake: remover `myDataGate` e o `await myDataGate?.future;` de `myData()` (a Tarefa 2 do plano anterior o criou; agora quem trava é `termsStatusGate`); voltar `consents:` do `myDataResult` padrão a `const []`; trocar todo `'2026.1'` por `legalDocumentsVersion` (em `updateConsent` e em `acceptTermsOfUse`); e em `acceptTermsOfUse` acrescentar `termsAccepted = true;` antes do `return record;`. + +**`terms_gate_flow_test.dart`**: troque `semAceite()` por + +```dart +FakePatientBackend semAceite() => FakePatientBackend()..termsAccepted = false; +``` + +e ajuste os testes que dependiam de `myData`: no de "falha ao ler os consentimentos", use `semAceite()..termsStatusFailure = const BackendFailure('sem rede')`; no da leitura pendurada, `semAceite()..termsStatusGate = Completer()`; no "já aceitou a versão vigente", troque as asserções por `expect(backend.termsStatusCalls, 1); expect(backend.myDataCallCount, 0);`. Acrescente: + +```dart + testWidgets('"Agora não" leva à tela inicial e nada é gravado', (tester) async { + final backend = semAceite(); + await tester.pumpWidget(SinalAcsApp(backend: backend)); + await login(tester); + + await tapKey(tester, 'terms_gate_later_button'); + + expect(find.byType(NavigationBar), findsOneWidget); + expect(backend.acceptTermsCalls, 0); + }); + + testWidgets('quem tocou "Agora não" vê o aviso de novo no login seguinte', (tester) async { + final backend = semAceite(); + await tester.pumpWidget(SinalAcsApp(backend: backend)); + await login(tester); + await tapKey(tester, 'terms_gate_later_button'); + expect(find.byType(NavigationBar), findsOneWidget); + + // Novo início do app sobre o mesmo backend: a árvore é refeita do zero. + await tester.pumpWidget(const SizedBox()); + await tester.pumpWidget(SinalAcsApp(backend: backend)); + await login(tester); + + expect(find.byKey(const Key('terms_gate_accept_button')), findsOneWidget); + expect(backend.acceptTermsCalls, 0); + }); +``` + +**`patient_app_mvp_test.dart`**: desfazer o remendo do plano anterior. Em `openMyData`, remover o bloco `final later = find.byKey(const Key('terms_gate_later_button')); if (...) {...}` e o comentário dele; e voltar `expect(backend.myDataCallCount, 2);` (teste "mostra o cadastro e as condições crônicas…") para `1`, removendo o comentário "Uma leitura do login…". Como o login já não lê `myData()`, os 23 testes do grupo voltam a passar sem o remendo. + +Dentro do grupo `Meus dados (LGPD)`, ao lado de "correção: só envia com texto de verdade…": + +```dart + testWidgets('correção que falhou volta com o texto ao reabrir; sai do rascunho depois de enviada', (tester) async { + final backend = FakePatientBackend() + ..myDataResult = overview() + ..dataRequestFailure = const BackendFailure('Sem conexão com o servidor.'); + await pumpMyData(tester, backend); + + await tapByKey(tester, 'request_correction_button'); + await tester.enterText(find.byKey(const Key('correction_details_field')), 'Meu contato mudou.'); + await tester.tap(find.byKey(const Key('correction_request_submit'))); + await tester.pumpAndSettle(); + expect(find.text('Sem conexão com o servidor.'), findsOneWidget); + + backend.dataRequestFailure = null; + await tapByKey(tester, 'request_correction_button'); + expect( + tester.widget(find.byKey(const Key('correction_details_field'))).controller!.text, + 'Meu contato mudou.', + ); + await tester.tap(find.byKey(const Key('correction_request_submit'))); + await tester.pumpAndSettle(); + expect(backend.correctionRequests, ['Meu contato mudou.']); + + await tapByKey(tester, 'request_correction_button'); + expect( + tester.widget(find.byKey(const Key('correction_details_field'))).controller!.text, + isEmpty, + ); + }); +``` + +**`onboarding_flow_test.dart`**, ao final de `main()`: + +```dart + testWidgets('toque duplo em "Ler QR Code" abre uma leitura só', (tester) async { + final gate = Completer(); + var calls = 0; + await tester.pumpWidget(SinalAcsApp( + backend: FakePatientBackend(), + qrScanner: (_) { + calls++; + return gate.future; + }, + )); + await openOnboarding(tester); + + await tester.tap(find.byKey(const Key('scan_qr_button'))); + await tester.pump(); + await tester.tap(find.byKey(const Key('scan_qr_button')), warnIfMissed: false); + await tester.pump(); + expect(calls, 1); + + gate.complete(null); + await tester.pumpAndSettle(); + await tapKey(tester, 'scan_qr_button'); + expect(calls, 2, reason: 'depois de voltar, dá para ler de novo'); + }); + + testWidgets('o aviso do QR some quando a pessoa volta a digitar', (tester) async { + await tester.pumpWidget(SinalAcsApp( + backend: FakePatientBackend(), + qrScanner: (_) async => 'https://exemplo.invalid/pagina', + )); + await openOnboarding(tester); + await tapKey(tester, 'scan_qr_button'); + expect(find.textContaining('não é um convite do SinalACS'), findsOneWidget); + + await tester.enterText(find.byKey(const Key('onboarding_token_field')), 'convite-123'); + await tester.pump(); + + expect(find.textContaining('não é um convite do SinalACS'), findsNothing); + }); +``` + +(`import 'dart:async';` no topo, se ainda não houver.) + +- [ ] **Step 2: Ver falhar** + +Run: `cd apps/patient && flutter test test/terms_gate_flow_test.dart test/onboarding_flow_test.dart test/patient_app_mvp_test.dart` +Expected: FAIL — `hasAcceptedCurrentTerms` não existe em `PatientBackend`; depois de compilar, os testes de toque duplo, aviso do QR e correção falham. + +- [ ] **Step 3: Camada de rede** + +Em `backend_client.dart`: na interface, depois de `acceptTermsOfUse`: + +```dart + /// Se o paciente já aceitou o Termo de Uso e a Política de Privacidade da + /// versão vigente (LGPD-RF18). O login por OTP consulta isto para decidir se + /// mostra o convite ao aceite. + Future hasAcceptedCurrentTerms(); +``` + +`MisconfiguredBackend`: `@override Future hasAcceptedCurrentTerms() async => _recusar();`. `BackendClient`: + +```dart + @override + Future hasAcceptedCurrentTerms() async { + final token = await _requireToken(); + return _guard(() => _client.patients.hasAcceptedCurrentTerms(accessToken: token)); + } +``` + +- [ ] **Step 4: `app.dart`** + +1. Login: em `_needsTerms`, trocar o corpo do `try` por + +```dart + final accepted = + await BackendScope.of(context).hasAcceptedCurrentTerms().timeout(_termsCheckTimeout); + return !accepted; +``` + + e remover `import 'package:sinalacs_patient/core/legal/terms_acceptance.dart';`. Apagar `lib/core/legal/terms_acceptance.dart` e `test/terms_acceptance_test.dart` (`git rm`). + +2. Onboarding, em `_OnboardingScreenState`: campo `bool _scanning = false;`; o botão passa a `onPressed: _busy || _scanning ? null : _scan,`; `_scan` marca e libera: + +```dart + Future _scan() async { + if (_scanning) return; + setState(() => _scanning = true); + try { + await _readQr(); + } finally { + if (mounted) setState(() => _scanning = false); + } + } +``` + + com o corpo atual de `_scan` renomeado para `Future _readQr() async { ... }` (sem mudar nada dentro). No `onChanged` do campo `onboarding_token_field`, trocar `(_) => setState(() {})` por `(_) => setState(() => _error = null)`. + +3. Correção, na tela "Meus dados": campo `String? _pendingCorrection;`. `_submitRequest` passa a devolver `Future` (`true` só quando a chamada não lançou `BackendFailure`; os `return` de dentro do `try`/`catch` viram `return true`/`return false`, mantendo o `finally`). `_requestCorrection`: + +```dart + Future _requestCorrection() async { + if (_busy) return; + final details = await showDialog( + context: context, + builder: (_) => _CorrectionRequestDialog(initialText: _pendingCorrection), + ); + if (details == null || !mounted) { + // Cancelou de propósito: o rascunho não fica para trás. + _pendingCorrection = null; + return; + } + // Guardado até o servidor aceitar: se o envio falhar, o texto volta quando + // a pessoa reabrir o diálogo, em vez de ser digitado de novo. + _pendingCorrection = details; + final ok = await _submitRequest( + (backend) => backend.requestDataCorrection(details), + 'Pedido de correção registrado', + ); + if (ok) _pendingCorrection = null; + } +``` + + `_CorrectionRequestDialog` ganha `const _CorrectionRequestDialog({this.initialText});` e `final String? initialText;`, e o estado inicia `final _controller = TextEditingController();` por `late final _controller = TextEditingController(text: widget.initialText);`. + +- [ ] **Step 5: Ver passar** + +Run: `cd apps/patient && flutter analyze && flutter test` +Expected: `No issues found!`, suíte verde. Se algum teste antigo contava `myDataCallCount` ou linhas de consentimento por causa do remendo desfeito, ajuste e registre no ledger. + +- [ ] **Step 6: Commit** + +```bash +git add apps/patient +git commit -m "fix(paciente): login consulta só o status do aceite; toque duplo na câmera, aviso do QR e texto de correção + +Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Tarefa 3: app do ACS — convite expirado some da tela + +**Files:** +- Modify: `apps/acs/lib/app/invite_screen.dart` +- Modify: `apps/acs/test/support/fakes.dart` (`generateInvite`) +- Test: `apps/acs/test/invite_screen_test.dart` + +**Interfaces:** +- Consumes: `EnrollmentTokenResult({token, expiresAt})`. +- Produces: tela que esconde o QR e o código quando `expiresAt` passa, com `Key('invite_expired')`. + +- [ ] **Step 1: Teste que falha** + +Em `test/support/fakes.dart`, `generateInvite` devolve hoje `expiresAt: DateTime.utc(2026, 9, 29, 10, 15)`, uma data fixa que a tela vai passar a comparar com o relógio. Troque por um prazo relativo, configurável: + +```dart + /// Validade do convite devolvido por `generateInvite`, contada de agora. + Duration inviteLifetime = const Duration(minutes: 15); +``` + +e `expiresAt: DateTime.now().toUtc().add(inviteLifetime),`. Os testes existentes que afirmam o texto `Válido até HH:MM` devem passar a calcular o esperado a partir do valor devolvido (leia `invite_screen_test.dart` e ajuste as asserções de horário; registre no ledger). + +Acrescente em `test/invite_screen_test.dart`: + +```dart + testWidgets('convite que expira com a tela aberta some e pede um novo', (tester) async { + tester.view.physicalSize = const Size(800, 2000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final backend = backendComPacientes()..inviteLifetime = const Duration(minutes: 15); + await tester.pumpWidget( + SinalAcsApp(backend: backend, feedBuilder: (queue) => FakeAlertFeed(queue)), + ); + await entrar(tester); + await abrirConvite(tester); + await tapKey(tester, 'invite_patient_${syntheticPatientId(5)}'); + await tapKey(tester, 'generate_invite_button'); + expect(find.byType(QrImageView), findsOneWidget); + + await tester.pump(const Duration(minutes: 15, seconds: 1)); + + expect(find.byType(QrImageView), findsNothing); + expect(find.byKey(const Key('invite_token_text')), findsNothing); + expect(find.byKey(const Key('invite_expired')), findsOneWidget); + + await tapKey(tester, 'generate_invite_button'); + expect(find.byType(QrImageView), findsOneWidget); + expect(find.byKey(const Key('invite_expired')), findsNothing); + }); + + testWidgets('trocar de paciente cancela o aviso de expiração do convite anterior', (tester) async { + tester.view.physicalSize = const Size(800, 2000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final backend = backendComPacientes(); + await tester.pumpWidget( + SinalAcsApp(backend: backend, feedBuilder: (queue) => FakeAlertFeed(queue)), + ); + await entrar(tester); + await abrirConvite(tester); + await tapKey(tester, 'invite_patient_${syntheticPatientId(5)}'); + await tapKey(tester, 'generate_invite_button'); + await tapKey(tester, 'invite_patient_${syntheticPatientId(6)}'); + + await tester.pump(const Duration(minutes: 16)); + + expect(find.byKey(const Key('invite_expired')), findsNothing, + reason: 'o convite já tinha saído da tela; nada a avisar'); + }); +``` + +- [ ] **Step 2: Ver falhar** + +Run: `cd apps/acs && flutter test test/invite_screen_test.dart` +Expected: FAIL — o QR continua na tela depois de 15 min, e `invite_expired` não existe. + +- [ ] **Step 3: Implementação** + +Em `invite_screen.dart`: `import 'dart:async';`; no estado, `Timer? _expiryTimer;` e `bool _expired = false;`. + +```dart + void _clearExpiry() { + _expiryTimer?.cancel(); + _expiryTimer = null; + _expired = false; + } + + /// Agenda o aviso para o instante em que o convite deixa de valer. O relógio + /// do aparelho pode estar errado: o servidor é quem recusa um convite + /// expirado, e isto só evita deixar um QR morto na tela como se valesse. + void _watchExpiry(DateTime expiresAt) { + _clearExpiry(); + final remaining = expiresAt.difference(DateTime.now()); + if (remaining <= Duration.zero) { + _expired = true; + return; + } + _expiryTimer = Timer(remaining, () { + if (mounted) setState(() => _expired = true); + }); + } + + @override + void dispose() { + _expiryTimer?.cancel(); + super.dispose(); + } +``` + +(Se já existir um `dispose`, acrescente só o `cancel`.) Chamadas: em `_select`, dentro do `if (_selected?.patientId != patient.patientId)`, além de `_invite = null`, `_clearExpiry();`. Em `_generate`, no ramo em que `_invite = invite` é aceito, `_watchExpiry(invite.expiresAt);`; no `on BackendFailure`, `_clearExpiry();` junto de `_invite = null`. Em `build`, `final invite = _invite;` passa a ser usado assim: o bloco `if (invite != null && _selected != null) ...[` fica `if (invite != null && _selected != null && !_expired) ...[`, e logo antes dele: + +```dart + if (invite != null && _expired) + Padding( + padding: const EdgeInsets.only(top: 24), + child: Semantics( + liveRegion: true, + child: const Text( + 'O convite expirou. Gere um novo.', + key: Key('invite_expired'), + textAlign: TextAlign.center, + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + ), +``` + +- [ ] **Step 4: Ver passar, suíte e commit** + +Run: `cd apps/acs && flutter analyze && flutter test` +Expected: `No issues found!`, suíte verde. + +```bash +git add apps/acs +git commit -m "fix(acs): convite expirado some da tela e pede um novo + +Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Tarefa 4: Documentação e registro + +**Files:** +- Modify: `PROGRESS.md`, `apps/CLAUDE.md`, `backend/CLAUDE.md` + +**Interfaces:** Consumes: tudo acima. Produces: nada de código. + +- [ ] **Step 1: apps/CLAUDE.md e backend/CLAUDE.md** + +Em `apps/CLAUDE.md`, no parágrafo do login do paciente, trocar "the app reads `myData()` and, when the latest `termsOfUse` consent row is not `granted` in `legalDocumentsVersion`, shows `TermsAcceptanceScreen`" por "the app asks `patients.hasAcceptedCurrentTerms` (a `bool` — not the whole \"Meus dados\" panel, which would write a read-audit row on every login) with a 3 s timeout and, when the answer is `false`, shows `TermsAcceptanceScreen`". Em `backend/CLAUDE.md`, na entrada de `patients.acceptTermsOfUse`, acrescentar "idempotente (aceite vigente já gravado devolve a linha existente) e `patients.hasAcceptedCurrentTerms` (o `bool` que o login consulta)"; e onde falar de `requestDataDeletion`, se falar, que a criação é serializada por `pg_advisory_xact_lock` por titular. + +- [ ] **Step 2: PROGRESS.md** + +Na seção "Direitos do titular…", trocar o item "Corrida de dois pedidos de exclusão simultâneos…" por uma nota "**Resolvido (2026-09-29):** a criação passou a ser atômica (`createDeletionRequestIfNoneOpen`, advisory lock por titular)". Na seção "QR Code do onboarding e documentos legais", nos bullets "não há mecanismo de reaceite no login" e "Falta um aceite no primeiro login", acrescentar "— **resolvido**, ver 'Aceite do termo no login OTP'". Onde o PROGRESS.md chama `ipHash` de "placeholder", trocar por "marcador deliberado de ausência (`nao-aplicavel-painel-titular`, ver `signed_consent_log.dart`)". Ao final, uma seção `## Fechamento das pendências do paciente (2026-09-29)` com: o que foi fechado (status do aceite sem ler o painel, aceite idempotente, exclusão serializada, `resourceId` da auditoria de consentimento e auditoria da repetição da exclusão, toque duplo na câmera, aviso do QR, texto de correção, convite expirado no ACS, lacunas de teste), o que ficou de fora de propósito (o marcador `nao-aplicavel-*` de `consent_logs`; admin backend; push RF14) e as contagens reais de teste (rode as três suítes e copie os números). + +- [ ] **Step 3: Verificações e commit** + +Run: +```bash +./scripts/qa/ci_invariants.sh +graphify update . +``` +Expected: invariantes OK; grafo atualizado. + +```bash +git add PROGRESS.md apps/CLAUDE.md backend/CLAUDE.md +git commit -m "docs: registra o fechamento das pendências do app paciente + +Co-Authored-By: Claude Sonnet 5.5 " +``` diff --git a/docs/superpowers/plans/2026-09-29-menores-adiados-e-push-do-paciente.md b/docs/superpowers/plans/2026-09-29-menores-adiados-e-push-do-paciente.md new file mode 100644 index 00000000..7781e0ff --- /dev/null +++ b/docs/superpowers/plans/2026-09-29-menores-adiados-e-push-do-paciente.md @@ -0,0 +1,712 @@ +# Menores adiados da rodada 3 e recebimento de push no paciente (RF14) — Plano de Implementação + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fechar os menores adiados da rodada 3 (aceite simultâneo, locks, rascunho de correção, testes e docs) e entregar o lado do paciente do RF14: registro do token de push condicionado ao consentimento `segmentedPush`, sem depender do projeto Firebase, que segue não provisionado. + +**Architecture:** O servidor ganha a tabela `push_tokens` e o endpoint `devices` (`registerPushToken`), que recusa registro sem consentimento vigente; revogar `segmentedPush` apaga os tokens do titular. O app obtém o token por uma interface `PushTokenSource` cuja implementação padrão devolve `null` (sem Firebase) e registra no login e ao conceder o consentimento. A troca por `firebase_messaging` fica para quando o projeto existir, sem mexer em servidor nem em telas. + +**Tech Stack:** Serverpod 3.4.13 (`serverpod generate` + `create-migration`), Postgres com `pg_advisory_xact_lock`, Flutter 3.44, `flutter_test`. + +**Spec:** `docs/superpowers/specs/2026-09-16-decisoes-produto-pos-validacao.md` §3.2 (contrato RF14) e §2 (consentimento); `spec/lgpd_design.md` (LGPD-RF02/RF05). + +## Global Constraints + +- Regenerar com `export PATH=$PATH:~/.pub-cache/bin; cd backend/sinalacs_server && serverpod generate && serverpod create-migration`. Testes de integração: `docker compose --profile test up -d postgres-test`. +- `flutter analyze` limpo nos dois apps, infos incluídas. `dart analyze` do backend no baseline de 41 infos. +- Alerta vermelho nunca é descartado nem atrasado: nenhuma falha de push (token, rede, consentimento) pode bloquear login, home ou o botão de urgência. +- Conteúdo do push não carrega dado de saúde identificável (§3.2); esta rodada não envia push, só registra token. +- `userId` vem sempre de `user.id`, nunca de parâmetro (INV-05). +- Texto de UI e comentários em português. Nenhum dado real de paciente em testes. +- Contagens de testes ao fim: backend, paciente e ACS verdes; `./scripts/qa/ci_invariants.sh` ok. + +## Review Focus + +- Token registrado com consentimento revogado: o servidor recusa e o app engole a recusa sem mensagem (o paciente não pediu push). +- Revogar `segmentedPush` e conceder de novo: os tokens antigos somem na revogação; a concessão registra o token atual. +- O mesmo token registrado duas vezes (login repetido, duas abas): uma linha só, `updatedAt` renovado. +- Token trocado por outro titular no mesmo aparelho: a linha muda de dono, nunca fica com dois donos. +- Duas chamadas simultâneas de `acceptTermsOfUse`: uma linha em `consent_logs`. + +--- + +## Mapa de arquivos + +- Backend criar: `lib/src/models/push_token.spy.yaml`, `lib/src/application/patients/push_token_service.dart`, `lib/src/infrastructure/database/orm_push_token_store.dart`, `lib/src/infrastructure/database/subject_lock.dart`, `lib/src/endpoints/devices_endpoint.dart`, `test/unit/push_token_service_test.dart`, `test/integration/push_token_endpoint_test.dart`. +- Backend modificar: `data_subject_rights_service.dart`, `orm_data_subject_rights_store.dart`, `runtime/alert_runtime.dart` (fábrica do serviço), `patients_endpoint.dart` (comentário), `test/unit/data_subject_rights_service_test.dart`, `test/integration/data_subject_rights_endpoint_test.dart`. +- Paciente criar: `lib/core/push/push_token_source.dart`, `test/push_registration_test.dart`. +- Paciente modificar: `lib/app/app.dart`, `lib/core/network/backend_client.dart`, `test/support/fake_patient_backend.dart`, `test/patient_app_mvp_test.dart`. +- Docs: `apps/CLAUDE.md`, `backend/CLAUDE.md`, `PROGRESS.md`, `spec/lgpd_data_audit.md`, `spec/PRD_system.md`, memória. + +--- + +### Task 1: Aceite do termo atômico e locks de duas chaves (backend) + +**Files:** +- Create: `backend/sinalacs_server/lib/src/infrastructure/database/subject_lock.dart` +- Modify: `lib/src/application/patients/data_subject_rights_service.dart`, `lib/src/infrastructure/database/orm_data_subject_rights_store.dart`, `lib/src/endpoints/patients_endpoint.dart` +- Test: `test/unit/data_subject_rights_service_test.dart`, `test/integration/data_subject_rights_endpoint_test.dart` + +**Interfaces:** +- Produces: `Future lockPerSubject(Session session, Transaction transaction, {required int namespace, required String key})` em `subject_lock.dart`; constantes `lockNamespaceDeletion = 1`, `lockNamespaceTerms = 2` (o `OrmAuditTrail` continua na forma de uma chave, espaço separado). +- Produces: `DataSubjectRightsStore.recordConsentUnlessCurrent(ConsentLogEntry entry) → Future<({String? id, ConsentRecordSnapshot? existing})>`: sob lock, se a linha mais recente do propósito já é `granted` na `entry.version`, devolve `existing` e não grava; senão grava e devolve `id`. +- Consumes: `signedConsentLog`, `ConsentLogEntry`, `ConsentRecordSnapshot` (já existem). + +- [ ] **Step 1: Teste unitário vermelho — o serviço delega a idempotência ao store** + +Em `test/unit/data_subject_rights_service_test.dart`, o fake store ganha `recordConsentUnlessCurrent` (mesma regra do real, sobre a lista em memória) e um contador `unlessCurrentCalls`. Teste novo: + +```dart +test('acceptTermsOfUse usa a gravação condicional e não audita a repetição', () async { + await service.acceptTermsOfUse(patient); + await service.acceptTermsOfUse(patient); + + expect(store.unlessCurrentCalls, 2); + expect(store.consents.where((c) => c.purpose == ConsentPurpose.termsOfUse.name), hasLength(1)); + expect(audit.events.where((e) => e.resourceType == 'consent_log'), hasLength(1)); +}); +``` + +- [ ] **Step 2: Rodar e ver falhar** + +Run: `cd backend/sinalacs_server && dart test test/unit/data_subject_rights_service_test.dart` +Expected: FAIL — `recordConsentUnlessCurrent` não existe na interface. + +- [ ] **Step 3: Implementar o serviço e a interface** + +Em `data_subject_rights_service.dart`, na interface: + +```dart + /// Grava `entry` **só se** a linha mais recente do propósito ainda não for um + /// `granted` na mesma versão, de forma atômica por titular: duas chamadas + /// simultâneas resultam em uma linha, e a segunda recebe a da primeira em + /// `existing`. + Future<({String? id, ConsentRecordSnapshot? existing})> recordConsentUnlessCurrent( + ConsentLogEntry entry, + ); +``` + +E `acceptTermsOfUse` vira: + +```dart + Future acceptTermsOfUse(AuthenticatedUser user) async { + _requirePatient(user); + final now = _clock().toUtc(); + final result = await _store.recordConsentUnlessCurrent(ConsentLogEntry( + userId: user.id, + purpose: ConsentPurpose.termsOfUse, + action: 'granted', + version: consentPolicyVersion, + timestamp: now, + )); + // Idempotente: já aceitou a versão vigente, devolve a linha existente em + // vez de crescer o histórico e a trilha de auditoria a cada chamada. + if (result.existing != null) return result.existing!; + await _auditConsent(user, result.id!); + return ConsentRecordSnapshot( + purpose: ConsentPurpose.termsOfUse.name, + action: 'granted', + version: consentPolicyVersion, + timestamp: now, + ); + } +``` + +Extrair de `_record` o trecho `_audit.recordSafely(...)` para `_auditConsent(user, id)` (mesma chamada, `resourceType: 'consent_log'`, `result: 'granted'`) e usá-lo nos dois lugares. + +- [ ] **Step 4: Rodar e ver passar** + +Run: `dart test test/unit/data_subject_rights_service_test.dart` +Expected: PASS. + +- [ ] **Step 5: Teste de integração vermelho — corrida de aceites** + +No grupo de corrida de `test/integration/data_subject_rights_endpoint_test.dart` (usa `RollbackDatabase.disabled`, `_seedRace` e `_cleanupRace`), teste novo: + +```dart +test('dois acceptTermsOfUse simultâneos gravam uma linha só', () async { + final race = await _seedRace(session); + addTearDown(() => _cleanupRace(session, race)); + + await Future.wait([ + endpoint.acceptTermsOfUse(session, accessToken: race.token), + endpoint.acceptTermsOfUse(session, accessToken: race.token), + ]); + + final rows = await ConsentLog.db.find( + session, + where: (t) => t.userId.equals(race.userId) & t.purpose.equals(ConsentPurpose.termsOfUse.name), + ); + expect(rows, hasLength(1)); +}); +``` + +Se `_seedRace` já semeia uma linha `termsOfUse`, ajustar o `where` para contar só as criadas depois (`timestamp`), ou semear um paciente sem aceite. + +Run: `docker compose --profile test up -d postgres-test && cd backend/sinalacs_server && dart test test/integration/data_subject_rights_endpoint_test.dart -N "simultâneos gravam uma linha"` +Expected: FAIL — `Expected: an object with length of <1> Actual: [..., ...]` (o store ainda não existe; comece implementando só a interface com uma versão sem lock que grave sempre, para ver a duplicata, e troque no Step 6). + +- [ ] **Step 6: Implementar o lock e o store** + +`subject_lock.dart`: + +```dart +import 'package:serverpod/serverpod.dart'; + +/// Namespaces dos advisory locks por titular. A forma de duas chaves de +/// `pg_advisory_xact_lock(int, int)` não compartilha espaço com a de uma chave +/// (`OrmAuditTrail`), então um `hashtext` nunca colide com a cadeia de auditoria. +const int lockNamespaceDeletion = 1; +const int lockNamespaceTerms = 2; + +/// Serializa, dentro de [transaction], quem disputa a mesma [key] no mesmo +/// [namespace]. Solta sozinho no fim da transação. +Future lockPerSubject( + Session session, + Transaction transaction, { + required int namespace, + required String key, +}) async { + await session.db.unsafeExecute( + 'SELECT pg_advisory_xact_lock(@ns::int, hashtext(@key));', + parameters: QueryParameters.named({'ns': namespace, 'key': key}), + transaction: transaction, + ); +} +``` + +Em `orm_data_subject_rights_store.dart`, `createDeletionRequestIfNoneOpen` troca o `unsafeExecute` por `lockPerSubject(session, transaction, namespace: lockNamespaceDeletion, key: userId)`. E o método novo: + +```dart + @override + Future<({String? id, ConsentRecordSnapshot? existing})> recordConsentUnlessCurrent( + ConsentLogEntry entry, + ) async { + final session = _session(); + final userUuid = UuidValue.fromString(entry.userId); + return session.db.transaction((transaction) async { + await lockPerSubject(session, transaction, + namespace: lockNamespaceTerms, key: '${entry.purpose.name}:${entry.userId}'); + final latest = await ConsentLog.db.findFirstRow( + session, + where: (t) => t.userId.equals(userUuid) & t.purpose.equals(entry.purpose.name), + orderBy: (t) => t.timestamp, + orderDescending: true, + transaction: transaction, + ); + if (latest != null && latest.action == 'granted' && latest.version == entry.version) { + return ( + id: null, + existing: ConsentRecordSnapshot( + purpose: latest.purpose, + action: latest.action, + version: latest.version, + timestamp: latest.timestamp, + ), + ); + } + final row = await ConsentLog.db.insertRow( + session, + signedConsentLog(entry, signature: _signature, origin: 'painel-titular'), + transaction: transaction, + ); + return (id: row.id!.uuid, existing: null); + }); + } +``` + +Atualizar o comentário de `acceptTermsOfUse` em `patients_endpoint.dart` ("grava uma linha nova" → "grava uma linha só se a versão vigente ainda não foi aceita; repetir devolve a existente"), e o mesmo em `apps/patient/lib/core/network/backend_client.dart` (`acceptTermsOfUse`). + +- [ ] **Step 7: Rodar tudo do backend** + +Run: `cd backend/sinalacs_server && dart test` +Expected: PASS (346 anteriores + 2 novos = 348), inclusive o teste de corrida da exclusão, agora com o lock de duas chaves. `dart analyze` no baseline de 41 infos. + +- [ ] **Step 8: Commit** + +```bash +git add backend/sinalacs_server +git commit -m "fix(backend): aceite do termo atômico e advisory locks de duas chaves (LGPD-RF18)" +``` + +--- + +### Task 2: Tabela `push_tokens`, serviço e endpoint `devices` (backend) + +**Files:** +- Create: `lib/src/models/push_token.spy.yaml`, `lib/src/application/patients/push_token_service.dart`, `lib/src/infrastructure/database/orm_push_token_store.dart`, `lib/src/endpoints/devices_endpoint.dart` +- Modify: `lib/src/runtime/alert_runtime.dart` (fábrica `pushTokenServiceFor(session)`, ao lado de `dataSubjectRightsServiceFor`), `data_subject_rights_service.dart` (revogação apaga tokens) +- Test: `test/unit/push_token_service_test.dart`, `test/integration/push_token_endpoint_test.dart` + +**Interfaces:** +- Produces: `PushTokenStore` com `Future hasGrantedConsent(String userId)`, `Future upsert({required String userId, required String? microAreaId, required String token, required String platform, required DateTime now})`, `Future deleteAllFor(String userId)`. +- Produces: `PushTokenService.register(AuthenticatedUser user, {required String token, required String platform})` — só paciente; recusa com `DataRightsException` se não houver consentimento `segmentedPush` vigente, se `token` vazio ou maior que 4096 caracteres, ou se `platform` não for `android`/`ios`. +- Produces: RPC `devices.registerPushToken(session, {required String accessToken, required String token, required String platform}) → Future`. +- Consumes: `latestConsent` de `DataSubjectRightsStore` (o store de push usa o mesmo `ConsentLog`), `lockPerSubject` da Task 1 (namespace novo `lockNamespacePushToken = 3`). + +- [ ] **Step 1: Modelo** + +`push_token.spy.yaml`: + +```yaml +### Token de push (FCM/APNs) do aparelho de um paciente que consentiu com +### `segmentedPush` (RF14, decisão §3.2). Uma linha por token: se o mesmo token +### aparece para outro titular, a linha muda de dono, nunca duplica. +### +### O token identifica um aparelho, não uma pessoa, mas junto de `userId` liga +### aparelho a titular — por isso some na revogação do consentimento. +class: PushToken +table: push_tokens +fields: + id: UuidValue?, defaultPersist=random + userId: UuidValue, relation(parent=users) + microAreaId: UuidValue? + token: String + platform: String + createdAt: DateTime + updatedAt: DateTime +indexes: + push_tokens_token_key: + fields: token + unique: true + push_tokens_user_id_idx: + fields: userId +``` + +Run: `export PATH=$PATH:~/.pub-cache/bin; cd backend/sinalacs_server && serverpod generate && serverpod create-migration` +Expected: gera `push_token.dart`, atualiza `protocol.dart` e cria uma migração nova com `CREATE TABLE "push_tokens"`. + +- [ ] **Step 2: Testes unitários vermelhos** + +`test/unit/push_token_service_test.dart` com um `_FakePushTokenStore` (mapa token→linha, `consent` booleano, `deleted` contador): + +```dart +test('sem consentimento vigente, recusa e não grava', () async { + store.consent = false; + await expectLater( + service.register(patient, token: 'tok-1', platform: 'android'), + throwsA(isA()), + ); + expect(store.rows, isEmpty); +}); + +test('registra e repete sem duplicar', () async { + await service.register(patient, token: 'tok-1', platform: 'android'); + await service.register(patient, token: 'tok-1', platform: 'android'); + expect(store.rows.keys, ['tok-1']); +}); + +test('o mesmo token de outro titular troca de dono', () async { + await service.register(patient, token: 'tok-1', platform: 'android'); + await service.register(otherPatient, token: 'tok-1', platform: 'android'); + expect(store.rows['tok-1']!.userId, otherPatient.id); +}); + +test('recusa token vazio, longo demais e plataforma desconhecida', () async { + for (final args in [('', 'android'), ('x' * 4097, 'android'), ('tok', 'web')]) { + await expectLater( + service.register(patient, token: args.$1, platform: args.$2), + throwsA(isA()), + ); + } +}); + +test('ACS não registra token', () async { + await expectLater( + service.register(acs, token: 'tok-1', platform: 'android'), + throwsA(isA()), + ); +}); +``` + +Mais um teste em `data_subject_rights_service_test.dart` (o fake store de lá ganha `pushTokensDeleted`, ou o serviço recebe um `PushTokenStore` opcional — ver Step 3): `updateConsent(segmentedPush, granted: false)` chama `deleteAllFor(user.id)` uma vez; `granted: true` e outras finalidades não chamam. + +Run: `dart test test/unit/push_token_service_test.dart` +Expected: FAIL — `PushTokenService` não existe. + +- [ ] **Step 3: Implementar serviço, store e revogação** + +```dart +abstract interface class PushTokenStore { + Future hasGrantedConsent(String userId); + Future upsert({ + required String userId, + required String? microAreaId, + required String token, + required String platform, + required DateTime now, + }); + Future deleteAllFor(String userId); +} + +const int pushTokenMaxLength = 4096; +const Set pushPlatforms = {'android', 'ios'}; + +class PushTokenService { + PushTokenService({required PushTokenStore store, DateTime Function()? clock}) + : _store = store, + _clock = clock ?? DateTime.now; + + final PushTokenStore _store; + final DateTime Function() _clock; + + Future register(AuthenticatedUser user, + {required String token, required String platform}) async { + Authorization.require( + user, + roles: {UserRole.patient}, + onDenied: () => StateError('Somente o próprio paciente registra o aparelho para avisos.'), + requireMicroArea: false, + ); + final trimmed = token.trim(); + if (trimmed.isEmpty || trimmed.length > pushTokenMaxLength || !pushPlatforms.contains(platform)) { + throw DataRightsException(message: 'Aparelho inválido para receber avisos.'); + } + if (!await _store.hasGrantedConsent(user.id)) { + throw DataRightsException( + message: 'Ative "Avisos da equipe de saúde" em Meus Dados para receber avisos.', + ); + } + await _store.upsert( + userId: user.id, + microAreaId: user.microAreaId, + token: trimmed, + platform: platform, + now: _clock().toUtc(), + ); + } +} +``` + +`DataSubjectRightsService` ganha o parâmetro nomeado opcional `PushTokenStore? pushTokens` e, em `updateConsent`, depois de `_record`, `if (purpose == ConsentPurpose.segmentedPush && !granted) await _pushTokens?.deleteAllFor(user.id);`. A fábrica `dataSubjectRightsServiceFor` em `alert_runtime.dart` passa o `OrmPushTokenStore`. + +`OrmPushTokenStore`: `hasGrantedConsent` lê o `ConsentLog` mais recente de `segmentedPush` do titular (`action == 'granted'`); `upsert` roda em transação com `lockPerSubject(namespace: lockNamespacePushToken, key: token)`, busca `PushToken` por `token`, atualiza `userId`/`microAreaId`/`platform`/`updatedAt` se existir, senão insere; `deleteAllFor` usa `PushToken.db.deleteWhere(session, where: (t) => t.userId.equals(uuid))` e devolve o tamanho da lista removida. + +`DevicesEndpoint extends AuthenticatedEndpoint`: + +```dart +class DevicesEndpoint extends AuthenticatedEndpoint { + /// Registra o token de push do aparelho do paciente autenticado (RF14, §3.2). + /// Só grava com o consentimento `segmentedPush` vigente. + Future registerPushToken( + Session session, { + required String accessToken, + required String token, + required String platform, + }) async { + final user = authenticate(accessToken); + try { + await AlertRuntime.instance + .pushTokenServiceFor(session) + .register(user, token: token, platform: platform); + } on StateError catch (error) { + throw AlertPermissionException(message: error.message); + } + } +} +``` + +Adicionar `pushTokenServiceFor` em `alert_runtime.dart` no mesmo formato de `dataSubjectRightsServiceFor`. + +- [ ] **Step 4: Rodar os unitários** + +Run: `dart test test/unit/push_token_service_test.dart test/unit/data_subject_rights_service_test.dart` +Expected: PASS. + +- [ ] **Step 5: Integração** + +`test/integration/push_token_endpoint_test.dart` (mesmo esqueleto de `data_subject_rights_endpoint_test.dart`: `withServerpod` e um paciente semeado com token): + +```dart +test('paciente com consentimento registra e repete sem duplicar', () async { + await patients.updateConsent(session, accessToken: token, purpose: ConsentPurpose.segmentedPush, granted: true); + await devices.registerPushToken(session, accessToken: token, token: 'tok-1', platform: 'android'); + await devices.registerPushToken(session, accessToken: token, token: 'tok-1', platform: 'android'); + expect(await PushToken.db.count(session, where: (t) => t.token.equals('tok-1')), 1); +}); + +test('sem consentimento a chamada falha e nada é gravado', () async { + await expectLater( + devices.registerPushToken(session, accessToken: token, token: 'tok-2', platform: 'android'), + throwsA(isA()), + ); + expect(await PushToken.db.count(session, where: (t) => t.token.equals('tok-2')), 0); +}); + +test('revogar segmentedPush apaga os tokens do titular', () async { + await patients.updateConsent(session, accessToken: token, purpose: ConsentPurpose.segmentedPush, granted: true); + await devices.registerPushToken(session, accessToken: token, token: 'tok-3', platform: 'ios'); + await patients.updateConsent(session, accessToken: token, purpose: ConsentPurpose.segmentedPush, granted: false); + expect(await PushToken.db.count(session, where: (t) => t.token.equals('tok-3')), 0); +}); + +test('token inexistente ou de ACS: token inválido é recusado', () async { + await expectLater( + devices.registerPushToken(session, accessToken: 'lixo', token: 'tok', platform: 'android'), + throwsA(isA()), + ); +}); +``` + +Run: `dart test test/integration/push_token_endpoint_test.dart` +Expected: PASS. Se o teste de postura de endpoints (`endpoint_auth_posture_test.dart`) ou de cobertura de auditoria falhar por causa do endpoint novo, tratar como achado: o endpoint estende `AuthenticatedEndpoint`, e a decisão de auditar (ou não) o registro fica registrada no teste, como em `hasAcceptedCurrentTerms` (leitura, sem linha de auditoria; aqui é escrita de metadado de aparelho: auditar com `resourceType: 'push_token'` só se o teste de cobertura exigir). + +- [ ] **Step 6: Suíte do backend e commit** + +Run: `cd backend/sinalacs_server && dart test && dart analyze` +Expected: tudo verde; analyze no baseline. + +```bash +git add backend/sinalacs_server +git commit -m "feat(backend): registro de token de push condicionado ao consentimento (RF14)" +``` + +--- + +### Task 3: Menores do app paciente (rascunho, testes que faltam) + +**Files:** +- Modify: `apps/patient/lib/app/app.dart` (`_requestCorrection`) +- Test: `apps/patient/test/patient_app_mvp_test.dart`, `apps/patient/test/terms_gate_flow_test.dart` (ou `onboarding_flow_test.dart` para o scanner, onde o teste de câmera vive) + +**Interfaces:** +- Consumes: `FakePatientBackend.correctionRequests`, `QrScannerScope` e o helper de câmera falsa já usados em `onboarding_flow_test.dart`. + +- [ ] **Step 1: Testes vermelhos** + +No grupo de correção de `patient_app_mvp_test.dart` (mesmos helpers `tapByKey`, `outlined` e o `drag` do teste de falha): + +```dart +testWidgets('tocar fora do diálogo de correção não descarta o rascunho', (tester) async { + await abrirMeusDadosComBackend(tester, FakePatientBackend()); + await tapByKey(tester, 'request_correction_button'); + await tester.enterText(find.byKey(const Key('correction_details_field')), 'Meu contato mudou.'); + await tester.pump(); + + await tester.tapAt(const Offset(4, 4)); // fora do diálogo + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('correction_details_field')), findsOneWidget); + expect( + tester.widget(find.byKey(const Key('correction_details_field'))).controller!.text, + 'Meu contato mudou.', + ); +}); + +testWidgets('Cancelar descarta o rascunho: reabrir vem vazio', (tester) async { + await abrirMeusDadosComBackend(tester, FakePatientBackend()); + await tapByKey(tester, 'request_correction_button'); + await tester.enterText(find.byKey(const Key('correction_details_field')), 'Meu contato mudou.'); + await tester.pump(); + await tester.tap(find.byKey(const Key('correction_request_cancel'))); + await tester.pumpAndSettle(); + + await tapByKey(tester, 'request_correction_button'); + expect( + tester.widget(find.byKey(const Key('correction_details_field'))).controller!.text, + isEmpty, + ); +}); +``` + +Usar o nome real do helper de abertura de "Meus dados" que o grupo já emprega (ler as linhas 1140-1230 do arquivo antes; o snippet acima segue o padrão delas). + +Para o scanner, em `onboarding_flow_test.dart`, junto do teste de toque duplo: + +```dart +testWidgets('o botão de escanear volta a funcionar depois que o leitor lança', (tester) async { + var chamadas = 0; + final scanner = FakeQrScanner((context) async { + chamadas++; + if (chamadas == 1) throw StateError('câmera indisponível'); + return enrollmentTokenValido; + }); + // monta o app sob QrScannerScope(scanner: scanner), abre o onboarding + await tapKey(tester, 'scan_qr_button'); + expect(find.byKey(const Key('scan_qr_button')), findsOneWidget); + await tapKey(tester, 'scan_qr_button'); + expect(chamadas, 2); +}); +``` + +Adaptar `FakeQrScanner`, `enrollmentTokenValido` e a montagem aos nomes que o teste vizinho do toque duplo já usa. + +- [ ] **Step 2: Rodar e ver falhar** + +Run: `cd apps/patient && flutter test test/patient_app_mvp_test.dart test/onboarding_flow_test.dart` +Expected: FAIL só no teste de toque fora (o diálogo fecha e o campo some). Os outros dois passam de primeira: registrar no ledger como testes de caracterização, sem RED observado. + +- [ ] **Step 3: Implementar** + +Em `_requestCorrection`, `showDialog(context: context, barrierDismissible: false, builder: ...)`. O comentário do `details == null` continua correto (só "Cancelar" devolve `null`). + +- [ ] **Step 4: Rodar a suíte e commitar** + +Run: `cd apps/patient && flutter test && flutter analyze` +Expected: PASS (182 + 3 = 185), analyze limpo. + +```bash +git add apps/patient +git commit -m "fix(paciente): tocar fora do diálogo de correção não apaga o rascunho" +``` + +--- + +### Task 4: Registro do token de push no app paciente (RF14, lado cliente) + +**Files:** +- Create: `apps/patient/lib/core/push/push_token_source.dart`, `apps/patient/test/push_registration_test.dart` +- Modify: `apps/patient/lib/core/network/backend_client.dart`, `apps/patient/lib/app/app.dart`, `apps/patient/test/support/fake_patient_backend.dart` + +**Interfaces:** +- Produces: `abstract interface class PushTokenSource { Future currentDevice(); }`, `class PushDevice { const PushDevice({required this.token, required this.platform}); }`, `class NoPushTokenSource implements PushTokenSource` (sempre `null`). +- Produces: `PatientBackend.registerPushToken({required String token, required String platform}) → Future` (interface, `MisconfiguredBackend` e `BackendClient` → `_client.devices.registerPushToken`). +- Produces: `SinalAcsApp({..., PushTokenSource pushTokens = const NoPushTokenSource()})`. +- Consumes: `serverpod generate` da Task 2 regenera o cliente com `devices`. + +- [ ] **Step 1: Testes vermelhos** + +`test/push_registration_test.dart`, com `FakePatientBackend` ganhando `pushRegistrations` (lista de `(token, platform)`), `pushRegistrationFailure` (lançado se não nulo) e um `_FakeSource(PushDevice?)`: + +```dart +testWidgets('depois do login registra o token do aparelho', (tester) async { + final backend = FakePatientBackend(); + await tester.pumpWidget(SinalAcsApp(backend: backend, pushTokens: _FakeSource(const PushDevice(token: 'tok-1', platform: 'android')))); + await login(tester); + expect(backend.pushRegistrations, [('tok-1', 'android')]); +}); + +testWidgets('sem token (sem Firebase) não chama o servidor', (tester) async { + final backend = FakePatientBackend(); + await tester.pumpWidget(SinalAcsApp(backend: backend)); + await login(tester); + expect(backend.pushRegistrations, isEmpty); +}); + +testWidgets('recusa do servidor não afeta a home nem mostra erro', (tester) async { + final backend = FakePatientBackend()..pushRegistrationFailure = const BackendFailure('sem consentimento'); + await tester.pumpWidget(SinalAcsApp(backend: backend, pushTokens: _FakeSource(const PushDevice(token: 'tok-1', platform: 'android')))); + await login(tester); + expect(find.text('Registrar alerta de urgência'), findsOneWidget); + expect(find.textContaining('sem consentimento'), findsNothing); +}); + +testWidgets('conceder "Avisos da equipe" em Meus Dados registra o token', (tester) async { + // login com consentimento de push negado, abrir Meus Dados, ligar o switch do segmentedPush + // esperado: backend.pushRegistrations tem exatamente um item depois do toque +}); + +testWidgets('revogar não registra nada', (tester) async { + // mesmo cenário com o switch já ligado, desligar; pushRegistrations não cresce +}); +``` + +Copiar `login` de `terms_gate_flow_test.dart` (ou movê-lo para `test/support/`, se já houver um terceiro consumidor) e usar as chaves reais do switch de consentimento no painel (ler o trecho de `updateConsent` em `app.dart:1755` e o teste de consentimento existente para os nomes). + +Run: `cd apps/patient && flutter test test/push_registration_test.dart` +Expected: FAIL — `PushTokenSource` e o parâmetro `pushTokens` não existem. + +- [ ] **Step 2: Implementar** + +`push_token_source.dart`: + +```dart +/// Aparelho apto a receber push: o token do provedor e a plataforma. +class PushDevice { + const PushDevice({required this.token, required this.platform}); + + final String token; + + /// `android` ou `ios`, o mesmo vocabulário que o servidor valida. + final String platform; +} + +/// De onde o app tira o token de push. A implementação real (FCM) entra quando +/// existir um projeto Firebase (decisão §3.2 do documento de decisões de +/// produto); até lá [NoPushTokenSource] mantém o registro inerte, e servidor e +/// telas já estão prontos para a troca. +abstract interface class PushTokenSource { + /// `null` quando o aparelho não tem token (sem provedor, sem permissão). + Future currentDevice(); +} + +class NoPushTokenSource implements PushTokenSource { + const NoPushTokenSource(); + + @override + Future currentDevice() async => null; +} +``` + +`backend_client.dart`: método `registerPushToken` na interface (com doc: "Falha se não houver consentimento `segmentedPush` vigente; quem chama ignora a falha"), no `MisconfiguredBackend` (`throw failure`, como os vizinhos) e no `BackendClient`, no mesmo formato de `updateConsent` (linha 528): `_call(() => _client.devices.registerPushToken(accessToken: token, token: pushToken, platform: platform))`, ajustando ao nome real do helper e dos parâmetros. + +`app.dart`: `SinalAcsApp` recebe `pushTokens`; guardá-lo no `State` que faz o login e chamar, depois de `_entrar` autenticar (e ao concluir o onboarding), `unawaited(_registerPush())`: + +```dart + /// Registra o aparelho para avisos (RF14). Silencioso de propósito: o + /// paciente não pediu isto na tela, e uma recusa (consentimento desligado) ou + /// uma falha de rede nunca pode atrasar a home nem o botão de urgência. + Future _registerPush() async { + try { + final device = await widget.pushTokens.currentDevice(); + if (device == null || !mounted) return; + await BackendScope.of(context).registerPushToken(token: device.token, platform: device.platform); + } on BackendFailure { + // sem consentimento ou sem rede: tenta de novo no próximo login + } catch (_) { + // token indisponível não é erro do paciente + } + } +``` + +No painel "Meus Dados", depois de `updateConsent` com `purpose == ConsentPurpose.segmentedPush && granted` bem-sucedido, chamar a mesma rotina (extraí-la para função de nível de biblioteca que recebe `backend`, `source` e um `mounted`, para os dois pontos de uso). Obter a fonte via um `PushTokenScope` (InheritedWidget no estilo de `QrScannerScope`) montado em `SinalAcsApp`, para não passar por construtores de telas. + +- [ ] **Step 3: Rodar e ver passar** + +Run: `cd apps/patient && flutter test test/push_registration_test.dart` +Expected: PASS (5). + +- [ ] **Step 4: Suíte e commit** + +Run: `cd apps/patient && flutter test && flutter analyze` +Expected: tudo verde (185 + 5 = 190), analyze limpo. `cd ../acs && flutter test` continua 172. + +```bash +git add apps/patient +git commit -m "feat(paciente): registra o token de push do aparelho quando há consentimento (RF14)" +``` + +--- + +### Task 5: Documentação, memória e verificação final + +**Files:** +- Modify: `apps/CLAUDE.md` (trocar "failed myData()" pela regra real: login consulta `hasAcceptedCurrentTerms`; registro de push silencioso), `backend/CLAUDE.md` (locks: duas chaves, três namespaces; `push_tokens`; reposicionar a cláusula do lock que está no meio da lista), `PROGRESS.md` (nova seção; corrigir "Aceite do termo no login OTP", que descreve o comportamento antigo de `myData`, e as linhas de "seguem pendentes"), `spec/lgpd_data_audit.md` (entrada de `push_tokens`, recontar tabelas na `definition.sql` da migração mais recente), `spec/PRD_system.md` (linha RF14: lado paciente pronto, envio e Firebase pendentes), `CLAUDE.md` (se citar contagem de tabelas), memória em `~/.claude/projects/-home-rock-Documents-Dev-APPs-SinalACS/memory/` (novo arquivo + linha no `MEMORY.md`). + +- [ ] **Step 1: Editar os docs acima** com a contagem real medida (`grep -c 'CREATE TABLE' backend/sinalacs_server/migrations//definition.sql`) e as contagens finais de testes. + +- [ ] **Step 2: Verificação completa** + +Run: `cd backend/sinalacs_server && dart test && dart analyze; cd ../../apps/patient && flutter test && flutter analyze; cd ../acs && flutter test && flutter analyze; cd ../.. && ./scripts/qa/ci_invariants.sh; graphify update .` +Expected: backend 348+N, paciente 190, ACS 172, analyzes no baseline/limpos, `ci_invariants` ok. + +- [ ] **Step 3: Commit** + +```bash +git add PROGRESS.md apps/CLAUDE.md backend/CLAUDE.md spec CLAUDE.md +git commit -m "docs: registra os menores fechados e o registro de push do paciente" +``` + +--- + +## Fora desta rodada (por decisão) + +- Envio segmentado (`notices.sendSegmented`), tela de avisos do ACS e SDK `firebase_messaging`: dependem do projeto Firebase, que o §3.2 marca como bloqueio externo. +- Apagar tokens no atendimento do pedido de exclusão: pertence ao backoffice que atende os pedidos, ainda inexistente. +- Aviso de 15 dias de mudança dos termos: não escolhido. + +## Autorrevisão + +- **Cobertura:** minors da rodada 3 → Tasks 1, 3, 5 (aceite simultâneo, locks, rascunho, dois testes, docs). O erro de "Meus dados" fora da tela para quem rolou até o botão é pré-existente e fica de fora, por decisão de escopo. RF14 do lado do paciente → Tasks 2, 4. +- **Placeholders:** os trechos de teste das Tasks 3 e 4 nomeiam helpers e chaves a confirmar lendo os testes vizinhos (indicado em cada passo); nenhum comportamento ficou por definir. +- **Tipos:** `recordConsentUnlessCurrent`, `lockPerSubject`, `PushTokenStore`, `PushDevice`, `registerPushToken` usam o mesmo nome e a mesma assinatura em todas as tasks. diff --git a/docs/superpowers/plans/2026-09-29-menores-do-push-e-aviso-de-mudanca-dos-termos.md b/docs/superpowers/plans/2026-09-29-menores-do-push-e-aviso-de-mudanca-dos-termos.md new file mode 100644 index 00000000..f6734bcf --- /dev/null +++ b/docs/superpowers/plans/2026-09-29-menores-do-push-e-aviso-de-mudanca-dos-termos.md @@ -0,0 +1,653 @@ +# Menores do push e aviso de 15 dias de mudança dos termos — Plano de Implementação + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fechar os menores adiados da rodada 4 (revogação atômica, auditoria da troca de dono, teto de tokens, testes que faltam, `maybeOf`, regra de aceite duplicada) e entregar o aviso, dentro do app, de 15 dias antes de uma nova versão dos termos valer (LGPD-RF18). + +**Architecture:** A revogação de `segmentedPush` passa a gravar o `denied` e apagar os tokens na mesma transação, sob o lock por titular que o registro já usa. O aviso de mudança é um agendamento constante no servidor (`TermsChangeSchedule`, hoje vazio) que se recusa a existir com menos de 15 dias entre publicação e vigência; `patients.termsChangeNotice` devolve o aviso ativo e o app o mostra como um cartão dispensável na home, sem nunca bloquear nada. + +**Tech Stack:** Serverpod 3.4.13 (`serverpod generate` + `create-migration`), Postgres com `pg_advisory_xact_lock`, Flutter 3.44, `flutter_test`. + +**Revisão de 2026-09-29 (depois do RF14 com Gorush):** este plano foi escrito antes do envio de avisos e revisado contra o código de `aa4f3ae`. O que mudou desde então e já vale aqui: o RF14 usa Gorush (`notices.sendSegmented`, `GorushClient`, `NoticeService`); `push_token_endpoint_test.dart` ganhou um grupo de corrida com `_seedRaceLean` (ids `9200…`, sem `Patient` nem `Acs`) e os outros arquivos filtram contagens por `userId`; o baseline do `dart analyze` do backend é 50 infos; `NoticeService` audita `community_notice`. Nada aqui toca o envio, o Gorush nem o ACS. + +**Spec:** `docs/superpowers/specs/2026-09-16-decisoes-produto-pos-validacao.md` §2 e §3.2; `spec/lgpd_design.md` (LGPD-RF18: "aviso de mudança com 15 dias de antecedência e novo aceite quando a versão mudar"). + +## Global Constraints + +- Regenerar com `export PATH=$PATH:~/.pub-cache/bin; cd backend/sinalacs_server && serverpod generate && serverpod create-migration`. Testes de integração: `docker compose --profile test up -d postgres-test`. +- `flutter analyze` limpo nos dois apps, infos incluídas. `dart analyze` do backend no baseline de 50 infos (avisos: zero). +- Alerta vermelho nunca é descartado nem atrasado: aviso de termos e registro de push nunca bloqueiam login, home nem o botão de urgência; falha ou lentidão vira "sem aviso". +- `userId` vem sempre de `user.id` (INV-05). Textos de UI e comentários em português. Nenhum dado real em testes. +- Testes de integração sem rollback usam ids próprios (o grupo de corrida do push já usa `9200…`; o que este plano acrescentar usa `9300…`) e `enrollmentId` próprio, e limpam à mão; o seed de `ACS-001` derruba outros arquivos em paralelo. +- `legalDocumentsVersion` (app) continua igual a `consentPolicyVersion` (backend); esta rodada não troca a versão. + +## Review Focus + +- Notice cuja vigência é antes de 15 dias da publicação: o servidor se recusa a construí-la. +- Relógio do servidor exatamente na publicação e exatamente na vigência: aviso ativo na primeira, ausente na segunda (a partir daí vale o convite ao aceite). +- Servidor lento ou fora do ar ao buscar o aviso: a home abre normalmente, sem cartão e sem erro. +- Revogar `segmentedPush` com falha ao apagar tokens: nem o `denied` fica gravado (tudo ou nada). +- Mais de 10 tokens de um titular: o mais antigo sai, o registro novo entra, e o titular nunca é recusado por isso. + +--- + +## Mapa de arquivos + +- Backend criar: `lib/src/application/patients/terms_change_schedule.dart`, `lib/src/models/api/terms_change_notice.spy.yaml` (confirmar a pasta onde ficam os outros DTOs de `models/api/`), `test/unit/terms_change_schedule_test.dart`. +- Backend modificar: `data_subject_rights_service.dart`, `push_token_service.dart`, `orm_data_subject_rights_store.dart`, `orm_push_token_store.dart`, `subject_lock.dart` (sem mudança de namespace), `patients_endpoint.dart`, `devices_endpoint.dart`, `alert_runtime.dart`, `test/unit/data_subject_rights_service_test.dart`, `test/unit/push_token_service_test.dart`, `test/integration/push_token_endpoint_test.dart`. +- Paciente criar: `lib/core/legal/terms_change_notice_card.dart`, `test/terms_change_notice_test.dart`. +- Paciente modificar: `lib/core/push/push_token_source.dart`, `lib/core/network/backend_client.dart`, `lib/app/app.dart`, `test/support/fake_patient_backend.dart`, `test/push_registration_test.dart`. +- Docs: `PROGRESS.md`, `apps/CLAUDE.md`, `backend/CLAUDE.md`, `spec/lgpd_design.md`, `spec/lgpd_data_audit.md`, memória. + +--- + +### Task 1: Revogação atômica, resultado do registro, teto de tokens e auditoria (backend) + +**Files:** +- Modify: `lib/src/application/patients/data_subject_rights_service.dart`, `push_token_service.dart`, `lib/src/infrastructure/database/orm_data_subject_rights_store.dart`, `orm_push_token_store.dart`, `lib/src/runtime/alert_runtime.dart`, `lib/src/endpoints/devices_endpoint.dart` +- Test: `test/unit/data_subject_rights_service_test.dart`, `test/unit/push_token_service_test.dart`, `test/integration/push_token_endpoint_test.dart` + +**Interfaces:** +- Produces: `enum PushRegistration { registered, ownerChanged, refused }` em `push_token_service.dart`. +- Produces: `PushTokenStore.registerIfConsented(...) → Future` (era `Future`); mesmos parâmetros. +- Produces: `DataSubjectRightsStore.recordConsentRevokingPush(ConsentLogEntry entry) → Future`: numa transação sob `lockPerSubject(namespace: lockNamespacePushToken, key: userId)`, grava o `entry` e apaga os tokens do titular; devolve o id da linha de consentimento. +- Produces: `const int maxPushTokensPerUser = 10;` em `push_token_service.dart`. +- Consumes: `lockPerSubject`, `lockNamespacePushToken`, `lockNamespacePushTokenRow`, `signedConsentLog`. + +- [ ] **Step 1: Testes unitários vermelhos** + +Em `data_subject_rights_service_test.dart`, o fake store ganha `recordConsentRevokingPush` (registra em `consents` e incrementa `revokingPushCalls`), e o teste `'só a revogação de segmentedPush chama deleteAllFor'` do grupo de push vira: + +```dart +test('revogar segmentedPush usa a gravação atômica; o resto usa recordConsent', () async { + await service.updateConsent(_patient, purpose: ConsentPurpose.segmentedPush, granted: true); + await service.updateConsent(_patient, purpose: ConsentPurpose.localReminders, granted: false); + expect(store.revokingPushCalls, 0); + + await service.updateConsent(_patient, purpose: ConsentPurpose.segmentedPush, granted: false); + expect(store.revokingPushCalls, 1); + expect(store.consents.last.action, 'denied'); + expect(audit.events.last.resourceType, 'consent_log'); +}); +``` + +Remova o parâmetro `pushTokens` e a classe `FakePushTokenStore` desse arquivo. Em `push_token_service_test.dart`, o fake devolve `PushRegistration` e há três testes novos: + +```dart +test('troca de dono é auditada, primeiro registro e repetição não', () async { + await service.register(_patient, token: 'tok-1', platform: 'android'); + await service.register(_patient, token: 'tok-1', platform: 'android'); + expect(audit.events, isEmpty); + + await service.register(_otherPatient, token: 'tok-1', platform: 'android'); + expect(audit.events.single.resourceType, 'push_token'); + expect(audit.events.single.userId, _otherPatient.id); +}); + +test('recusa por falta de consentimento não é auditada e lança', () async { + store.consent = false; + await expectLater( + service.register(_patient, token: 'tok-1', platform: 'android'), + throwsA(isA()), + ); + expect(audit.events, isEmpty); +}); +``` + +(O `_FakePushTokenStore` decide `ownerChanged` quando a linha já existe com outro `userId`. `PushTokenService` passa a receber `AuditTrail audit`, e o `setUp` usa um `_FakeAudit` local (subclasse de `AuditTrail` que só acumula `events`), o mesmo que `notice_service_test.dart` já faz; não há `test/support/fake_audit_trail.dart`.) + +Run: `cd backend/sinalacs_server && dart test test/unit/data_subject_rights_service_test.dart test/unit/push_token_service_test.dart` +Expected: FAIL — `recordConsentRevokingPush`, `PushRegistration` e o parâmetro `audit` não existem. + +- [ ] **Step 2: Implementar serviços** + +`push_token_service.dart`: + +```dart +/// Desfecho de [PushTokenStore.registerIfConsented]. +enum PushRegistration { registered, ownerChanged, refused } + +/// Teto de aparelhos por titular. Passou do teto, o token mais antigo sai: quem +/// troca de celular nunca é recusado por causa de aparelhos velhos. +const int maxPushTokensPerUser = 10; +``` + +A interface passa a devolver `Future`, e o comentário diz que `ownerChanged` é o caso em que o token já existia para outro titular. `register` termina com: + +```dart + final outcome = await _store.registerIfConsented( + userId: user.id, + microAreaId: user.microAreaId, + token: trimmed, + platform: platform, + now: _clock().toUtc(), + ); + if (outcome == PushRegistration.refused) { + throw DataRightsException( + message: 'Ative "Avisos da equipe de saúde" em Meus Dados para receber avisos.', + ); + } + // Só a troca de dono deixa rastro: é o único evento que move um vínculo + // aparelho↔titular sem ação do titular anterior. Registrar e repetir não + // auditam, para não gravar uma linha por login. + if (outcome == PushRegistration.ownerChanged) { + await _audit.recordSafely(AuditEvent( + userId: user.id, + actionType: 'write', + resourceType: 'push_token', + result: 'granted', + )); + } +``` + +Confirme em `AuditEvent` se `resourceId` é obrigatório; se for, passe `userId`. O texto do token nunca entra na trilha. + +`data_subject_rights_service.dart`: na interface, `Future recordConsentRevokingPush(ConsentLogEntry entry);` com comentário "grava o `denied` de `segmentedPush` e apaga os tokens do titular na mesma transação". Em `updateConsent`, para `segmentedPush` com `granted == false`, chamar um `_record` que use `_store.recordConsentRevokingPush` em vez de `recordConsent` (parametrize `_record` com `revokePush: bool`). Remova o parâmetro `pushTokens`, o campo `_pushTokens` e o import de `PushTokenStore`. Remova também `_isCurrentAcceptance` e faça `hasAcceptedCurrentTerms` chamar `_store.latestConsent(...)` e comparar com uma função pública `bool isCurrentTermsAcceptance(ConsentRecordSnapshot? latest)` no mesmo arquivo, que `orm_data_subject_rights_store.dart` também usa em `recordConsentUnlessCurrent` (uma regra só). A regra recebe `{required String version}`: use `isCurrentAcceptance(latest, version: entry.version)`. + +- [ ] **Step 3: Rodar unitários** + +Run: `dart test test/unit/data_subject_rights_service_test.dart test/unit/push_token_service_test.dart` +Expected: PASS. + +- [ ] **Step 4: Testes de integração vermelhos** + +Em `push_token_endpoint_test.dart`, no grupo de corrida existente (ids `9200…`, semente `_seedRaceLean`, o `cleanup` já apaga `PushToken` e `ConsentLog` do titular), três testes novos. Esse grupo COMMITA: mantenha as sementes enxutas (sem `Patient`), porque outros arquivos contam tabelas inteiras e já ficaram intermitentes por causa disso: + +```dart +test('revogação atômica: falha ao apagar tokens não deixa denied gravado', () async { + // _seedRaceLean + consentimento granted + token registrado (via store) + // constrói OrmDataSubjectRightsStore(..., debugFailAfterTokenDelete: true) + // chama recordConsentRevokingPush(entry 'denied') e espera o erro + // depois: o último consent do titular continua 'granted' e o token continua lá +}); + +test('passou do teto, o token mais antigo sai e o novo entra', () async { + // concede; registra 'tok-a'..'tok-k' (11 tokens) em sequência, com `now` crescente + // esperado: count == 10, 'tok-a' ausente, 'tok-k' presente +}); + +test('troca de dono devolve ownerChanged', () async { + // dois titulares consentidos, mesmo token + // primeiro: registered; segundo: ownerChanged; repetição do segundo: registered +}); +``` + +Para o primeiro, uma falha "natural" do banco não dá: o `userId` que apaga os tokens é o mesmo que o `INSERT` do consentimento exige por FK, então não existe entrada que faça só o segundo passo falhar. Por isso `OrmDataSubjectRightsStore` ganha o parâmetro nomeado opcional `@visibleForTesting bool debugFailAfterTokenDelete = false`: com ele ligado, `recordConsentRevokingPush` lança `StateError` **depois** do `deleteWhere` e **antes** do `insertRow`, na mesma transação. A ordem dentro da transação passa a ser: trava por titular → apagar tokens → gravar o consentimento. O teste prova o que importa: a transação inteira reverteu (o token continua no banco e nenhum `denied` existe). + +Run: `dart test test/integration/push_token_endpoint_test.dart` +Expected: FAIL — `recordConsentRevokingPush` não existe. + +- [ ] **Step 5: Implementar stores** + +`orm_data_subject_rights_store.dart`: + +```dart + @override + Future recordConsentRevokingPush(ConsentLogEntry entry) async { + final session = _session(); + final userUuid = UuidValue.fromString(entry.userId); + return session.db.transaction((transaction) async { + await lockPerSubject(session, transaction, + namespace: lockNamespacePushToken, key: entry.userId); + await PushToken.db.deleteWhere( + session, + where: (t) => t.userId.equals(userUuid), + transaction: transaction, + ); + if (_debugFailAfterTokenDelete) { + throw StateError('falha injetada depois de apagar os tokens (só em teste)'); + } + final row = await ConsentLog.db.insertRow( + session, + signedConsentLog(entry, signature: _signature, origin: 'painel-titular'), + transaction: transaction, + ); + return row.id!.uuid; + }); + } +``` + +`orm_data_subject_rights_store.dart` ganha o parâmetro nomeado opcional `@visibleForTesting bool debugFailAfterTokenDelete = false` no construtor (campo `_debugFailAfterTokenDelete`), usado só pelo teste de atomicidade; `AlertRuntime` nunca o passa. + +`orm_push_token_store.dart`: `registerIfConsented` devolve `PushRegistration`. Sem consentimento: apaga o token de outro titular e devolve `refused`. Existente com outro dono: atualiza e devolve `ownerChanged`. Existente do mesmo dono: atualiza e devolve `registered`. Novo: insere; depois, ainda na transação, lê os tokens do titular ordenados por `updatedAt` descendente e apaga os que passam de `maxPushTokensPerUser`: + +```dart + final mine = await PushToken.db.find( + session, + where: (t) => t.userId.equals(userUuid), + orderBy: (t) => t.updatedAt, + orderDescending: true, + transaction: transaction, + ); + for (final old in mine.skip(maxPushTokensPerUser)) { + await PushToken.db.deleteRow(session, old, transaction: transaction); + } +``` + +O teto roda nos dois caminhos que gravam (insere e atualiza), para que trocar de dono para quem já está no teto também respeite. `deleteAllFor` deixa de existir na interface e no ORM (o único chamador era a revogação); remova-o de `PushTokenStore` e dos fakes. Ajuste `alert_runtime.dart` (o `PushTokenService` recebe `audit: auditTrailFor(session)` e `DataSubjectRightsService` perde o `pushTokens`). `devices_endpoint.dart` ganha no comentário: "Auditoria: só a troca de dono do token grava linha (`push_token`); registrar e repetir não, porque a revogação já deixa `consent_log`." + +- [ ] **Step 6: Suíte e commit** + +Run: `cd backend/sinalacs_server && dart test && dart analyze` +Expected: tudo verde, estável em 5 execuções seguidas de `dart test`; analyze em 50 infos, zero avisos. + +```bash +git add backend/sinalacs_server +git commit -m "fix(backend): revogação de push atômica, auditoria da troca de dono e teto de tokens (RF14)" +``` + +--- + +### Task 2: Agenda de mudança dos termos e endpoint do aviso (backend) + +**Files:** +- Create: `lib/src/application/patients/terms_change_schedule.dart`, `lib/src/models/api/terms_change_notice.spy.yaml`, `test/unit/terms_change_schedule_test.dart` +- Modify: `lib/src/application/patients/data_subject_rights_service.dart`, `lib/src/endpoints/patients_endpoint.dart`, `lib/src/runtime/alert_runtime.dart` (só se o serviço precisar de relógio/agenda injetada) +- Test: `test/unit/terms_change_schedule_test.dart`, `test/unit/data_subject_rights_service_test.dart`, `test/integration/data_subject_rights_endpoint_test.dart` + +**Interfaces:** +- Produces: `class TermsChangeSchedule { const TermsChangeSchedule({required this.version, required this.publishedAt, required this.effectiveFrom, required this.summary}); }` — o construtor tem `assert` de que `effectiveFrom.difference(publishedAt) >= dataSubjectRequestDeadline` (15 dias) e de que `version != consentPolicyVersion`. Método `bool isActiveAt(DateTime now)` → `!now.isBefore(publishedAt) && now.isBefore(effectiveFrom)`. +- Produces: `const TermsChangeSchedule? upcomingTermsChange = null;` (nada agendado hoje). +- Produces: DTO `TermsChangeNotice { version: String, effectiveFrom: DateTime, summary: String }` e RPC `patients.termsChangeNotice(session, {required String accessToken}) → Future`. +- Produces: `DataSubjectRightsService.termsChangeNoticeAt(AuthenticatedUser user, {TermsChangeSchedule? schedule}) → Future` — na verdade, sem I/O: síncrono, só paciente (mesma regra `_requirePatient`), usa o relógio injetado. + +- [ ] **Step 1: Testes vermelhos** + +`test/unit/terms_change_schedule_test.dart`: + +```dart +import 'package:sinalacs_server/src/application/patients/terms_change_schedule.dart'; +import 'package:test/test.dart'; + +final _pub = DateTime.utc(2026, 10, 1); + +TermsChangeSchedule agenda({DateTime? vigencia, String versao = '2026.2'}) => TermsChangeSchedule( + version: versao, + publishedAt: _pub, + effectiveFrom: vigencia ?? _pub.add(const Duration(days: 15)), + summary: 'Resumo.', + ); + +void main() { + test('vigência com menos de 15 dias da publicação não existe', () { + expect(() => agenda(vigencia: _pub.add(const Duration(days: 14, hours: 23))), + throwsA(isA())); + }); + + test('exatamente 15 dias é aceito', () { + expect(agenda().effectiveFrom, _pub.add(const Duration(days: 15))); + }); + + test('a versão nova não pode ser a vigente', () { + expect(() => agenda(versao: consentPolicyVersion), throwsA(isA())); + }); + + test('ativa na publicação, inativa na vigência', () { + final a = agenda(); + expect(a.isActiveAt(_pub.subtract(const Duration(seconds: 1))), isFalse); + expect(a.isActiveAt(_pub), isTrue); + expect(a.isActiveAt(a.effectiveFrom.subtract(const Duration(seconds: 1))), isTrue); + expect(a.isActiveAt(a.effectiveFrom), isFalse); + }); + + test('a agenda real do repositório respeita a regra (vazia hoje)', () { + final real = upcomingTermsChange; + if (real != null) { + expect(real.effectiveFrom.difference(real.publishedAt) >= const Duration(days: 15), isTrue); + } + }); +} +``` + +Importe `consentPolicyVersion` de `onboarding_service.dart`. Em `data_subject_rights_service_test.dart`: + +```dart +group('termsChangeNotice (LGPD-RF18, aviso de 15 dias)', () { + final agenda = TermsChangeSchedule( + version: '2026.2', + publishedAt: _now.subtract(const Duration(days: 1)), + effectiveFrom: _now.add(const Duration(days: 14)), + summary: 'Novo canal de dúvidas.', + ); + + test('sem agenda, não há aviso', () { + expect(service.termsChangeNotice(_patient), isNull); + }); + + test('com agenda ativa, devolve versão, vigência e resumo', () { + final svc = DataSubjectRightsService(store: store, audit: audit, clock: () => _now, termsChange: agenda); + final notice = svc.termsChangeNotice(_patient)!; + expect(notice.version, '2026.2'); + expect(notice.effectiveFrom, agenda.effectiveFrom); + expect(notice.summary, 'Novo canal de dúvidas.'); + }); + + test('fora da janela (já vigente) não há aviso', () { + final svc = DataSubjectRightsService( + store: store, audit: audit, clock: () => agenda.effectiveFrom, termsChange: agenda); + expect(svc.termsChangeNotice(_patient), isNull); + }); + + test('só paciente', () { + final svc = DataSubjectRightsService(store: store, audit: audit, clock: () => _now, termsChange: agenda); + expect(() => svc.termsChangeNotice(_acs), throwsA(isA())); + }); +}); +``` + +Run: `dart test test/unit/terms_change_schedule_test.dart test/unit/data_subject_rights_service_test.dart` +Expected: FAIL — `TermsChangeSchedule` e `termsChangeNotice` não existem. + +- [ ] **Step 2: Implementar** + +`terms_change_schedule.dart`: + +```dart +import 'package:sinalacs_server/src/application/onboarding/onboarding_service.dart' + show consentPolicyVersion; +import 'package:sinalacs_server/src/application/patients/data_subject_rights_service.dart' + show dataSubjectRequestDeadline; + +/// Antecedência mínima do aviso de mudança dos termos (LGPD-RF18): a mesma +/// contagem de 15 dias do prazo de resposta ao titular. +const Duration termsChangeNoticePeriod = dataSubjectRequestDeadline; + +/// Uma versão nova dos termos anunciada antes de valer. O construtor recusa +/// (em `assert`, que roda em desenvolvimento e nos testes) uma vigência a menos +/// de 15 dias da publicação: publicar o aviso tarde é um erro de quem edita o +/// repositório, não algo que o app deva corrigir depois. +/// +/// Para agendar uma mudança: acrescente uma [LegalVersion] no app, troque +/// [upcomingTermsChange] por uma agenda com a versão nova, e só depois — na +/// data de vigência — mude `consentPolicyVersion` e `legalDocumentsVersion`. +class TermsChangeSchedule { + const TermsChangeSchedule({ + required this.version, + required this.publishedAt, + required this.effectiveFrom, + required this.summary, + }) : assert(version != consentPolicyVersion, 'a versão anunciada já é a vigente'), + assert( + effectiveFrom.difference(publishedAt) >= termsChangeNoticePeriod, + 'o aviso exige 15 dias entre a publicação e a vigência', + ); + + final String version; + final DateTime publishedAt; + final DateTime effectiveFrom; + final String summary; + + bool isActiveAt(DateTime now) => !now.isBefore(publishedAt) && now.isBefore(effectiveFrom); +} + +/// Nada agendado hoje. +const TermsChangeSchedule? upcomingTermsChange = null; +``` + +`DateTime.difference` não é `const`: como o `assert` só roda em runtime, o construtor `const` com `assert` que chama métodos não constantes não compila. Use então um construtor não `const` e a agenda como `final TermsChangeSchedule? upcomingTermsChange = null;`. O ciclo de imports (`terms_change_schedule` importa `data_subject_rights_service`, que importa a agenda): quebre-o movendo `dataSubjectRequestDeadline` só para uso local aqui — defina `const Duration termsChangeNoticePeriod = Duration(days: 15);` sem importar o serviço. + +`DataSubjectRightsService` ganha o parâmetro nomeado opcional `TermsChangeSchedule? termsChange` (o padrão é `upcomingTermsChange`) e: + +```dart + /// Aviso de mudança dos termos ativo agora, ou `null` (LGPD-RF18). Sem I/O. + TermsChangeNoticeSnapshot? termsChangeNotice(AuthenticatedUser user) { + _requirePatient(user); + final schedule = _termsChange; + if (schedule == null || !schedule.isActiveAt(_clock().toUtc())) return null; + return TermsChangeNoticeSnapshot( + version: schedule.version, + effectiveFrom: schedule.effectiveFrom, + summary: schedule.summary, + ); + } +``` + +com `class TermsChangeNoticeSnapshot { const ...; final String version; final DateTime effectiveFrom; final String summary; }` no mesmo arquivo. Nos testes acima, `termsChangeNotice` é síncrono (`expect(service.termsChangeNotice(_patient), isNull)`). + +DTO `terms_change_notice.spy.yaml` (na pasta de DTOs de `models/`, conforme `patient_data_overview`, confirmando o local com `ls lib/src/models/api`): + +```yaml +### Aviso de mudança dos termos (LGPD-RF18): a versão que passa a valer, a data +### de vigência e um resumo do que muda. Publicado com pelo menos 15 dias de +### antecedência (`TermsChangeSchedule`). +class: TermsChangeNotice +fields: + version: String + effectiveFrom: DateTime + summary: String +``` + +`PatientsEndpoint`: + +```dart + /// Aviso de mudança dos termos ativo agora (LGPD-RF18), ou `null`. Só + /// paciente; sem leitura de banco e sem linha de auditoria. + Future termsChangeNotice( + Session session, { + required String accessToken, + }) async { + final user = authenticate(accessToken); + try { + final notice = + AlertRuntime.instance.dataSubjectRightsServiceFor(session).termsChangeNotice(user); + return notice == null + ? null + : TermsChangeNotice( + version: notice.version, + effectiveFrom: notice.effectiveFrom, + summary: notice.summary, + ); + } on StateError catch (error) { + throw AlertPermissionException(message: error.message); + } + } +``` + +Rode `serverpod generate` (e `create-migration`: "No changes detected" é o esperado, é só DTO). + +- [ ] **Step 3: Integração** + +Em `data_subject_rights_endpoint_test.dart`, grupo principal: + +```dart +test('termsChangeNotice sem agenda devolve null e não grava auditoria', () async { + final session = sessionBuilder.build(); + await _seed(session); + final token = await patientToken(); + final before = await AuditLog.db.count(session); + + expect(await endpoints.patients.termsChangeNotice(sessionBuilder, accessToken: token), isNull); + expect(await AuditLog.db.count(session), before); +}); + +test('termsChangeNotice recusa token inválido', () async { + await expectLater( + endpoints.patients.termsChangeNotice(sessionBuilder, accessToken: 'lixo'), + throwsA(isA()), + ); +}); +``` + +Run: `dart test test/integration/data_subject_rights_endpoint_test.dart` +Expected: PASS. Se `endpoint_auth_posture_test.dart` ou o teste de cobertura de auditoria acusar o método novo, registre a decisão "leitura sem I/O, sem auditoria" no teste, como `hasAcceptedCurrentTerms` fez. + +- [ ] **Step 4: Suíte e commit** + +Run: `cd backend/sinalacs_server && dart test && dart analyze` +Expected: verde e estável em 5 execuções; 50 infos. + +```bash +git add backend/sinalacs_server apps/patient/lib +git commit -m "feat(backend): agenda e aviso de 15 dias de mudança dos termos (LGPD-RF18)" +``` + +(`apps/patient/lib`, porque `serverpod generate` regenera `sinalacs_client` sob `backend/`; confirme o caminho real do cliente gerado com `git status` antes de adicionar.) + +--- + +### Task 3: Aviso no app do paciente e menores do cliente + +**Files:** +- Create: `apps/patient/lib/core/legal/terms_change_notice_card.dart`, `apps/patient/test/terms_change_notice_test.dart` +- Modify: `lib/core/network/backend_client.dart`, `lib/core/push/push_token_source.dart`, `lib/app/app.dart`, `test/support/fake_patient_backend.dart`, `test/push_registration_test.dart` + +**Interfaces:** +- Produces: `PatientBackend.termsChangeNotice() → Future` (interface, `MisconfiguredBackend` que lança `failure`, `BackendClient` que chama `_client.patients.termsChangeNotice`). +- Produces: `PushTokenScope.maybeOf(BuildContext) → PushTokenSource` que cai em `const NoPushTokenSource()` quando não há escopo; `of` continua existindo com o `assert`. +- Produces: `class TermsChangeNoticeCard extends StatelessWidget { const TermsChangeNoticeCard({required this.notice, required this.onDismiss, required this.onRead}); }` com chaves `terms_change_notice_card`, `terms_change_notice_read`, `terms_change_notice_dismiss`. +- Consumes: `TermsChangeNotice` do cliente gerado (Task 2) e `LegalDocumentScreen` de `legal_screens.dart` (confirme o nome e os argumentos lendo o arquivo antes; o botão "Ler" abre a Política/Termo vigentes, porque o texto da versão nova só existe no app quando a versão é publicada). + +- [ ] **Step 1: Testes vermelhos** + +`test/terms_change_notice_test.dart` (copie o `login` de `terms_gate_flow_test.dart`; `FakePatientBackend` ganha `TermsChangeNotice? termsNotice`, `int termsNoticeCalls`, `BackendFailure? termsNoticeFailure` e `Completer? termsNoticeGate`): + +```dart +final _aviso = TermsChangeNotice( + version: '2026.2', + effectiveFrom: DateTime.utc(2026, 10, 20), + summary: 'Novo canal de dúvidas.', +); + +testWidgets('com aviso ativo, a home mostra o cartão com a data e o resumo', (tester) async { + final backend = FakePatientBackend()..termsNotice = _aviso; + await tester.pumpWidget(SinalAcsApp(backend: backend)); + await login(tester); + + expect(find.byKey(const Key('terms_change_notice_card')), findsOneWidget); + expect(find.textContaining('20/10/2026'), findsOneWidget); + expect(find.textContaining('Novo canal de dúvidas.'), findsOneWidget); +}); + +testWidgets('sem aviso, não há cartão', (tester) async { + await tester.pumpWidget(SinalAcsApp(backend: FakePatientBackend())); + await login(tester); + expect(find.byKey(const Key('terms_change_notice_card')), findsNothing); +}); + +testWidgets('dispensar some com o cartão e não volta na mesma sessão', (tester) async { + final backend = FakePatientBackend()..termsNotice = _aviso; + await tester.pumpWidget(SinalAcsApp(backend: backend)); + await login(tester); + await tester.tap(find.byKey(const Key('terms_change_notice_dismiss'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('terms_change_notice_card')), findsNothing); +}); + +testWidgets('falha ao buscar o aviso não afeta a home nem mostra erro', (tester) async { + final backend = FakePatientBackend() + ..termsNotice = _aviso + ..termsNoticeFailure = const BackendFailure('sem rede'); + await tester.pumpWidget(SinalAcsApp(backend: backend)); + await login(tester); + expect(find.byType(PatientHomeShell), findsOneWidget); + expect(find.byKey(const Key('terms_change_notice_card')), findsNothing); + expect(find.textContaining('sem rede'), findsNothing); +}); + +testWidgets('servidor que nunca responde não atrasa a home', (tester) async { + final backend = FakePatientBackend() + ..termsNotice = _aviso + ..termsNoticeGate = Completer(); + await tester.pumpWidget(SinalAcsApp(backend: backend)); + await login(tester); + expect(find.byType(PatientHomeShell), findsOneWidget); + expect(find.byKey(const Key('terms_change_notice_card')), findsNothing); +}); +``` + +Em `push_registration_test.dart`, mais dois: + +```dart +testWidgets('fonte de token que nunca completa não atrasa a home', (tester) async { + await tester.pumpWidget(SinalAcsApp( + backend: FakePatientBackend(), + pushTokens: _NeverSource(), + )); + await login(tester); + expect(find.byType(PatientHomeShell), findsOneWidget); +}); + +testWidgets('login fora do SinalAcsApp (sem PushTokenScope) degrada para sem push', (tester) async { + final backend = FakePatientBackend(); + await tester.pumpWidget(BackendScope( + backend: backend, + child: /* mesmos escopos que SinalAcsApp monta, menos o PushTokenScope */, + )); + // login e esperado: home aberta, backend.pushRegistrations vazio +}); +``` + +com `class _NeverSource implements PushTokenSource { @override Future currentDevice() => Completer().future; }`. Para o segundo, monte a árvore com `LocationScope`, `RemindersScope`, `QrScannerScope` e `MaterialApp(home: PatientLoginScreen())` sem o `PushTokenScope`; se montar isso à mão ficar longo, extraia um helper de teste `pumpSemPushScope`. Adapte as chaves e o `login` ao que os testes vizinhos usam. + +Run: `cd apps/patient && flutter test test/terms_change_notice_test.dart test/push_registration_test.dart` +Expected: FAIL — `termsChangeNotice`, o cartão e `maybeOf` não existem; o teste "fonte que nunca completa" já passa por causa do `unawaited` (teste de caracterização, sem RED, ledgerar). + +- [ ] **Step 2: Implementar** + +`push_token_source.dart`: acrescente + +```dart + /// Como [of], mas sem escopo cai em [NoPushTokenSource]: o registro de push é + /// acessório e uma tela montada fora do `SinalAcsApp` não pode quebrar o login. + static PushTokenSource maybeOf(BuildContext context) => + context.dependOnInheritedWidgetOfExactType()?.source ?? + const NoPushTokenSource(); +``` + +e troque `PushTokenScope.of(context)` por `maybeOf` nos três pontos de `app.dart`. `backend_client.dart`: `termsChangeNotice` na interface (com comentário "o app ignora falha e lentidão; sem aviso, sem cartão"), no `MisconfiguredBackend` (`_recusar()`) e no `BackendClient` (mesmo formato de `hasAcceptedCurrentTerms`). + +`terms_change_notice_card.dart`: um `Card` com ícone, o título "Os termos vão mudar", `'A versão ${notice.version} passa a valer em ${_data(notice.effectiveFrom.toLocal())}. ${notice.summary}'`, um `TextButton` "Ler os termos atuais" (`onRead`) e um `IconButton` de fechar com `tooltip: 'Dispensar aviso'` (`onDismiss`), nas chaves acima. Cores com os tokens `*OnSurface` de `PatientColors` (regra de contraste do projeto), e o `Semantics` do botão de fechar vem do `tooltip`. + +`PatientHomeShell`: um `State` novo carrega o aviso em `initState` (`unawaited(_loadNotice())`) com `.timeout(_termsNoticeTimeout)` (3 s, mesma constante de valor que `_termsCheckTimeout`; reuse-a se ficar legível) e captura `BackendFailure`, `TimeoutException` e qualquer erro, guardando `_notice` e `_noticeDismissed`. O cartão entra acima do conteúdo da aba, fora do corpo rolável do alerta, só quando `_notice != null && !_noticeDismissed`. `onRead` abre o documento com `Navigator.push(MaterialPageRoute(builder: (_) => const LegalDocumentScreen(...)))` como o menu Mais já faz (`app.dart`, entrada "Privacidade e termos"). Se o shell for construído sem `BackendScope` em algum teste existente (o teste de `PatientHomeShell` só sob `RemindersScope` mencionado nos comentários), use `context.getInheritedWidgetOfExactType()` e pule a busca quando ausente, para não quebrá-lo. + +- [ ] **Step 3: Rodar e ver passar** + +Run: `cd apps/patient && flutter test test/terms_change_notice_test.dart test/push_registration_test.dart` +Expected: PASS. + +- [ ] **Step 4: Suíte, semântica e commit** + +Adicione ao `terms_change_notice_test.dart` um teste com `tester.ensureSemantics()` e `expectNenhumBotaoInerte` (helper de `test/support/semantics_scan.dart`) sobre o cartão, e um de contraste do texto do cartão contra a superfície em que ele renderiza (`contrastOn` de `test/support/contrast.dart`, mínimo 4.5). + +Run: `cd apps/patient && flutter test && flutter analyze; cd ../acs && flutter test` +Expected: tudo verde (190 + os novos), analyze limpo, ACS em 172. + +```bash +git add apps/patient +git commit -m "feat(paciente): cartão de aviso de mudança dos termos e push sem quebrar fora do escopo (LGPD-RF18)" +``` + +--- + +### Task 4: Documentação, memória e verificação final + +**Files:** +- Modify: `PROGRESS.md` (nova seção; tirar "aviso de 15 dias" e "menores" das pendências), `apps/CLAUDE.md` (o cartão e o `maybeOf`), `backend/CLAUDE.md` (revogação atômica, auditoria da troca de dono, teto de 10 tokens, `patients.termsChangeNotice` e `TermsChangeSchedule`, com o passo a passo de agendar uma mudança), `spec/lgpd_design.md` (aviso de 15 dias implementado, dentro do app), `spec/lgpd_data_audit.md` (`audit_logs` com `resourceType: push_token`; `push_tokens` com o teto), `spec/PRD_system.md` se citar o aviso, memória `patient-push-and-minors-2026-09-29` (atualizar em vez de criar outra) e `MEMORY.md`. + +- [ ] **Step 1:** Editar os docs acima, com as contagens reais medidas ao fim. + +- [ ] **Step 2: Verificação completa** + +Run: `cd backend/sinalacs_server && dart test && dart analyze; cd ../../apps/patient && flutter test && flutter analyze; cd ../acs && flutter test && flutter analyze; cd ../.. && ./scripts/qa/ci_invariants.sh; graphify update .` +Expected: backend verde (repetir `dart test` 5 vezes, sem falhas intermitentes), analyze do backend em 50 infos e zero avisos, paciente e ACS verdes, `ci_invariants` ok. + +- [ ] **Step 3: Commit** + +```bash +git add PROGRESS.md apps/CLAUDE.md backend/CLAUDE.md spec CLAUDE.md +git commit -m "docs: registra os menores do push fechados e o aviso de mudança dos termos" +``` + +--- + +## Fora desta rodada (por decisão) + +- Notificação por push ou SMS do aviso: o canal escolhido é o cartão dentro do app, porque o envio de push depende do projeto Firebase (§3.2) e o SMS é gateway de OTP, não de comunicados. +- Texto novo dos termos e troca de `consentPolicyVersion`: nenhuma mudança está agendada; `upcomingTermsChange` fica `null`. +- Revisão jurídica do texto 2026.1, backoffice, Firebase e apagar tokens no atendimento da exclusão. + +## Autorrevisão + +- **Cobertura:** minors da rodada 4 → Task 1 (atomicidade, auditoria da troca de dono, teto, regra de aceite única) e Task 3 (teste da fonte que nunca completa, `maybeOf`); aviso de 15 dias → Tasks 2 e 3; docs → Task 4. +- **Placeholders:** os pontos que dependem de nomes locais (pasta dos DTOs, argumentos de `LegalDocumentScreen`, campos obrigatórios de `AuditEvent`, montagem sem `PushTokenScope`) trazem a instrução de confirmação no passo; o comportamento esperado está definido em todos. +- **Tipos:** `PushRegistration`, `recordConsentRevokingPush`, `maxPushTokensPerUser`, `TermsChangeSchedule`, `termsChangeNotice` (síncrono no serviço, assíncrono no endpoint e no cliente) e `maybeOf` têm o mesmo nome e a mesma assinatura em todas as tasks. `dataSubjectRequestDeadline` não é importado pela agenda, para não criar ciclo. diff --git a/docs/superpowers/plans/2026-09-29-qr-onboarding-e-documentos-legais.md b/docs/superpowers/plans/2026-09-29-qr-onboarding-e-documentos-legais.md new file mode 100644 index 00000000..42a2fa22 --- /dev/null +++ b/docs/superpowers/plans/2026-09-29-qr-onboarding-e-documentos-legais.md @@ -0,0 +1,2196 @@ +# QR Code do onboarding e documentos legais — Plano de implementação + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fechar as duas lacunas restantes do app paciente escolhidas para esta rodada: (1) RF02 de ponta a ponta — o ACS gera o convite e mostra o QR Code, o paciente lê pela câmera; (2) LGPD-RF18/RF19/RF10 — Termo de Uso e Política de Privacidade versionados no app, com resumo visual do fluxo de dados, histórico de versões e aceite explícito gravado no cadastro. + +**Architecture:** O backend já tem `onboarding.generateEnrollmentToken` (ACS) e `onboarding.completeEnrollment` (paciente), mas nenhum app chama o primeiro, e o segundo só aceita o token digitado. O plano liga o ACS ao RPC existente e desenha o QR com `qr_flutter`; no paciente, `mobile_scanner` lê o QR e preenche o **mesmo** campo que já existe (o caminho manual continua). Os documentos legais são conteúdo Dart constante no app paciente (`lib/core/legal/`), versionados com o mesmo literal `2026.1` que o backend já carimba em `consent_logs.version`; o aceite vira uma quarta finalidade, `ConsentPurpose.termsOfUse`, gravada pela mesma transação do onboarding e obrigatória como `healthDataProcessing`. + +**Tech Stack:** Serverpod 3.4.13 (Dart), Postgres, Flutter 3.44 (`apps/patient`, `apps/acs`), `mobile_scanner` ^7.0.0 (paciente), `qr_flutter` ^4.1.0 (ACS). + +**Spec:** `spec/PRD_system.md` (RF02, linha 135) e `spec/lgpd_design.md` (LGPD-RF10 linha 157, RF18 linha 249, RF19 linha 259, retenção §5.6 linha 527). Leia os trechos antes de começar. + +## Global Constraints + +- Documentação, comentários, mensagens e texto de UI em **português**, no tom dos arquivos vizinhos. +- Nunca dado real de paciente em teste, log, screenshot ou config: tokens, nomes e UUIDs sintéticos (`Fulano de Tal`, `00000000-0000-4000-8000-00000000000N`). +- Nunca editar à mão `backend/sinalacs_server/lib/src/generated/`, `backend/sinalacs_client/`, `migrations/` ou `test/integration/test_tools/serverpod_test_tools.dart` — só via `serverpod generate` / `serverpod create-migration` (em `backend/sinalacs_server`, com `export PATH=$PATH:~/.pub-cache/bin`). +- **Não** rodar `dart format` em `apps/patient/lib/app/app.dart` nem em `apps/acs/lib/app/app.dart`: reflui centenas de linhas alheias. Aplicar as edições à mão, no estilo do entorno. Arquivos **novos** podem ser formatados. +- O token de convite em claro nunca vai para disco, log ou fila offline — só memória de tela (o servidor guarda só o hash, `enrollment_tokens.tokenHash`). +- Classificação de risco e territorialização não mudam: `generateEnrollmentToken` já recusa paciente de outra microárea (INV-01); o app não tenta contornar. +- Cor como texto usa o token `*OnSurface` (`AcsColors.redOnSurface`, `PatientColors.dangerOnSurface`), nunca a cor de preenchimento. +- Botões com `minimumSize: const Size(48, 52)` (WCAG 2.5.5); mensagens dinâmicas de erro dentro de `Semantics(liveRegion: true, ...)` (WCAG 4.1.3). +- `flutter analyze` tem de sair limpo (infos contam) nos dois apps; `cd backend && dart analyze` sem novos avisos além dos ~37 infos preexistentes. +- `consentPolicyVersion` (backend) e `legalDocumentsVersion` (app paciente) são o **mesmo** literal, `'2026.1'`; um teste do app guarda essa igualdade. +- Commits terminam com `Co-Authored-By: Claude Opus 5.5 `. Nada de push/merge sem perguntar. +- Depois de mudar código: `graphify update .` (na raiz; `graphify-out` é ignorado pelo git). +- Testes de backend exigem `docker compose --profile test up -d postgres-test` antes de `dart test`. + +## Review Focus + +1. **QR que não é convite** (link de site, Pix, QR de outro app) lido pela câmera → a pessoa vê "Este QR Code não é um convite do SinalACS…", e o campo do código **não** é sobrescrito. Teste na Tarefa 6 (`QR que não é convite mostra aviso e não preenche o campo`). +2. **Câmera indisponível ou permissão negada** → a leitura falha sem travar o onboarding; aparece um aviso para digitar o código, e o caminho manual continua funcionando. Teste na Tarefa 6 (`falha da câmera mostra aviso e o campo manual continua utilizável`). A página da câmera em si (canal de plataforma) só é verificável no aparelho — registrado em PROGRESS.md. +3. **ACS troca de paciente com um QR na tela** → o QR do paciente anterior some antes de gerar outro; nunca fica na tela um convite atribuído ao nome errado. Teste na Tarefa 5 (`trocar de paciente esconde o convite anterior`). +4. **Cliente que pula a tela e manda `termsAccepted: false`** → o servidor recusa com `EnrollmentException`, não grava nenhuma linha em `consent_logs` e o convite continua válido para nova tentativa. Testes na Tarefa 3 (unitário e integração). +5. **Versão do documento no app diverge da versão carimbada pelo backend** → um teste falha no CI do app paciente, em vez de o histórico de "Meus dados" mostrar um aceite de uma versão que o app nunca exibiu. Teste na Tarefa 1 (`versão dos documentos é a mesma que o backend carimba`). + +--- + +## Mapa de arquivos + +| Arquivo | Responsabilidade | Tarefa | +|---|---|---| +| `apps/patient/lib/core/legal/legal_documents.dart` (novo) | Modelo + conteúdo constante do Termo de Uso e da Política de Privacidade, versão vigente | 1 | +| `apps/patient/test/legal_documents_test.dart` (novo) | Conteúdo mínimo RF18/RF19 + guarda de versão contra o backend | 1 | +| `apps/patient/lib/app/legal_screens.dart` (novo) | `LegalDocumentsScreen` (índice) e `LegalDocumentScreen` (resumo visual + texto completo + histórico) | 2 | +| `apps/patient/lib/app/app.dart` | Link no login, item em "Mais", aceite no onboarding, botão de leitura do QR, `QrScannerScope` na raiz | 2, 3, 6 | +| `apps/patient/test/legal_screens_test.dart` (novo) | Navegação e renderização dos documentos | 2 | +| `backend/sinalacs_server/lib/src/models/enums/consent_purpose.spy.yaml` | `termsOfUse` | 3 | +| `backend/sinalacs_server/lib/src/application/onboarding/onboarding_service.dart` | Exigir aceite do termo | 3 | +| `backend/sinalacs_server/lib/src/endpoints/onboarding_endpoint.dart` | Parâmetro `termsAccepted` | 3 | +| `backend/sinalacs_server/lib/src/application/patients/data_subject_rights_service.dart` | Recusar `updateConsent(termsOfUse)` | 3 | +| `apps/patient/lib/core/network/backend_client.dart`, `lib/core/consent/consent_decisions.dart`, `test/support/fake_patient_backend.dart` | `termsAccepted` + rótulo da nova finalidade | 3 | +| `apps/acs/lib/core/network/backend_client.dart`, `test/support/fakes.dart`, `test/support/fake_rpc_server.dart` | `AcsBackend.generateInvite` | 4 | +| `apps/acs/lib/app/invite_screen.dart` (novo) | Tela "Convidar paciente" com QR | 5 | +| `apps/acs/lib/app/app.dart` | `AcsDestination.invite` + item em "Mais" | 5 | +| `apps/acs/test/invite_screen_test.dart` (novo) | Fluxo do convite | 5 | +| `apps/patient/lib/core/onboarding/enrollment_qr.dart` (novo) | Validação pura do conteúdo lido | 6 | +| `apps/patient/lib/app/qr_scanner.dart` (novo) | `QrScanner`, `QrScannerScope`, página da câmera (`mobile_scanner`) | 6 | +| `apps/patient/android/app/src/main/AndroidManifest.xml` | Permissão `CAMERA` | 6 | +| `PROGRESS.md`, `apps/CLAUDE.md`, `spec/lgpd_design.md`, `spec/PRD_system.md` | Registro do que foi feito e do que ficou de fora | 7 | + +Ordem obrigatória: 1 → 2 → 3 (3 abre os documentos a partir do onboarding); 4 → 5; 6 depois de 3 (ambas editam `OnboardingScreen`). 4–5 são independentes de 1–3. + +--- + +### Tarefa 1: Conteúdo versionado do Termo de Uso e da Política de Privacidade + +**Files:** +- Create: `apps/patient/lib/core/legal/legal_documents.dart` +- Test: `apps/patient/test/legal_documents_test.dart` + +**Interfaces:** +- Consumes: nada. +- Produces: + - `const String legalDocumentsVersion = '2026.1';` + - `class LegalSummaryStep { const LegalSummaryStep({required String title, required String text}); final String title; final String text; }` + - `class LegalSection { const LegalSection({required String title, required String body}); final String title; final String body; }` + - `class LegalVersion { const LegalVersion({required String version, required String date, required String changes}); ... }` + - `class LegalDocument { const LegalDocument({required String id, required String title, required String version, required String effectiveDate, required List summary, required List sections, required List history}); ... }` + - `const LegalDocument privacyPolicy` (id `'privacy'`) e `const LegalDocument termsOfUse` (id `'terms'`). + +- [ ] **Step 1: Escrever o teste que falha** + +Criar `apps/patient/test/legal_documents_test.dart`: + +```dart +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_patient/core/legal/legal_documents.dart'; + +/// Conteúdo mínimo que `spec/lgpd_design.md` exige de cada documento +/// (LGPD-RF18, linha 249; LGPD-RF19, linha 259). O teste procura o tópico no +/// título das seções, sem acento e em minúsculas, para não quebrar por uma +/// troca de redação que mantém o tópico. +String _norm(String text) => text + .toLowerCase() + .replaceAll(RegExp('[áàâã]'), 'a') + .replaceAll(RegExp('[éê]'), 'e') + .replaceAll('í', 'i') + .replaceAll(RegExp('[óôõ]'), 'o') + .replaceAll('ú', 'u') + .replaceAll('ç', 'c'); + +void expectTopics(LegalDocument document, List topics) { + final titles = document.sections.map((s) => _norm(s.title)).join(' | '); + for (final topic in topics) { + expect(titles, contains(topic), reason: '${document.title} sem seção sobre "$topic"'); + } +} + +void main() { + test('política de privacidade cobre o conteúdo mínimo do LGPD-RF19', () { + expectTopics(privacyPolicy, [ + 'quem cuida dos seus dados', + 'dados que coletamos', + 'para que usamos', + 'bases legais', + 'com quem compartilhamos', + 'por quanto tempo', + 'seus direitos', + 'como protegemos', + 'encarregado', + 'duvidas', + ]); + }); + + test('termo de uso cobre o conteúdo mínimo do LGPD-RF18', () { + expectTopics(termsOfUse, [ + 'regras de uso', + 'responsabilidades', + 'o que nao e permitido', + 'propriedade intelectual', + 'limites de responsabilidade', + 'lei aplicavel', + 'alteracoes', + ]); + }); + + test('os dois documentos têm resumo, versão vigente e histórico que a inclui', () { + for (final document in [privacyPolicy, termsOfUse]) { + expect(document.summary, isNotEmpty, reason: document.title); + expect(document.version, legalDocumentsVersion, reason: document.title); + expect(document.history.map((v) => v.version), contains(document.version), + reason: '${document.title}: versão vigente fora do histórico'); + for (final section in document.sections) { + expect(section.body.trim(), isNotEmpty, reason: '${document.title} › ${section.title}'); + } + } + }); + + test('versão dos documentos é a mesma que o backend carimba em consent_logs', () { + // Guarda de deriva: o aceite gravado no cadastro leva `consentPolicyVersion` + // do backend; se o app mostrar outra versão, "Meus dados" passa a exibir um + // aceite de um texto que a pessoa nunca viu. Monorepo: o CI do app faz + // checkout do repositório inteiro, então o arquivo do servidor existe. + final source = File( + '../../backend/sinalacs_server/lib/src/application/onboarding/onboarding_service.dart', + ).readAsStringSync(); + final match = RegExp(r"consentPolicyVersion = '([^']+)'").firstMatch(source); + expect(match, isNotNull, reason: 'consentPolicyVersion não encontrado no backend'); + expect(legalDocumentsVersion, match!.group(1)); + }); +} +``` + +- [ ] **Step 2: Rodar e ver falhar** + +Run: `cd apps/patient && flutter test test/legal_documents_test.dart` +Expected: FAIL na compilação — `legal_documents.dart` não existe. + +- [ ] **Step 3: Implementar o conteúdo** + +Criar `apps/patient/lib/core/legal/legal_documents.dart`: + +```dart +/// Termo de Uso e Política de Privacidade do app do paciente (LGPD-RF18, +/// LGPD-RF19 e LGPD-RF10 de `spec/lgpd_design.md`). +/// +/// Conteúdo constante, no próprio app, de propósito: o documento precisa +/// abrir antes do cadastro (a pessoa lê antes de aceitar) e sem rede. A versão +/// é a mesma que o backend grava em `consent_logs.version` +/// (`consentPolicyVersion`), e `test/legal_documents_test.dart` falha se as +/// duas divergirem. +/// +/// Trocar o texto exige versão nova: acrescente uma [LegalVersion] ao +/// histórico, mude [legalDocumentsVersion] **e** `consentPolicyVersion` no +/// backend. O texto de 2026.1 ainda precisa de revisão jurídica e dos dados +/// reais do controlador antes de produção (ver PROGRESS.md). +library; + +const String legalDocumentsVersion = '2026.1'; + +/// Um passo do resumo visual (versão resumida do documento). +class LegalSummaryStep { + const LegalSummaryStep({required this.title, required this.text}); + + final String title; + final String text; +} + +/// Uma seção da versão completa. +class LegalSection { + const LegalSection({required this.title, required this.body}); + + final String title; + final String body; +} + +/// Uma entrada do histórico de versões (controle de versões consultável, +/// LGPD-RF18). +class LegalVersion { + const LegalVersion({required this.version, required this.date, required this.changes}); + + final String version; + + /// Data de vigência, já no formato de exibição (`dd/mm/aaaa`). + final String date; + final String changes; +} + +class LegalDocument { + const LegalDocument({ + required this.id, + required this.title, + required this.version, + required this.effectiveDate, + required this.summary, + required this.sections, + required this.history, + }); + + final String id; + final String title; + final String version; + final String effectiveDate; + final List summary; + final List sections; + final List history; +} + +const _history2026_1 = [ + LegalVersion( + version: '2026.1', + date: '29/09/2026', + changes: 'Primeira versão publicada no aplicativo.', + ), +]; + +/// Política de Privacidade. O [LegalDocument.summary] é o resumo visual do +/// fluxo de dados pedido pelo LGPD-RF10: de onde o dado sai, por onde passa e +/// quem o vê, na ordem em que acontece. +const privacyPolicy = LegalDocument( + id: 'privacy', + title: 'Política de Privacidade', + version: legalDocumentsVersion, + effectiveDate: '29/09/2026', + summary: [ + LegalSummaryStep( + title: 'Você informa', + text: 'Seu cadastro vem da sua UBS. No app, você responde a triagem, ' + 'pode enviar um alerta de urgência e escolhe seus consentimentos.', + ), + LegalSummaryStep( + title: 'O app protege', + text: 'Tudo viaja criptografado. Suas condições de saúde, respostas de ' + 'triagem e anotações de visita são guardadas cifradas no servidor.', + ), + LegalSummaryStep( + title: 'A equipe da sua área vê', + text: 'Só o agente comunitário de saúde da sua microárea e a equipe da ' + 'sua UBS, para organizar as visitas pela prioridade.', + ), + LegalSummaryStep( + title: 'Em emergência', + text: 'Se o alerta for grave, o agente aciona o SAMU com o necessário ' + 'para o atendimento.', + ), + LegalSummaryStep( + title: 'Você decide', + text: 'Em "Meus dados" você vê o que está guardado, copia seus dados, ' + 'muda consentimentos e pede correção ou exclusão.', + ), + ], + sections: [ + LegalSection( + title: 'Quem cuida dos seus dados', + body: 'O responsável pelos seus dados (controlador) é a Secretaria ' + 'Municipal de Saúde do seu município, a quem pertence a UBS que ' + 'acompanha você. O SinalACS é a ferramenta que a equipe de saúde usa ' + 'para organizar as visitas.', + ), + LegalSection( + title: 'Dados que coletamos', + body: 'Cadastro: nome, data de nascimento, contato de emergência e se ' + 'você tem condição crônica. O CPF é guardado só de forma embaralhada ' + '(hash), usada para conferir o seu acesso.\n' + 'Saúde: condições crônicas e respostas da triagem, guardadas ' + 'cifradas, e a classificação de risco que resulta delas.\n' + 'Alerta de urgência: o horário e, se você permitir o GPS, uma área ' + 'aproximada de onde você está — nunca o endereço exato.\n' + 'Consentimentos: cada escolha que você faz, com data e versão deste ' + 'texto.\n' + 'Lembretes: ficam só no seu aparelho e não vão para o servidor.', + ), + LegalSection( + title: 'Para que usamos', + body: 'Para classificar o risco da sua triagem de forma igual para ' + 'todos, avisar o agente de saúde quando você pede ajuda, organizar a ' + 'fila de visitas da sua microárea e mostrar a você o andamento do seu ' + 'pedido. Não usamos seus dados para propaganda nem os vendemos.', + ), + LegalSection( + title: 'Bases legais', + body: 'Dados de saúde são tratados com o seu consentimento específico e ' + 'para a tutela da saúde (Lei 13.709/2018, art. 11). Os registros que ' + 'a lei manda guardar, como os de visita, seguem a obrigação legal ' + '(art. 7º, II). Lembretes e avisos só acontecem se você consentir, e ' + 'você pode mudar de ideia quando quiser.', + ), + LegalSection( + title: 'Com quem compartilhamos', + body: 'Com o agente comunitário de saúde da sua microárea e com a equipe ' + 'da sua UBS. Em emergência, com o SAMU, só o necessário para o ' + 'atendimento. Nenhum outro agente de saúde, de outra área, vê seus ' + 'dados.', + ), + LegalSection( + title: 'Por quanto tempo guardamos', + body: 'Alertas: 2 anos. Visitas: 5 anos, como pede a legislação de ' + 'saúde. Registros de acesso: 1 ano. Registros de consentimento: ' + 'enquanto existir o cadastro, para provar as suas escolhas. Depois ' + 'disso, os dados são apagados ou anonimizados.', + ), + LegalSection( + title: 'Seus direitos', + body: 'Você pode confirmar que tratamos seus dados, ver e copiar tudo o ' + 'que está guardado, pedir correção, pedir exclusão, retirar os ' + 'consentimentos que não são obrigatórios e saber com quem os dados ' + 'foram compartilhados. Faça isso em "Meus dados". Os pedidos são ' + 'respondidos em até 15 dias.', + ), + LegalSection( + title: 'Como protegemos', + body: 'Conexão criptografada entre o app e o servidor, dados de saúde ' + 'cifrados no banco, acesso limitado à equipe da sua microárea e ' + 'registro de cada acesso aos seus dados.', + ), + LegalSection( + title: 'Encarregado de dados', + body: 'O encarregado pelo tratamento de dados (DPO) é o da Secretaria ' + 'Municipal de Saúde do seu município. Você pode chegar até ele pela ' + 'sua UBS.', + ), + LegalSection( + title: 'Dúvidas sobre seus dados', + body: 'Fale com o seu agente comunitário de saúde ou com a sua UBS: eles ' + 'encaminham a sua pergunta ao encarregado. Pedidos de correção e de ' + 'exclusão podem ser feitos direto em "Meus dados".', + ), + LegalSection( + title: 'Mudanças nesta política', + body: 'Quando este texto mudar, o app mostra a nova versão com pelo ' + 'menos 15 dias de antecedência e explica o que mudou. As versões ' + 'anteriores ficam no histórico abaixo.', + ), + ], + history: _history2026_1, +); + +/// Termo de Uso, em linguagem simples (acessível para pessoas idosas e com +/// pouca leitura, LGPD-RF18). +const termsOfUse = LegalDocument( + id: 'terms', + title: 'Termo de Uso', + version: legalDocumentsVersion, + effectiveDate: '29/09/2026', + summary: [ + LegalSummaryStep( + title: 'Para quem é', + text: 'Para pacientes acompanhados por um agente comunitário de saúde, ' + 'com cadastro na UBS.', + ), + LegalSummaryStep( + title: 'Em risco de vida, ligue 192', + text: 'O botão de urgência avisa o seu agente de saúde. Se a vida estiver ' + 'em risco, ligue também para o SAMU.', + ), + LegalSummaryStep( + title: 'Use com verdade', + text: 'Responda a triagem com sinceridade e use o alerta só quando ' + 'precisar de ajuda.', + ), + LegalSummaryStep( + title: 'O app ajuda, não substitui', + text: 'A triagem organiza a prioridade das visitas. Ela não é consulta ' + 'nem diagnóstico.', + ), + ], + sections: [ + LegalSection( + title: 'Regras de uso', + body: 'O app é para você acompanhar sua saúde com a equipe da sua UBS: ' + 'responder a triagem, pedir ajuda em urgência, ver o andamento do seu ' + 'pedido e cuidar dos seus dados. O acesso é pessoal: entre só com o ' + 'seu CPF ou com o convite que o seu agente de saúde mostrou a você.', + ), + LegalSection( + title: 'Responsabilidades', + body: 'A equipe de saúde organiza as visitas pela classificação de risco. ' + 'Você se compromete a informar dados verdadeiros e a manter seu ' + 'celular protegido. A classificação da triagem não é diagnóstico e ' + 'não substitui uma consulta.', + ), + LegalSection( + title: 'O que não é permitido', + body: 'Enviar alertas falsos, usar o acesso de outra pessoa, tentar ver ' + 'dados de outras pessoas ou atrapalhar o funcionamento do app.', + ), + LegalSection( + title: 'Propriedade intelectual', + body: 'O app, sua marca e seu código pertencem aos seus desenvolvedores e ' + 'ao poder público que o adotou. O uso é gratuito e não transfere ' + 'nenhum desses direitos.', + ), + LegalSection( + title: 'Limites de responsabilidade', + body: 'O app depende de internet e do seu aparelho para funcionar. Ele ' + 'não garante tempo de atendimento. Em risco de vida, ligue 192 (SAMU) ' + 'sem esperar resposta pelo app.', + ), + LegalSection( + title: 'Lei aplicável', + body: 'Vale a lei brasileira, incluindo a Lei Geral de Proteção de Dados ' + '(Lei 13.709/2018). Questões judiciais são tratadas no foro do seu ' + 'município.', + ), + LegalSection( + title: 'Alterações neste termo', + body: 'Quando este termo mudar, o app mostra a nova versão com pelo menos ' + '15 dias de antecedência e pede o seu aceite de novo. As versões ' + 'anteriores ficam no histórico abaixo.', + ), + ], + history: _history2026_1, +); +``` + +- [ ] **Step 4: Rodar e ver passar** + +Run: `cd apps/patient && flutter test test/legal_documents_test.dart` +Expected: PASS, 4/4. + +- [ ] **Step 5: Analisar e commitar** + +Run: `cd apps/patient && flutter analyze` +Expected: `No issues found!` + +```bash +git add apps/patient/lib/core/legal/legal_documents.dart apps/patient/test/legal_documents_test.dart +git commit -m "feat(paciente): conteúdo versionado do Termo de Uso e da Política de Privacidade (LGPD-RF18/RF19) + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Tarefa 2: Telas dos documentos legais e pontos de entrada + +**Files:** +- Create: `apps/patient/lib/app/legal_screens.dart` +- Modify: `apps/patient/lib/app/app.dart` (tela de login, ~linha 462; `_showMoreDestinations`, ~linha 2616) +- Test: `apps/patient/test/legal_screens_test.dart` + +**Interfaces:** +- Consumes: `privacyPolicy`, `termsOfUse`, `LegalDocument` (Tarefa 1). +- Produces: + - `class LegalDocumentsScreen extends StatelessWidget { const LegalDocumentsScreen({super.key}); }` — tiles com keys `legal_open_privacy` e `legal_open_terms`. + - `class LegalDocumentScreen extends StatelessWidget { const LegalDocumentScreen({super.key, required LegalDocument document}); }` — keys `legal_version`, `legal_summary`, `legal_section_<índice>`, `legal_history`. + - Key `login_legal_link` (tela de login) e `more_legal` (menu "Mais"). + +- [ ] **Step 1: Escrever os testes que falham** + +Criar `apps/patient/test/legal_screens_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_patient/app/app.dart'; +import 'package:sinalacs_patient/app/legal_screens.dart'; +import 'package:sinalacs_patient/core/legal/legal_documents.dart'; + +import 'support/fake_patient_backend.dart'; +import 'support/semantics_scan.dart'; + +Future tapKey(WidgetTester tester, String key) async { + final finder = find.byKey(Key(key)); + await tester.ensureVisible(finder); + await tester.pumpAndSettle(); + await tester.tap(finder); + await tester.pumpAndSettle(); +} + +void main() { + testWidgets('antes de entrar, a tela de login abre a política e o termo', (tester) async { + // Ler antes de aceitar: os documentos têm de abrir sem sessão. + await tester.pumpWidget(SinalAcsApp(backend: FakePatientBackend())); + await tapKey(tester, 'login_legal_link'); + + expect(find.byType(LegalDocumentsScreen), findsOneWidget); + await tapKey(tester, 'legal_open_privacy'); + expect(find.text('Política de Privacidade'), findsWidgets); + expect(find.textContaining('Versão $legalDocumentsVersion'), findsOneWidget); + + await tester.pageBack(); + await tester.pumpAndSettle(); + await tapKey(tester, 'legal_open_terms'); + expect(find.text('Termo de Uso'), findsWidgets); + }); + + testWidgets('documento mostra resumo, texto completo expansível e histórico de versões', (tester) async { + tester.view.physicalSize = const Size(800, 2400); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + + await tester.pumpWidget(const MaterialApp(home: LegalDocumentScreen(document: privacyPolicy))); + + expect(find.byKey(const Key('legal_summary')), findsOneWidget); + for (final step in privacyPolicy.summary) { + expect(find.text(step.title), findsOneWidget); + } + // Versão completa recolhida por padrão: o corpo só aparece ao expandir. + final first = privacyPolicy.sections.first; + expect(find.text(first.body), findsNothing); + await tapKey(tester, 'legal_section_0'); + expect(find.text(first.body), findsOneWidget); + + await tester.ensureVisible(find.byKey(const Key('legal_history'))); + expect(find.textContaining('2026.1 · vigente desde 29/09/2026'), findsOneWidget); + }); + + testWidgets('documento não tem botão inerte para leitor de tela', (tester) async { + final handle = tester.ensureSemantics(); + await tester.pumpWidget(const MaterialApp(home: LegalDocumentScreen(document: termsOfUse))); + expectNenhumBotaoInerte(tester); + handle.dispose(); + }); +} +``` + +E, em `apps/patient/test/patient_app_mvp_test.dart`, acrescentar ao final de `main()` (antes da última `}`) um teste do menu "Mais", com o helper `login(tester)` que o arquivo já tem (linha ~161, o mesmo que os testes de "Meus dados" usam): + +```dart + testWidgets('menu Mais abre Privacidade e termos', (tester) async { + await tester.pumpWidget(SinalAcsApp(backend: FakePatientBackend())); + await login(tester); + await tester.tap(find.text('Mais')); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('more_legal'))); + await tester.pumpAndSettle(); + expect(find.byType(LegalDocumentsScreen), findsOneWidget); + }); +``` + +Adicione `import 'package:sinalacs_patient/app/legal_screens.dart';` no topo. + +- [ ] **Step 2: Rodar e ver falhar** + +Run: `cd apps/patient && flutter test test/legal_screens_test.dart test/patient_app_mvp_test.dart` +Expected: FAIL na compilação — `legal_screens.dart` não existe. + +- [ ] **Step 3: Implementar as telas** + +Criar `apps/patient/lib/app/legal_screens.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:sinalacs_patient/app/patient_theme.dart'; +import 'package:sinalacs_patient/core/legal/legal_documents.dart'; + +/// Índice "Privacidade e termos": abre a partir do login (antes do cadastro, +/// para ler antes de aceitar) e do menu "Mais" — Mais → Privacidade e termos → +/// documento, três toques (painel de privacidade em até 3 cliques, +/// LGPD-RF03). +class LegalDocumentsScreen extends StatelessWidget { + const LegalDocumentsScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Privacidade e termos')), + body: SafeArea( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + for (final (key, document) in const [ + ('legal_open_privacy', privacyPolicy), + ('legal_open_terms', termsOfUse), + ]) + Card( + child: ListTile( + key: Key(key), + leading: Icon( + document.id == 'privacy' ? Icons.privacy_tip_outlined : Icons.description_outlined, + ), + title: Text(document.title), + subtitle: Text('Versão ${document.version} · vigente desde ${document.effectiveDate}'), + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => LegalDocumentScreen(document: document)), + ), + ), + ), + ], + ), + ), + ); + } +} + +/// Um documento legal: primeiro o resumo visual (passos numerados, na ordem +/// em que o dado circula — LGPD-RF10), depois a versão completa em seções +/// expansíveis e, por fim, o histórico de versões (LGPD-RF18/RF19). +class LegalDocumentScreen extends StatelessWidget { + const LegalDocumentScreen({super.key, required this.document}); + + final LegalDocument document; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(document.title)), + body: SafeArea( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text( + 'Versão ${document.version} · vigente desde ${document.effectiveDate}', + key: const Key('legal_version'), + style: const TextStyle(color: Colors.white70), + ), + const SizedBox(height: 16), + const Text('Resumo', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + Column( + key: const Key('legal_summary'), + children: [ + for (final (index, step) in document.summary.indexed) + Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: PatientColors.accent, + foregroundColor: Colors.white, + child: Text('${index + 1}'), + ), + title: Text(step.title, style: const TextStyle(fontWeight: FontWeight.bold)), + subtitle: Text(step.text), + ), + ), + ], + ), + const SizedBox(height: 16), + const Text('Texto completo', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + for (final (index, section) in document.sections.indexed) + ExpansionTile( + key: Key('legal_section_$index'), + tilePadding: EdgeInsets.zero, + childrenPadding: const EdgeInsets.only(bottom: 12), + expandedAlignment: Alignment.centerLeft, + title: Text(section.title), + children: [Text(section.body)], + ), + const SizedBox(height: 16), + const Text('Histórico de versões', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + Column( + key: const Key('legal_history'), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final entry in document.history) + ListTile( + contentPadding: EdgeInsets.zero, + title: Text( + '${entry.version} · ${entry.version == document.version ? 'vigente desde' : 'de'} ${entry.date}', + ), + subtitle: Text(entry.changes), + ), + ], + ), + ], + ), + ), + ); + } +} +``` + +Confirme que `PatientColors.accent` existe em `apps/patient/lib/app/patient_theme.dart` (texto branco sobre o *preenchimento* accent é o uso previsto por `test/contrast_tokens_test.dart`). Se o nome do token de preenchimento for outro, use o equivalente e registre a troca. + +- [ ] **Step 4: Ligar os pontos de entrada em `app.dart`** + +Adicionar o import junto aos outros `package:sinalacs_patient/app/...`: + +```dart +import 'package:sinalacs_patient/app/legal_screens.dart'; +``` + +Na tela de login, dentro do bloco `if (_step == _LoginStep.credenciais) ...[` (~linha 461), logo **depois** do `SizedBox` que envolve o `OutlinedButton.icon` de `start_onboarding_button`, acrescentar: + +```dart + const SizedBox(height: 4), + TextButton( + key: const Key('login_legal_link'), + onPressed: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const LegalDocumentsScreen()), + ), + child: const Text('Política de Privacidade e Termo de Uso'), + ), +``` + +Em `_showMoreDestinations` (~linha 2616, uma linha só), acrescentar ao fim da lista `children`, depois do `ListTile` de "Meus dados", sem reformatar a linha: + +```dart +, ListTile(key: const Key('more_legal'), leading: const Icon(Icons.gavel_outlined), title: const Text('Privacidade e termos'), onTap: () { Navigator.pop(sheetContext); Navigator.of(context).push(MaterialPageRoute(builder: (_) => const LegalDocumentsScreen())); }) +``` + +- [ ] **Step 5: Rodar e ver passar** + +Run: `cd apps/patient && flutter test test/legal_screens_test.dart test/patient_app_mvp_test.dart` +Expected: PASS (3 novos + o do menu "Mais", e os existentes do MVP). + +- [ ] **Step 6: Suíte e commit** + +Run: `cd apps/patient && flutter analyze && flutter test` +Expected: `No issues found!` e todos os testes passando (145 anteriores + 5 da Tarefa 1–2 = 150, ajuste se a contagem inicial diferir). + +```bash +git add apps/patient/lib/app/legal_screens.dart apps/patient/lib/app/app.dart apps/patient/test/legal_screens_test.dart apps/patient/test/patient_app_mvp_test.dart +git commit -m "feat(paciente): telas de Privacidade e termos com resumo visual e histórico (LGPD-RF10/RF18/RF19) + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Tarefa 3: Aceite versionado do Termo de Uso no cadastro + +**Files:** +- Modify: `backend/sinalacs_server/lib/src/models/enums/consent_purpose.spy.yaml` +- Modify: `backend/sinalacs_server/lib/src/application/onboarding/onboarding_service.dart:83,138-146` +- Modify: `backend/sinalacs_server/lib/src/endpoints/onboarding_endpoint.dart:38-62` +- Modify: `backend/sinalacs_server/lib/src/application/patients/data_subject_rights_service.dart:63-74` +- Test: `backend/sinalacs_server/test/unit/onboarding_service_test.dart`, `test/unit/data_subject_rights_service_test.dart`, `test/integration/onboarding_endpoint_test.dart` +- Modify: `apps/patient/lib/core/network/backend_client.dart` (interface ~linha 103, `MisconfiguredBackend` ~212, `BackendClient` ~456) +- Modify: `apps/patient/lib/core/consent/consent_decisions.dart:27-31` +- Modify: `apps/patient/lib/app/app.dart` (`OnboardingScreen` ~linhas 596–808; `_purposeDescription` ~1720) +- Modify: `apps/patient/test/support/fake_patient_backend.dart:249-260` +- Test: `apps/patient/test/onboarding_flow_test.dart` + +**Interfaces:** +- Consumes: `LegalDocumentScreen`, `privacyPolicy`, `termsOfUse`, `legalDocumentsVersion` (Tarefas 1–2). +- Produces: + - `ConsentPurpose.termsOfUse` (enum gerado, cliente e servidor). + - `onboarding.completeEnrollment(..., required bool termsAccepted)`. + - `PatientBackend.completeEnrollment({required String token, required bool healthDataConsent, required bool remindersConsent, required bool pushConsent, required bool termsAccepted})`. + - Keys no onboarding: `onboarding_terms_accept`, `onboarding_open_terms`, `onboarding_open_privacy`. + - Mensagem de recusa: `'É preciso aceitar o Termo de Uso e a Política de Privacidade.'` (servidor e app, idêntica). + +- [ ] **Step 1: Testes unitários do backend que falham** + +Em `backend/sinalacs_server/test/unit/onboarding_service_test.dart`, grupo `completeEnrollment`: + +1. No teste `'consome o token, grava 3 consent_logs e emite sessão'`: renomear para `'consome o token, grava 4 consent_logs e emite sessão'`, acrescentar `ConsentPurpose.termsOfUse: true,` ao mapa `consents`, trocar `hasLength(3)` por `hasLength(4)` e acrescentar `expect(byPurpose[ConsentPurpose.termsOfUse], 'granted');`. Atualizar o comentário "um Set de 3 ações" para "4". +2. Nos testes `'um segundo uso do mesmo token falha…'` e `'token expirado falha'`: cada mapa `const {ConsentPurpose.healthDataProcessing: true}` vira `const {ConsentPurpose.healthDataProcessing: true, ConsentPurpose.termsOfUse: true}`. +3. Acrescentar ao grupo: + +```dart + test('recusa concluir sem aceitar o Termo de Uso, e o convite continua válido', () async { + final generated = await service.generateToken(acs, patientId: 'patient-1'); + + await expectLater( + () => service.completeEnrollment( + token: generated.token, + consents: const { + ConsentPurpose.healthDataProcessing: true, + ConsentPurpose.termsOfUse: false, + }, + ), + throwsA(isA().having( + (e) => e.message, + 'message', + 'É preciso aceitar o Termo de Uso e a Política de Privacidade.', + )), + ); + expect(store.consentLogs, isEmpty); + + // Mesma regra do consentimento obrigatório de saúde: a recusa não + // consome o convite, a pessoa pode aceitar e tentar de novo. + final user = await service.completeEnrollment( + token: generated.token, + consents: const { + ConsentPurpose.healthDataProcessing: true, + ConsentPurpose.termsOfUse: true, + }, + ); + expect(user.id, 'patient-1'); + }); + + test('o aceite do termo leva a versão vigente dos documentos', () async { + final generated = await service.generateToken(acs, patientId: 'patient-1'); + await service.completeEnrollment( + token: generated.token, + consents: const { + ConsentPurpose.healthDataProcessing: true, + ConsentPurpose.termsOfUse: true, + }, + ); + final terms = store.consentLogs.singleWhere((e) => e.purpose == ConsentPurpose.termsOfUse); + expect(terms.action, 'granted'); + expect(terms.version, consentPolicyVersion); + }); +``` + +Em `backend/sinalacs_server/test/unit/data_subject_rights_service_test.dart`, ao lado do teste que já recusa `healthDataProcessing` em `updateConsent` (procure `ConsentPurpose.healthDataProcessing` no arquivo e copie a montagem de `service`/usuário dele), acrescentar: + +```dart + test('recusa mexer no aceite do Termo de Uso pelo painel', () async { + await expectLater( + service.updateConsent(patient, purpose: ConsentPurpose.termsOfUse, granted: false), + throwsA(isA()), + ); + expect(store.consentLogs, isEmpty); + }); +``` + +(Use os nomes de variáveis que o teste vizinho usa para o serviço, o paciente e a store — `service`, `patient`, `store` acima são os esperados; ajuste se diferirem.) + +- [ ] **Step 2: Enum + serviço + endpoint** + +`backend/sinalacs_server/lib/src/models/enums/consent_purpose.spy.yaml` — acrescentar o valor e atualizar o comentário: + +```yaml +### As finalidades de consentimento do onboarding (decisão §2.2 de +### docs/superpowers/specs/2026-09-16-decisoes-produto-pos-validacao.md). +### `healthDataProcessing` e `termsOfUse` são obrigatórias para usar o app; +### as outras duas podem ser recusadas sem impedir o restante do fluxo. +### `termsOfUse` registra o aceite do Termo de Uso e da Política de +### Privacidade na versão `consentPolicyVersion` (LGPD-RF18/RF19) — não é +### consentimento revogável pelo painel, é a condição de uso do app. +enum: ConsentPurpose +serialized: byName +values: + - healthDataProcessing + - localReminders + - segmentedPush + - termsOfUse +``` + +`onboarding_service.dart` — atualizar o doc de `consentPolicyVersion`: + +```dart +/// Versão do texto de política vigente. Mesmo padrão que +/// `spec/lgpd_design.md` define para a política de privacidade — trocar +/// exige nova versão publicada, não incrementar este literal sem mudança de +/// texto real. É também a versão do Termo de Uso e da Política de Privacidade +/// que o app paciente exibe (`legalDocumentsVersion` em +/// `apps/patient/lib/core/legal/legal_documents.dart`); o teste +/// `apps/patient/test/legal_documents_test.dart` falha se as duas divergirem. +const String consentPolicyVersion = '2026.1'; +``` + +Em `completeEnrollment`, logo depois do `if (mandatory != true) { ... }`: + +```dart + // O aceite do Termo de Uso e da Política de Privacidade é a outra + // condição de uso (LGPD-RF18: "aceite explícito no cadastro"). Checado + // antes de consumir o convite, pelo mesmo motivo do consentimento de + // saúde: a recusa não pode queimar o QR. + if (consents[ConsentPurpose.termsOfUse] != true) { + throw EnrollmentException( + message: 'É preciso aceitar o Termo de Uso e a Política de Privacidade.', + ); + } +``` + +Atualizar o doc do método: "grava as 3 finalidades" → "grava as 4 finalidades". + +`onboarding_endpoint.dart` — acrescentar o parâmetro e o mapeamento: + +```dart + Future completeEnrollment( + Session session, { + required String token, + required bool healthDataConsent, + required bool remindersConsent, + required bool pushConsent, + required bool termsAccepted, + }) async { +``` + +e, no mapa `consents:`, `ConsentPurpose.termsOfUse: termsAccepted,`. Trocar o comentário "gravar os 3 `consent_logs`" por "gravar os 4 `consent_logs`". + +`data_subject_rights_service.dart` — em `updateConsent`, depois do `if` de `healthDataProcessing`: + +```dart + if (purpose == ConsentPurpose.termsOfUse) { + throw DataRightsException( + message: 'O aceite do Termo de Uso é feito no cadastro e não é alterado aqui. ' + 'Para deixar de usar o app, solicite a exclusão dos seus dados.', + ); + } +``` + +- [ ] **Step 3: Gerar código e migração** + +Run: +```bash +cd backend/sinalacs_server && export PATH=$PATH:~/.pub-cache/bin && serverpod generate && serverpod create-migration +``` +Expected: `generate` conclui; `create-migration` diz que não há mudança de banco (a coluna `consent_logs.purpose` é `String`). Se ele criar uma migração mesmo assim, inspecione o `migration.sql`: aceitável apenas se estiver vazio de DDL; qualquer `ALTER`/`DROP` é sinal de erro — pare e investigue. + +- [ ] **Step 4: Atualizar os testes de integração** + +Em `backend/sinalacs_server/test/integration/onboarding_endpoint_test.dart`, acrescentar `termsAccepted: true,` depois de cada uma das 5 linhas `pushConsent: …,`: + +```bash +cd backend/sinalacs_server && sed -i -E 's/^(\s*)pushConsent: (true|false),$/&\n\1termsAccepted: true,/' test/integration/onboarding_endpoint_test.dart && grep -c "termsAccepted: true," test/integration/onboarding_endpoint_test.dart +``` +Expected: `5`. + +Depois, à mão: +- teste `'completeEnrollment com token válido grava 3 consent_logs…'`: renomear para "4", `hasLength(3)` → `hasLength(4)`, acrescentar `expect(byPurpose['termsOfUse'], 'granted');`; +- teste da corrida: `hasLength(3)` → `hasLength(4)` e o comentário "3 linhas, não 6" → "4 linhas, não 8"; +- acrescentar, depois do teste `'recusa do consentimento obrigatório…'`, copiando o `_seed`/login dele: + +```dart + test('recusa do Termo de Uso falha, não grava nada e não consome o convite', () async { + final session = sessionBuilder.build(); + await _seed(session); + + final login = await endpoints.auth.developmentLogin(sessionBuilder, role: 'acs'); + final generated = await endpoints.onboarding.generateEnrollmentToken( + sessionBuilder, + accessToken: login.accessToken, + patientId: _patientId, + ); + + await expectLater( + endpoints.onboarding.completeEnrollment( + sessionBuilder, + token: generated.token, + healthDataConsent: true, + remindersConsent: true, + pushConsent: true, + termsAccepted: false, + ), + throwsA(isA()), + ); + final rows = await ConsentLog.db.find( + session, + where: (t) => t.userId.equals(UuidValue.fromString(_patientId)), + ); + expect(rows, isEmpty); + + // O mesmo convite ainda serve: a recusa foi antes do consumo. + final result = await endpoints.onboarding.completeEnrollment( + sessionBuilder, + token: generated.token, + healthDataConsent: true, + remindersConsent: true, + pushConsent: true, + termsAccepted: true, + ); + expect(result.accessToken, isNotEmpty); + }); +``` + +- [ ] **Step 5: Rodar o backend** + +Run: +```bash +docker compose --profile test up -d postgres-test +cd backend && dart analyze +cd sinalacs_server && dart test test/unit/onboarding_service_test.dart test/unit/data_subject_rights_service_test.dart test/integration/onboarding_endpoint_test.dart +``` +Expected: analyze sem novos avisos; os três arquivos passam, incluindo os 4 testes novos. + +- [ ] **Step 6: Commit do backend** + +```bash +git add backend/ +git commit -m "feat(backend): aceite versionado do Termo de Uso no onboarding (LGPD-RF18) + +Co-Authored-By: Claude Opus 5.5 " +``` + +(O app paciente não compila entre este commit e o próximo — o switch de `ConsentPurpose` deixou de ser exaustivo e `completeEnrollment` ganhou um parâmetro obrigatório. É o próximo passo desta mesma tarefa.) + +- [ ] **Step 7: Testes do app que falham** + +Em `apps/patient/test/onboarding_flow_test.dart`: + +1. Nos quatro testes que concluem o cadastro com sucesso ou chegam ao backend (`'marcar o obrigatório e concluir…'`, `'falha do backend mostra a mensagem…'`, `'…lembretes marcado grava…'`, `'…lembretes desmarcado grava…'`), acrescentar `await tapKey(tester, 'onboarding_terms_accept');` logo depois de `await tapKey(tester, 'onboarding_consent_health');`. +2. No mapa esperado de `'marcar o obrigatório e concluir…'`, acrescentar `'termsAccepted': true,`. +3. No primeiro teste (`'deve abrir com campo de token e os 3 consentimentos desmarcados'`), acrescentar: + +```dart + final termsAccept = tester.widget( + find.byKey(const Key('onboarding_terms_accept')), + ); + expect(termsAccept.value, isFalse); +``` + +4. Acrescentar ao final de `main()`: + +```dart + testWidgets('sem aceitar o termo, concluir mostra o motivo e não chama o backend', (tester) async { + final backend = FakePatientBackend(); + await tester.pumpWidget(SinalAcsApp(backend: backend)); + await openOnboarding(tester); + + await tester.enterText(find.byKey(const Key('onboarding_token_field')), 'convite-123'); + await tapKey(tester, 'onboarding_consent_health'); + await tapKey(tester, 'complete_enrollment_button'); + + expect(find.text('É preciso aceitar o Termo de Uso e a Política de Privacidade.'), findsOneWidget); + expect(backend.enrollmentCalls, isEmpty); + }); + + testWidgets('o onboarding abre o termo e a política antes do aceite', (tester) async { + await tester.pumpWidget(SinalAcsApp(backend: FakePatientBackend())); + await openOnboarding(tester); + + await tapKey(tester, 'onboarding_open_terms'); + expect(find.byType(LegalDocumentScreen), findsOneWidget); + expect(find.text('Termo de Uso'), findsWidgets); + await tester.pageBack(); + await tester.pumpAndSettle(); + + await tapKey(tester, 'onboarding_open_privacy'); + expect(find.text('Política de Privacidade'), findsWidgets); + }); +``` + +com `import 'package:sinalacs_patient/app/legal_screens.dart';` no topo. + +- [ ] **Step 8: Rodar e ver falhar** + +Run: `cd apps/patient && flutter test test/onboarding_flow_test.dart` +Expected: FAIL na compilação (`termsOfUse` não tratado no `switch`, `termsAccepted` ausente) — é o estado deixado pelo Step 6. + +- [ ] **Step 9: Camada de rede e rótulos do app** + +`apps/patient/lib/core/network/backend_client.dart` — nas três ocorrências de `completeEnrollment` (interface, `MisconfiguredBackend`, `BackendClient`), acrescentar `required bool termsAccepted,` depois de `required bool pushConsent,`; na interface, trocar o doc "gravando os 3 consentimentos" por "gravando os 4 registros de consentimento (incluindo o aceite do Termo de Uso)"; no `BackendClient`, passar `termsAccepted: termsAccepted,` para `_client.onboarding.completeEnrollment`. + +`apps/patient/test/support/fake_patient_backend.dart` — em `completeEnrollment`, acrescentar o parâmetro `required bool termsAccepted,` e a entrada `'termsAccepted': termsAccepted,` no mapa registrado. + +`apps/patient/lib/core/consent/consent_decisions.dart` — no `switch` de `consentPurposeLabel`: + +```dart + ConsentPurpose.termsOfUse => 'Termo de Uso e Política de Privacidade', +``` + +`apps/patient/lib/app/app.dart` — em `_purposeDescription` (~linha 1720), acrescentar o braço (não é exibido como switch, mas o `switch` precisa ser exaustivo): + +```dart + ConsentPurpose.termsOfUse => 'Aceito no cadastro.', +``` + +- [ ] **Step 10: Aceite na tela de onboarding** + +Em `OnboardingScreen` (`app.dart`): + +1. Estado, junto dos outros consentimentos: + +```dart + // Aceite do Termo de Uso e da Política de Privacidade (LGPD-RF18): também + // desmarcado por padrão e obrigatório, gravado pelo servidor com a versão + // vigente dos documentos. + bool _termsAccepted = false; +``` + +2. Em `_complete()`, logo depois do bloco que recusa `!_healthDataConsent`: + +```dart + if (!_termsAccepted) { + setState(() { + _error = 'É preciso aceitar o Termo de Uso e a Política de Privacidade.'; + }); + return; + } +``` + +3. Na chamada `completeEnrollment(...)`, acrescentar `termsAccepted: _termsAccepted,`. + +4. No `build`, depois do `CheckboxListTile` de `onboarding_consent_push` e antes do `SizedBox(height: 20)` que precede o botão: + +```dart + const SizedBox(height: 12), + const Align( + alignment: Alignment.centerLeft, + child: Text('Termo de Uso e Privacidade', style: TextStyle(fontWeight: FontWeight.bold)), + ), + Wrap( + spacing: 8, + children: [ + TextButton( + key: const Key('onboarding_open_terms'), + onPressed: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const LegalDocumentScreen(document: termsOfUse)), + ), + child: const Text('Ler o Termo de Uso'), + ), + TextButton( + key: const Key('onboarding_open_privacy'), + onPressed: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const LegalDocumentScreen(document: privacyPolicy)), + ), + child: const Text('Ler a Política de Privacidade'), + ), + ], + ), + CheckboxListTile( + key: const Key('onboarding_terms_accept'), + value: _termsAccepted, + onChanged: (value) => setState(() => _termsAccepted = value ?? false), + controlAffinity: ListTileControlAffinity.leading, + title: const Text( + 'Li e aceito o Termo de Uso e a Política de Privacidade ' + '(versão $legalDocumentsVersion) (obrigatório)', + ), + ), +``` + +5. Imports em `app.dart`: `import 'package:sinalacs_patient/core/legal/legal_documents.dart';` (o de `legal_screens.dart` já entrou na Tarefa 2). + +- [ ] **Step 11: Rodar e ver passar** + +Run: `cd apps/patient && flutter test test/onboarding_flow_test.dart test/consent_decisions_test.dart` +Expected: PASS, incluindo os 2 testes novos. + +- [ ] **Step 12: Suíte do app e commit** + +Run: `cd apps/patient && flutter analyze && flutter test` +Expected: `No issues found!` e suíte verde. + +Run também `cd apps/acs && flutter analyze` — o ACS consome o mesmo `sinalacs_client`; um `switch` exaustivo sobre `ConsentPurpose` ali quebraria. Expected: `No issues found!`. + +```bash +git add apps/patient/ +git commit -m "feat(paciente): aceite explícito do Termo de Uso e da Política no cadastro (LGPD-RF18) + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Tarefa 4: `generateInvite` na camada de rede do ACS + +**Files:** +- Modify: `apps/acs/lib/core/network/backend_client.dart` (interface ~linha 52; `MisconfiguredBackend` ~102; `BackendClient` ~329) +- Modify: `apps/acs/test/support/fakes.dart` (`FakeAcsBackend`) +- Modify: `apps/acs/test/support/fake_rpc_server.dart` (switch de `payload`) +- Test: `apps/acs/test/backend_client_test.dart` + +**Interfaces:** +- Consumes: RPC existente `onboarding.generateEnrollmentToken(accessToken, patientId) → EnrollmentTokenResult {token, expiresAt}`. +- Produces: + - `Future AcsBackend.generateInvite({required String patientId})`. + - `FakeAcsBackend.inviteCalls` (`List`, patientIds na ordem), `FakeAcsBackend.inviteFailure` (`BackendFailure?`); tokens sintéticos `'convite-sintetico-'` (n começa em 1), `expiresAt` = `DateTime.utc(2026, 9, 29, 10, 15)`. + +- [ ] **Step 1: Teste que falha** + +Em `apps/acs/test/backend_client_test.dart`, acrescentar ao final de `main()`: + +```dart + test('generateInvite pede o convite do paciente com o token da sessão', () async { + await backend.login(matricula: 'ACS-001', senha: 'senha-sintetica'); + + final invite = await backend.generateInvite( + patientId: '00000000-0000-4000-8000-000000000005', + ); + + expect(invite.token, 'convite-sintetico'); + expect(invite.expiresAt, DateTime.utc(2026, 9, 29, 10, 15)); + final request = server.requests.last; + expect(request.endpoint, 'onboarding'); + expect(request.method, 'generateEnrollmentToken'); + expect(request.args['patientId'], '00000000-0000-4000-8000-000000000005'); + expect(request.args['accessToken'], isNotEmpty); + }); + + test('generateInvite recusado por território vira mensagem de paciente fora da microárea', () async { + await backend.login(matricula: 'ACS-001', senha: 'senha-sintetica'); + server.rejectInviteWithPermission = true; + + await expectLater( + backend.generateInvite(patientId: '00000000-0000-4000-8000-000000000009'), + throwsA( + isA() + .having((f) => f.message, 'message', 'Este paciente não pertence à sua microárea.') + .having((f) => f.isRecoverable, 'isRecoverable', isFalse), + ), + ); + }); +``` + +- [ ] **Step 2: Rodar e ver falhar** + +Run: `cd apps/acs && flutter test test/backend_client_test.dart` +Expected: FAIL na compilação — `generateInvite` e `rejectInviteWithPermission` não existem. + +- [ ] **Step 3: Servidor de teste** + +Em `apps/acs/test/support/fake_rpc_server.dart`: + +1. Campo, junto de `rejectWith`: + +```dart + /// Faz `generateEnrollmentToken` recusar como o backend recusa paciente de + /// outra microárea (`AlertPermissionException`, INV-01). + bool rejectInviteWithPermission = false; +``` + +2. Em `_handle`, depois do `if` de `loginInstitutional`/`rejectWith`: + +```dart + if (method == 'generateEnrollmentToken' && rejectInviteWithPermission) { + await _respond(request, HttpStatus.badRequest, { + 'className': 'AlertPermissionException', + 'data': {'message': 'Paciente fora da microárea do ACS.'}, + }); + return; + } +``` + +3. No `switch` de `payload`, antes de `_ => null`: + +```dart + // Corpo de `onboarding.generateEnrollmentToken`. Token sintético: o real + // tem 43 caracteres base64url, mas o cliente não valida o formato. + 'generateEnrollmentToken' => { + 'token': 'convite-sintetico', + 'expiresAt': '2026-09-29T10:15:00.000Z', + }, +``` + +- [ ] **Step 4: Interface e implementações** + +Em `apps/acs/lib/core/network/backend_client.dart`, garantir `EnrollmentTokenResult` importado de `package:sinalacs_client/sinalacs_client.dart` (acrescente ao `show`/import existente do arquivo). + +Na interface `AcsBackend`, depois de `listPatients()`: + +```dart + /// Convite de onboarding de um paciente da própria microárea (RF02). O + /// token em claro volta só nesta resposta e vira o QR Code da tela + /// "Convidar paciente" — nunca é gravado no aparelho. + Future generateInvite({required String patientId}); +``` + +Em `MisconfiguredBackend`: + +```dart + @override + Future generateInvite({required String patientId}) async => _recusar(); +``` + +Em `BackendClient`, depois de `listPatients()`: + +```dart + @override + Future generateInvite({required String patientId}) async { + final token = await _requireToken(); + return _guard( + () => _client.onboarding.generateEnrollmentToken( + accessToken: token, + patientId: patientId, + ), + // O servidor recusa com `AlertPermissionException` quando o paciente + // não é da microárea do ACS (INV-01) — "este alerta" não faria sentido. + permissionMessage: 'Este paciente não pertence à sua microárea.', + ); + } +``` + +- [ ] **Step 5: Duplo do backend** + +Em `apps/acs/test/support/fakes.dart`, dentro de `FakeAcsBackend` (importe `EnrollmentTokenResult` de `package:sinalacs_client/sinalacs_client.dart` junto dos tipos já importados): + +```dart + /// patientIds pedidos em `generateInvite`, na ordem. + final List inviteCalls = []; + + /// Falha da geração do convite, como paciente fora da microárea. + BackendFailure? inviteFailure; + + @override + Future generateInvite({required String patientId}) async { + inviteCalls.add(patientId); + final failure = inviteFailure; + if (failure != null) throw failure; + return EnrollmentTokenResult( + token: 'convite-sintetico-${inviteCalls.length}', + expiresAt: DateTime.utc(2026, 9, 29, 10, 15), + ); + } +``` + +- [ ] **Step 6: Rodar e ver passar** + +Run: `cd apps/acs && flutter test test/backend_client_test.dart` +Expected: PASS, incluindo os 2 novos. + +- [ ] **Step 7: Suíte e commit** + +Run: `cd apps/acs && flutter analyze && flutter test` +Expected: `No issues found!`; 160 testes (158 + 2). + +```bash +git add apps/acs/lib/core/network/backend_client.dart apps/acs/test/support/fakes.dart apps/acs/test/support/fake_rpc_server.dart apps/acs/test/backend_client_test.dart +git commit -m "feat(acs): generateInvite na camada de rede, sobre onboarding.generateEnrollmentToken (RF02) + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Tarefa 5: Tela "Convidar paciente" com QR Code no app do ACS + +**Files:** +- Modify: `apps/acs/pubspec.yaml`, `apps/acs/pubspec.lock` (via `flutter pub add`) +- Create: `apps/acs/lib/app/invite_screen.dart` +- Modify: `apps/acs/lib/app/app.dart` (enum `AcsDestination` linha 255; switch de conteúdo ~linha 761; `_more` ~linha 769) +- Test: `apps/acs/test/invite_screen_test.dart` + +**Interfaces:** +- Consumes: `AcsBackend.generateInvite`, `FakeAcsBackend.inviteCalls/inviteFailure/patients/listPatientsFailure` (Tarefa 4 e existentes); `entrar(tester)` de `test/login_flow_test.dart` (copiado abaixo, porque não é importável). +- Produces: `class InviteScreen extends StatefulWidget { const InviteScreen({super.key}); }`; `AcsDestination.invite`; keys `invite_patient_`, `generate_invite_button`, `invite_qr`, `invite_expires_at`, `invite_token_text`, `invite_error`, `invite_retry`, `invite_no_patients`. + +- [ ] **Step 1: Dependência** + +Run: `cd apps/acs && flutter pub add 'qr_flutter:^4.1.0'` +Expected: `pubspec.yaml` ganha `qr_flutter: ^4.1.0`; `pubspec.lock` atualizado. + +- [ ] **Step 2: Testes que falham** + +Criar `apps/acs/test/invite_screen_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:sinalacs_acs/app/app.dart'; +import 'package:sinalacs_acs/core/network/backend_client.dart'; +import 'package:sinalacs_client/sinalacs_client.dart' show MicroAreaPatient; + +import 'support/fakes.dart'; +import 'support/semantics_scan.dart'; + +/// Mesmo caminho de `entrar` em `login_flow_test.dart` (não importável entre +/// arquivos de teste). Credenciais sintéticas. +Future entrar(WidgetTester tester) async { + await tester.enterText(find.byKey(const Key('matricula_field')), 'ACS-001'); + await tester.enterText(find.byKey(const Key('senha_field')), 'senha-sintetica'); + await tester.tap(find.byKey(const Key('login_button'))); + await tester.pumpAndSettle(); +} + +Future abrirConvite(WidgetTester tester) async { + await tester.tap(find.text('Mais')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Convidar paciente')); + await tester.pumpAndSettle(); +} + +Future tapKey(WidgetTester tester, String key) async { + final finder = find.byKey(Key(key)); + await tester.ensureVisible(finder); + await tester.pumpAndSettle(); + await tester.tap(finder); + await tester.pumpAndSettle(); +} + +FakeAcsBackend backendComPacientes() => FakeAcsBackend() + ..patients = [ + MicroAreaPatient( + patientId: syntheticPatientId(5), + name: 'Fulano de Tal', + isChronic: false, + chronicConditions: const [], + ), + MicroAreaPatient( + patientId: syntheticPatientId(6), + name: 'Ciclana da Silva', + isChronic: true, + chronicConditions: const ['diabetes'], + ), + ]; + +void main() { + testWidgets('escolher o paciente e gerar mostra o QR com validade', (tester) async { + tester.view.physicalSize = const Size(800, 2000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final backend = backendComPacientes(); + await tester.pumpWidget(SinalAcsApp(backend: backend, feedBuilder: (queue) => FakeAlertFeed(queue))); + await entrar(tester); + await abrirConvite(tester); + + final gerar = tester.widget(find.byKey(const Key('generate_invite_button'))); + expect(gerar.onPressed, isNull, reason: 'sem paciente escolhido não há convite'); + + await tapKey(tester, 'invite_patient_${syntheticPatientId(6)}'); + await tapKey(tester, 'generate_invite_button'); + + expect(backend.inviteCalls, [syntheticPatientId(6)]); + expect(find.byType(QrImageView), findsOneWidget); + expect(find.byKey(const Key('invite_expires_at')), findsOneWidget); + expect(find.textContaining('Válido até'), findsOneWidget); + expect(find.text('convite-sintetico-1'), findsOneWidget); + }); + + testWidgets('gerar de novo substitui o convite exibido', (tester) async { + tester.view.physicalSize = const Size(800, 2000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final backend = backendComPacientes(); + await tester.pumpWidget(SinalAcsApp(backend: backend, feedBuilder: (queue) => FakeAlertFeed(queue))); + await entrar(tester); + await abrirConvite(tester); + + await tapKey(tester, 'invite_patient_${syntheticPatientId(5)}'); + await tapKey(tester, 'generate_invite_button'); + await tapKey(tester, 'generate_invite_button'); + + expect(backend.inviteCalls, hasLength(2)); + expect(find.text('convite-sintetico-2'), findsOneWidget); + expect(find.text('convite-sintetico-1'), findsNothing); + }); + + testWidgets('trocar de paciente esconde o convite anterior', (tester) async { + // Um QR na tela atribuído ao nome errado ativaria o cadastro de outra + // pessoa no aparelho de quem ler. + tester.view.physicalSize = const Size(800, 2000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final backend = backendComPacientes(); + await tester.pumpWidget(SinalAcsApp(backend: backend, feedBuilder: (queue) => FakeAlertFeed(queue))); + await entrar(tester); + await abrirConvite(tester); + + await tapKey(tester, 'invite_patient_${syntheticPatientId(5)}'); + await tapKey(tester, 'generate_invite_button'); + expect(find.byType(QrImageView), findsOneWidget); + + await tapKey(tester, 'invite_patient_${syntheticPatientId(6)}'); + expect(find.byType(QrImageView), findsNothing); + expect(find.text('convite-sintetico-1'), findsNothing); + }); + + testWidgets('recusa do servidor mostra o motivo e nenhum QR', (tester) async { + tester.view.physicalSize = const Size(800, 2000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final backend = backendComPacientes() + ..inviteFailure = const BackendFailure('Este paciente não pertence à sua microárea.', isRecoverable: false); + await tester.pumpWidget(SinalAcsApp(backend: backend, feedBuilder: (queue) => FakeAlertFeed(queue))); + await entrar(tester); + await abrirConvite(tester); + + await tapKey(tester, 'invite_patient_${syntheticPatientId(5)}'); + await tapKey(tester, 'generate_invite_button'); + + expect(find.byKey(const Key('invite_error')), findsOneWidget); + expect(find.text('Este paciente não pertence à sua microárea.'), findsOneWidget); + expect(find.byType(QrImageView), findsNothing); + }); + + testWidgets('falha ao carregar pacientes permite tentar de novo', (tester) async { + final backend = backendComPacientes() + ..listPatientsFailure = const BackendFailure('Sem conexão com o servidor.'); + await tester.pumpWidget(SinalAcsApp(backend: backend, feedBuilder: (queue) => FakeAlertFeed(queue))); + await entrar(tester); + await abrirConvite(tester); + + expect(find.text('Sem conexão com o servidor.'), findsOneWidget); + backend.listPatientsFailure = null; + await tapKey(tester, 'invite_retry'); + expect(find.text('Fulano de Tal'), findsOneWidget); + }); + + testWidgets('microárea sem paciente explica e não oferece geração', (tester) async { + await tester.pumpWidget(SinalAcsApp(backend: FakeAcsBackend(), feedBuilder: (queue) => FakeAlertFeed(queue))); + await entrar(tester); + await abrirConvite(tester); + + expect(find.byKey(const Key('invite_no_patients')), findsOneWidget); + expect(find.byKey(const Key('generate_invite_button')), findsNothing); + }); + + testWidgets('tela de convite não tem botão inerte para leitor de tela', (tester) async { + final handle = tester.ensureSemantics(); + await tester.pumpWidget(SinalAcsApp(backend: backendComPacientes(), feedBuilder: (queue) => FakeAlertFeed(queue))); + await entrar(tester); + await abrirConvite(tester); + expectNenhumBotaoInerte(tester); + handle.dispose(); + }); +} +``` + +`syntheticPatientId`, `seedMicroAreaId` e `FakeAlertFeed` estão em `test/support/fakes.dart`; `expectNenhumBotaoInerte` em `test/support/semantics_scan.dart` (verificados em 2026-09-29). + +- [ ] **Step 3: Rodar e ver falhar** + +Run: `cd apps/acs && flutter test test/invite_screen_test.dart` +Expected: FAIL — não há item "Convidar paciente" no menu (`find.text('Convidar paciente')` não encontra nada). + +- [ ] **Step 4: Implementar a tela** + +Criar `apps/acs/lib/app/invite_screen.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:sinalacs_acs/app/acs_theme.dart'; +import 'package:sinalacs_acs/core/network/backend_client.dart'; +import 'package:sinalacs_acs/core/network/backend_scope.dart'; +import 'package:sinalacs_client/sinalacs_client.dart' show EnrollmentTokenResult, MicroAreaPatient; + +/// Convite de onboarding (RF02): o ACS escolhe um paciente da própria +/// microárea e mostra o QR Code que o app do paciente lê. +/// +/// O token em claro existe só na memória desta tela — nunca vai para disco, +/// log ou fila offline; o servidor guarda só o hash (`enrollment_tokens`). +/// Sai da tela, some o token. A lista mostra nome e condições crônicas, a +/// mesma minimização do seletor da visita de rotina (spec/lgpd_design.md:364). +class InviteScreen extends StatefulWidget { + const InviteScreen({super.key}); + + @override + State createState() => _InviteScreenState(); +} + +class _InviteScreenState extends State { + List? _patients; + MicroAreaPatient? _selected; + EnrollmentTokenResult? _invite; + String? _error; + bool _loading = true; + bool _generating = false; + + @override + void initState() { + super.initState(); + // `BackendScope.of` depende de herança: não pode rodar dentro do + // `initState` em si. + WidgetsBinding.instance.addPostFrameCallback((_) => _load()); + } + + Future _load() async { + setState(() { + _loading = true; + _error = null; + }); + try { + final patients = await BackendScope.of(context).listPatients(); + if (!mounted) return; + setState(() { + _patients = patients; + _loading = false; + }); + } on BackendFailure catch (failure) { + if (!mounted) return; + setState(() { + _error = failure.message; + _loading = false; + }); + } + } + + void _select(MicroAreaPatient patient) { + setState(() { + // O convite exibido pertence ao paciente anterior: nunca deixá-lo na + // tela sob o nome de outra pessoa. + if (_selected?.patientId != patient.patientId) _invite = null; + _selected = patient; + _error = null; + }); + } + + Future _generate() async { + final patient = _selected; + if (patient == null) return; + setState(() { + _generating = true; + _error = null; + }); + try { + final invite = await BackendScope.of(context).generateInvite(patientId: patient.patientId); + if (!mounted) return; + setState(() { + _invite = invite; + _generating = false; + }); + } on BackendFailure catch (failure) { + if (!mounted) return; + setState(() { + _invite = null; + _error = failure.message; + _generating = false; + }); + } + } + + String _hhmm(DateTime time) => + '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}'; + + @override + Widget build(BuildContext context) { + final patients = _patients; + final invite = _invite; + return ListView( + padding: const EdgeInsets.all(16), + children: [ + const Text('Convidar paciente', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + const Text( + 'Escolha o paciente e mostre o QR Code para ele ler no app SinalACS ' + 'Paciente. O convite vale por 15 minutos e só pode ser usado uma vez.', + ), + const SizedBox(height: 16), + if (_loading) + const Center(child: CircularProgressIndicator()) + else if (patients == null) + OutlinedButton.icon( + key: const Key('invite_retry'), + onPressed: _load, + style: OutlinedButton.styleFrom(minimumSize: const Size(48, 52)), + icon: const Icon(Icons.refresh), + label: const Text('Tentar de novo'), + ) + else if (patients.isEmpty) + const Text( + 'Nenhum paciente cadastrado na sua microárea.', + key: Key('invite_no_patients'), + ) + else ...[ + for (final patient in patients) + ListTile( + key: Key('invite_patient_${patient.patientId}'), + selected: _selected?.patientId == patient.patientId, + title: Text(patient.name), + subtitle: patient.chronicConditions.isEmpty ? null : Text(patient.chronicConditions.join(', ')), + trailing: _selected?.patientId == patient.patientId + ? const Icon(Icons.check_circle, color: AcsColors.accentOnSurface) + : null, + onTap: () => _select(patient), + ), + const SizedBox(height: 12), + FilledButton.icon( + key: const Key('generate_invite_button'), + onPressed: _selected == null || _generating ? null : _generate, + style: FilledButton.styleFrom(minimumSize: const Size(48, 52)), + icon: const Icon(Icons.qr_code_2), + label: Text(invite == null ? 'Gerar convite' : 'Gerar novo convite'), + ), + ], + if (_error != null) + Padding( + padding: const EdgeInsets.only(top: 16), + child: Semantics( + liveRegion: true, + child: Text( + _error!, + key: const Key('invite_error'), + style: const TextStyle(color: AcsColors.redOnSurface, fontWeight: FontWeight.bold), + ), + ), + ), + if (invite != null && _selected != null) ...[ + const SizedBox(height: 24), + Center( + child: Container( + key: const Key('invite_qr'), + // Fundo branco com margem: leitores de QR precisam da "zona + // silenciosa" clara em volta, e o tema do app é escuro. + color: Colors.white, + padding: const EdgeInsets.all(16), + child: QrImageView( + data: invite.token, + size: 240, + backgroundColor: Colors.white, + semanticsLabel: 'QR Code do convite de ${_selected!.name}', + ), + ), + ), + const SizedBox(height: 12), + Text( + 'Convite de ${_selected!.name} · Válido até ${_hhmm(invite.expiresAt.toLocal())} · uso único', + key: const Key('invite_expires_at'), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + const Text( + 'Se a câmera do paciente não funcionar, ele pode digitar este código:', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.white70), + ), + SelectableText( + invite.token, + key: const Key('invite_token_text'), + textAlign: TextAlign.center, + style: const TextStyle(fontFamily: 'monospace'), + ), + ], + ], + ); + } +} +``` + +Nota: a mensagem de erro no carregamento da lista (`patients == null`) é o mesmo `_error` exibido abaixo do botão "Tentar de novo". + +- [ ] **Step 5: Ligar no shell do ACS** + +Em `apps/acs/lib/app/app.dart`: +- import: `import 'package:sinalacs_acs/app/invite_screen.dart';` (junto de `acs_theme.dart`); +- linha 255: `enum AcsDestination { area, queue, map, visit, escalation, geofencing, notices, invite }`; +- no `switch` de conteúdo, depois de `AcsDestination.notices => const NoticesScreen(),`: `AcsDestination.invite => const InviteScreen(),`; +- em `_more`, depois do item de "Avisos à comunidade": `_moreItem(sheet, Icons.qr_code_2, 'Convidar paciente', AcsDestination.invite),`. + +- [ ] **Step 6: Rodar e ver passar** + +Run: `cd apps/acs && flutter test test/invite_screen_test.dart` +Expected: PASS, 7/7. + +- [ ] **Step 7: Suíte e commit** + +Run: `cd apps/acs && flutter analyze && flutter test` +Expected: `No issues found!`; 167 testes. + +```bash +git add apps/acs/pubspec.yaml apps/acs/pubspec.lock apps/acs/lib/app/invite_screen.dart apps/acs/lib/app/app.dart apps/acs/test/invite_screen_test.dart +git commit -m "feat(acs): tela Convidar paciente com QR Code do convite de onboarding (RF02) + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Tarefa 6: Leitura do QR Code pela câmera no app paciente + +**Files:** +- Modify: `apps/patient/pubspec.yaml`, `apps/patient/pubspec.lock` (via `flutter pub add`) +- Create: `apps/patient/lib/core/onboarding/enrollment_qr.dart` +- Create: `apps/patient/lib/app/qr_scanner.dart` +- Modify: `apps/patient/lib/app/app.dart` (`SinalAcsApp` linhas 31–111; `OnboardingScreen`) +- Modify: `apps/patient/android/app/src/main/AndroidManifest.xml` +- Test: `apps/patient/test/enrollment_qr_test.dart` (novo), `apps/patient/test/onboarding_flow_test.dart` + +**Interfaces:** +- Consumes: `OnboardingScreen` com o aceite da Tarefa 3. +- Produces: + - `String? parseEnrollmentQr(String raw)` — devolve o token (sem espaços nas pontas) só se casar `^[A-Za-z0-9_-]{43}$` (32 bytes em base64url sem padding, formato de `OnboardingService._newToken`). + - `typedef QrScanner = Future Function(BuildContext context);` — `null` = pessoa cancelou. + - `Future scanQrWithCamera(BuildContext context)`. + - `class QrScannerScope extends InheritedWidget { const QrScannerScope({required QrScanner scanner, required Widget child}); static QrScanner of(BuildContext); }`. + - `SinalAcsApp({..., QrScanner? qrScanner})`. + - Keys: `scan_qr_button`; erros aparecem em `onboarding_error` (key existente). + +- [ ] **Step 1: Dependência e permissão** + +Run: `cd apps/patient && flutter pub add 'mobile_scanner:^7.0.0'` +Expected: `pubspec.yaml` ganha `mobile_scanner: ^7.0.0`; `pubspec.lock` atualizado. + +Em `apps/patient/android/app/src/main/AndroidManifest.xml`, depois da permissão `RECEIVE_BOOT_COMPLETED`: + +```xml + + + +``` + +- [ ] **Step 2: Testes que falham** + +Criar `apps/patient/test/enrollment_qr_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_patient/core/onboarding/enrollment_qr.dart'; + +/// Token sintético no formato real: 43 caracteres base64url. +const conviteSintetico = 'AbCdEfGhIjKlMnOpQrStUvWxYz0123456789-_AbCde'; + +void main() { + test('aceita um convite no formato do servidor', () { + expect(conviteSintetico.length, 43); + expect(parseEnrollmentQr(conviteSintetico), conviteSintetico); + }); + + test('tira espaços e quebras de linha nas pontas', () { + expect(parseEnrollmentQr(' $conviteSintetico\n'), conviteSintetico); + }); + + test('recusa QR que não é convite', () { + expect(parseEnrollmentQr('https://exemplo.invalid/pagina'), isNull); + expect(parseEnrollmentQr('00020126580014br.gov.bcb.pix'), isNull); + expect(parseEnrollmentQr(''), isNull); + expect(parseEnrollmentQr(conviteSintetico.substring(1)), isNull, reason: '42 caracteres'); + expect(parseEnrollmentQr('${conviteSintetico}A'), isNull, reason: '44 caracteres'); + expect(parseEnrollmentQr(conviteSintetico.replaceFirst('A', '+')), isNull, + reason: 'base64 padrão, não url-safe'); + }); +} +``` + +Em `apps/patient/test/onboarding_flow_test.dart`, acrescentar o import `import 'package:sinalacs_patient/app/qr_scanner.dart';`, a constante + +```dart +/// Token sintético no formato real (43 caracteres base64url). +const _conviteSintetico = 'AbCdEfGhIjKlMnOpQrStUvWxYz0123456789-_AbCde'; +``` + +e, ao final de `main()`: + +```dart + testWidgets('ler o QR do convite preenche o campo e libera concluir', (tester) async { + final backend = FakePatientBackend(); + await tester.pumpWidget(SinalAcsApp( + backend: backend, + qrScanner: (_) async => _conviteSintetico, + )); + await openOnboarding(tester); + + await tapKey(tester, 'scan_qr_button'); + final field = tester.widget(find.byKey(const Key('onboarding_token_field'))); + expect(field.controller!.text, _conviteSintetico); + + await tapKey(tester, 'onboarding_consent_health'); + await tapKey(tester, 'onboarding_terms_accept'); + await tapKey(tester, 'complete_enrollment_button'); + expect(backend.enrollmentCalls.single['token'], _conviteSintetico); + }); + + testWidgets('cancelar a leitura não muda o que já foi digitado', (tester) async { + await tester.pumpWidget(SinalAcsApp( + backend: FakePatientBackend(), + qrScanner: (_) async => null, + )); + await openOnboarding(tester); + await tester.enterText(find.byKey(const Key('onboarding_token_field')), 'convite-123'); + + await tapKey(tester, 'scan_qr_button'); + + final field = tester.widget(find.byKey(const Key('onboarding_token_field'))); + expect(field.controller!.text, 'convite-123'); + expect(find.byKey(const Key('onboarding_error')), findsNothing); + }); + + testWidgets('QR que não é convite mostra aviso e não preenche o campo', (tester) async { + await tester.pumpWidget(SinalAcsApp( + backend: FakePatientBackend(), + qrScanner: (_) async => 'https://exemplo.invalid/pagina', + )); + await openOnboarding(tester); + await tester.enterText(find.byKey(const Key('onboarding_token_field')), 'convite-123'); + + await tapKey(tester, 'scan_qr_button'); + + expect(find.textContaining('não é um convite do SinalACS'), findsOneWidget); + final field = tester.widget(find.byKey(const Key('onboarding_token_field'))); + expect(field.controller!.text, 'convite-123'); + }); + + testWidgets('falha da câmera mostra aviso e o campo manual continua utilizável', (tester) async { + final backend = FakePatientBackend(); + await tester.pumpWidget(SinalAcsApp( + backend: backend, + qrScanner: (_) async => throw StateError('câmera indisponível'), + )); + await openOnboarding(tester); + + await tapKey(tester, 'scan_qr_button'); + expect(find.textContaining('Não foi possível usar a câmera'), findsOneWidget); + + await tester.enterText(find.byKey(const Key('onboarding_token_field')), 'convite-123'); + await tapKey(tester, 'onboarding_consent_health'); + await tapKey(tester, 'onboarding_terms_accept'); + await tapKey(tester, 'complete_enrollment_button'); + expect(backend.enrollmentCalls, hasLength(1)); + }); +``` + +- [ ] **Step 3: Rodar e ver falhar** + +Run: `cd apps/patient && flutter test test/enrollment_qr_test.dart test/onboarding_flow_test.dart` +Expected: FAIL na compilação — `enrollment_qr.dart`, `qr_scanner.dart` e o parâmetro `qrScanner` não existem. + +- [ ] **Step 4: Validação pura** + +Criar `apps/patient/lib/core/onboarding/enrollment_qr.dart`: + +```dart +/// Formato do convite que `OnboardingService._newToken` gera no servidor: +/// 32 bytes aleatórios em base64url, sem `=` de padding — 43 caracteres. +final _enrollmentToken = RegExp(r'^[A-Za-z0-9_-]{43}$'); + +/// O token contido num QR Code lido pela câmera, ou `null` se o QR não for um +/// convite do SinalACS (link, Pix, QR de outro app). +/// +/// Só a leitura da câmera passa por aqui: o campo digitado à mão continua +/// indo ao servidor como está, e é o servidor quem diz se o convite vale. +/// Aqui o objetivo é outro — não sobrescrever o campo com o conteúdo de um QR +/// qualquer que a câmera tenha pegado. +String? parseEnrollmentQr(String raw) { + final value = raw.trim(); + return _enrollmentToken.hasMatch(value) ? value : null; +} +``` + +- [ ] **Step 5: Leitor e escopo** + +Criar `apps/patient/lib/app/qr_scanner.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; + +/// Lê um QR Code e devolve o texto dele, ou `null` se a pessoa voltar sem ler. +/// Pode lançar quando a câmera não está disponível. +typedef QrScanner = Future Function(BuildContext context); + +/// Disponibiliza o [QrScanner] para a árvore de widgets. +/// +/// Mesmo padrão de `BackendScope`/`LocationScope`: a tela de onboarding não +/// abre a câmera diretamente, o que permite trocar o leitor por um duplo em +/// teste hermético — a câmera real é canal de plataforma e não existe no +/// `flutter test`. +class QrScannerScope extends InheritedWidget { + const QrScannerScope({required this.scanner, required super.child, super.key}); + + final QrScanner scanner; + + static QrScanner of(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType(); + assert(scope != null, 'Nenhum QrScannerScope acima deste widget.'); + return scope!.scanner; + } + + @override + bool updateShouldNotify(QrScannerScope oldWidget) => scanner != oldWidget.scanner; +} + +/// Leitor real: abre a câmera numa tela própria e devolve o primeiro QR lido. +/// A imagem não é guardada nem enviada — só o texto do QR volta. +Future scanQrWithCamera(BuildContext context) => + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const _CameraScanPage()), + ); + +class _CameraScanPage extends StatefulWidget { + const _CameraScanPage(); + + @override + State<_CameraScanPage> createState() => _CameraScanPageState(); +} + +class _CameraScanPageState extends State<_CameraScanPage> { + final _controller = MobileScannerController(formats: const [BarcodeFormat.qrCode]); + + // A câmera entrega vários quadros por segundo: sem esta trava, o mesmo QR + // tentaria fechar a tela várias vezes. + bool _done = false; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _onDetect(BarcodeCapture capture) { + if (_done) return; + for (final barcode in capture.barcodes) { + final value = barcode.rawValue; + if (value != null && value.isNotEmpty) { + _done = true; + Navigator.of(context).pop(value); + return; + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Ler QR Code do convite')), + body: Stack( + children: [ + MobileScanner( + controller: _controller, + onDetect: _onDetect, + errorBuilder: (context, error) => const Center( + child: Padding( + padding: EdgeInsets.all(24), + child: Text( + 'Não foi possível usar a câmera. Volte e digite o código do convite.', + key: Key('camera_error'), + textAlign: TextAlign.center, + ), + ), + ), + ), + const Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: EdgeInsets.all(24), + child: Text( + 'Aponte a câmera para o QR Code mostrado pelo agente de saúde.', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold), + ), + ), + ), + ], + ), + ); + } +} +``` + +Se a versão resolvida do `mobile_scanner` tiver `errorBuilder` com três parâmetros (`(context, error, child)`, séries 5.x/6.x), ajuste a assinatura e registre a versão resolvida no ledger; o comportamento é o mesmo. + +- [ ] **Step 6: Ligar no app** + +Em `apps/patient/lib/app/app.dart`: + +1. Imports: `import 'package:sinalacs_patient/app/qr_scanner.dart';` e `import 'package:sinalacs_patient/core/onboarding/enrollment_qr.dart';`. +2. `SinalAcsApp`: parâmetro `this.qrScanner,` no construtor e o campo + +```dart + /// Injetável para teste. Em execução normal é [scanQrWithCamera], que abre + /// a câmera do aparelho. + final QrScanner? qrScanner; +``` + +3. `_SinalAcsAppState`: `late final QrScanner _qrScanner = widget.qrScanner ?? scanQrWithCamera;` e, no `build`, envolver o `MaterialApp` em `QrScannerScope(scanner: _qrScanner, child: MaterialApp(...))` (é o filho de `RemindersScope`). +4. Trocar o doc de `OnboardingScreen` ("a leitura por câmera é apenas um jeito alternativo… (fora de escopo aqui)") por: + +```dart +/// O campo de texto recebe o token do convite. A leitura do QR Code pela +/// câmera ("Ler QR Code com a câmera") preenche o mesmo campo — digitar +/// continua possível para quem não tem câmera ou negou a permissão. +``` + +5. Em `_OnboardingScreenState`, o método: + +```dart + Future _scan() async { + final scanner = QrScannerScope.of(context); + final String? raw; + try { + raw = await scanner(context); + } catch (_) { + if (!mounted) return; + setState(() => _error = 'Não foi possível usar a câmera. Digite o código do convite.'); + return; + } + if (!mounted || raw == null) return; + final token = parseEnrollmentQr(raw); + setState(() { + if (token == null) { + _error = 'Este QR Code não é um convite do SinalACS. Peça ao agente de ' + 'saúde para mostrar o convite de novo.'; + } else { + _tokenController.text = token; + _error = null; + } + }); + } +``` + +6. No `build`, trocar o texto de instrução por: + +```dart + const Text( + 'Leia o QR Code do convite mostrado pelo agente ' + 'comunitário de saúde, ou digite o código.', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.white70), + ), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + key: const Key('scan_qr_button'), + onPressed: _busy ? null : _scan, + style: OutlinedButton.styleFrom(minimumSize: const Size(48, 52)), + icon: const Icon(Icons.qr_code_scanner_outlined), + label: const Text('Ler QR Code com a câmera'), + ), + ), +``` + +(o `SizedBox(height: 20)` seguinte, antes do `TextField`, permanece). + +- [ ] **Step 7: Rodar e ver passar** + +Run: `cd apps/patient && flutter test test/enrollment_qr_test.dart test/onboarding_flow_test.dart` +Expected: PASS (3 + 4 novos, e os existentes). + +- [ ] **Step 8: Suíte, build e commit** + +Run: `cd apps/patient && flutter analyze && flutter test && flutter build apk --debug` +Expected: `No issues found!`, suíte verde, APK gerado (prova que o plugin nativo integra com `minSdk = 24`). Se o build do APK falhar por falta de SDK Android na máquina, registre no ledger e siga — o CI (`android-e2e`) cobre. + +```bash +git add apps/patient/pubspec.yaml apps/patient/pubspec.lock apps/patient/lib/core/onboarding/enrollment_qr.dart apps/patient/lib/app/qr_scanner.dart apps/patient/lib/app/app.dart apps/patient/android/app/src/main/AndroidManifest.xml apps/patient/test/enrollment_qr_test.dart apps/patient/test/onboarding_flow_test.dart +git commit -m "feat(paciente): leitura do QR Code do convite pela câmera no onboarding (RF02) + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Tarefa 7: Registro em PROGRESS.md e nos documentos de referência + +**Files:** +- Modify: `PROGRESS.md` (nova seção ao final) +- Modify: `apps/CLAUDE.md` +- Modify: `spec/lgpd_design.md` (notas de estado junto a LGPD-RF10/RF18/RF19) +- Modify: `spec/PRD_system.md:135` + +**Interfaces:** +- Consumes: tudo acima. Produces: nada de código. + +- [ ] **Step 1: PROGRESS.md** + +Acrescentar ao final (conte os testes reais com a saída das suítes antes de escrever os números): + +```markdown +## QR Code do onboarding e documentos legais (2026-09-29) + +Plano: `docs/superpowers/plans/2026-09-29-qr-onboarding-e-documentos-legais.md`, branch `fix/patient`. + +**RF02 de ponta a ponta.** O ACS ganhou "Mais › Convidar paciente": escolhe um paciente da própria microárea, gera o convite (`onboarding.generateEnrollmentToken`, que existia sem nenhum chamador) e mostra o QR Code (`qr_flutter`), com validade de 15 minutos e o código em texto para digitação. O paciente lê com "Ler QR Code com a câmera" (`mobile_scanner`, permissão `CAMERA`, câmera opcional na instalação), que preenche o mesmo campo do código; QR que não tem o formato do convite (43 caracteres base64url) é recusado sem sobrescrever o campo. O PRD citava `qr_code_scanner`, pacote descontinuado — trocado por `mobile_scanner`. + +**LGPD-RF18/RF19/RF10.** Termo de Uso e Política de Privacidade versão 2026.1 no app paciente (`lib/core/legal/legal_documents.dart`): resumo visual em passos (fluxo do dado), texto completo em seções, histórico de versões. Abrem antes do cadastro (link no login e no onboarding) e depois em "Mais › Privacidade e termos". O aceite é explícito, desmarcado por padrão e obrigatório; vira `ConsentPurpose.termsOfUse` em `consent_logs`, na mesma transação dos outros consentimentos, com `version = consentPolicyVersion`. `updateConsent` recusa alterá-lo. Um teste do app falha se a versão exibida divergir da carimbada pelo backend. + +**Ficou de fora, de propósito:** +- O texto 2026.1 precisa de revisão jurídica e dos dados reais do controlador e do encarregado (hoje genéricos: "Secretaria Municipal de Saúde do seu município"). +- Aviso de mudança com 15 dias de antecedência e novo aceite quando a versão mudar: só existe uma versão; não há mecanismo de reaceite no login. +- Pacientes que entram por CPF + OTP (RF01) sem ter passado pelo onboarding nunca aceitaram o termo — o seed inclusive. Falta um aceite no primeiro login. +- Canal de dúvidas é "fale com o ACS ou a UBS", sem canal digital próprio. +- A página da câmera (`_CameraScanPage`) só roda no aparelho; os testes cobrem o fluxo com um leitor duplo. Validar no emulador com um QR gerado pelo app do ACS. +- Contagens de teste depois desta entrega: backend N, paciente N, ACS N. +``` + +- [ ] **Step 2: apps/CLAUDE.md** + +Na seção "Flutter apps (`apps/acs/`, `apps/patient/`)", depois do parágrafo do login passwordless do paciente, acrescentar: + +```markdown +The onboarding QR (RF02) now exists on both sides: the ACS's "Mais › Convidar paciente" (`apps/acs/lib/app/invite_screen.dart`) calls `onboarding.generateEnrollmentToken` through `AcsBackend.generateInvite` and draws the token with `qr_flutter` — the plaintext token lives only in that screen's `State`, never on disk, and switching patient hides the previous QR. The patient's onboarding reads it with `mobile_scanner` through `QrScannerScope` (`apps/patient/lib/app/qr_scanner.dart`), injectable like `BackendScope` so widget tests never touch the camera; `parseEnrollmentQr` only accepts the server's 43-char base64url format. Terms of Use and Privacy Policy are constant Dart content in `apps/patient/lib/core/legal/legal_documents.dart`; `legalDocumentsVersion` must equal the backend's `consentPolicyVersion`, and `test/legal_documents_test.dart` reads the server file to enforce it. Acceptance is `ConsentPurpose.termsOfUse`, mandatory in `completeEnrollment` like `healthDataProcessing`. +``` + +- [ ] **Step 3: specs** + +Em `spec/PRD_system.md:135`, trocar `` Câmera, `qr_code_scanner` `` por `` Câmera, `mobile_scanner` (paciente) e `qr_flutter` (ACS) ``. + +Em `spec/lgpd_design.md`, logo depois da tabela de cada um dos requisitos LGPD-RF10 (~linha 157), RF18 (~249) e RF19 (~259), acrescentar uma linha de estado: + +```markdown +> **Estado (2026-09-29):** implementado no app paciente — ver PROGRESS.md "QR Code do onboarding e documentos legais". Pendentes: revisão jurídica do texto, aviso com 15 dias de antecedência e reaceite a cada nova versão. +``` + +(Na RF10, troque "reaceite a cada nova versão" por "canal digital de dúvidas".) + +- [ ] **Step 4: Verificações finais e commit** + +Run: +```bash +./scripts/qa/ci_invariants.sh +graphify update . +``` +Expected: invariantes OK; grafo atualizado. + +```bash +git add PROGRESS.md apps/CLAUDE.md spec/lgpd_design.md spec/PRD_system.md +git commit -m "docs: registra o QR do onboarding e os documentos legais do app paciente + +Co-Authored-By: Claude Opus 5.5 " +``` diff --git a/docs/superpowers/plans/2026-09-29-rf14-gorush-avisos-segmentados.md b/docs/superpowers/plans/2026-09-29-rf14-gorush-avisos-segmentados.md new file mode 100644 index 00000000..51aa26c4 --- /dev/null +++ b/docs/superpowers/plans/2026-09-29-rf14-gorush-avisos-segmentados.md @@ -0,0 +1,615 @@ +# RF14 — Avisos segmentados com Gorush Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fechar a revisão das specs do RF14 (Gorush no lugar do Firebase) e implementar o envio: Gorush no Docker Compose, cliente HTTP no backend, `notices.sendSegmented` com segmentação SQL restrita a quem consentiu, tela de envio no ACS e captura do token nativo no paciente por um provider Riverpod. + +**Architecture:** O paciente registra o token do aparelho em `push_tokens` (já existe). O ACS chama `notices.sendSegmented`; o servidor resolve os destinatários por SQL (microárea do token do ACS, `segmentedPush` vigente, filtro opcional de crônicos), entrega a lista ao Gorush por `POST /api/push` e apaga os tokens que o provedor declara inválidos. O Gorush é um relé para FCM/APNs e não é publicado fora da rede do Compose. + +**Tech Stack:** Serverpod 3.4.13, Postgres (`unsafeQuery`), `dart:io` `HttpClient` (o backend não tem pacote `http`), Gorush (`appleboy/gorush`), Flutter 3.44, `flutter_riverpod` só no app do paciente, `flutter_test`. + +**Spec:** `docs/superpowers/specs/2026-09-16-decisoes-produto-pos-validacao.md` §3.2 (já revisada para Gorush, ainda sem commit); `spec/stack.md`; `spec/lgpd_design.md` (consentimento `segmentedPush` antes de enviar). + +**Dependência de ordem:** executar depois de `2026-09-29-menores-do-push-e-aviso-de-mudanca-dos-termos.md`, que muda `PushTokenStore.registerIfConsented` e o teto de tokens. Este plano lê `push_tokens` e não altera o registro. + +## Global Constraints + +- Regenerar com `export PATH=$PATH:~/.pub-cache/bin; cd backend/sinalacs_server && serverpod generate && serverpod create-migration`. Integração: `docker compose --profile test up -d postgres-test`. +- `flutter analyze` limpo nos apps; `dart analyze` do backend no baseline vigente (44 infos, zero avisos). +- Alerta vermelho nunca é descartado nem atrasado: falha do Gorush ou do registro de push nunca bloqueia login, home nem alerta; o envio de aviso nunca passa pela fila do alerta MQTT. +- ACS só envia para a **própria microárea** (invariante do projeto): a microárea vem do token, nunca de parâmetro. +- Conteúdo do aviso não carrega dado de saúde identificável (§3.2): a mensagem é livre, mas o servidor não anexa nome, condição nem id de paciente ao payload; `data` leva só a tela a abrir. +- Segredos do Gorush seguem o padrão do repositório: variáveis `${VAR:?...}` no Compose, `.env.example` como referência e `bootstrap_env.sh` gera o que for segredo aleatório. Credenciais FCM/APNs são **arquivos fornecidos pela organização**, nunca gerados nem versionados. +- Testes de integração sem rollback usam ids próprios (`9400…`) e `enrollmentId` próprio, com limpeza manual. +- Textos de UI e comentários em português. Nenhum dado real em testes. + +## Review Focus + +- Titular que revogou `segmentedPush` depois de registrar o token: nunca recebe (a consulta usa a linha de consentimento mais recente, não a existência do token). +- Gorush fora do ar, lento ou respondendo 5xx: `sendSegmented` falha com erro tipado em tempo limitado e não deixa linha de auditoria dizendo "enviado". +- Token que o provedor devolve como inválido (`NotRegistered`, `BadDeviceToken`): é apagado, e o envio seguinte não o inclui. +- Microárea sem destinatário consentido: resposta `0 enviados`, sem chamada ao Gorush. +- ACS de outra microárea ou paciente chamando o endpoint: recusado; mensagem vazia ou acima do teto: recusada. + +--- + +## Mapa de arquivos + +- Backend criar: `lib/src/infrastructure/push/gorush_client.dart`, `lib/src/application/notices/notice_service.dart`, `lib/src/infrastructure/database/orm_notice_recipient_store.dart`, `lib/src/endpoints/notices_endpoint.dart`, `lib/src/models/api/notice_send_result.spy.yaml` (confirmar a pasta dos DTOs com `ls lib/src/models/api`), `test/unit/gorush_client_test.dart`, `test/unit/notice_service_test.dart`, `test/integration/notices_endpoint_test.dart`. +- Backend modificar: `lib/src/config/app_config.dart`, `lib/src/runtime/alert_runtime.dart`, `test/unit/app_config_test.dart`, `test/unit/compose_secret_agreement_test.dart` (se ele lista variáveis do Compose). +- Infra: `docker-compose.yml`, `.env.example`, `scripts/dev/bootstrap_env.sh`, `infra/docker/gorush/` (config), `.gitignore` (credenciais). +- ACS: `apps/acs/lib/app/app.dart` (`NoticesScreen`), `apps/acs/lib/core/network/…` (o cliente do backend do ACS; confirmar o arquivo), testes do ACS. +- Paciente: `apps/patient/pubspec.yaml`, `lib/core/push/push_token_provider.dart`, `lib/core/push/native_push_token_source.dart`, `lib/main.dart`, `lib/app/app.dart`, `test/push_riverpod_test.dart`. +- Docs: `PROGRESS.md`, `apps/CLAUDE.md`, `backend/CLAUDE.md`, `spec/stack.md`, `spec/lgpd_data_audit.md`, `CLAUDE.md`, memória. + +--- + +### Task 0: Fechar e commitar a revisão das specs + +**Files:** os já alterados (working tree): `PROGRESS.md`, `apps/patient/lib/core/push/push_token_source.dart`, `docs/superpowers/specs/2026-09-16-decisoes-produto-pos-validacao.md`, `spec/PRD_system.md`, `spec/lgpd_design.md`, `spec/stack.md`, `spec/validation_report.md`. + +**Interfaces:** Produces: a decisão registrada de que o **Riverpod entra só no push do paciente** (escolha do usuário em 2026-09-29), que substitui o parágrafo "O app **não** adota Riverpod" do §3.2 e a linha "Sem Riverpod" de `spec/stack.md`. + +- [ ] **Step 1: Corrigir o texto sobre Riverpod** + +No §3.2 e em `spec/stack.md`, troque o "não adota Riverpod" por: "O app do paciente adota `flutter_riverpod` **somente** para a captura e o registro do token de push (`pushTokenProvider`); o restante da injeção segue por `InheritedWidget` (`BackendScope`, `QrScannerScope`, `PushTokenScope`). Migrar o resto para Riverpod fica fora do RF14." Em `PROGRESS.md`, corrija a seção "Revisão do RF14" pela mesma frase e remova o item "Sem Riverpod". + +- [ ] **Step 2: Conferir que nada ainda diz "bloqueado por Firebase"** + +Run: `grep -rn -i "projeto Firebase\|sem Riverpod\|não adota Riverpod" spec docs/superpowers/specs PROGRESS.md apps/CLAUDE.md backend/CLAUDE.md` +Expected: nenhuma ocorrência que afirme bloqueio por Firebase ou a exclusão do Riverpod (menções históricas em "Revisão do RF14" que expliquem a mudança são aceitas). + +- [ ] **Step 3: Commit** + +```bash +git add PROGRESS.md apps/patient/lib/core/push/push_token_source.dart docs/superpowers spec +git commit -m "docs: RF14 passa a usar Gorush; Riverpod só no push do paciente" +``` + +--- + +### Task 1: Gorush no Compose e configuração do backend + +**Files:** +- Modify: `docker-compose.yml`, `.env.example`, `scripts/dev/bootstrap_env.sh` (só se houver segredo aleatório), `.gitignore`, `backend/sinalacs_server/lib/src/config/app_config.dart`, `backend/sinalacs_server/test/unit/app_config_test.dart`, `test/unit/compose_secret_agreement_test.dart` +- Create: `infra/docker/gorush/config.yml`, `infra/docker/gorush/README.md` + +**Interfaces:** +- Produces: `AppConfig.gorushUrl` (`String?`, env `GORUSH_URL`; `null` = envio de push desligado) e `AppConfig.gorushTimeout` (`Duration`, fixo em 5 s). +- Produces: serviço `gorush` no Compose, sob `profiles: [push]`, sem `ports:` publicados; o serviço `serverpod` recebe `GORUSH_URL: http://gorush:8088` **só** quando o perfil `push` está ativo (sem `depends_on` obrigatório: o backend sobe sem Gorush). + +- [ ] **Step 1: Teste vermelho de configuração** + +Em `app_config_test.dart`: + +```dart +test('GORUSH_URL ausente desliga o envio; presente é lido sem barra final', () { + final base = {...envMinimo}; + expect(AppConfig.fromEnvironment(base).gorushUrl, isNull); + expect( + AppConfig.fromEnvironment({...base, 'GORUSH_URL': 'http://gorush:8088/'}).gorushUrl, + 'http://gorush:8088', + ); +}); + +test('GORUSH_URL fora de http(s) é recusada', () { + expect( + () => AppConfig.fromEnvironment({...envMinimo, 'GORUSH_URL': 'ftp://x'}), + throwsA(isA()), + ); +}); +``` + +Use o nome real do construtor de fábrica e o mapa mínimo de ambiente que o arquivo já usa (leia `app_config_test.dart` antes; `envMinimo` acima é o rótulo do que ele já define). Run: `cd backend/sinalacs_server && dart test test/unit/app_config_test.dart`. Expected: FAIL — `gorushUrl` não existe. + +- [ ] **Step 2: Implementar em `AppConfig`** + +Campo `final String? gorushUrl;` (opcional no construtor, padrão `null`, para não quebrar os `AppConfig(...)` dos testes de integração) e `Duration get gorushTimeout => const Duration(seconds: 5);`. Na fábrica: lê `GORUSH_URL`, `trim`, tira uma `/` final, `null` se vazio; se não começar com `http://` ou `https://`, `throw StateError('GORUSH_URL deve começar com http:// ou https://.')`. Rode o teste (PASS). + +- [ ] **Step 3: Compose, exemplo e ignore** + +`docker-compose.yml`: serviço novo + +```yaml + gorush: + image: appleboy/gorush:1.18.4 + profiles: [push] + restart: unless-stopped + volumes: + - ./infra/docker/gorush/config.yml:/config.yml:ro + - ${GORUSH_CREDENTIALS_DIR:?defina em .env o diretório com as credenciais FCM/APNs}:/credentials:ro + command: ["-c", "/config.yml"] + # Sem `ports:` — só o backend, na rede do Compose, fala com o Gorush. +``` + +Confirme a última tag estável do `appleboy/gorush` antes de fixar (a tag acima é um marcador do formato, não uma verificação) e escreva a tag confirmada. No serviço `serverpod`, acrescente `GORUSH_URL: ${GORUSH_URL:-}`. `infra/docker/gorush/config.yml` liga só o que existe: `core.port: 8088`, `android.enabled` com `credential` apontando para `/credentials/fcm-service-account.json`, `ios.enabled` com `key_path: /credentials/apns-key.p8`, `key_id` e `team_id` vindos de `GORUSH_IOS_KEY_ID`/`GORUSH_IOS_TEAM_ID`, `log.format: json`. **Confira os nomes das chaves no README do Gorush da tag escolhida**; o `infra/docker/gorush/README.md` documenta quais arquivos a organização precisa fornecer e que eles não são versionados. `.env.example`: `GORUSH_URL=` (vazio desliga), `GORUSH_CREDENTIALS_DIR=`, `GORUSH_IOS_KEY_ID=`, `GORUSH_IOS_TEAM_ID=`, com comentário de que o Gorush é relé e ainda precisa de credenciais FCM/APNs. `.gitignore`: `infra/docker/gorush/credentials/`. + +- [ ] **Step 4: Verificação** + +Run: `docker compose config -q && docker compose --profile push config -q; cd backend/sinalacs_server && dart test test/unit/app_config_test.dart test/unit/compose_secret_agreement_test.dart` +Expected: `docker compose config -q` passa sem o perfil `push` e sem `GORUSH_CREDENTIALS_DIR`; com o perfil `push`, falha nomeando `GORUSH_CREDENTIALS_DIR` se ele faltar; testes passam. Se `compose_secret_agreement_test` acusar o serviço novo, ajuste o teste para reconhecer `${VAR:?}` de perfil opcional. + +- [ ] **Step 5: Commit** + +```bash +git add docker-compose.yml .env.example .gitignore infra/docker/gorush backend/sinalacs_server +git commit -m "feat(infra): Gorush opcional no Compose e GORUSH_URL no backend (RF14)" +``` + +--- + +### Task 2: Cliente do Gorush + +**Files:** +- Create: `backend/sinalacs_server/lib/src/infrastructure/push/gorush_client.dart`, `backend/sinalacs_server/test/unit/gorush_client_test.dart` + +**Interfaces:** +- Produces: `abstract interface class PushSender { Future send(PushMessage message, List targets); }` +- Produces: `class PushTarget { const PushTarget({required this.token, required this.platform}); }` (`platform`: `'android'` ou `'ios'`), `class PushMessage { const PushMessage({required this.title, required this.body, this.data = const {}}); }`, `class PushSendReport { const PushSendReport({required this.accepted, required this.invalidTokens}); final int accepted; final List invalidTokens; }`, `class PushGatewayException implements Exception { const PushGatewayException(this.message); }`. +- Produces: `class GorushClient implements PushSender { GorushClient({required String baseUrl, required Duration timeout, HttpClient? httpClient}); }`. + +- [ ] **Step 1: Teste vermelho contra um servidor HTTP local** + +`gorush_client_test.dart` sobe um `HttpServer.bind(InternetAddress.loopbackIPv4, 0)` que grava o corpo recebido e responde o que o teste mandar: + +```dart +test('agrupa por plataforma e usa os códigos do Gorush (1 iOS, 2 Android)', () async { + final gw = await FakeGorush.start(response: {'counts': 3, 'logs': []}); + final client = GorushClient(baseUrl: gw.url, timeout: const Duration(seconds: 2)); + + final report = await client.send( + const PushMessage(title: 'Vacina', body: 'Amanhã, na UBS.', data: {'screen': 'notices'}), + const [ + PushTarget(token: 'a1', platform: 'android'), + PushTarget(token: 'a2', platform: 'android'), + PushTarget(token: 'i1', platform: 'ios'), + ], + ); + + final sent = gw.lastBody['notifications'] as List; + expect(sent, hasLength(2)); + expect(sent.firstWhere((n) => n['platform'] == 2)['tokens'], ['a1', 'a2']); + expect(sent.firstWhere((n) => n['platform'] == 1)['tokens'], ['i1']); + expect(sent.first['title'], 'Vacina'); + expect(sent.first['data'], {'screen': 'notices'}); + expect(report.accepted, 3); + expect(report.invalidTokens, isEmpty); + await gw.close(); +}); + +test('tokens que o provedor recusa como inválidos voltam em invalidTokens', () async { + final gw = await FakeGorush.start(response: { + 'counts': 1, + 'logs': [ + {'type': 'failed-push', 'platform': 'android', 'token': 'a2', 'error': 'NotRegistered'}, + {'type': 'failed-push', 'platform': 'ios', 'token': 'i1', 'error': 'BadDeviceToken'}, + {'type': 'failed-push', 'platform': 'ios', 'token': 'i2', 'error': 'ServiceUnavailable'}, + ], + }); + final client = GorushClient(baseUrl: gw.url, timeout: const Duration(seconds: 2)); + final report = await client.send( + const PushMessage(title: 't', body: 'b'), + const [PushTarget(token: 'a2', platform: 'android'), PushTarget(token: 'i1', platform: 'ios'), PushTarget(token: 'i2', platform: 'ios')], + ); + expect(report.invalidTokens.toSet(), {'a2', 'i1'}); // erro transitório não apaga token + await gw.close(); +}); + +test('status 5xx vira PushGatewayException', () async { + final gw = await FakeGorush.start(status: 503, response: {}); + final client = GorushClient(baseUrl: gw.url, timeout: const Duration(seconds: 2)); + await expectLater( + client.send(const PushMessage(title: 't', body: 'b'), const [PushTarget(token: 'a', platform: 'android')]), + throwsA(isA()), + ); + await gw.close(); +}); + +test('servidor que não responde estoura o tempo limite como PushGatewayException', () async { + final gw = await FakeGorush.start(hang: true); + final client = GorushClient(baseUrl: gw.url, timeout: const Duration(milliseconds: 300)); + await expectLater( + client.send(const PushMessage(title: 't', body: 'b'), const [PushTarget(token: 'a', platform: 'android')]), + throwsA(isA()), + ); + await gw.close(); +}); + +test('lista vazia não chama o Gorush', () async { + final gw = await FakeGorush.start(response: {}); + final client = GorushClient(baseUrl: gw.url, timeout: const Duration(seconds: 2)); + final report = await client.send(const PushMessage(title: 't', body: 'b'), const []); + expect(report.accepted, 0); + expect(gw.requests, 0); + await gw.close(); +}); +``` + +`FakeGorush` é uma classe de teste no próprio arquivo (`start({status = 200, response, hang = false})`, `url`, `lastBody`, `requests`, `close`). Run: `dart test test/unit/gorush_client_test.dart`. Expected: FAIL — `GorushClient` não existe. + +- [ ] **Step 2: Implementar** + +`send`: se `targets` vazio, `PushSendReport(accepted: 0, invalidTokens: [])` sem I/O. Senão agrupa por plataforma em `{'notifications': [{'tokens': [...], 'platform': 2|1, 'title', 'message': body, 'data': data}]}` e faz `POST {baseUrl}/api/push` com `Content-Type: application/json` via `HttpClient`, `.timeout(timeout)` sobre a requisição inteira (abrir, escrever e ler a resposta); `TimeoutException`, `SocketException` e status fora de 2xx viram `PushGatewayException` (a mensagem não inclui tokens nem o corpo enviado). Resposta 2xx: `accepted = counts` (int; se ausente, `targets.length` menos as falhas); `invalidTokens` = tokens dos `logs` com `type == 'failed-push'` cujo `error` contenha (sem diferenciar caixa) `NotRegistered`, `Unregistered`, `InvalidRegistration`, `BadDeviceToken`, `DeviceTokenNotForTopic` ou `MismatchSenderId`. Nada de log com token. Rode os testes (PASS). + +- [ ] **Step 3: Commit** + +```bash +git add backend/sinalacs_server +git commit -m "feat(backend): cliente do Gorush com tempo limite e poda de tokens inválidos (RF14)" +``` + +--- + +### Task 3: Serviço, consulta segmentada e `notices.sendSegmented` + +**Files:** +- Create: `lib/src/application/notices/notice_service.dart`, `lib/src/infrastructure/database/orm_notice_recipient_store.dart`, `lib/src/endpoints/notices_endpoint.dart`, `lib/src/models/api/notice_send_result.spy.yaml`, `test/unit/notice_service_test.dart`, `test/integration/notices_endpoint_test.dart` +- Modify: `lib/src/runtime/alert_runtime.dart`, `test/unit/endpoint_auth_posture_test.dart` (só se ele enumerar endpoints) + +**Interfaces:** +- Consumes: `PushSender`, `PushTarget`, `PushMessage`, `PushSendReport`, `PushGatewayException` (Task 2); `AppConfig.gorushUrl` (Task 1); `AuditTrail`, `AuditEvent`, `Authorization.require`, `DataRightsException`, `AlertPermissionException`. +- Produces: `enum NoticeAudience { everyone, chronic }` (constante no serviço, não no protocolo; o endpoint recebe `String` e valida). +- Produces: `abstract interface class NoticeRecipientStore { Future> consentedTargets({required String microAreaId, required bool chronicOnly}); Future deleteTokens(List tokens); }`. +- Produces: `NoticeService({required NoticeRecipientStore store, required PushSender? sender, required AuditTrail audit})` e `Future sendSegmented(AuthenticatedUser user, {required String title, required String message, required String audience})`, com `class NoticeSendResult { final int recipients; final int accepted; }`. +- Produces: RPC `notices.sendSegmented(session, {required String accessToken, required String title, required String message, required String audience}) → Future` e o DTO `NoticeSendResult { recipients: int, accepted: int }`. +- Produces: constantes `noticeTitleMaxLength = 60`, `noticeMessageMaxLength = 240`. + +- [ ] **Step 1: Testes unitários vermelhos** + +`notice_service_test.dart`, com `_FakeRecipientStore` (lista fixa, `deleted`), `_FakeSender` (grava a mensagem e os alvos, devolve o relatório configurado ou lança) e o `FakeAuditTrail` (`test/support/fake_audit_trail.dart`, que a rodada anterior extrai; se ainda não existir, copie a classe): + +```dart +test('só ACS da própria microárea envia; paciente é recusado', () async { + await expectLater( + service.sendSegmented(_patient, title: 't', message: 'm', audience: 'everyone'), + throwsA(isA()), + ); + expect(sender.sent, isEmpty); +}); + +test('a consulta usa a microárea do token e o filtro de crônicos', () async { + await service.sendSegmented(_acs, title: 'Vacina', message: 'Amanhã.', audience: 'chronic'); + expect(store.lastMicroAreaId, _acs.microAreaId); + expect(store.lastChronicOnly, isTrue); +}); + +test('sem destinatário consentido: 0 enviados, sem chamar o provedor', () async { + store.targets = const []; + final r = await service.sendSegmented(_acs, title: 't', message: 'm', audience: 'everyone'); + expect((r.recipients, r.accepted), (0, 0)); + expect(sender.calls, 0); +}); + +test('o payload não leva dado do paciente, só título, mensagem e tela', () async { + await service.sendSegmented(_acs, title: 'Vacina', message: 'Amanhã.', audience: 'everyone'); + expect(sender.lastMessage!.title, 'Vacina'); + expect(sender.lastMessage!.data, {'screen': 'notices'}); +}); + +test('tokens inválidos devolvidos pelo provedor são apagados', () async { + sender.report = const PushSendReport(accepted: 1, invalidTokens: ['tok-b']); + await service.sendSegmented(_acs, title: 't', message: 'm', audience: 'everyone'); + expect(store.deleted, ['tok-b']); +}); + +test('falha do Gorush vira erro tipado e não audita "enviado"', () async { + sender.failure = const PushGatewayException('fora do ar'); + await expectLater( + service.sendSegmented(_acs, title: 't', message: 'm', audience: 'everyone'), + throwsA(isA()), + ); + expect(audit.events.where((e) => e.result == 'granted'), isEmpty); +}); + +test('sem Gorush configurado (sender nulo) o envio é recusado com mensagem clara', () async { + final off = NoticeService(store: store, sender: null, audit: audit); + await expectLater( + off.sendSegmented(_acs, title: 't', message: 'm', audience: 'everyone'), + throwsA(isA()), + ); +}); + +test('título e mensagem vazios, longos demais ou público desconhecido são recusados', () async { + for (final a in [('', 'm', 'everyone'), ('t', ' ', 'everyone'), ('x' * 61, 'm', 'everyone'), ('t', 'x' * 241, 'everyone'), ('t', 'm', 'todos')]) { + await expectLater( + service.sendSegmented(_acs, title: a.$1, message: a.$2, audience: a.$3), + throwsA(isA()), + ); + } + expect(sender.calls, 0); +}); + +test('o texto da mensagem nunca vai para a trilha de auditoria', () async { + await service.sendSegmented(_acs, title: 'Vacina', message: 'texto sigiloso', audience: 'everyone'); + for (final e in audit.events) { + expect('${e.resourceType} ${e.resourceId} ${e.result}'.contains('sigiloso'), isFalse); + } +}); +``` + +Run: `dart test test/unit/notice_service_test.dart`. Expected: FAIL — `NoticeService` não existe. `NoticeDeliveryException` é um exceção do modelo (`lib/src/models/exceptions/`, no formato de `DataRightsException`, mesma pasta e mesmo padrão `.spy.yaml`). + +- [ ] **Step 2: Implementar o serviço** + +`sendSegmented`: `Authorization.require(user, roles: {UserRole.acs}, onDenied: StateError('Somente o ACS envia avisos.'), requireMicroArea: true)`; `trim` em título e mensagem, validações acima com `DataRightsException` (reuse; a mensagem diz o limite); `audience` em `{'everyone','chronic'}`; se `_sender == null` → `NoticeDeliveryException(message: 'O envio de avisos não está configurado neste ambiente.')`; `targets = await _store.consentedTargets(microAreaId: user.microAreaId!, chronicOnly: audience == 'chronic')`; se vazio, audita (`result: 'no_recipients'`) e devolve `(0, 0)`; `try { report = await _sender.send(PushMessage(title:, body:, data: {'screen': 'notices'}), targets) } on PushGatewayException { throw NoticeDeliveryException(message: 'Não foi possível entregar o aviso agora. Tente de novo em instantes.'); }`; `if (report.invalidTokens.isNotEmpty) await _store.deleteTokens(report.invalidTokens);` audita `AuditEvent(userId: user.id, actionType: 'write', resourceType: 'community_notice', result: 'granted')` e devolve `NoticeSendResult(recipients: targets.length, accepted: report.accepted)`. Confirme em `AuditEvent` o campo `resourceId` (passe `user.microAreaId` se for obrigatório): a trilha registra quem enviou e para qual microárea, jamais o texto. + +- [ ] **Step 3: Testes de integração da consulta (vermelhos)** + +`notices_endpoint_test.dart`, grupo sem rollback com ids `9400…`, dois pacientes na microárea A, um na B, tokens `tok-a1`, `tok-a2`, `tok-b1`, e um `PushSender` de teste injetado pelo `AlertRuntime` (leia como os outros testes sobrescrevem serviços; se o runtime não tiver gancho para o sender, adicione `overrideNoticeSender(PushSender?)` no mesmo estilo de `overrideConfig`): + +```dart +test('só recebe quem tem consentimento vigente na microárea do ACS', () async { + // a1: granted; a2: granted e depois denied (com o token ainda no banco, de propósito) + // b1: granted, mas em outra microárea + await endpoints.notices.sendSegmented(sessionBuilder, accessToken: acsToken, + title: 'Vacina', message: 'Amanhã.', audience: 'everyone'); + expect(sender.lastTargets.map((t) => t.token), ['tok-a1']); +}); + +test('filtro de crônicos usa patients.isChronic', () async { + // a1 crônico, a3 não; ambos granted + // audience: 'chronic' → só o token do crônico +}); + +test('token inválido devolvido pelo provedor some do banco', () async { + sender.report = const PushSendReport(accepted: 0, invalidTokens: ['tok-a1']); + await endpoints.notices.sendSegmented(...); + expect(await PushToken.db.count(session, where: (t) => t.token.equals('tok-a1')), 0); +}); + +test('paciente e token inválido são recusados; ACS de outra microárea não enxerga a A', () async { + await expectLater( + endpoints.notices.sendSegmented(sessionBuilder, accessToken: patientToken, title: 't', message: 'm', audience: 'everyone'), + throwsA(isA()), + ); +}); +``` + +Para o token do ACS use `endpoints.auth.developmentLogin(sessionBuilder, role: 'acs')` e semeie usuários no mesmo estilo de `push_token_endpoint_test.dart` (com `enrollmentId` próprio). Run: `dart test test/integration/notices_endpoint_test.dart`. Expected: FAIL — o endpoint não existe. + +- [ ] **Step 4: Implementar store, DTO, endpoint e runtime** + +`orm_notice_recipient_store.dart` usa `session.db.unsafeQuery` com esta consulta (o consentimento mais recente por titular decide; o token sozinho não basta): + +```sql +SELECT pt.token, pt.platform +FROM push_tokens pt +JOIN users u ON u.id = pt."userId" +JOIN patients p ON p.id = u.id +WHERE u."microAreaId" = @micro + AND u.role = 'patient' + AND (NOT @chronic OR p."isChronic") + AND COALESCE(( + SELECT c.action FROM consent_logs c + WHERE c."userId" = pt."userId" AND c.purpose = 'segmentedPush' + ORDER BY c."timestamp" DESC LIMIT 1 + ), 'denied') = 'granted' +ORDER BY pt.token; +``` + +Confirme os nomes reais das colunas e do enum de `role` em `migrations/*/definition.sql` (o Serverpod guarda enums por nome em `text`; ajuste `u.role`). Parâmetros via `QueryParameters.named`, sem interpolar texto. `deleteTokens` usa `PushToken.db.deleteWhere(... t.token.inSet(tokens.toSet()))`. DTO `notice_send_result.spy.yaml` com `recipients: int` e `accepted: int`. `NoticesEndpoint extends AuthenticatedEndpoint`: + +```dart +class NoticesEndpoint extends AuthenticatedEndpoint { + /// Envia um aviso segmentado aos pacientes da microárea do ACS que + /// consentiram com `segmentedPush` (RF14). A microárea vem do token. + Future sendSegmented( + Session session, { + required String accessToken, + required String title, + required String message, + required String audience, + }) async { + final user = authenticate(accessToken); + try { + final r = await AlertRuntime.instance + .noticeServiceFor(session) + .sendSegmented(user, title: title, message: message, audience: audience); + return NoticeSendResult(recipients: r.recipients, accepted: r.accepted); + } on StateError catch (error) { + throw AlertPermissionException(message: error.message); + } + } +} +``` + +`AlertRuntime.noticeServiceFor(session)` monta `NoticeService(store: OrmNoticeRecipientStore(...), sender: config.gorushUrl == null ? null : GorushClient(baseUrl: config.gorushUrl!, timeout: config.gorushTimeout), audit: auditTrailFor(session))`. Rode `serverpod generate` e `create-migration` ("No changes detected" é esperado: só DTO e exceção). + +- [ ] **Step 5: Suíte e commit** + +Run: `cd backend/sinalacs_server && dart test && dart analyze` +Expected: verde, estável em 5 execuções seguidas, analyze no baseline com zero avisos. + +```bash +git add backend/sinalacs_server apps +git commit -m "feat(backend): notices.sendSegmented com segmentação SQL e consentimento vigente (RF14)" +``` + +(`apps` porque `serverpod generate` regenera `sinalacs_client`; confirme o caminho com `git status`.) + +--- + +### Task 4: Tela de envio no app do ACS + +**Files:** +- Modify: `apps/acs/lib/app/app.dart` (`NoticesScreen`), o cliente de backend do ACS (confirme o arquivo com `grep -rn "requestDataCorrection\|patients\." apps/acs/lib/core`), `apps/acs/test/support/` (fake do backend do ACS), teste novo `apps/acs/test/notices_screen_test.dart` + +**Interfaces:** +- Consumes: RPC `notices.sendSegmented` (Task 3) e `NoticeSendResult` do cliente gerado. +- Produces: no backend do ACS, `Future sendNotice({required String title, required String message, required bool chronicOnly})` (interface, implementação real, fake). O ACS já mantém um token de acesso com renovação silenciosa; use o mesmo helper dos outros métodos. + +- [ ] **Step 1: Testes vermelhos** + +```dart +testWidgets('enviar chama o backend com título, mensagem e público e mostra o resultado', (tester) async { + final backend = FakeAcsBackend(); + await abrirAvisos(tester, backend); + await tester.enterText(find.byKey(const Key('notice_title_field')), 'Vacinação'); + await tester.enterText(find.byKey(const Key('notice_message_field')), 'Amanhã, das 8h às 12h.'); + await tester.tap(find.byKey(const Key('notice_chronic_switch'))); + await tester.pump(); + await tester.tap(find.byKey(const Key('notice_send_button'))); + await tester.pumpAndSettle(); + + expect(backend.notices, [('Vacinação', 'Amanhã, das 8h às 12h.', true)]); + expect(find.textContaining('2 de 3'), findsOneWidget); // accepted de recipients +}); + +testWidgets('botão desabilitado com título ou mensagem vazios', (tester) async { + await abrirAvisos(tester, FakeAcsBackend()); + expect(tester.widget(find.byKey(const Key('notice_send_button'))).onPressed, isNull); +}); + +testWidgets('falha do envio mostra o erro do servidor e mantém o texto digitado', (tester) async { + final backend = FakeAcsBackend()..noticeFailure = const BackendFailure('Não foi possível entregar o aviso agora.'); + await abrirAvisos(tester, backend); + await tester.enterText(find.byKey(const Key('notice_title_field')), 'T'); + await tester.enterText(find.byKey(const Key('notice_message_field')), 'M'); + await tester.pump(); + await tester.tap(find.byKey(const Key('notice_send_button'))); + await tester.pumpAndSettle(); + expect(find.textContaining('Não foi possível entregar'), findsOneWidget); + expect(tester.widget(find.byKey(const Key('notice_message_field'))).controller!.text, 'M'); +}); + +testWidgets('o aviso avisa que não deve conter dado de saúde e mostra os limites', (tester) async { + await abrirAvisos(tester, FakeAcsBackend()); + expect(find.textContaining('não escreva nome nem condição de saúde'), findsOneWidget); +}); +``` + +Use os helpers de montagem e as classes de fake do ACS (`FakeAcsBackend` é o rótulo do que o arquivo de suporte já chama; leia `apps/acs/test/support` antes). Run: `cd apps/acs && flutter test test/notices_screen_test.dart`. Expected: FAIL — os campos e `sendNotice` não existem. + +- [ ] **Step 2: Implementar** + +Troque o `NoticesScreen` decorativo por uma tela de estado próprio: `TextField` de título (`maxLength: 60`), de mensagem (`maxLength: 240`, `maxLines: 4`), `SwitchListTile` "Só pacientes com condição crônica" (`notice_chronic_switch`), texto de apoio "Só chega a quem aceitou receber avisos. Não escreva nome nem condição de saúde na mensagem.", botão `notice_send_button` (desabilitado sem título/mensagem e durante o envio, com `_busy`), resultado "Aviso enviado a X de Y pacientes." e erro do servidor em texto. Sem persistir rascunho. Cores com os tokens `*OnSurface` de `AcsColors`. Rode os testes (PASS), depois `flutter test && flutter analyze` no ACS. + +- [ ] **Step 3: Commit** + +```bash +git add apps/acs +git commit -m "feat(acs): tela de envio de aviso comunitário (RF14)" +``` + +--- + +### Task 5: Captura do token no paciente com Riverpod + +**Files:** +- Create: `apps/patient/lib/core/push/push_token_provider.dart`, `apps/patient/lib/core/push/native_push_token_source.dart`, `apps/patient/test/push_riverpod_test.dart` +- Modify: `apps/patient/pubspec.yaml`, `apps/patient/lib/main.dart`, `apps/patient/lib/app/app.dart`, `apps/patient/lib/core/push/push_token_source.dart` (comentário) + +**Interfaces:** +- Consumes: `PushTokenSource`, `PushDevice`, `registerPushDevice(PatientBackend, PushTokenSource)` (de `push_token_source.dart`), `PatientBackend.registerPushToken`. +- Produces: `final pushTokenSourceProvider = Provider((ref) => const NativePushTokenSource());` (sobrescrevível em teste). +- Produces: `final pushRegistrationProvider = Provider Function(PatientBackend)>((ref) => (backend) => registerPushDevice(backend, ref.read(pushTokenSourceProvider)));` +- Produces: `class NativePushTokenSource implements PushTokenSource` que chama o `MethodChannel('sinalacs/push_token')`, método `getToken`, devolvendo `{'token': String, 'platform': 'android'|'ios'}`; `MissingPluginException`, `PlatformException` ou resposta malformada resultam em `null`. +- Produces: `SinalAcsApp` continua aceitando `pushTokens` (`PushTokenSource?`) — quando presente, tem precedência sobre o provider, para não reescrever os testes existentes. + +**Limite honesto desta task:** o lado **nativo** do canal (Kotlin com a biblioteca do FCM e `google-services.json` no Android; Swift com o registro APNs no iOS) exige as credenciais que a organização ainda não forneceu, e o FCM no Android não tem alternativa "leve" sem a biblioteca do Firebase. Esta task entrega o lado Dart, o provider e o contrato do canal; até o lado nativo existir, o canal não responde e o app degrada para "sem push". + +- [ ] **Step 1: Dependência** + +`apps/patient/pubspec.yaml`: `flutter_riverpod: ^2.6.1` (confirme a versão estável e compatível com o SDK com `flutter pub add flutter_riverpod`; se a resolução falhar por conflito, rode `flutter pub deps` e registre o motivo). Run: `cd apps/patient && flutter pub get`. Expected: resolve sem alterar outras versões (confira `git diff pubspec.lock`). + +- [ ] **Step 2: Testes vermelhos** + +`push_riverpod_test.dart`: + +```dart +testWidgets('o provider entrega o token da fonte e o registro chega ao backend', (tester) async { + final backend = FakePatientBackend(); + await tester.pumpWidget(ProviderScope( + overrides: [pushTokenSourceProvider.overrideWithValue(const _Fonte(PushDevice(token: 'tok-9', platform: 'android')))], + child: SinalAcsApp(backend: backend), + )); + await login(tester); + expect(backend.pushRegistrations, [('tok-9', 'android')]); +}); + +testWidgets('sem ProviderScope acima, o app ainda sobe e não registra', (tester) async { + final backend = FakePatientBackend(); + await tester.pumpWidget(SinalAcsApp(backend: backend)); + await login(tester); + expect(find.byType(PatientHomeShell), findsOneWidget); + expect(backend.pushRegistrations, isEmpty); +}); + +test('NativePushTokenSource devolve null quando o canal não existe', () async { + TestWidgetsFlutterBinding.ensureInitialized(); + expect(await const NativePushTokenSource().currentDevice(), isNull); +}); + +test('NativePushTokenSource lê o token e a plataforma do canal', () async { + TestWidgetsFlutterBinding.ensureInitialized(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + const MethodChannel('sinalacs/push_token'), + (call) async => {'token': 'abc', 'platform': 'ios'}, + ); + addTearDown(() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(const MethodChannel('sinalacs/push_token'), null)); + final device = await const NativePushTokenSource().currentDevice(); + expect((device!.token, device.platform), ('abc', 'ios')); +}); + +test('resposta malformada ou plataforma desconhecida vira null', () async { + TestWidgetsFlutterBinding.ensureInitialized(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + const MethodChannel('sinalacs/push_token'), + (call) async => {'token': '', 'platform': 'web'}, + ); + addTearDown(() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(const MethodChannel('sinalacs/push_token'), null)); + expect(await const NativePushTokenSource().currentDevice(), isNull); +}); +``` + +com `_Fonte` (fonte fixa, no arquivo) e `login` copiado de `push_registration_test.dart`. Run: `flutter test test/push_riverpod_test.dart`. Expected: FAIL — provider e fonte nativa não existem. + +- [ ] **Step 3: Implementar** + +`native_push_token_source.dart`: `MethodChannel('sinalacs/push_token')`; `currentDevice()` chama `invokeMapMethod('getToken')` dentro de `try`, valida `token` não vazio e `platform` em `{'android','ios'}`, e devolve `null` para `MissingPluginException`, `PlatformException` e qualquer outra falha. `push_token_provider.dart` define os dois providers acima. Em `app.dart`, `SinalAcsApp` continua sendo `StatefulWidget`; a fonte usada é `widget.pushTokens ?? _providerSource(context)`, onde `_providerSource` lê o `ProviderScope` com `ProviderScope.containerOf(context, listen: false)` **dentro de `try`** e cai em `NoPushTokenSource` se não houver escopo. `main.dart` envolve `runApp` em `ProviderScope`. O `PushTokenScope` (InheritedWidget) segue sendo o que as telas consomem, alimentado por esta fonte: o Riverpod fica só na borda de captura, como decidido. Rode os testes (PASS), depois `flutter test && flutter analyze` no paciente. + +- [ ] **Step 4: Commit** + +```bash +git add apps/patient +git commit -m "feat(paciente): provider Riverpod e canal nativo para o token de push (RF14)" +``` + +--- + +### Task 6: Documentação, memória e verificação final + +**Files:** +- Modify: `PROGRESS.md` (seção do RF14 com Gorush entregue; pendências reais), `apps/CLAUDE.md` (Riverpod só no push, canal `sinalacs/push_token`, tela de avisos do ACS), `backend/CLAUDE.md` (`notices.sendSegmented`, `GorushClient`, `GORUSH_URL`, perfil `push`), `spec/stack.md` (Riverpod, Gorush com credenciais), `spec/lgpd_data_audit.md` (`audit_logs` com `community_notice`), `spec/lgpd_design.md` (o aviso de `segmentedPush` agora tem leitor: `sendSegmented` consulta o consentimento mais recente), `CLAUDE.md` (lista de endpoints, se citar), `README`/`backend/DEPLOY.md` se descreverem o Compose, memória `patient-push-and-minors-2026-09-29` (ou nova) e `MEMORY.md`. + +- [ ] **Step 1:** Editar os docs, com contagens reais medidas. Registrar como **pendências que não são código**: credenciais FCM/APNs fornecidas pela organização, lado nativo do canal `sinalacs/push_token` (Kotlin/Swift) e teste real em aparelho, revisão jurídica do texto de `segmentedPush`, e a validação de ponta a ponta com um Gorush de verdade (só rodada com credenciais). + +- [ ] **Step 2: Verificação completa** + +Run: `cd backend/sinalacs_server && dart test && dart analyze; cd ../../apps/patient && flutter test && flutter analyze; cd ../acs && flutter test && flutter analyze; cd ../.. && docker compose config -q && ./scripts/qa/ci_invariants.sh; graphify update .` +Expected: backend verde (repetir 5 vezes, sem intermitência), paciente e ACS verdes, analyzes limpos/no baseline, `docker compose config -q` sem erro, `ci_invariants` ok. **Não** afirmar que o envio funciona de ponta a ponta: nenhum teste fala com um Gorush real. + +- [ ] **Step 3: Commit** + +```bash +git add PROGRESS.md apps/CLAUDE.md backend/CLAUDE.md spec CLAUDE.md +git commit -m "docs: registra o envio de avisos por Gorush e as pendências de credenciais" +``` + +--- + +## Fora deste plano (por decisão) + +- Lado nativo do canal (Kotlin/Swift), `google-services.json`, chave APNs e o teste em aparelho: dependem de credenciais que a organização não forneceu. +- Migrar o restante do app do paciente para Riverpod (escolha do usuário: só o push). +- Salvar o token no SQLite local: hoje nenhum consumidor local precisa dele; o servidor é a fonte. +- Agendamento e histórico de avisos enviados, avisos com imagem ou ação, envio para outras microáreas. + +## Autorrevisão + +- **Cobertura:** revisão das specs → Task 0; Gorush e config → Task 1; cliente → Task 2; segmentação SQL + consentimento + auditoria + endpoint → Task 3; envio no ACS → Task 4; captura do token com Riverpod → Task 5; docs → Task 6. A arquitetura colada tem três blocos (Flutter+Riverpod, PostgreSQL, Gorush): Tasks 5, 3 e 1–2. O nome `user_push_tokens` não é adotado: a tabela `push_tokens` já existe (registrado na Task 0/PROGRESS). +- **Placeholders:** os pontos que dependem de nomes locais (pasta dos DTOs, colunas reais da consulta, chaves do `config.yml` do Gorush, tag da imagem, arquivo do cliente do ACS, campos de `AuditEvent`) trazem a instrução de confirmar; nenhum comportamento ficou por definir. +- **Tipos:** `PushSender`/`PushTarget`/`PushMessage`/`PushSendReport`/`PushGatewayException` (Task 2) são usados com a mesma assinatura na Task 3; `NoticeSendResult` (DTO com `recipients` e `accepted`) é o mesmo no serviço, no endpoint e no cliente do ACS; `pushTokenSourceProvider` e `NativePushTokenSource` (Task 5) usam `PushTokenSource`/`PushDevice` já existentes. +- **Risco declarado:** o `PushTokenService`/`PushTokenStore` são alterados pelo plano anterior (`menores-do-push…`); por isso a ordem de execução é fixa. diff --git a/docs/superpowers/plans/2026-09-30-ci-credencial-fcm.md b/docs/superpowers/plans/2026-09-30-ci-credencial-fcm.md new file mode 100644 index 00000000..913fba0a --- /dev/null +++ b/docs/superpowers/plans/2026-09-30-ci-credencial-fcm.md @@ -0,0 +1,971 @@ +# Credenciais do FCM e Gorush no CI — Plano de Implementação + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fazer o job `android-e2e` do GitHub Actions decodificar os secrets `FCM_CREDENTIALS_BASE64` (chave de conta de serviço do FCM) e `GOOGLE_SERVICES_JSON_BASE64` (`google-services.json` do app do paciente), exportar `GOOGLE_APPLICATION_CREDENTIALS` e rodar o **push e2e com o Gorush e o FCM reais** no emulador do runner, sem nunca imprimir as credenciais e sem quebrar PRs de fork, onde os secrets não existem. + +**Architecture:** Um script testável (`scripts/ci/decode_secret_file.sh`) decodifica cada secret: o de conta de serviço vai para um arquivo temporário `0600` em `$RUNNER_TEMP` com `GOOGLE_APPLICATION_CREDENTIALS` apontando para ele; o `google-services.json` vai para `apps/patient/android/app/` (onde o build do paciente o procura). Um segundo script (`scripts/qa/ci_push_e2e.sh`) copia a chave de `GOOGLE_APPLICATION_CREDENTIALS` para o diretório que o Gorush monta e chama o `push_e2e.sh` existente. O emulador do CI passa a usar a imagem `google_apis` (com Play Services, sem o qual o FCM não entrega token). O guarda `ci_invariants.sh` ganha invariantes para que nada disso derive sem ninguém ver. + +**Tech Stack:** GitHub Actions (runner `ubuntu-24.04`, `reactivecircus/android-emulator-runner@v2`), bash, coreutils `base64`/`mktemp`/`install`, python3, Gorush 1.22.0 + FCM v1, actionlint 1.7.12 (via Docker). + +**Spec:** `.github/workflows/ci.yml` (job `android-e2e`), `scripts/qa/ci_invariants.sh`, `scripts/qa/push_e2e.sh` (o que o push e2e exige), `infra/docker/gorush/README.md`, `apps/patient/android/app/build.gradle.kts` (plugin do Google Services só com o `google-services.json` presente), `CLAUDE.md` da raiz (seção do CI) e `docs/ci-audit/2026-09-28-avaliacao-ci-develop.md`. + +## Estado verificado em 2026-09-30 + +- Os dois secrets **já estão cadastrados** no repositório: `FCM_CREDENTIALS_BASE64` e `GOOGLE_SERVICES_JSON_BASE64`. Não cadastre nada de novo. +- O `android-e2e` roda hoje **só** o smoke (`run_android_e2e.sh` → `e2e.sh --emulator --keep` + `measure_latency`). O push e2e (`push_e2e.sh`) nunca rodou na CI. +- O emulador do CI usa `api-level: 36`, `arch: x86_64`, `profile: pixel_7` e **não declara `target`**: a action usa o padrão `default` (imagem AOSP, **sem Google Play Services**). Sem Play Services o `getToken()` do FCM não devolve token. O cache do AVD tem chave `avd-36-x86_64-pixel_7-ubuntu-24.04`. +- `push_e2e.sh` (modo padrão, contra a stack de desenvolvimento) exige: `infra/docker/gorush/credentials/fcm-service-account.json` com modo `600` e ignorado pelo git (já está em `.gitignore`), `apps/patient/android/app/google-services.json` com o mesmo `project_id`, o `emulator-5554` (o padrão da action), e `.env` (do `bootstrap_env.sh`, que o job já roda). Ele segura o app instalado por 240 s. +- O build do paciente só aplica o plugin do Google Services quando `google-services.json` existe; sem ele o APK compila e o app degrada para "sem push" (é o que a CI faz hoje). +- Nada no repositório lê `GOOGLE_APPLICATION_CREDENTIALS` hoje (o Gorush lê `key_path: /credentials/fcm-service-account.json`). Este plano a torna a **origem** da chave que o `ci_push_e2e.sh` instala no diretório do Gorush. + +## Global Constraints + +- O conteúdo de nenhuma credencial é impresso (stdout, stderr, `::notice`/`::error`, `set -x`). Mensagens de erro não repetem trecho do secret nem do arquivo. +- Cada secret só chega ao script por `env:` do **passo**; nenhum `run:` do workflow contém `secrets.FCM_CREDENTIALS_BASE64` nem `secrets.GOOGLE_SERVICES_JSON_BASE64`, e o job não os declara em `env:` de nível de job (toda ação de terceiros os veria). +- Sem um secret (PR de fork, secret apagado): o passo termina com **sucesso**, emite `::notice` e não cria arquivo nem define variável; o job roda como roda hoje (push e2e pulado). Com o secret presente mas inválido: o passo **falha**, em voz alta. +- Arquivos: a chave em `$RUNNER_TEMP` (`0600`); a cópia no diretório do Gorush e o `google-services.json` em `0600`; um passo `if: always()` apaga os três. +- O script de decodificação **não sobrescreve** um arquivo de destino existente fora do CI (`GITHUB_ACTIONS != true`) e o modo de limpeza de arquivo **recusa** rodar fora do CI: um dev que execute o script por engano não perde o próprio `google-services.json`. +- `runs-on` continua `ubuntu-24.04`; ações nos majors mínimos (`checkout@v7`, `setup-java@v6`, `cache@v6`, `upload-artifact@v7`); sem `paths` filter; nenhum job criado ou renomeado (`JOBS_DOCUMENTADOS` e os 8 checks obrigatórios não mudam). `android-e2e` continua fora dos checks obrigatórios. +- A chave do cache do AVD continua começando com `avd-` e terminando com `-ubuntu-24.04` (invariante existente) e passa a conter o `target` do emulador. +- Textos, comentários e commits em português; commits terminam com `Co-Authored-By: Claude Sonnet 5.5 `. Sem push nem merge sem o usuário pedir. +- Nenhuma credencial real em teste, log ou commit: os testes usam JSONs **falsos** (`FAKE-PRIVATE-KEY-NOT-REAL-…`). + +## Review Focus + +- Secrets ausentes (PR de fork, `dependabot`): o job não fica vermelho, não define `GOOGLE_APPLICATION_CREDENTIALS` e o `ci_push_e2e.sh` pula com `::notice`. Testes nas Tasks 1 e 2. +- Base64 do `base64 -w0` **e** do `base64` quebrado a cada 76 colunas, com CRLF e espaço final: todos decodificam. Teste na Task 1. +- `google-services.json` de **outro** app Firebase (pacote diferente) ou uma chave de conta de serviço colada no secret errado: falha sem vazar. Teste na Task 1. +- `google-services.json` ficando no workspace depois do job, ou apagando o de um dev rodando o script localmente: a limpeza só age no CI, e a escrita recusa sobrescrever fora dele. Teste na Task 1. +- Alguém reintroduz um secret dentro de `run:` ou no `env:` do job, remove a limpeza, move a decodificação para depois do E2E, deixa o emulador voltar à imagem sem Play Services (AOSP), ou muda o `target` sem mudar a chave do cache do AVD: o `ci_invariants.sh` falha. Teste na Task 3. +- O push e2e falhando (FCM instável) não pode esconder o resultado do smoke nem o artefato de latência: o `run_android_e2e.sh` roda o push por último e propaga o código de saída dele. Teste na Task 2 (propagação) e prova no runner na Task 5. + +--- + +## Mapa de arquivos + +- Criar: `scripts/ci/decode_secret_file.sh` e `scripts/ci/decode_secret_file_test.sh`. +- Criar: `scripts/qa/ci_push_e2e.sh` e `scripts/qa/ci_push_e2e_test.sh`. +- Criar: `scripts/qa/ci_invariants_fcm_test.sh`. +- Modificar: `scripts/qa/run_android_e2e.sh` (push por último, `down` com o perfil `push`). +- Modificar: `.github/workflows/ci.yml` (passos de decodificação e limpeza; `target: google_apis` e chave do AVD; passo de testes no `workflow-lint`). +- Modificar: `scripts/qa/ci_invariants.sh` (`CI_WORKFLOW_PATH` e `check_credenciais_fcm`). +- Modificar: `infra/docker/gorush/README.md`, `CLAUDE.md`, `PROGRESS.md`. + +## Notas que o executor precisa saber antes de começar + +1. **Forks:** o GitHub não entrega secrets a `pull_request` de fork; o valor chega como string vazia. É o caso que os scripts tratam com `::notice`. +2. **Mudar o `target` do emulador muda o AVD:** a primeira execução depois da mudança não acha o cache (`avd-36-google_apis-…`) e regenera o snapshot (alguns minutos a mais, uma vez). Isto é esperado. +3. **A imagem `google_apis` não foi exercitada nesta máquina** (o emulador local é a imagem Google Play, onde o push e2e já passou). Só a primeira execução no runner prova que o FCM entrega token na `google_apis`. Se não entregar, a saída é `target: google_apis_playstore`; a Task 5 cobre isso. +4. `git push` e `gh workflow run` são ações com efeito fora da máquina: a Task 5 pede confirmação do usuário antes de cada uma. `gh secret list` (só lê nomes) pode ser usado para conferir que os dois secrets existem. +5. Ferramentas: `docker` (imagem `rhysd/actionlint:1.7.12` já baixada), `python3` com PyYAML, `base64`, `mktemp` e `install` do GNU coreutils. + +--- + +### Task 1: Script de decodificação de secrets, com testes + +**Files:** +- Create: `scripts/ci/decode_secret_file.sh` +- Test: `scripts/ci/decode_secret_file_test.sh` + +**Interfaces:** +- Produces (as Tasks 2–4 usam estes nomes e flags exatos): + - `decode_secret_file.sh --env --kind service_account|google_services (--temp-export | --to ) [--package ]` — lê o secret de `$`. Sem secret: `::notice`, exit 0, nada criado. Inválido: `::error`, exit 1, nada criado. `--temp-export NOME`: arquivo `0600` em `$RUNNER_TEMP/secret.XXXXXX.json` e linha `NOME=` anexada a `$GITHUB_ENV`. `--to ARQUIVO`: grava em `ARQUIVO` (`0600`, criando o diretório), **recusa** sobrescrever um arquivo existente quando `GITHUB_ACTIONS != true` (exit 1). `--package` (só `google_services`): exige um `client` com esse `package_name`. + - `decode_secret_file.sh --cleanup-temp ` — apaga o arquivo apontado por `$` **só** se estiver sob `$RUNNER_TEMP`; sempre exit 0. + - `decode_secret_file.sh --cleanup-file ` — apaga `ARQUIVO` **só** se `GITHUB_ACTIONS=true`; fora do CI avisa e sai 0 sem apagar. + - Uso incorreto (faltam `--env`/`--kind`, ou nem `--temp-export` nem `--to`, ou os dois, ou `--kind` desconhecido): exit 2. + +- [ ] **Step 1: Escrever os testes que falham** + +`scripts/ci/decode_secret_file_test.sh` (`chmod +x`): + +```bash +#!/usr/bin/env bash +# Testes de decode_secret_file.sh. Sem framework: cada caso roda o script num ambiente +# novo e afirma sobre o que saiu e o que ficou em disco. As credenciais são FALSAS. +set -uo pipefail +cd "$(dirname "$0")/../.." +script="$PWD/scripts/ci/decode_secret_file.sh" +# Cada asserção que falha acrescenta uma linha ao marcador (funciona dentro de subshell). +marcador="$(mktemp)" +raiz="$(mktemp -d)" +trap 'rm -rf "$raiz" "$marcador"' EXIT +FAKE_KEY='FAKE-PRIVATE-KEY-NOT-REAL-0123456789' +PACOTE='br.com.exemplo.app' + +sa_json() { + printf '{"type":"service_account","project_id":"p","client_email":"x@p.iam.gserviceaccount.com","private_key":"%s"}' "$FAKE_KEY" +} +gs_json() { # $1 = pacote (padrão $PACOTE) + printf '{"project_info":{"project_id":"fake-proj","api-key-fake":"%s"},"client":[{"client_info":{"android_client_info":{"package_name":"%s"}}}]}' "$FAKE_KEY" "${1:-$PACOTE}" +} + +novo_ambiente() { + T="$(mktemp -d "$raiz/caso.XXXXXX")" # limpo por inteiro no EXIT (um trap RETURN apagaria já ao sair daqui) + export RUNNER_TEMP="$T/runner_tmp" GITHUB_ENV="$T/github_env" + mkdir -p "$RUNNER_TEMP"; : >"$GITHUB_ENV" + unset GOOGLE_APPLICATION_CREDENTIALS FCM_SECRET GS_SECRET GITHUB_ACTIONS + DEST="$T/ws/apps/patient/android/app/google-services.json" +} + +# $1 descrição; resto: comando de teste (avaliado) +afirma() { + local desc="$1"; shift + if eval "$*"; then echo "ok: $desc"; else echo "FALHOU: $desc"; echo x >>"$marcador"; fi +} + +sa() { "$script" --env FCM_SECRET --kind service_account --temp-export GOOGLE_APPLICATION_CREDENTIALS "$@"; } +gs() { "$script" --env GS_SECRET --kind google_services --to "$DEST" "$@"; } +vazio_em() { [[ -z "$(ls -A "$1" 2>/dev/null)" ]]; } + +t_sem_secret_termina_bem_e_nao_cria_nada() { + novo_ambiente + out="$(sa 2>&1)"; rc=$? + afirma "sa sem secret: exit 0 com ::notice" '[[ $rc -eq 0 ]] && grep -q "^::notice" <<<"$out"' + afirma "sa sem secret: GITHUB_ENV intacto e nenhum arquivo" '[[ ! -s "$GITHUB_ENV" ]] && vazio_em "$RUNNER_TEMP"' + export GS_SECRET=$' \n\t ' + out="$(gs 2>&1)"; rc=$? + afirma "gs só de espaços conta como ausente: exit 0, nenhum arquivo" '[[ $rc -eq 0 && ! -e "$DEST" ]]' +} + +t_service_account_uma_linha() { + novo_ambiente + export FCM_SECRET="$(sa_json | base64 -w0)" + out="$(sa 2>&1)"; rc=$? + arq="$(sed -n 's/^GOOGLE_APPLICATION_CREDENTIALS=//p' "$GITHUB_ENV")" + afirma "sa uma linha: exit 0" '[[ $rc -eq 0 ]]' + afirma "sa uma linha: arquivo sob RUNNER_TEMP, conteúdo idêntico, modo 600" '[[ -f "$arq" && "$arq" == "$RUNNER_TEMP"/* && "$(cat "$arq")" == "$(sa_json)" && "$(stat -c %a "$arq")" == 600 ]]' + afirma "sa uma linha: exatamente uma linha em GITHUB_ENV" '[[ "$(wc -l <"$GITHUB_ENV")" -eq 1 ]]' + afirma "sa uma linha: NADA do conteúdo na saída" '! grep -q "$FAKE_KEY" <<<"$out" && ! grep -q "service_account" <<<"$out"' +} + +t_service_account_quebrado_com_crlf() { + novo_ambiente + export FCM_SECRET="$(sa_json | base64 | sed 's/$/\r/') " # quebra a cada 76 colunas + CRLF + espaços + out="$(sa 2>&1)"; rc=$? + arq="$(sed -n 's/^GOOGLE_APPLICATION_CREDENTIALS=//p' "$GITHUB_ENV")" + afirma "sa quebrado+CRLF: exit 0 e conteúdo idêntico" '[[ $rc -eq 0 && "$(cat "$arq")" == "$(sa_json)" ]]' +} + +t_preserva_o_que_ja_estava_em_github_env() { + novo_ambiente + echo 'OUTRA=1' >"$GITHUB_ENV" + export FCM_SECRET="$(sa_json | base64 -w0)" + sa >/dev/null 2>&1 + afirma "anexa, não sobrescreve" 'grep -qx "OUTRA=1" "$GITHUB_ENV" && grep -q "^GOOGLE_APPLICATION_CREDENTIALS=" "$GITHUB_ENV"' +} + +t_service_account_invalido_nao_vaza() { + novo_ambiente + for invalido in \ + 'isto nao e base64 !!!' \ + "$(printf 'isto nao e json %s' "$FAKE_KEY" | base64 -w0)" \ + "$(printf '{"type":"authorized_user","client_email":"a","private_key":"%s"}' "$FAKE_KEY" | base64 -w0)" \ + "$(printf '{"type":"service_account","client_email":"a"}' | base64 -w0)" \ + "$(printf '[1,2,3]' | base64 -w0)" \ + "$(gs_json | base64 -w0)"; do + export FCM_SECRET="$invalido" + out="$(sa 2>&1)"; rc=$? + afirma "sa inválido (${invalido:0:10}…): exit 1 com ::error" '[[ $rc -eq 1 ]] && grep -q "^::error" <<<"$out"' + afirma "sa inválido: nada do conteúdo vaza e nenhum traceback do Python" '! grep -q "$FAKE_KEY" <<<"$out" && ! grep -q "isto nao e" <<<"$out" && ! grep -q "Traceback" <<<"$out"' + afirma "sa inválido: não deixa arquivo nem variável" 'vazio_em "$RUNNER_TEMP" && [[ ! -s "$GITHUB_ENV" ]]' + done +} + +t_google_services_grava_no_destino() { + novo_ambiente + export GS_SECRET="$(gs_json | base64 -w0)" + out="$(gs --package "$PACOTE" 2>&1)"; rc=$? + afirma "gs: exit 0, arquivo no destino com conteúdo idêntico e modo 600" '[[ $rc -eq 0 && "$(cat "$DEST")" == "$(gs_json)" && "$(stat -c %a "$DEST")" == 600 ]]' + afirma "gs: não exporta variável e não deixa temporário" '[[ ! -s "$GITHUB_ENV" ]] && vazio_em "$RUNNER_TEMP"' + afirma "gs: nada do conteúdo na saída" '! grep -q "$FAKE_KEY" <<<"$out" && ! grep -q "fake-proj" <<<"$out"' +} + +t_google_services_invalido() { + novo_ambiente + for invalido in \ + "$(gs_json 'br.com.OUTRO.app' | base64 -w0)" \ + "$(printf '{"project_info":{"project_id":"x"},"client":[]}' | base64 -w0)" \ + "$(printf '{"client":[{"client_info":{"android_client_info":{"package_name":"%s"}}}]}' "$PACOTE" | base64 -w0)" \ + "$(sa_json | base64 -w0)" \ + 'isto nao e base64 !!!'; do + export GS_SECRET="$invalido" + out="$(gs --package "$PACOTE" 2>&1)"; rc=$? + afirma "gs inválido (${invalido:0:10}…): exit 1 e nada gravado" '[[ $rc -eq 1 && ! -e "$DEST" ]] && grep -q "^::error" <<<"$out"' + afirma "gs inválido: nada do conteúdo vaza" '! grep -q "$FAKE_KEY" <<<"$out" && ! grep -q "fake-proj" <<<"$out"' + done +} + +t_nao_sobrescreve_fora_do_ci() { + novo_ambiente + mkdir -p "$(dirname "$DEST")"; echo 'DO-DEV' >"$DEST" + export GS_SECRET="$(gs_json | base64 -w0)" + out="$(gs 2>&1)"; rc=$? + afirma "fora do CI: recusa sobrescrever e preserva o arquivo do dev" '[[ $rc -eq 1 && "$(cat "$DEST")" == "DO-DEV" ]]' + out="$(GITHUB_ACTIONS=true gs 2>&1)"; rc=$? + afirma "no CI: sobrescreve" '[[ $rc -eq 0 && "$(cat "$DEST")" == "$(gs_json)" ]]' +} + +t_cleanup_temp_apaga_so_sob_runner_temp() { + novo_ambiente + export FCM_SECRET="$(sa_json | base64 -w0)" + sa >/dev/null 2>&1 + export GOOGLE_APPLICATION_CREDENTIALS="$(sed -n 's/^GOOGLE_APPLICATION_CREDENTIALS=//p' "$GITHUB_ENV")" + "$script" --cleanup-temp GOOGLE_APPLICATION_CREDENTIALS; rc=$? + afirma "cleanup-temp: exit 0 e arquivo removido" '[[ $rc -eq 0 && ! -e "$GOOGLE_APPLICATION_CREDENTIALS" ]]' + alheio="$T/alheio.json"; echo x >"$alheio" + GOOGLE_APPLICATION_CREDENTIALS="$alheio" "$script" --cleanup-temp GOOGLE_APPLICATION_CREDENTIALS + afirma "cleanup-temp: não apaga arquivo fora do RUNNER_TEMP" '[[ -e "$alheio" ]]' + unset GOOGLE_APPLICATION_CREDENTIALS + "$script" --cleanup-temp GOOGLE_APPLICATION_CREDENTIALS; rc=$? + afirma "cleanup-temp sem variável: exit 0" '[[ $rc -eq 0 ]]' +} + +t_cleanup_file_so_no_ci() { + novo_ambiente + mkdir -p "$(dirname "$DEST")"; echo 'DO-DEV' >"$DEST" + "$script" --cleanup-file "$DEST"; rc=$? + afirma "cleanup-file fora do CI: exit 0 e NÃO apaga" '[[ $rc -eq 0 && -e "$DEST" ]]' + GITHUB_ACTIONS=true "$script" --cleanup-file "$DEST"; rc=$? + afirma "cleanup-file no CI: apaga" '[[ $rc -eq 0 && ! -e "$DEST" ]]' + GITHUB_ACTIONS=true "$script" --cleanup-file "$DEST"; rc=$? + afirma "cleanup-file de arquivo inexistente: exit 0" '[[ $rc -eq 0 ]]' +} + +t_uso_incorreto() { + novo_ambiente + "$script" --kind service_account --temp-export X >/dev/null 2>&1; rc=$? + afirma "sem --env: exit 2" '[[ $rc -eq 2 ]]' + "$script" --env A --kind service_account >/dev/null 2>&1; rc=$? + afirma "sem destino: exit 2" '[[ $rc -eq 2 ]]' + "$script" --env A --kind service_account --temp-export X --to /tmp/y >/dev/null 2>&1; rc=$? + afirma "os dois destinos: exit 2" '[[ $rc -eq 2 ]]' + "$script" --env A --kind outro --temp-export X >/dev/null 2>&1; rc=$? + afirma "kind desconhecido: exit 2" '[[ $rc -eq 2 ]]' +} + +t_nao_usa_set_x() { + afirma "o script não liga set -x (vazaria o secret no log)" '! grep -Eq "^[[:space:]]*set [-+a-z]*x|set -o xtrace" "$script"' +} + +t_sem_secret_termina_bem_e_nao_cria_nada +t_service_account_uma_linha +t_service_account_quebrado_com_crlf +t_preserva_o_que_ja_estava_em_github_env +t_service_account_invalido_nao_vaza +t_google_services_grava_no_destino +t_google_services_invalido +t_nao_sobrescreve_fora_do_ci +t_cleanup_temp_apaga_so_sob_runner_temp +t_cleanup_file_so_no_ci +t_uso_incorreto +t_nao_usa_set_x + +n="$(wc -l <"$marcador")" +echo; [[ "$n" -eq 0 ]] && echo "OK — decode_secret_file.sh" || { echo "$n asserção(ões) falharam"; exit 1; } +``` + +- [ ] **Step 2: Rodar e ver falhar** + +Run: `chmod +x scripts/ci/decode_secret_file_test.sh && ./scripts/ci/decode_secret_file_test.sh; echo rc=$?` +Expected: `rc=1`, dezenas de `FALHOU: …`, porque `scripts/ci/decode_secret_file.sh` não existe. (A asserção de `set -x` passa por ausência; é esperado.) + +- [ ] **Step 3: Implementar o script** + +`scripts/ci/decode_secret_file.sh` (`chmod +x`): + +```bash +#!/usr/bin/env bash +# +# Decodifica um secret do GitHub que guarda um arquivo em base64 (`base64 -w0 arquivo`). +# +# --env VAR --kind service_account --temp-export NOME # arquivo 0600 em $RUNNER_TEMP + NOME= em $GITHUB_ENV +# --env VAR --kind google_services --to ARQUIVO [--package ID] +# --cleanup-temp NOME # apaga o arquivo de $NOME, só se sob $RUNNER_TEMP +# --cleanup-file ARQUIVO # apaga ARQUIVO, só no CI (GITHUB_ACTIONS=true) +# +# Por que é um script e não YAML inline: o secret entra por `env:` do passo (nunca por +# `${{ }}` dentro de `run:`, que é injeção de script e pede um `echo` para vazar) e o +# comportamento fica testável (decode_secret_file_test.sh). +# +# Regras: NUNCA imprime o conteúdo (sem `set -x`, sem eco, erros genéricos); sem o secret +# (PR de fork) sai com 0 e não cria nada; com o secret inválido sai com 1; fora do CI não +# sobrescreve nem apaga arquivo que já existia (é o `google-services.json` de um dev). +set -euo pipefail +umask 077 + +uso() { echo 'uso: decode_secret_file.sh --env VAR --kind service_account|google_services (--temp-export NOME | --to ARQUIVO) [--package ID] | --cleanup-temp NOME | --cleanup-file ARQUIVO' >&2; exit 2; } + +modo=decodificar; env_nome=''; tipo=''; temp_export=''; destino=''; pacote=''; alvo='' +while [[ $# -gt 0 ]]; do + case "$1" in + --env) [[ $# -ge 2 ]] || uso; env_nome="$2"; shift 2 ;; + --kind) [[ $# -ge 2 ]] || uso; tipo="$2"; shift 2 ;; + --temp-export) [[ $# -ge 2 ]] || uso; temp_export="$2"; shift 2 ;; + --to) [[ $# -ge 2 ]] || uso; destino="$2"; shift 2 ;; + --package) [[ $# -ge 2 ]] || uso; pacote="$2"; shift 2 ;; + --cleanup-temp) [[ $# -ge 2 ]] || uso; modo=limpar_temp; alvo="$2"; shift 2 ;; + --cleanup-file) [[ $# -ge 2 ]] || uso; modo=limpar_arquivo; alvo="$2"; shift 2 ;; + *) uso ;; + esac +done + +if [[ "$modo" == limpar_temp ]]; then + arquivo="${!alvo:-}" + # Só apaga o que este script criou: sob $RUNNER_TEMP. Outro passo pode ter + # redefinido a variável para um arquivo que não é nosso. + if [[ -n "$arquivo" && -n "${RUNNER_TEMP:-}" && "$arquivo" == "$RUNNER_TEMP"/* ]]; then + rm -f -- "$arquivo" + fi + exit 0 +fi + +if [[ "$modo" == limpar_arquivo ]]; then + if [[ "${GITHUB_ACTIONS:-}" == true ]]; then + rm -f -- "$alvo" + else + echo "aviso: --cleanup-file só age no CI (GITHUB_ACTIONS=true); $alvo foi mantido." >&2 + fi + exit 0 +fi + +# -- decodificar ------------------------------------------------------------- +[[ -n "$env_nome" && -n "$tipo" ]] || uso +[[ "$tipo" == service_account || "$tipo" == google_services ]] || uso +if [[ -n "$temp_export" && -n "$destino" ]] || [[ -z "$temp_export" && -z "$destino" ]]; then uso; fi + +segredo="${!env_nome:-}" +if [[ -z "${segredo//[[:space:]]/}" ]]; then + echo "::notice title=secret::$env_nome ausente (PR de fork ou secret não cadastrado): nada foi decodificado e o que depende dele fica desligado." + exit 0 +fi + +if [[ -n "$destino" && -e "$destino" && "${GITHUB_ACTIONS:-}" != true ]]; then + echo "::error title=secret::$destino já existe e isto não é o CI: não sobrescrevo o arquivo de um desenvolvedor." + exit 1 +fi + +dir="${RUNNER_TEMP:-$(mktemp -d)}" +tmp="$(mktemp --suffix=.json "$dir/secret.XXXXXX")" +falhar() { + rm -f -- "$tmp" + echo "::error title=secret::$1" + exit 1 +} + +# base64 do `base64 -w0`, do `base64` com quebra em 76 colunas, com CRLF ou espaço +# final: tudo que é espaço em branco sai antes de decodificar. +if ! printf '%s' "$segredo" | tr -d '[:space:]' | base64 -d >"$tmp" 2>/dev/null; then + falhar "$env_nome não é um base64 válido." +fi + +# Só confirma a FORMA. Saída e erro descartados: um traceback do Python imprimiria o dado. +if ! python3 - "$tmp" "$tipo" "$pacote" >/dev/null 2>&1 <<'PY' +import json, sys +caminho, tipo, pacote = sys.argv[1:4] +d = json.load(open(caminho, encoding='utf-8')) +if tipo == 'service_account': + ok = (isinstance(d, dict) and d.get('type') == 'service_account' + and d.get('client_email') and d.get('private_key')) +else: # google_services + clientes = d.get('client') if isinstance(d, dict) else None + ok = bool(isinstance(d, dict) and (d.get('project_info') or {}).get('project_id') + and isinstance(clientes, list) and clientes) + if ok and pacote: + ok = any((((c.get('client_info') or {}).get('android_client_info') or {}).get('package_name') == pacote) + for c in clientes if isinstance(c, dict)) +sys.exit(0 if ok else 1) +PY +then + falhar "$env_nome decodificou, mas não tem a forma esperada de um $tipo${pacote:+ do pacote $pacote}." +fi + +chmod 600 "$tmp" +if [[ -n "$destino" ]]; then + mkdir -p "$(dirname "$destino")" + mv -f -- "$tmp" "$destino" + echo "::notice title=secret::$env_nome decodificado em $destino (conteúdo não impresso)." +else + if [[ -n "${GITHUB_ENV:-}" ]]; then + echo "$temp_export=$tmp" >>"$GITHUB_ENV" + fi + echo "::notice title=secret::$env_nome decodificado em $tmp ($temp_export definida para os próximos passos; conteúdo não impresso)." +fi +``` + +- [ ] **Step 4: Rodar e ver passar** + +Run: `./scripts/ci/decode_secret_file_test.sh; echo rc=$?` +Expected: todas as linhas `ok: …`, terminando em `OK — decode_secret_file.sh`, `rc=0`. + +- [ ] **Step 5: Provar que os testes pegam mutações** + +Rode cada mutação temporária numa cópia (`cp scripts/ci/decode_secret_file.sh /tmp/bak.sh`, edite, rode o teste, restaure com `cp /tmp/bak.sh scripts/ci/decode_secret_file.sh`): +1. Troque `chmod 600 "$tmp"` por `chmod 644 "$tmp"` **e** apague `umask 077` → o teste falha em `modo 600`. +2. Apague `>/dev/null 2>&1` do `python3 -` → o teste falha em `nenhum traceback do Python`. +3. Apague o `if [[ -n "$destino" && -e "$destino" … ]]` inteiro → falha `fora do CI: recusa sobrescrever`. +4. Troque `"${GITHUB_ACTIONS:-}" == true` do `--cleanup-file` por `-n "${GITHUB_ACTIONS:-x}"` → falha `cleanup-file fora do CI … NÃO apaga`. +Expected: vermelho em cada uma; verde de novo após restaurar. + +- [ ] **Step 6: Commit** + +```bash +git add scripts/ci/decode_secret_file.sh scripts/ci/decode_secret_file_test.sh +git commit -m "ci: script que decodifica secrets em base64 (chave do FCM e google-services.json) sem imprimir nem sobrescrever + +Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Task 2: Push e2e no CI — `ci_push_e2e.sh` e o `run_android_e2e.sh` + +**Files:** +- Create: `scripts/qa/ci_push_e2e.sh` +- Test: `scripts/qa/ci_push_e2e_test.sh` +- Modify: `scripts/qa/run_android_e2e.sh` (linhas 15 e 28-31) + +**Interfaces:** +- Consumes: `GOOGLE_APPLICATION_CREDENTIALS` (caminho da chave, definido pelo passo da Task 4 via `--temp-export`), `apps/patient/android/app/google-services.json`, `./scripts/qa/push_e2e.sh` (existente; sem argumentos no CI, isto é, só o caminho feliz). +- Produces: `ci_push_e2e.sh` — sem a chave ou sem o `google-services.json`: `::notice`, exit 0. Com os dois: instala a chave em `infra/docker/gorush/credentials/fcm-service-account.json` (modo `0600`) e **é substituído** (`exec`) por `$PUSH_E2E_SCRIPT` (padrão `./scripts/qa/push_e2e.sh`; só o teste o troca), propagando o exit dele. `run_android_e2e.sh` roda o push **por último** e sai com o código dele. + +- [ ] **Step 1: Escrever o teste que falha** + +`scripts/qa/ci_push_e2e_test.sh` (`chmod +x`). Ele monta uma árvore mínima num diretório temporário (o script faz `cd` para a raiz a partir da própria localização): + +```bash +#!/usr/bin/env bash +# ci_push_e2e.sh: pula sem credenciais; com elas, instala a chave e chama o push e2e. +set -uo pipefail +cd "$(dirname "$0")/../.." +orig="$PWD/scripts/qa/ci_push_e2e.sh" +raiz="$(mktemp -d)"; trap 'rm -rf "$raiz"' EXIT +marcador="$raiz/falhas"; : >"$marcador" +afirma() { if eval "$2"; then echo "ok: $1"; else echo "FALHOU: $1"; echo x >>"$marcador"; fi; } + +nova_arvore() { + R="$raiz/repo.$RANDOM"; mkdir -p "$R/scripts/qa" "$R/apps/patient/android/app" + cp "$orig" "$R/scripts/qa/ci_push_e2e.sh" + cat >"$R/stub_push.sh" <<'STUB' +#!/usr/bin/env bash +echo "chamado" >>"$(dirname "$0")/chamadas" +[[ -f infra/docker/gorush/credentials/fcm-service-account.json ]] && stat -c %a infra/docker/gorush/credentials/fcm-service-account.json >"$(dirname "$0")/modo_da_chave" +exit "${STUB_RC:-0}" +STUB + chmod +x "$R/stub_push.sh" "$R/scripts/qa/ci_push_e2e.sh" + echo '{"chave":"FAKE"}' >"$raiz/chave.json" + unset GOOGLE_APPLICATION_CREDENTIALS STUB_RC + export PUSH_E2E_SCRIPT="$R/stub_push.sh" +} +roda() { "$R/scripts/qa/ci_push_e2e.sh" 2>&1; } + +nova_arvore +out="$(roda)"; rc=$? +afirma "sem variável e sem google-services: pula (exit 0, ::notice, stub não chamado)" '[[ $rc -eq 0 ]] && grep -q "^::notice" <<<"$out" && [[ ! -e "$R/chamadas" ]]' + +nova_arvore +export GOOGLE_APPLICATION_CREDENTIALS="$raiz/chave.json" +out="$(roda)"; rc=$? +afirma "com a chave mas sem google-services.json: pula" '[[ $rc -eq 0 && ! -e "$R/chamadas" && ! -e "$R/infra" ]]' + +nova_arvore +echo '{}' >"$R/apps/patient/android/app/google-services.json" +out="$(roda)"; rc=$? +afirma "com google-services.json mas sem a chave: pula" '[[ $rc -eq 0 && ! -e "$R/chamadas" ]]' + +nova_arvore +export GOOGLE_APPLICATION_CREDENTIALS="$raiz/inexistente.json" +echo '{}' >"$R/apps/patient/android/app/google-services.json" +out="$(roda)"; rc=$? +afirma "variável apontando para arquivo inexistente: pula" '[[ $rc -eq 0 && ! -e "$R/chamadas" ]]' + +nova_arvore +export GOOGLE_APPLICATION_CREDENTIALS="$raiz/chave.json" +echo '{}' >"$R/apps/patient/android/app/google-services.json" +out="$(roda)"; rc=$? +cofre="$R/infra/docker/gorush/credentials/fcm-service-account.json" +afirma "com os dois: instala a chave idêntica em 0600" '[[ "$(cat "$cofre")" == "$(cat "$raiz/chave.json")" && "$(cat "$R/modo_da_chave")" == 600 ]]' +afirma "com os dois: chama o push e2e uma vez" '[[ "$(wc -l <"$R/chamadas")" -eq 1 ]]' +afirma "a saída não imprime o conteúdo da chave" '! grep -q "FAKE" <<<"$out"' + +nova_arvore +export GOOGLE_APPLICATION_CREDENTIALS="$raiz/chave.json" STUB_RC=7 +echo '{}' >"$R/apps/patient/android/app/google-services.json" +out="$(roda)"; rc=$? +afirma "o exit do push e2e é propagado (7)" '[[ $rc -eq 7 ]]' + +n="$(wc -l <"$marcador")" +echo; [[ "$n" -eq 0 ]] && echo "OK — ci_push_e2e.sh" || { echo "$n asserção(ões) falharam"; exit 1; } +``` + +- [ ] **Step 2: Rodar e ver falhar** + +Run: `chmod +x scripts/qa/ci_push_e2e_test.sh && ./scripts/qa/ci_push_e2e_test.sh; echo rc=$?` +Expected: `rc=1` (o `cp` do script inexistente falha e as asserções ficam vermelhas). + +- [ ] **Step 3: Implementar o script** + +`scripts/qa/ci_push_e2e.sh` (`chmod +x`): + +```bash +#!/usr/bin/env bash +# +# Push e2e (Gorush e FCM reais) no CI — só se as credenciais existirem. +# +# Precisa de GOOGLE_APPLICATION_CREDENTIALS (chave da conta de serviço, decodificada pelo +# passo do workflow) e de apps/patient/android/app/google-services.json. Sem elas (PR de +# fork, secrets ausentes) avisa e sai com 0: o smoke do job continua valendo sozinho. +# +# O Gorush lê a chave de infra/docker/gorush/credentials/fcm-service-account.json +# (config.yml `key_path`), então a chave de GOOGLE_APPLICATION_CREDENTIALS é instalada ali +# com modo 600, que é o que o push_e2e.sh exige. Nunca imprime a chave. +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/../.." + +origem="${GOOGLE_APPLICATION_CREDENTIALS:-}" +google_services=apps/patient/android/app/google-services.json +destino=infra/docker/gorush/credentials/fcm-service-account.json +push="${PUSH_E2E_SCRIPT:-./scripts/qa/push_e2e.sh}" + +if [[ -z "$origem" || ! -f "$origem" || ! -f "$google_services" ]]; then + echo '::notice title=push e2e::sem a chave do FCM ou sem google-services.json (PR de fork ou secrets ausentes): push e2e pulado.' + exit 0 +fi + +install -D -m 600 "$origem" "$destino" +exec "$push" +``` + +- [ ] **Step 4: Rodar e ver passar** + +Run: `./scripts/qa/ci_push_e2e_test.sh; echo rc=$?` +Expected: todas `ok:` e `OK — ci_push_e2e.sh`, `rc=0`. + +- [ ] **Step 5: Ligar ao `run_android_e2e.sh`** + +Em `scripts/qa/run_android_e2e.sh`: + +(a) linha 15: `trap 'docker compose down' EXIT` → `trap 'docker compose --profile push down' EXIT` (com o perfil, o Gorush que o push e2e sobe também é derrubado). + +(b) substituir as linhas 28–31 por: + +```bash +./scripts/qa/e2e.sh --emulator --keep +dart run scripts/qa/measure_latency.dart \ + --mqtt-password "$MQTT_ACS_PASSWORD" \ + --output build/qa/latency/metrics.json + +# Push e2e (Gorush e FCM reais) por ÚLTIMO: uma falha do FCM não pode esconder o resultado +# do smoke nem impedir o artefato de latência, mas o job TEM de falhar quando o push falha. +# Sem as credenciais (PR de fork) o script avisa e sai com 0. +push_rc=0 +./scripts/qa/ci_push_e2e.sh || push_rc=$? +exit "$push_rc" +``` + +- [ ] **Step 6: Verificar sintaxe e commit** + +Run: `bash -n scripts/qa/run_android_e2e.sh scripts/qa/ci_push_e2e.sh && echo ok` +Expected: `ok`. + +```bash +git add scripts/qa/ci_push_e2e.sh scripts/qa/ci_push_e2e_test.sh scripts/qa/run_android_e2e.sh +git commit -m "ci(android-e2e): push e2e com o Gorush e o FCM reais quando há credenciais, por último e propagando o resultado + +Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Task 3: Invariantes do workflow (credenciais e emulador com Play Services) + +**Files:** +- Modify: `scripts/qa/ci_invariants.sh` (a linha do `exec python3 - …`; novo `check_credenciais_fcm`; lista `CHECKS`) +- Test: `scripts/qa/ci_invariants_fcm_test.sh` + +**Interfaces:** +- Consumes: os nomes exatos que a Task 4 cria no `ci.yml` — passos `Decodifica a credencial do FCM` (env `FCM_CREDENTIALS_BASE64`), `Decodifica o google-services.json` (env `GOOGLE_SERVICES_JSON_BASE64`), `Remove as credenciais do FCM` (`if: always()`, `run` contendo `decode_secret_file.sh --cleanup-temp GOOGLE_APPLICATION_CREDENTIALS`, `--cleanup-file apps/patient/android/app/google-services.json` e `--cleanup-file infra/docker/gorush/credentials/fcm-service-account.json`), o passo `E2E no emulador Android`, e os dois passos `reactivecircus/android-emulator-runner` com `target: google_apis` e a chave de cache `avd-36-google_apis-x86_64-pixel_7-ubuntu-24.04`. +- Produces: `CI_WORKFLOW_PATH` (variável opcional que aponta o guarda para outro `ci.yml`, só para teste) e `check_credenciais_fcm` (mais um item em `CHECKS`; a mensagem final passa de `8 grupos` para `9 grupos`). + +- [ ] **Step 1: Escrever o teste que falha** + +`scripts/qa/ci_invariants_fcm_test.sh` (`chmod +x`): muta uma cópia do `ci.yml` e exige que o guarda reprove cada regressão (e aprove a cópia intacta). + +```bash +#!/usr/bin/env bash +# O guarda do workflow precisa pegar as regressões das credenciais do FCM e do emulador. +set -uo pipefail +cd "$(dirname "$0")/../.." +orig=.github/workflows/ci.yml +tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT +marcador="$tmp/falhas"; : >"$marcador" + +roda() { CI_WORKFLOW_PATH="$1" ./scripts/qa/ci_invariants.sh 2>&1; } +afirma() { if eval "$2"; then echo "ok: $1"; else echo "FALHOU: $1"; echo x >>"$marcador"; fi; } + +# mutação: $1 nome, $2 expressão python em `t` (o texto do ci.yml) que devolve o novo texto +muta() { + local nome="$1" codigo="$2" alvo="$tmp/$1.yml" + python3 - "$orig" "$alvo" < indice_e2e: + falhas.append(f'FCM: {nome_passo!r} vem DEPOIS do E2E; o arquivo não existiria nele') + limpeza = [p for p in passos[ultimo + 1:] if p.get('name') == LIMPEZA_FCM] + if not limpeza: + falhas.append(f'FCM: nenhum passo {LIMPEZA_FCM!r} depois das decodificações') + else: + passo = limpeza[0] + if passo.get('if') != 'always()': + falhas.append(f'FCM: o passo {LIMPEZA_FCM!r} precisa de if: always()') + for trecho in LIMPEZAS_ESPERADAS: + if trecho not in (passo.get('run') or ''): + falhas.append(f'FCM: o passo {LIMPEZA_FCM!r} não roda {trecho!r}') + # Emulador com Play Services e cache do AVD coerente. + emuladores = [p for p in passos if str(p.get('uses', '')).startswith('reactivecircus/android-emulator-runner')] + if not emuladores: + falhas.append('FCM: android-e2e sem passo reactivecircus/android-emulator-runner') + for passo in emuladores: + if (passo.get('with') or {}).get('target') != TARGET_EMULADOR: + falhas.append(f"FCM: o passo {passo.get('name')!r} precisa de target: {TARGET_EMULADOR} (sem Play Services o FCM não entrega token)") + for passo in passos: + chave = str((passo.get('with') or {}).get('key', '')) + if chave.startswith('avd-') and f'-{TARGET_EMULADOR}-' not in chave: + falhas.append(f'FCM: a chave do cache do AVD {chave!r} não contém o target {TARGET_EMULADOR!r}') +``` + +(c) acrescentar `check_credenciais_fcm` ao final da lista `CHECKS`. + +- [ ] **Step 4: Rodar (ainda vermelho, por falta do `ci.yml`)** + +Run: `./scripts/qa/ci_invariants.sh; echo rc=$?` +Expected: várias linhas `FALHA: FCM: …` e `rc=1`. É o esperado: o guarda já está certo e o workflow ainda não. A Task 4 o deixa verde. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/qa/ci_invariants.sh scripts/qa/ci_invariants_fcm_test.sh +git commit -m "ci: invariantes das credenciais do FCM e do emulador com Play Services + +Co-Authored-By: Claude Sonnet 5.5 " +``` + +> Este commit deixa o `ci_invariants.sh` vermelho até a Task 4. **Não dê push entre as Tasks 3 e 4**: a Task 4 é o commit que fecha o par. + +--- + +### Task 4: Passos no `ci.yml` + +**Files:** +- Modify: `.github/workflows/ci.yml` (jobs `android-e2e` e `workflow-lint`) + +**Interfaces:** +- Consumes: `scripts/ci/decode_secret_file.sh` (Task 1), `scripts/qa/run_android_e2e.sh` já com o push (Task 2), os nomes que o guarda da Task 3 exige. +- Produces: o `android-e2e` decodifica as duas credenciais antes do E2E, limpa sempre, e usa o emulador `google_apis`. + +- [ ] **Step 1: Passos de decodificação** + +Logo depois de `- uses: actions/checkout@v7` do job `android-e2e` (antes de `Habilita KVM`): + +```yaml + # Credenciais do FCM para o push e2e (Gorush e FCM reais). Cada secret é um arquivo em + # base64 (`base64 -w0 arquivo`). A chave da conta de serviço vai para um arquivo + # temporário (0600) e GOOGLE_APPLICATION_CREDENTIALS aponta para ele; o + # google-services.json vai para onde o build do paciente o procura (sem ele o APK + # compila e degrada para "sem push"). Sem os secrets (PR de fork) os passos só avisam + # e o job roda como sempre. Cada secret entra por `env:` do PASSO: nunca em `run:` + # (injeção de script) e nunca no `env:` do job (toda ação de terceiros o veria) — + # ci_invariants.sh vigia as duas coisas. + - name: Decodifica a credencial do FCM + env: + FCM_CREDENTIALS_BASE64: ${{ secrets.FCM_CREDENTIALS_BASE64 }} + run: | + ./scripts/ci/decode_secret_file.sh --env FCM_CREDENTIALS_BASE64 \ + --kind service_account --temp-export GOOGLE_APPLICATION_CREDENTIALS + - name: Decodifica o google-services.json + env: + GOOGLE_SERVICES_JSON_BASE64: ${{ secrets.GOOGLE_SERVICES_JSON_BASE64 }} + run: | + ./scripts/ci/decode_secret_file.sh --env GOOGLE_SERVICES_JSON_BASE64 \ + --kind google_services --package br.com.prismrr.sinalacs.patient \ + --to apps/patient/android/app/google-services.json +``` + +- [ ] **Step 2: Emulador com Play Services** + +Nos **dois** passos `reactivecircus/android-emulator-runner@v2` (`Gera o snapshot do AVD para o cache` e `E2E no emulador Android`), logo depois de `api-level: 36`: + +```yaml + target: google_apis +``` + +E a chave do cache do AVD (`key: avd-36-x86_64-pixel_7-ubuntu-24.04`) passa a: + +```yaml + key: avd-36-google_apis-x86_64-pixel_7-ubuntu-24.04 +``` + +Acrescente ao comentário dessa chave: `# target no nome: o push e2e precisa de Play Services (a imagem padrão da action é AOSP, sem ele); trocar o target sem trocar a chave restauraria o AVD antigo.` + +- [ ] **Step 3: Limpeza, depois do E2E** + +Junto da limpeza do `pg_data/` (depois do passo `E2E no emulador Android`, antes do `upload-artifact`): + +```yaml + # Apaga as credenciais mesmo se o E2E falhar. Num runner hospedado o disco é descartado + # ao fim do job, mas o passo mantém a regra verdadeira em qualquer runner (inclusive um + # autohospedado no futuro) e tira o google-services.json e a chave do Gorush do workspace. + - name: Remove as credenciais do FCM + if: always() + run: | + ./scripts/ci/decode_secret_file.sh --cleanup-temp GOOGLE_APPLICATION_CREDENTIALS + ./scripts/ci/decode_secret_file.sh --cleanup-file apps/patient/android/app/google-services.json + ./scripts/ci/decode_secret_file.sh --cleanup-file infra/docker/gorush/credentials/fcm-service-account.json +``` + +- [ ] **Step 4: Testes dos scripts no `workflow-lint`** + +No job `workflow-lint`, depois do passo que roda `./scripts/qa/ci_invariants.sh`: + +```yaml + - name: Testes dos scripts de CI + run: | + ./scripts/ci/decode_secret_file_test.sh + ./scripts/qa/ci_push_e2e_test.sh + ./scripts/qa/ci_invariants_fcm_test.sh +``` + +(`workflow-lint` é um dos 8 checks obrigatórios e já tem `python3` e PyYAML; os testes precisam só de bash, coreutils e python3. O nome do job não muda.) + +- [ ] **Step 5: Rodar o guarda, os testes e o actionlint** + +Run: +```bash +./scripts/qa/ci_invariants.sh +./scripts/ci/decode_secret_file_test.sh +./scripts/qa/ci_push_e2e_test.sh +./scripts/qa/ci_invariants_fcm_test.sh +docker run --rm -v "$PWD:/repo" --workdir /repo rhysd/actionlint:1.7.12 -color; echo actionlint_rc=$? +``` +Expected: `ok: 9 grupos de invariantes do CI`; `OK — decode_secret_file.sh`; `OK — ci_push_e2e.sh`; `OK — invariantes das credenciais do FCM` (as 12 mutações reprovadas e o intacto aprovado); `actionlint_rc=0` sem saída. Se o actionlint acusar `shellcheck` em algum `run:`, corrija o trecho do YAML. + +- [ ] **Step 6: Ensaiar os passos como o runner os executa, com credenciais FALSAS** + +```bash +T="$(mktemp -d)"; export RUNNER_TEMP="$T" GITHUB_ENV="$T/env" GITHUB_ACTIONS=true +FCM_CREDENTIALS_BASE64="$(printf '{"type":"service_account","client_email":"x@p","private_key":"FAKE"}' | base64 -w0)" \ + ./scripts/ci/decode_secret_file.sh --env FCM_CREDENTIALS_BASE64 --kind service_account --temp-export GOOGLE_APPLICATION_CREDENTIALS +GOOGLE_SERVICES_JSON_BASE64="$(printf '{"project_info":{"project_id":"p"},"client":[{"client_info":{"android_client_info":{"package_name":"br.com.prismrr.sinalacs.patient"}}}]}' | base64 -w0)" \ + ./scripts/ci/decode_secret_file.sh --env GOOGLE_SERVICES_JSON_BASE64 --kind google_services --package br.com.prismrr.sinalacs.patient --to "$T/ws/google-services.json" +cut -c1-60 "$T/env"; ls -l "$T/ws/google-services.json" | cut -c1-10 +unset GITHUB_ACTIONS RUNNER_TEMP GITHUB_ENV +``` +Expected: duas linhas `::notice`, `GOOGLE_APPLICATION_CREDENTIALS=…/secret.XXXXXX.json` em `$T/env` e modo `-rw-------`. **Não use `--to apps/patient/android/app/google-services.json` neste ensaio**: como `GITHUB_ACTIONS=true` foi exportado de propósito, o script sobrescreveria o arquivo real do dev; por isso o destino é `$T`. + +- [ ] **Step 7: Commit** + +```bash +git add .github/workflows/ci.yml +git commit -m "ci(android-e2e): decodifica as credenciais do FCM, usa o emulador com Play Services e roda o push e2e + +Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Task 5: Documentação e prova no runner real + +**Files:** +- Modify: `infra/docker/gorush/README.md` (seção nova "Na CI") +- Modify: `CLAUDE.md` (bullet do CI) +- Modify: `PROGRESS.md` (seção curta datada) + +**Interfaces:** +- Consumes: Tasks 1–4 commitadas. +- Produces: documentação e a prova de que o push e2e roda num runner de verdade (depende de ações que o usuário precisa autorizar). + +- [ ] **Step 1: Documentar** + +- `infra/docker/gorush/README.md`, seção nova: + +```markdown +## Na CI (GitHub Actions) + +O job `android-e2e` usa dois secrets, cada um o arquivo em base64 (`base64 -w0 arquivo`): + +| Secret | Arquivo | Onde vai no runner | +|---|---|---| +| `FCM_CREDENTIALS_BASE64` | `fcm-service-account.json` | arquivo `0600` em `$RUNNER_TEMP`; `GOOGLE_APPLICATION_CREDENTIALS` aponta para ele; `ci_push_e2e.sh` o instala em `infra/docker/gorush/credentials/` (o que o Gorush monta) | +| `GOOGLE_SERVICES_JSON_BASE64` | `google-services.json` | `apps/patient/android/app/` (o build do paciente o procura ali) | + +Com os dois, o job roda o `push_e2e.sh` (caminho feliz) depois do smoke: o aviso do ACS chega +à bandeja do emulador pelo Gorush e FCM reais. Sem eles (PR de fork, secret apagado) os passos +só avisam e o job roda como antes. O emulador do CI usa a imagem `google_apis` (Play Services): +a imagem padrão da action é AOSP e o FCM não entrega token nela. + +Os arquivos são apagados por um passo `if: always()`; `scripts/ci/decode_secret_file.sh` nunca +imprime o conteúdo e não sobrescreve nem apaga arquivos de um desenvolvedor fora do CI. +``` + +- `CLAUDE.md`, no bullet do CI: acrescentar que `workflow-lint` também roda `scripts/ci/decode_secret_file_test.sh`, `scripts/qa/ci_push_e2e_test.sh` e `scripts/qa/ci_invariants_fcm_test.sh`, e que o `ci_invariants.sh` falha quando `FCM_CREDENTIALS_BASE64`/`GOOGLE_SERVICES_JSON_BASE64` aparecem dentro de um `run:` ou no `env:` do job, quando uma decodificação vem depois do E2E, quando falta o passo `if: always()` de limpeza, quando o emulador perde o `target: google_apis` ou quando a chave do cache do AVD não o contém. +- `PROGRESS.md`: seção "Credenciais do FCM e Gorush no CI (2026-09-30)" com o que foi feito, os testes, e o limite da nota 3 (a imagem `google_apis` só é provada pelo primeiro run no runner). + +- [ ] **Step 2: Conferir links e guarda** + +Run: `./scripts/qa/check_documentation_links.sh; ./scripts/qa/ci_invariants.sh` +Expected: ambos verdes. + +- [ ] **Step 3: Commit** + +```bash +git add infra/docker/gorush/README.md CLAUDE.md PROGRESS.md +git commit -m "docs: credenciais do FCM e push e2e no CI (secrets, destinos e o que o runner prova) + +Co-Authored-By: Claude Sonnet 5.5 " +``` + +- [ ] **Step 4: PARAR e pedir autorização antes de qualquer efeito externo** + +Não execute sem um "sim" explícito do usuário para cada item: + +1. `gh secret list` (só lê nomes): conferir que `FCM_CREDENTIALS_BASE64` e `GOOGLE_SERVICES_JSON_BASE64` existem. Não imprime valores. +2. `git push -u origin fix/patient` (a branch nunca foi publicada). +3. `gh workflow run CI --ref fix/patient` e `gh run watch`. + +Resultado esperado no log do job `android-e2e`: +- **Decodifica a credencial do FCM** e **Decodifica o google-services.json**: verdes, com `::notice … decodificado em …`. **Nenhuma** linha com o conteúdo: procure `private_key`, `BEGIN` e `api_key` no log (zero ocorrências). +- O boot do emulador `google_apis` (regenera o AVD na 1ª vez) e o smoke passando como antes. +- O `push_e2e.sh` terminando com `OK — o aviso chegou ao emulador`. +- **Remove as credenciais do FCM**: verde ao final. +- Num PR de fork (ou com um secret ausente): `::notice … ausente`, `push e2e pulado`, job verde. + +**Se o push falhar no runner** (diagnóstico antes de mexer): (a) `getToken()` sem token → troque `target: google_apis` por `google_apis_playstore` nos dois passos da action e na chave do cache (e ajuste `TARGET_EMULADOR` no guarda e a chave na mutação de teste), repita; (b) `FALTA … fcm-service-account.json`/modo → o `install` do `ci_push_e2e.sh` não rodou: confira `GOOGLE_APPLICATION_CREDENTIALS` no log; (c) `project_id difere` → os dois secrets são de projetos Firebase diferentes; (d) timeout do job → o push adiciona ~6 min; se o job se aproximar dos 60 min, suba `timeout-minutes` (decisão do usuário). + +- [ ] **Step 5: Registrar o resultado** + +Se a prova no runner foi feita, acrescente o número do run ao `PROGRESS.md` e faça um commit `docs:` curto. Se não foi, registre "prova no runner real pendente: precisa do push da branch" na mesma seção. + +--- + +## Fora do escopo + +- Rodar o push e2e com `--negativos` ou com `--e2e-db` no CI (mais ~3 min e mais peças: relé do OTP e o `postgres-test`). O caminho feliz contra a stack de desenvolvimento é o que entra. +- Restringir o passo a branches protegidas ou a um **Environment** do GitHub com revisores: hoje qualquer PR de dentro do repositório recebe os secrets e um autor com permissão de escrita poderia, editando o `ci.yml`, imprimi-los. O `ci_invariants.sh` protege contra regressão acidental, não contra um autor mal-intencionado. Decisão de política do repositório. +- Rotação da chave da conta de serviço e a cópia em `/opt/apps_android/` (modo 644) fora do repositório. +- iOS/APNs e aparelho físico. + +## Self-review + +- **Cobertura do pedido:** os dois secrets decodificados por base64 para arquivos no runner (Tasks 1, 4); `GOOGLE_APPLICATION_CREDENTIALS` definida e **consumida** pelo `ci_push_e2e.sh` (Tasks 1, 2, 4); Gorush ligado no CI com o FCM real (Tasks 2, 4); `google-services.json` no lugar onde o build do paciente o procura (Tasks 1, 4); emulador com Play Services (Tasks 3, 4). +- **Placeholders:** nenhum; todo passo de código traz o código e todo comando traz o resultado esperado. +- **Consistência de nomes:** `decode_secret_file.sh` com `--env/--kind/--temp-export/--to/--package/--cleanup-temp/--cleanup-file`, `ci_push_e2e.sh`, `PUSH_E2E_SCRIPT`, `Decodifica a credencial do FCM`, `Decodifica o google-services.json`, `Remove as credenciais do FCM`, `check_credenciais_fcm`, `CI_WORKFLOW_PATH`, `target: google_apis` e `avd-36-google_apis-x86_64-pixel_7-ubuntu-24.04` são os mesmos nas Tasks 1–5; a contagem `9 grupos` vem de acrescentar um item a `CHECKS` (hoje 8). +- **Ordem de commits:** a Task 3 deixa o guarda vermelho de propósito até a Task 4; o plano manda não dar push entre as duas. +- **Riscos anotados:** a imagem `google_apis` não foi exercitada localmente (nota 3, com a saída `google_apis_playstore`); o autor com escrita que exfiltra o secret (Fora do escopo); o tempo do job (Task 5, item d). diff --git a/docs/superpowers/plans/2026-09-30-finalizacao-app-paciente.md b/docs/superpowers/plans/2026-09-30-finalizacao-app-paciente.md new file mode 100644 index 00000000..d3e76bd1 --- /dev/null +++ b/docs/superpowers/plans/2026-09-30-finalizacao-app-paciente.md @@ -0,0 +1,354 @@ +# Finalização do app do paciente — Plano de Implementação + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fechar o que resta técnico no `apps/patient` (lacuna LGPD do token FCM pedido sem consentimento), provar o app inteiro no emulador `emulator-5554` e deixar o repositório pronto para a primeira execução da CI da branch `fix/patient`. + +**Architecture:** O app só pede o token ao FCM depois de confirmar, no servidor, que a decisão mais recente de `segmentedPush` é `granted` (mesma fonte que `PatientDataOverview.consents` já expõe; sem espelho local novo). O resto do plano é verificação: bateria do app, e2e no emulador e conferência do que a CI vai rodar. + +**Tech Stack:** Flutter 3 / Dart, `sinalacs_client` (Serverpod), Docker Compose, `scripts/qa/e2e.sh`, `scripts/qa/push_e2e.sh`, emulador Android. + +**Spec:** `spec/PRD_system.md` (RF01–RF06, RF14, RF16, RF18), `spec/lgpd_design.md` (seção "`ConsentPurpose.segmentedPush` tem leitor, com uma lacuna no aparelho"), `PROGRESS.md` (seção "RF14: Gorush e FCM provados no emulador"). + +## Estado verificado em 2026-09-30 (antes deste plano) + +- `flutter analyze` em `apps/patient`: sem problemas. `flutter test`: **225 passam**. +- RF01–RF06, RF14 (Android), RF18, "Meus Dados" LGPD, QR, termos: construídos (ver `PROGRESS.md`). +- **Emulador 5554 NÃO está no ar**: `adb devices` vem vazio e o AVD `sinal-acs` citado no `PROGRESS.md` não existe nesta máquina (só `Medium_Phone` e `Medium_Tablet`). `adb` não está no `PATH`: usar `~/Android/Sdk/platform-tools/adb`. A Task 0 resolve isso. +- Compose: só o `gorush` está de pé; o resto da stack está parado. + +## Global Constraints + +- Textos de UI, comentários e mensagens de commit em português. +- Alerta vermelho nunca é descartado nem atrasado: nada aqui pode bloquear login, home ou o botão de urgência. O registro de push continua silencioso e fora do caminho crítico. +- Nenhum dado real de paciente em testes, logs ou capturas. +- Classificação de risco continua vinda só de `triage.evaluate` (determinística). +- `legalDocumentsVersion` (app) == `consentPolicyVersion` (backend): não mudar versão nem agendar mudança de termos. +- `flutter analyze` sem problemas e `flutter test` verde antes de cada commit. +- Commits terminam com `Co-Authored-By: Claude Sonnet 5.5 `. Sem push nem merge sem o usuário pedir. + +## Review Focus + +- Consentimento `segmentedPush` revogado ou nunca decidido: o app **não** chama `currentDevice()` (nem o FCM). Teste na Task 1. +- `myData()` lento, falhando ou sem rede no login: o login/home não atrasam e nada é registrado (falha fecha, não abre). Teste na Task 1. +- Consentimento concedido na tela "Meus Dados" depois do login: o token é pedido e registrado nessa hora, sem nova chamada a `myData()`. Teste na Task 1. +- Histórico de consentimento com `granted` seguido de `revoked`: vale o mais recente. Teste na Task 1. +- Emulador sem a permissão `POST_NOTIFICATIONS`: o token registra mas o aviso não aparece; o e2e concede por `adb`. Conferido na Task 3. + +--- + +### Task 0: Emulador 5554 e stack local + +**Files:** +- Nenhum arquivo do repositório muda (ambiente). + +**Interfaces:** +- Produces: `emulator-5554` em `device`, stack do Compose saudável, `.env` presente. As Tasks 2 e 3 dependem disso. + +- [ ] **Step 1: Confirmar o estado** + +```bash +export PATH="$HOME/Android/Sdk/platform-tools:$HOME/Android/Sdk/emulator:$PATH" +adb devices -l +emulator -list-avds +``` +Expected hoje: lista de devices vazia; AVDs `Medium_Phone` e `Medium_Tablet`. + +- [ ] **Step 2: Subir o emulador (o primeiro a subir recebe a porta 5554)** + +```bash +emulator -avd Medium_Phone -no-snapshot-save -no-audio > /tmp/emu.log 2>&1 & +adb wait-for-device +until [ "$(adb shell getprop sys.boot_completed | tr -d '\r')" = "1" ]; do sleep 3; done +adb devices +``` +Expected: `emulator-5554 device`. Se vier outra porta (já havia um emulador), fechar o outro e repetir: os scripts usam `emulator-5554` fixo. Se o AVD for API < 29 ou sem Google Play services, o teste de push (Task 3) não terá FCM; criar um AVD com imagem `google_apis` (`sdkmanager`/`avdmanager`) chamado `sinal-acs`. + +- [ ] **Step 3: Configurar e subir a stack** + +```bash +cd /home/rock/Documents/Dev/APPs/SinalACS +[ -f .env ] || ./scripts/dev/bootstrap_env.sh +docker compose up --build -d +docker compose ps +``` +Expected: `backend`, `postgres`, `mosquitto`, `traefik` saudáveis e os quatro seeds concluídos (`database-seed`, `health-data-seed`, `acs-credential-seed`, `cpf-hash-seed`). Um `pg_data/` antigo pode perder dados clínicos sintéticos (migração `20260917191250458`): aceitável, ou `docker compose down && rm -rf pg_data/` antes. + +- [ ] **Step 4: Sem commit** (nada versionado mudou). + +--- + +### Task 1: Não pedir o token ao FCM sem consentimento `segmentedPush` (fecha a lacuna LGPD) + +**Files:** +- Modify: `apps/patient/lib/core/push/push_token_source.dart` (função `registerPushDevice`, linhas 36-44) +- Modify: `apps/patient/lib/app/app.dart:462`, `:815` (chamadas pós-login) e `:1870` (chamada pós-concessão) +- Modify: `apps/patient/test/support/fake_patient_backend.dart` (o `myData()` do fake precisa aceitar consentimentos configuráveis; ler o arquivo antes) +- Test: `apps/patient/test/push_registration_test.dart` + +**Interfaces:** +- Consumes: `PatientBackend.myData()` → `PatientDataOverview` (com `.consents`); `currentConsentDecisions(List) → Map` em `lib/core/consent/consent_decisions.dart`; `PushTokenSource.currentDevice()`. +- Produces: `Future registerPushDevice(PatientBackend backend, PushTokenSource source, {bool consentKnownGranted = false})`. Com `consentKnownGranted: false` ela consulta `myData()` (teto de 3 s) e só prossegue se `currentConsentDecisions(...)[ConsentPurpose.segmentedPush] == true`. + +- [ ] **Step 1: Ler o fake e os testes existentes** + +Ler `test/support/fake_patient_backend.dart` (como `myData()` e `pushRegistrations` estão montados) e `test/push_registration_test.dart` inteiro. Os testes atuais esperam registro logo após o login: passam a precisar de um fake cujo `myData()` devolva `segmentedPush` `granted`. Adicionar ao fake um campo `List consents` (padrão: `segmentedPush` granted) usado por `myData()`, sem mudar o que os outros testes veem (o padrão deve reproduzir o que eles já assumem; se algum depender de histórico vazio, dar a esse teste um fake próprio). + +- [ ] **Step 2: Escrever os testes que falham** + +Acrescentar em `test/push_registration_test.dart` (mesmo estilo do arquivo; `login`, `_FakeSource` e `_aparelho` já existem). Usar um `_FakeSource` que conta chamadas: + +```dart +class _CountingSource implements PushTokenSource { + int calls = 0; + @override + Future currentDevice() async { + calls++; + return _aparelho; + } +} + +testWidgets('sem consentimento de avisos, nem pergunta o token ao provedor', (tester) async { + final backend = FakePatientBackend(consents: const []); + final source = _CountingSource(); + await tester.pumpWidget(SinalAcsApp(backend: backend, pushTokens: source)); + await login(tester); + + expect(source.calls, 0); + expect(backend.pushRegistrations, isEmpty); + expect(find.byType(PatientHomeShell), findsOneWidget); +}); + +testWidgets('consentimento revogado depois de concedido vale o mais recente', (tester) async { + final backend = FakePatientBackend(consents: [ + _registro('segmentedPush', 'granted', DateTime(2026, 9, 1)), + _registro('segmentedPush', 'revoked', DateTime(2026, 9, 2)), + ]); + final source = _CountingSource(); + await tester.pumpWidget(SinalAcsApp(backend: backend, pushTokens: source)); + await login(tester); + + expect(source.calls, 0); +}); + +testWidgets('myData falhando no login fecha: nada é pedido nem registrado', (tester) async { + final backend = FakePatientBackend()..failMyData = true; + final source = _CountingSource(); + await tester.pumpWidget(SinalAcsApp(backend: backend, pushTokens: source)); + await login(tester); + + expect(source.calls, 0); + expect(find.byType(PatientHomeShell), findsOneWidget); +}); +``` +`_registro(purpose, action, timestamp)` monta um `PatientConsentRecord` com os campos que o arquivo de fake já usa em outros testes (copiar a construção de lá; se o fake ainda não tiver `failMyData`, adicioná-lo: `myData()` lança `BackendFailure('falha')` quando `true`). + +- [ ] **Step 3: Rodar e ver falhar** + +Run: `cd apps/patient && flutter test test/push_registration_test.dart` +Expected: os três testes novos FALHAM (hoje o app pergunta o token sempre); os antigos seguem verdes. + +- [ ] **Step 4: Implementar** + +Em `lib/core/push/push_token_source.dart`, substituir `registerPushDevice`: + +```dart +const _consentLookupTimeout = Duration(seconds: 3); + +Future registerPushDevice( + PatientBackend backend, + PushTokenSource source, { + bool consentKnownGranted = false, +}) async { + try { + // LGPD: o aparelho só fala com o provedor (Google/Apple) depois de o + // servidor confirmar o consentimento vigente de avisos. Na dúvida (falha, + // teto estourado, nunca decidiu) não pergunta: fecha, não abre. + if (!consentKnownGranted) { + final overview = await backend.myData().timeout(_consentLookupTimeout); + if (currentConsentDecisions(overview.consents)[ConsentPurpose.segmentedPush] != true) return; + } + final device = await source.currentDevice(); + if (device == null) return; + await backend.registerPushToken(token: device.token, platform: device.platform); + } catch (_) { + // Intencionalmente silencioso — ver acima. + } +} +``` +Adicionar os imports de `consent_decisions.dart` e de `ConsentPurpose` (o mesmo import que `app.dart` usa). Em `app.dart:1870`, a chamada logo após conceder passa `consentKnownGranted: true`. Conferir que o texto do comentário de documentação acima da função continue verdadeiro e ajustá-lo (ele diz "tenta de novo no próximo login ou ao conceder"). + +- [ ] **Step 5: Teste da concessão posterior** + +Acrescentar (o teste deve falhar se a chamada de `:1870` perder o flag e passar a consultar `myData()` de novo — o fake conta chamadas de `myData`): + +```dart +testWidgets('conceder avisos em Meus Dados registra o token sem nova consulta', (tester) async { + final backend = FakePatientBackend(consents: const []); + final source = _CountingSource(); + await tester.pumpWidget(SinalAcsApp(backend: backend, pushTokens: source)); + await login(tester); + final antes = backend.myDataCalls; + + // abrir Meus Dados e conceder "Avisos da equipe": copiar os gestos do teste + // equivalente de `patient_app_mvp_test.dart` que concede `segmentedPush`. + await concederAvisos(tester); + + expect(backend.pushRegistrations, [('tok-1', 'android')]); + expect(backend.myDataCalls - antes, lessThanOrEqualTo(1)); // só o refresh da própria tela +}); +``` +`concederAvisos` é uma função de apoio do arquivo: localizar em `patient_app_mvp_test.dart` o teste que concede `segmentedPush` e reaproveitar os mesmos `find`/`tap`. O fake ganha `int myDataCalls`. + +- [ ] **Step 6: Rodar tudo do app** + +Run: `cd apps/patient && flutter analyze && flutter test` +Expected: sem problemas; todos os testes verdes (225 + novos). + +- [ ] **Step 7: Documentação** + +Em `spec/lgpd_design.md` (parágrafo "Lacuna:" logo após `ConsentPurpose.segmentedPush tem leitor`), trocar a lacuna por: o app só pede o token ao FCM depois de `myData()` mostrar `segmentedPush` vigente como `granted`, e fecha na dúvida. Em `apps/CLAUDE.md`, remover a frase "app still asks FCM for token every login even without `segmentedPush` consent … known LGPD gap". Em `PROGRESS.md`, acrescentar a seção "Token de push só com consentimento (2026-09-30)" com o que foi feito e a nova contagem de testes. + +- [ ] **Step 8: Commit** + +```bash +git add apps/patient spec/lgpd_design.md apps/CLAUDE.md PROGRESS.md +git commit -m "fix(paciente): token de push só é pedido ao provedor com consentimento de avisos vigente (RF14, LGPD) + +Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Task 2: Bateria e2e do app do paciente no emulador 5554 + +**Files:** +- Nenhum arquivo muda, salvo correções que a execução exigir (cada correção vira commit próprio, com teste). +- Usa: `scripts/qa/e2e.sh`, `apps/patient/integration_test/{smoke_test,backend_connection_test}.dart`, `apps/patient/tool/live_check.dart`. + +**Interfaces:** +- Consumes: Task 0 (emulador + stack) e Task 1 (código novo já no app que vai ser instalado). +- Produces: registro em `PROGRESS.md` do que passou no emulador, com data e comando. + +- [ ] **Step 1: Ler o uso do script** + +Run: `./scripts/qa/e2e.sh --help 2>&1 | head -40` (se não houver `--help`, ler o cabeçalho do arquivo). Confirmar as flags `--emulator` e `--full` e se existe filtro por app. + +- [ ] **Step 2: Checagem viva contra o backend (sem emulador)** + +Run: `cd apps/patient && dart run tool/live_check.dart` +Expected: passa contra `https://localhost/` (login OTP, triagem, alerta, status, Meus Dados). + +- [ ] **Step 3: Smoke + conexão real no emulador** + +Run: `./scripts/qa/e2e.sh --emulator` e depois, para só o paciente, `cd apps/patient && flutter test integration_test/smoke_test.dart integration_test/backend_connection_test.dart -d emulator-5554` +Expected: verde. Falha de `adb install` com "Broken pipe" é transitória conhecida (o script já repete). + +- [ ] **Step 4: Se algo falhar, seguir `superpowers:systematic-debugging`** + +Reproduzir, achar a causa, escrever o teste que falha, corrigir, repetir o Step 3. Nada de "retry até passar". + +- [ ] **Step 5: Registrar e commitar** + +Acrescentar a `PROGRESS.md` uma linha sob a seção da Task 1: comandos rodados, emulador (AVD/API) e resultado. Commit: + +```bash +git add PROGRESS.md +git commit -m "docs: registra a bateria e2e do app do paciente no emulador 5554 + +Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Task 3: Entrega de push ponta a ponta com o consentimento novo + +**Files:** +- Nenhum arquivo muda, salvo correções. +- Usa: `scripts/qa/push_e2e.sh`, `apps/patient/integration_test/{push_register_test,push_native_token_test}.dart` (só rodam com `--dart-define=PUSH_E2E=1`). + +**Interfaces:** +- Consumes: Task 1 (o app agora consulta `myData()` antes do token) e a chave de serviço do FCM em `GORUSH_CREDENTIALS_DIR` + `android/app/google-services.json` (ambos fora do git). +- Produces: prova de que a Task 1 não quebrou a entrega no emulador. + +- [ ] **Step 1: Pré-condições** + +```bash +ls apps/patient/android/app/google-services.json +unset GORUSH_CREDENTIALS_DIR # o script ignora o valor do ambiente, mas melhor não arrastá-lo +``` +Sem `google-services.json` ou sem a chave do FCM, **pular esta task e registrar que não foi possível**; não fingir que passou. + +- [ ] **Step 2: Rodar** + +Run: `./scripts/qa/push_e2e.sh --negativos` +Expected: `recipients=1 accepted=1`; os negativos (token falso podado, Gorush parado, revogação) passam como em `PROGRESS.md`. O teste de registro usa `Avisos da equipe` concedido, então deve continuar registrando com a Task 1. + +- [ ] **Step 3: Conferir o caso novo à mão no emulador** + +Com o app instalado, entrar com um paciente do seed que **não** concedeu `segmentedPush`, e confirmar no backend que não há linha nova em `push_tokens`: + +```bash +docker compose exec postgres psql -U postgres -d sinalacs -c 'select count(*) from push_tokens;' +``` +Expected: contagem inalterada após o login. (Nome do banco/usuário: ver `.env`; ajustar se diferir.) + +- [ ] **Step 4: Registrar e commitar** (mesmo formato da Task 2, Step 5). + +--- + +### Task 4: Pronto para a primeira execução da CI + +**Files:** +- Modify: `PROGRESS.md` (registro). +- Sem código novo, salvo correções. + +**Interfaces:** +- Consumes: Tasks 1–3 commitadas. +- Produces: branch `fix/patient` limpa e verificada localmente com o que a CI rodará para o paciente. + +- [ ] **Step 1: Invariantes da CI** + +Run: `./scripts/qa/ci_invariants.sh` +Expected: sem falhas (a branch nunca rodou na CI; este é o mais perto disso sem rede). + +- [ ] **Step 2: Mesmo caminho do job `patient-app`** + +```bash +cd apps/patient && flutter pub get && flutter analyze && flutter test +flutter build apk --debug +``` +Expected: verde. O build sem `google-services.json` precisa funcionar (o plugin do Google Services só aplica quando o arquivo existe); se o arquivo existir localmente, renomeá-lo temporariamente para simular a CI e devolver depois. + +- [ ] **Step 3: Backend (só se a Task 1 tiver tocado algo lá — não deve)** + +Run: `cd backend/sinalacs_server && dart test` — pular se `git diff main --stat -- backend` não mostrar mudança nesta rodada. + +- [ ] **Step 4: Atualizar a contagem de testes e o "o que falta"** + +Em `PROGRESS.md`, atualizar a contagem de testes do paciente e listar o que **continua** fora deste plano: iOS/APNs, aparelho físico, Gorush hospedado, revisão jurídica do texto 2026.1, backoffice que atende exclusão/correção (LGPD), MFA/refresh token, menores deferidos do RF14. + +- [ ] **Step 5: Commit** + +```bash +git add PROGRESS.md +git commit -m "docs: fecha a rodada de finalização do app do paciente e lista o que segue aberto + +Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +## Fora do escopo (decisões e dependências externas) + +- **iOS/APNs:** exige Mac/Xcode e chave APNs; o lado Swift do `sinalacs/push_token` não existe. +- **Aparelho físico e Gorush hospedado:** dependem de infraestrutura e de credenciais fora do repositório. +- **Backoffice de atendimento** de exclusão/correção (`apps/admin` ainda é mock): é outro plano, com backend próprio. +- **Revisão jurídica** do texto 2026.1 dos termos. +- **Permissões do host (fora do repo):** `chmod 600` na cópia da chave em `/opt/apps_android/fcm-service-account.json`. + +## Self-review + +- **Cobertura:** a lacuna LGPD (Task 1), configuração do ambiente (Task 0), testes no emulador (Tasks 2–3) e prontidão para CI (Task 4) estão cobertos; RF01–RF06/RF16/RF18 já passam na bateria existente e são reexecutados na Task 2. +- **Placeholders:** os helpers `_registro`, `concederAvisos`, `failMyData`, `myDataCalls` e `consents` do fake são definidos nas Steps 1, 2 e 5 da Task 1 com instrução de onde copiar a construção; nenhum outro trecho depende de tipo indefinido. +- **Consistência:** a assinatura `registerPushDevice(backend, source, {consentKnownGranted})` é a mesma na definição (Step 4) e nos usos (`app.dart:462`, `:815` sem flag; `:1870` com flag). diff --git a/docs/superpowers/plans/2026-09-30-gorush-fcm-e2e-emulador.md b/docs/superpowers/plans/2026-09-30-gorush-fcm-e2e-emulador.md new file mode 100644 index 00000000..3e4a2c88 --- /dev/null +++ b/docs/superpowers/plans/2026-09-30-gorush-fcm-e2e-emulador.md @@ -0,0 +1,537 @@ +# Gorush + FCM: configuração final e teste ponta a ponta no emulador — Plano de Implementação + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fazer o aviso comunitário chegar de verdade a um aparelho: o app paciente obtém um token FCM real (lado nativo Android), o backend o guarda, `notices.sendSegmented` entrega a lista ao **Gorush real** e a notificação aparece no emulador `emulator-5554`. + +**Architecture:** O Gorush roda no Compose (perfil `push`, sem porta publicada) com a **conta de serviço** do FCM; o app usa o `google-services.json` só para pedir o token ao Firebase Messaging pelo canal `sinalacs/push_token`, cujo contrato Dart já existe. A configuração do Gorush e o parser do cliente são validados contra respostas **reais** do Gorush e do FCM (com a chave real da conta de serviço, já presente, e um token falso que não entrega nada a ninguém), porque até aqui o cliente só foi provado contra um servidor HTTP falso. + +**Tech Stack:** Gorush 1.22.0 (`appleboy/gorush`), Docker Compose, Android (Gradle/AGP 9.0.1, Kotlin, `firebase-messaging`, plugin `com.google.gms.google-services`), Flutter 3.44 `integration_test`, Dart (backend/ACS), `adb`. + +**Spec:** `docs/superpowers/specs/2026-09-16-decisoes-produto-pos-validacao.md` §3.2 (revisada para Gorush); `infra/docker/gorush/README.md` (checklist do que nunca foi verificado contra um Gorush real). + +## Global Constraints + +- **Segredos:** a chave da conta de serviço (`fcm-service-account.json`) tem modo `600`, vive só em `infra/docker/gorush/credentials/` (já no `.gitignore`) e **nunca** é impressa (`cat`, `echo`, logs), nem copiada para fora desse diretório. O token FCM também nunca é impresso: só o tamanho. A chave está hoje com modo `644` (legível por outros usuários da máquina): a Task 1 a corrige para `600`. A imagem do Gorush roda como `gorush` (uid 1000), igual ao uid deste usuário, então `600` continua legível no container **nesta** máquina; com outro uid o container não lê a chave, e o README passa a dizer isso (não relaxar para `644` como atalho: usar `chown`/grupo). +- **Dispositivo:** só `emulator-5554` (`Medium_Phone`, API 36, `google_apis_playstore`, tem Play Services e internet). O `adb` não está no `PATH`: `export PATH=$PATH:~/Android/Sdk/platform-tools:~/flutter/bin`. Nunca `adb uninstall` de pacote que não seja `br.com.prismrr.sinalacs.patient`. +- **Rede do emulador:** o RPC é alcançado por `adb reverse tcp:8443 tcp:443` e `--dart-define=SINALACS_HOST=https://localhost:8443/` (como `scripts/qa/e2e.sh`); `10.0.2.2:443` já falhou medidamente. +- **O build não pode depender do `google-services.json`:** ele é ignorado pelo git, então a CI (`patient-app`, `android-e2e`) e outros devs não o têm. O plugin do Google Services só é aplicado quando o arquivo existe, e o código nativo degrada para "sem push" sem o `FirebaseApp`. +- O Gorush **nunca** publica porta no Compose. Os containers de smoke desta rodada ligam só em `127.0.0.1` e são removidos no fim. +- Alerta vermelho nunca é descartado nem atrasado: nada aqui toca o caminho do alerta MQTT; falha de push nunca bloqueia login, home nem o botão de urgência. +- `flutter analyze` limpo; `dart analyze` do backend no baseline de 51 infos e zero avisos; repetir `dart test` do backend 5 vezes se qualquer arquivo do backend mudar. `docker compose config -q` e `./scripts/qa/ci_invariants.sh` ok ao fim. +- Texto de UI e comentários em português. Nenhum dado real de paciente. +- Não afirmar o que não foi observado: iOS/APNs **não** é testado nesta rodada e o plano não o promete. + +## Review Focus + +- **Build sem `google-services.json`** (CI, outro dev): compila e o app degrada para "sem push", sem crash. +- **Gorush com iOS desligado e um token `ios` na lista:** o erro dele não apaga o token (não é erro de token) e não derruba a entrega Android. +- **Token inválido real:** o Gorush devolve o erro do FCM v1, o backend reconhece, apaga **só** essa linha e mantém o token bom. +- **`log.hide_token` mascara o token na resposta?** Se sim, a poda nunca casa com `push_tokens.token`: precisa ser visto na resposta real, não suposto. +- **Gorush parado no meio do uso:** `sendSegmented` falha rápido (≤ 5 s), com a mensagem certa, e sem linha de auditoria `granted`. +- **App em primeiro plano:** FCM não mostra mensagem de notificação na bandeja com o app aberto; o teste usa o app em segundo plano e a documentação diz isso. +- **Permissão de notificação negada (Android 13+):** o token ainda registra; só a exibição some. + +--- + +## Estado verificado hoje (2026-09-30) + +- Emulador `emulator-5554` de pé, com Play Services e internet. +- `apps/patient/android/app/google-services.json` existe (projeto `sinal-acs`, pacote `br.com.prismrr.sinalacs.patient`) e é ignorado pelo git. `.gitignore` tem uma alteração **não commitada** (a linha `google-services.json`). +- **A chave da conta de serviço existe** em `infra/docker/gorush/credentials/fcm-service-account.json`, é ignorada pelo git e foi validada **só na estrutura**, sem imprimir nada: `type: service_account`, `project_id` = `sinal-acs` (igual ao do `google-services.json`), `private_key` com cabeçalho PEM, `client_email` de conta de serviço. **Não** está provado que ela está ativa, que tem permissão de enviar, nem que a API *Firebase Cloud Messaging API (V1)* está habilitada no projeto: isso só aparece na primeira chamada real (Task 1 Step 3). +- A imagem `appleboy/gorush:1.22.0` já traz `HEALTHCHECK ["/bin/gorush", "--ping"]` e roda como o usuário `gorush` (uid 1000). +- `.env` não define `GORUSH_URL`; o Compose só tem `postgres-test` de pé. O build Android não tem plugin do Google Services, biblioteca do Firebase Messaging nem `POST_NOTIFICATIONS`; `MainActivity.kt` é um `FlutterActivity` vazio (o canal `sinalacs/push_token` não tem lado nativo). +- `infra/docker/gorush/config.yml` liga `ios.enabled: true` com `key_path` para um arquivo que não existe, e o Compose usa `appleboy/gorush:latest`. Hoje o Gorush **provavelmente não sobe** assim: a Task 1 prova (ou refuta) isso. + +## Mapa de arquivos + +- Infra: `docker-compose.yml`, `infra/docker/gorush/config.yml`, `infra/docker/gorush/README.md`, `scripts/qa/gorush_smoke.sh` (novo), `scripts/qa/push_e2e.sh` (novo), `.gitignore`, `.env` (local, não versionado). +- Backend: `backend/sinalacs_server/test/unit/fixtures/gorush_*.json` (novos), `test/unit/gorush_client_test.dart`, `lib/src/infrastructure/push/gorush_client.dart` (só se a resposta real exigir). +- App paciente (Android): `android/settings.gradle.kts`, `android/app/build.gradle.kts`, `android/app/src/main/kotlin/br/com/prismrr/sinalacs/patient/MainActivity.kt`, `android/app/src/main/AndroidManifest.xml`, `integration_test/push_native_token_test.dart` (novo), `integration_test/push_register_test.dart` (novo). +- App ACS: `apps/acs/tool/send_notice.dart` (novo). +- Docs: `PROGRESS.md`, `apps/CLAUDE.md`, `backend/CLAUDE.md`, `spec/stack.md`, `docs/superpowers/specs/2026-09-16-decisoes-produto-pos-validacao.md` §3.2, memória. + +--- + +### Task 1: Gorush sobe com a configuração certa e a resposta real vira contrato + +**Files:** +- Modify: `docker-compose.yml`, `infra/docker/gorush/config.yml`, `infra/docker/gorush/README.md`, `.gitignore` +- Create: `scripts/qa/gorush_smoke.sh`, `backend/sinalacs_server/test/unit/fixtures/gorush_invalid_token.json` +- Modify (teste): `backend/sinalacs_server/test/unit/gorush_client_test.dart`; `lib/src/infrastructure/push/gorush_client.dart` só se o teste do contrato falhar + +**Interfaces:** +- Produces: imagem do Gorush **fixada** em `appleboy/gorush:1.22.0` no Compose; `ios.enabled: false` no `config.yml`; `scripts/qa/gorush_smoke.sh` (sobe um Gorush descartável em `127.0.0.1:18088` com a chave real, posta um push para um token **falso** e grava a resposta em `$1`). +- Produces: fixture `gorush_invalid_token.json` no formato `{"status": , "body": }` e um teste de contrato que a usa e exige a poda do token. +- Consumes: `GorushClient`, `PushTarget`, `PushMessage`, `PushSendReport`, `PushGatewayException(message, {outcomeUnknown})` (já existem). + +- [ ] **Step 1: Commitar a alteração do `.gitignore` (é sua, já no working tree)** + +Run: `git diff .gitignore | grep '^[+-]' | grep -v '^+++\|^---'` e confirme que só acrescenta `google-services.json`. +Expected: um bloco `# Google services` + `google-services.json`. + +```bash +git add .gitignore && git commit -m "chore: ignora google-services.json (credencial do projeto Firebase, não versionada)" +``` + +- [ ] **Step 2: RED — provar que a configuração atual não sobe, e fechar o modo da chave** + +```bash +chmod 600 infra/docker/gorush/credentials/fcm-service-account.json +stat -c '%a %U' infra/docker/gorush/credentials/fcm-service-account.json # 600 rock +docker run --rm --name gorush-red -v $PWD/infra/docker/gorush/config.yml:/config.yml:ro \ + -v $PWD/infra/docker/gorush/credentials:/credentials:ro appleboy/gorush:1.22.0 -c /config.yml 2>&1 | head -20 +``` + +Expected: o container **encerra com erro** citando o iOS/a chave APNs (`/credentials/apns-key.p8`). Se ele subir normalmente, o achado "ios.enabled quebra o boot" é falso: registre `Ruling:` no ledger e pule a parte do iOS do Step 3. Se ele falhar por **não conseguir ler** `fcm-service-account.json` (permissão), o uid do container não é o do usuário: registre e trate como nas restrições globais, sem `644`. + +- [ ] **Step 3: Corrigir a configuração** + +`infra/docker/gorush/config.yml`: `ios.enabled: false` (com o comentário: "iOS só liga quando existir chave APNs e um app iOS; ligue com `GORUSH_IOS_ENABLED=true` no ambiente do serviço, que o Gorush lê por `GORUSH__`") e confirme, pelo README da **1.22.0** e por `docker run --rm appleboy/gorush:1.22.0 --help`, os nomes reais das chaves `android.*` (o plano assume `android.enabled` e `android.key_path`; se a 1.22.0 usar outro nome, use o dela e ledgere). `docker-compose.yml`: trocar `image: appleboy/gorush:latest` por `image: appleboy/gorush:1.22.0` e **não** acrescentar `healthcheck`: a imagem já traz `CMD ["/bin/gorush", "--ping"]` (intervalo 10 s), que herda o Compose; só confirme com `docker compose --profile push ps` que o serviço chega a `healthy`. + +- [ ] **Step 4: Script de smoke e captura da resposta real do FCM** + +`scripts/qa/gorush_smoke.sh` (não usa o serviço do Compose: um container descartável, só em `127.0.0.1`; o token é falso, então nada é entregue a ninguém): + +```bash +#!/usr/bin/env bash +# Sobe um Gorush descartável com a chave em $GORUSH_SMOKE_CREDENTIALS (diretório com +# fcm-service-account.json), posta UM push para um token FALSO e grava a resposta +# (status + corpo) em $1. Serve para observar o formato REAL que o GorushClient +# precisa entender. Nunca imprime a chave. +set -euo pipefail +out="${1:?uso: gorush_smoke.sh }" +cred="${GORUSH_SMOKE_CREDENTIALS:?defina GORUSH_SMOKE_CREDENTIALS}" +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +name="gorush-smoke-$$" +trap 'docker rm -f "$name" >/dev/null 2>&1 || true' EXIT +docker run -d --name "$name" -p 127.0.0.1:18088:8088 \ + -v "$repo_root/infra/docker/gorush/config.yml:/config.yml:ro" \ + -v "$cred:/credentials:ro" appleboy/gorush:1.22.0 -c /config.yml >/dev/null +for _ in $(seq 1 20); do + curl -fsS -m 2 http://127.0.0.1:18088/healthz >/dev/null 2>&1 && break + sleep 1 +done +curl -fsS -m 2 http://127.0.0.1:18088/healthz >/dev/null || { docker logs "$name" 2>&1 | tail -20 >&2; echo 'erro: o Gorush não ficou saudável' >&2; exit 1; } +token="$(python3 -c "print('x' * 152)")" +status=$(curl -sS -m 30 -o /tmp/gorush_body.$$ -w '%{http_code}' -X POST http://127.0.0.1:18088/api/push \ + -H 'Content-Type: application/json' \ + -d "{\"notifications\":[{\"tokens\":[\"$token\"],\"platform\":2,\"title\":\"t\",\"message\":\"m\"}]}") +python3 - "$status" /tmp/gorush_body.$$ "$out" <<'PYEOF' +import json, sys +status, body_path, out = int(sys.argv[1]), sys.argv[2], sys.argv[3] +raw = open(body_path).read() +try: body = json.loads(raw) +except Exception: body = raw +json.dump({"status": status, "body": body}, open(out, "w"), indent=2, ensure_ascii=False) +PYEOF +rm -f /tmp/gorush_body.$$ +``` + +Run: `chmod +x scripts/qa/gorush_smoke.sh && GORUSH_SMOKE_CREDENTIALS=$PWD/infra/docker/gorush/credentials ./scripts/qa/gorush_smoke.sh /tmp/gorush_invalid_token.json && cat /tmp/gorush_invalid_token.json` +Expected: o Gorush fica saudável (prova que o Step 3 consertou o boot) e o arquivo mostra a resposta real do FCM a um token inválido. **Este é o primeiro contato com o FCM real**, e ele também prova ou refuta a chave: se a resposta for uma falha de **autenticação/permissão** (401/403, `PERMISSION_DENIED`, API V1 desativada) em vez de "token inválido", **pare**, registre a mensagem (sem a chave) e diga ao usuário o que ajustar no console do Firebase (ativar *Firebase Cloud Messaging API (V1)*, papel da conta de serviço). Leia e registre no ledger: o `status` HTTP, se `counts` existe, o formato de `logs[]` (`type`, `error`, `token`) **e se o token aparece mascarado**. + +- [ ] **Step 5: O contrato real vira teste (RED se a poda não casar)** + +Copie a resposta para `backend/sinalacs_server/test/unit/fixtures/gorush_invalid_token.json`; o token do smoke é falso (`'x' * 152`), então a fixture não contém nada sensível. Em `gorush_client_test.dart` (importe `dart:convert` e `dart:io` se faltarem): + +```dart +test('resposta REAL do Gorush a um token inválido: o token é reconhecido e podado', () async { + final fixture = jsonDecode(File('test/unit/fixtures/gorush_invalid_token.json').readAsStringSync()) + as Map; + final body = fixture['body']; + final gw = await FakeGorush.start( + status: fixture['status'] as int, + rawBody: body is String ? body : jsonEncode(body), + ); + addTearDown(gw.close); + final client = GorushClient(baseUrl: gw.url, timeout: const Duration(seconds: 2)); + final falso = 'x' * 152; + + final report = await client.send( + _msg, + [PushTarget(token: falso, platform: 'android'), const PushTarget(token: 'tok-bom', platform: 'android')], + ); + + // O erro do FCM é sobre o TOKEN: só ele pode ser apagado, e nunca o bom. + expect(report.invalidTokens, [falso]); + expect(report.accepted, lessThanOrEqualTo(1)); +}); +``` + +Run: `cd backend/sinalacs_server && dart test test/unit/gorush_client_test.dart` +Expected: se **PASSAR**, o parser já entendia a resposta real: registre "sem RED: o parser já entendia" (o teste ainda vale como contrato). Se **FALHAR** (token mascarado por `log.hide_token`, ou string de erro do FCM v1 fora da lista `_invalidTokenErrors`), esse é o RED real: corrija `GorushClient._post`/`_invalidTokenErrors` ou `config.yml` (`log.hide_token: false` só se o mascaramento for o motivo, e registre a troca no README) **sem enfraquecer o teste**, e ledgere o que a resposta real tinha de diferente. + +- [ ] **Step 6: Verificações e commit** + +Run: `docker compose --profile push config -q && docker compose config -q; cd backend/sinalacs_server && dart analyze | tail -1 && for i in 1 2 3 4 5; do dart test 2>&1 | tail -1; done; docker rm -f gorush-red 2>/dev/null` +Expected: compose sem erro, analyze em 51 infos, 5 de 5 verdes. + +```bash +git add docker-compose.yml infra/docker/gorush scripts/qa/gorush_smoke.sh backend/sinalacs_server +git commit -m "fix(infra): Gorush 1.22.0 fixada, iOS desligado até existir APNs, e a resposta real vira teste de contrato (RF14)" +``` + +--- + +### Task 2: Lado nativo Android do token FCM e teste no emulador + +**Files:** +- Modify: `apps/patient/android/settings.gradle.kts`, `apps/patient/android/app/build.gradle.kts`, `apps/patient/android/app/src/main/kotlin/br/com/prismrr/sinalacs/patient/MainActivity.kt`, `apps/patient/android/app/src/main/AndroidManifest.xml` +- Create: `apps/patient/integration_test/push_native_token_test.dart` + +**Interfaces:** +- Consumes: o contrato do canal de `NativePushTokenSource` (`lib/core/push/native_push_token_source.dart`): canal `sinalacs/push_token`, método `getToken`, resposta `{'token': String, 'platform': 'android'}`; erro de plataforma vira `null` no Dart. +- Produces: o lado nativo desse canal; o plugin do Google Services aplicado **só** quando `android/app/google-services.json` existe. + +- [ ] **Step 1: Teste vermelho no emulador** + +`integration_test/push_native_token_test.dart`: + +```dart +/// Prova, no aparelho, que o canal nativo devolve um token FCM REAL. Precisa de +/// Play Services, internet e do `google-services.json` no build. Nunca imprime o token. +library; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:sinalacs_patient/core/push/native_push_token_source.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + test('o canal nativo devolve um token FCM real do aparelho', () async { + final device = await const NativePushTokenSource() + .currentDevice() + .timeout(const Duration(seconds: 60)); + + expect(device, isNotNull, + reason: 'sem token: o lado nativo falta, ou o build não tem o google-services.json'); + expect(device!.platform, 'android'); + expect(device.token.length, greaterThan(100)); + expect(device.token.contains(':'), isTrue, reason: 'token FCM tem a forma :'); + }); +} +``` + +Run: `export PATH=$PATH:~/Android/Sdk/platform-tools:~/flutter/bin && cd apps/patient && flutter test integration_test/push_native_token_test.dart -d emulator-5554` +Expected: FAIL em `device, isNotNull` (o canal não tem lado nativo). Se o app nem instalar, leia o erro antes de seguir. + +- [ ] **Step 2: Versões, confirmadas e não supostas** + +Confirme as versões **atuais** no Google Maven e registre-as no ledger: + +```bash +curl -s https://dl.google.com/dl/android/maven2/com/google/gms/google-services/maven-metadata.xml | grep -E "|" +curl -s https://dl.google.com/dl/android/maven2/com/google/firebase/firebase-messaging/maven-metadata.xml | grep -E "|" +``` + +Use `` de cada um. O AGP do projeto é o `9.0.1` (muito novo): se o plugin do Google Services não for compatível, o build falha na configuração — nesse caso leia o erro, procure a versão compatível e registre um `Ruling:`; **não** remova o AGP nem force uma versão antiga do Gradle. + +- [ ] **Step 3: Gradle** + +`settings.gradle.kts`, no bloco `plugins { ... }`: + +```kotlin + id("com.google.gms.google-services") version "" apply false +``` + +`app/build.gradle.kts`: logo depois do bloco `plugins { ... }`, acrescente: + +```kotlin +// google-services.json não é versionado (CI e outros devs não o têm): sem ele o +// plugin derrubaria o build inteiro. Só aplica quando o arquivo existe; sem ele o +// app compila e o lado nativo responde "firebase_unavailable" (sem push). +if (file("google-services.json").exists()) { + apply(plugin = "com.google.gms.google-services") +} +``` + +e no bloco `dependencies { ... }`: + +```kotlin + implementation("com.google.firebase:firebase-messaging:") +``` + +- [ ] **Step 4: Kotlin e manifesto** + +`MainActivity.kt`: + +```kotlin +package br.com.prismrr.sinalacs.patient + +import com.google.firebase.FirebaseApp +import com.google.firebase.messaging.FirebaseMessaging +import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel + +/// Lado nativo do canal `sinalacs/push_token` (RF14): devolve o token FCM do +/// aparelho. O contrato Dart está em `native_push_token_source.dart`: qualquer erro +/// aqui vira `null` lá, e o app segue sem push. +class MainActivity : FlutterActivity() { + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "sinalacs/push_token") + .setMethodCallHandler { call, result -> + if (call.method != "getToken") { + result.notImplemented() + return@setMethodCallHandler + } + // Sem google-services.json (CI, outro dev) o FirebaseApp não existe. + if (FirebaseApp.getApps(this).isEmpty()) { + result.error("firebase_unavailable", "FirebaseApp não inicializado", null) + return@setMethodCallHandler + } + FirebaseMessaging.getInstance().token.addOnCompleteListener { task -> + val token = if (task.isSuccessful) task.result else null + if (!token.isNullOrBlank()) { + result.success(mapOf("token" to token, "platform" to "android")) + } else { + result.error("token_unavailable", task.exception?.message, null) + } + } + } + } +} +``` + +`AndroidManifest.xml`, junto das outras `uses-permission`: + +```xml + + +``` + +- [ ] **Step 5: GREEN no emulador e o build sem o arquivo** + +Run: `cd apps/patient && flutter test integration_test/push_native_token_test.dart -d emulator-5554` +Expected: PASS. O teste confirma forma e tamanho do token, nunca o conteúdo. Se falhar com `null`, leia `adb -s emulator-5554 logcat -d | grep -i -E "firebase|fcm|gms" | tail -30` (sem colar o token). + +Depois prove que o build **sem** o arquivo continua funcionando e o app não quebra: + +```bash +cd apps/patient +mv android/app/google-services.json /tmp/gs.json.bak +trap 'mv /tmp/gs.json.bak android/app/google-services.json 2>/dev/null' EXIT +flutter build apk --debug 2>&1 | tail -3 +flutter test integration_test/push_native_token_test.dart -d emulator-5554 2>&1 | tail -4 +mv /tmp/gs.json.bak android/app/google-services.json; trap - EXIT +``` + +Expected: o APK **compila** sem o arquivo; o teste de integração **falha** em `device, isNotNull` com a razão da mensagem (degradação, sem crash do app). Se o build falhar sem o arquivo, o `if` do Step 3 está errado: corrija antes de seguir (isso quebraria a CI). + +- [ ] **Step 6: Suíte do app e commit** + +Run: `cd apps/patient && flutter test && flutter analyze; cd ../acs && flutter test` +Expected: paciente 223, ACS 178, analyze limpo. + +```bash +git add apps/patient/android apps/patient/integration_test +git commit -m "feat(paciente): lado nativo Android do token FCM, com o plugin do Google Services só quando há google-services.json (RF14)" +``` + +--- + +### Task 3: Ferramentas do teste ponta a ponta (registro real e envio do ACS) + +**Files:** +- Create: `apps/patient/integration_test/push_register_test.dart`, `apps/acs/tool/send_notice.dart` + +**Interfaces:** +- Consumes: `BackendClient` do paciente (`developmentLogin(role:)`, `updateConsent`, `registerPushToken`) e do ACS (`developmentLogin(role:)`, `sendNotice`), `NativePushTokenSource`, `BackendConfig.rpcCaAsset` (asset da CA) — mesmas APIs que `smoke_test.dart` e `tool/live_check.dart` já usam. +- Produces: um teste de integração que registra o token **real** do aparelho no backend com consentimento; uma ferramenta de linha de comando que envia um aviso como ACS e imprime só `recipients`/`accepted`. + +- [ ] **Step 1: O registro real, como teste de integração** + +`apps/patient/integration_test/push_register_test.dart` — copie o preâmbulo de `smoke_test.dart` (leitura da CA do asset, `BackendClient(trustedCaBytes:)`, `addTearDown(backend.close)`, login `backend.developmentLogin(role: 'patient')`; leia o arquivo antes de copiar, o nome exato das funções pode diferir) e acrescente: + +```dart + test('registra no backend o token FCM real do aparelho, com consentimento', () async { + // ... preâmbulo: caBytes, backend, login de paciente ... + final device = await const NativePushTokenSource() + .currentDevice() + .timeout(const Duration(seconds: 60)); + expect(device, isNotNull, reason: 'sem token FCM real: rode a Task 2 antes'); + + // Sem consentimento, o servidor recusa e nada é gravado. + await expectLater( + backend.registerPushToken(token: device!.token, platform: device.platform), + throwsA(isA()), + ); + + await backend.updateConsent(purpose: ConsentPurpose.segmentedPush, granted: true); + await backend.registerPushToken(token: device.token, platform: device.platform); // não lança + }); +``` + +Run (sem stack ainda, só para ver o RED de "sem backend"): `cd apps/patient && flutter test integration_test/push_register_test.dart -d emulator-5554 --dart-define=SINALACS_HOST=https://localhost:8443/` +Expected: FAIL por falta de backend/túnel (conexão), não por erro de compilação. O GREEN vem na Task 4, com a stack de pé. + +- [ ] **Step 2: A ferramenta do ACS** + +`apps/acs/tool/send_notice.dart` — mesmo esqueleto de `tool/live_check.dart` (lê a CA de `apps/acs/assets/certs/dev_rpc_ca.crt` com `dart:io`, monta `BackendClient(host:, trustedCaBytes:)`, `--host` com default `https://localhost/`): + +```dart +/// Envia UM aviso comunitário como ACS de desenvolvimento e imprime só as contagens. +/// dart run tool/send_notice.dart --title "SinalACS e2e" --message "Teste do Gorush" [--chronic] [--host https://localhost/] +/// Precisa da stack de pé com ENABLE_DEV_LOGIN=true. Nunca imprime token nem destinatários. +``` + +Fluxo: `developmentLogin(role: 'acs')` → `sendNotice(title:, message:, chronicOnly:)` → imprime `recipients= accepted=` e sai com `0`; `BackendFailure` imprime **a mensagem** e sai com `2` (o script de e2e precisa distinguir "falhou" de "0 destinatários"). + +Run: `cd apps/acs && dart analyze tool/send_notice.dart && dart run tool/send_notice.dart --help` +Expected: analyze limpo; `--help` imprime o uso. + +- [ ] **Step 3: Commit** + +```bash +git add apps/patient/integration_test/push_register_test.dart apps/acs/tool/send_notice.dart +git commit -m "test(e2e): registro real do token FCM e ferramenta de envio do ACS (RF14)" +``` + +--- + +### Task 4: Teste ponta a ponta no emulador com o Gorush real + +**Files:** +- Create: `scripts/qa/push_e2e.sh` +- Modify (conforme a resposta real): `backend/sinalacs_server/lib/src/infrastructure/push/gorush_client.dart`, `test/unit/gorush_client_test.dart`, `test/unit/fixtures/` +- Modify: `.env` (local, não versionado), `infra/docker/gorush/README.md` + +**Interfaces:** +- Consumes: Tasks 1 a 3. +- Produces: `./scripts/qa/push_e2e.sh` que sobe a stack com o perfil `push`, roda o registro no emulador, envia como ACS e confere a notificação na bandeja do emulador, mais os casos negativos abaixo. + +- [ ] **Step 1: Guarda da chave (a chave já existe; isto impede regressão)** + +```bash +K=infra/docker/gorush/credentials/fcm-service-account.json +test -f $K || { echo "FALTA $K"; exit 3; } +git check-ignore -q $K || { echo "chave NÃO está ignorada pelo git"; exit 3; } +[ "$(stat -c %a $K)" = "600" ] || { echo "modo da chave diferente de 600"; exit 3; } +python3 - <<'PYEOF' +import json +k = json.load(open('infra/docker/gorush/credentials/fcm-service-account.json')) +g = json.load(open('apps/patient/android/app/google-services.json')) +assert k.get('type') == 'service_account', 'não é chave de conta de serviço' +assert k.get('project_id') == g['project_info']['project_id'], 'project_id difere do google-services.json' +assert k.get('client_email') and k.get('private_key'), 'chave incompleta' +print('chave ok para o projeto', k['project_id']) # nunca imprime a chave +PYEOF +``` + +Expected: `chave ok para o projeto sinal-acs`. O mesmo bloco abre `scripts/qa/push_e2e.sh`, que sai com `3` e a instrução quando a chave falta (outra máquina, CI): no console do Firebase, Configurações do projeto → Contas de serviço → Gerar nova chave privada, salvar nesse caminho com modo `600`, e conferir que a API *Firebase Cloud Messaging API (V1)* está ativada no projeto. + +- [ ] **Step 2: Stack de pé com o Gorush** + +```bash +grep -q '^GORUSH_URL=' .env && sed -i 's|^GORUSH_URL=.*|GORUSH_URL=http://gorush:8088|' .env || echo 'GORUSH_URL=http://gorush:8088' >> .env +docker compose --profile push up -d --build +``` + +(edite o `.env` só por essa linha; ele é local e não versionado. Nunca imprima o `.env`.) Aguarde `sinalacs-serverpod` saudável e o seed terminar (`docker compose ps`), depois `docker compose logs gorush | tail -5`. +Expected: `gorush` de pé sem erro de credencial; o backend com `GORUSH_URL` definido (`docker compose exec serverpod printenv GORUSH_URL` imprime a URL). + +- [ ] **Step 3: Registro real no emulador (GREEN de `push_register_test`)** + +```bash +export PATH=$PATH:~/Android/Sdk/platform-tools:~/flutter/bin +adb -s emulator-5554 reverse tcp:8443 tcp:443 +adb -s emulator-5554 shell pm grant br.com.prismrr.sinalacs.patient android.permission.POST_NOTIFICATIONS 2>/dev/null || true +cd apps/patient && flutter test integration_test/push_register_test.dart -d emulator-5554 \ + --dart-define=SINALACS_HOST=https://localhost:8443/ +cd ../.. && docker compose exec -T postgres psql -U "$(grep ^POSTGRES_USER= .env | cut -d= -f2)" \ + -d "$(grep ^POSTGRES_DB= .env | cut -d= -f2)" -Atc "select count(*), min(platform) from push_tokens" +``` + +Expected: o teste PASSA e a consulta devolve `1|android` (uma linha, sem imprimir o token). O `pm grant` pode falhar se o app ainda não estiver instalado: rode de novo depois da primeira instalação. + +- [ ] **Step 4: O envio real e a notificação na bandeja** + +Com o app em **segundo plano** (FCM não mostra mensagem de notificação na bandeja com o app aberto): + +```bash +adb -s emulator-5554 shell input keyevent KEYCODE_HOME +adb -s emulator-5554 shell cmd notification cancel_all 2>/dev/null || true +cd apps/acs && dart run tool/send_notice.dart --title "SinalACS e2e" --message "Teste do Gorush" +sleep 8 +adb -s emulator-5554 shell dumpsys notification --noredact | grep -A12 "pkg=br.com.prismrr.sinalacs.patient" | grep -E "title|text|SinalACS e2e|Teste do Gorush" | head +``` + +Expected: a ferramenta imprime `recipients=1 accepted=1` e o `dumpsys` mostra a notificação do pacote do app com o título `SinalACS e2e` e o texto `Teste do Gorush`. Se `accepted=0`, o erro está no Gorush/FCM: `docker compose logs gorush | tail -20` (com `hide_messages`, o texto não aparece no log). Capture, **sanitizada**, a resposta real do Gorush a esse envio bem-sucedido (o backend não a expõe: use o `gorush_smoke.sh` ou um `docker compose exec` que chame o Gorush de dentro da rede) e salve em `test/unit/fixtures/gorush_success.json`. + +- [ ] **Step 5: Casos negativos reais (cada um observado, não suposto)** + +1. **Token inválido é podado, o bom fica.** Insira um token falso para o mesmo titular e envie de novo: + +```bash +PSQL="docker compose exec -T postgres psql -U $(grep ^POSTGRES_USER= .env | cut -d= -f2) -d $(grep ^POSTGRES_DB= .env | cut -d= -f2) -Atc" +UID_=$($PSQL "select \"userId\" from push_tokens limit 1") +$PSQL "insert into push_tokens (\"userId\",\"microAreaId\",token,platform,\"createdAt\",\"updatedAt\") select \"userId\",\"microAreaId\",'$(python3 -c "print('x'*150)")','android',now(),now() from push_tokens where \"userId\"='$UID_' limit 1" +(cd apps/acs && dart run tool/send_notice.dart --title "SinalACS e2e" --message "Poda") +$PSQL "select count(*) from push_tokens where \"userId\"='$UID_'" +``` + +Expected: a ferramenta imprime `recipients=2` e o `count` final é **1** (o falso foi apagado, o real ficou). A Task 1 Step 5 já provou a poda contra a resposta real do Gorush em isolamento; aqui ela é provada **de ponta a ponta** (backend → Gorush → FCM → banco). **Se continuar 2**, algo entre o backend e o Gorush difere do isolamento (token truncado na lista, `sync`, tempo): leia `docker compose logs serverpod | tail` e o log do Gorush, e corrija com um teste vermelho antes. + +2. **Token `ios` com iOS desligado não apaga nada e não derruba o Android.** Insira uma linha `platform='ios'` e envie: a ferramenta não pode falhar, o token real continua recebendo (`accepted>=1`) e a linha `ios` **não** é apagada (erro de configuração do servidor não é erro do token). +3. **Revogar apaga os tokens e zera os destinatários.** No emulador, com o app de teste, `updateConsent(segmentedPush, granted: false)` (use `push_register_test` com uma variante ou o endpoint via a ferramenta); depois `send_notice` imprime `recipients=0` e nenhuma notificação nova chega. +4. **Gorush parado:** `docker compose stop gorush`, rode `send_notice`: deve sair com código `2` em **≤ 6 s** com "Não foi possível entregar o aviso agora…" (ou "resultado desconhecido", se estourar o tempo); `select result from audit_logs where "resourceType"='community_notice' order by timestamp desc limit 1` **não** pode ser `granted`. Religue com `docker compose start gorush`. +5. **Sem o `google-services.json` no build:** já provado na Task 2 (compila e degrada). Não repetir. + +- [ ] **Step 6: O script que repete tudo** + +`scripts/qa/push_e2e.sh` encadeia os Steps 1 a 4 (pré-condição da chave, stack, `adb reverse`, `pm grant`, registro no emulador, `KEYCODE_HOME`, envio, `dumpsys`) com `set -euo pipefail`, nenhum `echo` de segredo e `exit 3` claro quando a chave falta. Os casos negativos do Step 5 ficam como funções opcionais (`--negativos`). Escreva o script **depois** de ter rodado os passos à mão, com os comandos que funcionaram. + +Run: `./scripts/qa/push_e2e.sh` +Expected: termina com `OK — o aviso chegou ao emulador` e código 0 na máquina que tem a chave; com código 3 e a instrução na que não tem. + +- [ ] **Step 7: Suíte e commit** + +Run: `cd backend/sinalacs_server && dart analyze | tail -1 && for i in 1 2 3 4 5; do dart test 2>&1 | tail -1; done; docker compose --profile push config -q` +Expected: analyze em 51 infos; 5 de 5 verdes. + +```bash +git add scripts/qa/push_e2e.sh infra/docker/gorush backend/sinalacs_server +git commit -m "test(e2e): aviso do ACS chega ao emulador pelo Gorush real; poda e degradação provadas contra o FCM (RF14)" +``` + +--- + +### Task 5: Documentação, memória e verificação final + +**Files:** +- Modify: `PROGRESS.md`, `apps/CLAUDE.md`, `backend/CLAUDE.md`, `spec/stack.md`, `docs/superpowers/specs/2026-09-16-decisoes-produto-pos-validacao.md` (§3.2), `infra/docker/gorush/README.md`, memória (`rf14-gorush-2026-09-29` atualizada ou nova) e `MEMORY.md`. + +- [ ] **Step 1:** Atualizar os docs com o que **foi observado**, item a item do checklist do README do Gorush (`counts`/`logs`, máscara do token, strings de erro do FCM v1, boot sem credenciais, tag fixada): cada um vira "verificado em contra a 1.22.0" ou continua "não verificado". Se a Task 4 foi bloqueada pela chave, o texto diz **em primeiro plano** que o envio real NÃO foi provado, e lista o que falta (a chave e a API FCM v1 ativada). `apps/CLAUDE.md`: o lado nativo do canal agora existe, o plugin é condicional e o app degrada sem o arquivo. **iOS/APNs continua sem teste e sem implementação: dizer isso.** + +- [ ] **Step 2: Verificação completa** + +Run: `cd backend/sinalacs_server && for i in 1 2 3 4 5; do dart test 2>&1 | tail -1; done; dart analyze | tail -1; cd ../../apps/patient && flutter test && flutter analyze; cd ../acs && flutter test && flutter analyze; cd ../.. && docker compose config -q && docker compose --profile push config -q && ./scripts/qa/ci_invariants.sh; graphify update .` +Expected: backend 5 de 5, analyze em 51 infos e zero avisos; paciente e ACS verdes; compose e `ci_invariants` ok. + +- [ ] **Step 3: Commit** + +```bash +git add PROGRESS.md apps/CLAUDE.md backend/CLAUDE.md spec docs infra +git commit -m "docs: registra o que foi provado contra o Gorush e o FCM reais e o que não foi (RF14)" +``` + +--- + +## Fora desta rodada (por decisão) + +- **iOS/APNs:** nenhum app iOS, nenhuma chave APNs; `ios.enabled` fica `false`. +- Renovação do token (`onNewToken`) e abrir uma tela ao tocar na notificação (`data.screen`). +- Enviar pelo perfil `admin` (o backend do admin não existe) e agendamento de avisos. +- Hospedar o Gorush fora do Compose local e a rotação da chave da conta de serviço. + +## Autorrevisão + +- **Cobertura:** "analisar a configuração" → Task 1 (prova que o boot atual falha, corrige, fixa a tag, captura a resposta real); "finalizar a configuração" → Tasks 1 e 2 (Gorush + lado nativo Android + plugin condicional); "testes com o Gorush" → Tasks 3 e 4 (registro real, envio real, bandeja, casos negativos); "emulador 5554" → Tasks 2 e 4; docs → Task 5. Os itens do Review Focus têm teste: build sem o arquivo (Task 2 Step 5), token `ios` (Task 4 Step 5.2), poda real (5.1), máscara do token (5.1 e Task 1 Step 4), Gorush parado (5.4), app em segundo plano (Step 4), permissão (Step 3 com `pm grant`). +- **Placeholders:** `` (Task 2) é substituído no Step 2 pelo valor lido do Google Maven, que o passo manda ler e registrar; o preâmbulo de `push_register_test` e de `send_notice.dart` remete a dois arquivos existentes com o comando exato para lê-los; as chaves `android.*` do Gorush 1.22.0 são confirmadas por `--help` no Task 1 Step 3. Nenhum comportamento ficou por definir. +- **Tipos:** `PushTarget`/`PushMessage`/`PushSendReport`/`PushGatewayException`, `NativePushTokenSource`, `registerPushToken`, `updateConsent`, `sendNotice` e o canal `sinalacs/push_token` usam os nomes que já existem no código. +- **Riscos declarados:** (1) a chave existe e tem estrutura válida, mas **não está provado que funciona**: API FCM V1 desativada, papel insuficiente ou chave revogada só aparecem no Task 1 Step 4, que para e diz o que ajustar; (2) o plugin do Google Services pode não ser compatível com o AGP 9.0.1; (3) a imagem do emulador pode não entregar FCM de forma confiável (token sai, mas a entrega depende da conectividade do Play Services); (4) `dumpsys notification` varia entre versões do Android — se o formato mudar, o passo de conferência ajusta o `grep`, não o critério (a notificação tem de existir). diff --git a/docs/superpowers/plans/2026-09-30-menores-do-rf14-e-texto-novo-dos-termos.md b/docs/superpowers/plans/2026-09-30-menores-do-rf14-e-texto-novo-dos-termos.md new file mode 100644 index 00000000..11bdd58b --- /dev/null +++ b/docs/superpowers/plans/2026-09-30-menores-do-rf14-e-texto-novo-dos-termos.md @@ -0,0 +1,573 @@ +# Menores do RF14 e texto novo dos termos durante os 15 dias — Plano de Implementação + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fechar os menores adiados do RF14 e do aviso de termos (poda do teto, desempates, rastro do dono anterior, cliente do Gorush, auditoria com 0 aceitos, contraste e semântica do cartão) e permitir que o paciente leia o **texto novo** dos termos durante os 15 dias de aviso. + +**Architecture:** O texto futuro é conteúdo constante do app (como o texto vigente: precisa abrir sem rede e antes do cadastro), embarcado numa versão do app **antes** de o servidor publicar o aviso. O cartão só oferece "Ler o texto novo" quando a versão do aviso devolvida pelo servidor for a mesma que o app carrega; senão cai em "Ler os termos atuais". Um teste amarra as duas pontas (agenda do backend × texto do app). Os menores do backend são correções pontuais nos stores e no serviço de avisos, cada uma com um teste que já falha hoje. + +**Tech Stack:** Serverpod 3.4.13 (`serverpod generate` só se um `.spy.yaml` mudar — aqui não muda), Postgres, `dart:io`, Flutter 3.44, `flutter_test`. + +**Spec:** `docs/superpowers/specs/2026-09-16-decisoes-produto-pos-validacao.md` §2 e §3.2; `spec/lgpd_design.md` (LGPD-RF18/RF19: atualização comunicada com 15 dias de antecedência, versão consultável). + +## Global Constraints + +- Rodar `docker compose --profile test up -d postgres-test` antes dos testes de integração. `flutter analyze` limpo nos apps; `dart analyze` do backend no baseline de 51 infos, zero avisos. +- Alerta vermelho nunca é descartado nem atrasado: nada aqui pode bloquear login, home nem o botão de urgência. O cartão continua **fora** da aba de urgência. +- Textos de UI e comentários em português. Nenhum dado real em testes. +- Testes de integração sem rollback usam ids próprios (o grupo de corrida do push usa `9200…`; o de avisos, se precisar de um grupo sem rollback, `9500…`) e semente enxuta; esses grupos commitam linhas e já deixaram a suíte intermitente. Repetir `dart test` 5 vezes antes de dar uma tarefa de backend por concluída. +- `legalDocumentsVersion` (app) == `consentPolicyVersion` (backend) continua valendo; esta rodada **não** troca a versão vigente nem agenda uma mudança real (`upcomingTermsChange` e `upcomingLegalDocuments` ficam `null`). +- Contrastes medidos com `test/support/contrast.dart` (mínimo 4.5 para texto, 3.0 para ícone/UI) contra a superfície em que o widget realmente renderiza (`PatientColors.surfaceRaised`, o `Card`). + +## Review Focus + +- Aviso do servidor com versão que o app não conhece: o cartão não oferece um texto novo que não existe; oferece só o vigente, sem erro. +- Texto novo embarcado com versão diferente da agenda do backend (ou agenda ativa sem texto no app): o teste de amarração falha no CI, não em produção. +- Corpo 2xx do Gorush ilegível: o pedido pode ter sido entregue, então o erro é "resultado desconhecido", nunca "tente de novo". +- Falha ao apagar tokens inválidos depois de um envio bem-sucedido: o ACS recebe o resultado do envio, não um erro; a auditoria registra o envio. +- Paciente com token e consentimento mas sem linha clínica em `patients`: recebe o aviso "para todos" e fica fora do filtro de crônicos. + +--- + +## Mapa de arquivos + +- Backend modificar: `lib/src/application/patients/push_token_service.dart`, `data_subject_rights_service.dart`, `lib/src/application/notices/notice_service.dart`, `lib/src/infrastructure/database/orm_push_token_store.dart`, `orm_data_subject_rights_store.dart`, `orm_notice_recipient_store.dart`, `lib/src/infrastructure/push/gorush_client.dart`, `lib/src/runtime/alert_runtime.dart`, e os testes `test/unit/push_token_service_test.dart`, `data_subject_rights_service_test.dart`, `notice_service_test.dart`, `gorush_client_test.dart`, `test/integration/push_token_endpoint_test.dart`, `notices_endpoint_test.dart`. +- Paciente criar: `apps/patient/test/upcoming_legal_documents_test.dart`. Modificar: `lib/core/legal/legal_documents.dart`, `lib/core/legal/terms_change_notice_card.dart`, `lib/app/legal_screens.dart`, `lib/app/app.dart`, `test/terms_change_notice_test.dart`, `test/contrast_tokens_test.dart`, `test/legal_documents_test.dart`. +- Docs: `PROGRESS.md`, `apps/CLAUDE.md`, `backend/CLAUDE.md`, `spec/lgpd_design.md`, `spec/lgpd_data_audit.md`, memória. + +--- + +### Task 1: Menores do registro de token e do consentimento (backend) + +**Files:** +- Modify: `lib/src/application/patients/push_token_service.dart`, `lib/src/infrastructure/database/orm_push_token_store.dart`, `lib/src/infrastructure/database/orm_data_subject_rights_store.dart`, `lib/src/application/patients/data_subject_rights_service.dart` +- Test: `test/unit/push_token_service_test.dart`, `test/unit/data_subject_rights_service_test.dart`, `test/integration/push_token_endpoint_test.dart` + +**Interfaces:** +- Produces: `class PushRegistrationResult { const PushRegistrationResult(this.outcome, {this.previousOwnerId}); final PushRegistration outcome; final String? previousOwnerId; }` em `push_token_service.dart`. `PushTokenStore.registerIfConsented(...)` passa a devolver `Future` (`previousOwnerId` só é preenchido quando `outcome == ownerChanged`). +- Produces: `DataSubjectRightsService({..., TermsChangeSchedule? Function()? termsChangeReader})` no lugar do parâmetro `termsChange`; o padrão é `() => upcomingTermsChange`. (Remova `TermsChangeSchedule? termsChange` e ajuste os testes que o usam.) +- Consumes: `PushRegistration`, `maxPushTokensPerUser`, `lockPerSubject`, `lockNamespacePushToken`, `lockNamespacePushTokenRow`, `isCurrentAcceptance`. + +- [ ] **Step 1: Testes vermelhos (unitários)** + +Em `push_token_service_test.dart`, o `_FakePushTokenStore` passa a devolver `PushRegistrationResult` (`ownerChanged` traz `previousOwnerId: previous.userId`). Trocar o teste da troca de dono por: + +```dart +test('troca de dono audita o novo dono E o anterior, sem o token', () async { + await service.register(_patient, token: 'tok-1', platform: 'android'); + await service.register(_patient, token: 'tok-1', platform: 'android'); + expect(audit.events, isEmpty); + + await service.register(_otherPatient, token: 'tok-1', platform: 'android'); + + expect(audit.events.map((e) => e.userId).toSet(), {_otherPatient.id, _patient.id}); + for (final e in audit.events) { + expect(e.resourceType, 'push_token'); + expect('${e.resourceId} ${e.result}'.contains('tok-1'), isFalse); + } + expect(audit.events.map((e) => e.result), containsAll(['granted', 'lost'])); +}); +``` + +Em `data_subject_rights_service_test.dart`, trocar `termsChange: agenda` por `termsChangeReader: () => agenda` e acrescentar: + +```dart +test('um leitor que devolve null vence a constante do repositório', () { + final svc = DataSubjectRightsService( + store: store, + audit: audit, + clock: () => _now, + termsChangeReader: () => null, + ); + expect(svc.termsChangeNotice(_patient), isNull); +}); +``` + +Run: `cd backend/sinalacs_server && dart test test/unit/push_token_service_test.dart test/unit/data_subject_rights_service_test.dart` +Expected: FAIL (compilação: `PushRegistrationResult`, `termsChangeReader`). + +- [ ] **Step 2: Implementar serviço e interface** + +`push_token_service.dart`: acrescentar `PushRegistrationResult` (acima), trocar o retorno da interface, e no `register`: + +```dart + final result = await _store.registerIfConsented(...); // mesmos argumentos + if (result.outcome == PushRegistration.refused) { + throw DataRightsException( + message: 'Ative "Avisos da equipe de saúde" em Meus Dados para receber avisos.', + ); + } + if (result.outcome == PushRegistration.ownerChanged) { + await _audit.recordSafely(AuditEvent( + userId: user.id, actionType: 'write', resourceType: 'push_token', result: 'granted', + )); + // Rastro para o titular anterior (LGPD-RF08): ele perdeu o vínculo sem agir. + final previous = result.previousOwnerId; + if (previous != null) { + await _audit.recordSafely(AuditEvent( + userId: previous, actionType: 'write', resourceType: 'push_token', result: 'lost', + )); + } + } +``` + +`data_subject_rights_service.dart`: construtor recebe `TermsChangeSchedule? Function()? termsChangeReader`, guarda `_termsChangeReader = termsChangeReader ?? (() => upcomingTermsChange)` e `termsChangeNotice` usa `_termsChangeReader()`. + +- [ ] **Step 3: Rodar os unitários** + +Run: `dart test test/unit/push_token_service_test.dart test/unit/data_subject_rights_service_test.dart` +Expected: PASS. + +- [ ] **Step 4: Testes de integração vermelhos** + +Em `push_token_endpoint_test.dart`, no grupo de corrida (ids `9200…`, `_seedRaceLean`, `cleanup` já apaga `PushToken` e `ConsentLog` dos dois titulares). Envolver cada `await _seedRaceLean(session);` do grupo **dentro** do `try` (o `cleanup` no `finally` é idempotente; assim uma semente que falha no meio não deixa ids fixos commitados). Adaptar os três usos de `registerIfConsented` para `(...).outcome`. Testes novos: + +```dart +test('a poda do teto só apaga tokens do próprio titular', () async { + final session = sessionBuilder.build(); + try { + await _seedRaceLean(session); + final consents = consentStore(); + await grant(consents, _racePatientId); + await grant(consents, _raceAcsId); + final tokens = OrmPushTokenStore(session: () => sessionBuilder.build()); + final base = DateTime.now().toUtc(); + // B (_raceAcsId) está no teto; o token mais antigo dele é 'tok-b-0'. + for (var i = 0; i < maxPushTokensPerUser; i++) { + await tokens.registerIfConsented( + userId: _raceAcsId, microAreaId: _raceMicroAreaId, + token: 'tok-b-$i', platform: 'android', now: base.add(Duration(seconds: i)), + ); + } + // C (_racePatientId) recebe o token mais antigo de B: troca de dono. + await tokens.registerIfConsented( + userId: _racePatientId, microAreaId: _raceMicroAreaId, + token: 'tok-b-0', platform: 'android', now: base.add(const Duration(minutes: 1)), + ); + // B registra um 11º token: a poda dele NÃO pode levar o 'tok-b-0' que agora é de C. + await tokens.registerIfConsented( + userId: _raceAcsId, microAreaId: _raceMicroAreaId, + token: 'tok-b-novo', platform: 'android', now: base.add(const Duration(minutes: 2)), + ); + final donoDeB0 = await PushToken.db.findFirstRow(session, where: (t) => t.token.equals('tok-b-0')); + expect(donoDeB0?.userId, UuidValue.fromString(_racePatientId)); + } finally { + await cleanup(session); + } +}); + +test('relógio que voltou: o token recém-gravado nunca é o podado', () async { + final session = sessionBuilder.build(); + try { + await _seedRaceLean(session); + await grant(consentStore(), _racePatientId); + final tokens = OrmPushTokenStore(session: () => sessionBuilder.build()); + final futuro = DateTime.now().toUtc().add(const Duration(hours: 1)); + for (var i = 0; i < maxPushTokensPerUser; i++) { + await tokens.registerIfConsented( + userId: _racePatientId, microAreaId: _raceMicroAreaId, + token: 'tok-f-$i', platform: 'android', now: futuro, + ); + } + // `now` MENOR que o de todos os outros: por updatedAt ele seria o mais antigo. + final result = await tokens.registerIfConsented( + userId: _racePatientId, microAreaId: _raceMicroAreaId, + token: 'tok-agora', platform: 'android', now: DateTime.now().toUtc(), + ); + expect(result.outcome, PushRegistration.registered); + expect(await PushToken.db.count(session, where: (t) => t.token.equals('tok-agora')), 1); + expect( + await PushToken.db.count(session, where: (t) => t.userId.equals(UuidValue.fromString(_racePatientId))), + maxPushTokensPerUser, + ); + } finally { + await cleanup(session); + } +}); + +test('troca de dono devolve o dono anterior', () async { + // dois titulares consentidos; A registra 'tok-dono'; B registra o mesmo + // esperado: result.outcome == ownerChanged e result.previousOwnerId == _racePatientId +}); + +test('consentimentos com o MESMO timestamp: o desempate é estável (id maior vence)', () async { + // insere duas linhas segmentedPush do mesmo titular com o mesmo `timestamp`: + // id menor = 'granted' (inserida primeiro) + // id maior = 'denied' (inserida depois) + // registerIfConsented deve recusar (o de id maior vence => 'denied'), sempre. + // Use ConsentLog.db.insertRow com `id: UuidValue.fromString('…-000000000001')` e '…-000000000002'. +}); +``` + +O terceiro e o quarto testes: complete com o mesmo esqueleto do primeiro (semente, `grant`/inserção direta, `try/finally cleanup`); no quarto, insira as duas linhas na ordem "id menor primeiro" para que, sem desempate, a leitura devolva o `granted` (ordem de heap) e o teste falhe. + +Run: `dart test test/integration/push_token_endpoint_test.dart` +Expected: FAIL nos quatro (compilação por `.outcome`/`previousOwnerId`; depois: a poda apaga o token de C, o token novo é podado, o desempate devolve `granted`). + +- [ ] **Step 5: Implementar os stores** + +`orm_push_token_store.dart`, em `registerIfConsented`: +1. Leitura do consentimento com desempate: `orderByList: (t) => [Order(column: t.timestamp, orderDescending: true), Order(column: t.id, orderDescending: true)]` no lugar de `orderBy`/`orderDescending`. +2. `previousOwnerId = ownerChanged ? existing!.userId.uuid : null` guardado antes do `updateRow`. +3. Poda, sempre poupando o token recém-gravado e apagando **por id E titular**: + +```dart + final mine = await PushToken.db.find( + session, + where: (t) => t.userId.equals(userUuid) & t.token.notEquals(token), + orderByList: (t) => [ + Order(column: t.updatedAt, orderDescending: true), + Order(column: t.createdAt, orderDescending: true), + ], + transaction: transaction, + ); + final doomed = mine.skip(maxPushTokensPerUser - 1).map((t) => t.id!).toSet(); + if (doomed.isNotEmpty) { + await PushToken.db.deleteWhere( + session, + where: (t) => t.id.inSet(doomed) & t.userId.equals(userUuid), + transaction: transaction, + ); + } +``` + +4. Devolver `PushRegistrationResult(PushRegistration.refused)`, `.registered` ou `PushRegistrationResult(PushRegistration.ownerChanged, previousOwnerId: previousOwnerId)`. + +`orm_data_subject_rights_store.dart`: `latestConsent` e a leitura dentro de `recordConsentUnlessCurrent` passam ao mesmo `orderByList` (timestamp desc, id desc). Confirme com `grep -n "orderBy" lib/src/infrastructure/database/orm_data_subject_rights_store.dart`. + +- [ ] **Step 6: Suíte e commit** + +Run: `cd backend/sinalacs_server && dart analyze && for i in 1 2 3 4 5; do dart test 2>&1 | tail -1; done` +Expected: analyze em 51 infos e zero avisos; 5 de 5 verdes. Se um arquivo de integração ficar intermitente, a causa é uma semente que commita demais: enxugue-a, não repita até passar. + +```bash +git add backend/sinalacs_server +git commit -m "fix(backend): poda do teto por titular, desempates estáveis e rastro do dono anterior do token (RF14)" +``` + +--- + +### Task 2: Menores do envio de avisos (backend) + +**Files:** +- Modify: `lib/src/infrastructure/push/gorush_client.dart`, `lib/src/application/notices/notice_service.dart`, `lib/src/infrastructure/database/orm_notice_recipient_store.dart`, `lib/src/runtime/alert_runtime.dart` +- Test: `test/unit/gorush_client_test.dart`, `test/unit/notice_service_test.dart`, `test/integration/notices_endpoint_test.dart` + +**Interfaces:** +- Produces: `GorushClient.close()` (fecha o `HttpClient`; um `send` depois de fechado lança `PushGatewayException`). +- Produces: em `AlertRuntime`, `PushSender? _gorush` criado uma vez por `GorushClient` (não por requisição), recriado/fechado em `overrideConfig`. +- Consumes: `PushSender`, `PushGatewayException(message, {outcomeUnknown})`, `NoticeService`, `NoticeRecipientStore`, `AuditTrail`. + +- [ ] **Step 1: Testes vermelhos (unitários)** + +`gorush_client_test.dart` (o `FakeGorush.start` aceita `response` como `Map`; estenda-o para aceitar `rawBody` (String) que, se presente, é escrito no lugar do JSON): + +```dart +test('corpo 2xx ilegível: resultado desconhecido, nunca "tente de novo"', () async { + for (final raw in ['isto não é json', '[1,2,3]', '{"logs":"não-é-lista"}']) { + final gw = await FakeGorush.start(rawBody: raw); + addTearDown(gw.close); + final client = GorushClient(baseUrl: gw.url, timeout: const Duration(seconds: 2)); + final envio = client.send(_msg, const [PushTarget(token: 'a', platform: 'android')]); + if (raw.contains('não-é-lista')) { + final report = await envio; // forma inesperada em `logs`: tolerada, sem falhas + expect(report.accepted, 1); + } else { + await expectLater( + envio, + throwsA(isA().having((e) => e.outcomeUnknown, 'outcomeUnknown', isTrue)), + reason: raw, + ); + } + } +}); + +test('depois de close(), send falha com PushGatewayException e não com StateError', () async { + final gw = await FakeGorush.start(response: {}); + addTearDown(gw.close); + final client = GorushClient(baseUrl: gw.url, timeout: const Duration(seconds: 2))..close(); + await expectLater( + client.send(_msg, const [PushTarget(token: 'a', platform: 'android')]), + throwsA(isA()), + ); +}); +``` + +`notice_service_test.dart` (o `_FakeRecipientStore` ganha `Object? deleteFailure` lançado por `deleteTokens`): + +```dart +test('falha ao apagar tokens inválidos não vira erro: o envio já aconteceu', () async { + sender.report = const PushSendReport(accepted: 1, invalidTokens: ['tok-b']); + store.deleteFailure = StateError('banco fora do ar'); + final r = await service.sendSegmented(_acs, title: 't', message: 'm', audience: 'everyone'); + expect((r.recipients, r.accepted), (2, 1)); + expect(audit.events.single.result, 'granted'); +}); + +test('nenhum aceito: não audita "granted"', () async { + sender.report = const PushSendReport(accepted: 0, invalidTokens: []); + final r = await service.sendSegmented(_acs, title: 't', message: 'm', audience: 'everyone'); + expect(r.accepted, 0); + expect(audit.events.single.result, 'not_delivered'); +}); +``` + +Run: `dart test test/unit/gorush_client_test.dart test/unit/notice_service_test.dart` +Expected: FAIL (`close` e `deleteFailure` não existem; `rawBody` idem; depois: o erro escapa, a auditoria diz `granted`). + +- [ ] **Step 2: Implementar cliente e serviço** + +`gorush_client.dart`: +- `void close() => _http.close(force: true);` +- em `send`, acrescentar `on StateError { throw const PushGatewayException('O cliente do Gorush foi encerrado.'); }` e trocar o tratamento de `FormatException` por `outcomeUnknown: true` (a resposta era 2xx: o Gorush pode ter entregue). +- em `_post`, `jsonDecode` só é aceito se `is Map`; senão `throw const PushGatewayException('O Gorush respondeu algo ilegível.', outcomeUnknown: true)`. `logs` que não é lista vale como vazio (`final logs = json['logs'] is List ? json['logs'] as List : const []`). + +`notice_service.dart`: cercar o apagamento de tokens (best effort) e escolher o resultado da auditoria: + +```dart + if (report.invalidTokens.isNotEmpty) { + try { + await _store.deleteTokens(report.invalidTokens); + } catch (_) { + // A poda é higiene: o aviso já saiu, e um erro aqui levaria o ACS a reenviar. + } + } + await _record(user, microAreaId, report.accepted > 0 ? 'granted' : 'not_delivered'); +``` + +- [ ] **Step 3: Rodar os unitários** + +Run: `dart test test/unit/gorush_client_test.dart test/unit/notice_service_test.dart` +Expected: PASS. + +- [ ] **Step 4: Testes de integração vermelhos** + +Em `notices_endpoint_test.dart`: +1. Renomear o teste `grava uma linha community_notice sem o texto do aviso` para `grava uma linha community_notice só com ACS e microárea` e acrescentar `expect(jsonEncode(rows.single.toJson()).contains('Amanhã, das 8h'), isFalse);` (importe `dart:convert`). +2. `_addPatient` ganha `bool withPatientRow = true` (pula o `Patient.db.insertRow` quando `false`). Teste novo: + +```dart +test('paciente sem linha clínica recebe "para todos" e fica fora do filtro de crônicos', () async { + final session = await seedAll(); + await _addPatient(session, id: _noClinicalId, microAreaId: _microAreaId, chronic: false, withPatientRow: false); + await _consent(session, _noClinicalId, 'granted', DateTime.utc(2026, 9, 1)); + await _token(session, _noClinicalId, 'tok-sem-clinica', _microAreaId); + + await send(await tokenOf('acs')); + expect(sender.lastTargets.map((t) => t.token), contains('tok-sem-clinica')); + + await send(await tokenOf('acs'), audience: 'chronic'); + expect(sender.lastTargets.map((t) => t.token), isNot(contains('tok-sem-clinica'))); +}); + +test('consentimentos com o mesmo timestamp: o de id maior decide', () async { + // mesmo titular, `timestamp` igual: 'granted' com id menor (inserido primeiro), + // 'denied' com id maior. O token dele NÃO pode receber o aviso. +}); +``` + +(`_noClinicalId = '00000000-0000-4000-8000-000000000024'`; para o segundo teste use ids explícitos em `ConsentLog(id: UuidValue.fromString(...))`.) + +Run: `dart test test/integration/notices_endpoint_test.dart` +Expected: FAIL nos dois novos (o `INNER JOIN` exclui o paciente sem linha clínica; o desempate devolve `granted`). + +- [ ] **Step 5: Implementar o store e o runtime** + +`orm_notice_recipient_store.dart`: `JOIN patients p` vira `LEFT JOIN patients p ON p."id" = u."id"`, o filtro vira `(NOT @chronic OR COALESCE(p."isChronic", false))`, e o subselect do consentimento ganha o desempate: `ORDER BY c."timestamp" DESC, c."id" DESC LIMIT 1`. + +`alert_runtime.dart`: guardar `GorushClient? _gorush` (e a URL com que foi criado). `noticeServiceFor` reaproveita o cliente quando `config.gorushUrl` não mudou; `overrideConfig` e `overrideNoticeSender` chamam `_gorush?.close(); _gorush = null;`. + +- [ ] **Step 6: Suíte e commit** + +Run: `cd backend/sinalacs_server && dart analyze && for i in 1 2 3 4 5; do dart test 2>&1 | tail -1; done` +Expected: analyze em 51 infos, zero avisos; 5 de 5 verdes. + +```bash +git add backend/sinalacs_server +git commit -m "fix(backend): cliente do Gorush encerrável e tolerante, envio não vira erro por falha de poda, LEFT JOIN e desempate (RF14)" +``` + +--- + +### Task 3: Texto novo dos termos, contraste e semântica do cartão (app paciente) + +**Files:** +- Create: `apps/patient/test/upcoming_legal_documents_test.dart` +- Modify: `lib/core/legal/legal_documents.dart`, `lib/core/legal/terms_change_notice_card.dart`, `lib/app/legal_screens.dart`, `lib/app/app.dart`, `test/terms_change_notice_test.dart`, `test/contrast_tokens_test.dart`, `test/legal_documents_test.dart` + +**Interfaces:** +- Produces em `legal_documents.dart`: `class UpcomingLegalDocuments { const UpcomingLegalDocuments({required this.version, required this.privacy, required this.terms}); final String version; final LegalDocument privacy; final LegalDocument terms; }` e `const UpcomingLegalDocuments? upcomingLegalDocuments = null;`. +- Produces: `LegalDocumentsScreen({super.key, this.upcoming})` — com `upcoming != null` lista os documentos futuros e o título passa a ser "Termos que passam a valer". +- Produces: `TermsChangeNoticeCard({required notice, required onRead, required onDismiss, this.onReadNew})` — `onReadNew` só é passado quando o app carrega o texto da versão do aviso; a nova chave é `terms_change_notice_read_new`. +- Consumes: `TermsChangeNotice` (`version`, `effectiveFrom`, `summary`) do cliente gerado; `LegalDocument`, `privacyPolicy`, `termsOfUse`. + +- [ ] **Step 1: Testes vermelhos** + +`test/upcoming_legal_documents_test.dart` prova a amarração entre o backend e o app com uma função de leitura testada por si mesma: + +```dart +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_patient/core/legal/legal_documents.dart'; + +/// Versão anunciada no backend, ou `null` quando `upcomingTermsChange = null`. +String? backendUpcomingVersion(String source) { + if (RegExp(r'upcomingTermsChange\s*=\s*null').hasMatch(source)) return null; + final match = RegExp(r"upcomingTermsChange[^;]*version:\s*'([^']+)'", dotAll: true).firstMatch(source); + return match?.group(1); +} + +void main() { + test('o leitor devolve null com a agenda vazia e a versão quando há agenda', () { + expect(backendUpcomingVersion('final TermsChangeSchedule? upcomingTermsChange = null;'), isNull); + expect( + backendUpcomingVersion("final TermsChangeSchedule? upcomingTermsChange = TermsChangeSchedule(version: '2026.2', publishedAt: x);"), + '2026.2', + ); + }); + + test('agenda do backend e texto do app andam juntos', () { + final source = File( + '../../backend/sinalacs_server/lib/src/application/patients/terms_change_schedule.dart', + ).readAsStringSync(); + final anunciada = backendUpcomingVersion(source); + expect( + upcomingLegalDocuments?.version, + anunciada, + reason: 'o aviso anunciado no servidor precisa ter o texto novo embarcado no app ' + '(e vice-versa): publique o app antes de publicar o aviso', + ); + }); + + test('o texto novo, quando existe, não pode repetir a versão vigente', () { + final novo = upcomingLegalDocuments; + if (novo != null) { + expect(novo.version, isNot(legalDocumentsVersion)); + expect(novo.privacy.version, novo.version); + expect(novo.terms.version, novo.version); + } + }); +} +``` + +Em `test/terms_change_notice_test.dart` acrescentar (o `_textoNovo` é um `UpcomingLegalDocuments` de teste com a versão `'2026.2'`, montado com cópias de `privacyPolicy`/`termsOfUse`; o `SinalAcsApp` ganha o parâmetro de teste `upcomingDocuments`, `UpcomingLegalDocuments?`, que tem precedência sobre a constante): + +```dart +testWidgets('com o texto novo embarcado na mesma versão do aviso, oferece "Ler o texto novo"', (tester) async { + final backend = FakePatientBackend()..termsNotice = _aviso; // version 2026.2 + await tester.pumpWidget(SinalAcsApp(backend: backend, upcomingDocuments: _textoNovo)); + await login(tester); + + await tester.tap(find.byKey(const Key('terms_change_notice_read_new'))); + await tester.pumpAndSettle(); + + expect(find.text('Termos que passam a valer'), findsOneWidget); + expect(find.textContaining('Versão 2026.2'), findsWidgets); +}); + +testWidgets('aviso de versão que o app não conhece: só o texto vigente, sem erro', (tester) async { + final backend = FakePatientBackend()..termsNotice = _aviso; + await tester.pumpWidget(SinalAcsApp(backend: backend)); // sem texto novo embarcado + await login(tester); + expect(find.byKey(const Key('terms_change_notice_read_new')), findsNothing); + expect(find.byKey(const Key('terms_change_notice_read')), findsOneWidget); +}); + +testWidgets('texto novo de OUTRA versão que a do aviso não é oferecido', (tester) async { + final backend = FakePatientBackend()..termsNotice = _aviso; // 2026.2 + await tester.pumpWidget(SinalAcsApp(backend: backend, upcomingDocuments: _textoNovoDe('2026.3'))); + await login(tester); + expect(find.byKey(const Key('terms_change_notice_read_new')), findsNothing); +}); + +testWidgets('o título do cartão é um cabeçalho semântico', (tester) async { + final handle = tester.ensureSemantics(); + final backend = FakePatientBackend()..termsNotice = _aviso; + await tester.pumpWidget(SinalAcsApp(backend: backend)); + await login(tester); + expect( + tester.getSemantics(find.text('Os termos vão mudar')), + matchesSemantics(label: 'Os termos vão mudar', isHeader: true), + ); + handle.dispose(); +}); +``` + +Em `test/contrast_tokens_test.dart`, acrescentar à matriz os três pares que o cartão usa e que o tema gera a partir do seed (o `Card` renderiza sobre `PatientColors.surfaceRaised`): + +```dart +final scheme = buildPatientTheme().colorScheme; +// dentro da lista `cases`: +('texto do cartão (onSurface) sobre card', scheme.onSurface, PatientColors.surfaceRaised, normalText), +('TextButton do cartão (primary) sobre card', scheme.primary, PatientColors.surfaceRaised, normalText), +('ícone de fechar do cartão (onSurfaceVariant) sobre card', scheme.onSurfaceVariant, PatientColors.surfaceRaised, largeTextOrUi), +``` + +(`cases` é `const`; troque para `final` para aceitar valores do tema.) + +Run: `cd apps/patient && flutter test test/upcoming_legal_documents_test.dart test/terms_change_notice_test.dart test/contrast_tokens_test.dart` +Expected: FAIL (compilação: `upcomingLegalDocuments`, `upcomingDocuments`, `terms_change_notice_read_new`; depois o cabeçalho semântico). Os três pares de contraste podem passar de primeira (caracterização): se algum falhar, a mensagem traz a razão medida e o passo 2 corrige a cor do cartão. + +- [ ] **Step 2: Implementar** + +`legal_documents.dart`: acrescentar `UpcomingLegalDocuments` e `const UpcomingLegalDocuments? upcomingLegalDocuments = null;`, com este comentário: "Texto da versão que **ainda não vale** e já foi anunciada (LGPD-RF18, 15 dias). O app embarca o texto antes de o servidor publicar o aviso: publique uma versão do app com este valor, só depois troque `upcomingTermsChange` no backend, e na vigência mude `legalDocumentsVersion` e `consentPolicyVersion` e mova o texto para `privacyPolicy`/`termsOfUse`. O teste `upcoming_legal_documents_test.dart` falha se as duas pontas divergirem." + +`legal_screens.dart`: `LegalDocumentsScreen({super.key, this.upcoming})`; a lista e o subtítulo usam `upcoming?.privacy`/`upcoming?.terms` quando presente (subtítulo "Versão X · passa a valer em " é responsabilidade do chamador: passe a data por um parâmetro `String? effectiveLabel`); o título do `AppBar` é `upcoming == null ? 'Privacidade e termos' : 'Termos que passam a valer'`. + +`terms_change_notice_card.dart`: parâmetro `VoidCallback? onReadNew`; quando não nulo, um `FilledButton.tonal` com a chave `terms_change_notice_read_new` e o rótulo "Ler o texto novo" antes do "Ler os termos atuais". O título vira `Semantics(header: true, child: Text('Os termos vão mudar', ...))`. Se algum par de contraste falhou no passo 1, defina explicitamente a cor do `TextButton`/ícone com `PatientColors.accentOnSurface`/`Colors.white70` e repita a medição. + +`app.dart`: `SinalAcsApp` ganha `final UpcomingLegalDocuments? upcomingDocuments;` (documentar: "injetável para teste; o padrão é `upcomingLegalDocuments`"). O shell lê a fonte do texto novo por um `InheritedWidget` mínimo `UpcomingDocumentsScope` (mesmo padrão de `QrScannerScope`), e passa `onReadNew` ao cartão **só se** `docs != null && docs.version == notice.version`, abrindo `LegalDocumentsScreen(upcoming: docs, effectiveLabel: ...)`. + +- [ ] **Step 3: Rodar e ver passar** + +Run: `cd apps/patient && flutter test test/upcoming_legal_documents_test.dart test/terms_change_notice_test.dart test/contrast_tokens_test.dart test/legal_documents_test.dart test/legal_screens_test.dart` +Expected: PASS. + +- [ ] **Step 4: Mutação, suíte e commit** + +Prove que a amarração pega o defeito: troque temporariamente `upcomingTermsChange = null` por uma agenda com `version: '2026.2'` no backend (sem tocar no app) e confirme que `agenda do backend e texto do app andam juntos` falha; restaure. + +Run: `cd apps/patient && flutter test && flutter analyze; cd ../acs && flutter test` +Expected: tudo verde, analyze limpo, ACS 178. + +```bash +git add apps/patient +git commit -m "feat(paciente): texto novo dos termos durante os 15 dias, contraste e semântica do cartão (LGPD-RF18)" +``` + +--- + +### Task 4: Documentação, memória e verificação final + +**Files:** +- Modify: `PROGRESS.md` (nova seção; riscar os minors resolvidos), `apps/CLAUDE.md` (texto novo e `UpcomingDocumentsScope`), `backend/CLAUDE.md` (procedimento de agendar agora inclui embarcar o texto no app antes; poda por titular; cliente do Gorush encerrável), `spec/lgpd_design.md` (a comunicação dos 15 dias oferece o texto novo), `spec/lgpd_data_audit.md` (`push_token` com `result = lost` para o dono anterior; `community_notice` com `not_delivered`), memória (atualizar `push-minors-and-terms-notice-2026-09-29` ou criar uma nova) e `MEMORY.md`. + +- [ ] **Step 1:** Editar os docs. No `backend/CLAUDE.md`, o passo a passo de agendar uma mudança passa a ser: (1) escrever o texto novo em `upcomingLegalDocuments` no app e publicar essa versão do app; (2) só então trocar `upcomingTermsChange` no backend (a regra dos 15 dias compara datas declaradas); (3) na vigência, mudar `consentPolicyVersion` e `legalDocumentsVersion`, mover o texto para `privacyPolicy`/`termsOfUse` e voltar as duas constantes para `null`. Registrar como **limite**: um app antigo, sem o texto embarcado, vê só "Ler os termos atuais" durante os 15 dias. + +- [ ] **Step 2: Verificação completa** + +Run: `cd backend/sinalacs_server && for i in 1 2 3 4 5; do dart test 2>&1 | tail -1; done; dart analyze | tail -1; cd ../../apps/patient && flutter test && flutter analyze; cd ../acs && flutter test && flutter analyze; cd ../.. && docker compose config -q && ./scripts/qa/ci_invariants.sh; graphify update .` +Expected: backend verde 5 de 5, analyze em 51 infos e zero avisos, paciente e ACS verdes, `ci_invariants` ok. + +- [ ] **Step 3: Commit** + +```bash +git add PROGRESS.md apps/CLAUDE.md backend/CLAUDE.md spec CLAUDE.md +git commit -m "docs: registra os menores do RF14 fechados e o texto novo dos termos" +``` + +--- + +## Fora desta rodada (por decisão) + +- Agendar uma mudança real dos termos (`upcomingTermsChange` e `upcomingLegalDocuments` ficam `null`), revisão jurídica do texto, lado nativo do token de push, credenciais FCM/APNs e Gorush real. +- Mostrar o texto novo a app antigo (sem o texto embarcado): só vê o vigente. +- Marcar dentro do texto novo o que mudou em relação ao vigente (diff), e avisar quem já está com o app aberto. + +## Autorrevisão + +- **Cobertura:** minors do RF14 → Tasks 1 e 2 (poda por titular, desempate da poda e dos consentimentos, rastro do dono anterior, injeção de "sem agenda", `HttpClient` encerrável, JSON de forma inesperada e corpo ilegível, falha na poda depois do envio, auditoria com 0 aceitos, `LEFT JOIN`, nome do teste de auditoria, cleanup do grupo de corrida); contraste e `Semantics(header)` → Task 3; texto novo → Task 3; docs → Task 4. +- **Placeholders:** os passos 4 de Tasks 1 e 2 deixam dois testes como esqueleto comentado (troca de dono devolve o dono anterior; desempate por id) com instrução explícita do que semear e afirmar; os nomes locais a confirmar (`grep` do `orderBy` do store de consentimento, campos de `AuditEvent`) trazem o comando. +- **Tipos:** `PushRegistrationResult` (Task 1) é o único retorno de `registerIfConsented` em serviço, store e testes; `termsChangeReader` substitui `termsChange` em todos os usos; `UpcomingLegalDocuments`/`upcomingLegalDocuments`/`upcomingDocuments`/`UpcomingDocumentsScope` (Task 3) usam o mesmo nome em app, cartão, tela e testes. +- **Riscos declarados:** o teste do desempate por id depende da ordem de heap do Postgres para ser vermelho antes da correção (documentado no passo); o par de contraste do `TextButton` vem do tema gerado por seed e pode exigir cor explícita; sem `upcomingTermsChange` real, a amarração só é provada por mutação temporária. diff --git a/docs/superpowers/plans/2026-09-30-teste-completo-paciente-banco-de-teste.md b/docs/superpowers/plans/2026-09-30-teste-completo-paciente-banco-de-teste.md new file mode 100644 index 00000000..11ae4394 --- /dev/null +++ b/docs/superpowers/plans/2026-09-30-teste-completo-paciente-banco-de-teste.md @@ -0,0 +1,982 @@ +# Teste completo do app do paciente contra um banco de teste — Plano de Implementação + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rodar no `emulator-5554` uma jornada completa do app do paciente (login OTP real pela tela → triagem → alerta de urgência → status → "Meus Dados" → consentimento de avisos → push do ACS chegando pelo Gorush e FCM) contra uma stack Docker cujo banco é o **banco de teste** (`postgres-test`), com fixtures geradas por execução, sem nenhum UUID, CPF ou login de desenvolvimento fixos. + +**Architecture:** Um override do Compose (`docker-compose.e2e.yml`) aponta `serverpod`, os seeds e o Gorush para o banco `sinalacs_e2e` dentro do serviço `postgres-test` (efêmero, sem volume) e desliga `ENABLE_DEV_LOGIN`. Um seeder Dart (`bin/seed_e2e_fixtures.dart`) cria UBS, microárea, ACS com credencial e pacientes com **UUIDs aleatórios e CPFs sintéticos de dígito verificador válido**, e grava um manifesto JSON gitignorado. Os testes de `integration_test/` leem o manifesto por `--dart-define-from-file`; o código OTP (que o gateway `log` só escreve no log do servidor) chega ao emulador por um relé HTTP local. O login de desenvolvimento continua existindo só como fallback da CI (`smoke_test.dart`). + +**Tech Stack:** Docker Compose, Postgres 15, Serverpod 3.4.13 (Dart), Flutter 3 (`integration_test`), bash, `adb`, Gorush 1.22.0 + FCM. + +**Spec:** `spec/PRD_system.md` (RF01, RF03–RF05, RF14), `spec/lgpd_design.md` (nada de dado real em seed/teste/log; consentimento por finalidade), `backend/CLAUDE.md` (seeds, `postgres-test`), `apps/CLAUDE.md` (validação no emulador). + +## Inventário dos dados fixos que este plano substitui + +| Onde | Dado fixo | Substituído por | +|---|---|---| +| `backend/.../seeds/development.sql` | UUIDs `…0001`–`…0009`, nomes "Fulano de Tal" etc., `cpfHash` literal | Fixtures geradas por execução (Task 2); `development.sql` segue existindo só para a stack de desenvolvimento | +| `backend/.../bin/seed_cpf_hashes.dart` | CPFs `12345678909`, `98765432100`… amarrados aos UUIDs acima | CPFs gerados com DV válido, únicos por execução | +| `lib/src/endpoints/auth_endpoint.dart:24-36` | `_patient`/`_acs` com UUIDs e `deviceId` fixos (`developmentLogin`) | Login real: OTP (paciente) e matrícula+senha (ACS), com `ENABLE_DEV_LOGIN=false` na stack de e2e | +| `apps/patient/integration_test/{smoke,backend_connection,push_register}_test.dart`, `tool/{live_check,push_consent}.dart` | `developmentLogin(role: 'patient')`; constante `seedMicroAreaId = '0000…0003'` | Helper `loginPatient()` (fixtures + OTP quando há manifesto; fallback ao login de desenvolvimento sem manifesto, para a CI) e microárea lida da sessão | +| `apps/acs/tool/send_notice.dart` | `developmentLogin(role: 'acs')` | `loginInstitutional` com matrícula/senha do manifesto | +| `scripts/qa/push_e2e.sh` | `docker exec sinalacs-postgres … sinalacs_db`, `delete from push_tokens` no banco de desenvolvimento | Mesmo script apontando para o banco de e2e (não apaga nada do banco de desenvolvimento) | +| `apps/patient/test/**` (widget tests) | `'Paciente de Teste'`, `'Fulano de Tal'`, CPF `123.456.789-09`, `FakePatientBackend` | **Não muda, de propósito**: são herméticos (sem rede nem banco) e é isso que os faz rápidos e estáveis; a cobertura com banco real vive na jornada nova | + +## Estado verificado em 2026-09-30 + +- `emulator-5554` (AVD `Medium_Phone`) e a stack de desenvolvimento estão de pé (`docker compose ps`); `postgres-test` está no ar (`--profile test`, porta host 9090, banco `sinalacs_test`, **sem volume**). +- `adb` não está no `PATH`: `export PATH="$HOME/Android/Sdk/platform-tools:$PATH"`. `serverpod`: `export PATH="$HOME/.pub-cache/bin:$PATH"`. +- O gateway de SMS `log` escreve `[SMS-GATEWAY=log] código de acesso: NNNNNN` no log do container `sinalacs-serverpod`. Regras do OTP: 6 dígitos, TTL 5 min, teto de 5 verificações, **60 s entre pedidos por paciente**. +- Credenciais do FCM em `infra/docker/gorush/credentials/` (o `push_e2e.sh` valida) e `apps/patient/android/app/google-services.json`. +- Suítes atuais: paciente 233 (widget), backend 433. + +## Global Constraints + +- Nenhum dado real de paciente em seed, teste, log ou captura; CPFs só sintéticos com DV válido, nunca impressos em log nem em mensagem de erro. +- O seeder recusa rodar se `APP_ENV != development` **ou** se o banco de destino não for `sinalacs_e2e` (nunca toca o banco de desenvolvimento nem `sinalacs_test`, que é do `dart test`). +- Alerta vermelho nunca é descartado nem atrasado; triagem determinística vem só de `triage.evaluate`. +- Textos, comentários e commits em português; commits terminam com `Co-Authored-By: Claude Sonnet 5.5 `. Sem push nem merge. +- `flutter analyze` sem problemas e `dart analyze` do backend no baseline de 51 infos antes de cada commit; o `ci_invariants.sh` continua verde (ele vigia `docker-compose.yml` e o workflow, não arquivos novos). +- O manifesto `.e2e/fixtures.json` contém segredos de teste (senha do ACS): gitignorado, `chmod 600`. + +## Review Focus + +- Paciente com OTP vencido ou digitado errado: a tela mostra o erro (`login_error`) e **não** entra; o teste de jornada cobre o código errado antes do certo. +- Segundo pedido de OTP dentro de 60 s: o relé devolve o código do **último** pedido, não o antigo; o teste pede, espera e compara. +- Paciente de outra microárea (fixture `outsider`): o ACS da microárea principal não o enxerga em `patients.listMicroArea` — prova de território com o banco real. +- Stack de e2e com `ENABLE_DEV_LOGIN=false`: `developmentLogin` falha como "não encontrado"; o teste garante que nada no fluxo depende dele. +- Rodar duas vezes seguidas: a segunda execução não colide (UUIDs/CPFs novos, banco recriado) e não deixa lixo no banco de desenvolvimento. + +--- + +### Task 1: Banco de teste para a stack de e2e (Compose override) + +**Files:** +- Create: `docker-compose.e2e.yml` +- Create: `scripts/qa/e2e_stack.sh` +- Modify: `.gitignore` (acrescentar `.e2e/`) + +**Interfaces:** +- Produces: `./scripts/qa/e2e_stack.sh up|down|psql ""`. `up` cria o banco `sinalacs_e2e` no `postgres-test`, sobe `serverpod` + `gorush` + dependências apontando para ele, com `ENABLE_DEV_LOGIN=false` e `SMS_GATEWAY=log`, e espera saudável. `psql` roda SQL nele (usado pelos scripts e testes do host). Tasks 2–6 dependem disso. + +- [ ] **Step 1: Ler as peças que o override toca** + +Ler `docker-compose.yml` inteiro (serviços `serverpod`, `database-seed`, `health-data-seed`, `acs-credential-seed`, `cpf-hash-seed`, `gorush`, `traefik`) e anotar os nomes das variáveis `SERVERPOD_DATABASE_*`. O override **só** redefine essas variáveis e `ENABLE_DEV_LOGIN`; não copia o resto. + +- [ ] **Step 2: Escrever o override** + +`docker-compose.e2e.yml`: + +```yaml +# Stack de e2e: o mesmo backend, mas com o banco de TESTE (postgres-test, +# efêmero, sem volume) e sem login de desenvolvimento. Uso: +# ./scripts/qa/e2e_stack.sh up +# O banco `sinalacs_e2e` é separado de `sinalacs_test` (do `dart test`) para que +# a suíte do backend e a jornada no emulador não pisem uma na outra. +services: + serverpod: + depends_on: + postgres-test: + condition: service_healthy + environment: + SERVERPOD_DATABASE_HOST: postgres-test + SERVERPOD_DATABASE_NAME: sinalacs_e2e + SERVERPOD_DATABASE_USER: postgres + SERVERPOD_DATABASE_PASSWORD: ${TEST_DATABASE_PASSWORD:?defina em .env — rode ./scripts/dev/bootstrap_env.sh} + ENABLE_DEV_LOGIN: "false" + SMS_GATEWAY: log + GORUSH_URL: http://gorush:8088 +``` + +O `postgres-test` não tem `container_name` no plano de rede do serviço `serverpod`? Ele tem (`sinalacs-postgres-test`) e está na rede default do projeto: o host `postgres-test` resolve. Conferir com `docker compose --profile test --profile push -f docker-compose.yml -f docker-compose.e2e.yml config | grep -A12 "serverpod:"`. + +- [ ] **Step 3: Escrever o script de stack** + +`scripts/qa/e2e_stack.sh` (`chmod +x`): + +```bash +#!/usr/bin/env bash +# Sobe/derruba a stack de e2e contra o banco de teste. Ver docker-compose.e2e.yml. +set -euo pipefail +cd "$(dirname "$0")/../.." +[[ -f .env ]] || { echo 'erro: rode ./scripts/dev/bootstrap_env.sh'; exit 1; } +set -a; source .env; set +a +unset GORUSH_CREDENTIALS_DIR # o Compose o prioriza sobre o .env (ver PROGRESS.md) +export GORUSH_CREDENTIALS_DIR="$PWD/infra/docker/gorush/credentials" +dc() { docker compose --profile test --profile push -f docker-compose.yml -f docker-compose.e2e.yml "$@"; } +db=sinalacs_e2e +pg() { docker exec -e PGPASSWORD="$TEST_DATABASE_PASSWORD" sinalacs-postgres-test psql -U postgres -d "${2:-$db}" -Atc "$1"; } + +case "${1:-}" in + up) + dc up -d postgres-test >/dev/null + until docker exec sinalacs-postgres-test pg_isready -U postgres -d sinalacs_test >/dev/null 2>&1; do sleep 1; done + # Recria o banco: cada execução parte de um banco vazio. + pg "drop database if exists $db with (force)" postgres >/dev/null + pg "create database $db" postgres >/dev/null + # Só o backend e o relé; os seeds de desenvolvimento NÃO sobem (as fixtures vêm da Task 2). + dc up -d --build --no-deps serverpod gorush traefik mosquitto + for _ in $(seq 1 90); do + [[ "$(docker inspect -f '{{.State.Health.Status}}' sinalacs-serverpod 2>/dev/null)" == healthy ]] && exit 0 + sleep 2 + done + echo 'erro: serverpod não ficou saudável'; dc logs --tail 40 serverpod; exit 1 ;; + down) dc stop serverpod gorush >/dev/null; pg "drop database if exists $db with (force)" postgres >/dev/null ;; + psql) pg "$2" ;; + *) echo 'uso: e2e_stack.sh up|down|psql ""'; exit 2 ;; +esac +``` + +- [ ] **Step 4: Verificar** + +Run: `./scripts/qa/e2e_stack.sh up && ./scripts/qa/e2e_stack.sh psql "select count(*) from users" && curl -sk https://localhost/ -o /dev/null -w '%{http_code}\n'` +Expected: `up` sai com 0; `count` = `0` (migrações aplicadas pelo Serverpod, nenhum seed); HTTP 200/404 do Traefik (não erro de conexão). Conferir também que o banco de desenvolvimento não mudou: `docker exec sinalacs-postgres psql -U sinalacs_user -d sinalacs_db -Atc "select count(*) from push_tokens"` igual ao de antes. + +- [ ] **Step 5: Commit** + +```bash +git add docker-compose.e2e.yml scripts/qa/e2e_stack.sh .gitignore +git commit -m "test(e2e): stack de e2e com o banco de teste e sem login de desenvolvimento + +Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Task 2: Fixtures geradas por execução (seeder Dart + manifesto) + +**Files:** +- Create: `backend/sinalacs_server/lib/src/infrastructure/testing/e2e_fixtures.dart` (geração pura: CPF válido, UUID v4, manifesto) +- Create: `backend/sinalacs_server/bin/seed_e2e_fixtures.dart` (grava no banco e escreve `.e2e/fixtures.json`) +- Test: `backend/sinalacs_server/test/unit/e2e_fixtures_test.dart` +- Modify: `scripts/qa/e2e_stack.sh` (comando `seed`) + +**Interfaces:** +- Consumes: `Cpf` (`lib/src/application/auth/cpf.dart`, valida DV), `HmacCpfHasher(pepper:)`, `Argon2PasswordHasher` (ver `bin/seed_acs_credentials.dart` para a chamada e as colunas de `user_credentials`), `HealthDataCipher` (ver `bin/seed_health_data.dart` para cifrar `chronicConditions`). +- Produces: `E2eFixtures generateE2eFixtures(Random random)` com os campos `ubsId`, `microAreaId`, `otherMicroAreaId`, `acs` (`id`, `matricula`, `password`), `patients` (lista de `E2ePatient{id, name, cpf, birthDate, chronic, microAreaId}`; nomes: `main`, `chronic`, `outsider` em `role` de fixture). `toJson()`/`fromJson()`. Arquivo `.e2e/fixtures.json` (modo 600). + +- [ ] **Step 1: Escrever os testes que falham** + +`test/unit/e2e_fixtures_test.dart`: + +```dart +import 'dart:math'; + +import 'package:sinalacs_server/src/application/auth/cpf.dart'; +import 'package:sinalacs_server/src/infrastructure/testing/e2e_fixtures.dart'; +import 'package:test/test.dart'; + +void main() { + test('todo CPF gerado tem dígito verificador válido (mil amostras)', () { + final random = Random(1); + for (var i = 0; i < 1000; i++) { + expect(() => Cpf.parse(generateValidCpfDigits(random)), returnsNormally); + } + }); + + test('duas execuções não compartilham UUID nem CPF', () { + final a = generateE2eFixtures(Random(1)); + final b = generateE2eFixtures(Random(2)); + final idsA = {a.microAreaId, a.acs.id, ...a.patients.map((p) => p.id)}; + final idsB = {b.microAreaId, b.acs.id, ...b.patients.map((p) => p.id)}; + expect(idsA.intersection(idsB), isEmpty); + expect(a.patients.map((p) => p.cpf).toSet().intersection(b.patients.map((p) => p.cpf).toSet()), isEmpty); + }); + + test('dentro de uma execução tudo é único e o forasteiro está em outra microárea', () { + final f = generateE2eFixtures(Random(7)); + expect(f.patients.map((p) => p.cpf).toSet(), hasLength(f.patients.length)); + expect(f.patients.map((p) => p.id).toSet(), hasLength(f.patients.length)); + final outsider = f.patients.singleWhere((p) => p.role == 'outsider'); + expect(outsider.microAreaId, f.otherMicroAreaId); + expect(f.patients.where((p) => p.role != 'outsider').every((p) => p.microAreaId == f.microAreaId), isTrue); + }); + + test('nenhum UUID é do formato fixo do seed de desenvolvimento', () { + final f = generateE2eFixtures(Random(3)); + for (final id in [f.microAreaId, f.acs.id, ...f.patients.map((p) => p.id)]) { + expect(id.startsWith('00000000-0000-4000-8000'), isFalse); + expect(RegExp(r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$').hasMatch(id), isTrue); + } + }); + + test('o manifesto ida e volta é idêntico', () { + final f = generateE2eFixtures(Random(9)); + expect(E2eFixtures.fromJson(f.toJson()).toJson(), f.toJson()); + }); + + test('toString e mensagens de erro não vazam CPF nem senha', () { + final f = generateE2eFixtures(Random(4)); + final texto = f.toString(); + for (final p in f.patients) { + expect(texto.contains(p.cpf), isFalse); + } + expect(texto.contains(f.acs.password), isFalse); + }); +} +``` + +- [ ] **Step 2: Rodar e ver falhar** + +Run: `cd backend/sinalacs_server && dart test test/unit/e2e_fixtures_test.dart` +Expected: FAIL (arquivo/símbolos inexistentes). + +- [ ] **Step 3: Implementar a geração pura** + +`lib/src/infrastructure/testing/e2e_fixtures.dart`: + +```dart +import 'dart:math'; + +/// Dados SINTÉTICOS de uma execução de e2e. Nada aqui é estável entre +/// execuções: UUIDs e CPFs mudam a cada chamada (quem precisa deles lê o +/// manifesto). Nunca um dado real (LGPD). +class E2ePatient { + const E2ePatient({ + required this.id, + required this.role, + required this.name, + required this.cpf, + required this.birthDate, + required this.chronic, + required this.microAreaId, + }); + + final String id; + final String role; // main | chronic | outsider + final String name; + final String cpf; // só dígitos; nunca em log + final String birthDate; // AAAA-MM-DD + final bool chronic; + final String microAreaId; + + Map toJson() => { + 'id': id, 'role': role, 'name': name, 'cpf': cpf, + 'birthDate': birthDate, 'chronic': chronic, 'microAreaId': microAreaId, + }; + + factory E2ePatient.fromJson(Map j) => E2ePatient( + id: j['id']! as String, role: j['role']! as String, name: j['name']! as String, + cpf: j['cpf']! as String, birthDate: j['birthDate']! as String, + chronic: j['chronic']! as bool, microAreaId: j['microAreaId']! as String, + ); +} + +class E2eAcs { + const E2eAcs({required this.id, required this.matricula, required this.password}); + + final String id; + final String matricula; + final String password; + + Map toJson() => {'id': id, 'matricula': matricula, 'password': password}; + + factory E2eAcs.fromJson(Map j) => + E2eAcs(id: j['id']! as String, matricula: j['matricula']! as String, password: j['password']! as String); +} + +class E2eFixtures { + const E2eFixtures({ + required this.ubsId, + required this.microAreaId, + required this.otherMicroAreaId, + required this.acs, + required this.patients, + }); + + final String ubsId; + final String microAreaId; + final String otherMicroAreaId; + final E2eAcs acs; + final List patients; + + E2ePatient byRole(String role) => patients.singleWhere((p) => p.role == role); + + Map toJson() => { + 'ubsId': ubsId, 'microAreaId': microAreaId, 'otherMicroAreaId': otherMicroAreaId, + 'acs': acs.toJson(), 'patients': [for (final p in patients) p.toJson()], + }; + + factory E2eFixtures.fromJson(Map j) => E2eFixtures( + ubsId: j['ubsId']! as String, microAreaId: j['microAreaId']! as String, + otherMicroAreaId: j['otherMicroAreaId']! as String, + acs: E2eAcs.fromJson((j['acs']! as Map).cast()), + patients: [for (final p in (j['patients']! as List)) E2ePatient.fromJson((p as Map).cast())], + ); + + @override + String toString() => 'E2eFixtures(${patients.length} pacientes, acs ${acs.id.substring(0, 8)}…)'; +} + +String generateUuidV4(Random random) { + String hex(int n) => List.generate(n, (_) => random.nextInt(16).toRadixString(16)).join(); + final variant = '89ab'[random.nextInt(4)]; + return '${hex(8)}-${hex(4)}-4${hex(3)}-$variant${hex(3)}-${hex(12)}'; +} + +/// 11 dígitos com os dois dígitos verificadores corretos e sem todos iguais. +String generateValidCpfDigits(Random random) { + while (true) { + final d = List.generate(9, (_) => random.nextInt(10)); + if (d.toSet().length == 1) continue; + int dv(List base) { + var sum = 0; + for (var i = 0; i < base.length; i++) { + sum += base[i] * (base.length + 1 - i); + } + final r = (sum * 10) % 11; + return r == 10 ? 0 : r; + } + final d1 = dv(d); + final d2 = dv([...d, d1]); + return [...d, d1, d2].join(); + } +} + +String _birthDate(Random random) { + final year = 1950 + random.nextInt(50); + return '$year-${(1 + random.nextInt(12)).toString().padLeft(2, '0')}-${(1 + random.nextInt(28)).toString().padLeft(2, '0')}'; +} + +String _password(Random random) { + const alphabet = 'abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + return List.generate(20, (_) => alphabet[random.nextInt(alphabet.length)]).join(); +} + +E2eFixtures generateE2eFixtures(Random random) { + final microAreaId = generateUuidV4(random); + final otherMicroAreaId = generateUuidV4(random); + final usedCpfs = {}; + E2ePatient patient(String role, String name, {required bool chronic, required String microAreaId}) { + String cpf; + do { + cpf = generateValidCpfDigits(random); + } while (!usedCpfs.add(cpf)); + return E2ePatient( + id: generateUuidV4(random), role: role, name: name, cpf: cpf, + birthDate: _birthDate(random), chronic: chronic, microAreaId: microAreaId, + ); + } + + return E2eFixtures( + ubsId: generateUuidV4(random), + microAreaId: microAreaId, + otherMicroAreaId: otherMicroAreaId, + acs: E2eAcs(id: generateUuidV4(random), matricula: 'E2E-${1000 + random.nextInt(9000)}', password: _password(random)), + patients: [ + patient('main', 'Paciente E2E Principal', chronic: false, microAreaId: microAreaId), + patient('chronic', 'Paciente E2E Crônico', chronic: true, microAreaId: microAreaId), + patient('outsider', 'Paciente E2E Outra Área', chronic: false, microAreaId: otherMicroAreaId), + ], + ); +} +``` + +- [ ] **Step 4: Rodar e ver passar** + +Run: `cd backend/sinalacs_server && dart test test/unit/e2e_fixtures_test.dart` +Expected: 6/6 PASS. (Se `Cpf.parse` tiver outro nome, ler `lib/src/application/auth/cpf.dart` e ajustar só o teste.) + +- [ ] **Step 5: O seeder que grava no banco** + +`bin/seed_e2e_fixtures.dart`. Estrutura (seguir `bin/seed_acs_credentials.dart` para a conexão `Connection.open(Endpoint(...))`, o hasher Argon2 e o `INSERT INTO "user_credentials"`; `bin/seed_cpf_hashes.dart` para `HmacCpfHasher(pepper: env['CPF_HASH_PEPPER'])`; `bin/seed_health_data.dart` para cifrar condições crônicas): + +```dart +Future main(List args) async { + final env = Platform.environment; + if ((env['APP_ENV'] ?? 'development') != 'development') { stderr.writeln('recusando: APP_ENV != development'); exit(2); } + if (env['SERVERPOD_DATABASE_NAME'] != 'sinalacs_e2e') { + stderr.writeln('recusando: este seeder só escreve em sinalacs_e2e'); exit(2); + } + final out = File(args.isNotEmpty ? args.first : '.e2e/fixtures.json'); + final f = generateE2eFixtures(Random.secure()); + // conexão como em seed_acs_credentials.dart, depois, numa transação: + // ubs(f.ubsId), micro_areas(f.microAreaId e f.otherMicroAreaId, ambas com ubsId), + // users (paciente: cpfHash = HmacCpfHasher.hash(Cpf.parse(cpf)), name, birthDate, role 'patient', microAreaId), + // users (acs: role 'acs', cpfHash NULL ou literal 'e2e-acs-' — ver o NOT NULL em development.sql; nunca um CPF, pelo aviso de seed_cpf_hashes.dart), + // patients (id, emergencyContact 'Contato E2E', isChronic, chronicConditionsEncrypted via HealthDataCipher, keyVersion), + // acs (id, enrollmentId = f.acs.matricula, ubsId, active true), + // user_credentials (Argon2 de f.acs.password, mesmas colunas de seed_acs_credentials.dart). + // Por fim: out.parent.createSync(recursive: true); out.writeAsStringSync(jsonEncode(f.toJson())); chmod 600. +} +``` + +Escrever o corpo completo copiando os `INSERT` de `seeds/development.sql` (mesmas colunas) com parâmetros nomeados no lugar dos literais. Não deixar nenhum UUID/CPF literal no arquivo. + +- [ ] **Step 6: Comando `seed` no script de stack** + +Acrescentar ao `case` de `e2e_stack.sh`: + +```bash + seed) + ( cd backend/sinalacs_server && \ + SERVERPOD_DATABASE_HOST=localhost SERVERPOD_DATABASE_PORT=9090 \ + SERVERPOD_DATABASE_NAME=$db SERVERPOD_DATABASE_USER=postgres \ + SERVERPOD_DATABASE_PASSWORD="$TEST_DATABASE_PASSWORD" \ + dart run bin/seed_e2e_fixtures.dart ../../.e2e/fixtures.json ) ;; +``` +(`CPF_HASH_PEPPER` e `HEALTH_DATA_ENCRYPTION_KEY` vêm do `.env` já carregado: precisam ser os mesmos do servidor.) + +- [ ] **Step 7: Verificar no banco** + +Run: `./scripts/qa/e2e_stack.sh seed && ./scripts/qa/e2e_stack.sh psql "select role, count(*) from users group by role order by role" && ls -l .e2e/fixtures.json` +Expected: `acs|1`, `patient|3`; arquivo `-rw-------`. `grep -c 00000000-0000-4000 .e2e/fixtures.json` = `0`. + +- [ ] **Step 8: Commit** + +```bash +git add backend/sinalacs_server/lib/src/infrastructure/testing backend/sinalacs_server/bin/seed_e2e_fixtures.dart backend/sinalacs_server/test/unit/e2e_fixtures_test.dart scripts/qa/e2e_stack.sh +git commit -m "test(e2e): fixtures sintéticas geradas por execução e seeder do banco de teste + +Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Task 3: Relé do código OTP para o emulador + +**Files:** +- Create: `scripts/qa/otp_relay.py` +- Test: `scripts/qa/otp_relay_test.py` + +**Interfaces:** +- Produces: servidor HTTP em `127.0.0.1:8765` com `GET /code?since=` → `200` com o código mais recente do log do servidor gerado **depois** de `since`, ou `404` se não houver; o emulador o alcança por `adb reverse tcp:8765 tcp:8765` como `http://localhost:8765/code`. Função pura `parse_latest_code(log_text) -> str | None`. + +- [ ] **Step 1: Teste que falha** + +`scripts/qa/otp_relay_test.py`: + +```python +import unittest +from otp_relay import parse_latest_code + +class T(unittest.TestCase): + def test_sem_linha(self): + self.assertIsNone(parse_latest_code("nada aqui\n")) + def test_pega_a_mais_recente(self): + log = ("[SMS-GATEWAY=log] código de acesso: 111111 (gateway de desenvolvimento — nenhum SMS foi enviado)\n" + "ruído\n" + "[SMS-GATEWAY=log] código de acesso: 222222 (gateway de desenvolvimento — nenhum SMS foi enviado)\n") + self.assertEqual(parse_latest_code(log), "222222") + def test_ignora_seis_digitos_fora_da_linha_do_gateway(self): + self.assertIsNone(parse_latest_code("pedido 123456 recebido\n")) + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Rodar e ver falhar** + +Run: `cd scripts/qa && python3 otp_relay_test.py` +Expected: FAIL (`ModuleNotFoundError: otp_relay`). + +- [ ] **Step 3: Implementar** + +`scripts/qa/otp_relay.py`: + +```python +#!/usr/bin/env python3 +"""Relé do código OTP do gateway `log` para o emulador (só desenvolvimento/e2e). + +O gateway de log escreve o código no stdout do servidor; o emulador não lê +`docker logs`. Este servidor expõe só o código mais recente, sem destino nem CPF +(o log não os tem). Escuta em 127.0.0.1; o emulador chega por `adb reverse`. +""" +import re, subprocess, sys, time +from http.server import BaseHTTPRequestHandler, HTTPServer +from urllib.parse import urlparse, parse_qs + +PADRAO = re.compile(r"\[SMS-GATEWAY=log\] código de acesso: (\d{6}) ") + +def parse_latest_code(log_text): + achados = PADRAO.findall(log_text) + return achados[-1] if achados else None + +class Handler(BaseHTTPRequestHandler): + container = "sinalacs-serverpod" + + def do_GET(self): + url = urlparse(self.path) + if url.path != "/code": + self.send_error(404); return + since_ms = int(parse_qs(url.query).get("since", ["0"])[0]) + desde = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(since_ms / 1000)) + log = subprocess.run(["docker", "logs", "--since", desde, self.container], + capture_output=True, text=True).stdout + code = parse_latest_code(log) + if code is None: + self.send_error(404); return + self.send_response(200); self.send_header("Content-Type", "text/plain"); self.end_headers() + self.wfile.write(code.encode()) + + def log_message(self, *a): # silencioso: o código não vai para o terminal + pass + +if __name__ == "__main__": + HTTPServer(("127.0.0.1", int(sys.argv[1]) if len(sys.argv) > 1 else 8765), Handler).serve_forever() +``` + +- [ ] **Step 4: Rodar e ver passar** + +Run: `cd scripts/qa && python3 otp_relay_test.py` +Expected: 3 testes OK. + +- [ ] **Step 5: Verificação com a stack real** + +```bash +python3 scripts/qa/otp_relay.py & RELAY=$! +# peça um OTP de uma fixture (ver Task 4 para o helper); aqui, com o manifesto: +CPF=$(python3 -c "import json;p=json.load(open('.e2e/fixtures.json'))['patients'][0];print(p['cpf'])") +echo "(o pedido real é feito pelo teste da Task 4; este passo só confirma 404 sem pedido)" +curl -s -o /dev/null -w '%{http_code}\n' "http://127.0.0.1:8765/code?since=$(( $(date +%s) * 1000 ))"; kill $RELAY +``` +Expected: `404` (nenhum código depois de agora). + +- [ ] **Step 6: Commit** + +```bash +git add scripts/qa/otp_relay.py scripts/qa/otp_relay_test.py +git commit -m "test(e2e): relé do código OTP do gateway de log para o emulador + +Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Task 4: Login do paciente pelas fixtures nos testes de integração (sem login de desenvolvimento) + +**Files:** +- Create: `apps/patient/integration_test/support/e2e_login.dart` +- Test: `apps/patient/test/e2e_login_test.dart` (parte pura: leitura do manifesto e fallback) +- Modify: `apps/patient/integration_test/{smoke_test,backend_connection_test,push_register_test}.dart` (trocar `developmentLogin` e a constante `seedMicroAreaId`) +- Modify: `apps/patient/tool/{live_check,push_consent}.dart` +- Modify: `scripts/qa/e2e.sh` (nenhuma mudança de comportamento: sem manifesto continua no fallback) + +**Interfaces:** +- Produces: `class E2eLogin { static E2eConfig? fromDefines(); }`, `Future loginPatient(BackendClient backend, {String role = 'main'})`. Regras: com `--dart-define-from-file=.e2e/fixtures.json` **e** `--dart-define=OTP_RELAY=http://localhost:8765/code`, faz `requestOtp` + relé + `verifyOtp` do paciente da fixture `role`; sem manifesto, cai em `developmentLogin(role: 'patient')` (CI). `String microAreaOf(AuthSession s)` devolve `s.microAreaId` (sem constante). +- Consumes: `BackendClient.requestOtp({cpf, birthDate})`/`verifyOtp({cpf, code})` (`lib/core/network/backend_client.dart:63-74`); manifesto da Task 2; relé da Task 3. + +- [ ] **Step 1: Teste puro que falha** + +`apps/patient/test/e2e_login_test.dart` (o helper expõe `E2eConfig.fromMap`, testável sem emulador; `integration_test/support/` não é importável de `test/`, então o trecho puro mora em `lib/core/testing/e2e_config.dart` — **fora de `lib/` de produção não**: manter em `test/support/e2e_config.dart` e importar das duas suítes por caminho relativo `../../test/support/e2e_config.dart`): + +```dart +import 'package:flutter_test/flutter_test.dart'; + +import 'support/e2e_config.dart'; + +void main() { + test('sem manifesto, não há configuração e vale o fallback', () { + expect(E2eConfig.fromMap(const {}), isNull); + }); + + test('o manifesto escolhe o paciente pelo papel', () { + final config = E2eConfig.fromMap({ + 'patients': [ + {'role': 'main', 'cpf': '52998224725', 'birthDate': '1990-01-01', 'id': 'a', 'name': 'x', 'chronic': false, 'microAreaId': 'm'}, + {'role': 'outsider', 'cpf': '11144477735', 'birthDate': '1970-02-02', 'id': 'b', 'name': 'y', 'chronic': false, 'microAreaId': 'n'}, + ], + 'acs': {'id': 'c', 'matricula': 'E2E-1', 'password': 'p'}, + 'microAreaId': 'm', + })!; + expect(config.patient('outsider').birthDate, DateTime(1970, 2, 2)); + expect(() => config.patient('inexistente'), throwsStateError); + }); + + test('toString nunca mostra o CPF', () { + final config = E2eConfig.fromMap({ + 'patients': [{'role': 'main', 'cpf': '52998224725', 'birthDate': '1990-01-01', 'id': 'a', 'name': 'x', 'chronic': false, 'microAreaId': 'm'}], + 'acs': {'id': 'c', 'matricula': 'E2E-1', 'password': 'p'}, + 'microAreaId': 'm', + })!; + expect(config.toString().contains('52998224725'), isFalse); + }); +} +``` + +- [ ] **Step 2: Rodar e ver falhar** + +Run: `cd apps/patient && flutter test test/e2e_login_test.dart` +Expected: FAIL (`e2e_config.dart` não existe). + +- [ ] **Step 3: Implementar `test/support/e2e_config.dart` e `integration_test/support/e2e_login.dart`** + +`test/support/e2e_config.dart`: + +```dart +/// Manifesto das fixtures de e2e (gerado por `bin/seed_e2e_fixtures.dart`). +/// Chega como `--dart-define-from-file=.e2e/fixtures.json`: cada chave do JSON +/// vira um define. Não importar em código de produção. +class E2ePatientFixture { + const E2ePatientFixture({required this.role, required this.cpf, required this.birthDate, required this.microAreaId}); + final String role; + final String cpf; + final DateTime birthDate; + final String microAreaId; + @override + String toString() => 'E2ePatientFixture($role)'; // sem CPF +} + +class E2eConfig { + const E2eConfig({required this.patients, required this.microAreaId, required this.acsMatricula, required this.acsPassword}); + final List patients; + final String microAreaId; + final String acsMatricula; + final String acsPassword; + + E2ePatientFixture patient(String role) => + patients.firstWhere((p) => p.role == role, orElse: () => throw StateError('sem paciente "$role" no manifesto')); + + static E2eConfig? fromMap(Map map) { + final list = map['patients']; + if (list is! List) return null; + final acs = (map['acs']! as Map).cast(); + return E2eConfig( + patients: [ + for (final raw in list.cast()) + E2ePatientFixture( + role: raw['role'] as String, cpf: raw['cpf'] as String, + birthDate: DateTime.parse(raw['birthDate'] as String), microAreaId: raw['microAreaId'] as String, + ), + ], + microAreaId: map['microAreaId']! as String, + acsMatricula: acs['matricula']! as String, + acsPassword: acs['password']! as String, + ); + } + + @override + String toString() => 'E2eConfig(${patients.length} pacientes)'; +} +``` + +`integration_test/support/e2e_login.dart`: `const _patients = String.fromEnvironment('patients')` **não** funciona (define de lista vira texto). Por isso o manifesto entra como **um** define JSON: o script passa `--dart-define=E2E_FIXTURES="$(cat .e2e/fixtures.json)"` e o helper faz `jsonDecode(const String.fromEnvironment('E2E_FIXTURES'))`. Corrigir o texto do docstring de `e2e_config.dart` para isso. + +```dart +import 'dart:convert'; +import 'dart:io'; + +import 'package:sinalacs_patient/core/network/auth_session.dart'; +import 'package:sinalacs_patient/core/network/backend_client.dart'; + +import '../../test/support/e2e_config.dart'; + +const _fixtures = String.fromEnvironment('E2E_FIXTURES'); +const _otpRelay = String.fromEnvironment('OTP_RELAY', defaultValue: 'http://localhost:8765/code'); + +E2eConfig? e2eConfig() => + _fixtures.isEmpty ? null : E2eConfig.fromMap((jsonDecode(_fixtures) as Map).cast()); + +/// Entra como o paciente [role] da fixture, pelo OTP real; sem manifesto (CI), +/// usa o login de desenvolvimento, como antes. +Future loginPatient(BackendClient backend, {String role = 'main'}) async { + final config = e2eConfig(); + if (config == null) return backend.developmentLogin(role: 'patient'); + final fixture = config.patient(role); + final pedidoEm = DateTime.now().toUtc().millisecondsSinceEpoch; + await backend.requestOtp(cpf: fixture.cpf, birthDate: fixture.birthDate); + final code = await _codeFromRelay(pedidoEm); + return backend.verifyOtp(cpf: fixture.cpf, code: code); +} + +Future _codeFromRelay(int since) async { + final client = HttpClient(); + try { + for (var i = 0; i < 20; i++) { + final request = await client.getUrl(Uri.parse('$_otpRelay?since=$since')); + final response = await request.close(); + final body = await utf8.decodeStream(response); + if (response.statusCode == 200) return body.trim(); + await Future.delayed(const Duration(milliseconds: 500)); + } + throw StateError('o relé não devolveu o código do OTP (rode otp_relay.py e adb reverse tcp:8765)'); + } finally { + client.close(force: true); + } +} +``` + +Ajustar `requestOtp`/`verifyOtp` aos tipos reais de `BackendClient` (o CPF chega com ou sem máscara conforme a assinatura: ler `backend_client.dart:63-80` e `passwordless_login_test.dart`). + +- [ ] **Step 4: Rodar o teste puro** + +Run: `cd apps/patient && flutter test test/e2e_login_test.dart` +Expected: 3/3 PASS. + +- [ ] **Step 5: Trocar os usos fixos** + +- `smoke_test.dart`, `backend_connection_test.dart`, `push_register_test.dart`: `await backend.developmentLogin(role: 'patient')` → `await loginPatient(backend)`; remover `const seedMicroAreaId` e trocar `expect(session.microAreaId, seedMicroAreaId)` por `expect(session.microAreaId, e2eConfig()?.microAreaId ?? seedMicroAreaIdDeDesenvolvimento)` **ou**, melhor, `expect(session.microAreaId, isNotEmpty)` quando há manifesto (a igualdade com a fixture é a prova). Manter a constante de desenvolvimento num único lugar (`e2e_login.dart`, `const developmentMicroAreaId`) só para o fallback da CI. +- `tool/live_check.dart` e `tool/push_consent.dart` (rodam no host com `dart run`): ler o manifesto de `.e2e/fixtures.json` se existir (`File`), senão `developmentLogin`. Reusar `E2eConfig.fromMap`. + +- [ ] **Step 6: Provar o teste falhando sem o relé e passando com ele** + +```bash +export PATH="$HOME/Android/Sdk/platform-tools:$PATH" +./scripts/qa/e2e_stack.sh up && ./scripts/qa/e2e_stack.sh seed +adb reverse tcp:8443 tcp:443 && adb reverse tcp:8765 tcp:8765 +cd apps/patient +# 1) sem o relé: falha com a mensagem do helper +flutter test integration_test/smoke_test.dart -d emulator-5554 --dart-define=SINALACS_HOST=https://localhost:8443/ --dart-define=E2E_FIXTURES="$(cat ../../.e2e/fixtures.json)" 2>&1 | tail -5 +# 2) com o relé +python3 ../../scripts/qa/otp_relay.py & R=$! +flutter test integration_test/smoke_test.dart -d emulator-5554 --dart-define=SINALACS_HOST=https://localhost:8443/ --dart-define=E2E_FIXTURES="$(cat ../../.e2e/fixtures.json)" 2>&1 | tail -5; kill $R +``` +Expected: (1) FAIL com "o relé não devolveu o código do OTP"; (2) `All tests passed!`. Em (2) o login foi o OTP real com a stack sem login de desenvolvimento. + +- [ ] **Step 7: Sem manifesto continua verde (CI)** + +Run: `./scripts/qa/e2e.sh --emulator` (stack de desenvolvimento, sem `E2E_FIXTURES`) +Expected: verde, como antes (usa o fallback). + +- [ ] **Step 8: Commit** + +```bash +git add apps/patient +git commit -m "test(paciente): integração entra pelo OTP real com fixtures; login de desenvolvimento só como fallback da CI + +Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Task 5: Jornada completa pela tela (integration_test com o app real) + +**Files:** +- Create: `apps/patient/integration_test/full_journey_test.dart` +- Modify: `apps/patient/lib/app/app.dart` **somente se** faltar uma `Key` estável nos botões de urgência/triagem (ler antes; preferir achar por `Key` que já existe: `panic_button`, `submit_triage`, `refresh_status`, `cpf_field`, `birth_date_field`, `enter_button`, `otp_code_field`, `verify_code_button`, `login_error`, `terms_gate_accept_button`, `consent_switch_segmentedPush`). + +**Interfaces:** +- Consumes: `loginPatient`/`e2eConfig()` (Task 4), `BackendClient` real, `SinalAcsApp(backend:, pushTokens:)`, fixtures `main`, `chronic`, `outsider`. +- Produces: o teste `jornada` (um arquivo, vários `testWidgets` em sequência com `group`), usado por `patient_full_e2e.sh` (Task 7). + +- [ ] **Step 1: Ler as telas para os gestos exatos** + +Ler em `apps/patient/test/patient_app_mvp_test.dart` os testes que usam `panic_button`, `submit_triage` (triagem: como marcar "dor no peito"), `refresh_status` e `openMyData` (helpers nas linhas 172-211) e copiar os mesmos `find`/`tap` — eles já são a verdade dos gestos. + +- [ ] **Step 2: Escrever o teste (vai falhar até a stack de e2e estar de pé)** + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show rootBundle; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:sinalacs_patient/app/app.dart'; +import 'package:sinalacs_patient/core/network/backend_client.dart'; +import 'package:sinalacs_patient/core/network/backend_config.dart'; + +import 'support/e2e_login.dart'; +import '../test/support/e2e_config.dart'; +import 'support/otp_relay_client.dart'; // extrair de e2e_login.dart: Future codeFromRelay(int since) + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + final config = e2eConfig(); + + Future backendReal() async { + final ca = (await rootBundle.load(BackendConfig.rpcCaAsset)).buffer.asUint8List(); + return BackendClient(trustedCaBytes: ca); + } + + Future digitarEEntrar(WidgetTester tester, E2ePatientFixture p, {String? codigo}) async { + await tester.enterText(find.byKey(const Key('cpf_field')), p.cpf); + final d = p.birthDate; + final data = '${d.day.toString().padLeft(2, '0')}/${d.month.toString().padLeft(2, '0')}/${d.year}'; + await tester.enterText(find.byKey(const Key('birth_date_field')), data); + final pedidoEm = DateTime.now().toUtc().millisecondsSinceEpoch; + await tester.tap(find.byKey(const Key('enter_button'))); + await tester.pumpAndSettle(const Duration(seconds: 3)); + await tester.enterText(find.byKey(const Key('otp_code_field')), codigo ?? await codeFromRelay(pedidoEm)); + await tester.tap(find.byKey(const Key('verify_code_button'))); + await tester.pumpAndSettle(const Duration(seconds: 5)); + } + + group('jornada do paciente contra o banco de teste', skip: config == null ? 'defina E2E_FIXTURES (scripts/qa/patient_full_e2e.sh)' : false, () { + testWidgets('código errado não entra; o certo entra', (tester) async { + final backend = await backendReal(); + addTearDown(backend.close); + await tester.pumpWidget(SinalAcsApp(backend: backend)); + final p = config!.patient('main'); + await digitarEEntrar(tester, p, codigo: '000000'); + expect(find.byKey(const Key('login_error')), findsOneWidget); + expect(find.byKey(const Key('panic_button')), findsNothing); + }); + + testWidgets('login, termos, triagem vermelha, alerta e status', (tester) async { + final backend = await backendReal(); + addTearDown(backend.close); + await tester.pumpWidget(SinalAcsApp(backend: backend)); + final p = config!.patient('chronic'); // paciente diferente: respeita os 60 s do OTP + await digitarEEntrar(tester, p); + // Termos: o paciente da fixture não aceitou; é um convite, não uma trava. + if (find.byKey(const Key('terms_gate_accept_button')).evaluate().isNotEmpty) { + await tester.tap(find.byKey(const Key('terms_gate_accept_button'))); + await tester.pumpAndSettle(const Duration(seconds: 3)); + } + // Triagem: dor no peito -> vermelho (copiar os gestos de patient_app_mvp_test.dart). + // ... marcar sintoma e tocar submit_triage; esperar 'vermelho' na tela. + // Alerta: tocar panic_button; esperar a confirmação; abrir o status e tocar refresh_status. + // Esperar o texto de status real ("pendente") — não o vazio 'status_empty'. + expect(find.byKey(const Key('status_empty')), findsNothing); + }); + + testWidgets('Meus dados mostra o cadastro da fixture, e só o dela', (tester) async { + // login do 'main'; abrir Mais > Meus dados (openMyData); + // expect(find.text(nome da fixture), findsOneWidget) — o manifesto tem o nome; + // expect(find.textContaining('Outra Área'), findsNothing). + }); + }); +} +``` + +Os comentários `// ...` acima marcam **onde colar os gestos lidos no Step 1**; eles não ficam no arquivo final — cada um vira as linhas `tester.tap/enterText/expect` correspondentes. O teste só está completo quando nenhum comentário-lacuna resta. Acrescentar `nome` ao `E2ePatientFixture` (campo `name`) para a asserção de "Meus dados". + +- [ ] **Step 3: Rodar sem a stack e ver falhar pelo motivo certo** + +Run: `cd apps/patient && flutter test integration_test/full_journey_test.dart -d emulator-5554 --dart-define=SINALACS_HOST=https://localhost:8443/` +Expected: grupo **pulado** (sem `E2E_FIXTURES`), sem falha falsa. + +- [ ] **Step 4: Rodar com a stack, o seed, o relé e o `adb reverse`** + +```bash +cd apps/patient && flutter test integration_test/full_journey_test.dart -d emulator-5554 \ + --dart-define=SINALACS_HOST=https://localhost:8443/ --dart-define=E2E_FIXTURES="$(cat ../../.e2e/fixtures.json)" +``` +Expected: 3/3 PASS. Se uma tela mudar, corrigir o **teste** (gesto), nunca o app para agradar o teste; se o app estiver errado, parar e usar `superpowers:systematic-debugging`. + +- [ ] **Step 5: Prova de território (Review Focus)** + +Acrescentar um `testWidgets` que entra como `main`, abre "Meus dados" e confirma que o nome do `outsider` não aparece; e um teste de backend real, no mesmo arquivo, que faz `loginInstitutional` do ACS (helper `acsBackend` com `config.acsMatricula`/`acsPassword`) e confirma que `patients.listMicroArea` lista `main` e `chronic` e **não** `outsider`. Rodar de novo; Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add apps/patient/integration_test apps/patient/test/support +git commit -m "test(paciente): jornada completa pela tela contra o banco de teste (OTP, triagem, alerta, status, Meus dados, território) + +Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Task 6: Push de ponta a ponta com o ACS institucional e o banco de e2e + +**Files:** +- Modify: `apps/acs/tool/send_notice.dart` (aceitar `--matricula` e `--password`; sem eles, `developmentLogin` como hoje) +- Modify: `apps/acs/test/` (teste do parser de argumentos, se a leitura for extraída) +- Modify: `scripts/qa/push_e2e.sh` (modo `--e2e-db`: `psql_q` via `e2e_stack.sh psql`, login do paciente/ACS do manifesto; sem a flag, comportamento atual intacto) +- Modify: `apps/patient/integration_test/push_register_test.dart` (já usa `loginPatient` da Task 4) + +**Interfaces:** +- Consumes: Task 1 (`e2e_stack.sh psql`), Task 2 (manifesto), Task 4 (`loginPatient`), `AcsBackend.loginInstitutional` (ver `apps/acs/lib/core/network/backend_client.dart`). +- Produces: `push_e2e.sh --e2e-db --negativos` sai com 0 provando entrega e negativos **sem tocar** o banco de desenvolvimento. + +- [ ] **Step 1: Teste que falha (argumentos do `send_notice`)** + +Extrair a leitura de argumentos para uma função pura `NoticeArgs parseNoticeArgs(List)` em `apps/acs/tool/send_notice_args.dart` e testar em `apps/acs/test/send_notice_args_test.dart`: `--matricula M --password P` preenche os campos; sem eles ficam `null`; `--title` vazio é erro. Rodar `cd apps/acs && flutter test test/send_notice_args_test.dart` → FAIL; implementar; → PASS. + +- [ ] **Step 2: `send_notice` com login institucional** + +No `main`, trocar `await backend.developmentLogin(role: 'acs')` por: se `args.matricula != null`, `await backend.loginInstitutional(matricula:, password:)`; senão o login de desenvolvimento. A senha nunca é impressa. + +- [ ] **Step 3: `push_e2e.sh --e2e-db`** + +Dentro do script: quando `--e2e-db`, (a) `psql_q() { ./scripts/qa/e2e_stack.sh psql "$1"; }`; (b) não subir a stack de desenvolvimento (`docker compose --profile push up` do topo vira `./scripts/qa/e2e_stack.sh up && seed`); (c) `send()` passa `--matricula/--password` lidos de `.e2e/fixtures.json` com `python3 -c`; (d) o teste de registro recebe `--dart-define=E2E_FIXTURES=...` e `adb reverse tcp:8765`, e o relé sobe e é encerrado em `trap`; (e) `tool/push_consent.dart` usa o manifesto (Task 4). Ler o script inteiro antes (150 linhas) e alterar só esses pontos. + +- [ ] **Step 4: Rodar** + +Run: `export PATH="$HOME/Android/Sdk/platform-tools:$PATH"; ./scripts/qa/push_e2e.sh --e2e-db --negativos` +Expected: termina com `OK — o aviso chegou ao emulador` e exit 0. Conferir depois que o banco de desenvolvimento não foi tocado: `docker exec sinalacs-postgres psql -U sinalacs_user -d sinalacs_db -Atc "select count(*) from push_tokens"` inalterado e nenhuma linha `community_notice` nova em `audit_logs` de desenvolvimento. + +- [ ] **Step 5: Commit** + +```bash +git add apps/acs/tool apps/acs/test scripts/qa/push_e2e.sh apps/patient/integration_test +git commit -m "test(e2e): push do ACS institucional até o emulador contra o banco de teste, sem tocar o de desenvolvimento + +Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Task 7: Runner único, documentação e execução final no emulador 5554 + +**Files:** +- Create: `scripts/qa/patient_full_e2e.sh` +- Modify: `apps/CLAUDE.md`, `backend/CLAUDE.md` (seção de seeds), `PROGRESS.md`, `.env.example` (nada novo esperado), memória do projeto + +**Interfaces:** +- Consumes: Tasks 1–6. +- Produces: `./scripts/qa/patient_full_e2e.sh [--sem-push]`: um comando que faz tudo e sai 0/≠0. + +- [ ] **Step 1: Escrever o runner** + +```bash +#!/usr/bin/env bash +# Teste completo do app do paciente no emulador-5554 contra o banco de teste. +set -euo pipefail +cd "$(dirname "$0")/../.." +export PATH="$HOME/Android/Sdk/platform-tools:$PATH" +dev=emulator-5554 +adb -s "$dev" get-state >/dev/null 2>&1 || { echo "emulador $dev não encontrado"; exit 4; } + +./scripts/qa/e2e_stack.sh up +./scripts/qa/e2e_stack.sh seed +adb -s "$dev" reverse tcp:8443 tcp:443 >/dev/null +adb -s "$dev" reverse tcp:8765 tcp:8765 >/dev/null +python3 scripts/qa/otp_relay.py & relay=$! +trap 'kill $relay 2>/dev/null || true; ./scripts/qa/e2e_stack.sh down' EXIT + +fixtures="$(cat .e2e/fixtures.json)" +( cd apps/patient + flutter test integration_test/full_journey_test.dart integration_test/backend_connection_test.dart \ + -d "$dev" --dart-define=SINALACS_HOST=https://localhost:8443/ --dart-define=E2E_FIXTURES="$fixtures" ) +if [[ "${1:-}" != --sem-push ]]; then + trap - EXIT; kill $relay 2>/dev/null || true + ./scripts/qa/push_e2e.sh --e2e-db --negativos + ./scripts/qa/e2e_stack.sh down +fi +echo 'OK — jornada completa do paciente contra o banco de teste' +``` + +`chmod +x`. O `trap` derruba o banco de e2e mesmo em falha. + +- [ ] **Step 2: Rodar duas vezes seguidas (Review Focus)** + +Run: `./scripts/qa/patient_full_e2e.sh; echo "1ª exit=$?"; ./scripts/qa/patient_full_e2e.sh; echo "2ª exit=$?"` +Expected: as duas com exit 0, UUIDs/CPFs diferentes (`diff` entre cópias do manifesto mostra tudo distinto) e o banco de desenvolvimento intacto. + +- [ ] **Step 3: Suítes de regressão** + +Run: `cd apps/patient && flutter analyze && flutter test; cd ../../backend/sinalacs_server && dart test; cd ../.. && ./scripts/qa/ci_invariants.sh` +Expected: analyze limpo; paciente 236+ (233 + 3 do `e2e_login_test`); backend 439 (433 + 6 de fixtures); invariantes ok. `./scripts/qa/e2e.sh --emulator` segue verde (fallback da CI). + +- [ ] **Step 4: Documentar** + +- `apps/CLAUDE.md`: parágrafo "Teste completo do paciente" (comando, o que prova, pré-requisitos: emulador, credenciais FCM, Play Services, relé). +- `backend/CLAUDE.md`: `seed_e2e_fixtures.dart` e a regra de que só escreve em `sinalacs_e2e`. +- `PROGRESS.md`: seção "Teste completo do paciente (2026-09-30)" com o que foi provado, contagens e o que **não** foi (iOS, aparelho físico, Gorush hospedado; `development.sql` e `developmentLogin` continuam existindo para a stack de desenvolvimento e a CI). +- Memória do projeto: um arquivo novo `patient-full-e2e-2026-09-30.md` e a linha em `MEMORY.md`. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/qa/patient_full_e2e.sh apps/CLAUDE.md backend/CLAUDE.md PROGRESS.md +git commit -m "test(e2e): runner único do teste completo do paciente e documentação + +Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +## Fora do escopo + +- Trocar os widget tests (`FakePatientBackend`, "Fulano de Tal", CPF `123.456.789-09`): são herméticos por desenho. +- Remover `developmentLogin`, `development.sql` ou os seeds de desenvolvimento: a stack de desenvolvimento e o job `android-e2e` da CI ainda dependem deles. Migrar a CI para a stack de e2e é um plano próprio (mexe em `ci.yml` e no `ci_invariants.sh`). +- iOS/APNs, aparelho físico, Gorush hospedado, backoffice de atendimento dos pedidos do titular. + +## Self-review + +- **Cobertura do pedido:** backend no Docker com Gorush (Task 1, 6), dados fixos identificados (tabela) e substituídos por fixtures no banco de teste (Tasks 1–4, 6), teste completo no emulador (Tasks 5–7). +- **Placeholders:** os trechos marcados "colar os gestos lidos no Step 1" da Task 5 e o corpo do seeder da Task 2, Step 5, dependem de arquivos existentes citados com caminho e linha; o plano os declara como a fonte a copiar e proíbe deixar comentário-lacuna no arquivo final. Se o executor preferir, deve ler `seed_acs_credentials.dart`/`seed_health_data.dart` antes de começar a Task 2. +- **Consistência de nomes:** `E2eConfig.fromMap`/`patient(role)`, `loginPatient`, `e2eConfig()`, `codeFromRelay`, `generateE2eFixtures`, `E2eFixtures.byRole`, `e2e_stack.sh up|down|seed|psql` usados igual em todas as tasks. Correção feita: o manifesto entra por **um** define `E2E_FIXTURES` (JSON), não por `--dart-define-from-file` (que quebraria listas). +- **Rulings:** banco `sinalacs_e2e` dentro do `postgres-test` (e não `sinalacs_test`) para não colidir com `dart test`; fallback ao login de desenvolvimento quando não há manifesto, para não quebrar o `android-e2e` da CI. diff --git a/docs/superpowers/plans/2026-10-01-finalizacao-app-acs.md b/docs/superpowers/plans/2026-10-01-finalizacao-app-acs.md new file mode 100644 index 00000000..8f9cb9c6 --- /dev/null +++ b/docs/superpowers/plans/2026-10-01-finalizacao-app-acs.md @@ -0,0 +1,1040 @@ +# Finalização do app ACS — Plano de Implementação + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fechar o que falta para o app `apps/acs` fazer, num aparelho de verdade, o que o PRD promete — GPS do check-in (RF12), acionamento do SAMU (RF13) — e provar a jornada inteira do ACS no `emulator-5554` com login real contra o banco de teste. + +**Architecture:** Quatro correções independentes, cada uma com teste que falha antes: (1) manifesto Android declara o que o código já usa; (2) botão do SAMU abre o discador por uma interface injetável; (3) o caminho de GPS real do check-in é provado no emulador com `adb emu geo fix`; (4) um e2e do ACS no molde do `patient_full_e2e.sh` (banco `sinalacs_e2e`, fixtures sintéticas, login institucional pela tela). O resto é documentação e a barra final. + +**Tech Stack:** Flutter 3 / Dart, `geolocator`, `url_launcher` (nomeado no PRD, RF13), `integration_test`, Docker Compose (`docker-compose.e2e.yml`), bash, Python 3 (`otp_relay.py`), `adb`. + +**Spec:** `spec/PRD_system.md` (RF07–RF13, §2.2), `docs/superpowers/specs/2026-09-16-decisoes-produto-pos-validacao.md` (§4 geofencing: só em primeiro plano, sem localização em background), `spec/lgpd_design.md`, `apps/CLAUDE.md`. + +> **Desvios da execução (2026-10-01) — leia antes do resto.** O plano abaixo é o que +> foi escrito; o que foi entregue difere em dois pontos, ambos medidos: +> 1. **Task 3 não prova o fix de GPS.** Neste AVD (Android 16, Play Services) nem +> `adb emu geo fix`, nem provider de teste, nem `forceLocationManager` entregam +> posição ao app. O e2e prova a **permissão** em runtime (e falha com o manifesto +> antigo); "Local alcançado" com posição segue coberto só por testes com posição +> injetada. Onde o texto abaixo diz "GPS real"/`adb emu geo fix`, leia isto. +> 2. **Task 4 não cobre MQTT/ACK.** O `aclfile` do broker só libera o UUID de +> microárea do seed de dev e as fixtures de e2e são aleatórias. Onde o texto +> diz "alerta MQTT"/"ACK" no e2e do banco de teste, leia isto; o alerta pelo +> broker segue provado por `smoke`/`red_alert_cycle` na stack de dev. +> Também mudaram: nomes `*_e2e.dart`, `flutter drive` na Task 3, e um defeito do +> `dispose` do painel corrigido na Task 4. Os `Ruling:` completos estão no PROGRESS.md. + +## Estado verificado em 2026-10-01 + +Medido nesta sessão, não herdado de documento: + +- `flutter analyze` em `apps/acs`: **No issues found**. `flutter test`: **198 passam**. +- `emulator-5554` **não estava no ar** (`adb devices` vazio; `adb` não está no `PATH`). AVDs existentes: `Medium_Phone`, `Medium_Tablet`. Foi iniciado com `emulator -avd Medium_Phone -port 5554 -no-snapshot-save -no-boot-anim`. +- Stack de desenvolvimento subida (`docker compose up --build -d`, `database-seed`, `sync_dev_ca.sh`). Bateria `integration_test` do ACS no emulador, com os `--dart-define` de `e2e.sh`: **15 testes passam** (`smoke`, `red_alert_cycle`, `map_flow`, `encrypted_storage`). Chave do Google Maps presente no `.env`. +- **Achado 1 (defeito real, RF12):** `aapt2 dump permissions` nos APKs de debug **e** de release do ACS não mostra `ACCESS_FINE_LOCATION` nem `ACCESS_COARSE_LOCATION`. O `AndroidManifest.xml` `main` não declara nenhuma `uses-permission`. Sem elas, `Geolocator.requestPermission()` não pode mostrar o diálogo e `_loadCurrentPosition` (`apps/acs/lib/app/app.dart:549`) engole o erro num `catch (_)`: `_currentPosition` fica `null` para sempre e o check-in passivo nunca chega a "Local alcançado". Os testes atuais passam porque **injetam** `currentPosition`/`initialPosition`; nenhum exercita o GPS do aparelho. O documento de decisão §4 diz que a permissão "declarada no `AndroidManifest.xml`" existe — não existe. +- **Achado 2 (hipótese minha que a medição desmentiu):** `INTERNET` **está** no APK de release (vem de uma dependência transitiva, não do manifesto do app). Não é defeito; a Task 1 a declara explicitamente só para não depender de uma dependência. +- **Achado 3 (RF13, L-07):** `EscalationScreen` (`app.dart:1915`) ainda mostra `'Discagem não está integrada neste protótipo.'` no botão do SAMU e `'Encaminhamento será integrado à UBS.'` no da UBS. `url_launcher` não está no `pubspec.yaml`. +- **Achado 4 (lacuna de teste):** todos os `integration_test/` e `tool/` do ACS usam `developmentLogin(role: 'acs')`. O paciente tem jornada completa com login real no banco de teste (`scripts/qa/patient_full_e2e.sh`); o ACS não tem equivalente — o login institucional (RF07) e o fluxo pela tela nunca rodam no emulador. +- **Achado 5 (fora do ACS, a verificar):** o manifesto `main` do paciente também não tem `uses-permission` de `INTERNET`; como o ACS, deve vir de dependência. Não mexer aqui; só registrar (Task 5). + +## Fora deste plano (e por quê) + +| Item | Motivo | +|---|---| +| MFA/TOTP e refresh token do ACS | Adiados de propósito (`PROGRESS.md`, `spec/security_assessment.md` F5). | +| RF08 — cache persistido da microárea (offline) | Guardar nome e condições crônicas de pacientes no aparelho exige decisão de LGPD (minimização, SQLCipher, expurgo) e é uma feature do tamanho de um plano próprio. **Recomendo um plano separado**, depois de decidido o desenho em `spec/lgpd_design.md`. | +| Botão "Encaminhar para UBS Central" | Não há fonte de contato da UBS (telefone/URL) nem no modelo de dados nem no envelope MQTT. É decisão de produto; a Task 2 deixa o botão exatamente como está e o diz na tela de documentação. | +| iOS | `apps/acs/ios/` não existe. | +| Restrição/cobrança da chave do Google Maps | Operacional, fora do código. | + +## Global Constraints + +- Nenhum dado real de paciente em teste, log, captura ou config; CPFs/nomes só sintéticos. `integration_test` novos usam UUIDs/fixtures sintéticos. +- Triagem determinística e alerta vermelho nunca descartado/atrasado: nenhuma tarefa toca `triage.evaluate`, `AlertQueue` ou a fila offline além de ler. +- **Sem localização em segundo plano** (decisão §4): `ACCESS_BACKGROUND_LOCATION` não pode aparecer no manifesto (teste da Task 1 garante). +- O botão do SAMU **abre o discador, nunca liga sozinho** (`ACTION_DIAL`; sem `CALL_PHONE`). Alvo de toque ≥ `64x60` dp (já é; não regredir — `spec/ux_accessibility_assessment.md`). +- Cor clínica usada como texto/ícone usa o token `*OnSurface` (`apps/CLAUDE.md`). +- Textos de UI, comentários e commits em português. +- **Commits sem qualquer atribuição de IA** (`Co-Authored-By`, "Generated with Claude Code"): regra adicionada ao `CLAUDE.md` em `ef140a5`. Isto substitui o rodapé `Co-Authored-By` dos planos anteriores. Sem push nem merge. +- `flutter analyze` em `apps/acs` sem problemas antes de cada commit; baseline `flutter test`: 198 (só pode crescer). +- O manifesto `.e2e/fixtures.json` tem a senha do ACS: gitignorado, `chmod 600`, apagado ao final. +- `adb`/`flutter`/`serverpod` não estão no `PATH` desta máquina: `export PATH="$PATH:$HOME/Android/Sdk/platform-tools:$HOME/Android/Sdk/emulator:$HOME/flutter/bin:$HOME/.pub-cache/bin"`. + +## Review Focus + +As entradas e condições que a spec implica, nenhuma tarefa exercitaria sozinha e mais provavelmente morderiam quem usa o app. Cada linha tem o teste que a fixa na tarefa dona: + +1. **Permissão de localização negada** (ACS toca "Negar"): o check-in mostra "Localização atual indisponível…" e o registro de visita continua disponível pelo caminho manual — nunca trava o ACS. → teste na Task 3. +2. **Sem discador no aparelho** (`launchUrl` devolve `false` ou lança): o botão do SAMU mostra o número em texto ("Ligue manualmente para 192"), nunca falha em silêncio. É o botão mais crítico da UI. → teste na Task 2. +3. **Toque duplo no SAMU:** abrir o discador duas vezes não pode empilhar duas telas de discagem. → teste na Task 2. +4. **Senha errada no login institucional:** erro visível (`login_error`) e o painel **não** abre; a sessão certa logo depois funciona sem reiniciar. → teste na Task 4. +5. **Paciente de outra microárea** não aparece no seletor de visita do ACS (RBAC por território, RNF06). → teste na Task 4. + +--- + +### Task 1: Declarar a localização no manifesto Android + +**Files:** +- Create: `apps/acs/test/android_manifest_test.dart` +- Modify: `apps/acs/android/app/src/main/AndroidManifest.xml:1-2` (acrescentar `uses-permission` antes de ` RegExp( + '', + ).hasMatch(manifest); + + test('declara localização precisa e aproximada (RF12)', () { + expect(declares('ACCESS_FINE_LOCATION'), isTrue); + expect(declares('ACCESS_COARSE_LOCATION'), isTrue); + }); + + test('declara INTERNET no manifesto de release, sem depender de plugin', () { + expect(declares('INTERNET'), isTrue); + }); + + test('NÃO pede localização em segundo plano (decisão §4 de 2026-09-16)', () { + expect(declares('ACCESS_BACKGROUND_LOCATION'), isFalse, + reason: 'o geofence é só em primeiro plano; background exigiria revisão da loja e LGPD'); + }); +} +``` + +- [ ] **Step 2: Rodar e ver falhar** + +Run: `cd apps/acs && flutter test test/android_manifest_test.dart` +Expected: FAIL — `declara localização precisa e aproximada` e `declara INTERNET…` com `Expected: true Actual: `; o terceiro passa. + +- [ ] **Step 3: Implementação mínima** + +Em `apps/acs/android/app/src/main/AndroidManifest.xml`, logo depois de `` e antes de ` + + + +``` + +- [ ] **Step 4: Rodar e ver passar** + +Run: `cd apps/acs && flutter test test/android_manifest_test.dart` +Expected: PASS (3 testes). + +- [ ] **Step 5: Provar no APK real (debug e release)** + +```bash +export PATH="$PATH:$HOME/Android/Sdk/platform-tools:$HOME/flutter/bin" +cd apps/acs +flutter build apk --debug +flutter build apk --release --android-project-arg=sinalacs.allowMissingMqttPassword=true +AAPT="$HOME/Android/Sdk/build-tools/36.0.0/aapt2" +for apk in debug release; do echo "== $apk"; "$AAPT" dump permissions build/app/outputs/flutter-apk/app-$apk.apk | grep -E "LOCATION|INTERNET"; done +``` +Expected: nos dois, `ACCESS_COARSE_LOCATION`, `ACCESS_FINE_LOCATION` e `INTERNET`; **nenhum** `ACCESS_BACKGROUND_LOCATION`. + +- [ ] **Step 6: Commit** + +```bash +git add apps/acs/test/android_manifest_test.dart apps/acs/android/app/src/main/AndroidManifest.xml +git commit -m "fix(acs): declara localização e INTERNET no manifesto de release (RF12)" +``` + +--- + +### Task 2: Acionar o SAMU pelo discador (RF13 / L-07) + +**Files:** +- Create: `apps/acs/lib/core/services/emergency_dialer.dart` +- Create: `apps/acs/test/escalation_dialer_test.dart` +- Modify: `apps/acs/pubspec.yaml` (dependência `url_launcher`) +- Modify: `apps/acs/lib/app/app.dart:1915-1962` (`EscalationScreen`) +- Modify: `apps/acs/android/app/src/main/AndroidManifest.xml` (``) +- Modify: `apps/acs/test/android_manifest_test.dart` (um teste) +- Modify: `apps/acs/integration_test/map_flow_test.dart` (um teste) + +**Interfaces:** +- Consumes: `declares(...)` e `manifest` da Task 1; `_message(BuildContext, String)` de `app.dart:2224`; `testAlert(...)` e `seedMicroAreaId` de `test/support/fakes.dart`. +- Produces: `const samuNumber = '192'`; `abstract interface class EmergencyDialer { Future dial(String number); }`; `class UrlLauncherEmergencyDialer implements EmergencyDialer` (const); `EscalationScreen({alert, onVisit, EmergencyDialer dialer = const UrlLauncherEmergencyDialer()})`. + +- [ ] **Step 1: Escrever os testes que falham** + +Criar `apps/acs/test/escalation_dialer_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/app/app.dart'; +import 'package:sinalacs_acs/core/services/emergency_dialer.dart'; + +import 'support/fakes.dart'; + +class _FakeDialer implements EmergencyDialer { + _FakeDialer({this.opens = true, this.throws = false}); + + final bool opens; + final bool throws; + final dialed = []; + + @override + Future dial(String number) async { + dialed.add(number); + if (throws) throw StateError('sem discador'); + return opens; + } +} + +Future _pump(WidgetTester tester, _FakeDialer dialer) => tester.pumpWidget(MaterialApp( + home: Scaffold( + body: EscalationScreen(alert: testAlert(alertId: 'a1'), dialer: dialer), + ), + )); + +void main() { + testWidgets('o botão do SAMU abre o discador com 192', (tester) async { + final dialer = _FakeDialer(); + await _pump(tester, dialer); + + await tester.tap(find.text('Ligar para o SAMU (192)')); + await tester.pump(); + + expect(dialer.dialed, ['192']); + expect(find.text('Discagem não está integrada neste protótipo.'), findsNothing); + }); + + testWidgets('sem discador, mostra o número em texto em vez de falhar em silêncio', (tester) async { + final dialer = _FakeDialer(opens: false); + await _pump(tester, dialer); + + await tester.tap(find.text('Ligar para o SAMU (192)')); + await tester.pump(); + + expect(find.textContaining('Ligue manualmente para 192'), findsOneWidget); + }); + + testWidgets('um erro do discador cai no mesmo aviso, sem derrubar a tela', (tester) async { + final dialer = _FakeDialer(throws: true); + await _pump(tester, dialer); + + await tester.tap(find.text('Ligar para o SAMU (192)')); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect(find.textContaining('Ligue manualmente para 192'), findsOneWidget); + }); + + testWidgets('toque duplo não abre dois discadores', (tester) async { + final dialer = _FakeDialer(); + await _pump(tester, dialer); + + await tester.tap(find.text('Ligar para o SAMU (192)')); + await tester.tap(find.text('Ligar para o SAMU (192)')); + await tester.pump(); + + expect(dialer.dialed, ['192']); + }); + + testWidgets('o botão da UBS continua sendo o aviso honesto (sem contato cadastrado)', (tester) async { + await _pump(tester, _FakeDialer()); + + await tester.tap(find.text('Encaminhar para UBS Central')); + await tester.pump(); + + expect(find.text('Encaminhamento será integrado à UBS.'), findsOneWidget); + }); +} +``` + +Acrescentar a `apps/acs/test/android_manifest_test.dart`, dentro de `main()`: + +```dart + test('declara a consulta ao discador (tel:) para o botão do SAMU (RF13)', () { + // Android 11+: sem , `canLaunchUrl(tel:)` devolve false mesmo com discador. + expect( + RegExp(r'\s*') + .hasMatch(manifest), + isTrue, + ); + }); +``` + +- [ ] **Step 2: Rodar e ver falhar** + +Run: `cd apps/acs && flutter test test/escalation_dialer_test.dart test/android_manifest_test.dart` +Expected: FAIL — `escalation_dialer_test.dart` não compila (`emergency_dialer.dart` não existe) e o teste de `tel:` falha. + +- [ ] **Step 3: Dependência e interface** + +Em `apps/acs/pubspec.yaml`, em `dependencies:` (junto de `qr_flutter`): + +```yaml + # RF13: abre o discador do aparelho (tel:192). O PRD já a nomeia na matriz + # de capacidades; é invólucro de intent do sistema, não framework novo. + url_launcher: ^6.3.0 +``` + +Run: `cd apps/acs && flutter pub get` + +Criar `apps/acs/lib/core/services/emergency_dialer.dart`: + +```dart +import 'package:url_launcher/url_launcher.dart'; + +/// Número do SAMU. Constante de domínio (PRD, RF13), não configuração. +const samuNumber = '192'; + +/// Abre o discador do aparelho com o número já digitado. +/// +/// **Nunca liga sozinho:** `tel:` abre o discador e a pessoa confirma a +/// chamada. É o que dispensa `CALL_PHONE` no manifesto (permissão sensível, +/// revisada pela loja) e evita uma ligação por toque acidental. +abstract interface class EmergencyDialer { + /// `true` se o discador foi aberto; `false` se o aparelho não tem como. + Future dial(String number); +} + +class UrlLauncherEmergencyDialer implements EmergencyDialer { + const UrlLauncherEmergencyDialer(); + + @override + Future dial(String number) async { + try { + return await launchUrl(Uri(scheme: 'tel', path: number)); + } catch (_) { + // Qualquer falha vira "não abriu": quem chama mostra o número em texto. + return false; + } + } +} +``` + +- [ ] **Step 4: Ligar na tela** + +Em `apps/acs/lib/app/app.dart`, `import 'package:sinalacs_acs/core/services/emergency_dialer.dart';` junto dos outros imports de `core/services`. Trocar a classe `EscalationScreen` (`StatelessWidget` → `StatefulWidget`, para guardar o "já abri"): + +```dart +class EscalationScreen extends StatefulWidget { + const EscalationScreen({ + super.key, + this.alert, + this.onVisit, + this.dialer = const UrlLauncherEmergencyDialer(), + }); + + final PrioritizedAlert? alert; + final void Function(PrioritizedAlert alert)? onVisit; + final EmergencyDialer dialer; + + @override + State createState() => _EscalationScreenState(); +} + +class _EscalationScreenState extends State { + /// Trava o toque duplo: dois `launchUrl` seguidos empilham dois discadores. + bool _dialing = false; + + Future _callSamu() async { + if (_dialing) return; + setState(() => _dialing = true); + var opened = false; + try { + opened = await widget.dialer.dial(samuNumber); + } catch (_) { + opened = false; + } + if (!mounted) return; + setState(() => _dialing = false); + if (!opened) { + _message(context, 'Não foi possível abrir o discador. Ligue manualmente para $samuNumber.'); + } + } + + @override + Widget build(BuildContext context) { + final current = widget.alert; + final onVisit = widget.onVisit; + // ... corpo atual de build(), com duas trocas: + // 1. `onPressed: () => _message(context, 'Discagem não está integrada neste protótipo.')` + // vira `onPressed: _dialing ? null : _callSamu` + // 2. `onPressed: onVisit == null ? null : () => onVisit!(current)` vira + // `onPressed: onVisit == null ? null : () => onVisit(current)` + } +} +``` + +(O resto do `build` — `_InfoRow`, botão da UBS com `'Encaminhamento será integrado à UBS.'`, `escalation_visit`, o texto "A visita é acompanhamento do caso…" — fica **igual**, só trocando `alert`/`onVisit` por `current`/`onVisit` locais como acima. `minimumSize: const Size(64, 60)` do SAMU não muda.) + +Em `AndroidManifest.xml`, dentro de ``: + +```xml + + + + +``` + +- [ ] **Step 5: Rodar e ver passar** + +Run: `cd apps/acs && flutter test test/escalation_dialer_test.dart test/android_manifest_test.dart test/login_flow_test.dart && flutter analyze` +Expected: PASS (os testes existentes de `EscalationScreen` em `login_flow_test.dart:775` continuam verdes) e `No issues found`. + +- [ ] **Step 6: Provar no emulador que o discador resolve** + +Acrescentar a `apps/acs/integration_test/map_flow_test.dart`, dentro de `main()`: + +```dart + testWidgets('o discador do aparelho resolve tel:192 (RF13)', (tester) async { + // Prova o que o widget test não alcança: declarada e um + // handler de `tel:` presente no aparelho. Não abre a chamada. + final available = await canLaunchUrl(Uri(scheme: 'tel', path: '192')); + expect(available, isTrue, + reason: 'sem para tel: (Android 11+) ou sem discador no emulador'); + }); +``` +(import `package:url_launcher/url_launcher.dart`.) + +Run (stack de dev de pé, emulador no ar — ver Task 6 para o preparo): +`cd apps/acs && flutter test integration_test/map_flow_test.dart -d emulator-5554 --dart-define=SINALACS_HOST=https://localhost:8443/ --dart-define=SINALACS_MQTT_HOST=localhost --dart-define=SINALACS_MQTT_PASSWORD="$MQTT_ACS_PASSWORD"` +Expected: PASS. **Se `false`:** `adb -s emulator-5554 shell pm resolve-activity -a android.intent.action.DIAL -d tel:192` — vazio quer dizer imagem de emulador sem discador (registrar, não é defeito do app). + +- [ ] **Step 7: Commit** + +```bash +git add apps/acs/pubspec.yaml apps/acs/pubspec.lock apps/acs/lib/core/services/emergency_dialer.dart apps/acs/lib/app/app.dart apps/acs/android/app/src/main/AndroidManifest.xml apps/acs/test/escalation_dialer_test.dart apps/acs/test/android_manifest_test.dart apps/acs/integration_test/map_flow_test.dart +git commit -m "feat(acs): botão do SAMU abre o discador com 192 (RF13, L-07)" +``` + +--- + +### Task 3: Provar o GPS real do check-in no emulador (RF12) + +**Files:** +- Create: `apps/acs/integration_test/geofence_gps_e2e.dart` +- Create: `scripts/qa/acs_gps_e2e.sh` + +**Interfaces:** +- Consumes: manifesto da Task 1; `SinalAcsApp`, `FakeAcsBackend`, `FakeAlertFeed`, `testAlert`, `seedMicroAreaId`, `testLocationCell` (`test/support/fakes.dart`); chaves `matricula_field`, `senha_field`, `login_button`, `geofence_status`. +- Produces: `scripts/qa/acs_gps_e2e.sh [--sem-permissao]` (exit 0 = passou). + +O diálogo de permissão é do sistema: o `integration_test` não o toca. Então o script instala o APK **já com a permissão concedida** (`adb install -g`) e posiciona o GPS do emulador (`adb emu geo fix `). A célula `-1580:-4783` tem centro em lat `-15.795`, lon `-47.825` e o raio de chegada é 150 m (`RouteService.arrivalThresholdKm = 0.15`). + +- [ ] **Step 1: Descobrir se `flutter test` aceita APK pronto (decide o mecanismo)** + +Run: `cd apps/acs && flutter test --help | grep -i "use-application-binary"` +Expected: uma linha com `--use-application-binary`. Se **não** houver, usar o caminho B do Step 4. + +- [ ] **Step 2: Escrever o teste que falha** + +Criar `apps/acs/integration_test/geofence_gps_e2e.dart`: + +```dart +/// RF12 com o GPS do aparelho, não com posição injetada. Pré-requisitos e +/// cenário em `scripts/qa/acs_gps_e2e.sh` (concede/nega a permissão e fixa a +/// posição do emulador antes de rodar). `--dart-define=EXPECT_PERMISSION=granted|denied`. +@Timeout(Duration(minutes: 2)) +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:sinalacs_acs/app/app.dart'; + +import '../test/support/fakes.dart'; + +const _expect = String.fromEnvironment('EXPECT_PERMISSION', defaultValue: 'granted'); + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('o GPS do aparelho decide o check-in passivo ($_expect)', (tester) async { + final alert = testAlert(alertId: 'gps-1', locationCell: testLocationCell); + final backend = FakeAcsBackend(); + + await tester.pumpWidget(SinalAcsApp( + backend: backend, + feedBuilder: (queue) => FakeAlertFeed(queue), + initialAlert: alert, + // `currentPosition` AUSENTE de propósito: a posição tem de vir do Geolocator. + )); + await tester.enterText(find.byKey(const Key('matricula_field')), '123456'); + await tester.enterText(find.byKey(const Key('senha_field')), 'qualquer'); + await tester.tap(find.byKey(const Key('login_button'))); + await _pumpFor(tester, const Duration(seconds: 3)); + + await tester.tap(find.text('Mais')); + await _pumpFor(tester, const Duration(milliseconds: 500)); + await tester.tap(find.text('Geofencing')); + // O GPS leva alguns segundos para o primeiro fix no emulador. + await _pumpUntil( + tester, + () => _expect == 'granted' + ? find.text('Local alcançado. O registro da visita está liberado.').evaluate().isNotEmpty + : find.textContaining('Localização atual indisponível').evaluate().isNotEmpty, + timeout: const Duration(seconds: 30), + ); + + if (_expect == 'granted') { + expect(find.text('Local alcançado. O registro da visita está liberado.'), findsOneWidget); + expect(tester.widget(find.byKey(const Key('geofence_visit'))).onPressed, isNotNull); + } else { + // Review Focus 1: permissão negada nunca trava o ACS. + expect(find.textContaining('Localização atual indisponível'), findsOneWidget); + expect(tester.takeException(), isNull); + } + }); +} + +Future _pumpFor(WidgetTester tester, Duration d) async { + final fim = DateTime.now().add(d); + while (DateTime.now().isBefore(fim)) { + await tester.pump(const Duration(milliseconds: 100)); + } +} + +Future _pumpUntil(WidgetTester tester, bool Function() done, {required Duration timeout}) async { + final fim = DateTime.now().add(timeout); + while (!done() && DateTime.now().isBefore(fim)) { + await tester.pump(const Duration(milliseconds: 200)); + } +} +``` + +Run (sem o script, permissão ainda não concedida, deve falhar): +`cd apps/acs && flutter test integration_test/geofence_gps_e2e.dart -d emulator-5554` +Expected: FAIL por timeout — a posição nunca chega (sem permissão concedida, e o diálogo do sistema fica sem ninguém para tocar). + +- [ ] **Step 3: Criar o script** + +Criar `scripts/qa/acs_gps_e2e.sh` (`chmod +x`): + +```bash +#!/usr/bin/env bash +# +# GPS real do check-in do ACS (RF12) no emulador-5554. +# +# ./scripts/qa/acs_gps_e2e.sh # permissão concedida: "Local alcançado" +# ./scripts/qa/acs_gps_e2e.sh --sem-permissao # permissão negada: "indisponível", sem travar +# +# O diálogo de permissão é do sistema e o integration_test não o toca: o APK é +# instalado já com a permissão concedida (ou negada) por `pm`, e a posição do +# emulador é fixada por `adb emu geo fix` no centro da célula `-1580:-4783` +# (lat -15.795, lon -47.825), a 0 m do destino (raio de chegada: 150 m). +# Não precisa da stack: o teste usa FakeAcsBackend. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$repo_root/apps/acs" +export PATH="$PATH:$HOME/Android/Sdk/platform-tools:$HOME/flutter/bin" + +dev=emulator-5554 +pkg=br.com.prismrr.sinalacs.acs +expect=granted +[[ "${1:-}" == --sem-permissao ]] && expect=denied +adb -s "$dev" get-state >/dev/null 2>&1 || { echo "emulador $dev não encontrado (adb devices)"; exit 4; } + +flutter build apk --debug >/dev/null +adb -s "$dev" uninstall "$pkg" >/dev/null 2>&1 || true +adb -s "$dev" install -r build/app/outputs/flutter-apk/app-debug.apk >/dev/null +for p in ACCESS_FINE_LOCATION ACCESS_COARSE_LOCATION; do + if [[ "$expect" == granted ]]; then + adb -s "$dev" shell pm grant "$pkg" "android.permission.$p" + else + adb -s "$dev" shell pm revoke "$pkg" "android.permission.$p" || true + fi +done +adb -s "$dev" emu geo fix -47.825 -15.795 >/dev/null + +# --use-application-binary: reaproveita o APK que recebeu a permissão (um +# `flutter test` normal reinstalaria e perderia a concessão). +flutter test integration_test/geofence_gps_e2e.dart -d "$dev" \ + --use-application-binary=build/app/outputs/flutter-apk/app-debug.apk \ + --dart-define=EXPECT_PERMISSION="$expect" +echo "OK — check-in por GPS real ($expect)" +``` + +- [ ] **Step 4: Rodar e ver passar** + +Run: `./scripts/qa/acs_gps_e2e.sh && ./scripts/qa/acs_gps_e2e.sh --sem-permissao` +Expected: os dois imprimem `OK — check-in por GPS real (granted)` e `(denied)`. + +**Caminho B** (se o Step 1 não achou `--use-application-binary`, ou se `flutter test` reinstalar e perder a concessão — sintoma: o `granted` expira por timeout): trocar o `flutter test` final por `flutter drive --driver=test_driver/integration_test.dart --target=integration_test/geofence_gps_e2e.dart -d "$dev" --use-application-binary=…` e criar `apps/acs/test_driver/integration_test.dart` com `import 'package:integration_test/integration_test_driver.dart'; Future main() => integrationDriver();`. Registrar no commit qual caminho foi preciso. + +- [ ] **Step 5: Commit** + +```bash +git add apps/acs/integration_test/geofence_gps_e2e.dart scripts/qa/acs_gps_e2e.sh +git commit -m "test(acs): GPS real do check-in no emulador, com e sem permissão (RF12)" +``` + +--- + +### Task 4: Jornada completa do ACS no banco de teste (login institucional real) + +**Files:** +- Modify: `scripts/qa/otp_relay.py` (endpoint opt-in `GET /acs`) +- Modify: `scripts/qa/otp_relay_test.py` +- Create: `apps/acs/integration_test/support/e2e_acs.dart` +- Create: `apps/acs/integration_test/full_journey_e2e.dart` +- Create: `scripts/qa/acs_full_e2e.sh` + +**Interfaces:** +- Consumes: `e2e_stack.sh up|seed|down` e `.e2e/fixtures.json` (`E2eFixtures`: `acs{matricula,password}`, `patients[]{role,name,cpf,birthDate,microAreaId,id}`, `microAreaId`, `otherMicroAreaId`); `otp_relay.py` (`/now`, `/code?since=`); chaves `matricula_field`, `senha_field`, `login_button`, `login_error`, `patient_picker`, `patient_`, `patient_search`, `save_visit`, `sync_visits`, `pending_visits_count`, `ack_`; `api.Client` de `sinalacs_client`. +- Produces: `E2eAcsConfig` e `acsCredentialFromRelay()` em `support/e2e_acs.dart`; `acs_full_e2e.sh [--sem-gps]`. + +**Decisão a registrar (e que o usuário pode reverter):** a senha do ACS é aleatória e descartável, mas o `patient_full_e2e.sh` a tira de propósito do `--dart-define` (revisão I1: argv visível em `ps` e embutida no APK). Para o teste de tela ela precisa chegar ao aparelho. Escolha: **servi-la pelo relé de OTP, opt-in** (`E2E_FIXTURES_FILE` definido). Fica fora do APK; **continua legível por qualquer processo local em `127.0.0.1`** — mesma exposição do argv, só que sem ir para o APK e sem aparecer em `ps`. Aceitável para credencial sintética destruída ao final; se não for, a alternativa é só rodar o login institucional no host (como `tool/territory_check.dart`) e testar a tela com `FakeAcsBackend`. + +- [ ] **Step 1: Teste Python do endpoint `/acs` (falha primeiro)** + +Acrescentar a `scripts/qa/otp_relay_test.py`, antes do `if __name__`: + +```python +class AcsTest(unittest.TestCase): + def test_le_matricula_e_senha_do_manifesto(self): + import json, tempfile + from otp_relay import acs_do_manifesto + with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as f: + json.dump({"acs": {"matricula": "E2E-1234", "password": "s3nha"}}, f) + self.assertEqual(acs_do_manifesto(f.name), {"matricula": "E2E-1234", "senha": "s3nha"}) + + def test_sem_arquivo_nao_serve_nada(self): + from otp_relay import acs_do_manifesto + self.assertIsNone(acs_do_manifesto(None)) + self.assertIsNone(acs_do_manifesto("/nao/existe.json")) +``` + +Run: `cd scripts/qa && python3 otp_relay_test.py` +Expected: FAIL — `ImportError: cannot import name 'acs_do_manifesto'`. + +- [ ] **Step 2: Implementar `/acs` (opt-in)** + +Em `scripts/qa/otp_relay.py`: `import json, os` ao lado dos imports; antes de `class Handler`: + +```python +def acs_do_manifesto(caminho): + """Matrícula e senha sintéticas do ACS do manifesto de e2e, ou None. + + Só existe para o e2e de TELA do ACS. Opt-in: sem E2E_FIXTURES_FILE o relé + não serve credencial nenhuma, e o e2e do paciente segue exatamente como era.""" + if not caminho: + return None + try: + with open(caminho) as f: + acs = json.load(f)["acs"] + return {"matricula": acs["matricula"], "senha": acs["password"]} + except (OSError, KeyError, ValueError): + return None +``` + +Em `Handler.do_GET`, logo depois do bloco do `/now`: + +```python + if url.path == "/acs": + credencial = acs_do_manifesto(os.environ.get("E2E_FIXTURES_FILE")) + if credencial is None: + self.send_error(404); return + self.send_response(200); self.send_header("Content-Type", "application/json"); self.end_headers() + self.wfile.write(json.dumps(credencial).encode()) + return +``` + +Run: `cd scripts/qa && python3 otp_relay_test.py` +Expected: PASS (todos os testes, os antigos inclusive). + +- [ ] **Step 3: Suporte Dart do ACS** + +Criar `apps/acs/integration_test/support/e2e_acs.dart`: + +```dart +import 'dart:convert'; +import 'dart:io'; + +import 'package:sinalacs_client/sinalacs_client.dart' as api; + +/// Manifesto de fixtures SEM o bloco `acs` (a senha vem do relé, nunca do +/// `--dart-define`): `--dart-define=E2E_FIXTURES='{"patients":[…]}'`. +const _fixtures = String.fromEnvironment('E2E_FIXTURES'); +const relayBase = String.fromEnvironment('OTP_RELAY_BASE', defaultValue: 'http://localhost:8765'); + +class E2ePatient { + const E2ePatient({required this.id, required this.role, required this.name, required this.cpf, required this.birthDate, required this.microAreaId}); + final String id; + final String role; + final String name; + final String cpf; // nunca em log + final String birthDate; // AAAA-MM-DD + final String microAreaId; + @override + String toString() => 'E2ePatient($role)'; +} + +List e2ePatients() { + if (_fixtures.isEmpty) throw StateError('sem --dart-define=E2E_FIXTURES (use scripts/qa/acs_full_e2e.sh)'); + final map = (jsonDecode(_fixtures) as Map).cast(); + return [ + for (final raw in (map['patients']! as List).cast()) + E2ePatient( + id: raw['id'] as String, + role: raw['role'] as String, + name: raw['name'] as String, + cpf: raw['cpf'] as String, + birthDate: raw['birthDate'] as String, + microAreaId: raw['microAreaId'] as String, + ), + ]; +} + +E2ePatient e2ePatient(String role) => e2ePatients().firstWhere((p) => p.role == role); + +Future _get(String path, {String query = ''}) async { + final client = HttpClient(); + try { + final request = await client.getUrl(Uri.parse(relayBase).replace(path: path, query: query)); + final response = await request.close(); + final body = await utf8.decodeStream(response); + if (response.statusCode != 200) throw StateError('o relé respondeu ${response.statusCode} a $path'); + return body.trim(); + } on SocketException { + throw StateError('o relé não está no ar (scripts/qa/otp_relay.py + adb reverse tcp:8765 tcp:8765)'); + } finally { + client.close(force: true); + } +} + +/// Matrícula e senha do ACS da fixture, servidas pelo relé (opt-in). +Future<({String matricula, String senha})> acsCredentialFromRelay() async { + final json = jsonDecode(await _get('/acs')) as Map; + return (matricula: json['matricula'] as String, senha: json['senha'] as String); +} + +/// Entra como o paciente [role] pelo OTP real e devolve o token. +Future loginPatientOtp(api.Client client, String role) async { + final p = e2ePatient(role); + final pedidoEm = int.parse(await _get('/now')); + await client.auth.requestOtp(cpf: p.cpf, birthDate: DateTime.parse(p.birthDate)); + for (var i = 0; i < 20; i++) { + try { + final code = await _get('/code', query: 'since=$pedidoEm'); + final session = await client.auth.verifyOtp(cpf: p.cpf, code: code); + return session.accessToken; + } on StateError { + await Future.delayed(const Duration(milliseconds: 500)); + } + } + throw StateError('o relé não devolveu o código do OTP'); +} +``` + +- [ ] **Step 4: Escrever a jornada (falha primeiro)** + +Criar `apps/acs/integration_test/full_journey_e2e.dart`: + +```dart +/// Jornada completa do ACS contra o BANCO DE TESTE, pela tela, com login +/// institucional real (RF07) e MQTT real. Rodada por `scripts/qa/acs_full_e2e.sh`. +/// +/// PRIVACIDADE: só fixtures sintéticas geradas na execução; nada de CPF em log. +@Timeout(Duration(minutes: 4)) +library; + +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show rootBundle; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:sinalacs_acs/app/app.dart'; +import 'package:sinalacs_acs/core/network/backend_client.dart'; +import 'package:sinalacs_acs/core/network/backend_config.dart'; +import 'package:sinalacs_client/sinalacs_client.dart' as api; + +import 'support/e2e_acs.dart'; + +Future _pumpUntil(WidgetTester tester, bool Function() done, {Duration timeout = const Duration(seconds: 30)}) async { + final fim = DateTime.now().add(timeout); + while (!done() && DateTime.now().isBefore(fim)) { + await tester.pump(const Duration(milliseconds: 200)); + } + expect(done(), isTrue, reason: 'condição não atingida em ${timeout.inSeconds}s'); +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('login real, alerta pelo MQTT, visita pelo seletor e sincronização', (tester) async { + final ca = (await rootBundle.load(BackendConfig.rpcCaAsset)).buffer.asUint8List(); + final backend = BackendClient(trustedCaBytes: ca); + addTearDown(backend.close); + final host = const String.fromEnvironment('SINALACS_HOST', defaultValue: 'https://10.0.2.2/'); + final patientClient = api.Client(host, securityContext: SecurityContext()..setTrustedCertificatesBytes(ca)) + ..connectivityMonitor = null; + addTearDown(patientClient.close); + + final cred = await acsCredentialFromRelay(); + final main = e2ePatient('main'); + final outsider = e2ePatient('outsider'); + + await tester.pumpWidget(SinalAcsApp(backend: backend)); + + // Review Focus 4 — senha errada: erro visível, painel fechado. + await tester.enterText(find.byKey(const Key('matricula_field')), cred.matricula); + await tester.enterText(find.byKey(const Key('senha_field')), '${cred.senha}-errada'); + await tester.tap(find.byKey(const Key('login_button'))); + await _pumpUntil(tester, () => find.byKey(const Key('login_error')).evaluate().isNotEmpty); + expect(find.byKey(const Key('login_button')), findsOneWidget, reason: 'o painel não pode abrir'); + + // A sessão certa logo depois funciona, sem reiniciar. + await tester.enterText(find.byKey(const Key('senha_field')), cred.senha); + await tester.tap(find.byKey(const Key('login_button'))); + await _pumpUntil(tester, () => find.byKey(const Key('login_button')).evaluate().isEmpty); + + // Alerta vermelho do paciente (OTP real) chega ao painel pelo broker. + final token = await loginPatientOtp(patientClient, 'api'); + final created = await patientClient.alerts.createRedAlert( + accessToken: token, + idempotencyKey: 'e2e-acs-${DateTime.now().microsecondsSinceEpoch}', + locationHash: 'sem-local-00', + ); + await _pumpUntil(tester, () => find.byKey(Key('alert_${created.alertId}')).evaluate().isNotEmpty); + await tester.tap(find.byKey(Key('ack_${created.alertId}'))); + await _pumpUntil(tester, () => find.byKey(Key('ack_${created.alertId}')).evaluate().isEmpty); + + // Visita de rotina pelo seletor: o paciente da microárea aparece; o de fora, não (RNF06). + await tester.tap(find.text('Visita')); + await _pumpUntil(tester, () => find.byKey(const Key('patient_picker')).evaluate().isNotEmpty); + expect(find.byKey(Key('patient_${main.id}')), findsOneWidget); + expect(find.byKey(Key('patient_${outsider.id}')), findsNothing, reason: 'outra microárea'); + + await tester.tap(find.byKey(Key('patient_${main.id}'))); + await tester.pump(); + await tester.tap(find.byKey(const Key('save_visit'))); + await _pumpUntil(tester, () => find.byKey(const Key('pending_visits_count')).evaluate().isNotEmpty); + await tester.tap(find.byKey(const Key('sync_visits'))); + await _pumpUntil(tester, () => find.byKey(const Key('pending_visits_count')).evaluate().isEmpty); + + // A visita está NO SERVIDOR (e só o ACS da microárea a enxerga). + final acsClient = api.Client(host, securityContext: SecurityContext()..setTrustedCertificatesBytes(ca)) + ..connectivityMonitor = null; + addTearDown(acsClient.close); + final acs = await acsClient.auth.loginInstitutional(matricula: cred.matricula, password: cred.senha); + final remotas = await acsClient.visits.pull(accessToken: acs.accessToken, since: DateTime.fromMillisecondsSinceEpoch(0)); + expect(remotas.any((v) => v.patientId == main.id), isTrue); + }); +} +``` + +**Nome de arquivo (`_e2e.dart`, não `_test.dart`) é de propósito:** `flutter test integration_test` descobre `*_test.dart` recursivamente, e `e2e.sh --emulator --full` rodaria este teste contra a stack de desenvolvimento, onde não há fixtures nem relé. Com `_e2e.dart` só o script o roda, por caminho explícito. + +**Já conferido contra o código** (2026-10-01): `auth.loginInstitutional` recebe `password:` (`client.dart:138`); as abas são `Área`, `Fila`, `Mapa`, `Visita`, `Mais`; `ack_` só existe enquanto `!alert.acknowledged` (`app.dart:1123`). **Ainda a conferir ao rodar:** que a fila marca o alerta como confirmado depois do `acknowledge` (o `ack_` precisa sumir); se não sumir, ler como `app.dart:714` atualiza o alerta e trocar a espera por um texto de "confirmado" — não afrouxar a asserção. + +- [ ] **Step 5: Criar o orquestrador** + +Criar `scripts/qa/acs_full_e2e.sh` (`chmod +x`), espelhando `patient_full_e2e.sh`: + +```bash +#!/usr/bin/env bash +# +# Jornada completa do ACS no emulador-5554, contra o BANCO DE TESTE. +# +# ./scripts/qa/acs_full_e2e.sh +# +# Sobe a stack de e2e (e2e_stack.sh: banco sinalacs_e2e, sem login de +# desenvolvimento), semeia fixtures sintéticas (UUIDs, CPFs e a senha do ACS +# novos a cada execução), sobe o relé (OTP + credencial do ACS, opt-in) e roda +# integration_test/full_journey_e2e.dart no emulador. Nada é escrito no banco +# de desenvolvimento; o banco e o manifesto são apagados ao final. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$repo_root" +export PATH="$PATH:$HOME/Android/Sdk/platform-tools:$HOME/flutter/bin:$HOME/.pub-cache/bin" + +dev=emulator-5554 +adb -s "$dev" get-state >/dev/null 2>&1 || { echo "emulador $dev não encontrado (adb devices)"; exit 4; } + +relay_pid="" +cleanup() { + [[ -n "$relay_pid" ]] && kill "$relay_pid" 2>/dev/null || true + rm -f .e2e/fixtures.json # primeiro: tem a senha do ACS, mesmo se o down falhar + ./scripts/qa/e2e_stack.sh down >/dev/null 2>&1 \ + || echo 'aviso: e2e_stack.sh down falhou; o banco sinalacs_e2e pode ter sobrado' >&2 + echo 'A stack de desenvolvimento foi substituída pela de e2e: rode `docker compose up -d` para voltá-la.' +} +trap cleanup EXIT + +if ss -ltn 2>/dev/null | grep -q '127.0.0.1:8765 '; then + echo 'erro: a porta 8765 já está ocupada (relé antigo?). Encerre-o: pkill -f scripts/qa/otp_relay.py' >&2 + exit 1 +fi + +echo "== stack de e2e (banco de teste)" +./scripts/qa/e2e_stack.sh up +./scripts/qa/e2e_stack.sh seed +./scripts/dev/sync_dev_ca.sh >/dev/null 2>&1 || true +set -a; source .env; set +a +adb -s "$dev" reverse tcp:8443 tcp:443 >/dev/null +adb -s "$dev" reverse tcp:8883 tcp:8883 >/dev/null +adb -s "$dev" reverse tcp:8765 tcp:8765 >/dev/null + +E2E_FIXTURES_FILE="$repo_root/.e2e/fixtures.json" python3 scripts/qa/otp_relay.py >/dev/null 2>&1 & +relay_pid=$! +for _ in $(seq 1 20); do + curl -fs http://127.0.0.1:8765/now >/dev/null 2>&1 && break + sleep 0.25 +done +kill -0 "$relay_pid" 2>/dev/null || { echo 'erro: o relé não subiu' >&2; exit 1; } + +# Sem o bloco `acs`: a senha chega pelo relé, nunca pelo argv do flutter test nem no APK. +fixtures="$(python3 -c "import json;d=json.load(open('.e2e/fixtures.json'));d.pop('acs',None);print(json.dumps(d))")" + +echo "== jornada do ACS no emulador" +( cd apps/acs && flutter pub get >/dev/null && flutter test integration_test/full_journey_e2e.dart -d "$dev" \ + --dart-define=SINALACS_HOST=https://localhost:8443/ \ + --dart-define=SINALACS_MQTT_HOST=localhost \ + --dart-define=SINALACS_MQTT_PASSWORD="$MQTT_ACS_PASSWORD" \ + --dart-define=E2E_FIXTURES="$fixtures" ) +echo 'OK — jornada completa do ACS contra o banco de teste' +``` + +- [ ] **Step 6: Rodar e ver passar** + +Run: `./scripts/qa/acs_full_e2e.sh` +Expected: `OK — jornada completa do ACS contra o banco de teste`, e depois `ls .e2e/fixtures.json` → `No such file`. +Se o teste falhar por um dos três nomes do Step 4, corrigi-lo e rodar de novo; **não** afrouxar uma asserção para passar. + +- [ ] **Step 7: Voltar a stack de desenvolvimento** + +Run: `docker compose up -d && docker compose ps --format '{{.Name}} {{.Status}}'` +Expected: `sinalacs-serverpod … (healthy)`. + +- [ ] **Step 8: Commit** + +```bash +git add scripts/qa/otp_relay.py scripts/qa/otp_relay_test.py scripts/qa/acs_full_e2e.sh apps/acs/integration_test/support/e2e_acs.dart apps/acs/integration_test/full_journey_e2e.dart +git commit -m "test(acs): jornada completa com login institucional real no banco de teste" +``` + +--- + +### Task 5: Documentação + +**Files:** +- Modify: `PROGRESS.md` (seção nova "Finalização do app ACS (2026-10-01)") +- Modify: `apps/CLAUDE.md` (ACS: permissões, discador, `acs_gps_e2e.sh`, `acs_full_e2e.sh`) +- Modify: `spec/validation_report.md` (linha RF13 e L-07; **só** isso, ele é velho nos dois sentidos) +- Modify: `docs/superpowers/specs/2026-09-16-decisoes-produto-pos-validacao.md` (§4: a permissão passou a existir de fato) +- Modify: `docs/roteiro-video-sponsor.md:153` (a mensagem do SAMU mudou) + +**Interfaces:** consome tudo das Tasks 1–4. + +- [ ] **Step 1: Atualizar os textos** + +- `PROGRESS.md`: registrar os 5 achados do topo deste plano, o que mudou e o que ficou de fora (RF08 cache, UBS, MFA/refresh token), com a data. +- `apps/CLAUDE.md`: onde descreve o ACS, acrescentar uma frase por item — manifesto declara localização (guardado por `test/android_manifest_test.dart`); `EmergencyDialer` (`core/services/emergency_dialer.dart`) abre o discador, nunca liga; `scripts/qa/acs_gps_e2e.sh` e `scripts/qa/acs_full_e2e.sh` e o que cada um prova. +- `spec/validation_report.md`: linha RF13 de `ausente` para `parcial` ("SAMU abre o discador; UBS segue sem fonte de contato") e riscar L-07 (`~~…~~`) com a nota de que a UBS permanece. +- Decisão §4: nota curta "a permissão de localização em primeiro plano só passou a estar no manifesto em 2026-10-01". +- `docs/roteiro-video-sponsor.md:153`: a linha do SAMU deixa de citar a mensagem de protótipo; a da UBS fica. + +- [ ] **Step 2: Registrar o achado do paciente, sem mexer nele** + +Em `PROGRESS.md`, uma linha em "O que continua em aberto": "`apps/patient` — manifesto `main` sem `INTERNET` explícito (hoje herdado de dependência); verificar com `aapt2 dump permissions` no APK de release antes de qualquer mudança." + +- [ ] **Step 3: Conferir links** + +Run: `./scripts/dev/check_documentation_links.sh` +Expected: sem links quebrados. + +- [ ] **Step 4: Commit** + +```bash +git add PROGRESS.md apps/CLAUDE.md spec/validation_report.md docs/superpowers/specs/2026-09-16-decisoes-produto-pos-validacao.md docs/roteiro-video-sponsor.md +git commit -m "docs: registra a finalização do app ACS (localização, SAMU, e2e)" +``` + +--- + +### Task 6: Barra final no emulador + +**Files:** nenhum (só verificação; qualquer correção volta à tarefa dona). + +**Interfaces:** consome Tasks 1–5. + +- [ ] **Step 1: Suítes herméticas** + +Run: `cd apps/acs && flutter analyze && flutter test` +Expected: `No issues found!` e **mais** de 198 testes (198 + 3 manifesto + 1 manifesto `tel` + 5 do discador = **207**), `All tests passed!`. + +- [ ] **Step 2: Emulador e stack no ar** + +```bash +export PATH="$PATH:$HOME/Android/Sdk/platform-tools:$HOME/Android/Sdk/emulator:$HOME/flutter/bin" +adb devices | grep -q "emulator-5554.*device" || { (cd /tmp && nohup emulator -avd Medium_Phone -port 5554 -no-snapshot-save -no-boot-anim >/tmp/emu.log 2>&1 &); adb wait-for-device; until [ "$(adb -s emulator-5554 shell getprop sys.boot_completed | tr -d '\r')" = 1 ]; do sleep 2; done; } +docker compose ps --format '{{.Name}} {{.Status}}' | grep -q 'sinalacs-serverpod.*healthy' || docker compose up --build -d +docker compose up database-seed +./scripts/dev/sync_dev_ca.sh +``` +Expected: `adb devices` lista `emulator-5554 device`; `sinalacs-serverpod … (healthy)`. + +- [ ] **Step 3: Bateria `integration_test` do ACS (stack de desenvolvimento)** + +```bash +set -a; source .env; set +a +adb -s emulator-5554 reverse tcp:8443 tcp:443; adb -s emulator-5554 reverse tcp:8883 tcp:8883 +( cd apps/acs && flutter test integration_test -d emulator-5554 \ + --dart-define=SINALACS_HOST=https://localhost:8443/ \ + --dart-define=SINALACS_MQTT_HOST=localhost \ + --dart-define=SINALACS_MQTT_PASSWORD="$MQTT_ACS_PASSWORD" \ + --dart-define=GOOGLE_MAPS_API_KEY="$GOOGLE_MAPS_API_KEY" ) +``` +Expected: os 15 do baseline **+ 1** (o do discador, Task 2) = 16 passam. Os dois `*_e2e.dart` não rodam aqui (o sufixo os esconde da descoberta por pasta), o que mantém `e2e.sh --emulator --full` e a CI iguais. + +- [ ] **Step 4: Os dois e2e novos** + +Run: `./scripts/qa/acs_gps_e2e.sh && ./scripts/qa/acs_gps_e2e.sh --sem-permissao && ./scripts/qa/acs_full_e2e.sh` +Expected: três `OK — …`. Depois: `docker compose up -d` e `ls .e2e/` sem `fixtures.json`. + +- [ ] **Step 5: Guardas do repositório** + +Run: `./scripts/qa/ci_invariants.sh && python3 scripts/qa/otp_relay_test.py && ./scripts/dev/check_documentation_links.sh` +Expected: todos verdes. (Os scripts novos **não** entram na CI: o `ci_invariants.sh` vigia a lista de jobs e o `android-e2e` já roda `e2e.sh --emulator`. Incluir o `acs_full_e2e.sh` na CI é decisão à parte — exige o banco de teste no runner.) + +- [ ] **Step 6: Estado do repositório** + +Run: `git status --short && git log --oneline -6` +Expected: árvore limpa; um commit por tarefa, **sem** `Co-Authored-By`. Sem push. + +--- + +## Self-review + +**Cobertura:** RF12 (GPS real: Tasks 1, 3), RF13/L-07 (SAMU: Task 2; UBS fora, justificado), RF07 + RF09 + RF11 + RBAC fim a fim no emulador (Task 4: login real, alerta MQTT, ACK, seletor com território, visita sincronizada e conferida no servidor), documentação (Task 5), execução no `emulator-5554` (Tasks 2–4, 6). RF08 cache, MFA/refresh token, iOS ficam explicitamente fora, com motivo. + +**Placeholders:** dois pontos dependem de execução e têm o caminho de decisão escrito: o `ack_` sumir após confirmar (Task 4, Step 4) e o caminho B do `--use-application-binary` (Task 3, Step 4, com os comandos). Nenhum "TBD". + +**Consistência de tipos/nomes:** `EmergencyDialer.dial(String) → Future` e `samuNumber` (Task 2) usados só na Task 2; `declares()`/`manifest` (Task 1) reusados na Task 2; `acsCredentialFromRelay()` devolve `({String matricula, String senha})` e é consumido com `.matricula`/`.senha` na Task 4; `E2ePatient.id` usado em `patient_`. + +**Review Focus:** as 5 linhas têm teste dono — 1 → Task 3 (`denied`), 2 e 3 → Task 2, 4 e 5 → Task 4. + +**Riscos que só a execução resolve:** (1) `flutter test --use-application-binary` preservar a permissão concedida (Task 3 tem o caminho B); (2) a imagem `Medium_Phone` ter discador (Task 2, Step 6 diz como distinguir); (3) o `pumpAndSettle` nunca assenta com MQTT/timers ativos — por isso `_pumpUntil` em vez de `pumpAndSettle`. diff --git a/docs/superpowers/plans/2026-10-01-issue-flutter-plugins-dependencies-e-conferencia-tema-claro.md b/docs/superpowers/plans/2026-10-01-issue-flutter-plugins-dependencies-e-conferencia-tema-claro.md new file mode 100644 index 00000000..8a8d545c --- /dev/null +++ b/docs/superpowers/plans/2026-10-01-issue-flutter-plugins-dependencies-e-conferencia-tema-claro.md @@ -0,0 +1,225 @@ +# Issue do `.flutter-plugins-dependencies` + conferência visual do tema claro — Plano de implementação + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Abrir uma issue no GitHub (e, em seguida, corrigi-la) para tirar `.flutter-plugins-dependencies` (e o irmão legado `.flutter-plugins`) do versionamento em `apps/acs` e `apps/patient`, e conferir visualmente no emulador as telas novas no tema claro. + +**Architecture:** Os dois arquivos são gerados por `flutter pub get` e carregam caminhos absolutos e timestamps da máquina de quem rodou — por isso conflitam em todo merge. O `.gitignore` de cada app já lista `.flutter-plugins-dependencies` (linha 30), mas o arquivo foi commitado antes e continua rastreado; `.flutter-plugins` nem está no `.gitignore`. A correção é `git rm --cached` + completar o `.gitignore`. A conferência visual é independente: sobe o emulador, alterna o `ThemeMode` para claro e captura as telas. + +**Tech Stack:** git, `gh` CLI, Flutter (`/home/rock/flutter/bin`), Android SDK em `~/Android/Sdk` (`adb`/`emulator` **não** estão no `PATH`). + +**Spec:** não há spec; o pedido do usuário (“abrir uma issue para tirar .flutter-plugins-dependencies do versionamento, que causa conflito nos merge. Conferir visualmente as telas novas no tema claro usando o emulador”) e `spec/ux_accessibility_assessment.md` (contraste) fazem as vezes. + +## Global Constraints + +- Texto de issue, commits, comentários e copy de UI em **português** (CLAUDE.md do projeto). +- Commits terminam com `Co-Authored-By: Claude Sonnet 5.5 `; PRs terminam com `🤖 Generated with [Claude Code](https://claude.com/claude-code)`. +- Nunca usar dados reais de paciente em capturas de tela; usar só as fixtures sintéticas do seed/e2e. +- Capturas de tela **não** vão para o git (ficam no scratchpad ou em `build/`, já ignorado); só o resumo textual vai na issue/PR. +- `apps/admin` não tem os arquivos rastreados (verificado: 0) — não mexer nele além de conferir o `.gitignore`. +- CI tem `workflow-lint` com `scripts/qa/ci_invariants.sh`; a mudança não toca workflows, mas rodar o script antes do PR. + +## Review Focus + +1. Clone limpo / outro dev roda `flutter pub get` depois da mudança: os arquivos são regenerados e **não** aparecem em `git status` (Task 2 verifica). +2. Branches abertas que ainda têm o arquivo rastreado: ao fazer merge da correção, o git pode reportar conflito modify/delete — o corpo da issue documenta a resolução (`git rm --cached`), Task 1. +3. `git rm --cached` NÃO pode apagar o arquivo do disco do dev (Flutter precisa dele para builds): Task 2 confere que os arquivos continuam existindo. +4. Os apps compilam e rodam sem os arquivos rastreados: `flutter pub get` + `flutter analyze` em ambos (Task 2). +5. Tema claro: texto/ícone com token de preenchimento (`red`/`accent`/…) em vez de `*OnSurface` fica ilegível sobre `Card`/`surfaceRaised` — o checklist da Task 3 olha exatamente isso. + +--- + +### Task 1: Abrir a issue + +**Files:** +- Create (temporário, scratchpad): `/tmp/claude-1000/-home-rock-Documents-Dev-APPs-SinalACS/cb28809e-5551-4f0b-8789-904ebcc2fcef/scratchpad/issue_flutter_plugins.md` + +**Interfaces:** +- Produces: número da issue (`#N`), usado no nome da branch e no `Closes #N` da Task 2. + +- [ ] **Step 1: Confirmar o estado atual (evidência para a issue)** + +Run: +```bash +cd /home/rock/Documents/Dev/APPs/SinalACS +git ls-files | grep -E 'flutter-plugins' +git check-ignore -v apps/acs/.flutter-plugins-dependencies apps/acs/.flutter-plugins +git log --oneline -5 -- apps/acs/.flutter-plugins-dependencies +``` +Expected: 4 arquivos listados (`apps/{acs,patient}/.flutter-plugins` e `.flutter-plugins-dependencies`); `check-ignore` não casa (rastreado vence o ignore) ou casa só o `-dependencies`; log mostra o commit que o adicionou. + +- [ ] **Step 2: Escrever o corpo da issue** + +Conteúdo do arquivo `issue_flutter_plugins.md`: +```markdown +## Problema + +`apps/acs/.flutter-plugins-dependencies`, `apps/patient/.flutter-plugins-dependencies` e os `.flutter-plugins` ao lado deles estão versionados. São arquivos **gerados** por `flutter pub get` e contêm caminhos absolutos da máquina (`/home//...`) e `date_created`. Cada dev regenera um conteúdo diferente, então todo merge entre branches conflita neles. + +O `.gitignore` de cada app já lista `.flutter-plugins-dependencies`, mas o arquivo foi commitado antes disso e o git continua rastreando-o. O `.flutter-plugins` (legado) nem está no `.gitignore`. + +## Proposta + +1. `git rm --cached` nos 4 arquivos (continuam no disco; o Flutter os regenera). +2. Adicionar `.flutter-plugins` ao `.gitignore` de `apps/acs`, `apps/patient` e `apps/admin`. +3. Verificar que `flutter pub get` regenera os arquivos sem que apareçam em `git status`. + +## Critérios de aceite + +- [ ] `git ls-files | grep flutter-plugins` não retorna nada. +- [ ] `flutter pub get` em `acs` e `patient` não suja o `git status`. +- [ ] `flutter analyze` limpo nos dois apps. +- [ ] CI verde (`workflow-lint`, `patient-app`, `acs-app`). + +## Quem tem branch aberta + +Ao integrar a correção, se o git acusar conflito modify/delete nesses arquivos, resolva com: + + git rm --cached apps/acs/.flutter-plugins apps/acs/.flutter-plugins-dependencies \ + apps/patient/.flutter-plugins apps/patient/.flutter-plugins-dependencies + +Depois rode `flutter pub get` normalmente. +``` + +- [ ] **Step 3: Pedir confirmação ao usuário e criar a issue** + +Criar issue é uma ação visível a terceiros: mostrar o corpo ao usuário e só então rodar: +```bash +gh issue create \ + --title "Tirar .flutter-plugins-dependencies e .flutter-plugins do versionamento" \ + --body-file /tmp/claude-1000/-home-rock-Documents-Dev-APPs-SinalACS/cb28809e-5551-4f0b-8789-904ebcc2fcef/scratchpad/issue_flutter_plugins.md +``` +Expected: URL da issue; anotar o número `N`. + +--- + +### Task 2: Corrigir (desversionar os arquivos) + +**Files:** +- Modify: `apps/acs/.gitignore`, `apps/patient/.gitignore`, `apps/admin/.gitignore` (acrescentar `.flutter-plugins` ao lado da linha `.flutter-plugins-dependencies`) +- Remove do índice (não do disco): `apps/acs/.flutter-plugins`, `apps/acs/.flutter-plugins-dependencies`, `apps/patient/.flutter-plugins`, `apps/patient/.flutter-plugins-dependencies` + +**Interfaces:** +- Consumes: número `N` da Task 1. + +- [ ] **Step 1: Branch a partir da develop** + +```bash +git switch develop && git pull --ff-only +git switch -c fix/N-desversionar-flutter-plugins +``` +(substituir `N`; se a política do repo for outro prefixo, seguir `CONTRIBUTING.md`.) + +- [ ] **Step 2: Completar os `.gitignore`** + +Em cada um dos três, logo abaixo da linha `.flutter-plugins-dependencies` (linha 30 no acs), adicionar a linha `.flutter-plugins`. Conferir antes com `grep -n 'flutter-plugins' apps/*/.gitignore` para não duplicar. + +- [ ] **Step 3: Desversionar sem apagar do disco** + +```bash +git rm --cached apps/acs/.flutter-plugins apps/acs/.flutter-plugins-dependencies \ + apps/patient/.flutter-plugins apps/patient/.flutter-plugins-dependencies +ls apps/acs/.flutter-plugins-dependencies apps/patient/.flutter-plugins-dependencies +``` +Expected: `ls` ainda encontra os arquivos (Review Focus 3). + +- [ ] **Step 4: Provar que regenerar não suja o git** + +```bash +for a in acs patient; do (cd apps/$a && flutter pub get && flutter analyze); done +git status --short +git ls-files | grep flutter-plugins || echo "ok: nada rastreado" +``` +Expected: `status` mostra só os `.gitignore` modificados e as 4 remoções (`D`); `analyze` sem issues; "ok: nada rastreado". + +- [ ] **Step 5: Guardas da CI** + +```bash +./scripts/qa/ci_invariants.sh +``` +Expected: saída 0. + +- [ ] **Step 6: Commit e PR** + +```bash +git add apps/acs/.gitignore apps/patient/.gitignore apps/admin/.gitignore +git commit -m "chore: tira .flutter-plugins e .flutter-plugins-dependencies do versionamento + +Arquivos gerados por flutter pub get, com caminhos absolutos da máquina; +conflitavam em todo merge. Closes #N + +Co-Authored-By: Claude Sonnet 5.5 " +``` +Abrir PR para `develop` (`gh pr create`, corpo termina com a linha “🤖 Generated with [Claude Code](https://claude.com/claude-code)”) — só após o usuário aprovar o push. + +--- + +### Task 3: Conferência visual das telas novas no tema claro (emulador) + +Independente das Tasks 1–2; pode rodar em paralelo. Nenhuma alteração de código esperada; se achar defeito, vira issue/commit à parte. + +**Files:** +- Referência: `apps/{acs,patient}/lib/core/services/theme_controller.dart`, `apps/{acs,patient}/lib/app/{acs,patient}_theme.dart`, `apps/{acs,patient}/test/contrast_tokens_test.dart`, `spec/ux_accessibility_assessment.md` +- Saída: capturas em `.../scratchpad/tema-claro/` (fora do git) + +**Interfaces:** +- Consumes: `ThemeController` (modo claro/escuro/automático, entregue pelo PR #11 `feat/theme-light-dark`). + +- [ ] **Step 1: Disponibilizar `adb` e o emulador** + +```bash +export ANDROID_HOME=$HOME/Android/Sdk +export PATH=$PATH:$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator +emulator -list-avds +adb devices +``` +Expected: ao menos um AVD listado (o e2e usou o `emulator-5554`). Se `adb`/`emulator` não existirem, avisar o usuário — não instalar nada sozinho. + +- [ ] **Step 2: Subir o emulador e o backend** + +```bash +emulator -avd -no-snapshot-save & +adb wait-for-device +./scripts/qa/e2e_stack.sh # ver --help; sobe a stack com o banco de teste e fixtures sintéticas +``` +Consultar a skill `validacao-e2e` para o fluxo exato de login real (OTP via relé) — usar só fixtures sintéticas. + +- [ ] **Step 3: Rodar cada app e forçar o tema claro** + +```bash +cd apps/patient && flutter run -d emulator-5554 +``` +No app: Perfil/Configurações → Aparência → **Claro** (ou, se a opção estiver ausente na tela, mudar o `ThemeMode` do `ThemeController` e fazer hot reload). Repetir para `apps/acs`. + +- [ ] **Step 4: Percorrer as telas novas e capturar** + +Para cada tela, `adb exec-out screencap -p > scratchpad/tema-claro/_.png` e abrir a imagem com Read para inspecionar. Telas novas a cobrir (a lista vem do histórico recente — confirmar com `git diff --stat 3a6ce44^1 HEAD -- 'apps/*/lib/**/*screen*.dart' 'apps/*/lib/**/*page*.dart'`): +- Paciente: login/OTP, aceite de termos, Meus Dados (LGPD), consentimento/solicitações de titular, aviso de mudança de termos, alerta de urgência, status do pedido. +- ACS: login institucional, fila priorizada, detalhe do paciente, registro de visita offline, mapa/micro-área, convite/QR. + +Checklist por tela (Review Focus 5): +- [ ] texto e ícones legíveis sobre `Card`/`surfaceRaised` (cores de risco usando `*OnSurface`, não o fill) +- [ ] nenhum fundo escuro “vazando” (hardcoded `Colors.black`/`0xFF1…`) +- [ ] bordas/divisórias visíveis; alvos de toque ≥ 48dp +- [ ] campos de formulário, estados desabilitado e de erro distinguíveis +- [ ] status bar/navigation bar com ícones de contraste correto + +- [ ] **Step 5: Registrar o resultado** + +Para cada defeito: tela, app, descrição e o token suspeito (`grep -rn 'Colors\.\|Color(0x' apps//lib/`). Abrir uma issue por defeito (com confirmação do usuário) ou reportar “nenhum defeito” com a lista de telas conferidas. Não afirmar que uma tela foi conferida sem ter visto a captura. + +- [ ] **Step 6: Encerrar** + +```bash +adb emu kill +cd /home/rock/Documents/Dev/APPs/SinalACS && docker compose down +``` +(`down` sem `-v`; não remover `pg_data/` sem o usuário pedir.) + +--- + +## Self-review + +- Cobertura: issue (Task 1), correção que a issue descreve (Task 2), conferência visual (Task 3) — os três pontos do pedido. +- Sem placeholders além de `N` (número da issue, só conhecido após a Task 1) e `` (saída do passo anterior). +- Nomes consistentes: os 4 arquivos e a branch `fix/N-desversionar-flutter-plugins` usados igual nas tasks. diff --git a/docs/superpowers/plans/2026-10-01-minors-da-revisao-do-app-acs.md b/docs/superpowers/plans/2026-10-01-minors-da-revisao-do-app-acs.md new file mode 100644 index 00000000..5f8c902f --- /dev/null +++ b/docs/superpowers/plans/2026-10-01-minors-da-revisao-do-app-acs.md @@ -0,0 +1,946 @@ +# Minors da revisão do app ACS — Plano de Implementação + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fechar os 7 achados Minor adiados na revisão final do branch `fix/app_acs`, sem alterar o comportamento do app ACS: só scripts de QA, um teste de integração, o relé de OTP e um número num relatório. + +**Architecture:** Cada achado vira uma correção pequena com verificação que falha antes. Os scripts de shell ganham testes herméticos (adb/flutter falsos no `PATH`, no molde de `scripts/qa/ci_push_e2e_test.sh`); o relé ganha teste Python; a asserção de microárea passa a ser conferida também no servidor; a contagem do relatório ganha um conferidor. + +**Tech Stack:** bash, Python 3 (`unittest`), `adb`/`uiautomator`, Flutter `integration_test`/`flutter drive`, Docker Compose (`e2e_stack.sh`). + +**Spec:** `docs/superpowers/plans/2026-10-01-finalizacao-app-acs.md` (seção "Desvios da execução" e Tasks 3–4, que criaram o que aqui se corrige). A revisão que originou os 7 itens não está em disco; a tabela abaixo a resume. + +> **Desvio da execução (Task 6) — leia antes dela.** O desenho de "priming em rodadas +> separadas" (`negar_de_vez`, modo `prime`, exit 5, checagem de `USER_FIXED` por `dumpsys`) +> **não funciona**: `flutter drive` desinstala o app ao terminar (medido), então o estado de +> permissão não sobrevive entre rodadas, e o `--dart-define` passado ao `drive` é ignorado com +> `--use-application-binary`. O que foi entregue: **uma rodada só**; o teste `denied_forever` +> chama `requestPermission()` até 2 vezes na mesma sessão enquanto um tocador (`uiautomator` + +> `input tap`) recusa cada diálogo; o script limita os toques a 2. O amostrador de foco da janela +> fica só no cenário `granted`. O id do botão medido foi `permission_deny_button`. + +## Os 7 itens e onde são fechados + +| # | Achado da revisão | Task | +|---|---|---| +| 1 | `spec/validation_report.md:96` diz "3 `parcial` · 5 `ausente`"; a tabela tem 4 · 4 | 1 | +| 2 | A permissão negada não simula `deniedForever` (caso comum em campo) | 6 | +| 3 | `acs_gps_e2e.sh` desinstala o app do ACS sem avisar (apaga fila SQLCipher e Keystore de dev) | 5 | +| 4 | `acs_gps_e2e.sh` esconde o erro do `flutter build` (`>/dev/null`) | 5 | +| 5 | A asserção RNF06 do e2e depende de não haver filtro padrão no seletor | 4 | +| 6 | A senha sintética de `/acs` é legível por qualquer app do emulador durante a execução (≤ 45 min) | 3 | +| 7 | A checagem da porta 8765 só enxerga `127.0.0.1` | 2 | + +## Estado verificado em 2026-10-01 + +- `adb`, `flutter` e `emulator` não estão no `PATH`: `export PATH="$PATH:$HOME/Android/Sdk/platform-tools:$HOME/Android/Sdk/emulator:$HOME/flutter/bin"`. +- **`pm set-permission-flags` não existe** no AVD `Medium_Phone` (Android 16): `adb shell pm help` não o lista. A sugestão do revisor para o item 2 não funciona como escrita; a Task 6 recusa o diálogo do sistema de verdade e **mede** antes de depender disso. +- `Geolocator.checkPermission()` devolve `denied` (não `deniedForever`) num app que nunca pediu a permissão; só `requestPermission()` devolve `deniedForever` sem abrir diálogo quando a permissão está `USER_FIXED`. A Task 6 asserta sobre `requestPermission()`. +- Tabela de RF de `spec/validation_report.md`: 18 linhas (`RF01`–`RF18`); contadas por `grep`: 4 `parcial`, 4 `ausente` (o texto diz 3 · 5). +- O emulador pode ainda ter o APK de teste do ACS de execuções anteriores instalado. Depois da Task 5, a primeira rodada do script de GPS exige `--reinstalar` (é o comportamento novo, não um defeito). +- Os mesmos 3 scripts repetem a checagem `ss -ltn 2>/dev/null | grep -q '127.0.0.1:8765 '`: `acs_full_e2e.sh:31`, `patient_full_e2e.sh:41`, `push_e2e.sh:93`. + +## Fora deste plano + +| Item | Motivo | +|---|---| +| Ligar os novos testes de shell/Python à CI | Exigiria mexer em `.github/workflows/ci.yml`, vigiado por `ci_invariants.sh`. Decisão à parte. | +| `iniciar_rele()` de `push_e2e.sh` | O revisor apontou que parece chamar a si mesma (defeito anterior ao branch). A Task 2 só troca a linha da checagem de porta; confira com `sed -n 90,102p scripts/qa/push_e2e.sh` e, se for mesmo recursiva, abra issue em vez de corrigir aqui. | +| Provar a chegada de um fix de GPS | Segue impossível neste AVD (ver o plano anterior). | + +## Global Constraints + +- Nenhum dado real de paciente em teste, log ou captura; só fixtures sintéticas. +- **Commits sem qualquer atribuição de IA** (`Co-Authored-By`, "Generated with Claude Code") — regra do `CLAUDE.md` da raiz. Textos, comentários e commits em português. Sem push nem merge. +- `ACCESS_BACKGROUND_LOCATION` continua proibida (não tocar o manifesto). +- A senha do ACS nunca vai para o argv do `flutter` nem para o APK (já vale; não regredir). +- `flutter analyze` em `apps/acs` sem problemas e `flutter test` em 208 (só pode crescer) antes de cada commit que toque Dart. +- `acs_full_e2e.sh` substitui a stack de desenvolvimento pela de e2e: ao final, `docker compose up -d` e conferir `sinalacs-serverpod ... (healthy)`. + +## Review Focus + +Falhas que o plano implica e nenhuma tarefa exercitaria sozinha; cada linha tem o teste dono: + +1. **App já instalado, sem `--reinstalar`:** o script aborta (exit 3) **antes** do build e sem nenhum `adb uninstall`. → Task 5, caso `guarda`. +2. **Falha no meio da execução** (build ok, `flutter drive` falha): o app de teste instalado pelo script é removido ao sair, para a próxima rodada não esbarrar na guarda. → Task 5, caso `drive_falha`. +3. **Porta 8765 ocupada em `0.0.0.0`/`[::]`** (não só `127.0.0.1`) é detectada. → Task 2. +4. **Segundo `GET /acs`** no mesmo relé devolve 404 (a senha sai uma vez só). → Task 3. +5. **Linha nova na tabela de RF com status fora da lista** (ex.: `**quase**`) faz o conferidor da contagem falhar, em vez de ser ignorada e deixar a contagem errada de novo. → Task 1. + +--- + +### Task 1: Conferidor da contagem do `validation_report.md` + +**Files:** +- Create: `scripts/qa/contagem_validation_report.py` +- Create: `scripts/qa/contagem_validation_report_test.py` +- Modify: `spec/validation_report.md:96` + +**Interfaces:** +- Consumes: nada. +- Produces: `contar(texto) -> dict[str,int]`, `conferir(texto) -> tuple[str,str]` (declarada, esperada) e o CLI `python3 scripts/qa/contagem_validation_report.py [arquivo]` (exit 0 confere; 1 diverge; 2 erro de formato). + +- [ ] **Step 1: Escrever o teste que falha** + +Criar `scripts/qa/contagem_validation_report_test.py`: + +```python +import unittest + +from contagem_validation_report import conferir, contar + +TABELA = """\ +| RF01 | A | **backend** | x | +| RF02 | B | **parcial** | x | +| RF03 | C | **ausente** | x | +| RNF01 | D | **parcial** | x | + +**Contagem:** {linha}. +""" + + +class ContagemTest(unittest.TestCase): + def test_ignora_rnf_e_confere(self): + declarada, esperada = conferir(TABELA.format(linha="1 `backend` · 1 `parcial` · 1 `ausente`")) + self.assertEqual(declarada, esperada) + + def test_detecta_divergencia(self): + declarada, esperada = conferir(TABELA.format(linha="1 `backend` · 2 `parcial` · 1 `ausente`")) + self.assertNotEqual(declarada, esperada) + + def test_status_desconhecido_e_erro(self): + with self.assertRaises(ValueError): + contar("| RF09 | Z | **quase** | x |\n") + + def test_sem_linha_de_contagem_e_erro(self): + with self.assertRaises(ValueError): + conferir("| RF01 | A | **backend** | x |\n") + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Rodar e ver falhar** + +Run: `cd scripts/qa && python3 contagem_validation_report_test.py` +Expected: FAIL — `ModuleNotFoundError: No module named 'contagem_validation_report'`. + +- [ ] **Step 3: Implementação mínima** + +Criar `scripts/qa/contagem_validation_report.py` (`chmod +x`): + +```python +#!/usr/bin/env python3 +"""Confere a linha "**Contagem:**" de spec/validation_report.md contra a tabela de RF. + +A linha foi escrita à mão e ficou para trás quando RF13 mudou de status; este +conferidor recalcula a partir das linhas `| RFnn | ... | **status** | ... |`. +Status fora da lista conhecida é erro (exit 2), não linha ignorada: ignorar +deixaria a contagem errada de novo, em silêncio. +""" +import pathlib +import re +import sys + +ORDEM = ["backend", "backend + app", "app-only", "parcial", "ausente"] +LINHA_RF = re.compile(r"^\| RF\d+ \|[^|]*\| \*\*([^*]+)\*\* \|", re.M) +LINHA_CONTAGEM = re.compile(r"^\*\*Contagem:\*\* (.+)\.$", re.M) + + +def contar(texto): + contagem = {chave: 0 for chave in ORDEM} + for status in LINHA_RF.findall(texto): + if status not in contagem: + raise ValueError(f"status desconhecido na tabela de RF: {status!r}") + contagem[status] += 1 + return contagem + + +def esperada(contagem): + return " · ".join(f"{contagem[chave]} `{chave}`" for chave in ORDEM if contagem[chave]) + + +def conferir(texto): + declarada = LINHA_CONTAGEM.search(texto) + if declarada is None: + raise ValueError("sem linha '**Contagem:**'") + return declarada.group(1), esperada(contar(texto)) + + +if __name__ == "__main__": + caminho = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else "spec/validation_report.md") + try: + declarada_, esperada_ = conferir(caminho.read_text(encoding="utf-8")) + except ValueError as erro: + print(f"erro: {erro}", file=sys.stderr) + sys.exit(2) + if declarada_ != esperada_: + print( + f"erro: contagem desatualizada em {caminho}\n declarada: {declarada_}\n da tabela: {esperada_}", + file=sys.stderr, + ) + sys.exit(1) + print("ok: contagem do validation_report confere") +``` + +- [ ] **Step 4: Rodar o teste e ver passar; rodar o conferidor e ver o defeito real** + +Run: `cd scripts/qa && python3 contagem_validation_report_test.py && cd ../.. && python3 scripts/qa/contagem_validation_report.py` +Expected: `OK` (4 testes) e o conferidor **falha** (exit 1) mostrando `declarada: 7 \`backend\` · 2 \`backend + app\` · 1 \`app-only\` · 3 \`parcial\` · 5 \`ausente\`` e `da tabela: …· 4 \`parcial\` · 4 \`ausente\``. Se algum dos três primeiros números da linha "da tabela" divergir de 7 · 2 · 1, **investigue a tabela antes de editar** — não aceite o valor às cegas. + +- [ ] **Step 5: Corrigir o relatório** + +Em `spec/validation_report.md:96`, trocar `3 \`parcial\` · 5 \`ausente\`` por `4 \`parcial\` · 4 \`ausente\``. + +Run: `python3 scripts/qa/contagem_validation_report.py` +Expected: `ok: contagem do validation_report confere`. + +- [ ] **Step 6: Commit** + +```bash +git add scripts/qa/contagem_validation_report.py scripts/qa/contagem_validation_report_test.py spec/validation_report.md +git commit -m "docs: corrige a contagem do validation_report e adiciona conferidor" +``` + +--- + +### Task 2: Checagem de porta 8765 em qualquer endereço + +**Files:** +- Create: `scripts/qa/lib_rele.sh` +- Create: `scripts/qa/lib_rele_test.sh` +- Modify: `scripts/qa/acs_full_e2e.sh:31`, `scripts/qa/patient_full_e2e.sh:41`, `scripts/qa/push_e2e.sh:93` + +**Interfaces:** +- Consumes: nada. +- Produces: `porta_ocupada ` (função bash; exit 0 se alguém escuta na porta TCP em **qualquer** endereço). + +- [ ] **Step 1: Escrever o teste que falha** + +Criar `scripts/qa/lib_rele_test.sh` (`chmod +x`): + +```bash +#!/usr/bin/env bash +# Teste hermético de scripts/qa/lib_rele.sh (porta_ocupada) e de que os três +# runners do relé a usam. Não precisa de emulador nem de Docker. +set -uo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/../.." +# shellcheck disable=SC1091 +source scripts/qa/lib_rele.sh +falhas=0 +confere() { "$@" || { echo "FALHOU: $*"; falhas=$((falhas + 1)); }; } +nega() { if "$@"; then echo "FALHOU (esperava falso): $*"; falhas=$((falhas + 1)); fi; } + +porta="$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1",0)); print(s.getsockname()[1])')" +nega porta_ocupada "$porta" + +for endereco in 127.0.0.1 0.0.0.0 ::; do + python3 -c ' +import socket, sys, time +familia = socket.AF_INET6 if ":" in sys.argv[1] else socket.AF_INET +s = socket.socket(familia) +s.bind((sys.argv[1], int(sys.argv[2]))) +s.listen() +time.sleep(30)' "$endereco" "$porta" 2>/dev/null & + pid=$! + for _ in $(seq 1 20); do porta_ocupada "$porta" && break; sleep 0.1; done + if kill -0 "$pid" 2>/dev/null; then + confere porta_ocupada "$porta" + else + echo "pulado: este host não consegue escutar em $endereco" + fi + kill "$pid" 2>/dev/null; wait "$pid" 2>/dev/null + for _ in $(seq 1 20); do porta_ocupada "$porta" || break; sleep 0.1; done +done + +for f in acs_full_e2e.sh patient_full_e2e.sh push_e2e.sh; do + nega grep -qF "grep -q '127.0.0.1:8765 '" "scripts/qa/$f" + confere grep -q "porta_ocupada 8765" "scripts/qa/$f" +done + +[[ "$falhas" -eq 0 ]] && echo "ok: lib_rele" || { echo "$falhas falha(s)"; exit 1; } +``` + +- [ ] **Step 2: Rodar e ver falhar** + +Run: `./scripts/qa/lib_rele_test.sh` +Expected: FAIL — `source: scripts/qa/lib_rele.sh: No such file or directory` (e, sem `set -e`, as asserções seguintes também falham). + +- [ ] **Step 3: Implementação mínima** + +Criar `scripts/qa/lib_rele.sh`: + +```bash +# Funções compartilhadas pelos runners que sobem o relé do OTP (otp_relay.py). +# Uso: source "$(dirname "${BASH_SOURCE[0]}")/lib_rele.sh" + +# Sucesso (0) se ALGUM processo escuta na porta TCP $1, em qualquer endereço +# (127.0.0.1, 0.0.0.0, [::] ou o IP da rede). Um relé antigo esquecido num +# deles responderia com código velho; olhar só 127.0.0.1 deixava passar os +# outros e o erro só aparecia no bind do relé novo, sem dizer o motivo. +porta_ocupada() { + ss -ltn "sport = :$1" 2>/dev/null | grep -q LISTEN +} +``` + +Trocar nos três scripts, com um passo único (cada arquivo tem exatamente uma ocorrência): + +```bash +python3 - <<'EOF' +import pathlib +for nome in ("acs_full_e2e.sh", "patient_full_e2e.sh", "push_e2e.sh"): + p = pathlib.Path("scripts/qa") / nome + s = p.read_text() + antigo = "if ss -ltn 2>/dev/null | grep -q '127.0.0.1:8765 '; then" + assert s.count(antigo) == 1, f"{nome}: esperava 1 ocorrência" + s = s.replace(antigo, "if porta_ocupada 8765; then") + marca = "set -euo pipefail\n" + assert marca in s, f"{nome}: sem 'set -euo pipefail'" + s = s.replace(marca, marca + 'source "$(dirname "${BASH_SOURCE[0]}")/lib_rele.sh"\n', 1) + p.write_text(s) +EOF +``` + +- [ ] **Step 4: Rodar e ver passar** + +Run: `./scripts/qa/lib_rele_test.sh && for f in acs_full_e2e patient_full_e2e push_e2e; do bash -n scripts/qa/$f.sh && echo "sintaxe ok: $f"; done` +Expected: `ok: lib_rele` (linhas "pulado" são aceitáveis) e três `sintaxe ok`. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/qa/lib_rele.sh scripts/qa/lib_rele_test.sh scripts/qa/acs_full_e2e.sh scripts/qa/patient_full_e2e.sh scripts/qa/push_e2e.sh +git commit -m "fix(qa): checagem da porta 8765 enxerga qualquer endereço, não só 127.0.0.1" +``` + +--- + +### Task 3: Senha do ACS servida uma única vez pelo relé + +**Files:** +- Modify: `scripts/qa/otp_relay.py` (cabeçalho, `Handler`, rota `/acs`) +- Modify: `scripts/qa/otp_relay_test.py` + +**Interfaces:** +- Consumes: `acs_do_manifesto(caminho)` e `Handler` de `otp_relay.py` (Task 4 do plano anterior). +- Produces: `Handler.acs_entregue: bool` (atributo de classe; `GET /acs` responde 200 uma vez por processo e 404 depois). + +- [ ] **Step 1: Escrever o teste que falha** + +Acrescentar a `scripts/qa/otp_relay_test.py`, antes do `if __name__ == "__main__":`: + +```python +class AcsEntregaUnicaTest(unittest.TestCase): + """Qualquer app do emulador alcança localhost:8765 (adb reverse): a senha + sintética do ACS só pode sair uma vez por execução do relé.""" + + def setUp(self): + import json + import os + import tempfile + import threading + from http.server import HTTPServer + + import otp_relay + + self.otp_relay = otp_relay + arquivo = tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) + json.dump({"acs": {"matricula": "E2E-1", "password": "s"}}, arquivo) + arquivo.close() + self.caminho = arquivo.name + os.environ["E2E_FIXTURES_FILE"] = self.caminho + otp_relay.Handler.acs_entregue = False + self.servidor = HTTPServer(("127.0.0.1", 0), otp_relay.Handler) + otp_relay.Handler.porta = self.servidor.server_address[1] + threading.Thread(target=self.servidor.serve_forever, daemon=True).start() + + def tearDown(self): + import os + + self.servidor.shutdown() + self.servidor.server_close() + os.environ.pop("E2E_FIXTURES_FILE", None) + os.unlink(self.caminho) + self.otp_relay.Handler.porta = 8765 + self.otp_relay.Handler.acs_entregue = False + + def _get(self): + import urllib.error + import urllib.request + + url = f"http://127.0.0.1:{self.servidor.server_address[1]}/acs" + try: + with urllib.request.urlopen(url) as resposta: + return resposta.status + except urllib.error.HTTPError as erro: + return erro.code + + def test_serve_uma_vez_e_depois_404(self): + self.assertEqual(self._get(), 200) + self.assertEqual(self._get(), 404) +``` + +- [ ] **Step 2: Rodar e ver falhar** + +Run: `cd scripts/qa && python3 otp_relay_test.py AcsEntregaUnicaTest` +Expected: FAIL — `AssertionError: 200 != 404` (o segundo pedido ainda recebe a senha). + +- [ ] **Step 3: Implementação mínima** + +Em `scripts/qa/otp_relay.py`: + +1. No docstring do módulo, acrescentar o parágrafo: + +``` +`/acs` (só com E2E_FIXTURES_FILE) entrega a matrícula e a senha SINTÉTICAS do ACS +da execução, **uma única vez por processo**: qualquer app do emulador que alcance +localhost:8765 (adb reverse) poderia lê-las, então depois do primeiro pedido bem +sucedido — o do teste, no começo — a rota responde 404. O relé é por execução. +``` + +2. Em `class Handler`, junto de `porta` e `container`: + +```python + acs_entregue = False +``` + +3. Na rota `/acs`, trocar a condição e marcar a entrega: + +```python + if url.path == "/acs": + credencial = acs_do_manifesto(os.environ.get("E2E_FIXTURES_FILE")) + if credencial is None or Handler.acs_entregue: + self.send_error(404); return + Handler.acs_entregue = True + self.send_response(200); self.send_header("Content-Type", "application/json"); self.end_headers() + self.wfile.write(json.dumps(credencial).encode()) + return +``` + +- [ ] **Step 4: Rodar e ver passar (suíte inteira do relé)** + +Run: `cd scripts/qa && python3 otp_relay_test.py` +Expected: `OK` (os 9 testes anteriores + 1 novo = 10). + +- [ ] **Step 5: Commit** + +```bash +git add scripts/qa/otp_relay.py scripts/qa/otp_relay_test.py +git commit -m "fix(qa): relé serve a senha sintética do ACS uma única vez por execução" +``` + +--- + +### Task 4: Asserção de microárea (RNF06) conferida também no servidor + +**Files:** +- Modify: `apps/acs/integration_test/full_journey_e2e.dart` (seletor e checagem final) + +**Interfaces:** +- Consumes: `acsCredentialFromRelay()`, `e2ePatient(role)` de `support/e2e_acs.dart`; `acsClient` (cliente separado, já criado no fim do teste); `api.Client.patients.listMicroArea({required String accessToken}) → Future>`; Task 3 (o `acs_full_e2e.sh` lê `/acs` uma vez só). +- Produces: nada além do teste. + +A UI esconde o paciente de fora por filtro de tela *e* por regra do servidor; a regra é do servidor. A asserção passa a olhar o servidor (e a tela passa a provar que o filtro de busca está vazio, para o `findsOneWidget` do paciente da microárea não ser enganado por um filtro). + +- [ ] **Step 1: Escrever a asserção (invertida) e ver que o teste a pega** + +Em `apps/acs/integration_test/full_journey_e2e.dart`, logo depois do `final acs = await acsClient.auth.loginInstitutional(...)`, acrescentar: + +```dart + // RNF06 no servidor: a lista da microárea nunca traz o paciente de fora. + // Quem garante é o backend, não o filtro do seletor. + final daMicroarea = await acsClient.patients.listMicroArea(accessToken: acs.accessToken); + expect(daMicroarea.any((p) => p.patientId == main.id), isTrue); + expect(daMicroarea.any((p) => p.patientId == outsider.id), isTrue, reason: 'MUTAÇÃO TEMPORÁRIA: deve falhar'); +``` + +(`isTrue` em `outsider` é **de propósito**: prova que a asserção morde.) + +Run: `./scripts/qa/acs_full_e2e.sh` +Expected: FAIL com `MUTAÇÃO TEMPORÁRIA: deve falhar` (e o manifesto `.e2e/fixtures.json` apagado pelo `cleanup`). + +- [ ] **Step 2: Corrigir a asserção e provar o filtro vazio na tela** + +Trocar a linha mutada por: + +```dart + expect(daMicroarea.any((p) => p.patientId == outsider.id), isFalse, reason: 'outra microárea'); +``` + +E, no trecho do seletor (logo depois de `expect(find.byKey(Key('patient_${main.id}')), findsOneWidget);`), acrescentar: + +```dart + // O seletor não pode estar filtrado: sem isto, "o de fora não aparece" + // poderia ser só um filtro por nome escondendo tudo o que não casa. + final busca = tester.widget(find.byKey(const Key('patient_search'))); + expect(busca.controller!.text, isEmpty, reason: 'o seletor deve abrir sem filtro de busca'); +``` + +- [ ] **Step 3: Rodar e ver passar** + +Run: `cd apps/acs && flutter analyze integration_test/full_journey_e2e.dart; cd ../.. && ./scripts/qa/acs_full_e2e.sh` +Expected: `No issues found!` e `OK — jornada completa do ACS contra o banco de teste`. Esta execução também exercita as Tasks 2 e 3 (porta e `/acs` de uso único). + +- [ ] **Step 3b: Devolver a stack de desenvolvimento** + +Run: `docker compose up -d && docker compose ps --format '{{.Name}} {{.Status}}' | grep serverpod` +Expected: `sinalacs-serverpod … (healthy)`; `ls .e2e/fixtures.json` → `No such file`. + +- [ ] **Step 4: Commit** + +```bash +git add apps/acs/integration_test/full_journey_e2e.dart +git commit -m "test(acs): confere RNF06 também no servidor e exige seletor sem filtro" +``` + +--- + +### Task 5: Script de GPS — guarda de reinstalação, erro do build visível, limpeza ao sair + +**Files:** +- Create: `scripts/qa/acs_gps_e2e_test.sh` +- Modify: `scripts/qa/acs_gps_e2e.sh` (reescrita do corpo; cabeçalho mantido e ampliado) + +**Interfaces:** +- Consumes: `integration_test/geofence_gps_e2e.dart` e `test_driver/integration_test.dart` (já existem). +- Produces: `acs_gps_e2e.sh [--sem-permissao] [--reinstalar]` com exit 0 = ok, 2 = argumento inválido, 3 = app já instalado sem `--reinstalar`, 4 = sem emulador, 1 = build/teste falhou; `MQTT_ACS_PASSWORD` pode vir do ambiente (o `.env` passa a ser opcional). O script **remove o app de teste ao sair** se foi ele quem o instalou. + +- [ ] **Step 1: Escrever o teste hermético que falha** + +Criar `scripts/qa/acs_gps_e2e_test.sh` (`chmod +x`): + +```bash +#!/usr/bin/env bash +# Teste hermético de scripts/qa/acs_gps_e2e.sh: adb e flutter FALSOS no PATH. +# Não precisa de emulador, de Docker nem do .env (a senha vai pelo ambiente). +set -uo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/../.." +tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT +mkdir "$tmp/bin" +export FAKE_LOG="$tmp/chamadas.log" + +cat >"$tmp/bin/adb" <<'EOF' +#!/usr/bin/env bash +echo "adb $*" >>"$FAKE_LOG" +case "$*" in + *get-state*) echo device ;; + *"pm list packages"*) [[ "${FAKE_INSTALADO:-0}" == 1 ]] && echo "package:br.com.prismrr.sinalacs.acs" ;; + *"dumpsys window"*) [[ "${FAKE_DIALOGO:-0}" == 1 ]] && echo "mCurrentFocus=Window{1 u0 com.google.android.permissioncontroller/x.GrantPermissionsActivity}" ;; + *"dumpsys package"*) [[ "${FAKE_FIXADA:-0}" == 1 ]] && echo "android.permission.ACCESS_FINE_LOCATION: granted=false, flags=[ USER_SET|USER_FIXED ]" ;; +esac +exit 0 +EOF +cat >"$tmp/bin/flutter" <<'EOF' +#!/usr/bin/env bash +echo "flutter $*" >>"$FAKE_LOG" +case "$1" in + build) [[ "${FAKE_BUILD_FALHA:-0}" == 1 ]] && { echo "ERRO-FALSO-DO-GRADLE: sem espaço" >&2; exit 1; } ;; + drive) [[ "${FAKE_DRIVE_FALHA:-0}" == 1 ]] && exit 1 ;; +esac +exit 0 +EOF +chmod +x "$tmp/bin/"* + +falhas=0 +caso() { # caso [VAR=valor ...] -- [args do script] + nome=$1; shift + envs=(); while [[ $# -gt 0 && $1 != -- ]]; do envs+=("$1"); shift; done; shift + : >"$FAKE_LOG" + saida="$(env PATH="$tmp/bin:$PATH" MQTT_ACS_PASSWORD=x "${envs[@]}" ./scripts/qa/acs_gps_e2e.sh "$@" 2>&1)"; codigo=$? +} +verifica() { eval "$1" || { echo "FALHOU [$nome]: $1"; echo "$saida" | tail -4; falhas=$((falhas + 1)); }; } +conta() { grep -c "$1" "$FAKE_LOG" || true; } + +caso guarda FAKE_INSTALADO=1 -- +verifica '[[ $codigo -eq 3 ]]' +verifica 'grep -q "APAGA" <<<"$saida"' +verifica '[[ $(conta uninstall) -eq 0 ]]' +verifica '[[ $(conta "^flutter build") -eq 0 ]]' + +caso build_falha FAKE_BUILD_FALHA=1 -- +verifica '[[ $codigo -ne 0 ]]' +verifica 'grep -q "o build do APK falhou" <<<"$saida"' +verifica 'grep -q "ERRO-FALSO-DO-GRADLE" <<<"$saida"' +verifica '[[ $(conta "install -r") -eq 0 ]]' + +caso reinstalar FAKE_INSTALADO=1 -- --reinstalar +verifica '[[ $codigo -eq 0 ]]' +verifica '[[ $(conta uninstall) -eq 2 ]]' # antes de instalar e ao sair + +caso drive_falha FAKE_DRIVE_FALHA=1 -- +verifica '[[ $codigo -ne 0 ]]' +verifica '[[ $(conta uninstall) -eq 1 ]]' # o app de teste não fica para a próxima rodada + +caso ok -- +verifica '[[ $codigo -eq 0 ]]' +verifica 'grep -q "fix de GPS não verificado" <<<"$saida"' + +caso argumento_invalido -- --nao-existe +verifica '[[ $codigo -eq 2 ]]' + +[[ "$falhas" -eq 0 ]] && echo "ok: acs_gps_e2e" || { echo "$falhas falha(s)"; exit 1; } +``` + +- [ ] **Step 2: Rodar e ver falhar** + +Run: `./scripts/qa/acs_gps_e2e_test.sh` +Expected: FAIL em vários casos — `guarda` (o script atual desinstala sem perguntar e sai 0), `build_falha` (sem mensagem), `reinstalar`/`drive_falha` (contagem de `uninstall` errada) e `argumento_invalido`. + +- [ ] **Step 3: Implementação** + +Reescrever `scripts/qa/acs_gps_e2e.sh` (mantendo o cabeçalho de comentário atual e acrescentando, depois dele, o parágrafo abaixo). O corpo: + +```bash +# GUARDA: se o app do ACS já estiver instalado no emulador, o script aborta +# (exit 3) — reinstalar APAGA a fila de visitas offline (SQLCipher) e a chave do +# Keystore do app de desenvolvimento. `--reinstalar` autoriza. O script é dono +# do pacote enquanto roda e o remove ao sair, para a próxima rodada não esbarrar +# na própria guarda. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$repo_root" +export PATH="$PATH:$HOME/Android/Sdk/platform-tools:$HOME/flutter/bin" +if [[ -f .env ]]; then set -a; source .env; set +a; fi +: "${MQTT_ACS_PASSWORD:?exporte MQTT_ACS_PASSWORD ou rode ./scripts/dev/bootstrap_env.sh}" +cd apps/acs + +dev=emulator-5554 +pkg=br.com.prismrr.sinalacs.acs +apk=build/app/outputs/flutter-apk/app-debug.apk +expect=granted +reinstalar=0 +for arg in "$@"; do + case "$arg" in + --sem-permissao) expect=denied ;; + --reinstalar) reinstalar=1 ;; + *) echo "argumento desconhecido: $arg" >&2; exit 2 ;; + esac +done +adb -s "$dev" get-state >/dev/null 2>&1 || { echo "emulador $dev não encontrado (adb devices)"; exit 4; } + +instalado() { adb -s "$dev" shell pm list packages "$pkg" 2>/dev/null | grep -q "^package:$pkg\$"; } +if instalado && [[ "$reinstalar" -eq 0 ]]; then + echo "erro: $pkg já está instalado em $dev." >&2 + echo " Reinstalar APAGA a fila de visitas offline (SQLCipher) e a chave do Keystore deste aparelho." >&2 + echo " Se for só o app de teste de uma execução anterior, rode de novo com --reinstalar." >&2 + exit 3 +fi + +log="$(mktemp)" +instalamos=0 +limpar() { + rm -f "$log" + if [[ "$instalamos" -eq 1 ]]; then adb -s "$dev" uninstall "$pkg" >/dev/null 2>&1 || true; fi +} +trap limpar EXIT + +if ! flutter build apk --debug --target=integration_test/geofence_gps_e2e.dart \ + --dart-define=SINALACS_MQTT_PASSWORD="$MQTT_ACS_PASSWORD" \ + --dart-define=EXPECT_PERMISSION="$expect" >"$log" 2>&1; then + echo "erro: o build do APK falhou. Últimas linhas:" >&2 + tail -n 30 "$log" >&2 + exit 1 +fi +if instalado; then adb -s "$dev" uninstall "$pkg" >/dev/null 2>&1 || true; fi +adb -s "$dev" install -r "$apk" >/dev/null +instalamos=1 +for p in ACCESS_FINE_LOCATION ACCESS_COARSE_LOCATION; do + if [[ "$expect" == granted ]]; then + adb -s "$dev" shell pm grant "$pkg" "android.permission.$p" + else + adb -s "$dev" shell pm revoke "$pkg" "android.permission.$p" || true + fi +done + +flutter drive --driver=test_driver/integration_test.dart \ + --target=integration_test/geofence_gps_e2e.dart -d "$dev" \ + --use-application-binary="$apk" \ + --dart-define=EXPECT_PERMISSION="$expect" +echo "OK — permissão de localização em runtime ($expect); fix de GPS não verificado" +``` + +Também atualizar a linha de uso do cabeçalho: `# ./scripts/qa/acs_gps_e2e.sh [--sem-permissao] [--reinstalar]`. + +- [ ] **Step 4: Rodar e ver passar** + +Run: `bash -n scripts/qa/acs_gps_e2e.sh && ./scripts/qa/acs_gps_e2e_test.sh` +Expected: `ok: acs_gps_e2e`. + +- [ ] **Step 5: Provar no emulador (concedida) e ver a guarda real** + +```bash +export PATH="$PATH:$HOME/Android/Sdk/platform-tools:$HOME/flutter/bin" +./scripts/qa/acs_gps_e2e.sh; echo "exit=$?" # há APK de teste de rodadas anteriores: deve abortar com 3 +./scripts/qa/acs_gps_e2e.sh --reinstalar; echo "exit=$?" +adb -s emulator-5554 shell pm list packages br.com.prismrr.sinalacs.acs | wc -l +``` +Expected: primeiro `exit=3` com a mensagem "APAGA…" (se o emulador estiver sem o app, `exit=0` — então rode só o segundo); depois `OK — permissão de localização em runtime (granted); fix de GPS não verificado`, `exit=0`, e o último comando imprime `0` (app removido ao sair). + +- [ ] **Step 6: Commit** + +```bash +git add scripts/qa/acs_gps_e2e.sh scripts/qa/acs_gps_e2e_test.sh +git commit -m "fix(qa): script de GPS avisa antes de apagar o app, mostra o erro do build e limpa ao sair" +``` + +--- + +### Task 6: Permissão "negada de vez" (`deniedForever`) no e2e de GPS + +**Files:** +- Modify: `scripts/qa/acs_gps_e2e.sh` (negar de vez, amostrador de diálogo) +- Modify: `scripts/qa/acs_gps_e2e_test.sh` (casos novos) +- Modify: `apps/acs/integration_test/geofence_gps_e2e.dart` (modos `denied_forever` e `prime`) + +**Interfaces:** +- Consumes: Task 5 (estrutura do script, `limpar`, `instalado`, `FAKE_*` do teste). +- Produces: `--sem-permissao` passa a significar "negada de vez" (`USER_FIXED`), o estado de um ACS que já recusou duas vezes; `EXPECT_PERMISSION` ∈ `granted | denied_forever | prime`; exit 5 = não foi possível levar a permissão a `USER_FIXED`; o script falha (exit 1) se o diálogo de permissão do sistema aparecer por cima do app. + +**Por que não `pm set-permission-flags`:** não existe neste Android (ver "Estado verificado"). O caminho é recusar o diálogo do sistema duas vezes com `uiautomator` + `input tap` (no Android 11+, dois "Não permitir" tornam a negação permanente), e **conferir** o resultado em `dumpsys package` (`granted=false` com `USER_FIXED`). O cenário antigo (revogada mas ainda pedível) é descartado: deixava o diálogo aberto por cima do app, e o teste de widget do Flutter nem percebia. + +- [ ] **Step 1: Medir os ids reais dos botões do diálogo** + +```bash +export PATH="$PATH:$HOME/Android/Sdk/platform-tools:$HOME/flutter/bin" +./scripts/qa/acs_gps_e2e.sh --sem-permissao --reinstalar > /tmp/claude-1000/gps_med.log 2>&1 & +sleep 40 # (medição manual, uma vez) — com o diálogo na tela: +adb -s emulator-5554 exec-out uiautomator dump /dev/tty | grep -o 'resource-id="com.android.permissioncontroller[^"]*"' | sort -u +``` +Expected: uma lista contendo `permission_deny_button` e/ou `permission_deny_and_dont_ask_again_button`. Se os ids forem **outros**, troque os dois nomes em `tocar_recusar` (Step 3) pelos medidos, e registre no commit. Se o diálogo não aparecer (nenhum `resource-id` do `permissioncontroller`), vá para **Step 8 (fallback)**. + +- [ ] **Step 2: Escrever os testes herméticos que falham** + +Em `scripts/qa/acs_gps_e2e_test.sh`, antes da linha `[[ "$falhas" -eq 0 ]] && …`, acrescentar: + +```bash +caso dialogo FAKE_DIALOGO=1 -- +verifica '[[ $codigo -ne 0 ]]' +verifica 'grep -q "diálogo de permissão" <<<"$saida"' + +caso sem_permissao FAKE_FIXADA=1 -- --sem-permissao +verifica '[[ $codigo -eq 0 ]]' +verifica '[[ $(conta "pm revoke") -eq 2 ]]' +verifica 'grep -q "EXPECT_PERMISSION=denied_forever" "$FAKE_LOG"' +verifica '[[ $(conta "EXPECT_PERMISSION=prime") -eq 0 ]]' # já fixada: não precisa recusar de novo + +caso nao_fixa FAKE_FIXADA=0 -- --sem-permissao +verifica '[[ $codigo -eq 5 ]]' +verifica 'grep -q "USER_FIXED" <<<"$saida"' +verifica '[[ $(conta "EXPECT_PERMISSION=prime") -eq 3 ]]' +``` + +Run: `./scripts/qa/acs_gps_e2e_test.sh` +Expected: FAIL nos três casos novos (o script ainda não tem amostrador, nem `denied_forever`, nem exit 5). + +- [ ] **Step 3: Implementar no script** + +Em `scripts/qa/acs_gps_e2e.sh`: + +1. `--sem-permissao) expect=denied_forever ;;`. + +2. Antes do bloco `for p in ACCESS_FINE_LOCATION …`, definir as funções: + +```bash +# "Negada de vez" = granted=false com USER_FIXED (o app não consegue mais abrir o diálogo). +negada_de_vez() { + adb -s "$dev" shell dumpsys package "$pkg" | grep -Eq 'ACCESS_FINE_LOCATION: granted=false, flags=\[[^]]*USER_FIXED' +} + +# Toca em "Não permitir" no diálogo de permissão do sistema, se ele estiver na tela. +tocar_recusar() { + local xml xy + xml="$(adb -s "$dev" exec-out uiautomator dump /dev/tty 2>/dev/null || true)" + xy="$(python3 - "$xml" <<'PY' +import re, sys +xml = sys.argv[1] +for rid in ("permission_deny_and_dont_ask_again_button", "permission_deny_button"): + m = re.search(r'resource-id="com\.android\.permissioncontroller:id/%s"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"' % rid, xml) + if m: + x1, y1, x2, y2 = map(int, m.groups()) + print((x1 + x2) // 2, (y1 + y2) // 2) + break +PY +)" + if [[ -n "$xy" ]]; then adb -s "$dev" shell input tap $xy; fi + return 0 +} + +# Não há `pm set-permission-flags` neste Android (medido): a única forma de chegar a +# USER_FIXED é recusar o diálogo do sistema de verdade. Roda o teste em modo `prime` +# (ele só abre o painel e deixa o app pedir a permissão) com um tocador recusando. +negar_de_vez() { + local tentativa tocador + for tentativa in 1 2 3; do + if negada_de_vez; then return 0; fi + ( while true; do tocar_recusar; sleep 1; done ) & + tocador=$! + flutter drive --driver=test_driver/integration_test.dart \ + --target=integration_test/geofence_gps_e2e.dart -d "$dev" \ + --use-application-binary="$apk" --dart-define=EXPECT_PERMISSION=prime || true + kill "$tocador" 2>/dev/null || true + done + negada_de_vez +} +``` + +3. O bloco de permissões passa a ser: + +```bash +if [[ "$expect" == granted ]]; then + for p in ACCESS_FINE_LOCATION ACCESS_COARSE_LOCATION; do + adb -s "$dev" shell pm grant "$pkg" "android.permission.$p" + done +else + for p in ACCESS_FINE_LOCATION ACCESS_COARSE_LOCATION; do + adb -s "$dev" shell pm revoke "$pkg" "android.permission.$p" || true + done + negar_de_vez || { echo "erro: não consegui levar a permissão a USER_FIXED (negada de vez) em 3 tentativas." >&2; exit 5; } +fi +``` + +4. Amostrador do diálogo e o `drive` final (substituindo o `flutter drive` e o `echo` finais). Declarar `amostras=""` e `amostrador=""` junto de `instalamos=0`, e estender `limpar()`: + +```bash +limpar() { + rm -f "$log" "$amostras" + if [[ -n "$amostrador" ]]; then kill "$amostrador" 2>/dev/null || true; fi + if [[ "$instalamos" -eq 1 ]]; then adb -s "$dev" uninstall "$pkg" >/dev/null 2>&1 || true; fi +} +``` + +e no fim do script: + +```bash +# O teste de widget do Flutter injeta toques no próprio Flutter e NÃO percebe um diálogo +# do sistema aberto por cima do app. Quem percebe é o foco da janela, amostrado durante a execução. +amostras="$(mktemp)" +amostrar() { adb -s "$dev" shell dumpsys window 2>/dev/null | grep -m1 mCurrentFocus >>"$amostras" || true; } +( while true; do amostrar; sleep 1; done ) & +amostrador=$! + +drive_rc=0 +flutter drive --driver=test_driver/integration_test.dart \ + --target=integration_test/geofence_gps_e2e.dart -d "$dev" \ + --use-application-binary="$apk" \ + --dart-define=EXPECT_PERMISSION="$expect" || drive_rc=$? +amostrar # uma leitura final garante ao menos uma amostra mesmo num teste curto +kill "$amostrador" 2>/dev/null || true; amostrador="" +if grep -q permissioncontroller "$amostras"; then + echo "erro: o diálogo de permissão do sistema apareceu por cima do app durante o teste." >&2 + exit 1 +fi +if [[ "$drive_rc" -ne 0 ]]; then exit "$drive_rc"; fi +echo "OK — permissão de localização em runtime ($expect); fix de GPS não verificado" +``` + +(Ajustar `limpar` do Task 5: o `amostras="$(mktemp)"` fica antes do `trap`, no mesmo ponto em que `log` é criado, para o `rm -f` funcionar mesmo em saída antecipada: declarar `amostras=""`/`amostrador=""` no topo e atribuir `amostras` ao chegar nesse bloco.) + +- [ ] **Step 4: Rodar os testes herméticos** + +Run: `bash -n scripts/qa/acs_gps_e2e.sh && ./scripts/qa/acs_gps_e2e_test.sh` +Expected: `ok: acs_gps_e2e` (os casos da Task 5 continuam verdes; o caso `nao_fixa` leva ~3 s por causa do `sleep 1` do tocador). + +- [ ] **Step 5: Ajustar o teste Dart aos modos novos** + +Em `apps/acs/integration_test/geofence_gps_e2e.dart`: + +1. Atualizar o comentário da constante e o bloco da permissão. Trocar o `if (_expect == 'granted') {…} else {…}` inicial por: + +```dart + final permission = await Geolocator.checkPermission(); + if (_expect == 'granted') { + expect(permission, anyOf(LocationPermission.whileInUse, LocationPermission.always), + reason: 'o manifesto precisa declarar ACCESS_FINE_LOCATION para a permissão existir'); + } else if (_expect == 'denied_forever') { + // Negada de vez (USER_FIXED): o `requestPermission` volta na hora, sem diálogo. + // (`checkPermission` sozinho devolve `denied`: só o pedido distingue o "de vez".) + expect(permission, isNot(anyOf(LocationPermission.whileInUse, LocationPermission.always))); + expect(await Geolocator.requestPermission(), LocationPermission.deniedForever); + } + // `prime`: não asserta nada sobre a permissão; só deixa o app pedi-la para o script + // do emulador recusar o diálogo (ver scripts/qa/acs_gps_e2e.sh, `negar_de_vez`). +``` + +2. Trocar `if (_expect == 'denied') {` (mais abaixo) por `if (_expect == 'denied_forever') {`. + +3. No comentário do cabeçalho, trocar "concede ou revoga a permissão por `pm`" por "concede por `pm` ou leva a permissão a negada-de-vez recusando o diálogo". + +Run: `cd apps/acs && flutter analyze integration_test/geofence_gps_e2e.dart` +Expected: `No issues found!`. + +- [ ] **Step 6: Provar no emulador — negada de vez** + +```bash +export PATH="$PATH:$HOME/Android/Sdk/platform-tools:$HOME/flutter/bin" +./scripts/qa/acs_gps_e2e.sh --sem-permissao --reinstalar; echo "exit=$?" +``` +Expected: `OK — permissão de localização em runtime (denied_forever); fix de GPS não verificado`, `exit=0`. Se sair `exit=5`, o tocador não recusou o diálogo: volte ao Step 1 (ids) ou ao Step 8. + +Prova contrária (RED real do cenário antigo): antes de implementar o Step 3, rodar a versão da Task 5 com `--sem-permissao --reinstalar` **mais o amostrador** mostra `erro: o diálogo de permissão do sistema apareceu…` (o cenário antigo deixava o diálogo aberto); depois do Step 3 o amostrador não vê o diálogo. + +- [ ] **Step 7: Confirmar a concedida e commitar** + +```bash +./scripts/qa/acs_gps_e2e.sh --reinstalar; echo "exit=$?" +git add scripts/qa/acs_gps_e2e.sh scripts/qa/acs_gps_e2e_test.sh apps/acs/integration_test/geofence_gps_e2e.dart +git commit -m "test(acs): permissão negada de vez e checagem de diálogo do sistema no e2e de GPS" +``` +Expected: `OK — … (granted) …`, `exit=0`. + +- [ ] **Step 8: Fallback (só se o Step 1 ou o Step 6 mostrarem que recusar o diálogo não funciona neste AVD)** + +Não é "pular o item": registre a decisão. Reverter `negar_de_vez`/`tocar_recusar`/`prime` (`git checkout -- scripts/qa/acs_gps_e2e.sh apps/acs/integration_test/geofence_gps_e2e.dart`), **manter** o amostrador de diálogo e os casos `dialogo` do teste, voltar `--sem-permissao` ao `pm revoke` e documentar no cabeçalho de `geofence_gps_e2e.dart` e no `PROGRESS.md`: "o caso `deniedForever` não é simulável neste AVD: `pm set-permission-flags` não existe e o diálogo não pôde ser recusado por `uiautomator` (medido em AAAA-MM-DD, motivo: …); coberto apenas por revisão". Commit: `docs(acs): registra o limite do cenário deniedForever no AVD`. + +--- + +### Task 7: Documentação e barra final + +**Files:** +- Modify: `PROGRESS.md` (seção "Finalização do app ACS (2026-10-01)") +- Modify: `apps/CLAUDE.md` (parágrafo "ACS: permissões, SAMU e e2e") + +**Interfaces:** consome as Tasks 1–6. + +- [ ] **Step 1: Atualizar os textos** + +- `PROGRESS.md`, na seção "Finalização do app ACS (2026-10-01)", acrescentar um item "**Minors da revisão — fechados:**" listando os 7 (contagem do relatório com conferidor `scripts/qa/contagem_validation_report.py`; `deniedForever` simulado por recusa real do diálogo — ou o fallback, se foi o caso —; o script de GPS aborta se o app já está instalado e exige `--reinstalar`, mostra o erro do build e remove o app de teste ao sair; RNF06 conferido também por `patients.listMicroArea` e com seletor sem filtro; `/acs` servido uma vez por execução; checagem de porta em qualquer endereço via `scripts/qa/lib_rele.sh`). +- `apps/CLAUDE.md`, no parágrafo "ACS: permissões, SAMU e e2e", trocar a frase de `acs_gps_e2e.sh` por: "`scripts/qa/acs_gps_e2e.sh [--sem-permissao] [--reinstalar]` prova a permissão em runtime — concedida, ou negada de vez (`USER_FIXED`), sempre com checagem de que o diálogo do sistema não aparece — e aborta se o app já estiver instalado (reinstalar apaga a fila SQLCipher e o Keystore; `--reinstalar` autoriza); não prova a chegada de um fix". Acrescentar: "O relé de OTP serve `/acs` (senha sintética do ACS, opt-in por `E2E_FIXTURES_FILE`) **uma única vez** por execução." + +- [ ] **Step 2: Barra final** + +```bash +export PATH="$PATH:$HOME/Android/Sdk/platform-tools:$HOME/flutter/bin" +python3 scripts/qa/contagem_validation_report.py +(cd scripts/qa && python3 contagem_validation_report_test.py && python3 otp_relay_test.py) +./scripts/qa/lib_rele_test.sh && ./scripts/qa/acs_gps_e2e_test.sh +(cd apps/acs && flutter analyze && flutter test) +./scripts/qa/ci_invariants.sh && ./scripts/qa/check_documentation_links.sh +./scripts/qa/acs_gps_e2e.sh --reinstalar && ./scripts/qa/acs_gps_e2e.sh --sem-permissao --reinstalar +./scripts/qa/acs_full_e2e.sh; docker compose up -d +``` +Expected: todos verdes: conferidor `ok`; Python `OK` (4 + 10 testes); `ok: lib_rele` e `ok: acs_gps_e2e`; `flutter analyze` sem problemas e `flutter test` ≥ 208; `ci_invariants` `ok: 9 grupos`; links sem erro; os dois GPS `OK`; jornada `OK`; `sinalacs-serverpod … (healthy)` e `ls .e2e/fixtures.json` → inexistente. + +- [ ] **Step 3: Estado do repositório e commit** + +```bash +git status --short && git log --oneline -9 && git log -9 --format=%B | grep -ci "co-authored\|generated with" +git add PROGRESS.md apps/CLAUDE.md +git commit -m "docs: registra o fechamento dos minors da revisão do app ACS" +``` +Expected: árvore limpa depois do commit; `0` na contagem de atribuições de IA; sem push. + +--- + +## Self-review + +**Cobertura:** item 1 → Task 1; 2 → Task 6 (com medição e fallback explícito); 3 e 4 → Task 5; 5 → Task 4; 6 → Task 3; 7 → Task 2; documentação e barra → Task 7. + +**Placeholders:** não há "TBD". Dois pontos dependem de medir no emulador e têm as duas saídas escritas: os ids dos botões do diálogo (Task 6, Step 1) e se recusar o diálogo chega a `USER_FIXED` (Task 6, Step 6 → Step 8 de fallback). O Step 6 também descreve a "prova contrária" em prosa, não como comando. + +**Consistência de nomes:** `porta_ocupada` (Task 2) usado só nos 3 runners; `Handler.acs_entregue` (Task 3) só no relé; `instalado`, `limpar`, `instalamos`, `amostras`, `amostrador` definidos na Task 5/6 e usados no mesmo script; `EXPECT_PERMISSION` ∈ `granted|denied_forever|prime` coerente entre o `.sh`, o teste hermético (`grep "EXPECT_PERMISSION=denied_forever"`) e o Dart; exit codes 2/3/4/5/1 batem entre o script e o teste. + +**Review Focus:** as 5 linhas têm teste dono (Task 5 `guarda` e `drive_falha`; Task 2; Task 3; Task 1). + +**Riscos que só a execução resolve:** (1) o `uiautomator` achar e tocar o botão no Android 16 (Task 6 tem medição e fallback); (2) `ss -ltn "sport = :N"` em hosts sem IPv6 (o teste pula o endereço `::`); (3) o caso `nao_fixa` leva ~3 s por causa do tocador em loop. diff --git a/docs/superpowers/plans/2026-10-01-reconciliar-develop-com-origin.md b/docs/superpowers/plans/2026-10-01-reconciliar-develop-com-origin.md new file mode 100644 index 00000000..f14214b1 --- /dev/null +++ b/docs/superpowers/plans/2026-10-01-reconciliar-develop-com-origin.md @@ -0,0 +1,303 @@ +# Reconciliar `develop` local com `origin/develop` — Plano + +> **Para agentes:** SUB-SKILL OBRIGATÓRIA: use superpowers:executing-plans (recomendado, é uma sequência única e acoplada) ou superpowers:subagent-driven-development. Passos usam checkbox (`- [ ]`). + +**Goal:** Integrar os 7 commits de `origin/develop` nos 90 commits locais de `develop`, sem perder nenhum dos dois lados e sem reescrever histórico publicado. + +**Architecture:** Um único `git merge origin/develop` (não rebase: 90 commits repetiriam os mesmos conflitos 90 vezes, e o remoto já usa merge commits de PR). Rede de segurança com tag de backup. Os 8 arquivos em conflito são resolvidos um a um, depois validados com os testes dos apps e o guarda de CI. A publicação vai por branch + PR, porque `develop` é protegida. + +**Tech Stack:** git, Flutter/Dart (apps ACS e Paciente), bash/python (`scripts/qa/ci_invariants.sh`), GitHub CLI. + +**Spec:** não há spec; a fonte é a análise de `git log` abaixo. + +## Diagnóstico (medido em 2026-10-01) + +- Merge-base: `a9757fd` (2026-09-28 21:22). +- Local (90 commits, todos de Herbert): RF05/RF14/RF18 LGPD, QR de onboarding, push/Gorush/FCM, endurecimento do CI. +- Remoto (7 commits, 3 PRs): + - PR #11 `feat/theme-light-dark` (vinimartinsufrr): tema claro/escuro/automático nos apps ACS e Paciente (`acs_theme.dart`, `patient_theme.dart`, `theme_controller.dart`, `shared_preferences`, `contrast_tokens_light_test.dart`). + - PR #27 `fix/ci-invariants-parsing`: `ci_invariants.sh` aceita `on:` string/lista e rejeita flag errada (issue #22). + - PR #29 `docs/publicacao-google-play` (sarah): `docs/publicacao-google-play.md`, `docs/README.md`. +- Simulação `git merge-tree` (sem tocar na árvore): 8 arquivos em conflito. + +| Arquivo | Natureza do conflito | Resolução | +|---|---|---| +| `CLAUDE.md` | 2 bullets de CI reescritos dos dois lados | manter a versão **local** (tem FCM/Gorush/guarda ampliado) e conferir se a versão remota acrescenta algo | +| `scripts/qa/ci_invariants.sh` | remoto: `PYTHONIOENCODING` + `$caminho_workflow`; local: `${CI_WORKFLOW_PATH:-...}` | **união**: manter o parsing de `on:`/flags do remoto **e** os checks novos do local | +| `apps/acs/pubspec.yaml` | local `qr_flutter`, remoto `shared_preferences` | manter **as duas** dependências | +| `apps/acs/pubspec.lock`, `apps/patient/pubspec.lock` | lock divergente | nunca editar à mão: resolver `pubspec.yaml` e regenerar com `flutter pub get` | +| `apps/patient/.flutter-plugins-dependencies` | arquivo gerado, versionado por engano (2 no repo) | regenerar com `flutter pub get`; abrir issue para tirá-lo do índice | +| `apps/acs/lib/app/app.dart` (4 hunks) | remoto refatora para tema controlável; local adiciona fluxos | integrar os dois, ver Task 3 | +| `apps/patient/lib/app/app.dart` (6 hunks) | idem, com termos/consentimento/QR | idem | +| `apps/patient/test/contrast_tokens_test.dart` | remoto troca `PatientColors.*` por `PatientDarkColors.*`/`risk.*`; local adicionou 3 casos do cartão de termos | manter os casos novos do local, escritos com o esquema de cores do remoto | + +## Global Constraints + +- Nunca `git push --force` em `develop`/`main` (protegidas; `origin/develop` já recebeu PRs de terceiros). +- Nunca perder commit: tag `backup/develop-pre-merge-2026-10-01` antes de qualquer coisa. +- Contraste WCAG: texto/ícone em cor clínica usa os tokens `*OnSurface`, medidos sobre `Card`/`surfaceRaised` (CLAUDE.md). Vale para os **dois** temas. +- Os 8 checks obrigatórios de `./scripts/qa/ci_invariants.sh --checks-obrigatorios` devem passar para o PR entrar. +- Mensagens de commit em português, no estilo `tipo(escopo): resumo`. + +## Review Focus + +- Tema claro: telas novas do local (Privacidade e termos, QR, cartão de aviso de termos, Meus dados) podem usar `PatientColors.*` fixo escuro e ficar ilegíveis no tema claro. Esperado: contraste ≥ 4.5:1 nos dois temas. +- `ci_invariants.sh` mesclado pode aceitar flag errada de novo ou quebrar com `on: push`. Esperado: exit 2 e JSON só em `--checks-obrigatorios`. +- Migração/dados: nenhum arquivo do backend é tocado pelo remoto, mas confirme que nada de `backend/` aparece no diff final além do esperado. +- Lock files: `pubspec.lock` regenerado não deve rebaixar pacotes que o local já havia fixado. + +--- + +### Task 1: Rede de segurança e árvore limpa + +**Files:** nenhum arquivo muda. + +**Interfaces:** +- Produces: tag `backup/develop-pre-merge-2026-10-01` apontando para `f0666dc`. + +- [ ] **Step 1: Confirmar árvore limpa e ponto de partida** + +Run: `git status --short && git rev-parse --short HEAD` +Expected: saída de status vazia; `f0666dc`. + +- [ ] **Step 2: Criar backup e buscar o remoto** + +```bash +git tag backup/develop-pre-merge-2026-10-01 HEAD +git fetch origin +git rev-list --left-right --count develop...origin/develop +``` +Expected: `90 7` (se o remoto andou, o segundo número sobe; reavalie os conflitos com `git merge-tree --write-tree --name-only develop origin/develop`). + +- [ ] **Step 3: Trabalhar em branch de integração** + +```bash +git switch -c integrar/develop-com-origin-2026-10-01 +``` +Expected: `develop` local fica intacta até o fim (fácil de abandonar). + +### Task 2: Iniciar o merge e resolver os arquivos textuais simples + +**Files:** +- Modify: `CLAUDE.md`, `apps/acs/pubspec.yaml`, `scripts/qa/ci_invariants.sh` + +**Interfaces:** +- Consumes: branch da Task 1. +- Produces: `pubspec.yaml` do ACS com `qr_flutter` **e** `shared_preferences`; `ci_invariants.sh` sem marcadores de conflito. + +- [ ] **Step 1: Iniciar o merge sem commit** + +Run: `git merge --no-commit --no-ff origin/develop` +Expected: `CONFLICT` em 8 arquivos (lista da tabela acima) e auto-merge no restante. + +- [ ] **Step 2: `CLAUDE.md`** + +Abra o arquivo, localize `<<<<<<< HEAD`. Fique com o lado local (`HEAD`) dos dois bullets de CI. Compare com o lado remoto (`git show origin/develop:CLAUDE.md | grep -n 'CI lives'`) e traga qualquer fato novo que só o remoto tenha. Remova os marcadores. +Run: `grep -n '^<<<<<<<\|^=======\|^>>>>>>>' CLAUDE.md` +Expected: sem saída. + +- [ ] **Step 3: `apps/acs/pubspec.yaml`** + +Deixe as duas linhas em `dependencies:`: + +```yaml + qr_flutter: ^4.1.0 + # Preferência de tema (claro/escuro/automático) — dado de UI não sensível, + # não precisa do Keystore/Keychain do flutter_secure_storage. + shared_preferences: ^2.3.2 +``` + +- [ ] **Step 4: `scripts/qa/ci_invariants.sh`** + +Fique com a linha de exec do local, que respeita `CI_WORKFLOW_PATH`, e acrescente o `PYTHONIOENCODING` do remoto antes dela: + +```bash +export PYTHONIOENCODING=utf-8 + +exec python3 - "${CI_WORKFLOW_PATH:-$repo_root/.github/workflows/ci.yml}" "$@" <<'PY' +``` + +Confira que `$caminho_workflow` não ficou referenciado em outro ponto: `grep -n caminho_workflow scripts/qa/ci_invariants.sh`. Se aparecer, defina a variável acima do `exec` com o mesmo valor padrão (`caminho_workflow="${CI_WORKFLOW_PATH:-$repo_root/.github/workflows/ci.yml}"`) e use-a. + +- [ ] **Step 5: Provar que o guarda funciona (teste do remoto + testes do local)** + +```bash +bash -n scripts/qa/ci_invariants.sh +./scripts/qa/ci_invariants.sh +./scripts/qa/ci_invariants.sh --checks-obrigatorio; echo "exit=$?" +bash scripts/qa/ci_invariants_fcm_test.sh +bash scripts/ci/decode_secret_file_test.sh +``` +Expected: guarda `ok`; flag errada com `exit=2`; os dois testes terminam com exit 0. + +- [ ] **Step 6: Marcar como resolvido (sem commit ainda)** + +```bash +git add CLAUDE.md apps/acs/pubspec.yaml scripts/qa/ci_invariants.sh +``` + +### Task 3: Resolver `app.dart` dos dois apps + +**Files:** +- Modify: `apps/acs/lib/app/app.dart`, `apps/patient/lib/app/app.dart` + +**Interfaces:** +- Consumes: do remoto, `ThemeController` (`apps/*/lib/core/services/theme_controller.dart`) e os temas `buildAcsTheme`/`buildPatientTheme` com variante clara/escura. +- Produces: `app.dart` que monta o `MaterialApp` com `theme`, `darkTheme` e `themeMode` do controller **e** mantém todos os providers/rotas/fluxos do local. + +- [ ] **Step 1: Ler as duas intenções antes de editar** + +```bash +git diff a9757fd origin/develop -- apps/acs/lib/app/app.dart +git diff a9757fd f0666dc -- apps/acs/lib/app/app.dart +``` +O remoto mexe na construção do `MaterialApp`/tema; o local, nas dependências injetadas e rotas. As mudanças são ortogonais: em cada hunk, mantenha a lógica do local e aplique a estrutura de tema do remoto por cima. Não descarte um lado inteiro. + +- [ ] **Step 2: Resolver hunk a hunk e analisar** + +Edite os 4 hunks do ACS e os 6 do Paciente. Em seguida: +```bash +grep -c '^<<<<<<<' apps/acs/lib/app/app.dart apps/patient/lib/app/app.dart +``` +Expected: `0` e `0`. + +- [ ] **Step 3: Resolver os pubspecs e regenerar locks** + +Para `apps/patient/pubspec.yaml` (auto-merge) confira que tem `shared_preferences` e as dependências do local. Depois, para cada app: +```bash +git checkout --theirs apps/patient/pubspec.lock apps/acs/pubspec.lock 2>/dev/null; true +(cd apps/acs && flutter pub get) +(cd apps/patient && flutter pub get) +``` +Expected: ambos terminam sem erro e reescrevem lock e `.flutter-plugins-dependencies`. Confira que nenhum pacote que o local fixou foi rebaixado: `git diff HEAD -- apps/*/pubspec.lock | grep '^-.*version'` e revise cada linha. + +- [ ] **Step 7: Análise estática** + +```bash +(cd apps/acs && flutter analyze) +(cd apps/patient && flutter analyze) +``` +Expected: `No issues found!` nos dois. + +- [ ] **Step 8: Marcar resolvidos** + +```bash +git add apps/acs/lib/app/app.dart apps/patient/lib/app/app.dart apps/acs/pubspec.lock apps/patient/pubspec.lock apps/patient/.flutter-plugins-dependencies apps/acs/.flutter-plugins-dependencies +``` + +### Task 4: Resolver o teste de contraste e cobrir o tema claro + +**Files:** +- Modify: `apps/patient/test/contrast_tokens_test.dart` +- Test: `apps/patient/test/contrast_tokens_light_test.dart` (vem do remoto), `apps/acs/test/contrast_tokens_light_test.dart` + +**Interfaces:** +- Consumes: `PatientDarkColors`, `risk` (extensão de tema do remoto) e `buildPatientTheme()`. + +- [ ] **Step 1: Resolver o conflito** + +Use as linhas do remoto (`PatientDarkColors.background`, `risk.yellowOnSurface` etc.) como base e **acrescente** os três casos do local, reescritos para o novo esquema: + +```dart + final scheme = buildPatientTheme().colorScheme; + // ... casos do remoto ... + ('branco sobre accentDark (passos do resumo legal)', Colors.white, PatientColors.accentDark, normalText), + ('texto do cartão de aviso (onSurface) sobre card', scheme.onSurface, PatientDarkColors.surfaceRaised, normalText), + ('TextButton do cartão de aviso (primary) sobre card', scheme.primary, PatientDarkColors.surfaceRaised, normalText), + ('ícone de fechar do cartão de aviso (onSurfaceVariant) sobre card', scheme.onSurfaceVariant, PatientDarkColors.surfaceRaised, largeTextOrUi), +``` +Se `buildPatientTheme()` agora recebe o brilho como parâmetro, passe o argumento equivalente ao escuro. Veja a assinatura em `apps/patient/lib/app/patient_theme.dart`. + +- [ ] **Step 2: Rodar a suíte de contraste dos dois apps** + +```bash +(cd apps/patient && flutter test test/contrast_tokens_test.dart test/contrast_tokens_light_test.dart) +(cd apps/acs && flutter test test/contrast_tokens_test.dart test/contrast_tokens_light_test.dart) +``` +Expected: PASS. Falha em caso do local contra o tema claro é **achado real** (Review Focus 1), não erro de merge: corrija o token em `patient_theme.dart` com a variante `*OnSurface` clara, não afrouxe o limiar. + +- [ ] **Step 3: Marcar resolvido** + +```bash +git add apps/patient/test/contrast_tokens_test.dart +git diff --name-only --diff-filter=U +``` +Expected: segunda saída vazia (nenhum arquivo em conflito). + +### Task 5: Validar o resultado integrado + +**Files:** nenhum, só verificação. + +- [ ] **Step 1: Suítes completas dos apps** + +```bash +(cd apps/acs && flutter test) +(cd apps/patient && flutter test) +``` +Expected: tudo verde (ACS ≈158 e Paciente ≈106 testes ou mais, segundo a memória do projeto, mais os de tema). Qualquer falha em tela do local sob tema claro: corrija antes de fechar o merge. + +- [ ] **Step 2: Backend não foi tocado pelo remoto** + +Run: `git diff --stat HEAD -- backend | tail -1` (com o merge ainda não commitado, compara com o local) +Expected: sem saída. + +- [ ] **Step 3: Guarda de CI e actionlint** + +```bash +./scripts/qa/ci_invariants.sh --checks-obrigatorios +``` +Expected: JSON com os 8 checks. + +- [ ] **Step 4: Concluir o merge** + +```bash +git commit -m "merge: integra origin/develop (tema claro/escuro, ci_invariants, guia Google Play) na develop local + +Co-Authored-By: Claude Sonnet 5.5 " +git rev-list --left-right --count origin/develop...HEAD +``` +Expected: `0 98` (os 7 do remoto já contidos; 90 locais + o merge + os 7 não contam à esquerda). + +- [ ] **Step 5: Atualizar o grafo** + +Run: `graphify update .` + +### Task 6: Publicar sem force-push + +**Files:** nenhum. + +- [ ] **Step 1: Confirmar com o dono antes de publicar** (ação externa e de 90 commits) + +Mostre `git log --oneline origin/develop..HEAD | wc -l` e peça o OK explícito. + +- [ ] **Step 2: Publicar a branch de integração e abrir PR para `develop`** + +```bash +git push -u origin integrar/develop-com-origin-2026-10-01 +gh pr create --base develop --head integrar/develop-com-origin-2026-10-01 \ + --title "merge: integra develop local (RF05/RF14/RF18, QR, push, CI) com tema claro/escuro" \ + --body "Resolve a divergência 90/7. Conflitos resolvidos em CLAUDE.md, ci_invariants.sh, pubspecs, app.dart (ACS e Paciente) e contrast_tokens_test.dart. Local validado: flutter test dos dois apps, ci_invariants.sh. + +🤖 Generated with [Claude Code](https://claude.com/claude-code)" +``` +Expected: os 8 checks obrigatórios ficam verdes (`android-e2e` é informativo). + +- [ ] **Step 3: Após o merge do PR, alinhar a `develop` local** + +```bash +git switch develop +git fetch origin +git reset --hard origin/develop # só depois de confirmar que o PR foi mergeado e o backup existe +git rev-list --left-right --count develop...origin/develop +``` +Expected: `0 0`. Remover a tag de backup apenas depois de alguns dias. + +### Task 7 (opcional, separada): higiene + +- [ ] Abrir issue: `.flutter-plugins-dependencies` está versionado em `apps/acs` e `apps/patient`; é arquivo gerado e causou conflito. Corrigir com `git rm --cached` e entrada no `.gitignore` em PR próprio, fora deste merge. + +## Auto-revisão + +- Cobertura: os 8 arquivos em conflito têm resolução (Tasks 2–4); validação (Task 5); publicação (Task 6). +- Riscos fora do escopo do remoto: nenhum arquivo de `backend/` ou migração é tocado pelo lado remoto. +- Reversão: `git switch develop` (intacta até a Task 6) ou `git reset --hard backup/develop-pre-merge-2026-10-01`. diff --git a/docs/superpowers/plans/2026-10-02-fechamento-gps-e2e-e-conferidor-de-contagem.md b/docs/superpowers/plans/2026-10-02-fechamento-gps-e2e-e-conferidor-de-contagem.md new file mode 100644 index 00000000..e19c51bc --- /dev/null +++ b/docs/superpowers/plans/2026-10-02-fechamento-gps-e2e-e-conferidor-de-contagem.md @@ -0,0 +1,295 @@ +# Fechamento do e2e de GPS do ACS e do conferidor de contagem — Plano de Implementação + +> **Para agentes:** SUB-SKILL OBRIGATÓRIA: use superpowers:subagent-driven-development (recomendado) ou superpowers:executing-plans para executar este plano tarefa a tarefa. Os passos usam checkbox (`- [ ]`). + +**Goal:** Fechar os 7 achados da revisão de `scripts/qa/acs_gps_e2e.sh`, `integration_test/geofence_gps_e2e.dart` e `scripts/qa/contagem_validation_report.py`. + +**Architecture:** A prova de "permissão fixada" passa a ser um estado lido do próprio Geolocator (`checkPermission()` depois de abrir o painel); a contagem de toques do tocador fica como trava complementar, agora exercitada pelo teste hermético (adb falso que mostra o diálogo). O script ganha ordem de limpeza correta, `dart-define` só onde tem efeito e comentários que dizem só o que é conferido. O conferidor de contagem passa a recusar linha de RF cujo status não esteja em negrito. + +**Tech Stack:** bash, Python 3 (`unittest`), Flutter `integration_test`, `geolocator`, adb. + +**Spec:** achados do pedido do usuário (7 itens, 2026-10-02); contexto em `docs/superpowers/plans/2026-10-01-minors-da-revisao-do-app-acs.md`. + +## Global Constraints + +- Português em comentários, mensagens e nomes, como o resto de `scripts/qa/`. +- Commits sem atribuição de IA, sem "Co-Authored-By", sem "Generated with Claude Code" (regra do `CLAUDE.md` do projeto). Ficam na branch `fix/app_acs`, sem push. +- Nada de dado real de paciente em teste/log. +- O script nunca apaga um app já instalado sem `--reinstalar` (exit 3) — não mexer nessa guarda. +- Testes herméticos não precisam de emulador, Docker nem `.env`. + +## Review Focus + +- Script interrompido (SIGTERM/Ctrl-C) com o tocador vivo: arquivos temporários não podem sobrar. → Task 2, caso `interrompido`. +- Mais de 2 toques do tocador deve reprovar a rodada e exatamente 2 deve passar. → Task 2, casos `toques_demais` e `toques_ok`. +- Linha de RF com status sem negrito (`| RF05 | X | parcial | ... |`) não pode ser contada como "nada". → Task 1. +- `--sem-permissao`: o `EXPECT_PERMISSION` só vale no build; o drive não pode repeti-lo. → Task 2, caso `sem_permissao`. + +## Mapa de arquivos + +- Modificar `scripts/qa/contagem_validation_report.py` + `contagem_validation_report_test.py` (item 7). +- Modificar `scripts/qa/acs_gps_e2e.sh` (itens 3, 4, 5) e `scripts/qa/acs_gps_e2e_test.sh` (itens 4, 5, 6). +- Modificar `apps/acs/integration_test/geofence_gps_e2e.dart` (itens 1, 2, 3). + +--- + +### Task 1: Conferidor de contagem recusa status sem negrito (item 7) + +**Files:** +- Modify: `scripts/qa/contagem_validation_report.py` +- Test: `scripts/qa/contagem_validation_report_test.py` + +**Interfaces:** +- Consumes: — +- Produces: `contar(texto) -> dict` passa a levantar `ValueError` também para linha `| RFnn |` sem status em negrito. `conferir`/CLI inalterados (o erro vira exit 2). + +- [ ] **Step 1: Teste que falha** — acrescentar em `ContagemTest`: + +```python + def test_status_sem_negrito_e_erro(self): + with self.assertRaises(ValueError): + contar("| RF05 | X | parcial | x |\n") + + def test_linha_de_rf_malformada_e_erro(self): + with self.assertRaises(ValueError): + contar("| RF05 | X |\n") +``` + +- [ ] **Step 2: Ver falhar** + +Run: `cd scripts/qa && python3 -m unittest contagem_validation_report_test -v` +Expected: os 2 testes novos FALHAM (`AssertionError: ValueError not raised`), os 4 antigos passam. + +- [ ] **Step 3: Implementar** — em `contagem_validation_report.py`, trocar `contar` e adicionar a regex de qualquer linha de RF: + +```python +LINHA_QUALQUER_RF = re.compile(r"^\| RF\d+ \|.*$", re.M) +``` + +```python +def contar(texto): + contagem = {chave: 0 for chave in ORDEM} + for linha in LINHA_QUALQUER_RF.findall(texto): + achado = LINHA_RF.match(linha) + if achado is None: + raise ValueError(f"linha de RF sem status em negrito: {linha[:60]!r}") + status = achado.group(1) + if status not in contagem: + raise ValueError(f"status desconhecido na tabela de RF: {status!r}") + contagem[status] += 1 + return contagem +``` + +Atualizar a docstring: "linha de RF sem status em negrito também é erro (exit 2)". (`LINHA_RF` com `re.M` e `^` continua válida em `.match` sobre a linha.) + +- [ ] **Step 4: Ver passar** — mesmo comando. Expected: 6 testes OK. Depois: `python3 scripts/qa/contagem_validation_report.py` → `ok: contagem do validation_report confere` (a tabela real tem todas em negrito; se falhar, a falha é um achado real: corrigir a linha em `spec/validation_report.md`). + +- [ ] **Step 5: Commit** + +```bash +git add scripts/qa/contagem_validation_report.py scripts/qa/contagem_validation_report_test.py +git commit -m "fix(qa): conferidor de contagem recusa linha de RF sem status em negrito" +``` + +--- + +### Task 2: Script de GPS — limpeza, dart-define e toques, com testes herméticos (itens 4, 5, 6) + +**Files:** +- Modify: `scripts/qa/acs_gps_e2e_test.sh` +- Modify: `scripts/qa/acs_gps_e2e.sh` + +**Interfaces:** +- Produces: variável de ambiente `GPS_E2E_INTERVALO` (segundos entre amostras/toques, padrão `1`); constante `max_toques=2` no script. + +- [ ] **Step 1: Estender o teste hermético (falha primeiro)** + +(a) No `adb` falso, acrescentar antes do `esac`: + +```bash + *uiautomator*) + if [[ "${FAKE_DIALOGO_TOQUES:-0}" == 1 ]]; then + n="$(cat "$FAKE_LOG.dumps" 2>/dev/null || echo 0)" + if [[ -z "${FAKE_DIALOGO_LIMITE:-}" || "$n" -lt "$FAKE_DIALOGO_LIMITE" ]]; then + echo $((n + 1)) >"$FAKE_LOG.dumps" + echo '' + fi + fi ;; +``` + +(b) No `flutter` falso, trocar a linha do `drive` por: + +```bash + drive) [[ "${FAKE_DRIVE_FALHA:-0}" == 1 ]] && exit 1 + [[ "${FAKE_DRIVE_TRAVA:-0}" == 1 ]] && sleep 30 + [[ -n "${FAKE_DRIVE_DEMORA:-}" ]] && sleep "$FAKE_DRIVE_DEMORA" ;; +``` + +(c) Em `caso()`, isolar o TMPDIR por caso e zerar o contador de dumps: + +```bash + rm -rf "$tmp/t" "$FAKE_LOG.dumps"; mkdir "$tmp/t" + saida="$(env PATH="$tmp/bin:$PATH" TMPDIR="$tmp/t" GPS_E2E_INTERVALO=0.1 MQTT_ACS_PASSWORD=x "${envs[@]}" ./scripts/qa/acs_gps_e2e.sh "$@" 2>&1)"; codigo=$? +``` + +(d) Trocar o `verifica` do caso `sem_permissao` e acrescentar os novos casos (antes da linha final `[[ "$falhas" ...`): + +```bash +caso sem_permissao -- --sem-permissao +verifica '[[ $codigo -eq 0 ]]' +verifica '[[ $(conta "pm revoke") -eq 2 ]]' +verifica 'grep -q "^flutter build .*EXPECT_PERMISSION=denied_forever" "$FAKE_LOG"' # o define vale só no build +verifica '! grep "^flutter drive" "$FAKE_LOG" | grep -q EXPECT_PERMISSION' # o drive não o repete +verifica '[[ $(conta "^flutter drive") -eq 1 ]]' # uma rodada só: o teste fixa a negação sozinho +verifica '[[ -z "$(ls -A "$tmp/t")" ]]' # nenhum temporário sobra + +caso toques_ok FAKE_DIALOGO_TOQUES=1 FAKE_DIALOGO_LIMITE=2 FAKE_DRIVE_DEMORA=2 -- --sem-permissao +verifica '[[ $codigo -eq 0 ]]' +verifica '[[ $(conta "input tap") -eq 2 ]]' # exatamente o teto: passa + +caso toques_demais FAKE_DIALOGO_TOQUES=1 FAKE_DRIVE_DEMORA=2 -- --sem-permissao +verifica '[[ $codigo -eq 1 ]]' +verifica '[[ $(conta "input tap") -gt 2 ]]' # o caminho de >2 foi exercitado de verdade +verifica 'grep -q "mais de 2 vezes" <<<"$saida"' +verifica '[[ -z "$(ls -A "$tmp/t")" ]]' + +caso interrompido FAKE_DIALOGO_TOQUES=1 FAKE_DRIVE_DEMORA=5 -- --sem-permissao +``` + +(`interrompido` precisa de SIGTERM no meio; como `caso` é síncrono, escrever este bloco à mão em vez de `caso`:) + +```bash +nome=interrompido; : >"$FAKE_LOG"; rm -rf "$tmp/t" "$FAKE_LOG.dumps"; mkdir "$tmp/t" +env PATH="$tmp/bin:$PATH" TMPDIR="$tmp/t" GPS_E2E_INTERVALO=0.1 MQTT_ACS_PASSWORD=x \ + FAKE_DIALOGO_TOQUES=1 FAKE_DRIVE_DEMORA=5 ./scripts/qa/acs_gps_e2e.sh --sem-permissao >/dev/null 2>&1 & +pid=$!; sleep 2; kill -TERM "$pid" 2>/dev/null; wait "$pid" 2>/dev/null; saida="" +verifica '[[ -z "$(ls -A "$tmp/t")" ]]' # item 5: nem o arquivo de toques vaza +verifica '[[ $(conta uninstall) -ge 1 ]]' # e o app de teste é removido +``` + +(Remover a linha `caso interrompido ...` e deixar só o bloco manual.) + +- [ ] **Step 2: Ver falhar** + +Run: `bash scripts/qa/acs_gps_e2e_test.sh` +Expected: FALHAM `sem_permissao` (o drive hoje leva `EXPECT_PERMISSION`), `toques_demais` ou `interrompido` (vazamento/sem intervalo configurável; `GPS_E2E_INTERVALO` ainda não existe, então `toques_ok` fica com 0–1 toque). Anotar quais falharam. + +- [ ] **Step 3: Implementar no script** + +(a) Intervalo configurável e teto nomeado, junto de `limite=`/antes do amostrador: + +```bash +intervalo="${GPS_E2E_INTERVALO:-1}" +max_toques=2 # as duas recusas que fixam a negação; um 3º toque = a permissão não ficou fixada +``` +Trocar os dois `sleep 1` dos laços `( while true; ... )` por `sleep "$intervalo"`, e `-gt 2` por `-gt "$max_toques"` (mensagem: `"... mais de $max_toques vezes ..."` — o teste casa em "mais de 2 vezes", que continua verdadeiro). + +(b) `limpar()` — matar e esperar o tocador ANTES de apagar o arquivo de toques (o laço recria o arquivo com `>>`), e tratar sinais: + +```bash +limpar() { + if [[ -n "$amostrador" ]]; then + kill "$amostrador" 2>/dev/null || true + wait "$amostrador" 2>/dev/null || true + fi + rm -f "$log" "$amostras" "$toques" + if [[ "$instalamos" -eq 1 ]]; then adb -s "$dev" uninstall "$pkg" >/dev/null 2>&1 || true; fi +} +trap limpar EXIT +trap 'exit 130' INT +trap 'exit 143' TERM +``` +(Os `trap` de INT/TERM garantem que o EXIT trap rode mesmo com o `flutter drive` em primeiro plano. Se o caso `interrompido` ainda falhar, o árbitro é o teste: ajustar até passar, sem enfraquecê-lo.) + +No fluxo normal, trocar `kill "$amostrador" 2>/dev/null || true; amostrador=""` por `kill "$amostrador" 2>/dev/null || true; wait "$amostrador" 2>/dev/null || true; amostrador=""` para que a contagem de toques seja lida depois da morte do tocador. + +(c) `dart-define` do drive: remover a linha `--dart-define=EXPECT_PERMISSION="$expect"` do `flutter drive` (com `--use-application-binary` o valor já está embutido no APK construído; repeti-lo no drive sugere que ele vale ali). Deixar o comentário no ponto do build: + +```bash +# O EXPECT_PERMISSION é fixado AQUI, no build: o `flutter drive` abaixo usa o APK pronto +# (--use-application-binary) e não recompila, então um dart-define no drive não teria efeito. +``` + +- [ ] **Step 4: Ver passar** + +Run: `bash scripts/qa/acs_gps_e2e_test.sh` (repetir 3x: toques dependem de tempo) +Expected: `ok: acs_gps_e2e` nas 3 rodadas. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/qa/acs_gps_e2e.sh scripts/qa/acs_gps_e2e_test.sh +git commit -m "fix(qa): gps e2e limpa o tocador antes dos temporários, sem define morto no drive e com caso hermético de >2 toques" +``` + +--- + +### Task 3: Teste Dart prova o estado, não a contagem; comentários honestos (itens 1, 2, 3) + +**Files:** +- Modify: `apps/acs/integration_test/geofence_gps_e2e.dart` +- Modify: `scripts/qa/acs_gps_e2e.sh` (só comentários do cabeçalho e do bloco do tocador) + +**Interfaces:** +- Consumes: `Geolocator.checkPermission()` / `requestPermission()`; os cenários `granted|denied_forever` da Task 2. +- Produces: — + +- [ ] **Step 1: Escrever a mudança do teste** (não há como falhar sem emulador; a prova é o passo 3) + +Em `geofence_gps_e2e.dart`, trocar o bloco `else` por no máximo 2 pedidos (1ª recusa + 2ª, que fixa): + +```dart + expect(permission, isNot(anyOf(LocationPermission.whileInUse, LocationPermission.always))); + // No máximo 2 pedidos: o script do emulador recusa o 1º diálogo e o 2º, e a 2ª recusa + // faz o Android parar de perguntar. Um 3º pedido seria só um pedido que já volta na hora. + var resposta = await Geolocator.requestPermission(); + if (resposta != LocationPermission.deniedForever) { + resposta = await Geolocator.requestPermission(); + } + expect(resposta, LocationPermission.deniedForever); +``` + +E, dentro do `if (_expect == 'denied_forever')` depois de abrir o painel, ANTES dos `expect` de texto: + +```dart + // A prova de "nada pergunta de novo": com a negação fixada, o painel de Geofencing não + // pode ter reaberto o fluxo de permissão. O estado lido de volta continua deniedForever. + expect(await Geolocator.checkPermission(), LocationPermission.deniedForever, + reason: 'abrir o painel não pode alterar nem reabrir a permissão já fixada'); +``` + +Corrigir o comentário do arquivo: no cabeçalho, onde se lê "negada-de-vez"/"USER_FIXED" não deve haver afirmação sobre a flag do Android — dizer "negada de vez (o Geolocator reporta `deniedForever`)". + +- [ ] **Step 2: Corrigir os comentários do script** + +Em `acs_gps_e2e.sh`: na linha 6, trocar `negada DE VEZ (USER_FIXED)` por `negada DE VEZ (o Geolocator reporta deniedForever)`. No comentário do bloco do tocador, reescrever as 3 linhas para: + +```bash +# No cenário `denied_forever` o diálogo é ESPERADO: o tocador abaixo o recusa. A prova principal de +# que a negação ficou fixada é do próprio teste (checkPermission() == deniedForever depois de abrir +# o painel). A contagem de toques é só uma trava complementar: no máximo $max_toques (as duas +# recusas); um 3º toque indica que o diálogo continuou aparecendo. Ela NÃO confere a flag USER_FIXED. +``` + +- [ ] **Step 3: Verificar** + +Run: `cd apps/acs && dart analyze integration_test/geofence_gps_e2e.dart` → `No issues found!` +Run: `bash scripts/qa/acs_gps_e2e_test.sh` → `ok: acs_gps_e2e` +Run (se houver o emulador-5554 livre do app): `./scripts/qa/acs_gps_e2e.sh --sem-permissao` e `./scripts/qa/acs_gps_e2e.sh` +Expected: `OK — permissão de localização em runtime (denied_forever)` e `(granted)`. Se o `checkPermission()` não vier `deniedForever` no emulador, NÃO afrouxar o `expect`: investigar (é exatamente o falso positivo que o item 1 queria expor). Sem emulador, dizer isso explicitamente ao reportar — a verificação em aparelho fica pendente. + +- [ ] **Step 4: Commit** + +```bash +git add apps/acs/integration_test/geofence_gps_e2e.dart scripts/qa/acs_gps_e2e.sh +git commit -m "test(acs): gps e2e confere deniedForever após abrir o painel, no máximo 2 pedidos e comentários sem USER_FIXED" +``` + +--- + +## Auto-revisão + +- **Cobertura:** 1 → Task 3; 2 → Task 3 (loop vira ≤2 pedidos, igual ao comentário e ao `max_toques`); 3 → Task 3 (comentários dos dois arquivos); 4 → Task 2 (define só no build, greps ancorados `^flutter build`/`^flutter drive`); 5 → Task 2 (`wait` antes do `rm`, traps, casos `sem_permissao`/`interrompido`); 6 → Task 2 (`toques_demais`, `toques_ok`); 7 → Task 1. +- **Placeholders:** nenhum; a única incerteza declarada é o comportamento de SIGTERM do bash, com o teste como árbitro. +- **Consistência:** `GPS_E2E_INTERVALO`, `max_toques`, `FAKE_DIALOGO_TOQUES/LIMITE`, `FAKE_DRIVE_DEMORA` usados com os mesmos nomes nas Tasks 2 e 3. +- **Risco aberto:** a prova em aparelho (Task 3, passo 3) depende do emulador-5554; sem ele, só a parte hermética e a análise estática são verificadas. diff --git a/docs/superpowers/plans/2026-10-02-pendencias-do-acs-flag-secure-ubs-rf08-mfa-assinatura-mtls.md b/docs/superpowers/plans/2026-10-02-pendencias-do-acs-flag-secure-ubs-rf08-mfa-assinatura-mtls.md new file mode 100644 index 00000000..0dcd4d81 --- /dev/null +++ b/docs/superpowers/plans/2026-10-02-pendencias-do-acs-flag-secure-ubs-rf08-mfa-assinatura-mtls.md @@ -0,0 +1,3080 @@ +# Pendências do ACS: FLAG_SECURE, assinatura, botão da UBS, RF08, MFA e mTLS — Plano de Implementação + +> **Para agentes:** SUB-SKILL OBRIGATÓRIA: use superpowers:subagent-driven-development (recomendado) ou superpowers:executing-plans para executar este plano tarefa a tarefa. Os passos usam checkbox (`- [ ]`). + +**Goal:** Fechar as seis pendências que o `PROGRESS.md` deixou abertas no app do ACS e no broker: bloquear captura de tela, assinar o release com chave própria, ligar para a UBS, persistir a microárea offline (RF08), exigir MFA (TOTP) no login institucional e exigir certificado de cliente no broker (mTLS). + +**Architecture:** Seis frentes independentes, uma ou duas tarefas cada, na ordem do menor para o maior risco: (1) `FLAG_SECURE` na `MainActivity`; (2) `signingConfig` de release lido de `key.properties`/ambiente, com a build falhando sem chave; (3) contato da UBS como coluna nova em `ubs` mais um endpoint `ubs.myContact` e o botão passando a discar; (4) cache da microárea na mesma base SQLCipher, com dono, validade e apagamento; (5)(6) TOTP RFC 6238 no `loginInstitutional` (backend, depois app); (7)(8) o Mosquitto passa a exigir certificado de cliente, o backend e o ACS passam a apresentá-lo. Cada tarefa tem um teste que falha antes e termina com prova no `emulator-5554` quando há o que provar nele. + +**Tech Stack:** Kotlin (Android), Gradle Kotlin DSL, Flutter/Dart, Serverpod 3.4.13 (`serverpod generate`/`create-migration`), `sqflite_sqlcipher`, `crypto` (HMAC-SHA1), `qr_flutter`, Mosquitto 2 + OpenSSL, `integration_test`, `scripts/qa/*.sh`. + +**Spec:** `spec/PRD_system.md` (RF07, RF08, RF13, RNF04, RNF06), `spec/lgpd_design.md` (LGPD-RT06 §Sessões e autenticação, §5.1 chave local, §5.6 minimização), `docs/superpowers/specs/2026-09-16-decisoes-produto-pos-validacao.md` (§4, §5), `spec/lgpd_data_audit.md`, `apps/CLAUDE.md`, `backend/CLAUDE.md`, `PROGRESS.md` (itens "Continua aberto" de 2026-10-02). + +## Decisões tomadas neste plano + +Cada uma é reversível; a coluna "se errada" diz o custo. + +| # | Decisão | Se errada | +|---|---|---| +| D1 | `FLAG_SECURE` na **janela inteira** do ACS, não só na lista da microárea. A lista aparece em Área, Visita e no seletor; proteger tela por tela deixaria uma sem. Efeito colateral aceito: `adb screencap` e gravação de tela do ACS saem pretos. | Remover duas linhas da `MainActivity`. | +| D2 | Chave de release lida de `apps/acs/android/key.properties` (já no `.gitignore`) **ou** de variáveis `SINALACS_KEYSTORE_*`. Sem chave, `assembleRelease` **falha**; assinar com a de debug exige `-Psinalacs.allowDebugSigning=true`. O plano **não cria** keystore real nem segredo de CI. | Apagar o guard. | +| D3 | O telefone da UBS é a coluna **nullable** `ubs.contactPhone`, entregue ao ACS por `ubs.myContact`. Sem telefone, o botão avisa e não liga. Quem cadastra o número é o backoffice, que ainda é mock: hoje vale a seed. | Coluna e endpoint são aditivos. | +| D4 | O cache de RF08 guarda **só o que a tela de visita já mostra** (nome, `isChronic`, condições) na base SQLCipher; validade de **72 h**; chaveado por `userId\|microAreaId`; apagado quando o dono muda ou a validade vence; **só entra no lugar de uma falha recuperável de rede**, nunca de uma recusa do servidor. | Mudar `maxAge`; ou apagar a tabela v6. | +| D5 | MFA = **TOTP RFC 6238** (SHA-1, 6 dígitos, 30 s, janela ±1). O segredo é cifrado com a mesma chave AES-256-GCM dos dados clínicos. A ativação **não usa token**: exige matrícula + senha + um código válido. `REQUIRE_ACS_MFA` é `false` em `development` e `true` fora dele. Sem MFA em produção, o ACS é obrigado a ativar antes de entrar. | Exigir só em produção já é o padrão; trocar o default. | +| D6 | Refresh token **continua fora** (a pendência era "MFA e refresh token"; o pedido foi só MFA). Redefinição de MFA por coordenador também fica fora: hoje se faz zerando as colunas no banco. | Plano à parte. | +| D7 | mTLS **mantém a senha MQTT**: `require_certificate true` sem `use_identity_as_username`. Trocar a senha pela identidade do certificado derrubaria o guard do Gradle, `BackendConfig.mqttPasswordMissing`, os scripts de dev e o CI. Fica cert **e** senha. | Passar a `use_identity_as_username` é plano à parte. | +| D8 | O certificado de cliente do ACS vem de **asset de desenvolvimento** (`assets/certs/acs_client.*`, gitignorado, copiado por `sync_dev_ca.sh`). A chave privada no APK serve só ao dev. Um release com ela dentro **falha** a build. O provisionamento por aparelho em produção (CSR no cadastro, chave no Keystore) **não** entra: o mTLS fica entregue na stack local e **aberto** para produção. | Dizer isso no `PROGRESS.md`; não fingir fechamento. | + +## Estado verificado em 2026-10-02 + +Medido ou lido nesta sessão: + +- ACS: `flutter analyze` limpo, `flutter test` com **220** testes passando; `emulator-5554` no ar. +- `MainActivity.kt` do ACS é `class MainActivity : FlutterActivity()`, sem `FLAG_SECURE`. +- `build.gradle.kts`: `buildTypes.release` assina com `signingConfigs.getByName("debug")` (comentário `TODO`); `android/.gitignore` já ignora `key.properties`, `*.jks`, `*.keystore`. +- `EscalationScreen` (`app.dart` ~1936): "Encaminhar para UBS Central" só mostra `Encaminhamento será integrado à UBS.`; `ubs` não tem coluna de contato; o `Acs` tem `ubsId`. +- RF08: `patients.listMicroArea` é chamada **ao vivo** em dois pontos (`_loadMicroAreaPatients`, painel; `_loadPatients`, tela de visita). Falha vira `BackendFailure` com `isRecoverable`; recusa de sessão vem com `isRecoverable: false`. A base local é `sinalacs_acs.db`, `schemaVersion = 5`, com `offline_visits` e `sync_cursor`. +- MFA: `InstitutionalAuthService.login` (`institutional_auth_service.dart`) verifica Argon2id, bloqueia em 5 falhas por 15 min, devolve a mesma mensagem para matrícula inexistente e senha errada; `user_credentials` não tem coluna de MFA; `HealthDataCipher.encrypt/decrypt` cifram `String`. +- mTLS: `mosquitto.conf` tem `allow_anonymous false` + `password_file` + `acl_file`, **sem** `require_certificate`; `init.sh` gera só o certificado do servidor; o app (`mqtt_secure_client.dart:290`) e o backend (`mqtt_alert_dispatcher.dart:61`) só confiam na CA, não apresentam certificado. + +## Fora deste plano + +Refresh token; redefinição de MFA por coordenador; assinatura de release dos apps `patient` e `admin`; segredos de assinatura no CI; cadastro do telefone da UBS pelo backoffice; provisionamento de certificado por aparelho em produção; iOS (`apps/acs/ios/` não existe). + +## Global Constraints + +- Português em comentários, mensagens de commit e texto de UI, como o resto do repositório. +- Commits **sem** atribuição de IA: nenhuma linha "Co-Authored-By", nenhuma "Generated with Claude Code" (regra do `CLAUDE.md` do projeto, que prevalece sobre o lembrete de atribuição da sessão). Tudo na branch `fix/app_acs`, sem push, merge nem PR. +- Nada de dado real de paciente em teste, log ou captura; só fixtures sintéticas. Segredo nunca em `argv` de `docker`/`gradle` quando houver alternativa (variável de ambiente). +- Migração do banco do servidor: só aditiva (coluna nullable ou tabela nova). Depois de mexer em `.spy.yaml` ou endpoint: `serverpod generate` e `serverpod create-migration` em `backend/sinalacs_server`. +- Migração da base local do ACS: só aditiva (`CREATE TABLE IF NOT EXISTS`), e `EncryptedLocalDatabase.schemaVersion` sobe junto com `_upgrade`. +- Triagem determinística e a semântica de retry/conflito/rejeição de `OfflineVisitQueue` não mudam. RNF06: o território vem sempre do token. +- Os scripts `acs_full_e2e.sh` e `patient_full_e2e.sh` derrubam a stack de desenvolvimento: rodar `docker compose up -d` depois. Nenhum script apaga um app já instalado sem `--reinstalar`. +- Ambiente de todos os comandos: `export PATH="$PATH:$HOME/Android/Sdk/platform-tools:$HOME/flutter/bin:$HOME/.pub-cache/bin"`; raiz `/home/rock/Documents/Dev/APPs/SinalACS`. +- Cor clínica usada como texto passa pelo token `*OnSurface` (`apps/CLAUDE.md`); este plano não adiciona cor. + +## Review Focus + +1. **Código TOTP reaproveitado dentro da mesma janela de 30 s (replay):** deve ser recusado. → Task 5, teste `recusa o mesmo código usado duas vezes`. +2. **Código TOTP errado depois de senha certa:** conta como tentativa, tranca no 5º erro como a senha, e **não** consome o passo. → Task 5, testes `código errado conta tentativa e tranca` e `código errado não consome o passo`. +3. **Cache da microárea servido a outro usuário/outra microárea, ou depois de 72 h:** nunca. → Task 4, testes `outro dono apaga e não serve` e `vencido não serve e é apagado`; e recusa do servidor nunca cai no cache. +4. **Broker aceitando cliente sem certificado ou com certificado de outra CA:** deve recusar, e a senha continua exigida. → Task 7, script `mtls_invariants.sh` (quatro casos). +5. **Release saindo com chave de debug ou com a chave privada de desenvolvimento dentro:** a build deve falhar. → Task 2 (sem chave) e Task 8 (chave de dev no asset). + +--- + +### Task 1: Janela do ACS com `FLAG_SECURE` + +**Files:** +- Modify: `apps/acs/android/app/src/main/kotlin/br/com/prismrr/sinalacs/acs/MainActivity.kt` +- Create: `apps/acs/test/secure_window_test.dart` +- Create: `scripts/qa/acs_secure_window.sh` +- Modify: `spec/lgpd_design.md` (fim da §5.1), `apps/CLAUDE.md` + +**Interfaces:** Consumes: —. Produces: nada que outra task use. + +- [ ] **Step 1: Escrever o teste de fonte que falha** + +Criar `apps/acs/test/secure_window_test.dart`: + +```dart +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +/// A lista da microárea mostra nome e condições crônicas (LGPD §5.6). Sem +/// `FLAG_SECURE`, o sistema deixa a tela ser capturada, gravada e reduzida a +/// miniatura nos "apps recentes" — dado de saúde fora do controle do app. +/// Este teste lê a `MainActivity` porque `flutter test` não tem janela +/// Android; a prova na janela de verdade é `scripts/qa/acs_secure_window.sh`. +void main() { + final fonte = File( + 'android/app/src/main/kotlin/br/com/prismrr/sinalacs/acs/MainActivity.kt', + ).readAsStringSync(); + + test('a janela do ACS é FLAG_SECURE (sem captura, gravação nem miniatura)', () { + expect(fonte, contains('override fun onCreate')); + expect(fonte, contains('WindowManager.LayoutParams.FLAG_SECURE')); + expect(fonte, contains('window.setFlags(')); + }); +} +``` + +- [ ] **Step 2: Escrever o verificador no emulador** + +Criar `scripts/qa/acs_secure_window.sh` e `chmod +x`: + +```bash +#!/usr/bin/env bash +# +# A janela do app do ACS tem FLAG_SECURE no emulador-5554 (sem captura de tela, +# gravação nem miniatura nos recentes). A prova é a flag da janela segundo o +# WindowManager (`dumpsys window windows`, linha `fl=`), não a leitura do código. +# +# ./scripts/qa/acs_secure_window.sh [--reinstalar] +# +# Aborta (exit 3) se o app já está instalado: reinstalar APAGA a fila de visitas +# offline (SQLCipher) e a chave do Keystore do app de desenvolvimento. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$repo_root" +export PATH="$PATH:$HOME/Android/Sdk/platform-tools:$HOME/flutter/bin" +if [[ -f .env ]]; then set -a; source .env; set +a; fi +: "${MQTT_ACS_PASSWORD:?exporte MQTT_ACS_PASSWORD ou rode ./scripts/dev/bootstrap_env.sh}" + +dev=emulator-5554 +pkg=br.com.prismrr.sinalacs.acs +reinstalar=0 +for arg in "$@"; do + case "$arg" in + --reinstalar) reinstalar=1 ;; + *) echo "argumento desconhecido: $arg" >&2; exit 2 ;; + esac +done +adb -s "$dev" get-state >/dev/null 2>&1 || { echo "emulador $dev não encontrado (adb devices)"; exit 4; } + +instalado() { adb -s "$dev" shell pm list packages "$pkg" 2>/dev/null | grep -q "^package:$pkg\$"; } +if instalado && [[ "$reinstalar" -eq 0 ]]; then + echo "erro: $pkg já está instalado em $dev; reinstalar APAGA a fila offline e a chave do Keystore." >&2 + echo " Se for só o app de teste de uma execução anterior, rode com --reinstalar." >&2 + exit 3 +fi + +log="$(mktemp)" +instalamos=0 +limpar() { + rm -f "$log" + if [[ "$instalamos" -eq 1 ]]; then adb -s "$dev" uninstall "$pkg" >/dev/null 2>&1 || true; fi +} +trap limpar EXIT + +if ! (cd apps/acs && flutter build apk --debug \ + --dart-define=SINALACS_MQTT_PASSWORD="$MQTT_ACS_PASSWORD" >"$log" 2>&1); then + echo "erro: o build do APK falhou. Últimas linhas:" >&2 + tail -n 30 "$log" >&2 + exit 1 +fi +if instalado; then adb -s "$dev" uninstall "$pkg" >/dev/null 2>&1 || true; fi +adb -s "$dev" install -r apps/acs/build/app/outputs/flutter-apk/app-debug.apk >/dev/null +instalamos=1 +adb -s "$dev" shell monkey -p "$pkg" -c android.intent.category.LAUNCHER 1 >/dev/null 2>&1 +sleep 6 + +# Primeiro `fl=` do bloco da janela do app (a linha `pfl=` é de outra coisa). +flags="$(adb -s "$dev" shell dumpsys window windows | awk -v pkg="$pkg" ' + /^ Window #/ { dentro = index($0, pkg "/") > 0 } + dentro && /^ +fl=/ { print; exit }')" +if [[ -z "$flags" ]]; then + echo "erro: a janela de $pkg não apareceu em dumpsys window (o app abriu?)." >&2 + exit 4 +fi +if ! grep -qw SECURE <<<"$flags"; then + echo "erro: a janela do ACS não tem FLAG_SECURE: $flags" >&2 + exit 1 +fi +echo "OK — janela do ACS com FLAG_SECURE" +``` + +- [ ] **Step 3: Ver os dois falharem** + +Run: `cd apps/acs && flutter test test/secure_window_test.dart 2>&1 | tail -6; cd ../.. && ./scripts/qa/acs_secure_window.sh; echo rc=$?` +Expected: o teste **FALHA** (`Expected: contains 'override fun onCreate'`); o script imprime `erro: a janela do ACS não tem FLAG_SECURE: fl=...` e `rc=1`. Se o script der `rc=4` (janela não apareceu), o `awk` não casou com o formato do `dumpsys` deste Android: rode `adb shell dumpsys window windows | grep -n "Window #\|fl="` com o app aberto e ajuste **só** a expressão do `awk` até achar o `fl=` do app **sem** SECURE (é o vermelho certo). Não siga com `rc=4`. + +- [ ] **Step 4: Implementar** + +Substituir o conteúdo de `MainActivity.kt`: + +```kotlin +package br.com.prismrr.sinalacs.acs + +import android.os.Bundle +import android.view.WindowManager +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // A lista da microárea mostra nome e condições crônicas. FLAG_SECURE + // bloqueia captura de tela, gravação e a miniatura nos apps recentes, + // na janela INTEIRA: Área, Visita e o seletor de paciente a exibem, e + // proteger tela por tela deixaria uma de fora. + window.setFlags( + WindowManager.LayoutParams.FLAG_SECURE, + WindowManager.LayoutParams.FLAG_SECURE, + ) + } +} +``` + +- [ ] **Step 5: Ver os dois passarem** + +Run: `cd apps/acs && flutter test test/secure_window_test.dart 2>&1 | tail -2; cd ../.. && ./scripts/qa/acs_secure_window.sh; echo rc=$?` +Expected: `All tests passed!`; `OK — janela do ACS com FLAG_SECURE` e `rc=0`; depois, `adb -s emulator-5554 shell pm list packages br.com.prismrr.sinalacs.acs` não lista nada (o script remove o app). + +- [ ] **Step 6: Conferir que nada que já rodava no emulador depende de captura** + +Run: `grep -rn "screencap\|takeScreenshot\|--screenshot" scripts apps/acs/integration_test apps/acs/test_driver 2>/dev/null | head; ./scripts/qa/acs_gps_e2e.sh && ./scripts/qa/acs_gps_e2e.sh --sem-permissao` +Expected: o `grep` não acha nada; os dois cenários de GPS terminam em `OK` (o `uiautomator dump` do tocador continua funcionando; ele lê a árvore de acessibilidade, não a imagem). + +- [ ] **Step 7: Documentar e commitar** + +Em `spec/lgpd_design.md`, no fim da §5.1 (onde está a decisão da chave local do SQLCipher), acrescentar: "**Captura de tela (2026-10-02).** A janela do app do ACS é `FLAG_SECURE`: o sistema não permite captura de tela, gravação nem miniatura nos apps recentes. Decisão: a janela inteira, porque a lista da microárea (nome e condições crônicas) aparece em mais de uma tela. Consequência aceita: `adb screencap` do ACS sai preto. Provado por `scripts/qa/acs_secure_window.sh`." + +Em `apps/CLAUDE.md`, no parágrafo "ACS: permissões, SAMU e e2e", acrescentar: "A janela do ACS é `FLAG_SECURE` (`MainActivity.kt`); `scripts/qa/acs_secure_window.sh` confere a flag no WindowManager e `test/secure_window_test.dart` guarda o código." + +```bash +git add apps/acs/android/app/src/main/kotlin apps/acs/test/secure_window_test.dart scripts/qa/acs_secure_window.sh spec/lgpd_design.md apps/CLAUDE.md +git commit -m "feat(acs): janela com FLAG_SECURE bloqueia captura de tela, gravação e miniatura dos recentes" +``` + +--- + +### Task 2: Assinatura de release com chave própria + +**Files:** +- Modify: `apps/acs/android/app/build.gradle.kts` +- Create: `scripts/qa/acs_release_signing.sh` +- Modify: `apps/CLAUDE.md` + +**Interfaces:** Consumes: —. Produces: propriedades Gradle `sinalacs.allowDebugSigning` (usada nesta task e na Task 8) e as variáveis `SINALACS_KEYSTORE_PATH`, `SINALACS_KEYSTORE_PASSWORD`, `SINALACS_KEY_ALIAS`, `SINALACS_KEY_PASSWORD`. + +- [ ] **Step 1: Escrever o verificador (falha porque hoje a build assina com debug)** + +Criar `scripts/qa/acs_release_signing.sh` e `chmod +x`: + +```bash +#!/usr/bin/env bash +# +# A build de release do ACS não sai com a chave de debug sem pedir. +# +# ./scripts/qa/acs_release_signing.sh +# +# Três cenários, cada um uma build de release (~1 min): +# 1. sem chave de release e sem a licença de debug -> a build FALHA, dizendo por quê; +# 2. com -Psinalacs.allowDebugSigning=true -> assina com "Android Debug"; +# 3. com um keystore descartável (criado aqui, em /tmp) por variáveis de ambiente +# -> assina com o CN do keystore. +# Nenhum segredo real é tocado: o keystore do cenário 3 nasce e morre neste script. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$repo_root" +export PATH="$PATH:$HOME/Android/Sdk/platform-tools:$HOME/flutter/bin" +if [[ -f .env ]]; then set -a; source .env; set +a; fi +: "${MQTT_ACS_PASSWORD:?exporte MQTT_ACS_PASSWORD ou rode ./scripts/dev/bootstrap_env.sh}" + +if [[ -f apps/acs/android/key.properties ]]; then + echo "erro: apps/acs/android/key.properties existe; ele mudaria o cenário 1. Mova-o para fora e rode de novo." >&2 + exit 3 +fi +apksigner="$(ls "$HOME"/Android/Sdk/build-tools/*/apksigner | tail -1)" +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +apk=apps/acs/build/app/outputs/flutter-apk/app-release.apk +define="--dart-define=SINALACS_MQTT_PASSWORD=$MQTT_ACS_PASSWORD" + +# O Gradle lê as variáveis SINALACS_KEYSTORE_* do ambiente; nos cenários 1 e 2 elas não existem. +construir() { (cd apps/acs && flutter build apk --release "$define" "$@"); } + +echo "== cenário 1: sem chave de release ==" +if saida="$(env -u SINALACS_KEYSTORE_PATH construir 2>&1)"; then + echo "erro: a build de release passou sem chave de release." >&2 + exit 1 +fi +grep -q "assinatura de release" <<<"$saida" || { echo "erro: a falha não explicou o motivo:" >&2; tail -n 15 <<<"$saida" >&2; exit 1; } + +echo "== cenário 2: -Psinalacs.allowDebugSigning=true ==" +construir -Psinalacs.allowDebugSigning=true >/dev/null +"$apksigner" verify --print-certs "$apk" | grep -q "CN=Android Debug" \ + || { echo "erro: o cenário 2 não assinou com a chave de debug." >&2; exit 1; } + +echo "== cenário 3: keystore descartável por variáveis de ambiente ==" +keytool -genkeypair -keystore "$tmp/teste.jks" -storepass senhateste -keypass senhateste \ + -alias teste -keyalg RSA -keysize 2048 -validity 2 -dname "CN=SinalACS Teste" >/dev/null 2>&1 +SINALACS_KEYSTORE_PATH="$tmp/teste.jks" SINALACS_KEYSTORE_PASSWORD=senhateste \ +SINALACS_KEY_ALIAS=teste SINALACS_KEY_PASSWORD=senhateste construir >/dev/null +"$apksigner" verify --print-certs "$apk" | grep -q "CN=SinalACS Teste" \ + || { echo "erro: o cenário 3 não assinou com o keystore informado." >&2; exit 1; } + +echo "OK — release exige chave própria; debug só com licença explícita" +``` + +- [ ] **Step 2: Ver falhar** + +Run: `./scripts/qa/acs_release_signing.sh; echo rc=$?` +Expected: `erro: a build de release passou sem chave de release.` e `rc=1` (o cenário 1 já falha o script: hoje a build assina com debug e passa). + +- [ ] **Step 3: Implementar no Gradle** + +Em `apps/acs/android/app/build.gradle.kts`: acrescentar, junto do `import java.util.Base64` do topo, + +```kotlin +import java.io.FileInputStream +import java.util.Properties +``` + +Depois da função `dartDefineValue` e antes do bloco `plugins`, acrescentar: + +```kotlin +// Chave de release: de `apps/acs/android/key.properties` (gitignorado) ou, para +// CI e shell, das variáveis SINALACS_KEYSTORE_*. Nenhum valor mora no repositório. +// storeFile=/caminho/para/release.jks +// storePassword=... +// keyAlias=... +// keyPassword=... +val keyProperties = Properties().apply { + val arquivo = rootProject.file("key.properties") + if (arquivo.exists()) FileInputStream(arquivo).use { load(it) } +} + +fun assinatura(propriedade: String, ambiente: String): String? = + keyProperties.getProperty(propriedade)?.takeIf { it.isNotBlank() } + ?: System.getenv(ambiente)?.takeIf { it.isNotBlank() } + +val releaseStoreFile = assinatura("storeFile", "SINALACS_KEYSTORE_PATH") +val releaseStorePassword = assinatura("storePassword", "SINALACS_KEYSTORE_PASSWORD") +val releaseKeyAlias = assinatura("keyAlias", "SINALACS_KEY_ALIAS") +val releaseKeyPassword = assinatura("keyPassword", "SINALACS_KEY_PASSWORD") +val temChaveDeRelease = listOf( + releaseStoreFile, releaseStorePassword, releaseKeyAlias, releaseKeyPassword, +).all { it != null } +``` + +Dentro de `android { ... }`, antes de `buildTypes`, acrescentar e trocar o bloco `release`: + +```kotlin + signingConfigs { + if (temChaveDeRelease) { + create("release") { + storeFile = file(releaseStoreFile!!) + storePassword = releaseStorePassword + keyAlias = releaseKeyAlias + keyPassword = releaseKeyPassword + } + } + } + + buildTypes { + release { + // Sem chave, a configuração continua carregável (sync da IDE, debug): + // quem barra a build de RELEASE é o guard `taskGraph.whenReady` abaixo. + signingConfig = signingConfigs.getByName(if (temChaveDeRelease) "release" else "debug") + } + } +``` + +No fim do arquivo, acrescentar o guard: + +```kotlin +// Guard de release: só quando uma tarefa de release está no grafo (o sync da IDE +// e `flutter run` em debug não passam por aqui). Sem chave de release, assinar +// com a de debug exige pedir (-Psinalacs.allowDebugSigning=true). +gradle.taskGraph.whenReady { + val buildaRelease = allTasks.any { + it.path.endsWith(":assembleRelease") || + it.path.endsWith(":bundleRelease") || + it.path.endsWith(":packageRelease") + } + if (buildaRelease && !temChaveDeRelease && !project.hasProperty("sinalacs.allowDebugSigning")) { + throw GradleException( + """ + |Sem chave de assinatura de release: esta build sairia assinada com a chave de debug. + | + |Informe a chave por apps/acs/android/key.properties (storeFile, storePassword, + |keyAlias, keyPassword) ou pelas variáveis SINALACS_KEYSTORE_PATH, + |SINALACS_KEYSTORE_PASSWORD, SINALACS_KEY_ALIAS e SINALACS_KEY_PASSWORD. + | + |Para assinar com a chave de debug DE PROPÓSITO (teste local, nunca distribuição): + | flutter build apk --release -Psinalacs.allowDebugSigning=true + """.trimMargin() + ) + } +} +``` + +- [ ] **Step 4: Ver passar** + +Run: `./scripts/qa/acs_release_signing.sh; echo rc=$?` +Expected: três cabeçalhos `== cenário N ==` e `OK — release exige chave própria; debug só com licença explícita`, `rc=0`. Se o cenário 1 falhar por o `grep "assinatura de release"` não casar, a mensagem do `GradleException` está certa e o Flutter a reformatou: ajuste o `grep` para uma palavra que apareça na saída real (`Sem chave de assinatura`) e **não** a mensagem. Se o `-P` do cenário 2 não chegar ao Gradle, use `--android-project-arg=sinalacs.allowDebugSigning=true` (flag do Flutter para argumentos do Gradle) nos scripts **e** na documentação. + +- [ ] **Step 5: Documentar e commitar** + +Em `apps/CLAUDE.md`, no parágrafo "ACS: permissões, SAMU e e2e", acrescentar: "A build de **release** do ACS exige chave própria (`android/key.properties` ou `SINALACS_KEYSTORE_*`) e **falha** sem ela; assinar com a de debug exige `-Psinalacs.allowDebugSigning=true`. `scripts/qa/acs_release_signing.sh` prova os três cenários com um keystore descartável. O CI não faz build de release e não guarda segredo de assinatura (aberto)." + +```bash +git add apps/acs/android/app/build.gradle.kts scripts/qa/acs_release_signing.sh apps/CLAUDE.md +git commit -m "feat(acs): build de release exige chave própria; debug só com licença explícita" +``` + +--- + +### Task 3: Botão da UBS passa a discar (RF13) + +**Files:** +- Modify: `backend/sinalacs_server/lib/src/models/ubs.spy.yaml` +- Create: `backend/sinalacs_server/lib/src/models/api/ubs_contact.spy.yaml` +- Create: `backend/sinalacs_server/lib/src/application/ubs/ubs_contact_service.dart` +- Create: `backend/sinalacs_server/lib/src/infrastructure/database/orm_ubs_contact_store.dart` +- Create: `backend/sinalacs_server/lib/src/endpoints/ubs_endpoint.dart` +- Modify: `backend/sinalacs_server/lib/src/runtime/alert_runtime.dart` (fábrica `ubsContactServiceFor`) +- Modify: `backend/sinalacs_server/lib/src/infrastructure/database/seeds/development.sql`, `backend/sinalacs_server/bin/seed_e2e_fixtures.dart` +- Test: `backend/sinalacs_server/test/unit/ubs_contact_service_test.dart` +- Modify: `apps/acs/lib/core/network/backend_client.dart`, `apps/acs/lib/app/app.dart`, `apps/acs/test/support/fakes.dart` +- Test: `apps/acs/test/escalation_ubs_test.dart` +- Modify: `spec/lgpd_data_audit.md`, `apps/CLAUDE.md` + +**Interfaces:** +- Consumes: `Authorization.require(user, roles:, onDenied:)` (usado por `VisitSyncService`), `AuthenticatedEndpoint.authenticate(accessToken)`, `EmergencyDialer.dial(String) -> Future`, `BackendScope.of(context)`. +- Produces: protocolo `UbsContact{ String name; String? phone }`; RPC `ubs.myContact(accessToken:)`; `AcsBackend.ubsContact() -> Future`; `UbsContactService.contactFor(AuthenticatedUser) -> Future`. + +- [ ] **Step 1: Teste do serviço (falha: o serviço não existe)** + +Criar `backend/sinalacs_server/test/unit/ubs_contact_service_test.dart`: + +```dart +import 'package:sinalacs_server/src/application/ubs/ubs_contact_service.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:test/test.dart'; + +const _acsId = '00000000-0000-4000-8000-000000000002'; + +class _FakeStore implements UbsContactStore { + _FakeStore(this.record); + UbsContactRecord? record; + final consultados = []; + + @override + Future findForAcs(String acsId) async { + consultados.add(acsId); + return record; + } +} + +AuthenticatedUser _usuario(UserRole role) => AuthenticatedUser( + id: _acsId, + role: role, + microAreaId: '00000000-0000-4000-8000-000000000003', + deviceId: 'dispositivo-teste', + ); + +void main() { + test('devolve nome e telefone da UBS do ACS', () async { + final service = UbsContactService( + store: _FakeStore(const UbsContactRecord(name: 'UBS Teste', phone: '+55 11 5550-0100')), + ); + + final contato = await service.contactFor(_usuario(UserRole.acs)); + + expect(contato.name, 'UBS Teste'); + expect(contato.phone, '+55 11 5550-0100'); + }); + + test('UBS sem telefone cadastrado devolve phone nulo, sem falhar', () async { + final service = UbsContactService( + store: _FakeStore(const UbsContactRecord(name: 'UBS Sem Fone', phone: null)), + ); + final contato = await service.contactFor(_usuario(UserRole.acs)); + expect(contato.phone, isNull); + }); + + test('telefone só com espaços vale como não cadastrado', () async { + final service = UbsContactService( + store: _FakeStore(const UbsContactRecord(name: 'UBS', phone: ' ')), + ); + expect((await service.contactFor(_usuario(UserRole.acs))).phone, isNull); + }); + + test('paciente não consulta o contato (nem toca no banco)', () async { + final store = _FakeStore(const UbsContactRecord(name: 'UBS', phone: '1')); + final service = UbsContactService(store: store); + + expect(() => service.contactFor(_usuario(UserRole.patient)), throwsA(isA())); + expect(store.consultados, isEmpty); + }); + + test('ACS sem linha na tabela acs é erro, não contato vazio', () async { + final service = UbsContactService(store: _FakeStore(null)); + expect(() => service.contactFor(_usuario(UserRole.acs)), throwsA(isA())); + }); +} +``` + +- [ ] **Step 2: Ver falhar** + +Run: `cd backend/sinalacs_server && dart test test/unit/ubs_contact_service_test.dart 2>&1 | tail -6` +Expected: **FAIL** de compilação (`Target of URI doesn't exist: ...ubs_contact_service.dart`). Antes disso, anotar a linha de base: `dart test test/unit 2>&1 | tail -2` (o total de testes unitários) no ledger. + +- [ ] **Step 3: Modelos, serviço, store e endpoint** + +`ubs.spy.yaml`, acrescentar ao final de `fields:`: + +```yaml + ### Telefone de contato da UBS para o ACS escalar um caso (RF13). Dado da + ### unidade, não de pessoa. `null` = ainda não cadastrado: o app avisa, não liga. + contactPhone: String? +``` + +Criar `models/api/ubs_contact.spy.yaml`: + +```yaml +### Contato da UBS do ACS autenticado (RF13). Só o que o botão "Ligar para a UBS" +### precisa: o nome para a mensagem e o telefone para o discador. +class: UbsContact +fields: + name: String + phone: String? +``` + +Criar `application/ubs/ubs_contact_service.dart`: + +```dart +import 'package:sinalacs_server/src/application/auth/authorization.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; + +class UbsContactRecord { + const UbsContactRecord({required this.name, required this.phone}); + + final String name; + final String? phone; +} + +abstract interface class UbsContactStore { + /// UBS do ACS `acsId`, ou `null` se o ACS ou a UBS não existirem. + Future findForAcs(String acsId); +} + +/// Contato da UBS para o botão de escalonamento do ACS (RF13). +/// +/// A UBS vem do **token** (`user.id` é o id do ACS), nunca de parâmetro — mesma +/// regra de território das outras consultas do ACS. +class UbsContactService { + UbsContactService({required this.store}); + + final UbsContactStore store; + + Future contactFor(AuthenticatedUser user) async { + Authorization.require( + user, + roles: {UserRole.acs}, + onDenied: () => StateError('Somente ACS consultam o contato da UBS.'), + ); + + final record = await store.findForAcs(user.id); + if (record == null) { + throw StateError('UBS do ACS não encontrada.'); + } + final phone = record.phone?.trim(); + return UbsContact( + name: record.name, + phone: (phone == null || phone.isEmpty) ? null : phone, + ); + } +} +``` + +Se `Authorization` não estiver em `application/auth/authorization.dart` ou exigir `microAreaId`, copie o formato exato da chamada em `application/visits/*` (o `VisitSyncService.pull` usa `Authorization.require(user, roles: {UserRole.acs}, onDenied: ...)`) e ajuste o `import`. + +Criar `infrastructure/database/orm_ubs_contact_store.dart`: + +```dart +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/ubs/ubs_contact_service.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; + +class OrmUbsContactStore implements UbsContactStore { + OrmUbsContactStore(this._session); + + final Session _session; + + @override + Future findForAcs(String acsId) async { + final acs = await Acs.db.findById(_session, UuidValue.fromString(acsId)); + if (acs == null) return null; + final ubs = await Ubs.db.findById(_session, acs.ubsId); + if (ubs == null) return null; + return UbsContactRecord(name: ubs.name, phone: ubs.contactPhone); + } +} +``` + +Criar `endpoints/ubs_endpoint.dart`: + +```dart +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/endpoints/authenticated_endpoint.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/runtime/alert_runtime.dart'; + +/// Contato da UBS do ACS (RF13). +class UbsEndpoint extends AuthenticatedEndpoint { + Future myContact( + Session session, { + required String accessToken, + }) async { + final user = authenticate(accessToken); + try { + return await AlertRuntime.instance.ubsContactServiceFor(session).contactFor(user); + } on StateError catch (error) { + throw AlertPermissionException(message: error.message); + } + } +} +``` + +Em `runtime/alert_runtime.dart`, ao lado de `patientDirectoryServiceFor` (linha ~206), acrescentar (com os `import`s do serviço e do store): + +```dart + /// Contato da UBS do ACS (RF13), para uma requisição. + UbsContactService ubsContactServiceFor(Session session) => + UbsContactService(store: OrmUbsContactStore(session)); +``` + +- [ ] **Step 4: Gerar, migrar e ver o serviço passar** + +Run: `cd backend/sinalacs_server && serverpod generate 2>&1 | tail -3 && serverpod create-migration 2>&1 | tail -3 && dart analyze 2>&1 | tail -2 && dart test test/unit/ubs_contact_service_test.dart 2>&1 | tail -2` +Expected: `generate` e `create-migration` sem erro (uma pasta nova em `migrations/` com `ALTER TABLE "ubs" ADD COLUMN "contactPhone" text;`); `No issues found!`; `All tests passed!` (5 testes). Conferir no SQL gerado que a coluna é **nullable** e que nada é `DROP`. + +- [ ] **Step 5: Seed de desenvolvimento e de e2e** + +Em `development.sql`, **depois** do `INSERT INTO "ubs" ... ON CONFLICT ("id") DO NOTHING;`, acrescentar (o `DO NOTHING` não atualiza bancos que já têm a UBS): + +```sql +-- Telefone sintético (RF13). O UPDATE cobre bancos de dev criados antes da coluna. +UPDATE "ubs" SET "contactPhone" = '+55 11 5550-0100' +WHERE "id" = '00000000-0000-4000-8000-000000000004' AND "contactPhone" IS NULL; +``` + +Em `bin/seed_e2e_fixtures.dart` (~linha 52), trocar o `INSERT INTO "ubs"` por: + +```dart + Sql.named('INSERT INTO "ubs" ("id","name","address","city","state","contactPhone") ' + "VALUES (@id, 'UBS E2E', 'Endereço de teste', 'São Paulo', 'SP', '+55 11 5550-0199')"), +``` + +- [ ] **Step 6: Teste do app (falha: não há `ubsContact`)** + +Em `apps/acs/test/support/fakes.dart`, dentro de `FakeAcsBackend`, acrescentar: + +```dart + /// Contato que `ubsContact()` devolve; `null` simula uma UBS sem telefone. + UbsContact ubsContactResult = UbsContact(name: 'UBS Teste', phone: '+55 11 5550-0100'); + + /// Falha da chamada, como uma queda de rede. + BackendFailure? ubsContactFailure; + + int ubsContactCount = 0; + + @override + Future ubsContact() async { + ubsContactCount++; + final falha = ubsContactFailure; + if (falha != null) throw falha; + return ubsContactResult; + } +``` + +Criar `apps/acs/test/escalation_ubs_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/app/app.dart'; +import 'package:sinalacs_acs/core/network/backend_client.dart'; +import 'package:sinalacs_acs/core/network/backend_scope.dart'; +import 'package:sinalacs_acs/core/services/emergency_dialer.dart'; +import 'package:sinalacs_client/sinalacs_client.dart' show UbsContact; + +import 'support/fakes.dart'; + +class _Dialer implements EmergencyDialer { + _Dialer({this.abre = true}); + final bool abre; + final discados = []; + + @override + Future dial(String number) async { + discados.add(number); + return abre; + } +} + +Future _abrir(WidgetTester tester, FakeAcsBackend backend, _Dialer dialer) => + tester.pumpWidget(MaterialApp( + home: BackendScope( + backend: backend, + child: Scaffold( + body: EscalationScreen(alert: testAlert(alertId: 'a1'), dialer: dialer), + ), + ), + )); + +void main() { + testWidgets('o botão da UBS abre o discador com o telefone cadastrado, só dígitos e +', (tester) async { + final backend = FakeAcsBackend(); + final dialer = _Dialer(); + await _abrir(tester, backend, dialer); + + await tester.tap(find.byKey(const Key('escalation_ubs'))); + await tester.pumpAndSettle(); + + expect(dialer.discados, ['+551155500100']); + expect(backend.ubsContactCount, 1); + }); + + testWidgets('UBS sem telefone: avisa e não liga', (tester) async { + final backend = FakeAcsBackend()..ubsContactResult = UbsContact(name: 'UBS Sem Fone'); + final dialer = _Dialer(); + await _abrir(tester, backend, dialer); + + await tester.tap(find.byKey(const Key('escalation_ubs'))); + await tester.pumpAndSettle(); + + expect(dialer.discados, isEmpty); + expect(find.textContaining('UBS Sem Fone ainda não cadastrou um telefone'), findsOneWidget); + }); + + testWidgets('sem discador, mostra o telefone em texto', (tester) async { + final backend = FakeAcsBackend(); + final dialer = _Dialer(abre: false); + await _abrir(tester, backend, dialer); + + await tester.tap(find.byKey(const Key('escalation_ubs'))); + await tester.pumpAndSettle(); + + expect(find.textContaining('Ligue manualmente para +55 11 5550-0100'), findsOneWidget); + }); + + testWidgets('falha ao obter o contato mostra a mensagem do backend e não liga', (tester) async { + final backend = FakeAcsBackend()..ubsContactFailure = const BackendFailure('Sem conexão.'); + final dialer = _Dialer(); + await _abrir(tester, backend, dialer); + + await tester.tap(find.byKey(const Key('escalation_ubs'))); + await tester.pumpAndSettle(); + + expect(dialer.discados, isEmpty); + expect(find.text('Sem conexão.'), findsOneWidget); + }); +} +``` + +- [ ] **Step 7: Ver falhar** + +Run: `cd apps/acs && flutter test test/escalation_ubs_test.dart 2>&1 | tail -6` +Expected: **FAIL** de compilação (`The method 'ubsContact' isn't defined` / `Key('escalation_ubs')` ausente). Se `UbsContact` não for exportado por `sinalacs_client`, rode `cd backend && dart pub get` e confira que o `serverpod generate` do Step 4 regenerou `backend/sinalacs_client`. + +- [ ] **Step 8: Implementar no app** + +Em `backend_client.dart`: na interface `AcsBackend` acrescentar + +```dart + /// Contato da UBS do ACS (RF13): nome e telefone, que pode não estar cadastrado. + Future ubsContact(); +``` + +na classe que recusa tudo (a que tem `_recusar()`): + +```dart + @override + Future ubsContact() async => _recusar(); +``` + +e no `BackendClient`, ao lado de `listPatients`: + +```dart + @override + Future ubsContact() async { + final token = await _requireToken(); + return _guard(() => _client.ubs.myContact(accessToken: token)); + } +``` + +Em `app.dart`, em `_EscalationScreenState`, depois de `_callSamu`: + +```dart + /// Liga para a UBS do ACS (RF13). O telefone vem do servidor; sem ele, avisa. + Future _callUbs() async { + if (_dialing) return; + setState(() => _dialing = true); + String? aviso; + try { + final contato = await BackendScope.of(context).ubsContact(); + final telefone = contato.phone?.trim(); + if (telefone == null || telefone.isEmpty) { + aviso = '${contato.name} ainda não cadastrou um telefone. Acione a coordenação.'; + } else { + // O discador recebe só dígitos e `+`: espaço e hífen viram %20 no `tel:`. + final numero = telefone.replaceAll(RegExp(r'[^0-9+]'), ''); + if (!await widget.dialer.dial(numero)) { + aviso = 'Não foi possível abrir o discador. Ligue manualmente para $telefone.'; + } + } + } on BackendFailure catch (falha) { + aviso = falha.message; + } catch (_) { + aviso = 'Não foi possível obter o contato da UBS.'; + } + if (!mounted) return; + setState(() => _dialing = false); + if (aviso != null) _message(context, aviso); + } +``` + +e trocar o `OutlinedButton` "Encaminhar para UBS Central" por: + +```dart + OutlinedButton.icon( + key: const Key('escalation_ubs'), + onPressed: _dialing ? null : _callUbs, + style: OutlinedButton.styleFrom(minimumSize: const Size(48, 52)), + icon: const Icon(Icons.local_hospital_outlined), + label: const Text('Ligar para a UBS'), + ), +``` + +Procurar referências ao texto antigo: `grep -rn "Encaminhar para UBS\|será integrado à UBS" apps spec docs PROGRESS.md --include=*.dart --include=*.md`. Atualizar os testes que o citam (`escalation_dialer_test.dart` etc.) para o novo comportamento, e os `.md` que dizem "botão da UBS segue sendo um aviso" (`PROGRESS.md`, `apps/CLAUDE.md`, `spec/validation_report.md` linha RF13) para "o botão liga para a UBS (`ubs.myContact`); sem telefone cadastrado, avisa". + +- [ ] **Step 9: Ver passar e rodar tudo** + +Run: `cd apps/acs && flutter analyze 2>&1 | tail -2 && flutter test 2>&1 | tail -2` +Expected: `No issues found!`; `All tests passed!` com **≥ 224** testes (220 + 4). + +- [ ] **Step 10: Prova no emulador contra o servidor real** + +Em `apps/acs/integration_test/full_journey_e2e.dart`, dentro do **primeiro** `testWidgets` (depois do `pullVisits`, onde já existe `acsClient` logado), acrescentar: + +```dart + // RF13: o contato da UBS chega pelo servidor real, escopado pelo token do ACS. + final contato = await acsClient.ubs.myContact(accessToken: acs.accessToken); + expect(contato.name, 'UBS E2E'); + expect(contato.phone, '+55 11 5550-0199'); +``` + +Run: `docker compose up -d --build 2>&1 | tail -2; ./scripts/qa/acs_full_e2e.sh 2>&1 | grep -E "OK —|Some tests|Expected|Actual|rc=" ; docker compose up -d` +Expected: `OK — jornada completa do ACS contra o banco de teste`. (O `acs_full_e2e.sh` sobe a stack de e2e a partir do código atual; se ele não reconstruir a imagem do servidor, o `myContact` não existirá e o teste falha com `404`: rode `docker compose -f docker-compose.e2e.yml build` antes e registre no ledger.) + +- [ ] **Step 11: Documentar e commitar** + +Em `spec/lgpd_data_audit.md`, na tabela de `ubs`, acrescentar a linha `contactPhone | String? | Dado da unidade (não pessoal) | Telefone para escalonamento (RF13) | Nullable`; e **re-medir** a contagem de tabelas citada no `CLAUDE.md` da raiz (`definition.sql` da última migração) — a tabela não muda de número, só ganha coluna. + +```bash +git add backend apps/acs spec PROGRESS.md apps/CLAUDE.md +git commit -m "feat(acs): botão da UBS liga para o telefone cadastrado (RF13) via ubs.myContact" +``` + +--- + +### Task 4: RF08 — microárea persistida e criptografada no aparelho + +**Files:** +- Modify: `spec/lgpd_design.md` (nova seção), `spec/lgpd_data_audit.md` +- Modify: `apps/acs/lib/core/database/encrypted_database.dart` (v6) +- Create: `apps/acs/lib/core/database/micro_area_cache_store.dart` +- Create: `apps/acs/lib/core/services/micro_area_directory.dart` +- Create: `apps/acs/lib/core/services/micro_area_directory_factory.dart` +- Modify: `apps/acs/lib/app/app.dart` (montagem e dois pontos de chamada, aviso de cache) +- Test: `apps/acs/test/micro_area_cache_store_test.dart`, `apps/acs/test/micro_area_directory_test.dart`, `apps/acs/test/encrypted_database_test.dart` (migração v6) +- Modify: `apps/acs/integration_test/full_journey_e2e.dart` (prova na base criptografada do aparelho) + +**Interfaces:** +- Consumes: `MicroAreaPatient({patientId, name, isChronic, chronicConditions})` (de `sinalacs_client`), `AuthSession{userId, microAreaId}` (`auth_session.dart`), `BackendFailure{message, isRecoverable}`, `DatabaseKeyStore.readOrCreate()`, `EncryptedLocalDatabase.open({databaseName, passphrase, allowUnencryptedForTesting})`. +- Produces: + - `class CachedMicroArea { final List patients; final DateTime fetchedAt; }` + - `class MicroAreaCacheStore { Future read({required String owner}); Future write({required String owner, required List patients, required DateTime at}); Future clear(); }` + - `class MicroAreaSnapshot { final List patients; final DateTime fetchedAt; final bool fromCache; }` + - `class MicroAreaDirectory { MicroAreaDirectory({required Future> Function() fetch, required AuthSession? Function() session, required MicroAreaCacheStore store, DateTime Function()? clock, Duration maxAge = const Duration(hours: 72)}); Future load(); }` + - `MicroAreaDirectory buildMicroAreaDirectory({required AcsBackend backend})` e a constante `microAreaCacheMaxAge`. + +- [ ] **Step 1: Escrever a decisão de LGPD antes do código** + +Em `spec/lgpd_design.md`, depois da §5.6 (minimização), acrescentar a seção: + +```markdown +### 5.7 Cache local da microárea (RF08) + +| | | +|---|---| +| **Decisão** | O ACS guarda no aparelho a última lista da microárea (`patients.listMicroArea`) para registrar visita **sem rede**. | +| **O que guarda** | Só o que a tela de visita já mostra: `patientId`, nome, `isChronic` e condições crônicas — o mesmo conjunto da §5.6. Nada de contato de emergência, endereço, histórico ou triagem. | +| **Onde** | Tabelas `micro_area_cache` e `micro_area_cache_meta` na **mesma base SQLCipher** das visitas offline (INV-04; chave no Keystore, §5.1). | +| **Dono** | A lista pertence a `userId\|microAreaId` da sessão que a baixou. Outro usuário ou outra microárea **apaga** o cache e não o serve (RNF06). | +| **Validade** | 72 horas desde o último download bem-sucedido. Vencida, **não é usada e é apagada**. | +| **Quando entra** | Só no lugar de uma falha **recuperável** de rede. Uma recusa do servidor (sessão inválida, território negado, `isRecoverable: false`) nunca cai no cache. | +| **Transparência** | A tela diz que a lista é do cache e de quando. | +| **Retenção** | Some pela validade, pelo troca de dono e com a desinstalação; nunca é enviada a lugar nenhum. | +| **Se for revisto** | Reduzir `microAreaCacheMaxAge` ou apagar a tabela v6 é migração aditiva reversível. | +``` + +Em `spec/lgpd_data_audit.md`, acrescentar as duas tabelas **locais** do aparelho (`micro_area_cache`: `patient_id`, `name` = identificador pessoal, `is_chronic`, `chronic_conditions` = dado sensível de saúde; `micro_area_cache_meta`: `owner`, `fetched_at` = metadado) com a mesma classificação das colunas equivalentes de `patients`, e a nota "no aparelho, sob SQLCipher". + +- [ ] **Step 2: Teste da migração v6 (falha: `schemaVersion` é 5)** + +Em `apps/acs/test/encrypted_database_test.dart`, copiando o `setUp`/`tearDown` e o jeito de criar um banco antigo dos testes de migração que já existem nesse arquivo (o de v4 → v5 para `sync_cursor`), acrescentar o caso: criar um banco **v5** com uma visita pendente em `offline_visits`, abri-lo com `EncryptedLocalDatabase.open`, e afirmar que (a) as duas tabelas novas existem (`SELECT name FROM sqlite_master WHERE name IN ('micro_area_cache','micro_area_cache_meta')` devolve 2 linhas), (b) a visita pendente continua lá, (c) `EncryptedLocalDatabase.schemaVersion == 6`. + +Run: `cd apps/acs && flutter test test/encrypted_database_test.dart 2>&1 | tail -6` +Expected: o caso novo **FALHA** (`Expected: <6> Actual: <5>`). + +- [ ] **Step 3: Implementar a migração v6** + +Em `encrypted_database.dart`: `static const schemaVersion = 6;`, acrescentar à doc da versão "v6 acrescenta `micro_area_cache` e `micro_area_cache_meta`, o cache da microárea (RF08, `spec/lgpd_design.md` §5.7)", e: + +```dart + /// Cache da microárea (RF08, §5.7 de spec/lgpd_design.md): a última lista que + /// o servidor devolveu, de um dono (`userId|microAreaId`), com a data do + /// download. Sob a mesma criptografia das visitas. + static const createMicroAreaCache = ''' +CREATE TABLE IF NOT EXISTS micro_area_cache ( + patient_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + is_chronic INTEGER NOT NULL, + chronic_conditions TEXT NOT NULL +)'''; + + static const createMicroAreaCacheMeta = ''' +CREATE TABLE IF NOT EXISTS micro_area_cache_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +)'''; +``` + +Nos dois caminhos que criam o banco do zero (`onCreate`, linhas ~156 e ~171 — onde já se executam `createOfflineVisits` e `createSyncCursor`), executar também as duas novas; em `_upgrade`, depois do bloco `from < 5`: + +```dart + if (from < 6) { + // Aditiva: `CREATE TABLE IF NOT EXISTS` não toca em nada que já existe. + await db.execute(createMicroAreaCache); + await db.execute(createMicroAreaCacheMeta); + } +``` + +Atualizar o comentário "Migração v1 → v2, … e v4 → v5" para incluir v5 → v6. + +Run: `cd apps/acs && flutter test test/encrypted_database_test.dart 2>&1 | tail -3` +Expected: `All tests passed!`. + +- [ ] **Step 4: Teste do store (falha: o store não existe)** + +Criar `apps/acs/test/micro_area_cache_store_test.dart`. Copie o cabeçalho de `test/sync_cursor_store_test.dart` (imports, `InMemoryDatabaseKeyStore`, nome de banco único por teste e remoção do arquivo no `tearDown`) e use `MicroAreaCacheStore(keyStore: ..., databaseName: ..., allowUnencryptedForTesting: true)`: + +```dart +MicroAreaPatient _paciente(int n, {bool cronico = false}) => MicroAreaPatient( + patientId: syntheticPatientId(n), + name: 'Paciente Sintético $n', + isChronic: cronico, + chronicConditions: cronico ? ['hipertensão', 'diabetes'] : const [], + ); + +// ... dentro de main(), com o store criado no setUp: +test('sem nada gravado, read devolve null', () async { + expect(await store.read(owner: 'u1|m1'), isNull); +}); + +test('grava e lê de volta a lista, a data e as condições crônicas', () async { + final em = DateTime.utc(2026, 10, 2, 8); + await store.write(owner: 'u1|m1', patients: [_paciente(1), _paciente(2, cronico: true)], at: em); + + final lido = await store.read(owner: 'u1|m1'); + + expect(lido!.fetchedAt, em); + expect(lido.patients.map((p) => p.patientId), [syntheticPatientId(1), syntheticPatientId(2)]); + expect(lido.patients.last.isChronic, isTrue); + expect(lido.patients.last.chronicConditions, ['hipertensão', 'diabetes']); +}); + +test('outro dono apaga e não serve', () async { + await store.write(owner: 'u1|m1', patients: [_paciente(1)], at: DateTime.utc(2026, 10, 2)); + + expect(await store.read(owner: 'u2|m1'), isNull, reason: 'outro usuário'); + expect(await store.read(owner: 'u1|m1'), isNull, reason: 'o cache do dono antigo foi APAGADO, não só escondido'); +}); + +test('regravar substitui a lista inteira (paciente que saiu da microárea some)', () async { + await store.write(owner: 'u1|m1', patients: [_paciente(1), _paciente(2)], at: DateTime.utc(2026, 10, 2)); + await store.write(owner: 'u1|m1', patients: [_paciente(2)], at: DateTime.utc(2026, 10, 3)); + + final lido = await store.read(owner: 'u1|m1'); + expect(lido!.patients.map((p) => p.patientId), [syntheticPatientId(2)]); +}); + +test('clear apaga tudo', () async { + await store.write(owner: 'u1|m1', patients: [_paciente(1)], at: DateTime.utc(2026, 10, 2)); + await store.clear(); + expect(await store.read(owner: 'u1|m1'), isNull); +}); +``` + +(`syntheticPatientId` vem de `test/support/fakes.dart`; `MicroAreaPatient` de `package:sinalacs_client/sinalacs_client.dart`.) + +Run: `cd apps/acs && flutter test test/micro_area_cache_store_test.dart 2>&1 | tail -4` +Expected: **FAIL** de compilação (`micro_area_cache_store.dart` não existe). + +- [ ] **Step 5: Implementar o store** + +Criar `lib/core/database/micro_area_cache_store.dart`: + +```dart +import 'dart:convert'; + +import 'package:sinalacs_acs/core/database/encrypted_database.dart'; +import 'package:sinalacs_acs/core/security/database_key_store.dart'; +import 'package:sinalacs_client/sinalacs_client.dart' show MicroAreaPatient; +import 'package:sqflite_common_ffi/sqflite_ffi.dart' show ConflictAlgorithm, Database; + +class CachedMicroArea { + const CachedMicroArea({required this.patients, required this.fetchedAt}); + + final List patients; + final DateTime fetchedAt; +} + +/// Última lista da microárea no aparelho (RF08), na base SQLCipher das visitas. +/// +/// Política em `spec/lgpd_design.md` §5.7: a lista pertence a um dono +/// (`userId|microAreaId`); [read] de **outro** dono apaga o cache em vez de +/// só escondê-lo — dado de outro território não fica no disco esperando uma +/// confusão. +class MicroAreaCacheStore { + MicroAreaCacheStore({ + required DatabaseKeyStore keyStore, + this.databaseName = 'sinalacs_acs.db', + this.allowUnencryptedForTesting = false, + }) : _keyStore = keyStore; + + static const _tabela = 'micro_area_cache'; + static const _meta = 'micro_area_cache_meta'; + static const _chaveDono = 'owner'; + static const _chaveData = 'fetched_at'; + + final DatabaseKeyStore _keyStore; + final String databaseName; + final bool allowUnencryptedForTesting; + Database? _database; + + Future _open() async { + final existente = _database; + if (existente != null && existente.isOpen) return existente; + final passphrase = await _keyStore.readOrCreate(); + return _database = await EncryptedLocalDatabase.open( + databaseName: databaseName, + passphrase: passphrase, + allowUnencryptedForTesting: allowUnencryptedForTesting, + ); + } + + Future read({required String owner}) async { + final db = await _open(); + final meta = { + for (final linha in await db.query(_meta)) linha['key'] as String: linha['value'] as String, + }; + final donoGravado = meta[_chaveDono]; + final data = meta[_chaveData]; + if (donoGravado == null || data == null) return null; + if (donoGravado != owner) { + await clear(); + return null; + } + + final linhas = await db.query(_tabela, orderBy: 'rowid'); + return CachedMicroArea( + fetchedAt: DateTime.parse(data), + patients: [ + for (final linha in linhas) + MicroAreaPatient( + patientId: linha['patient_id'] as String, + name: linha['name'] as String, + isChronic: (linha['is_chronic'] as int) == 1, + chronicConditions: (jsonDecode(linha['chronic_conditions'] as String) as List).cast(), + ), + ], + ); + } + + Future write({ + required String owner, + required List patients, + required DateTime at, + }) async { + final db = await _open(); + await db.transaction((txn) async { + await txn.delete(_tabela); + for (final p in patients) { + await txn.insert(_tabela, { + 'patient_id': p.patientId, + 'name': p.name, + 'is_chronic': p.isChronic ? 1 : 0, + 'chronic_conditions': jsonEncode(p.chronicConditions), + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + await txn.insert(_meta, {'key': _chaveDono, 'value': owner}, conflictAlgorithm: ConflictAlgorithm.replace); + await txn.insert(_meta, {'key': _chaveData, 'value': at.toUtc().toIso8601String()}, + conflictAlgorithm: ConflictAlgorithm.replace); + }); + } + + Future clear() async { + final db = await _open(); + await db.transaction((txn) async { + await txn.delete(_tabela); + await txn.delete(_meta); + }); + } +} +``` + +Run: `cd apps/acs && flutter test test/micro_area_cache_store_test.dart 2>&1 | tail -3` +Expected: `All tests passed!` (5 testes). + +- [ ] **Step 6: Teste do `MicroAreaDirectory` (falha: não existe)** + +Criar `apps/acs/test/micro_area_directory_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/core/database/micro_area_cache_store.dart'; +import 'package:sinalacs_acs/core/network/auth_session.dart'; +import 'package:sinalacs_acs/core/network/backend_client.dart'; +import 'package:sinalacs_acs/core/services/micro_area_directory.dart'; +import 'package:sinalacs_client/sinalacs_client.dart' show MicroAreaPatient; + +import 'support/fakes.dart'; + +/// Store em memória: o que se testa aqui é a REGRA (quando serve cache), não o SQLite. +class _Store implements MicroAreaCacheStore { + String? owner; + List? patients; + DateTime? at; + int clears = 0; + bool falhaAoGravar = false; + + @override + Future read({required String owner}) async { + if (this.owner == null) return null; + if (this.owner != owner) { + await clear(); + return null; + } + return CachedMicroArea(patients: patients!, fetchedAt: at!); + } + + @override + Future write({required String owner, required List patients, required DateTime at}) async { + if (falhaAoGravar) throw StateError('disco cheio'); + this.owner = owner; + this.patients = patients; + this.at = at; + } + + @override + Future clear() async { + clears++; + owner = null; + patients = null; + at = null; + } +} + +AuthSession _sessao({String userId = 'u1', String? microAreaId = 'm1'}) => AuthSession( + accessToken: 't', + tokenType: 'Bearer', + userId: userId, + role: 'acs', + microAreaId: microAreaId, + expiresAt: DateTime.utc(2030), + ); + +MicroAreaPatient _p(int n) => + MicroAreaPatient(patientId: syntheticPatientId(n), name: 'P$n', isChronic: false, chronicConditions: const []); + +void main() { + late _Store store; + late DateTime agora; + late AuthSession? sessao; + late Future> Function() busca; + + MicroAreaDirectory diretorio() => MicroAreaDirectory( + fetch: () => busca(), + session: () => sessao, + store: store, + clock: () => agora, + ); + + setUp(() { + store = _Store(); + agora = DateTime.utc(2026, 10, 2, 8); + sessao = _sessao(); + busca = () async => [_p(1), _p(2)]; + }); + + test('com rede: devolve a lista fresca e grava', () async { + final r = await diretorio().load(); + + expect(r.fromCache, isFalse); + expect(r.patients, hasLength(2)); + expect(store.patients, hasLength(2)); + expect(store.owner, 'u1|m1'); + }); + + test('sem rede, com cache do mesmo dono: serve o cache e diz a data', () async { + await diretorio().load(); + agora = agora.add(const Duration(hours: 5)); + busca = () async => throw const BackendFailure('Sem conexão.'); + + final r = await diretorio().load(); + + expect(r.fromCache, isTrue); + expect(r.patients, hasLength(2)); + expect(r.fetchedAt, DateTime.utc(2026, 10, 2, 8)); + }); + + test('sem rede e sem cache: a falha original sobe', () async { + busca = () async => throw const BackendFailure('Sem conexão.'); + expect(diretorio().load(), throwsA(isA())); + }); + + test('recusa do servidor NUNCA cai no cache', () async { + await diretorio().load(); + busca = () async => throw const BackendFailure('Sessão inválida.', isRecoverable: false); + + expect(diretorio().load(), throwsA(isA())); + }); + + test('outro dono apaga e não serve', () async { + await diretorio().load(); + sessao = _sessao(userId: 'u2'); + busca = () async => throw const BackendFailure('Sem conexão.'); + + expect(diretorio().load(), throwsA(isA())); + expect(store.owner, isNull, reason: 'o cache do outro usuário foi apagado'); + }); + + test('outra microárea apaga e não serve', () async { + await diretorio().load(); + sessao = _sessao(microAreaId: 'm2'); + busca = () async => throw const BackendFailure('Sem conexão.'); + + expect(diretorio().load(), throwsA(isA())); + expect(store.owner, isNull); + }); + + test('vencido não serve e é apagado', () async { + await diretorio().load(); + agora = agora.add(const Duration(hours: 73)); + busca = () async => throw const BackendFailure('Sem conexão.'); + + expect(diretorio().load(), throwsA(isA())); + expect(store.owner, isNull); + }); + + test('exatamente 72 h ainda serve', () async { + await diretorio().load(); + agora = agora.add(const Duration(hours: 72)); + busca = () async => throw const BackendFailure('Sem conexão.'); + + expect((await diretorio().load()).fromCache, isTrue); + }); + + test('falha ao gravar o cache não derruba a lista fresca', () async { + store.falhaAoGravar = true; + final r = await diretorio().load(); + expect(r.patients, hasLength(2)); + expect(r.fromCache, isFalse); + }); + + test('sem sessão não há dono: nada é lido nem gravado', () async { + sessao = null; + final r = await diretorio().load(); + expect(r.fromCache, isFalse); + expect(store.owner, isNull); + }); + + test('lista vazia fresca substitui o cache (a microárea esvaziou)', () async { + await diretorio().load(); + busca = () async => []; + await diretorio().load(); + expect(store.patients, isEmpty); + }); +} +``` + +Run: `cd apps/acs && flutter test test/micro_area_directory_test.dart 2>&1 | tail -4` +Expected: **FAIL** de compilação (`micro_area_directory.dart` não existe). + +- [ ] **Step 7: Implementar o serviço e a fábrica** + +Criar `lib/core/services/micro_area_directory.dart`: + +```dart +import 'dart:developer' as developer; + +import 'package:sinalacs_acs/core/database/micro_area_cache_store.dart'; +import 'package:sinalacs_acs/core/network/auth_session.dart'; +import 'package:sinalacs_acs/core/network/backend_client.dart'; +import 'package:sinalacs_client/sinalacs_client.dart' show MicroAreaPatient; + +/// Validade do cache da microárea (spec/lgpd_design.md §5.7). +const microAreaCacheMaxAge = Duration(hours: 72); + +class MicroAreaSnapshot { + const MicroAreaSnapshot({required this.patients, required this.fetchedAt, required this.fromCache}); + + final List patients; + final DateTime fetchedAt; + + /// `true` quando a lista veio do aparelho porque a central não respondeu. + final bool fromCache; +} + +/// Lista da microárea com cache no aparelho (RF08). +/// +/// Regra única, em um lugar só: o cache entra **apenas** no lugar de uma falha +/// recuperável (`isRecoverable`) e **apenas** para o mesmo `userId|microAreaId` +/// e dentro da validade. Recusa do servidor (`isRecoverable: false`) sobe como +/// veio — um território negado não pode ser contornado por um cache antigo. +class MicroAreaDirectory { + MicroAreaDirectory({ + required Future> Function() fetch, + required AuthSession? Function() session, + required MicroAreaCacheStore store, + DateTime Function()? clock, + this.maxAge = microAreaCacheMaxAge, + }) : _fetch = fetch, + _session = session, + _store = store, + _clock = clock ?? DateTime.now; + + final Future> Function() _fetch; + final AuthSession? Function() _session; + final MicroAreaCacheStore _store; + final DateTime Function() _clock; + final Duration maxAge; + + String? _owner() { + final s = _session(); + if (s == null || s.microAreaId == null) return null; + return '${s.userId}|${s.microAreaId}'; + } + + Future load() async { + final owner = _owner(); + try { + final fresh = await _fetch(); + final at = _clock().toUtc(); + if (owner != null) { + try { + await _store.write(owner: owner, patients: fresh, at: at); + } catch (error, stack) { + // Gravar o cache é conforto; nunca derruba a lista que acabou de chegar. + developer.log('não foi possível gravar o cache da microárea', + name: 'sinalacs.acs.micro_area_directory', error: error, stackTrace: stack); + } + } + return MicroAreaSnapshot(patients: fresh, fetchedAt: at, fromCache: false); + } on BackendFailure catch (falha) { + if (!falha.isRecoverable || owner == null) rethrow; + final guardado = await _lerSemFalhar(owner); + if (guardado == null) rethrow; + if (_clock().toUtc().difference(guardado.fetchedAt) > maxAge) { + await _apagarSemFalhar(); + rethrow; + } + return MicroAreaSnapshot(patients: guardado.patients, fetchedAt: guardado.fetchedAt, fromCache: true); + } + } + + Future _lerSemFalhar(String owner) async { + try { + return await _store.read(owner: owner); + } catch (_) { + return null; // cache ilegível = sem cache; a falha da rede é a que importa + } + } + + Future _apagarSemFalhar() async { + try { + await _store.clear(); + } catch (_) {} + } +} +``` + +Criar `lib/core/services/micro_area_directory_factory.dart` (mesmo desenho de `visit_pull_service_factory.dart`: em produção o `keyStore` é o do Keystore/Keychain do aparelho e `allowUnencryptedForTesting` fica `false`): + +```dart +import 'package:sinalacs_acs/core/database/micro_area_cache_store.dart'; +import 'package:sinalacs_acs/core/network/backend_client.dart'; +import 'package:sinalacs_acs/core/security/database_key_store.dart'; +import 'package:sinalacs_acs/core/services/micro_area_directory.dart'; + +/// Monta o diretório da microárea sobre o backend e a base SQLCipher do aparelho. +/// +/// [cacheStore] existe só para um teste inspecionar o que foi gravado; em +/// produção a chamada não passa nada e usa o padrão, respaldado pelo +/// Keystore/Keychain. +MicroAreaDirectory buildMicroAreaDirectory({ + required AcsBackend backend, + MicroAreaCacheStore? cacheStore, +}) => + MicroAreaDirectory( + fetch: backend.listPatients, + session: () => backend.session, + store: cacheStore ?? MicroAreaCacheStore(keyStore: SecureStorageDatabaseKeyStore()), + ); +``` + +Os testes de widget que **não** injetam um `MicroAreaDirectory` usam este de produção; o store só abre a base na primeira leitura/gravação e, no `flutter test`, o Keystore não existe — a falha é engolida pelo próprio `MicroAreaDirectory` (`falha ao gravar o cache não derruba a lista fresca`), então esses testes seguem verdes. + +Run: `cd apps/acs && flutter test test/micro_area_directory_test.dart 2>&1 | tail -3` +Expected: `All tests passed!` (11 testes). + +- [ ] **Step 8: Ligar nos dois pontos de chamada, com aviso** + +Em `app.dart`: + +1. `_SinalAcsAppState` (onde `_visitStore`/`buildVisitPullService` são montados, linhas ~81–98): acrescentar `late final MicroAreaDirectory _microAreaDirectory = widget.microAreaDirectory ?? buildMicroAreaDirectory(backend: widget.backend);` e o parâmetro opcional `MicroAreaDirectory? microAreaDirectory` em `SinalAcsApp` (para os testes), repassado a `AcsHomeShell` como `directory`. +2. `AcsHomeShell._loadMicroAreaPatients` (~436): trocar `final patients = await backend.listPatients();` por + ```dart + final snapshot = widget.directory == null + ? null + : await widget.directory!.load(); + final patients = snapshot?.patients ?? await backend.listPatients(); + ``` + e, no `setState`, `_microAreaPatientsLoadedAt = snapshot?.fetchedAt ?? DateTime.now();` e `_microAreaFromCache = snapshot?.fromCache ?? false;` (campo novo, `false` no início de cada carga). +3. `_VisitRegistrationScreenState._loadPatients` (~1602): receber `directory` por parâmetro opcional de `VisitRegistrationScreen` (o shell o repassa) e trocar `final result = await BackendScope.of(context).listPatients();` por `final snapshot = widget.directory == null ? null : await widget.directory!.load(); final result = snapshot?.patients ?? await BackendScope.of(context).listPatients();` guardando `_patientsFromCache = snapshot?.fromCache ?? false; _patientsFetchedAt = snapshot?.fetchedAt;`. +4. Aviso (nas duas telas, logo acima da lista/contador, quando `fromCache`), com a chave `micro_area_cache_notice` na tela de visita e `micro_area_cache_notice_area` na de Área: + +```dart +if (_patientsFromCache && _patientsFetchedAt != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Semantics( + liveRegion: true, + child: Text( + key: const Key('micro_area_cache_notice'), + 'Sem conexão com a central: lista salva em ${_formatarDataHora(_patientsFetchedAt!)}.', + style: TextStyle(color: context.acsRisk.accentOnSurface, fontWeight: FontWeight.bold), + ), + ), + ), +``` + +com a função de nível de arquivo (sem `intl`, o app não a usa): + +```dart +String _formatarDataHora(DateTime utc) { + final d = utc.toLocal(); + String dois(int n) => n.toString().padLeft(2, '0'); + return '${dois(d.day)}/${dois(d.month)} às ${dois(d.hour)}:${dois(d.minute)}'; +} +``` + +Os dois parâmetros novos são **opcionais**: os 220+ testes existentes, que não os passam, seguem iguais. + +- [ ] **Step 9: Teste de widget do aviso e do offline na tela de visita** + +Em `apps/acs/test/login_flow_test.dart` (ou arquivo novo `micro_area_cache_ui_test.dart` no mesmo estilo de `login_flow_test.dart`), caso: `FakeAcsBackend` com `patients` preenchidos; um `MicroAreaDirectory(fetch: backend.listPatients, session: () => backend.session, store: _Store())` com o `_Store` em memória do passo 6; abrir a aba Visita (primeira carga grava), depois `backend.listPatientsFailure = const BackendFailure('Sem conexão.')`, tocar "Tentar de novo"/reabrir a aba, e afirmar que `find.byKey(const Key('micro_area_cache_notice'))` aparece e que o paciente continua em `patient_picker`. Outro caso: com `listPatientsFailure = BackendFailure('Sessão inválida.', isRecoverable: false)` o aviso **não** aparece e o erro da tela sim. + +Run: `cd apps/acs && flutter analyze 2>&1 | tail -2 && flutter test 2>&1 | tail -2` +Expected: `No issues found!`; `All tests passed!` com **≥ 242** testes (224 + 1 migração + 5 store + 11 diretório + 2 de tela). + +- [ ] **Step 10: Prova no emulador: cacheia, cai a rede, serve, e o disco está cifrado** + +Em `integration_test/full_journey_e2e.dart`, acrescentar um `testWidgets` **entre** o da rajada e o fim do arquivo (antes de qualquer teste de MFA da Task 6), com os `import`s de `micro_area_cache_store.dart`, `micro_area_directory.dart`, `database_key_store.dart` e `encrypted_database.dart`: + +```dart + testWidgets('RF08: a microárea fica no aparelho, sobrevive à queda de rede e está cifrada', (tester) async { + final ca = (await rootBundle.load(BackendConfig.rpcCaAsset)).buffer.asUint8List(); + final backend = BackendClient(trustedCaBytes: ca); + addTearDown(backend.close); + final cred = await acsCredentialFromRelay(); + final main = e2ePatient('main'); + await backend.login(matricula: cred.matricula, senha: cred.senha); + + // O `keyStore` de PRODUÇÃO (Keystore do aparelho), numa base própria deste teste. + final store = MicroAreaCacheStore(keyStore: SecureStorageDatabaseKeyStore(), databaseName: 'rf08_e2e.db'); + final online = MicroAreaDirectory(fetch: backend.listPatients, session: () => backend.session, store: store); + final primeira = await online.load(); + expect(primeira.fromCache, isFalse); + expect(primeira.patients.any((p) => p.patientId == main.id), isTrue); + + // Rede caída: a central "não responde", o aparelho serve o que guardou. + final offline = MicroAreaDirectory( + fetch: () async => throw const BackendFailure('Sem conexão.'), + session: () => backend.session, + store: store, + ); + final segunda = await offline.load(); + expect(segunda.fromCache, isTrue); + expect(segunda.patients.any((p) => p.patientId == main.id), isTrue); + + // O arquivo no disco NÃO contém o nome do paciente em texto claro (SQLCipher no aparelho). + final bytes = await File(await EncryptedLocalDatabase.pathFor('rf08_e2e.db')).readAsBytes(); + expect(String.fromCharCodes(bytes).contains(main.name), isFalse, + reason: 'o nome do paciente não pode estar legível no arquivo do banco'); + expect(String.fromCharCodes(bytes.take(16)), isNot(startsWith('SQLite format 3'))); + await store.clear(); + }); +``` + +Run: `./scripts/qa/acs_full_e2e.sh 2>&1 | grep -E "OK —|Some tests|Expected|Actual|rc="; docker compose up -d` +Expected: `OK — jornada completa do ACS contra o banco de teste`. Se a asserção do nome falhar, a base **não** está cifrada neste caminho: é achado de segurança — pare e investigue (`allowUnencryptedForTesting` ligado fora de teste?), não relaxe a asserção. + +- [ ] **Step 11: Documentar e commitar** + +`PROGRESS.md`: marcar RF08 como "cache persistido e cifrado (72 h, por dono)" e remover "RF08 persistido" de "Continua aberto"; `apps/CLAUDE.md`: um parágrafo curto sobre `MicroAreaDirectory`/`MicroAreaCacheStore`; `spec/validation_report.md`: se a linha RF08 diz "parcial", atualizar a frase e rodar `python3 scripts/qa/contagem_validation_report.py` (a contagem muda de `parcial` para `backend + app`; ajuste a linha **Contagem** à mão e confira que o conferidor volta a `ok`). + +```bash +git add apps/acs spec PROGRESS.md apps/CLAUDE.md +git commit -m "feat(acs): RF08 — microárea persistida na base SQLCipher, por dono e com validade de 72 h" +``` + +--- + +### Task 5: MFA (TOTP) no login institucional — backend + +**Files:** +- Create: `backend/sinalacs_server/lib/src/application/auth/totp.dart` +- Create: `backend/sinalacs_server/lib/src/application/auth/totp_secret_vault.dart` +- Create: `backend/sinalacs_server/lib/src/infrastructure/crypto/health_cipher_totp_vault.dart` +- Modify: `backend/sinalacs_server/lib/src/application/auth/institutional_auth_service.dart` +- Modify: `backend/sinalacs_server/lib/src/infrastructure/database/orm_acs_credential_store.dart` +- Modify: `backend/sinalacs_server/lib/src/models/user_credential.spy.yaml` +- Create: `backend/sinalacs_server/lib/src/models/exceptions/mfa_required_exception.spy.yaml`, `mfa_enrollment_required_exception.spy.yaml`, `backend/sinalacs_server/lib/src/models/api/totp_enrollment_start.spy.yaml` +- Modify: `backend/sinalacs_server/lib/src/endpoints/auth_endpoint.dart`, `lib/src/runtime/alert_runtime.dart`, `lib/src/config/app_config.dart`, `.env.example`, `docker-compose.yml` +- Test: `backend/sinalacs_server/test/unit/totp_test.dart`, `backend/sinalacs_server/test/unit/institutional_auth_mfa_test.dart`, `backend/sinalacs_server/test/unit/app_config_test.dart` + +**Interfaces:** +- Consumes: `InstitutionalAuthService` (`store`, `hasher`, `audit`, `maxFailedAttempts`, `lockDuration`, `_recordAudit`, `_invalidCredentials`), `AcsCredentialRecord`, `PasswordHasher.derive/matches`, `HealthDataCipher.encrypt(String) -> EncryptedValue{ciphertextBase64,keyVersion}` / `decrypt(EncryptedValue) -> String`. +- Produces: + - `Totp.code(Uint8List secret, DateTime at) -> String`; `Totp.stepOf(DateTime) -> int`; `Totp.verify(Uint8List secret, String code, DateTime at, {int window = 1, int? lastStep}) -> int?`; `Totp.base32(Uint8List) -> String`; `Totp.otpauthUri({required String secretBase32, required String account, String issuer = 'SinalACS'}) -> String`. + - `class SealedSecret { String ciphertextBase64; int keyVersion }`; `abstract interface class TotpSecretVault { Future seal(Uint8List); Future open(SealedSecret); }`. + - `class TotpEnrollment { SealedSecret sealed; bool enabled; int? lastStep }`; `AcsCredentialRecord.totp` (opcional, `null` = sem MFA). + - `abstract interface class TotpStore { Future saveSecret(String acsId, SealedSecret s, DateTime at); Future enable(String acsId, int step, DateTime at); Future registerStep(String acsId, int step); }` + - `InstitutionalAuthService({..., TotpStore? totpStore, TotpSecretVault? vault, bool requireMfa = false, Random? random})`; `login(..., String? totpCode)`; `beginTotpEnrollment({matricula, password}) -> TotpEnrollmentStart`; `confirmTotpEnrollment({matricula, password, code})`. + - RPC: `auth.loginInstitutional(matricula:, password:, deviceId:, totpCode:)`, `auth.beginTotpEnrollment(matricula:, password:)`, `auth.confirmTotpEnrollment(matricula:, password:, code:)`; exceções `MfaRequiredException{message}`, `MfaEnrollmentRequiredException{message}`. + +- [ ] **Step 1: Teste do `Totp` com os vetores da RFC 6238 (falha: não existe)** + +Criar `backend/sinalacs_server/test/unit/totp_test.dart`: + +```dart +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:sinalacs_server/src/application/auth/totp.dart'; +import 'package:test/test.dart'; + +void main() { + // Segredo da RFC 6238 (Apêndice B): ASCII "12345678901234567890". + final segredo = Uint8List.fromList(ascii.encode('12345678901234567890')); + DateTime em(int segundos) => DateTime.fromMillisecondsSinceEpoch(segundos * 1000, isUtc: true); + + group('RFC 6238 — vetores do SHA-1 (6 últimos dígitos)', () { + final vetores = { + 59: '287082', + 1111111109: '081804', + 1111111111: '050471', + 1234567890: '005924', + 2000000000: '279037', + 20000000000: '353130', + }; + vetores.forEach((t, esperado) { + test('T=$t → $esperado', () => expect(Totp.code(segredo, em(t)), esperado)); + }); + }); + + test('base32 do segredo da RFC é o valor conhecido', () { + expect(Totp.base32(segredo), 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ'); + }); + + test('o passo muda a cada 30 s', () { + expect(Totp.stepOf(em(29)), 0); + expect(Totp.stepOf(em(30)), 1); + }); + + group('verify', () { + test('aceita o código do passo atual e devolve o passo', () { + final agora = em(1111111111); + expect(Totp.verify(segredo, Totp.code(segredo, agora), agora), Totp.stepOf(agora)); + }); + + test('aceita um passo antes e um depois (relógio fora por até 30 s)', () { + final agora = em(1111111111); + expect(Totp.verify(segredo, Totp.code(segredo, agora.subtract(const Duration(seconds: 30))), agora), + Totp.stepOf(agora) - 1); + expect(Totp.verify(segredo, Totp.code(segredo, agora.add(const Duration(seconds: 30))), agora), + Totp.stepOf(agora) + 1); + }); + + test('recusa dois passos de distância', () { + final agora = em(1111111111); + expect(Totp.verify(segredo, Totp.code(segredo, agora.add(const Duration(seconds: 60))), agora), isNull); + }); + + test('recusa passo já usado (replay): só aceita passo MAIOR que lastStep', () { + final agora = em(1111111111); + final passo = Totp.stepOf(agora); + expect(Totp.verify(segredo, Totp.code(segredo, agora), agora, lastStep: passo), isNull); + expect(Totp.verify(segredo, Totp.code(segredo, agora), agora, lastStep: passo - 1), passo); + }); + + test('recusa código com tamanho errado ou não numérico, sem lançar', () { + final agora = em(1111111111); + expect(Totp.verify(segredo, '12345', agora), isNull); + expect(Totp.verify(segredo, '1234567', agora), isNull); + expect(Totp.verify(segredo, 'abcdef', agora), isNull); + expect(Totp.verify(segredo, '', agora), isNull); + }); + + test('ignora espaços em volta do código (apps agrupam "123 456")', () { + final agora = em(1111111111); + final c = Totp.code(segredo, agora); + expect(Totp.verify(segredo, '${c.substring(0, 3)} ${c.substring(3)}', agora), Totp.stepOf(agora)); + }); + }); + + test('otpauthUri tem o formato que os autenticadores leem', () { + final uri = Totp.otpauthUri(secretBase32: 'ABC234', account: 'ACS-001'); + expect(uri, 'otpauth://totp/SinalACS:ACS-001?secret=ABC234&issuer=SinalACS&algorithm=SHA1&digits=6&period=30'); + }); +} +``` + +Run: `cd backend/sinalacs_server && dart test test/unit/totp_test.dart 2>&1 | tail -4` +Expected: **FAIL** de compilação (`totp.dart` não existe). + +- [ ] **Step 2: Implementar o `Totp`** + +Criar `lib/src/application/auth/totp.dart`: + +```dart +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; + +/// TOTP (RFC 6238) com SHA-1, 6 dígitos e passo de 30 s — o padrão que todo +/// aplicativo autenticador entende. Função pura: sem relógio, sem I/O. +class Totp { + Totp._(); + + static const period = 30; + static const digits = 6; + static const _alfabeto = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + + static int stepOf(DateTime at) => at.toUtc().millisecondsSinceEpoch ~/ 1000 ~/ period; + + static String code(Uint8List secret, DateTime at) => _hotp(secret, stepOf(at)); + + /// Passo (`stepOf`) em que [code] confere, dentro de ±[window] passos de [at], + /// ou `null`. Com [lastStep], só aceita passo **maior** que ele: é o que + /// impede reaproveitar um código já usado dentro da janela (replay). + static int? verify( + Uint8List secret, + String code, + DateTime at, { + int window = 1, + int? lastStep, + }) { + final limpo = code.replaceAll(RegExp(r'\s'), ''); + if (limpo.length != digits || !RegExp(r'^\d+$').hasMatch(limpo)) return null; + final atual = stepOf(at); + for (var passo = atual - window; passo <= atual + window; passo++) { + if (lastStep != null && passo <= lastStep) continue; + if (_hotp(secret, passo) == limpo) return passo; + } + return null; + } + + static String _hotp(Uint8List secret, int counter) { + final mensagem = ByteData(8)..setUint64(0, counter); + final h = Hmac(sha1, secret).convert(mensagem.buffer.asUint8List()).bytes; + final o = h[h.length - 1] & 0x0f; + final bin = ((h[o] & 0x7f) << 24) | (h[o + 1] << 16) | (h[o + 2] << 8) | h[o + 3]; + return (bin % 1000000).toString().padLeft(digits, '0'); + } + + /// Base32 (RFC 4648, sem preenchimento): o formato do segredo que o aplicativo + /// autenticador pede quando a pessoa não lê o QR. + static String base32(Uint8List bytes) { + var bits = 0; + var valor = 0; + final saida = StringBuffer(); + for (final b in bytes) { + valor = (valor << 8) | b; + bits += 8; + while (bits >= 5) { + saida.write(_alfabeto[(valor >> (bits - 5)) & 31]); + bits -= 5; + } + valor &= (1 << bits) - 1; + } + if (bits > 0) saida.write(_alfabeto[(valor << (5 - bits)) & 31]); + return saida.toString(); + } + + static String otpauthUri({ + required String secretBase32, + required String account, + String issuer = 'SinalACS', + }) => + 'otpauth://totp/${Uri.encodeComponent(issuer)}:${Uri.encodeComponent(account)}' + '?secret=$secretBase32&issuer=${Uri.encodeComponent(issuer)}' + '&algorithm=SHA1&digits=$digits&period=$period'; +} +``` + +Run: `dart test test/unit/totp_test.dart 2>&1 | tail -3` +Expected: `All tests passed!` (todos). Se um vetor da RFC falhar, o defeito é do `_hotp` (endianness do contador ou truncamento dinâmico), nunca do vetor. + +- [ ] **Step 3: Modelo, exceções e migração** + +`user_credential.spy.yaml`, acrescentar ao fim de `fields:` (antes de `indexes:`): + +```yaml + ### MFA por TOTP (RFC 6238). `null` = sem segredo gravado. O segredo é cifrado + ### (AES-256-GCM, a mesma chave dos dados clínicos); **nunca** em claro. + totpSecretEncrypted: String? + totpKeyVersion: int? + ### `null` = enrollment começou e não foi confirmado: a MFA ainda NÃO vale. + totpEnabledAt: DateTime? + ### Último passo de 30 s aceito. O mesmo código não entra duas vezes (replay). + totpLastStep: int? +``` + +Criar as duas exceções copiando o formato de `authentication_failed_exception.spy.yaml`: + +```yaml +### O ACS tem MFA ativa, a senha conferiu e faltou o código do autenticador. +### Só é lançada DEPOIS de a senha estar certa: não revela matrícula nem senha. +exception: MfaRequiredException +fields: + message: String +``` + +```yaml +### O servidor exige MFA e este ACS ainda não a ativou: a pessoa precisa +### ativar (auth.beginTotpEnrollment) antes de entrar. +exception: MfaEnrollmentRequiredException +fields: + message: String +``` + +`models/api/totp_enrollment_start.spy.yaml`: + +```yaml +### Início da ativação da MFA: o segredo para o autenticador (base32) e a URI +### `otpauth://` que vira o QR. Volta só nesta resposta; nunca é gravado em claro. +class: TotpEnrollmentStart +fields: + secretBase32: String + otpauthUri: String +``` + +Run: `cd backend/sinalacs_server && serverpod generate 2>&1 | tail -3 && serverpod create-migration 2>&1 | tail -3` +Expected: sem erro; a migração nova só tem `ALTER TABLE "user_credentials" ADD COLUMN ...` das 4 colunas, todas nullable. + +- [ ] **Step 4: Testes do serviço (falham: não há MFA no serviço)** + +Criar `backend/sinalacs_server/test/unit/institutional_auth_mfa_test.dart`: + +```dart +import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:sinalacs_server/src/application/audit/audit_trail.dart'; +import 'package:sinalacs_server/src/application/auth/institutional_auth_service.dart'; +import 'package:sinalacs_server/src/application/auth/password_hasher.dart'; +import 'package:sinalacs_server/src/application/auth/totp.dart'; +import 'package:sinalacs_server/src/application/auth/totp_secret_vault.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/infrastructure/crypto/argon2_password_hasher.dart'; +import 'package:test/test.dart'; + +const _acsId = '00000000-0000-4000-8000-000000000002'; +const _microAreaId = '00000000-0000-4000-8000-000000000003'; +const _senha = 'senha-sintetica-de-teste'; + +/// Cofre de teste: base64 reversível. Quem prova a cifra real é `health_data_cipher_test.dart`. +class _Cofre implements TotpSecretVault { + @override + Future seal(Uint8List secret) async => + SealedSecret(ciphertextBase64: base64Encode(secret), keyVersion: 1); + + @override + Future open(SealedSecret sealed) async => base64Decode(sealed.ciphertextBase64); +} + +class _Store implements AcsCredentialStore, TotpStore { + _Store(this.record); + AcsCredentialRecord record; + int enabledCalls = 0; + int? lastRegisteredStep; + + @override + Future findByEnrollmentId(String enrollmentId) async => + enrollmentId == 'ACS-001' ? record : null; + + @override + Future registerFailedAttempt(String acsId, + {required bool restartCounter, + required int maxFailedAttempts, + required DateTime lockUntil, + required DateTime at}) async { + final anterior = record.failedAttempts; + final proximo = restartCounter ? 1 : anterior + 1; + record = _copia(failedAttempts: proximo, lockedUntil: proximo >= maxFailedAttempts ? lockUntil : null); + } + + @override + Future registerSuccessfulLogin(String acsId, DateTime at) async { + record = _copia(failedAttempts: 0, lockedUntil: null); + } + + @override + Future saveCredential(String acsId, PasswordDigest digest, DateTime at) async {} + + @override + Future saveSecret(String acsId, SealedSecret s, DateTime at) async { + record = _copia(totp: TotpEnrollment(sealed: s, enabled: false, lastStep: null)); + } + + @override + Future enable(String acsId, int step, DateTime at) async { + enabledCalls++; + final t = record.totp!; + record = _copia(totp: TotpEnrollment(sealed: t.sealed, enabled: true, lastStep: step)); + } + + @override + Future registerStep(String acsId, int step) async { + lastRegisteredStep = step; + final t = record.totp!; + record = _copia(totp: TotpEnrollment(sealed: t.sealed, enabled: t.enabled, lastStep: step)); + } + + AcsCredentialRecord _copia({int? failedAttempts, DateTime? lockedUntil, TotpEnrollment? totp}) => + AcsCredentialRecord( + acsId: record.acsId, + microAreaId: record.microAreaId, + active: record.active, + digest: record.digest, + failedAttempts: failedAttempts ?? record.failedAttempts, + lockedUntil: failedAttempts != null ? lockedUntil : record.lockedUntil, + totp: totp ?? record.totp, + ); +} + +class _Audit extends AuditTrail { + @override + Future record(AuditEvent event) async {} +} + +void main() { + final hasher = Argon2PasswordHasher(memoryKb: 512, iterations: 1, parallelism: 1); + final cofre = _Cofre(); + final segredo = Uint8List.fromList(List.generate(20, (i) => i + 1)); + final t0 = DateTime.utc(2026, 10, 2, 12); + + Future<({InstitutionalAuthService servico, _Store store})> montar({ + bool comMfa = true, + bool exigir = false, + }) async { + final digest = await hasher.derive(_senha); + final sealed = await cofre.seal(segredo); + final store = _Store(AcsCredentialRecord( + acsId: _acsId, + microAreaId: _microAreaId, + active: true, + digest: digest, + failedAttempts: 0, + lockedUntil: null, + totp: comMfa ? TotpEnrollment(sealed: sealed, enabled: true, lastStep: null) : null, + )); + final servico = InstitutionalAuthService( + store: store, + hasher: hasher, + audit: _Audit(), + totpStore: store, + vault: cofre, + requireMfa: exigir, + random: Random(7), + ); + return (servico: servico, store: store); + } + + group('login com MFA ativa', () { + test('senha certa sem código → MfaRequiredException, sem contar tentativa', () async { + final m = await montar(); + await expectLater( + m.servico.login(matricula: 'ACS-001', password: _senha, now: t0), + throwsA(isA()), + ); + expect(m.store.record.failedAttempts, 0); + }); + + test('senha certa + código certo → entra e grava o passo usado', () async { + final m = await montar(); + final user = await m.servico + .login(matricula: 'ACS-001', password: _senha, totpCode: Totp.code(segredo, t0), now: t0); + expect(user.id, _acsId); + expect(m.store.lastRegisteredStep, Totp.stepOf(t0)); + }); + + test('recusa o mesmo código usado duas vezes (replay)', () async { + final m = await montar(); + final codigo = Totp.code(segredo, t0); + await m.servico.login(matricula: 'ACS-001', password: _senha, totpCode: codigo, now: t0); + + await expectLater( + m.servico.login(matricula: 'ACS-001', password: _senha, totpCode: codigo, now: t0), + throwsA(isA()), + ); + }); + + test('código do passo SEGUINTE ainda entra depois de um login (relógio adiantado)', () async { + final m = await montar(); + await m.servico.login(matricula: 'ACS-001', password: _senha, totpCode: Totp.code(segredo, t0), now: t0); + final proximo = t0.add(const Duration(seconds: 30)); + final user = await m.servico + .login(matricula: 'ACS-001', password: _senha, totpCode: Totp.code(segredo, proximo), now: proximo); + expect(user.id, _acsId); + }); + + test('código errado conta tentativa e tranca no 5º erro', () async { + final m = await montar(); + for (var i = 0; i < 4; i++) { + await expectLater( + m.servico.login(matricula: 'ACS-001', password: _senha, totpCode: '000000', now: t0), + throwsA(isA()), + ); + } + expect(m.store.record.failedAttempts, 4); + await expectLater( + m.servico.login(matricula: 'ACS-001', password: _senha, totpCode: '000000', now: t0), + throwsA(isA()), + ); + expect(m.store.record.lockedUntil, isNotNull); + }); + + test('código errado não consome o passo (o certo logo depois entra)', () async { + final m = await montar(); + await expectLater( + m.servico.login(matricula: 'ACS-001', password: _senha, totpCode: '000000', now: t0), + throwsA(isA()), + ); + expect(m.store.lastRegisteredStep, isNull); + final user = await m.servico + .login(matricula: 'ACS-001', password: _senha, totpCode: Totp.code(segredo, t0), now: t0); + expect(user.id, _acsId); + }); + + test('senha errada continua dando a mensagem única, com ou sem código', () async { + final m = await montar(); + await expectLater( + m.servico.login(matricula: 'ACS-001', password: 'errada', totpCode: Totp.code(segredo, t0), now: t0), + throwsA(isA()), + ); + }); + }); + + group('login sem MFA ativa', () { + test('com REQUIRE_ACS_MFA desligado, entra só com a senha', () async { + final m = await montar(comMfa: false); + expect((await m.servico.login(matricula: 'ACS-001', password: _senha, now: t0)).id, _acsId); + }); + + test('com REQUIRE_ACS_MFA ligado, a senha certa recebe MfaEnrollmentRequiredException', () async { + final m = await montar(comMfa: false, exigir: true); + await expectLater( + m.servico.login(matricula: 'ACS-001', password: _senha, now: t0), + throwsA(isA()), + ); + }); + + test('enrollment começado e NÃO confirmado não vale: entra só com a senha', () async { + final m = await montar(comMfa: false); + await m.servico.beginTotpEnrollment(matricula: 'ACS-001', password: _senha); + expect(m.store.record.totp!.enabled, isFalse); + expect((await m.servico.login(matricula: 'ACS-001', password: _senha, now: t0)).id, _acsId); + }); + }); + + group('ativação', () { + test('begin devolve segredo e URI; confirm com o código certo ativa', () async { + final m = await montar(comMfa: false); + final inicio = await m.servico.beginTotpEnrollment(matricula: 'ACS-001', password: _senha); + expect(inicio.otpauthUri, startsWith('otpauth://totp/SinalACS:ACS-001?secret=${inicio.secretBase32}')); + + final segredoSorteado = await cofre.open(m.store.record.totp!.sealed); + await m.servico.confirmTotpEnrollment( + matricula: 'ACS-001', + password: _senha, + code: Totp.code(segredoSorteado, t0), + now: t0, + ); + expect(m.store.enabledCalls, 1); + expect(m.store.record.totp!.enabled, isTrue); + }); + + test('confirm com código errado não ativa e conta tentativa', () async { + final m = await montar(comMfa: false); + await m.servico.beginTotpEnrollment(matricula: 'ACS-001', password: _senha); + await expectLater( + m.servico.confirmTotpEnrollment(matricula: 'ACS-001', password: _senha, code: '000000', now: t0), + throwsA(isA()), + ); + expect(m.store.enabledCalls, 0); + expect(m.store.record.failedAttempts, 1); + }); + + test('begin com senha errada é recusado como o login (mensagem única)', () async { + final m = await montar(comMfa: false); + await expectLater( + m.servico.beginTotpEnrollment(matricula: 'ACS-001', password: 'errada'), + throwsA(isA()), + ); + }); + + test('begin com MFA já ativa é recusado (redefinir exige a coordenação)', () async { + final m = await montar(); + await expectLater( + m.servico.beginTotpEnrollment(matricula: 'ACS-001', password: _senha), + throwsA(isA()), + ); + }); + }); +} +``` + +Run: `cd backend/sinalacs_server && dart test test/unit/institutional_auth_mfa_test.dart 2>&1 | tail -5` +Expected: **FAIL** de compilação (`totp_secret_vault.dart`, `TotpStore`, `TotpEnrollment`, parâmetros `totpCode`/`requireMfa` não existem). + +- [ ] **Step 5: Implementar cofre, estado e serviço** + +Criar `lib/src/application/auth/totp_secret_vault.dart`: + +```dart +import 'dart:typed_data'; + +/// Segredo TOTP já cifrado, com a versão da chave que o cifrou. +class SealedSecret { + const SealedSecret({required this.ciphertextBase64, required this.keyVersion}); + + final String ciphertextBase64; + final int keyVersion; +} + +/// Cifra e decifra o segredo TOTP. A camada de aplicação não conhece a cifra: +/// quem decide o algoritmo é `infrastructure/` (`HealthCipherTotpVault`). +abstract interface class TotpSecretVault { + Future seal(Uint8List secret); + Future open(SealedSecret sealed); +} +``` + +Criar `lib/src/infrastructure/crypto/health_cipher_totp_vault.dart`: + +```dart +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:sinalacs_server/src/application/auth/totp_secret_vault.dart'; +import 'package:sinalacs_server/src/infrastructure/crypto/health_data_cipher.dart'; + +/// Cofre do segredo TOTP sobre a mesma AES-256-GCM dos dados clínicos +/// (`HEALTH_DATA_ENCRYPTION_KEY`). Uma chave a menos para gerir; o custo é que +/// quem a perde, perde também as MFAs — e a redefinição é manual (decisão D6). +class HealthCipherTotpVault implements TotpSecretVault { + HealthCipherTotpVault(this._cipher); + + final HealthDataCipher _cipher; + + @override + Future seal(Uint8List secret) async { + final v = await _cipher.encrypt(base64Encode(secret)); + return SealedSecret(ciphertextBase64: v.ciphertextBase64, keyVersion: v.keyVersion); + } + + @override + Future open(SealedSecret sealed) async { + final claro = await _cipher.decrypt( + EncryptedValue(ciphertextBase64: sealed.ciphertextBase64, keyVersion: sealed.keyVersion), + ); + return base64Decode(claro); + } +} +``` + +Em `institutional_auth_service.dart`: + +1. Imports: `dart:math`, `dart:typed_data`, `totp.dart`, `totp_secret_vault.dart`. +2. Antes de `AcsCredentialRecord`, acrescentar: + +```dart +/// Estado da MFA de um ACS, como está gravado. +class TotpEnrollment { + const TotpEnrollment({required this.sealed, required this.enabled, required this.lastStep}); + + final SealedSecret sealed; + + /// `false` = a ativação começou e não foi confirmada: a MFA ainda NÃO vale. + final bool enabled; + final int? lastStep; +} + +/// Escrita do estado da MFA. Interface à parte de [AcsCredentialStore] de +/// propósito: quem só lê credencial (e os fakes que já existem) não muda. +abstract interface class TotpStore { + Future saveSecret(String acsId, SealedSecret secret, DateTime at); + Future enable(String acsId, int step, DateTime at); + Future registerStep(String acsId, int step); +} +``` + +3. Em `AcsCredentialRecord`: parâmetro **opcional** `this.totp` no construtor e `final TotpEnrollment? totp;` (`null` = sem MFA). +4. No serviço: parâmetros opcionais do construtor `this.totpStore, this.vault, this.requireMfa = false, Random? random` (`_random = random ?? Random.secure()`), e `static const _invalidCode = 'Código de verificação inválido.';`. +5. **Extrair** a parte do `login` que vai de "normaliza matrícula" até a senha conferida (e o bloqueio decidido) para um método privado `Future _authenticatePassword({required String matricula, required String password, required DateTime at})` que devolve a linha quando a senha confere e **lança** exatamente o que o `login` hoje lança nos outros casos (matrícula inexistente, senha errada, bloqueio, inativo). `login` passa a chamar esse método e seguir. **Refatoração pura:** `dart test test/unit/institutional_auth_service_test.dart` tem de continuar verde **antes** de seguir para o passo 6. +6. Em `login`, novo parâmetro `String? totpCode`, e **depois** de `_authenticatePassword` e **antes** de `store.registerSuccessfulLogin`: + +```dart + final totp = record.totp; + if (totp != null && totp.enabled) { + final codigo = totpCode?.trim() ?? ''; + if (codigo.isEmpty) { + // A senha conferiu; falta o código. Não é falha: não conta tentativa. + throw MfaRequiredException(message: 'Informe o código do aplicativo autenticador.'); + } + final segredo = await vault!.open(totp.sealed); + final passo = Totp.verify(segredo, codigo, at, lastStep: totp.lastStep); + if (passo == null) { + await _registrarFalha(record, at); + await _recordAudit(record.acsId, 'denied_totp'); + throw AuthenticationFailedException(message: _invalidCode); + } + await totpStore!.registerStep(record.acsId, passo); + } else if (requireMfa) { + await _recordAudit(record.acsId, 'denied_mfa_not_enrolled'); + throw MfaEnrollmentRequiredException( + message: 'Ative a verificação em duas etapas antes de entrar.', + ); + } +``` + +`_registrarFalha(record, at)` chama `store.registerFailedAttempt(record.acsId, restartCounter: , maxFailedAttempts: maxFailedAttempts, lockUntil: at.add(lockDuration), at: at)` — extraia do ramo da senha errada o cálculo de `restartCounter` para um helper compartilhado e use-o nos dois lugares (o código atual está no ramo `if (!await hasher.matches(...))`). Se `vault`/`totpStore` forem `null` com MFA ativa, é erro de montagem: `StateError('MFA ativa sem cofre configurado')`. + +7. Dois métodos públicos: + +```dart + /// Começa a ativação da MFA. Exige matrícula **e senha** (o ACS ainda não tem + /// token) e conta tentativa errada como o login. Só grava o segredo como + /// "pendente": a MFA vale depois de [confirmTotpEnrollment]. + Future beginTotpEnrollment({ + required String matricula, + required String password, + DateTime? now, + }) async { + final at = (now ?? DateTime.now()).toUtc(); + final record = await _authenticatePassword(matricula: matricula, password: password, at: at); + if (record.totp?.enabled ?? false) { + throw AuthenticationFailedException( + message: 'A verificação em duas etapas já está ativa. Peça a redefinição à coordenação.', + ); + } + final bytes = Uint8List.fromList(List.generate(20, (_) => _random.nextInt(256))); + await totpStore!.saveSecret(record.acsId, await vault!.seal(bytes), at); + final base32 = Totp.base32(bytes); + return TotpEnrollmentStart( + secretBase32: base32, + otpauthUri: Totp.otpauthUri(secretBase32: base32, account: matricula.trim()), + ); + } + + /// Confirma a ativação com um código válido do segredo pendente. + Future confirmTotpEnrollment({ + required String matricula, + required String password, + required String code, + DateTime? now, + }) async { + final at = (now ?? DateTime.now()).toUtc(); + final record = await _authenticatePassword(matricula: matricula, password: password, at: at); + final totp = record.totp; + if (totp == null || totp.enabled) { + throw AuthenticationFailedException(message: 'Não há ativação pendente para esta matrícula.'); + } + final passo = Totp.verify(await vault!.open(totp.sealed), code, at); + if (passo == null) { + await _registrarFalha(record, at); + await _recordAudit(record.acsId, 'denied_totp_enrollment'); + throw AuthenticationFailedException(message: _invalidCode); + } + await totpStore!.enable(record.acsId, passo, at); + await _recordAudit(record.acsId, 'mfa_enabled'); + } +``` + +Run: `cd backend/sinalacs_server && dart test test/unit/institutional_auth_service_test.dart test/unit/institutional_auth_mfa_test.dart test/unit/totp_test.dart 2>&1 | tail -4` +Expected: `All tests passed!` (os antigos continuam verdes e os novos também). Qualquer teste **antigo** vermelho = a refatoração do passo 5 mudou comportamento: desfaça e refaça menor. + +- [ ] **Step 6: ORM, endpoint, runtime e configuração** + +`orm_acs_credential_store.dart` (siga o estilo do próprio arquivo, que já faz o `UPDATE` de tentativas): `findByEnrollmentId` passa a preencher `totp:` quando `credential.totpSecretEncrypted != null` — `TotpEnrollment(sealed: SealedSecret(ciphertextBase64: ..., keyVersion: credential.totpKeyVersion!), enabled: credential.totpEnabledAt != null, lastStep: credential.totpLastStep)`; a classe passa a implementar também `TotpStore`: + +```dart + @override + Future saveSecret(String acsId, SealedSecret secret, DateTime at) async { + await _setColumns(acsId, (t) => [ + t.totpSecretEncrypted.set(secret.ciphertextBase64), + t.totpKeyVersion.set(secret.keyVersion), + t.totpEnabledAt.set(null), + t.totpLastStep.set(null), + ]); + } + + @override + Future enable(String acsId, int step, DateTime at) async { + await _setColumns(acsId, (t) => [t.totpEnabledAt.set(at), t.totpLastStep.set(step)]); + } + + @override + Future registerStep(String acsId, int step) async { + await _setColumns(acsId, (t) => [t.totpLastStep.set(step)]); + } +``` + +com `_setColumns` usando `UserCredential.db.updateWhere(session(), columnValues: columns, where: (t) => t.userId.equals(UuidValue.fromString(acsId)))` — **confira** no arquivo como o store já obtém a sessão (`session: () => session`) e como escreve; se `updateWhere` não existir nesta versão do Serverpod, use o mesmo mecanismo (SQL nomeado) que `registerSuccessfulLogin` usa. + +`alert_runtime.dart`, em `institutionalAuthServiceFor`: + +```dart + InstitutionalAuthService institutionalAuthServiceFor(Session session) { + final store = OrmAcsCredentialStore(session: () => session); + return InstitutionalAuthService( + store: store, + hasher: passwordHasher, + audit: auditTrailFor(session), + totpStore: store, + vault: HealthCipherTotpVault(healthDataCipher), + requireMfa: config.requireAcsMfa, + ); + } +``` + +`auth_endpoint.dart`: `loginInstitutional` ganha `String? totpCode` (repassado ao serviço) e acrescentar: + +```dart + /// Começa a ativação da MFA do ACS (RF07). Sem token: o ACS prova matrícula e senha. + Future beginTotpEnrollment( + Session session, { + required String matricula, + required String password, + }) => + AlertRuntime.instance + .institutionalAuthServiceFor(session) + .beginTotpEnrollment(matricula: matricula, password: password); + + /// Confirma a ativação com o primeiro código do autenticador. + Future confirmTotpEnrollment( + Session session, { + required String matricula, + required String password, + required String code, + }) => + AlertRuntime.instance + .institutionalAuthServiceFor(session) + .confirmTotpEnrollment(matricula: matricula, password: password, code: code); +``` + +`app_config.dart`: campo `final bool requireAcsMfa;` e, no `fromEnvironment`, seguindo `enableDevLogin`: `requireAcsMfa: environment['REQUIRE_ACS_MFA'] != null ? environment['REQUIRE_ACS_MFA'] == 'true' : environment['APP_ENV'] != 'development'` (padrão **ligado fora de development**). Teste em `test/unit/app_config_test.dart`: em `development` sem a variável é `false`; com `APP_ENV=production` sem a variável é `true`; `REQUIRE_ACS_MFA=false` explícito desliga mesmo em produção. + +`.env.example` e `docker-compose.yml` (serviço do servidor): `REQUIRE_ACS_MFA: ${REQUIRE_ACS_MFA:-false}` com a nota "em produção o padrão é `true`; este default é só da stack local". + +- [ ] **Step 7: Gerar, analisar e rodar a suíte do backend** + +Run: `cd backend/sinalacs_server && serverpod generate 2>&1 | tail -2 && dart analyze 2>&1 | tail -2 && dart test 2>&1 | tail -3` +Expected: `No issues found!`; `All tests passed!`. A suíte completa usa o banco de teste (`docker compose --profile test up -d postgres-test`); a contagem total sobe pelos testes novos (anotar no ledger o antes/depois). + +- [ ] **Step 8: Documentar e commitar** + +`spec/lgpd_data_audit.md`: acrescentar as 4 colunas de `user_credentials` (`totpSecretEncrypted` = segredo de autenticação, cifrado; `totpKeyVersion`, `totpEnabledAt`, `totpLastStep` = metadado de segurança) e **re-medir** a contagem de tabelas/colunas. `backend/CLAUDE.md`: parágrafo sobre `REQUIRE_ACS_MFA`, o cofre sobre a chave dos dados clínicos e a redefinição manual (zerar as quatro colunas). + +```bash +git add backend spec .env.example docker-compose.yml +git commit -m "feat(auth): MFA por TOTP no login institucional do ACS (RF07, LGPD-RT06), com replay barrado" +``` + +--- + +### Task 6: MFA — app do ACS e prova no emulador + +**Files:** +- Modify: `apps/acs/lib/core/network/backend_client.dart`, `apps/acs/lib/app/app.dart` (LoginScreen) +- Create: `apps/acs/lib/app/mfa_enrollment_screen.dart` +- Create: `apps/acs/integration_test/support/totp.dart`, `apps/acs/test/support/totp.dart` (mesma implementação mínima, só para teste) +- Test: `apps/acs/test/mfa_login_test.dart`, `apps/acs/test/totp_support_test.dart` +- Modify: `apps/acs/test/support/fakes.dart`, `apps/acs/integration_test/full_journey_e2e.dart`, `PROGRESS.md`, `apps/CLAUDE.md` + +**Interfaces:** +- Consumes (Task 5): RPC `auth.loginInstitutional(…, totpCode:)`, `auth.beginTotpEnrollment`, `auth.confirmTotpEnrollment`; exceções `MfaRequiredException`, `MfaEnrollmentRequiredException`; `TotpEnrollmentStart{secretBase32, otpauthUri}`. `qr_flutter` (`QrImageView`, já usado em `invite_screen.dart`). +- Produces: + - `AcsBackend.login({required String matricula, required String senha, String? totpCode})` + - `AcsBackend.beginTotpEnrollment({required String matricula, required String senha}) -> Future`; `AcsBackend.confirmTotpEnrollment({required String matricula, required String senha, required String code}) -> Future` + - `class MfaCodeRequired extends BackendFailure`, `class MfaEnrollmentRequired extends BackendFailure` + - `class MfaEnrollmentScreen` com chaves `mfa_secret`, `mfa_code_field`, `mfa_confirm_button`. + - `String Totp.code(Uint8List secret, DateTime at)` em `test/support/totp.dart` e `integration_test/support/totp.dart`. + +- [ ] **Step 1: Testes do app (falham: não há campo de código)** + +Em `test/support/fakes.dart`, `FakeAcsBackend`: o `login` passa a aceitar `String? totpCode`, guardar `lastTotpCode` e consultar `mfaRequired`/`mfaEnrollmentRequired`: + +```dart + /// Simula o servidor com MFA ativa: sem [totpCode] igual a [expectedTotpCode], levanta [MfaCodeRequired]. + String? expectedTotpCode; + bool mfaEnrollmentRequired = false; + String? lastTotpCode; + TotpEnrollmentStart enrollmentStart = + TotpEnrollmentStart(secretBase32: 'GEZDGNBVGY3TQOJQ', otpauthUri: 'otpauth://totp/SinalACS:ACS-001?secret=GEZDGNBVGY3TQOJQ'); + String? confirmedCode; + + @override + Future confirmTotpEnrollment({required String matricula, required String senha, required String code}) async { + confirmedCode = code; + } + + @override + Future beginTotpEnrollment({required String matricula, required String senha}) async => + enrollmentStart; +``` + +e, no início do `login` do fake (depois de registrar matrícula/senha): + +```dart + lastTotpCode = totpCode; + if (mfaEnrollmentRequired) throw const MfaEnrollmentRequired(); + if (expectedTotpCode != null && totpCode != expectedTotpCode) { + throw totpCode == null ? const MfaCodeRequired() : const BackendFailure('Código de verificação inválido.', isRecoverable: false); + } +``` + +Criar `apps/acs/test/mfa_login_test.dart` (use `entrar(tester)` e o estilo de `login_flow_test.dart`): + +```dart +// 1. MFA exigida: depois de matrícula+senha, aparece o campo de código e o painel NÃO abre. +testWidgets('pede o código do autenticador depois de matrícula e senha', (tester) async { + final backend = FakeAcsBackend()..expectedTotpCode = '123456'; + await tester.pumpWidget(SinalAcsApp(backend: backend, feedBuilder: (q) => FakeAlertFeed(q))); + await entrar(tester); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('totp_field')), findsOneWidget); + expect(find.byKey(const Key('login_button')), findsOneWidget, reason: 'o painel não abriu'); +}); + +// 2. Código certo: entra, e o app reenviou matrícula+senha COM o código. +testWidgets('com o código certo, entra', (tester) async { + final backend = FakeAcsBackend()..expectedTotpCode = '123456'; + await tester.pumpWidget(SinalAcsApp(backend: backend, feedBuilder: (q) => FakeAlertFeed(q))); + await entrar(tester); + await tester.pumpAndSettle(); + + await tester.enterText(find.byKey(const Key('totp_field')), '123456'); + await tester.tap(find.byKey(const Key('login_button'))); + await tester.pumpAndSettle(); + + expect(backend.lastTotpCode, '123456'); + expect(find.byKey(const Key('login_button')), findsNothing, reason: 'o painel abriu'); +}); + +// 3. Código errado: erro visível, painel fechado, campo continua. +testWidgets('com o código errado, mostra o erro e fica no login', (tester) async { + final backend = FakeAcsBackend()..expectedTotpCode = '123456'; + await tester.pumpWidget(SinalAcsApp(backend: backend, feedBuilder: (q) => FakeAlertFeed(q))); + await entrar(tester); + await tester.pumpAndSettle(); + + await tester.enterText(find.byKey(const Key('totp_field')), '000000'); + await tester.tap(find.byKey(const Key('login_button'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('login_error')), findsOneWidget); + expect(find.byKey(const Key('totp_field')), findsOneWidget); +}); + +// 4. Servidor exige MFA e o ACS não a tem: vai para a tela de ativação, com segredo e campo de código. +testWidgets('sem MFA ativada, leva à ativação e confirma com o código', (tester) async { + final backend = FakeAcsBackend()..mfaEnrollmentRequired = true; + await tester.pumpWidget(SinalAcsApp(backend: backend, feedBuilder: (q) => FakeAlertFeed(q))); + await entrar(tester); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('mfa_secret')), findsOneWidget); + expect(find.textContaining('GEZDGNBVGY3TQOJQ'), findsOneWidget); + + await tester.enterText(find.byKey(const Key('mfa_code_field')), '654321'); + await tester.tap(find.byKey(const Key('mfa_confirm_button'))); + await tester.pumpAndSettle(); + + expect(backend.confirmedCode, '654321'); + // Voltou ao login, pronto para entrar de novo (agora com MFA). + expect(find.byKey(const Key('login_button')), findsOneWidget); +}); + +// 5. O código é só dígitos: o campo recusa letras e passa de 6. +testWidgets('o campo de código aceita só 6 dígitos', (tester) async { + final backend = FakeAcsBackend()..expectedTotpCode = '123456'; + await tester.pumpWidget(SinalAcsApp(backend: backend, feedBuilder: (q) => FakeAlertFeed(q))); + await entrar(tester); + await tester.pumpAndSettle(); + + await tester.enterText(find.byKey(const Key('totp_field')), '12ab34567'); + + final campo = tester.widget(find.byKey(const Key('totp_field'))); + expect(campo.controller!.text, '123456', reason: 'letras caem fora e o 7º dígito não entra'); + expect(campo.keyboardType, TextInputType.number); +}); +``` + +Run: `cd apps/acs && flutter test test/mfa_login_test.dart 2>&1 | tail -4` +Expected: **FAIL** de compilação (`MfaCodeRequired`, `totpCode` não existem). + +- [ ] **Step 2: Implementar no cliente** + +`backend_client.dart`: `AcsBackend.login` ganha `String? totpCode`; acrescentar os dois métodos novos à interface, à classe que recusa tudo (`_recusar()`) e ao `BackendClient`; as duas falhas tipadas: + +```dart +/// O ACS tem MFA ativa e a senha conferiu: falta o código do autenticador. +class MfaCodeRequired extends BackendFailure { + const MfaCodeRequired() : super('Informe o código do aplicativo autenticador.'); +} + +/// O servidor exige MFA e este ACS ainda não a ativou. +class MfaEnrollmentRequired extends BackendFailure { + const MfaEnrollmentRequired() : super('Ative a verificação em duas etapas antes de entrar.', isRecoverable: false); +} +``` + +No `BackendClient.login`, repassar `totpCode:` a `_client.auth.loginInstitutional(...)`; e no `_guard` (onde `AuthenticationFailedException` vira `BackendFailure`, ~linha 411), antes dele: + +```dart + } on MfaRequiredException { + throw const MfaCodeRequired(); + } on MfaEnrollmentRequiredException { + throw const MfaEnrollmentRequired(); +``` + +`beginTotpEnrollment`/`confirmTotpEnrollment` chamam `_client.auth.beginTotpEnrollment(matricula:, password:)` / `confirmTotpEnrollment(matricula:, password:, code:)` **sem** `_requireToken()` (não há token) e dentro de `_guard`. A credencial segue só em memória (`_credentials`): nada novo é gravado em disco. + +- [ ] **Step 3: Implementar a tela de login e a de ativação** + +`_LoginScreenState` (`app.dart` ~161): estado `bool _pedeCodigo = false;` e `final _totp = TextEditingController();` (descartar no `dispose`); no tratamento da falha do `_enter`: + +```dart + } on MfaCodeRequired { + if (!mounted) return; + setState(() { _pedeCodigo = true; _loginError = null; _entering = false; }); + } on MfaEnrollmentRequired { + if (!mounted) return; + setState(() => _entering = false); + final ativou = await Navigator.of(context).push(MaterialPageRoute( + builder: (_) => MfaEnrollmentScreen( + backend: widget.backend, + matricula: _matricula.text.trim(), + senha: _senha.text, + ), + )); + if (ativou == true && mounted) { + setState(() => _pedeCodigo = true); + } + return; + } +``` + +`_enter` passa `totpCode: _pedeCodigo ? _totp.text.trim() : null` ao `backend.login`. O campo (só quando `_pedeCodigo`), com os mesmos espaçamentos dos outros campos: + +```dart + if (_pedeCodigo) ...[ + const SizedBox(height: 12), + TextField( + key: const Key('totp_field'), + controller: _totp, + keyboardType: TextInputType.number, + maxLength: 6, + autofillHints: const [AutofillHints.oneTimeCode], + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: const InputDecoration(labelText: 'Código do autenticador (6 dígitos)'), + onSubmitted: (_) => _enter(), + ), + ], +``` + +(`FilteringTextInputFormatter` vem de `package:flutter/services.dart`.) Se a pessoa **mudar** matrícula ou senha com o campo aberto, `setState(() => _pedeCodigo = false)` e limpar `_totp` — o código pertence à sessão que o pediu. + +Criar `lib/app/mfa_enrollment_screen.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:sinalacs_acs/core/network/backend_client.dart'; +import 'package:sinalacs_client/sinalacs_client.dart' show TotpEnrollmentStart; + +/// Ativação da verificação em duas etapas (RF07 / LGPD-RT06). +/// +/// Sem token: o ACS acabou de provar matrícula e senha. O segredo aparece só +/// aqui, em QR e em texto, e **não** é gravado no aparelho. +class MfaEnrollmentScreen extends StatefulWidget { + const MfaEnrollmentScreen({ + super.key, + required this.backend, + required this.matricula, + required this.senha, + }); + + final AcsBackend backend; + final String matricula; + final String senha; + + @override + State createState() => _MfaEnrollmentScreenState(); +} + +class _MfaEnrollmentScreenState extends State { + final _codigo = TextEditingController(); + TotpEnrollmentStart? _inicio; + String? _erro; + bool _ocupado = false; + + @override + void initState() { + super.initState(); + _iniciar(); + } + + @override + void dispose() { + _codigo.dispose(); + super.dispose(); + } + + Future _iniciar() async { + try { + final inicio = await widget.backend.beginTotpEnrollment(matricula: widget.matricula, senha: widget.senha); + if (mounted) setState(() => _inicio = inicio); + } on BackendFailure catch (falha) { + if (mounted) setState(() => _erro = falha.message); + } + } + + Future _confirmar() async { + if (_ocupado) return; + setState(() { _ocupado = true; _erro = null; }); + try { + await widget.backend.confirmTotpEnrollment( + matricula: widget.matricula, + senha: widget.senha, + code: _codigo.text.trim(), + ); + if (mounted) Navigator.of(context).pop(true); + } on BackendFailure catch (falha) { + if (mounted) setState(() { _erro = falha.message; _ocupado = false; }); + } + } + + @override + Widget build(BuildContext context) { + final inicio = _inicio; + return Scaffold( + appBar: AppBar(title: const Text('Verificação em duas etapas')), + body: ListView( + padding: const EdgeInsets.all(24), + children: [ + const Text('Leia o QR no aplicativo autenticador (ou digite a chave) e informe o código de 6 dígitos que ele mostrar.'), + const SizedBox(height: 16), + if (inicio == null && _erro == null) const Center(child: CircularProgressIndicator()), + if (inicio != null) ...[ + Center(child: QrImageView(data: inicio.otpauthUri, size: 200, backgroundColor: Colors.white)), + const SizedBox(height: 12), + SelectableText(inicio.secretBase32, key: const Key('mfa_secret')), + ], + if (_erro != null) + Semantics(liveRegion: true, child: Text(_erro!, key: const Key('mfa_error'), + style: TextStyle(color: Theme.of(context).colorScheme.error))), + const SizedBox(height: 16), + TextField( + key: const Key('mfa_code_field'), + controller: _codigo, + keyboardType: TextInputType.number, + maxLength: 6, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: const InputDecoration(labelText: 'Código de 6 dígitos'), + ), + const SizedBox(height: 12), + FilledButton( + key: const Key('mfa_confirm_button'), + onPressed: inicio == null || _ocupado ? null : _confirmar, + style: FilledButton.styleFrom(minimumSize: const Size(48, 52)), + child: const Text('Ativar'), + ), + ], + ), + ); + } +} +``` + +Importar a tela em `app.dart` (`import 'package:sinalacs_acs/app/mfa_enrollment_screen.dart';`). O QR é **branco com módulos escuros** por exigência de leitura (`invite_screen.dart` faz igual): não usar token de tema para ele. + +Run: `cd apps/acs && flutter analyze 2>&1 | tail -2 && flutter test test/mfa_login_test.dart 2>&1 | tail -3` +Expected: `No issues found!`; `All tests passed!` (5 testes). Depois a suíte inteira: `flutter test 2>&1 | tail -2` com **≥ 247** (242 + 5); os testes de login que chamam `login(...)` sem `totpCode` seguem verdes (parâmetro opcional). + +- [ ] **Step 2b: Teste de fonte ampliada nas telas novas** + +Em `test/text_scale_test.dart`, acrescentar caso a 130% e 200% que abre `MfaEnrollmentScreen` (com o `FakeAcsBackend`) em 360x800 e usa `percorrerTelaInteira` do harness da Task 1 anterior (`test/support/layout_harness.dart`), e outro com o campo de código aberto no login. Se estourar, corrigir com `Flexible`/rolagem como no plano anterior; sem `withClampedTextScaling`. + +- [ ] **Step 4: Gerador de código para os testes (RFC 6238, com os mesmos vetores)** + +Criar `apps/acs/test/support/totp.dart` e copiar byte a byte para `apps/acs/integration_test/support/totp.dart` (o `integration_test` não importa de `test/`): + +```dart +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; + +/// Gerador TOTP (RFC 6238, SHA-1, 6 dígitos, 30 s) SÓ para testes: a prova de +/// MFA no emulador precisa produzir o código que o autenticador produziria. +String totpCode(Uint8List secret, DateTime at) { + final passo = at.toUtc().millisecondsSinceEpoch ~/ 1000 ~/ 30; + final mensagem = ByteData(8)..setUint64(0, passo); + final h = Hmac(sha1, secret).convert(mensagem.buffer.asUint8List()).bytes; + final o = h[h.length - 1] & 0x0f; + final bin = ((h[o] & 0x7f) << 24) | (h[o + 1] << 16) | (h[o + 2] << 8) | h[o + 3]; + return (bin % 1000000).toString().padLeft(6, '0'); +} + +/// Decodifica base32 (RFC 4648, sem preenchimento) — o que o servidor entrega em `secretBase32`. +Uint8List base32Decode(String texto) { + const alfabeto = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + var bits = 0; + var valor = 0; + final saida = []; + for (final c in texto.toUpperCase().split('')) { + final i = alfabeto.indexOf(c); + if (i < 0) continue; + valor = (valor << 5) | i; + bits += 5; + if (bits >= 8) { + saida.add((valor >> (bits - 8)) & 0xff); + bits -= 8; + valor &= (1 << bits) - 1; + } + } + return Uint8List.fromList(saida); +} +``` + +Garantir `crypto` em `apps/acs/pubspec.yaml` (`dependencies:` — já está: `crypto: ^3.0.0`). Teste `test/totp_support_test.dart`: `totpCode(ascii('12345678901234567890'), DateTime.fromMillisecondsSinceEpoch(59000, isUtc: true)) == '287082'` e `base32Decode('GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ')` igual aos 20 bytes ASCII. Run: `flutter test test/totp_support_test.dart 2>&1 | tail -2` → `All tests passed!`. + +- [ ] **Step 5: Prova no emulador, contra o servidor real, pela tela** + +O relé entrega a credencial **uma vez** (memorizada por `acsCredentialFromRelay`) e a ativação persiste no banco da execução: este teste vai **por último** em `integration_test/full_journey_e2e.dart`, depois de todos os que fazem login só com a senha. O compose de e2e mantém `REQUIRE_ACS_MFA: "false"` (os outros testes entram só com a senha), então o teste **liga a MFA pelo RPC** e só então prova o login **pela tela**, com o campo de código. A tela de ativação (`mfa_secret`) fica provada pelos testes de widget do Step 1; registre isso como `Ruling:` no ledger. (Imports a acrescentar: `support/totp.dart`.) + +```dart + testWidgets('MFA: com a verificação ativa, o login pela tela pede e aceita o código', (tester) async { + final ca = (await rootBundle.load(BackendConfig.rpcCaAsset)).buffer.asUint8List(); + final backend = BackendClient(trustedCaBytes: ca); + addTearDown(backend.close); + const host = String.fromEnvironment('SINALACS_HOST', defaultValue: 'https://10.0.2.2/'); + final cliente = api.Client(host, securityContext: SecurityContext()..setTrustedCertificatesBytes(ca)) + ..connectivityMonitor = null; + addTearDown(cliente.close); + final cred = await acsCredentialFromRelay(); + + // 1) Liga a MFA pelo RPC: matrícula + senha + o código do segredo recém-sorteado. + final inicio = await cliente.auth.beginTotpEnrollment(matricula: cred.matricula, password: cred.senha); + final segredo = base32Decode(inicio.secretBase32); + await cliente.auth.confirmTotpEnrollment( + matricula: cred.matricula, + password: cred.senha, + code: totpCode(segredo, DateTime.now()), + ); + + // 2) Login pela tela: matrícula + senha → o app pede o código e o painel NÃO abre. + await tester.pumpWidget(SinalAcsApp(backend: backend)); + await tester.enterText(find.byKey(const Key('matricula_field')), cred.matricula); + await tester.enterText(find.byKey(const Key('senha_field')), cred.senha); + await tester.tap(find.byKey(const Key('login_button'))); + await _pumpUntil(tester, () => find.byKey(const Key('totp_field')).evaluate().isNotEmpty); + expect(find.byKey(const Key('login_button')), findsOneWidget, reason: 'o painel não pode abrir sem o código'); + + // 3) O código da ativação já foi usado (replay barrado): entra com o do PASSO SEGUINTE, + // que a janela de ±1 aceita. + await tester.enterText( + find.byKey(const Key('totp_field')), + totpCode(segredo, DateTime.now().add(const Duration(seconds: 30))), + ); + await tester.tap(find.byKey(const Key('login_button'))); + await _pumpUntil(tester, () => find.byKey(const Key('login_button')).evaluate().isEmpty); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(seconds: 1)); + }); +``` + +Run: `./scripts/qa/acs_full_e2e.sh 2>&1 | grep -E "OK —|Some tests|Expected|Actual|rc="; docker compose up -d` +Expected: `OK — jornada completa do ACS contra o banco de teste` (reconstruir a imagem do servidor antes, como na Task 3). + +- [ ] **Step 6: Documentar e commitar** + +`PROGRESS.md`: "MFA/TOTP do ACS entregue (RF07, LGPD-RT06): TOTP no `loginInstitutional`, ativação por matrícula+senha+código, replay barrado, exigida fora de `development` (`REQUIRE_ACS_MFA`); refresh token e redefinição por coordenador seguem abertos"; remover "MFA" de "Continua aberto" e de "adiados". `apps/CLAUDE.md`: parágrafo curto do fluxo (campo de código no login, `MfaEnrollmentScreen`, `acsCredentialFromRelay`, código do passo seguinte no e2e). + +```bash +git add apps/acs docker-compose.e2e.yml PROGRESS.md apps/CLAUDE.md +git commit -m "feat(acs): login com código TOTP e tela de ativação da verificação em duas etapas" +``` + +--- + +### Task 7: mTLS no broker — certificados de cliente e prova de recusa + +**Files:** +- Modify: `infra/docker/mosquitto/init.sh`, `infra/docker/mosquitto/mosquitto.conf` +- Create: `scripts/qa/mtls_invariants.sh` +- Modify: `docker-compose.yml`, `.env.example` +- Modify: `backend/sinalacs_server/lib/src/config/app_config.dart`, `backend/sinalacs_server/lib/src/infrastructure/mqtt/mqtt_alert_dispatcher.dart` +- Test: `backend/sinalacs_server/test/unit/app_config_test.dart` +- Modify: `spec/stack.md` (nota de mTLS), `backend/CLAUDE.md`, `CLAUDE.md` + +**Interfaces:** +- Consumes: CA de desenvolvimento em `infra/docker/mosquitto/runtime/certs/ca.{crt,key}`; usuários MQTT `backend` e `acs-area-12`. +- Produces: `runtime/certs/backend.{crt,key}` e `runtime/certs/acs-area-12.{crt,key}` (CN = usuário MQTT, `extendedKeyUsage=clientAuth`); variáveis `MQTT_CLIENT_CERT_PATH` e `MQTT_CLIENT_KEY_PATH` do backend; `AppConfig.mqttClientCertificatePath/mqttClientKeyPath`. + +- [ ] **Step 1: Escrever o verificador de mTLS (falha: o broker aceita cliente sem certificado)** + +Criar `scripts/qa/mtls_invariants.sh` e `chmod +x`: + +```bash +#!/usr/bin/env bash +# +# O broker MQTT exige certificado de cliente (mTLS) E mantém a senha. +# +# ./scripts/qa/mtls_invariants.sh +# +# Precisa da stack de desenvolvimento no ar (docker compose up) e do .env. +# Quatro casos, cada um uma publicação de `mosquitto_pub` na porta 8883: +# A) senha certa, SEM certificado de cliente -> RECUSADO +# B) senha certa + certificado assinado pela CA do broker -> ACEITO +# C) senha certa + certificado de OUTRA CA -> RECUSADO +# D) certificado certo + senha errada -> RECUSADO +# Nenhum segredo é impresso. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$repo_root" +if [[ -f .env ]]; then set -a; source .env; set +a; fi +: "${MQTT_BACKEND_PASSWORD:?exporte MQTT_BACKEND_PASSWORD ou rode ./scripts/dev/bootstrap_env.sh}" + +certs="$repo_root/infra/docker/mosquitto/runtime/certs" +for f in ca.crt backend.crt backend.key; do + [[ -f "$certs/$f" ]] || { echo "erro: $certs/$f não existe (a stack subiu com o init.sh novo?)." >&2; exit 4; } +done + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +# Certificado "de fora": CA própria e uma folha dela, com o mesmo CN de um usuário real. +openssl req -x509 -newkey rsa:2048 -nodes -days 2 -keyout "$tmp/fora-ca.key" -out "$tmp/fora-ca.crt" \ + -subj '/CN=ca-de-fora' >/dev/null 2>&1 +openssl req -newkey rsa:2048 -nodes -keyout "$tmp/fora.key" -out "$tmp/fora.csr" -subj '/CN=backend' >/dev/null 2>&1 +printf 'extendedKeyUsage=clientAuth\n' >"$tmp/fora.ext" +openssl x509 -req -days 2 -in "$tmp/fora.csr" -CA "$tmp/fora-ca.crt" -CAkey "$tmp/fora-ca.key" \ + -CAcreateserial -extfile "$tmp/fora.ext" -out "$tmp/fora.crt" >/dev/null 2>&1 +chmod 644 "$tmp"/* + +publicar() { # publicar [--cert X --key Y] (devolve o código de saída) + local senha="$1"; shift + timeout 20 docker run --rm --network host \ + -v "$certs:/c:ro" -v "$tmp:/f:ro" -e MQTT_PW="$senha" eclipse-mosquitto:2 \ + sh -c 'mosquitto_pub -h localhost -p 8883 --cafile /c/ca.crt -u backend -P "$MQTT_PW" \ + -t sinalacs/v1/mtls/probe -m x '"$*" >/dev/null 2>&1 +} + +falhas=0 +esperar() { # esperar + local quer="$1" nome="$2"; shift 2 + if "$@"; then got=aceito; else got=recusado; fi + if [[ "$got" == "$quer" ]]; then echo "ok: $nome ($got)"; else echo "FALHOU: $nome — esperava $quer, foi $got"; falhas=$((falhas + 1)); fi +} + +esperar recusado "A) senha certa, sem certificado de cliente" publicar "$MQTT_BACKEND_PASSWORD" +esperar aceito "B) senha certa + certificado da CA do broker" \ + publicar "$MQTT_BACKEND_PASSWORD" --cert /c/backend.crt --key /c/backend.key +esperar recusado "C) certificado de outra CA" \ + publicar "$MQTT_BACKEND_PASSWORD" --cert /f/fora.crt --key /f/fora.key +esperar recusado "D) certificado certo, senha errada" \ + publicar "senha-errada" --cert /c/backend.crt --key /c/backend.key + +[[ "$falhas" -eq 0 ]] && echo "OK — o broker exige certificado de cliente e mantém a senha" || { echo "$falhas falha(s)"; exit 1; } +``` + +- [ ] **Step 2: Ver falhar** + +Run: `./scripts/qa/mtls_invariants.sh; echo rc=$?` +Expected: `erro: .../runtime/certs/backend.crt não existe` e `rc=4` (o `init.sh` ainda não emite certificado de cliente). Esse é o vermelho do passo; depois do Step 3 o vermelho passa a ser o caso A aceito. + +- [ ] **Step 3: Emitir certificados de cliente no `init.sh`** + +Em `infra/docker/mosquitto/init.sh`, depois do bloco do certificado do servidor e **antes** de `rm -f "$password_file"`, acrescentar: + +```sh +client_days=397 + +# Certificado de CLIENTE (mTLS): CN = o usuário MQTT, assinado pela mesma CA do +# servidor, só para autenticação de cliente. Renova quando vence, quando a CA +# mudou (o `verify` falha) ou quando não existe — mesma regra do servidor. +issue_client() { + cn="$1" + if [ -f "$certs_dir/$cn.crt" ] && [ -f "$certs_dir/$cn.key" ] \ + && openssl x509 -in "$certs_dir/$cn.crt" -noout -checkend 86400 >/dev/null 2>&1 \ + && openssl verify -CAfile "$certs_dir/ca.crt" "$certs_dir/$cn.crt" >/dev/null 2>&1; then + return 0 + fi + echo "Gerando certificado de cliente para $cn..." + printf 'basicConstraints=CA:FALSE\nkeyUsage=digitalSignature\nextendedKeyUsage=clientAuth\n' \ + > "$certs_dir/$cn.ext" + openssl req -newkey rsa:2048 -nodes -keyout "$certs_dir/$cn.key" \ + -out "$certs_dir/$cn.csr" -subj "/CN=$cn" + openssl x509 -req -days "$client_days" -in "$certs_dir/$cn.csr" \ + -CA "$certs_dir/ca.crt" -CAkey "$certs_dir/ca.key" -CAcreateserial \ + -extfile "$certs_dir/$cn.ext" -out "$certs_dir/$cn.crt" + rm -f "$certs_dir/$cn.csr" "$certs_dir/$cn.ext" +} + +issue_client backend +issue_client acs-area-12 +``` + +(O `chmod 644 "$certs_dir"/*.crt "$certs_dir"/*.key` que já existe no fim cobre os arquivos novos; chave de cliente legível por todos é só o dev, como a do servidor.) + +Em `mosquitto.conf`, depois de `keyfile ...`, acrescentar: + +``` +# mTLS: o cliente precisa apresentar certificado assinado pela CA acima. A senha +# e o ACL continuam valendo (sem use_identity_as_username): cert + senha. +require_certificate true +``` + +- [ ] **Step 4: Backend apresenta o certificado** + +`app_config.dart`: campos `final String? mqttClientCertificatePath; final String? mqttClientKeyPath;` lidos de `MQTT_CLIENT_CERT_PATH` e `MQTT_CLIENT_KEY_PATH` (vazio = `null`); regra de boot, no mesmo lugar e estilo das outras validações: **só um dos dois** definido é erro (`StateError('MQTT_CLIENT_CERT_PATH e MQTT_CLIENT_KEY_PATH devem vir juntos')`) e, **fora de `development`** com `MQTT_USE_TLS=true`, os dois são obrigatórios. Testes em `test/unit/app_config_test.dart`: (1) só o cert → erro; (2) os dois em `development` → ok; (3) nenhum em `development` → ok; (4) nenhum em `production` com TLS → erro; (5) nenhum em `production` **sem** TLS → ok (o broker de teste do CI sem TLS). + +`mqtt_alert_dispatcher.dart` (~linha 61): + +```dart + if (_config.mqttUseTls && _config.mqttCaCertificatePath != null) { + final contexto = SecurityContext(withTrustedRoots: false) + ..setTrustedCertificates(_config.mqttCaCertificatePath!); + final cert = _config.mqttClientCertificatePath; + final chave = _config.mqttClientKeyPath; + if (cert != null && chave != null) { + contexto + ..useCertificateChain(cert) + ..usePrivateKey(chave); + } + client.securityContext = contexto; + } +``` + +(Mantenha o que o trecho original já faz além disso; só acrescente o cert de cliente.) + +`docker-compose.yml`, serviço do servidor: ao lado de onde `MQTT_CA_CERT_PATH` é definido e a CA montada, acrescentar `MQTT_CLIENT_CERT_PATH` e `MQTT_CLIENT_KEY_PATH` apontando para os arquivos `backend.crt`/`backend.key` **no mesmo diretório montado** onde está a CA (confira com `grep -n "MQTT_CA_CERT_PATH\|mosquitto/runtime\|/certs" docker-compose.yml`; se a CA vem por uma montagem de arquivo único, monte também os dois novos). `.env.example`: documentar as duas variáveis. Faça o mesmo no `docker-compose.e2e.yml` se ele tiver o próprio servidor com `MQTT_CA_CERT_PATH`. + +- [ ] **Step 5: Subir, ver o vermelho certo e o verde** + +Run: `cd backend/sinalacs_server && dart test test/unit/app_config_test.dart 2>&1 | tail -2; cd ../.. && docker compose up -d --build 2>&1 | tail -3; sleep 25; docker compose ps --format '{{.Name}} {{.Status}}' | grep -E "mosquitto|serverpod"; ./scripts/qa/mtls_invariants.sh; echo rc=$?` +Expected: `All tests passed!`; os dois containers `Up`/`healthy`; as quatro linhas `ok:` e `OK — o broker exige certificado de cliente e mantém a senha`, `rc=0`. **Para ver o vermelho de verdade**, antes de aplicar o `require_certificate`, rode o script com `init.sh` novo e `mosquitto.conf` antigo: o caso A sai `FALHOU — esperava recusado, foi aceito`. Se o backend não conectar ao broker depois do `require_certificate` (log `docker compose logs serverpod | grep -i mqtt`), os caminhos de `MQTT_CLIENT_*` não batem com a montagem: corrija a montagem, **nunca** desligue o `require_certificate`. + +- [ ] **Step 6: Alerta ponta a ponta no servidor (o backend publica, o broker entrega)** + +Run: `docker compose exec -T serverpod true 2>/dev/null; ./scripts/qa/e2e.sh --help 2>&1 | head -5` +Expected: a ajuda lista o modo `--emulator`. (A prova do alerta pelo broker com mTLS depende do app do ACS apresentar o certificado — Task 8. Aqui só se confere que o backend segue conectado: `docker compose logs --tail 20 serverpod | grep -i "mqtt"` sem `Connection refused`/`bad certificate`.) + +- [ ] **Step 7: Documentar e commitar** + +`backend/CLAUDE.md`: acrescentar `MQTT_CLIENT_CERT_PATH`/`MQTT_CLIENT_KEY_PATH` à lista de variáveis MQTT e a regra de boot. `spec/stack.md`: nota "Broker com mTLS (2026-10-02): `require_certificate true`; certificado de cliente por usuário MQTT, emitido pela CA de desenvolvimento; senha e ACL mantidos; produção exige provisionamento por aparelho (aberto)". `CLAUDE.md` da raiz: trocar "mTLS on the broker (no client certificates)" da lista do que falta por "mTLS: o broker exige certificado de cliente (dev); falta o provisionamento por aparelho em produção". + +```bash +git add infra scripts/qa/mtls_invariants.sh docker-compose.yml docker-compose.e2e.yml .env.example backend spec CLAUDE.md +git commit -m "feat(infra): broker MQTT exige certificado de cliente (mTLS), mantendo senha e ACL; backend apresenta o seu" +``` + +--- + +### Task 8: mTLS — o app do ACS apresenta o certificado + +**Files:** +- Modify: `scripts/dev/sync_dev_ca.sh` +- Modify: `apps/acs/lib/core/network/backend_config.dart`, `apps/acs/lib/core/services/mqtt_secure_client.dart`, `apps/acs/lib/core/services/alert_feed.dart`, `apps/acs/lib/app/app.dart` (mensagem do banner), `apps/acs/pubspec.yaml`, `.gitignore` de `apps/acs` +- Modify: `apps/acs/android/app/build.gradle.kts` (guard da chave de dev), `scripts/qa/acs_release_signing.sh` +- Test: `apps/acs/test/alert_feed_test.dart`, `apps/acs/test/mqtt_secure_client_test.dart` +- Modify: `PROGRESS.md`, `apps/CLAUDE.md` + +**Interfaces:** +- Consumes (Task 7): `runtime/certs/acs-area-12.{crt,key}`; broker com `require_certificate true`. +- Produces: assets `apps/acs/assets/certs/acs_client.crt` e `acs_client.key` (gitignorados); `BackendConfig.mqttClientCertAsset`, `BackendConfig.mqttClientKeyAsset`; `SecureMqttConfig.clientCertificate/clientPrivateKey` (`Uint8List?`); `AlertFeedFailureKind.missingClientCertificate`; propriedade Gradle `sinalacs.allowDevClientKey`. + +- [ ] **Step 1: Ver o app ser recusado pelo broker (vermelho no emulador)** + +Run: `./scripts/qa/e2e.sh --emulator 2>&1 | tail -15` +Expected (com a stack da Task 7 de pé): os testes de `smoke_test.dart` e `red_alert_cycle_test.dart` **falham** no passo do MQTT (o banner `Sem central`/recusa do broker). É o vermelho de dispositivo: o ACS não apresenta certificado. Anote as linhas no ledger. (Se o script exigir `--full` ou outros flags, use os que o cabeçalho de `scripts/qa/e2e.sh` documenta para rodar `smoke` e `red_alert_cycle` no `emulator-5554`.) + +- [ ] **Step 2: Testes unitários do app (falham: não há certificado de cliente na config)** + +Em `apps/acs/test/mqtt_secure_client_test.dart`, acrescentar, no estilo do arquivo, um caso que monta `SecureMqttConfig` **com** `clientCertificate`/`clientPrivateKey` e outro **sem**, e afirma que a config guarda os dois (e que, sem os dois, ficam `null`); e um caso de `buildMqttSecurityContext(config)` (função de nível de arquivo que o Step 3 extrai do ponto onde `client.securityContext` é montado, linha ~290): com um par cert+chave **PEM gerado no teste por `openssl`** (`Process.run('openssl', [...])` num `Directory.systemTemp.createTempSync()`, pulado com `skip:` se `openssl` não existir) o contexto monta sem lançar; com bytes de lixo, lança `TlsException`. + +Em `apps/acs/test/alert_feed_test.dart`, acrescentar: `AlertFeedFailureKind.values` contém `missingClientCertificate`, e um `AlertFeedFailure(AlertFeedFailureKind.missingClientCertificate, ...)` tem `transient == false` por padrão. + +Run: `cd apps/acs && flutter test test/mqtt_secure_client_test.dart test/alert_feed_test.dart 2>&1 | tail -5` +Expected: **FAIL** de compilação (`clientCertificate`, `buildMqttSecurityContext`, `missingClientCertificate` não existem). + +- [ ] **Step 3: Implementar no app** + +`backend_config.dart`: constantes + +```dart + /// Certificado e chave de CLIENTE para o mTLS do broker (spec/stack.md). + /// **Só desenvolvimento**: a chave privada num asset serve para a stack local; em + /// produção o certificado é provisionado por aparelho (aberto). + static const mqttClientCertAsset = 'assets/certs/acs_client.crt'; + static const mqttClientKeyAsset = 'assets/certs/acs_client.key'; +``` + +`mqtt_secure_client.dart`: em `SecureMqttConfig`, campos opcionais `final Uint8List? clientCertificate;` e `final Uint8List? clientPrivateKey;`; extrair para uma função de nível de arquivo e usar no ponto da linha ~290: + +```dart +/// Contexto TLS do cliente MQTT: confia **só** na CA do projeto e, se houver +/// certificado de cliente, o apresenta (mTLS). Hostname segue verificado. +SecurityContext buildMqttSecurityContext(SecureMqttConfig config) { + final contexto = SecurityContext(withTrustedRoots: false) + ..setTrustedCertificatesBytes(config.caCertificate); + final cert = config.clientCertificate; + final chave = config.clientPrivateKey; + if (cert != null && chave != null) { + contexto + ..useCertificateChainBytes(cert) + ..usePrivateKeyBytes(chave); + } + return contexto; +} +``` + +(`client.securityContext = buildMqttSecurityContext(config);` no lugar do trecho antigo; mantenha o resto do `connect` como está.) + +`alert_feed.dart`: novo `AlertFeedFailureKind.missingClientCertificate` ("Falta o certificado de cliente do ACS nos assets (`scripts/dev/sync_dev_ca.sh`)"), carregado **depois** do da CA, com o mesmo padrão `try { rootBundle.load } catch → AlertFeedFailure(missingClientCertificate, title: 'Falta o certificado de cliente da central neste aplicativo.', detail: 'Sem ele o broker recusa a conexão.', cause: error)`, e passado a `SecureMqttConfig(clientCertificate: ..., clientPrivateKey: ...)`. Em `app.dart`, onde o banner mapeia `AlertFeedFailureKind` (`grep -n "AlertFeedFailureKind" apps/acs/lib`), tratar o novo valor igual a `missingCaAsset` (não transitório, precisa recompilar/sincronizar). + +`apps/acs/pubspec.yaml`: confirmar que `assets/certs/` está listado como diretório (se listar arquivos um a um, acrescentar `acs_client.crt` e `acs_client.key`). `apps/acs/.gitignore` (ou o `.gitignore` da raiz, onde `dev_ca.crt` já é ignorado — confira com `git check-ignore -v apps/acs/assets/certs/dev_ca.crt`): ignorar `acs_client.crt` e `acs_client.key` do mesmo jeito. + +`scripts/dev/sync_dev_ca.sh`: ao copiar a CA para os assets, copiar também `infra/docker/mosquitto/runtime/certs/acs-area-12.crt` → `apps/acs/assets/certs/acs_client.crt` e `acs-area-12.key` → `acs_client.key`, **com a mesma verificação** que o script já faz da CA (`openssl verify -CAfile ca.crt acs-area-12.crt`) e o mesmo padrão de temporário + `mv`; `chmod 600` na chave copiada. + +Run: `./scripts/dev/sync_dev_ca.sh && cd apps/acs && flutter analyze 2>&1 | tail -2 && flutter test 2>&1 | tail -2` +Expected: assets copiados; `No issues found!`; `All tests passed!` (≥ 250). + +- [ ] **Step 4: Prova no emulador: o alerta volta a chegar, agora com mTLS** + +Run: `./scripts/qa/e2e.sh --emulator 2>&1 | tail -12; ./scripts/qa/mtls_invariants.sh` +Expected: `smoke_test`/`red_alert_cycle_test` **passam** (o ACS conecta ao broker com certificado + senha e recebe o alerta); as quatro linhas do `mtls_invariants.sh` seguem `ok:`. Conferir no log do broker que o cliente do app autenticou como `acs-area-12`: `docker compose logs mosquitto --tail 40 | grep -i "acs-area-12"`. + +- [ ] **Step 5: Release não leva a chave privada de desenvolvimento** + +Em `build.gradle.kts`, **dentro** do `gradle.taskGraph.whenReady` da Task 2, depois do guard de assinatura, acrescentar: + +```kotlin + // A chave privada de CLIENTE do broker de desenvolvimento (assets/certs/acs_client.key) + // não pode ir dentro de um APK de release: qualquer pessoa com o APK a extrairia. + val chaveDeDev = rootProject.file("../assets/certs/acs_client.key") + if (buildaRelease && chaveDeDev.exists() && !project.hasProperty("sinalacs.allowDevClientKey")) { + throw GradleException( + """ + |O release levaria a chave privada de desenvolvimento do broker (assets/certs/acs_client.key). + | + |Remova o arquivo antes de gerar o release (em produção o certificado de cliente é + |provisionado por aparelho — ainda não implementado). Para um release de TESTE LOCAL, + |acrescente -Psinalacs.allowDevClientKey=true. + """.trimMargin() + ) + } +``` + +Em `scripts/qa/acs_release_signing.sh`, os cenários 2 e 3 passam `-Psinalacs.allowDevClientKey=true`, e um **cenário 4** novo prova o guard: com a chave de dev presente (rode `sync_dev_ca.sh` antes ou crie um arquivo vazio descartável em `assets/certs/acs_client.key` e o remova no `trap`), `construir -Psinalacs.allowDebugSigning=true` **falha** com `chave privada de desenvolvimento`. + +Run: `./scripts/qa/acs_release_signing.sh; echo rc=$?` +Expected: quatro cenários e `OK — ...`, `rc=0`. (Para ver o vermelho: antes de acrescentar o guard, o cenário 4 sai `erro: o release passou levando a chave de desenvolvimento`.) + +- [ ] **Step 6: Documentar e commitar** + +`PROGRESS.md`: "mTLS do broker entregue **na stack local**: `require_certificate true`, certificado de cliente para o backend e para o ACS (asset de dev), senha e ACL mantidos; `scripts/qa/mtls_invariants.sh` prova os quatro casos. **Aberto:** provisionamento por aparelho em produção (CSR no cadastro, chave no Keystore); release com a chave de dev é barrado." Remover "mTLS" da lista de pendências do ACS. `apps/CLAUDE.md`: parágrafo curto (assets, `sync_dev_ca.sh`, `buildMqttSecurityContext`, guard do release). + +```bash +git add scripts apps/acs PROGRESS.md apps/CLAUDE.md +git commit -m "feat(acs): app apresenta certificado de cliente ao broker (mTLS); release barra a chave de desenvolvimento" +``` + +--- + +### Task 9: Registro final e barra no emulador + +**Files:** +- Modify: `PROGRESS.md`, `CLAUDE.md` (raiz: a frase "Still missing: MFA and refresh token…, mTLS on the broker…, a backend for the admin backoffice…") + +**Interfaces:** nenhuma. + +- [ ] **Step 1: Atualizar o estado do projeto** + +No `CLAUDE.md` da raiz, parágrafo "Project overview", reescrever "Still missing" para: refresh token, provisionamento de certificado por aparelho em produção (o broker já exige certificado de cliente na stack local), backend do backoffice, deploy de produção; e acrescentar ao "Already delivered": MFA TOTP do ACS, RF08 persistido, `FLAG_SECURE`, release assinado com chave própria, botão da UBS. Em `PROGRESS.md`, a seção "Pendências do ACS (2026-10-02)" com uma linha por tarefa (commit, prova, o que ficou aberto) e **o que foi decidido** (D1–D8 resumidos). + +- [ ] **Step 2: Barra final** + +```bash +export PATH="$PATH:$HOME/Android/Sdk/platform-tools:$HOME/flutter/bin:$HOME/.pub-cache/bin" +(cd backend/sinalacs_server && dart analyze && dart test 2>&1 | tail -2) +(cd apps/acs && flutter analyze && flutter test 2>&1 | tail -2) +python3 scripts/qa/contagem_validation_report.py +./scripts/qa/lib_rele_test.sh && ./scripts/qa/acs_gps_e2e_test.sh +./scripts/qa/ci_invariants.sh && ./scripts/qa/check_documentation_links.sh +docker compose up -d --build && sleep 25 && ./scripts/qa/mtls_invariants.sh +adb -s emulator-5554 shell pm list packages br.com.prismrr.sinalacs.acs # vazio +./scripts/qa/acs_secure_window.sh && ./scripts/qa/acs_gps_e2e.sh && ./scripts/qa/acs_gps_e2e.sh --sem-permissao +./scripts/qa/e2e.sh --emulator +./scripts/qa/acs_release_signing.sh +./scripts/qa/acs_full_e2e.sh; docker compose up -d +``` + +Expected: `No issues found!` nos dois; suíte do backend e `flutter test` **todos verdes** (o ACS com ≥ 250 testes); conferidor da contagem `ok`; `ok: lib_rele` e `ok: acs_gps_e2e`; `ci_invariants` `ok`; links sem erro; `OK — o broker exige certificado de cliente e mantém a senha`; `OK — janela do ACS com FLAG_SECURE`; os dois GPS `OK`; o e2e do emulador verde com mTLS; `OK — release exige chave própria...`; jornada `OK` (com as linhas `rajada:` e a prova de RF08/MFA); `sinalacs-serverpod … (healthy)`; `ls .e2e/fixtures.json` → inexistente; nenhum app do ACS instalado no fim. + +Se `ci_invariants.sh` apontar o CI (por exemplo, o job `serverpod-backend` precisar de `REQUIRE_ACS_MFA` ou dos caminhos `MQTT_CLIENT_*` para o broker de teste), corrigir `.github/workflows/ci.yml` **mantendo** as invariantes (runner `ubuntu-24.04`, sem `paths`, concurrency) e registrar a mudança no ledger. + +- [ ] **Step 3: Estado do repositório e commit** + +```bash +git status --short && git log --oneline -12 && git log -12 --format=%B | grep -ci "co-authored\|generated with" +git add PROGRESS.md CLAUDE.md +git commit -m "docs: registra o fechamento das pendências do ACS (FLAG_SECURE, assinatura, UBS, RF08, MFA, mTLS)" +``` + +Expected: árvore limpa depois do commit; **`0`** na contagem de atribuições de IA; sem push. + +--- + +## Auto-revisão + +**Cobertura:** FLAG_SECURE → Task 1; assinatura de release → Task 2 (e o guard da chave de dev na Task 8); botão da UBS → Task 3; RF08 → Task 4 (com a decisão de LGPD escrita **antes** do código, como o `PROGRESS.md` exigia); MFA → Tasks 5 (backend) e 6 (app + emulador); mTLS → Tasks 7 (broker + backend) e 8 (app + emulador). A barra final e o registro estão na Task 9. O que **não** entra (refresh token, redefinição de MFA, provisionamento de certificado por aparelho, backoffice) tem o motivo escrito e vira "aberto" no `PROGRESS.md`, sem fingir fechamento. + +**Placeholders:** não há "TBD". Três pontos dependem de **ler um arquivo que esta sessão não abriu por inteiro** e dizem exatamente o quê: o corpo do `login` institucional (Task 5, Step 5 — com refatoração guardada pelos testes existentes), a sintaxe de escrita do store do ORM (Task 5, Step 6) e a montagem dos certificados no `docker-compose.yml` (Task 7, Step 4). Cada um tem o teste que prova o resultado. + +**Consistência de nomes:** `MicroAreaDirectory.load()`/`MicroAreaSnapshot`/`MicroAreaCacheStore.read/write/clear` iguais nas Tasks 4 (store, serviço, telas e e2e); `TotpEnrollment{sealed,enabled,lastStep}`, `TotpStore.saveSecret/enable/registerStep`, `SealedSecret`, `TotpSecretVault.seal/open` iguais entre o teste e a implementação da Task 5; `AcsBackend.login(..., totpCode)`, `MfaCodeRequired`/`MfaEnrollmentRequired`, chaves `totp_field`/`mfa_secret`/`mfa_code_field`/`mfa_confirm_button` iguais entre os testes e as telas da Task 6; `sinalacs.allowDebugSigning` (Task 2) e `sinalacs.allowDevClientKey` (Task 8) usados nos mesmos scripts. + +**Review Focus:** as 5 linhas têm teste dono (replay e tentativa errada na Task 5; cache de outro dono e vencido na Task 4; recusa sem certificado e de outra CA na Task 7; release sem chave na Task 2 e com a chave de dev na Task 8). + +**Riscos que só a execução resolve:** (1) o formato do `dumpsys window windows` no Android 16 (Task 1 manda ajustar o `awk` até ver o vermelho certo); (2) se `-P` chega ao Gradle pelo `flutter build` (Task 2 traz a alternativa `--android-project-arg`); (3) a refatoração do `login` (Task 5) mexe em código de segurança — os testes antigos são a rede; (4) se o `acs_full_e2e.sh` reconstrói a imagem do servidor (Tasks 3, 5 e 6 mandam conferir); (5) se o `eclipse-mosquitto:2` com `--network host` alcança `localhost:8883` neste host (Task 7); (6) a base do aparelho estar mesmo cifrada no caminho do cache (Task 4, Step 10 trata como achado de segurança se não estiver). + +**Escopo:** este plano junta seis frentes independentes (a skill sugere um plano por subsistema). Cada Task termina em software testável por si e pode ser executada, commitada e revertida sozinha; a ordem só importa entre 5→6 (MFA) e 7→8 (mTLS), e a Task 8 altera o guard da Task 2. diff --git a/docs/superpowers/plans/2026-10-02-revisao-e-fechamento-do-app-acs.md b/docs/superpowers/plans/2026-10-02-revisao-e-fechamento-do-app-acs.md new file mode 100644 index 00000000..1a1a8958 --- /dev/null +++ b/docs/superpowers/plans/2026-10-02-revisao-e-fechamento-do-app-acs.md @@ -0,0 +1,806 @@ +# Revisão e fechamento do app ACS — Plano de Implementação + +> **Para agentes:** SUB-SKILL OBRIGATÓRIA: use superpowers:subagent-driven-development (recomendado) ou superpowers:executing-plans para executar este plano tarefa a tarefa. Os passos usam checkbox (`- [ ]`). + +**Goal:** Fechar as lacunas que a revisão de 2026-10-02 mediu no app `apps/acs` — layout que estoura com fonte ampliada, configuração Android que deixa dado clínico sair por backup, a meta de sincronização offline sem teste e a documentação de tema desatualizada — e provar tudo no `emulator-5554`. + +**Architecture:** Quatro correções independentes, cada uma com um teste que falha antes: (1) o layout passa a aguentar 130% e 200% de fonte, com um harness no molde do `apps/admin`; (2) o manifesto proíbe backup automático e o APK de release é medido e iniciado no emulador; (3) a rajada de 100 visitas offline é provada em teste hermético e na jornada real do emulador; (4) a persistência do tema ganha teste e `spec/ui_design.md` deixa de dizer que o tema é fixo. + +**Tech Stack:** Flutter 3 / Dart, `flutter_test`, `integration_test`, `shared_preferences`, Gradle/aapt2, `scripts/qa/acs_full_e2e.sh` (banco de teste), emulador Android 16 em `emulator-5554`. + +**Spec:** `spec/PRD_system.md` (RF07–RF13, RNF02, RNF05, M2.4), `spec/ux_accessibility_assessment.md` (WCAG 1.4.4), `spec/lgpd_design.md` (§5.4 backup, INV-04), `spec/ui_design.md`, `apps/CLAUDE.md`. + +## Estado verificado em 2026-10-02 + +Medido nesta sessão, não herdado de documento: + +- `flutter analyze` em `apps/acs`: **No issues found**. `flutter test`: **208 passam**. Cobertura de linhas: `app.dart` 88,1%; `theme_controller.dart` 23,1%; `alert_feed.dart` 27,3%; `mqtt_secure_client.dart` 46,4%; `backend_client.dart` 53,9%. +- `emulator-5554` no ar (`adb devices`). `./scripts/qa/acs_full_e2e.sh` na linha de base: **OK** (login real, seletor por microárea, visita sincronizada). O script derruba a stack de desenvolvimento; `docker compose up -d` a restaura. +- **Achado 1 (defeito, RNF05/WCAG 1.4.4):** sonda descartável com `textScaleFactor` 1.3 e 2.0 em 360x800 estoura o layout: `app.dart:2230` (chip de conexão do cabeçalho, 94px a 130% e 308px a 200%), `app.dart:2213` (`_InfoRow`, até 370px), `app.dart:897` (botão "Atualizar dados da microárea", até 561px). A sonda parou na aba Área (um `pumpAndSettle` nunca assenta ali, porque o spinner do pull anima sem parar no fake): as abas Fila, Mapa, Visita e os itens de "Mais" **não foram medidos**. O `apps/admin` tem harness e teste para isso; o ACS, nenhum. +- **Achado 2 (configuração):** o `AndroidManifest.xml` `main` do ACS não declara `android:allowBackup`; o padrão do Android é `true`, então o backup automático pode levar para a nuvem do usuário o `shared_preferences` e o banco local da fila de visitas (dado de saúde, INV-04). `release` ainda assina com a chave de debug (`build.gradle.kts`). +- **Achado 3 (meta sem prova):** o PRD (M2.4) pede "100 registros offline sincronizam em < 5s após rede" e RNF02 pede sincronização > 99,5%. Nenhum teste do ACS enfileira 100 visitas; a fila manda **todas as pendentes num lote só** (`OfflineVisitQueue.sync`), sem teto de tamanho nem no cliente nem em `visits.sync`. +- **Achado 4 (documentação):** `spec/ui_design.md` §1.2 (linha 14) diz que o tema é "fixo e permanente" e que o app não reage a claro/escuro; o código tem `themeMode` + `darkTheme` + tela "Preferências" (Claro/Escuro/Automático, commit `45ae667`). A persistência (`ThemeController`) está em 23% de cobertura. + +## Fora deste plano (e por quê) + +- **RF08, cache persistido da microárea offline:** exige desenho de LGPD antes (já registrado em `PROGRESS.md`). +- **MFA/TOTP e refresh token:** adiados por decisão (`PROGRESS.md`). +- **iOS (`apps/acs/ios/` não existe), botão da UBS (sem fonte de contato), assinatura de release com chave própria, deploy de produção, mTLS no broker:** decisão de produto ou infraestrutura, sem dono neste plano. +- **`FLAG_SECURE` (bloquear captura de tela e miniatura nos "recentes"):** a lista da microárea mostra nome e condições crônicas; é decisão de produto/LGPD que ninguém registrou. Fica como pergunta ao dono do produto, não como código. +- **Testes de `alert_feed.dart` (`refused`/`unreachable`) e `backend_client.dart`:** `MqttAlertFeed.start` constrói o `MqttSecureClient` por dentro, sem ponto de injeção; testá-lo exigiria refatorar. Esses caminhos são provados no dispositivo por `smoke_test.dart`/`red_alert_cycle_test.dart` (stack de desenvolvimento). Não vale refatorar só para subir o número. +- **`app.dart` com 2266 linhas:** não dividir agora; nenhum achado depende disso. + +## Global Constraints + +- Português em comentários, mensagens de commit e texto de UI, como o resto do repositório. +- Commits **sem** atribuição de IA: nenhuma linha "Co-Authored-By", nenhuma "Generated with Claude Code" (regra do `CLAUDE.md` do projeto, que prevalece sobre o lembrete de atribuição da sessão). Tudo na branch `fix/app_acs`, sem push, merge nem PR. +- Nada de dado real de paciente em teste, log ou captura: só fixtures sintéticas. +- Cor clínica (`red`/`yellow`/`green`) usada como texto passa pelo token `*OnSurface` (`apps/CLAUDE.md`); este plano não mexe em cor. +- Triagem determinística e fila offline: preservar a semântica de retry/conflito/rejeição de `OfflineVisitQueue` (risco arquitetural nº 1 do `AGENTS.md`). +- O script de GPS e o de e2e nunca apagam um app já instalado sem `--reinstalar`; os novos passos de emulador seguem a mesma regra. +- Ambiente de todos os comandos: `export PATH="$PATH:$HOME/Android/Sdk/platform-tools:$HOME/flutter/bin"`; raiz do repositório `/home/rock/Documents/Dev/APPs/SinalACS`. + +## Review Focus + +1. **Fonte a 200% em tela baixa ou com teclado aberto:** o formulário de visita (`VisitRegistrationScreen`) é o que mais cresce; a rolagem tem de alcançar o botão "Salvar e enfileirar sincronização". → Task 1, percurso com rolagem e `ensureVisible` do botão. +2. **Fonte alterada com o app já aberto** (Configurações → tamanho da fonte, app em segundo plano): o cabeçalho tem de crescer sem clipar. → Task 1, teste `o cabeçalho cresce quando a escala muda em tempo de execução`. +3. **Restauração de backup em outro aparelho:** o app tem de continuar abrindo e pedir login (a chave do Keystore não migra). → Task 2 prova `allowBackup=false` e que o APK de release sobe sem `FATAL` no emulador; a recuperação de chave perdida já é de `SqlCipherVisitStore`. +4. **Rajada com uma visita recusada no meio:** 100 visitas, uma `rejected` e uma `conflict` não podem travar as outras 98 nem sumir do aparelho. → Task 3, teste `uma recusada e um conflito no meio não seguram as demais`. +5. **Valor inválido gravado na preferência de tema** (arquivo de preferências corrompido ou de versão futura): o app abre em "Automático". → Task 4. + +--- + +### Task 1: Layout aguenta fonte ampliada (RNF05 / WCAG 1.4.4) + +**Files:** +- Create: `apps/acs/test/support/layout_harness.dart` +- Create: `apps/acs/test/text_scale_test.dart` +- Modify: `apps/acs/lib/app/app.dart` (linhas 227, 740, 897–903, 2213, 2214–2250) + +**Interfaces:** +- Consumes: `SinalAcsApp(backend:, feedBuilder:)`, `FakeAcsBackend`, `FakeAlertFeed`, `testAlert`, `testLocationCell` (`test/support/fakes.dart`); chaves `matricula_field`, `senha_field`, `login_button`. +- Produces (usados só dentro desta task e pela Task 3, que reaproveita `assentar`): + - `Future abrirPainel(WidgetTester tester, {required Size tamanho, double escalaDeFonte = 1.0})` + - `Future assentar(WidgetTester tester)` — o equivalente do `settleRealAsync` de `login_flow_test.dart` + - `void esperarSemEstouro(WidgetTester tester, String contexto)` + - `Future percorrerPainelInteiro(WidgetTester tester, String contexto)` + - `double acsHeaderHeight(BuildContext context)` em `app.dart` + +- [ ] **Step 1: Escrever o harness** + +Criar `apps/acs/test/support/layout_harness.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/app/app.dart'; + +import 'fakes.dart'; + +/// Ferramentas para provar que as telas do ACS cabem na janela em que foram +/// postas. Mesmo método do `apps/admin/test/support/layout_harness.dart`, e as +/// mesmas três armadilhas — errar qualquer uma produz um teste que passa +/// sempre: +/// +/// 1. `RenderFlex` só denuncia o estouro quando **pinta**; checar antes de +/// assentar os quadros não vê nada. +/// 2. Item de `ListView` fora da viewport não é construído e nunca reclama; +/// daí [percorrerTelaInteira]. +/// 3. `takeException()` consome **uma** exceção por chamada; [esperarSemEstouro] +/// esvazia todas. +/// +/// Uma quarta é só do ACS: `pumpAndSettle()` **nunca assenta** na aba Área, porque +/// o spinner do pull anima sem parar contra o fake. [assentar] alterna quadros +/// e tempo real, como o `settleRealAsync` de `login_flow_test.dart`. + +const destinosDaBarra = ['Área', 'Fila', 'Mapa', 'Visita']; + +/// Itens da folha "Mais", pelo rótulo que `_moreItem` mostra. +const itensDoMais = [ + 'Acionamento', + 'Geofencing', + 'Avisos à comunidade', + 'Convidar paciente', + 'Preferências', +]; + +/// Troca o tamanho da janela de uma sessão já aberta. [tamanho] é em dp, porque +/// `devicePixelRatio` é forçado a 1. +void redimensionar(WidgetTester tester, Size tamanho, {double escalaDeFonte = 1.0}) { + tester.view.devicePixelRatio = 1.0; + tester.view.physicalSize = tamanho; + tester.platformDispatcher.textScaleFactorTestValue = escalaDeFonte; +} + +Future assentar(WidgetTester tester) async { + for (var i = 0; i < 30; i++) { + await tester.pump(const Duration(milliseconds: 16)); + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 20))); + } + await tester.pump(); +} + +/// Falha se algum quadro pintado desde a última checagem reportou estouro. +void esperarSemEstouro(WidgetTester tester, String contexto) { + final erros = []; + Object? erro; + while ((erro = tester.takeException()) != null) { + erros.add(erro.toString().split('\n').first); + } + expect(erros, isEmpty, reason: 'estouro de layout em $contexto: ${erros.join(' | ')}'); +} + +/// Abre o painel já logado (login pela tela, contra o `FakeAcsBackend`), numa +/// janela de tamanho e escala de fonte fixos. Confere a tela de login ANTES de +/// entrar: ela também tem cabeçalho. +Future abrirPainel( + WidgetTester tester, { + required Size tamanho, + double escalaDeFonte = 1.0, +}) async { + redimensionar(tester, tamanho, escalaDeFonte: escalaDeFonte); + addTearDown(tester.view.reset); + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + + final alerta = testAlert(alertId: 'escala-1', locationCell: testLocationCell); + await tester.pumpWidget(SinalAcsApp( + backend: FakeAcsBackend(), + feedBuilder: (queue) { + queue.upsert(alerta); // `initialAlert` só seleciona; a tela lê a fila + return FakeAlertFeed(queue); + }, + )); + await tester.pump(); + esperarSemEstouro(tester, 'tela de login'); + + await tester.enterText(find.byKey(const Key('matricula_field')), 'ACS-001'); + await tester.enterText(find.byKey(const Key('senha_field')), 'senha-sintetica'); + final entrar = find.byKey(const Key('login_button')); + await tester.ensureVisible(entrar); // numa janela baixa o botão fica abaixo da dobra + await tester.pump(); + await tester.tap(entrar); + await assentar(tester); +} + +/// Rola a tela até o fim, checando estouro a cada passo. +Future percorrerTelaInteira(WidgetTester tester, String contexto) async { + esperarSemEstouro(tester, '$contexto (topo)'); + final rolaveis = find.byType(Scrollable); + if (rolaveis.evaluate().isEmpty) return; // ex.: o mapa não rola + for (var passo = 1; passo <= 8; passo++) { + await tester.drag(rolaveis.last, const Offset(0, -320)); + await assentar(tester); + esperarSemEstouro(tester, '$contexto (rolagem $passo)'); + } +} + +Future irParaDaBarra(WidgetTester tester, String destino) async { + final alvo = find.descendant(of: find.byType(NavigationBar), matching: find.text(destino)); + await tester.tap(alvo); + await assentar(tester); + esperarSemEstouro(tester, 'navegação para $destino'); +} + +Future irParaDoMais(WidgetTester tester, String item) async { + final mais = find.descendant(of: find.byType(NavigationBar), matching: find.text('Mais')); + await tester.tap(mais); + await assentar(tester); + esperarSemEstouro(tester, 'folha "Mais"'); + final alvo = find.text(item); + await tester.ensureVisible(alvo); + await tester.pump(); + await tester.tap(alvo); + await assentar(tester); + esperarSemEstouro(tester, 'navegação para $item'); +} + +/// Visita os quatro destinos da barra e os cinco itens de "Mais". +Future percorrerPainelInteiro(WidgetTester tester, String contexto) async { + for (final destino in destinosDaBarra) { + await irParaDaBarra(tester, destino); + await percorrerTelaInteira(tester, '$contexto / $destino'); + } + for (final item in itensDoMais) { + await irParaDoMais(tester, item); + await percorrerTelaInteira(tester, '$contexto / $item'); + } +} +``` + +- [ ] **Step 2: Escrever o teste que falha** + +Criar `apps/acs/test/text_scale_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'support/layout_harness.dart'; + +/// WCAG 1.4.4 (RNF05): o conteúdo precisa sobreviver 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 +/// acessibilidade 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 abrirPainel(tester, tamanho: const Size(360, 800), escalaDeFonte: escala); + await percorrerPainelInteiro(tester, 'fonte a ${(escala * 100).toInt()}%'); + }); + } + + testWidgets('o cabeçalho cresce quando a escala muda em tempo de execução', (tester) async { + // No Android a preferência de tamanho de fonte muda em Configurações, com o + // app já aberto em segundo plano: o cabeçalho tem de acompanhar. + await abrirPainel(tester, tamanho: const Size(360, 800)); + final alturaPadrao = tester.getSize(find.byType(AppBar)).height; + + redimensionar(tester, const Size(360, 800), escalaDeFonte: 2.0); + await assentar(tester); + final alturaAmpliada = tester.getSize(find.byType(AppBar)).height; + + expect(alturaAmpliada, greaterThan(alturaPadrao)); + esperarSemEstouro(tester, 'cabeçalho com fonte a 200%'); + }); +} +``` + +- [ ] **Step 3: Rodar e ver falhar** + +Run: `cd apps/acs && flutter test test/text_scale_test.dart 2>&1 | tail -40` +Expected: **FAIL** nos três testes. Mensagens com `estouro de layout em ... A RenderFlex overflowed by N pixels on the right` (o `-N` do cabeçalho `1.3`/`2.0`). Anotar **todos** os pontos que o relatório lista: os três conhecidos (`app.dart:2230`, `2213`, `897`) e qualquer outro que apareça nas abas Fila, Mapa, Visita ou nos itens de "Mais" — esses ainda não foram medidos. Se o teste passar de primeira, ele não está pegando o estouro: pare e confira o harness (armadilhas 1–3 no topo dele). + +- [ ] **Step 4: Corrigir os três pontos conhecidos** + +(a) Botão do pull — `app.dart` ~897–903: o `Text` dentro de um `Row(mainAxisSize: min)` não quebra linha. Trocar + +```dart + const Text('Atualizar dados da microárea'), +``` +por +```dart + const Flexible(child: Text('Atualizar dados da microárea', textAlign: TextAlign.center)), +``` + +(b) `_InfoRow` — `app.dart:2213`: o rótulo à esquerda não encolhe. Trocar `Text(label)` por `Flexible(child: Text(label))` e dar um respiro entre os dois, ficando + +```dart +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: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.start, children: [Flexible(child: Text(label)), const SizedBox(width: 12), Flexible(child: Text(value, textAlign: TextAlign.end, style: const TextStyle(fontWeight: FontWeight.bold)))])); } +``` + +(c) Cabeçalho — `app.dart` 2214–2250. A altura vem de quem monta o `Scaffold` (um `PreferredSizeWidget` não tem `BuildContext`; é o mesmo desenho do `adminHeaderHeight`). Acrescentar, antes de `_Header`: + +```dart +/// Altura do cabeçalho, crescendo com a escala de fonte. +/// +/// `preferredSize` não tem acesso ao `BuildContext`, então quem monta o +/// `Scaffold` calcula e entrega. 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. +double acsHeaderHeight(BuildContext context) => + MediaQuery.textScalerOf(context).scale(72).clamp(72.0, 132.0); +``` + +Trocar o construtor, o `preferredSize` e o chip: + +```dart +class _Header extends StatelessWidget implements PreferredSizeWidget { + const _Header(this.eyebrow, this.title, {required this.height, this.connected}); + + final String eyebrow; + final String title; + + /// Calculada por quem monta o Scaffold, via [acsHeaderHeight]. + final double height; + + /// Estado da conexão com o broker. `null` fora do painel. + /// + /// Fica no cabeçalho de propósito: um ACS precisa saber, sem procurar, que + /// parou de receber alertas. + final bool? connected; + + @override + Size get preferredSize => Size.fromHeight(height); +``` + +e, dentro de `actions`, limitar o chip e deixar o rótulo cortar em vez de estourar (o ícone e a cor continuam dizendo o estado; o texto completo vai no `Semantics`): + +```dart + Padding( + padding: const EdgeInsets.only(right: 12), + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width * 0.5), + child: Chip( + key: const Key('broker_status'), + avatar: connected == null ? null : Icon( + connected! ? Icons.cloud_done_outlined : Icons.cloud_off_outlined, + size: 18, + color: connected! ? context.acsRisk.greenOnSurface : context.acsRisk.redOnSurface, + ), + label: Text( + switch (connected) { + null => 'Offline ready', + true => 'Alertas em tempo real', + false => 'Sem central', + }, + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), + ), + ), +``` + +Os dois chamadores passam a altura (`app.dart:227` e `:740`): + +```dart + appBar: _Header('Segurança e rastreabilidade', 'Acesso institucional', height: acsHeaderHeight(context)), +``` +```dart + appBar: _Header('ACS • ${_brokerConnected ? 'em linha' : 'sem conexão'}', 'Painel operacional', height: acsHeaderHeight(context), connected: _brokerConnected), +``` +(O primeiro deixa de ser `const`.) + +- [ ] **Step 5: Rodar de novo e tratar o que sobrar** + +Run: `cd apps/acs && flutter test test/text_scale_test.dart 2>&1 | tail -40` +Expected: se ainda falhar, cada falha nomeia a linha de `app.dart`. Corrija **cada ponto novo** com o mesmo padrão — `Flexible`/`Expanded` em filho de `Row`, `Wrap` no lugar de `Row` de chips, `maxLines`+`ellipsis` em rótulo secundário —, **sem** `withClampedTextScaling` e **sem** reduzir o tamanho da fonte. Rodar até **PASS 3/3**. Registrar no PROGRESS.md (Task 5) a lista final de pontos corrigidos. + +- [ ] **Step 6: Confirmar a regressão completa** + +Run: `cd apps/acs && flutter analyze && flutter test 2>&1 | tail -3` +Expected: `No issues found!` e `All tests passed!` com **≥ 211** testes (208 + 3). + +- [ ] **Step 7: Provar no emulador com fonte a 200%** + +O mapa é onde o layout real (fonte do Android, não a de teste) pode divergir. Usar o teste de integração que não precisa da stack: + +```bash +export PATH="$PATH:$HOME/Android/Sdk/platform-tools:$HOME/flutter/bin" +adb -s emulator-5554 shell pm list packages br.com.prismrr.sinalacs.acs # tem de vir vazio (senão: pare, ver guarda abaixo) +adb -s emulator-5554 shell settings put system font_scale 2.0 +cd apps/acs && flutter test integration_test/map_flow_test.dart -d emulator-5554 \ + --dart-define=SINALACS_MQTT_PASSWORD="$(grep ^MQTT_ACS_PASSWORD ../../.env | cut -d= -f2-)" 2>&1 | tail -6 +adb -s emulator-5554 shell settings put system font_scale 1.0 +adb -s emulator-5554 uninstall br.com.prismrr.sinalacs.acs +``` +Expected: `All tests passed!`; sem `A RenderFlex overflowed` no log. Se o teste pedir a stack (o `map_flow_test` declara os mesmos pré-requisitos do `smoke_test`), ela está de pé: `docker compose ps` deve mostrar `sinalacs-serverpod … (healthy)`; se não, `docker compose up -d`. **Guarda:** se o primeiro comando listar o pacote, o app de dev está instalado e reinstalar apagaria a fila SQLCipher e a chave do Keystore: não prossiga sem o usuário autorizar. O `font_scale` volta a `1.0` mesmo se o teste falhar (rodar o penúltimo comando à mão). + +- [ ] **Step 8: Commit** + +```bash +git add apps/acs/test/support/layout_harness.dart apps/acs/test/text_scale_test.dart apps/acs/lib/app/app.dart +git commit -m "fix(acs): layout aguenta fonte a 130% e 200% (WCAG 1.4.4) com harness no molde do admin" +``` + +--- + +### Task 2: Manifesto sem backup automático e APK de release medido (INV-04 / LGPD) + +**Files:** +- Modify: `apps/acs/android/app/src/main/AndroidManifest.xml` +- Modify: `apps/acs/test/android_manifest_test.dart` + +**Interfaces:** +- Consumes: — +- Produces: nada que outra task use. + +- [ ] **Step 1: Escrever os testes que falham** + +Acrescentar em `apps/acs/test/android_manifest_test.dart`, antes do `}` final de `main()`: + +```dart + test('NÃO deixa o Android copiar o app para a nuvem (INV-04 / LGPD)', () { + // allowBackup é `true` por padrão. Sem `false` explícito, o backup + // automático leva para a conta Google da pessoa o `shared_preferences` e o + // banco local da fila de visitas — dado de saúde fora do controle do + // sistema. A chave do SQLCipher vive no Keystore e não migra, então o + // backup também não restauraria nada que prestasse: só vazaria. + expect( + RegExp(r']*android:allowBackup="false"', dotAll: true).hasMatch(manifest), + isTrue, + ); + }); + + test('o nome na gaveta do aparelho é legível, não o identificador do pacote', () { + expect(manifest, isNot(contains('android:label="sinalacs_acs"'))); + expect(manifest, contains('android:label="SinalACS ACS"')); + }); +``` + +- [ ] **Step 2: Ver falhar** + +Run: `cd apps/acs && flutter test test/android_manifest_test.dart 2>&1 | tail -15` +Expected: os 2 testes novos **FALHAM** (`Expected: true Actual: ` e `Expected: not contains 'android:label="sinalacs_acs"'`); os 4 antigos passam. + +- [ ] **Step 3: Implementar** + +Em `AndroidManifest.xml`, na tag ` +``` + +- [ ] **Step 4: Ver passar** + +Run: `cd apps/acs && flutter test test/android_manifest_test.dart 2>&1 | tail -4` +Expected: `All tests passed!` (6 testes). + +- [ ] **Step 5: Medir o manifesto mesclado do APK de release e subi-lo no emulador** + +O teste lê o manifesto-fonte; o que vai para o aparelho é o **mesclado** (plugins acrescentam permissões). Medir o APK real: + +```bash +export PATH="$PATH:$HOME/Android/Sdk/platform-tools:$HOME/flutter/bin" +set -a; source .env; set +a +adb -s emulator-5554 shell pm list packages br.com.prismrr.sinalacs.acs # vazio, senão pare (guarda: reinstalar apaga a fila SQLCipher) +cd apps/acs +flutter build apk --release --dart-define=SINALACS_MQTT_PASSWORD="$MQTT_ACS_PASSWORD" 2>&1 | tail -4 +AAPT2="$(ls ~/Android/Sdk/build-tools/*/aapt2 | tail -1)" +"$AAPT2" dump permissions build/app/outputs/flutter-apk/app-release.apk +"$AAPT2" dump badging build/app/outputs/flutter-apk/app-release.apk | grep -E "application-label:|debuggable|allowBackup" || true +``` +Expected: `Built build/app/outputs/flutter-apk/app-release.apk`; as permissões são **exatamente** `INTERNET`, `ACCESS_COARSE_LOCATION`, `ACCESS_FINE_LOCATION` (mais a `DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION` interna do AndroidX, que não conta); `application-label:'SinalACS ACS'`; **sem** `application-debuggable`. Se aparecer qualquer outra permissão (em especial `ACCESS_BACKGROUND_LOCATION` ou `READ_*`/`WRITE_EXTERNAL_STORAGE`), **não siga**: é um plugin puxando permissão contra a decisão §4 — descobrir qual (`./gradlew :app:dependencies`) e registrar no PROGRESS.md antes de decidir. Se o build de release falhar (R8 removendo classe usada por reflexão), é achado real: copie o erro para o ledger e conserte antes de seguir. + +Subir e abrir no emulador, conferindo que não há falha de inicialização: + +```bash +adb -s emulator-5554 install -r build/app/outputs/flutter-apk/app-release.apk +adb -s emulator-5554 logcat -c +adb -s emulator-5554 shell monkey -p br.com.prismrr.sinalacs.acs -c android.intent.category.LAUNCHER 1 >/dev/null +sleep 8 +adb -s emulator-5554 shell pidof br.com.prismrr.sinalacs.acs +adb -s emulator-5554 logcat -d | grep -E "FATAL EXCEPTION|AndroidRuntime" | head -5 +adb -s emulator-5554 uninstall br.com.prismrr.sinalacs.acs +``` +Expected: `pidof` imprime um número (o processo está vivo depois de 8 s); o `grep` não imprime nada; o pacote é removido ao final (o app de release de teste não fica para a próxima rodada). + +- [ ] **Step 6: Commit** + +```bash +git add apps/acs/android/app/src/main/AndroidManifest.xml apps/acs/test/android_manifest_test.dart +git commit -m "fix(acs): manifesto proíbe backup automático e dá nome legível ao app; release medido no emulador" +``` + +--- + +### Task 3: Rajada de 100 visitas offline (M2.4 / RNF02) + +**Files:** +- Create: `apps/acs/test/offline_burst_test.dart` +- Modify: `apps/acs/integration_test/full_journey_e2e.dart` (novo `testWidgets` + imports) + +**Interfaces:** +- Consumes: `OfflineVisitQueue({VisitStore? store, VisitSynchronizer? synchronizer})`, `OfflineVisitRecord({required patientId, required risk, required status, localId, createdAt, ...})`, `InMemoryVisitStore`, `FakeVisitSynchronizer({statusFor, messageFor, throwOnPush})` com `.batches`, `SyncOutcomeKind`, `syntheticPatientId(int)` (`test/support/fakes.dart`); no dispositivo, `BackendClient`, `BackendVisitSynchronizer(backend:)`, `acsCredentialFromRelay()`, `e2ePatient('main')`. +- Produces: — + +- [ ] **Step 1: Escrever o teste hermético (falha por não existir o arquivo; vira vermelho de verdade com o `statusFor`)** + +Criar `apps/acs/test/offline_burst_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/core/services/offline_visit_queue.dart'; + +import 'support/fakes.dart'; + +/// M2.4 / RNF02: "100 registros offline sincronizam em < 5s após rede" e +/// sincronização > 99,5%. Aqui a parte determinística: nada se perde, nada +/// duplica, e a fila sobrevive a um reinício. O tempo contra o servidor de +/// verdade é medido no dispositivo (`integration_test/full_journey_e2e.dart`). +void main() { + OfflineVisitRecord visita(int n) => OfflineVisitRecord( + patientId: syntheticPatientId(n), + risk: 'yellow', + status: 'PENDENTE', + localId: 'burst-$n', + createdAt: DateTime.utc(2026, 10, 2, 8).add(Duration(minutes: n)), + ); + + test('100 visitas enfileiradas, reiniciadas e sincronizadas: nenhuma se perde', () async { + final disco = InMemoryVisitStore(); + final antes = OfflineVisitQueue(store: disco); + for (var n = 0; n < 100; n++) { + await antes.add(visita(n)); + } + expect(antes.pendingCount, 100); + + // Reinício do app: outra fila sobre o mesmo "disco". + final envio = FakeVisitSynchronizer(); + final depois = OfflineVisitQueue(store: disco, synchronizer: envio); + await depois.restore(); + expect(depois.pendingCount, 100, reason: 'o que estava no disco volta inteiro'); + + final resultado = await depois.sync(); + + expect(resultado.kind, SyncOutcomeKind.synced); + expect(depois.pendingCount, 0); + expect(depois.syncedCount, 100); + expect(envio.batches, hasLength(1), reason: 'um lote só: uma ida e volta ao servidor'); + final enviados = envio.batches.single.map((v) => v.localId).toSet(); + expect(enviados, hasLength(100), reason: 'nenhum localId repetido (dedupe do servidor)'); + }); + + test('uma recusada e um conflito no meio não seguram as demais', () async { + final envio = FakeVisitSynchronizer( + statusFor: (v) => switch (v.localId) { + 'burst-40' => 'rejected', + 'burst-70' => 'conflict', + _ => 'synced', + }, + messageFor: (v) => v.localId == 'burst-40' ? 'paciente de outra microárea' : null, + ); + final fila = OfflineVisitQueue(synchronizer: envio); + for (var n = 0; n < 100; n++) { + await fila.add(visita(n)); + } + + await fila.sync(); + + expect(fila.syncedCount, 98); + expect(fila.rejectedCount, 1, reason: 'a recusada fica visível no aparelho até o ACS descartar'); + expect(fila.rejectedVisits.single.localId, 'burst-40'); + expect(fila.rejectedVisits.single.rejectionReason, 'paciente de outra microárea'); + expect(fila.conflictCount, 1, reason: 'o conflito fica registrado'); + // Conflito VOLTA para a fila (`_applyOutcomes`: "precisa de resolução, não de + // descarte"); a recusada, não — ela sai da retentativa e fica só visível. + expect(fila.pendingCount, 1); + expect(fila.pendingVisits.single.localId, 'burst-70'); + expect(fila.pendingVisits.single.status, 'CONFLITO'); + }); + + test('sem rede, as 100 continuam pendentes e a próxima tentativa as leva', () async { + final envio = FakeVisitSynchronizer(throwOnPush: true); + final fila = OfflineVisitQueue(synchronizer: envio); + for (var n = 0; n < 100; n++) { + await fila.add(visita(n)); + } + + final falha = await fila.sync(); + expect(falha.kind, SyncOutcomeKind.error); + expect(fila.pendingCount, 100, reason: 'falha de rede não perde o lote'); + + envio.throwOnPush = false; + await fila.sync(); + expect(fila.pendingCount, 0); + expect(fila.syncedCount, 100); + }); +} +``` + +- [ ] **Step 2: Rodar** + +Run: `cd apps/acs && flutter test test/offline_burst_test.dart 2>&1 | tail -20` +Expected: **PASS 3/3** — é um teste de **caracterização**: o comportamento já existe e este teste o trava (o contrato de conflito e recusa foi lido em `OfflineVisitQueue._applyOutcomes`: `conflict` entra em `_conflicts` **e** volta a `_pending` com status `CONFLITO`; `rejected` vai só para `_rejected`). Se algum falhar, é defeito real da fila (não do teste): pare e use systematic-debugging antes de seguir. Para provar que o teste enxerga uma regressão, trocar temporariamente `'burst-40' => 'rejected'` por `'burst-40' => 'synced'` deve fazer o 2º teste **falhar** (`Expected: <1> Actual: <0>`); desfazer. + +- [ ] **Step 3: Escrever a prova no dispositivo (falha antes de a fixture ser usada)** + +Em `apps/acs/integration_test/full_journey_e2e.dart`, acrescentar os imports + +```dart +import 'package:sinalacs_acs/core/services/backend_visit_synchronizer.dart'; +import 'package:sinalacs_acs/core/services/offline_visit_queue.dart'; +``` + +e, dentro de `main()`, **depois** do `testWidgets` existente (mesmo nível), este teste. Ele usa o `BackendClient` real contra o banco de teste, sem tela: + +```dart + testWidgets('100 visitas offline sobem em menos de 5 s (M2.4)', (tester) async { + final ca = (await rootBundle.load(BackendConfig.rpcCaAsset)).buffer.asUint8List(); + final backend = BackendClient(trustedCaBytes: ca); + addTearDown(backend.close); + final cred = await acsCredentialFromRelay(); + final main = e2ePatient('main'); + await backend.login(matricula: cred.matricula, senha: cred.senha); + + final fila = OfflineVisitQueue(synchronizer: BackendVisitSynchronizer(backend: backend)); + final base = DateTime.now().toUtc().subtract(const Duration(days: 1)); + for (var n = 0; n < 100; n++) { + // `localId` fica no padrão (um UUID novo): o protocolo o tipa como + // `UuidValue` e o servidor recusa o que não for UUID. + await fila.add(OfflineVisitRecord( + patientId: main.id, + risk: 'green', + status: 'PENDENTE', + createdAt: base.add(Duration(minutes: n)), + )); + } + final enviados = {for (final v in fila.pendingVisits) v.localId.toLowerCase()}; + expect(enviados, hasLength(100)); + + final relogio = Stopwatch()..start(); + final resultado = await fila.sync(); + relogio.stop(); + + expect(resultado.kind, SyncOutcomeKind.synced, reason: resultado.message); + expect(fila.syncedCount, 100); + expect(fila.pendingCount, 0); + // ignore: avoid_print + print('rajada: 100 visitas em ${relogio.elapsedMilliseconds} ms'); + expect(relogio.elapsed, lessThan(const Duration(seconds: 5)), + reason: 'PRD M2.4: 100 registros offline sincronizam em < 5 s'); + + final remotas = await backend.pullVisits(since: DateTime.fromMillisecondsSinceEpoch(0)); + final noServidor = {for (final v in remotas) v.localId.toString().toLowerCase()}; + expect(noServidor.containsAll(enviados), isTrue, + reason: 'as 100 visitas estão no servidor, conferidas por pull'); + }); +``` + +(`backend.login` e `pullVisits` são os métodos de `AcsBackend`: `Future login({required String matricula, required String senha})` e `Future> pullVisits({required DateTime since})`; `VisitSyncEntry.localId` é `UuidValue`, por isso o `toString()`.) + +- [ ] **Step 4: Rodar no emulador contra o banco de teste** + +```bash +export PATH="$PATH:$HOME/Android/Sdk/platform-tools:$HOME/flutter/bin" +adb -s emulator-5554 shell pm list packages br.com.prismrr.sinalacs.acs # vazio, senão pare +./scripts/qa/acs_full_e2e.sh 2>&1 | tee /tmp/acs_full_rajada.log | tail -12 +docker compose up -d # o script derruba a stack de desenvolvimento +grep "rajada:" /tmp/acs_full_rajada.log +``` +Expected: `OK — jornada completa do ACS contra o banco de teste`; linha `rajada: 100 visitas em NNN ms` com NNN < 5000. Se o servidor recusar o lote (corpo grande, transação longa) ou passar de 5 s: **não relaxe o limite**. Meça onde o tempo vai (`docker compose logs backend`), e só então decida entre fatiar em lotes de 50 no `BackendVisitSynchronizer` (com um teste hermético novo que prove que o resultado final é idêntico) e registrar o gargalo no PROGRESS.md. Registrar a decisão como `Ruling:` no ledger. + +- [ ] **Step 5: Commit** + +```bash +git add apps/acs/test/offline_burst_test.dart apps/acs/integration_test/full_journey_e2e.dart +git commit -m "test(acs): rajada de 100 visitas offline provada na fila e contra o servidor real no emulador" +``` + +--- + +### Task 4: Persistência do tema testada e `ui_design.md` corrigido + +**Files:** +- Create: `apps/acs/test/theme_controller_test.dart` +- Modify: `spec/ui_design.md:14` +- Modify: `spec/ux_ui_test_plan.md:170` + +**Interfaces:** +- Consumes: `ThemeController([ThemeMode value])`, `.restore()`, `.setThemeMode(ThemeMode)`, chave `theme_mode` em `SharedPreferences`. +- Produces: — + +- [ ] **Step 1: Escrever os testes** + +Criar `apps/acs/test/theme_controller_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sinalacs_acs/core/services/theme_controller.dart'; + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + test('sem nada gravado, abre em Automático', () async { + final controller = ThemeController(); + await controller.restore(); + expect(controller.value, ThemeMode.system); + }); + + test('a escolha sobrevive a um reinício', () async { + final primeira = ThemeController(); + await primeira.setThemeMode(ThemeMode.light); + + final depoisDoReinicio = ThemeController(); + await depoisDoReinicio.restore(); + expect(depoisDoReinicio.value, ThemeMode.light); + }); + + test('valor inválido na preferência (arquivo corrompido ou de versão futura) cai em Automático', () async { + SharedPreferences.setMockInitialValues({'theme_mode': 'sepia'}); + final controller = ThemeController(ThemeMode.dark); + await controller.restore(); + expect(controller.value, ThemeMode.system); + }); + + test('o ouvinte é avisado ao trocar', () async { + final controller = ThemeController(); + var avisos = 0; + controller.addListener(() => avisos++); + await controller.setThemeMode(ThemeMode.dark); + expect(avisos, 1); + expect(controller.value, ThemeMode.dark); + }); +} +``` + +- [ ] **Step 2: Rodar** + +Run: `cd apps/acs && flutter test test/theme_controller_test.dart 2>&1 | tail -10` +Expected: **PASS 4/4** (caracterização de comportamento já existente). Para provar que o 3º teste enxerga uma regressão, trocar temporariamente `orElse: () => ThemeMode.system` em `theme_controller.dart` por `orElse: () => ThemeMode.dark` e rodar: o teste **falha** (`Expected: ThemeMode: Actual: ThemeMode:`); **desfazer** a troca (`git diff apps/acs/lib` deve sair vazio). + +- [ ] **Step 3: Corrigir a documentação** + +`spec/ui_design.md`, linha 14, trocar o parágrafo do "Dark Mode (§1.2)" por (preservar o marcador `* **Dark Mode (§1.2):**`): + +``` +* **Tema (§1.2):** O escuro é o padrão de identidade visual (economia de bateria e menos fadiga em campo), mas **não é fixo**: o ACS e o paciente têm `themeMode` com `darkTheme` e uma tela "Preferências" com Claro, Escuro e Automático (segue o sistema), persistida localmente (`ThemeController`). Os dois temas passam na mesma matriz de contraste WCAG (`contrast_tokens_test.dart` e `contrast_tokens_light_test.dart`). Esta linha dizia que o tema era "fixo e permanente"; deixou de valer com o commit `45ae667`. +``` + +`spec/ux_ui_test_plan.md`, linha 170, trocar `**[Resolvido] §1.2 (Dark Mode):** Documentado formalmente como fixo/permanente no documento de design.` por: + +``` +* **[Resolvido, revisto em 2026-10-02] §1.2 (Dark Mode):** A decisão de "fixo/permanente" foi superada: o app passou a oferecer Claro/Escuro/Automático (`45ae667`). O documento de design foi atualizado; o teste de regressão é `theme_controller_test.dart` mais as duas matrizes de contraste. +``` +(manter o marcador `*` que a linha já tem no início.) + +- [ ] **Step 4: Conferir links e testes** + +Run: `./scripts/qa/check_documentation_links.sh && (cd apps/acs && flutter test 2>&1 | tail -2)` +Expected: sem erro de link; `All tests passed!` com **≥ 218** testes (208 + 3 + 3 + 4). + +- [ ] **Step 5: Commit** + +```bash +git add apps/acs/test/theme_controller_test.dart spec/ui_design.md spec/ux_ui_test_plan.md +git commit -m "test(acs): persistência do tema coberta; ui_design deixa de dizer que o tema é fixo" +``` + +--- + +### Task 5: Registro e barra final no emulador + +**Files:** +- Modify: `PROGRESS.md` (seção "Finalização do app ACS (2026-10-01)") +- Modify: `apps/CLAUDE.md` (parágrafo "ACS: permissões, SAMU e e2e") + +**Interfaces:** nenhuma. + +- [ ] **Step 1: Registrar no `PROGRESS.md`** + +Depois do item "**Minors da revisão — fechados (2026-10-01).**" acrescentar: + +``` +- **Revisão do app ACS e fechamento (2026-10-02).** Plano: `docs/superpowers/plans/2026-10-02-revisao-e-fechamento-do-app-acs.md`. (1) **Fonte ampliada (RNF05/WCAG 1.4.4):** o ACS estourava o layout a 130% e 200% (cabeçalho, `_InfoRow`, botão do pull, mais o que o harness achou nas outras abas: ); corrigido, com `test/support/layout_harness.dart` e `test/text_scale_test.dart`. (2) **Manifesto:** `android:allowBackup="false"` (o padrão era `true`: o backup automático podia levar o banco local e as preferências para a nuvem) e nome legível; o APK de release foi medido (`aapt2 dump permissions`: só INTERNET e localização em primeiro plano) e subiu no emulador sem `FATAL`. (3) **M2.4/RNF02:** 100 visitas offline provadas na fila (`offline_burst_test.dart`) e contra o servidor real no emulador ( ms, meta < 5000). (4) **Tema:** `ThemeController` coberto e `ui_design.md` corrigido. **Continua aberto:** assinatura de release com chave própria, `FLAG_SECURE` (decisão de produto/LGPD: a lista da microárea mostra nome e condições crônicas), RF08 persistido, MFA/refresh token, iOS, botão da UBS. +``` +(Substituir os dois marcadores `<...>` pelos valores reais medidos; não deixar os `<>` no arquivo.) + +- [ ] **Step 2: Registrar no `apps/CLAUDE.md`** + +No parágrafo "**ACS: permissões, SAMU e e2e (2026-10-01).**", acrescentar ao final: "O manifesto do ACS tem `android:allowBackup="false"` (guardado por `android_manifest_test.dart`). `test/text_scale_test.dart` percorre as quatro abas e os cinco itens de "Mais" a 130% e 200% de fonte (`test/support/layout_harness.dart`; `pumpAndSettle` nunca assenta na aba Área, use `assentar`). `test/offline_burst_test.dart` e o segundo teste de `integration_test/full_journey_e2e.dart` guardam a meta de 100 visitas offline em menos de 5 s." + +- [ ] **Step 3: Barra final** + +```bash +export PATH="$PATH:$HOME/Android/Sdk/platform-tools:$HOME/flutter/bin" +(cd apps/acs && flutter analyze && flutter test 2>&1 | tail -2) +(cd scripts/qa && python3 contagem_validation_report_test.py && python3 otp_relay_test.py) +python3 scripts/qa/contagem_validation_report.py +./scripts/qa/lib_rele_test.sh && ./scripts/qa/acs_gps_e2e_test.sh +./scripts/qa/ci_invariants.sh && ./scripts/qa/check_documentation_links.sh +adb -s emulator-5554 shell pm list packages br.com.prismrr.sinalacs.acs # vazio +./scripts/qa/acs_gps_e2e.sh && ./scripts/qa/acs_gps_e2e.sh --sem-permissao +./scripts/qa/acs_full_e2e.sh; docker compose up -d +``` +Expected: `No issues found!`; `flutter test` **≥ 218** passam; Python `OK`; `ok: contagem do validation_report confere`; `ok: lib_rele` e `ok: acs_gps_e2e`; `ci_invariants` `ok`; links sem erro; os dois GPS `OK` (sem `--reinstalar`: o pacote tem de estar ausente); jornada `OK` com a linha `rajada:`; `sinalacs-serverpod … (healthy)` e `ls .e2e/fixtures.json` → inexistente. + +- [ ] **Step 4: Estado do repositório e commit** + +```bash +git status --short && git log --oneline -8 && git log -8 --format=%B | grep -ci "co-authored\|generated with" +git add PROGRESS.md apps/CLAUDE.md +git commit -m "docs: registra a revisão e o fechamento do app ACS" +``` +Expected: árvore limpa depois do commit; **`0`** na contagem de atribuições de IA; sem push. + +--- + +## Auto-revisão + +**Cobertura:** achado 1 → Task 1; achado 2 → Task 2; achado 3 → Task 3; achado 4 → Task 4; registro e barra no emulador → Task 5. Os itens "Fora deste plano" têm o motivo escrito e nenhum vira tarefa escondida. + +**Placeholders:** não há "TBD". Dois pontos dependem de medir e têm as duas saídas escritas: os pontos de estouro além dos três conhecidos (Task 1, Step 5: regra de correção, proibição de `withClampedTextScaling`) e o tempo da rajada contra o servidor (Task 3, Step 4: não relaxar o limite; medir; fatiar ou registrar). Os dois `<>` do PROGRESS.md têm instrução explícita de substituição. + +**Consistência de nomes:** `assentar`, `esperarSemEstouro`, `abrirPainel`, `percorrerPainelInteiro`, `redimensionar` definidos na Task 1 e usados só nela; `acsHeaderHeight` definido e usado nos dois chamadores de `_Header`; `syntheticPatientId`, `FakeVisitSynchronizer.batches/throwOnPush/statusFor/messageFor` batem com `test/support/fakes.dart`; `backend.login(matricula:, senha:)` e `pullVisits(since:)` batem com `AcsBackend`. + +**Review Focus:** as 5 linhas têm teste dono (1 e 2 na Task 1; 3 na Task 2; 4 e a rajada na Task 3; 5 na Task 4). + +**Riscos que só a execução resolve:** (1) quantos pontos de estouro existem fora da aba Área (a sonda parou ali); (2) se o release com R8 compila e abre; (3) se `visits.sync` aguenta 100 entradas em um lote dentro de 5 s no banco de teste; (4) se o `map_flow_test` roda sem a stack (Task 1, Step 7). + +**Conferido na escrita:** o contrato de conflito/recusa (`_applyOutcomes`: conflito volta a `_pending`, recusa não) e o tipo de `localId` (`UuidValue` no protocolo) foram lidos no código e corrigidos no plano antes de entregar. diff --git a/docs/superpowers/plans/2026-10-03-fila-de-visitas-por-dono-e-corrida-do-login.md b/docs/superpowers/plans/2026-10-03-fila-de-visitas-por-dono-e-corrida-do-login.md new file mode 100644 index 00000000..730c4bc2 --- /dev/null +++ b/docs/superpowers/plans/2026-10-03-fila-de-visitas-por-dono-e-corrida-do-login.md @@ -0,0 +1,669 @@ +# Fila de visitas por dono e fechamento da corrida do login — Plano de Implementação + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** (A) Uma renovação de sessão do usuário anterior nunca sobrescreve um login novo, nem na janela da gravação no Keystore. (B) A fila de visitas offline, o cursor de `visits.pull` e as visitas recusadas pertencem a um ACS: o ACS seguinte não as vê, não as envia, não as atribui a si nem as descarta. (C) Nenhum trabalho de campo fica preso num aparelho: visitas de um ACS que saiu sobem ao servidor **com a autoria dele**, sem depender da sessão ativa; visitas legadas sem dono sobem como "autoria desconhecida (migração v7)"; e o aparelho só é limpo (wipe) depois que tudo subiu. + +**Architecture:** (A) Seção crítica serial (`_exclusive`) em `BackendClient` para tudo que muda `_session`, o token do Keystore e a época. (B) `offline_visits.owner` (schema v7, aditiva) com `VisitStorage.forOwner(...)`; linhas **legadas ficam em quarentena** (`owner IS NULL`, invisíveis para qualquer dono, acessíveis só por um `LegacyVisitStore`); cursor do pull por `userId|microAreaId`; fila + pull resolvidos por sessão. (C) Backend: `visits.syncLegacy` (grava com `authorship = legacyUnclaimed`, sem `acsId`, com `originDeviceId`) e um **token de envio diferido** por ACS (`acs_upload_tokens`: opaco, só hash no banco, amarrado a `userId`+`deviceId`, escopo único `visits.syncDeferred`, 7 dias) que permite subir as visitas de A depois de A sair, com a autoria correta. App: `DeferredFlushService` (legado + cada dono com token), disparado no login, na retomada, quando a conexão volta e antes do "Sair"; "Sair" tenta subir antes de sair; "Limpar este aparelho" (wipe) só habilita com tudo enviado. + +**Tech Stack:** Flutter/Dart (`apps/acs`, `connectivity_plus` já presente), `sqflite_sqlcipher` + ffi (testes), `flutter_secure_storage`; Serverpod 3.4.13 (`backend/sinalacs_server`) com migração. **Nenhuma dependência nova** (ver D8: `workmanager` fica fora). + +**Spec:** `PROGRESS.md` seção "Refresh token e desbloqueio biométrico do ACS (2026-10-03)" — itens abertos **(g)** (fila sem dono, com o caminho de perda por `discardRejected()`) e a corrida residual do `login()`; `spec/lgpd_design.md` §5.11 (cache por dono) e LGPD-RT06; `spec/lgpd_data_audit.md` (acrescentar `acs_upload_tokens` e as colunas novas de `visits`); `CLAUDE.md` (INV-01 território; INV-04; "preserve retry/queue/conflict semantics"; "red alerts never silently dropped"). + +## Global Constraints + +- Texto de UI, comentários e docs em **português**; nomes de código seguem o arquivo vizinho. +- INV-01/RNF06: o ACS só vê e só envia dado do próprio território. Visita de outro dono **nunca** é carregada, enviada, contada, exibida nem descartável por quem não é o dono. +- Offline-first (CLAUDE.md): preservar as semânticas de retentativa, fila e conflito da `OfflineVisitQueue`/`SyncFsm`. **Nenhuma** linha de outro dono é apagada ou alterada por `save()` — `save()` de B nunca toca nas linhas de A. +- INV-04: o banco local **já é SQLCipher** (AES-256, chave no Keystore); a coluna nova fica no mesmo banco. A retenção de dado de terceiros no aparelho continua sendo risco de LGPD (minimização), e é por isso que o plano o fecha por envio + wipe, não por criptografia. Nenhum dado real de paciente em teste (UUIDs sintéticos). +- **Autoria é dado de auditoria:** uma visita nunca é gravada no servidor em nome de um ACS que não a fez. Quem não pode provar a autoria grava `authorship = legacyUnclaimed` e `acsId` nulo. Quem *transporta* o envio (sessão que subiu o lote legado) fica só em `audit_logs`, nunca como autor. +- Backend: o envio por token diferido só aceita visitas de pacientes da microárea do dono do token **relida do banco**; ACS inativo ou sem microárea é recusado (INV-01). +- Tokens (`uploadToken`) seguem as regras do refresh token: só o hash no banco, só no Keystore no aparelho, nunca em log nem em mensagem de erro, uma única mensagem de recusa. +- A migração v6→v7 é **aditiva** (`ALTER TABLE ... ADD COLUMN`) e preserva as linhas existentes; migrações do backend só por `serverpod generate` + `serverpod create-migration` (nunca à mão). +- Nenhum token, senha ou conteúdo de visita em log. +- Commits **sem** "Co-Authored-By" / "Generated with Claude Code" (regra do `CLAUDE.md`). Um commit por tarefa. Não mexer em `.github/workflows/ci.yml`. +- `tool/live_check.dart` roda na VM Dart: não importar `flutter_secure_storage` em `backend_client.dart` nem em arquivos que ele importa (ver `secure_session_token_store.dart`). +- Testes: `cd apps/acs && flutter analyze && flutter test`; backend: `cd backend/sinalacs_server && dart test` (integração precisa de `docker compose --profile test up -d postgres-test`); o emulador `emulator-5554` é usado na Task 9. + +## Decisões (D1–D9) + +| # | Decisão | Por quê | +|---|---|---| +| D1 | Dono da fila = `userId` do ACS. Dono do cursor do pull = `userId\|microAreaId` (mesma chave do cache da microárea). | A visita pertence ao ACS; o pull é por território e um cursor global pularia visitas antigas do território de B (perda silenciosa que a decisão §5.4 proíbe). | +| D2 | **(revisada)** Linhas **legadas** (`owner IS NULL`) ficam em **quarentena**: nenhum dono as carrega, conta, envia ou descarta. Só o `LegacyVisitStore` as enxerga, e o único destino é o envio `visits.syncLegacy` (D4). | Adotá-las pelo primeiro ACS corromperia a autoria do dado clínico (relatórios de produtividade, auditoria e-SUS APS). | +| D3 | O cursor legado (`visits_pull`) é abandonado; cada dono começa do `epoch`. | Idempotente (o servidor deduplica por `localId`). | +| D4 | **(nova)** Envio de legado: `visits.syncLegacy` exige uma sessão de ACS territorializado **apenas como transporte**; cada visita é validada contra a microárea do paciente × microárea de quem transporta (INV-01); grava `authorship = legacyUnclaimed`, `acsId = null`, `originDeviceId = deviceId`; auditoria `visit_legacy_sync` com o transportador. Visita de outra microárea volta `rejected` e **continua em quarentena**. | Salva o trabalho de campo sem inventar autor. O pedido original citava "Device Token / Client Credentials": **não existe** credencial de aparelho no backend, e uma credencial genérica de aparelho não prova autoria — por isso o transporte usa uma sessão real e a autoria fica "desconhecida". | +| D5 | O resolvedor de fila/pull por sessão é injetável; testes que já injetam `visitQueue`/`visitPullService` seguem como *seam*. | Mantém ~90 referências de teste. | +| D6 | A corrida do `login()` é fechada por **exclusão mútua local**, não por mais um incremento de época. | O incremento extra deixaria possível o token de A no Keystore com a sessão de B. | +| D7 | **(nova)** Envio desvinculado da sessão = **token de envio diferido** por ACS (`acs_upload_tokens`), emitido no `loginInstitutional` junto do refresh token (também só com `deviceId` real), de escopo único (`visits.syncDeferred` e `visits.revokeUploadToken`), validade de **7 dias**, hash SHA-256 no banco, guardado no Keystore **por dono** (`acs_upload_token\|`). **Sobrevive ao "Sair"** enquanto houver visita pendente desse dono; é revogado no servidor e apagado do aparelho quando a fila do dono zera. | Permite subir as visitas de A depois de A sair, **com a autoria de A** e sem que A tenha sessão, sem o poder de leitura nem de qualquer outra chamada. Substitui a "Client Credentials" do pedido, que daria a qualquer portador autoria sobre qualquer ACS. | +| D8 | **(nova)** "Worker em background": nesta entrega o envio é disparado em primeiro plano por login, retomada, `connectivity_plus` (conexão voltou) e antes do "Sair". **Android WorkManager (`workmanager`) fica fora**: é dependência nova fora de `spec/stack.md` (exige decisão), roda em isolate sem a UI e sem prova no emulador neste plano. | YAGNI + regra do repositório de não introduzir pacote fora da stack sem decidir. Está registrado como pendência com a razão. | +| D9 | **(nova)** "Limpar este aparelho" (wipe): ACS autenticado, só habilitado quando `DeferredFlushService.flushAll()` termina com **zero** visitas restantes (todos os donos + legado) ou quando o resto é só visita descartada. O wipe do pedido era acionado por **Supervisor**: **não existe login de supervisor/coordenador** (os papéis `coordinator`/`admin` não têm caminho de emissão — `CLAUDE.md`). | Sem supervisor, o wipe seguro só pode ser o que é provadamente sem perda. Quando algo não sobe (token vencido, conta inativa), o wipe fica **bloqueado** e a pendência "wipe forçado por supervisor" é registrada. | + +## Review Focus + +1. **Atualização com linhas legadas:** aparelho com visitas pendentes e recusadas no schema v6. Esperado: nenhuma se perde, **nenhum ACS as vê**, e elas sobem como "autoria desconhecida" (Tasks 2 e 5). +2. **ACS A sai, ACS B entra no mesmo aparelho:** B vê 0 pendentes/0 recusadas; `sync()` de B não envia nada de A; `discardRejected()` de B não apaga a recusada de A; A volta e vê tudo (Task 4). +3. **`save()` de B nunca apaga linhas de A**, nem com erro no meio da transação (Task 2, leitura direta do arquivo). +4. **Cursor do pull de A não vale para B**; o dedupe de `localId` do pull enxerga só a fila do dono (Task 3). +5. **Renovação iniciada na janela da gravação do login:** espera, lê o token **novo** e não toca na sessão de B (Task 1). +6. **Visita legada de OUTRA microárea:** o transportador não pode gravar visita de território alheio; ela volta `rejected` e continua em quarentena (Task 5). +7. **Token de envio roubado/vencido/revogado ou de conta inativa:** recusa única, sem gravar nada; token de A **nunca** grava visita de paciente fora da microárea de A, e nunca sob a autoria de outro ACS (Task 6). +8. **Envio do lote de A em voo quando B entra / app morto no meio do envio:** a visita só sai do aparelho depois do 200 do servidor; reenviar o mesmo `localId` é idempotente (Task 7). +9. **Wipe com visita não enviada:** bloqueado, nunca apaga dado pendente (Task 8). + +--- + +## Parte A — Corrida do `login()` + +### Task 1: Seção crítica para login, renovação e logout + +**Files:** +- Modify: `apps/acs/lib/core/network/backend_client.dart` (`login` ~318, `developmentLogin` ~560, `_doRenew` ~395-440, `logout` ~520) +- Modify: `apps/acs/test/support/fake_rpc_server.dart` (token de login distinto por login) +- Test: `apps/acs/test/refresh_session_test.dart` (novo grupo) e, se preferir, `apps/acs/test/support/gated_session_token_store.dart` (novo) + +**Interfaces:** +- Consumes: `SessionTokenStore` (`read/write/clear`), `MemorySessionTokenStore`, `_invalidateRenewal()`, `_epoch`, `_storeLost`, `_staleRenewal`. +- Produces: `Future _exclusive(Future Function() body)` (privado, **não reentrante**); `GatedSessionTokenStore(MemorySessionTokenStore inner)` com `Completer? writeGate` (teste). + +**Raciocínio (leia antes de codar).** Hoje `login()` faz `_invalidateRenewal()` → `await _tokenStore.write(...)` → `_session = session`. Uma renovação que **começa** entre o incremento e o `_session = ...` captura a época nova, lê o token **antigo** do Keystore, e termina "não obsoleta": grava o filho da família antiga por cima do login e/ou sobrescreve `_session`. A correção é que (1) o login bumpa, grava e troca a sessão **dentro** de uma seção crítica; (2) a renovação lê o token e captura a época **dentro** da mesma seção, e confirma (checa época, grava filho, troca sessão) **dentro** dela; (3) a rede (refresh, logout no servidor) fica **fora** da seção. Regra de ouro: dentro de `_exclusive` só há E/S local (Keystore) e atribuições — nunca rede, nunca `renewSession()`, nunca outro `_exclusive`. + +- [ ] **Step 1: Estender o fake e criar o armazenamento que segura a escrita** + +`fake_rpc_server.dart`: o login passa a devolver `login-` (n = nº do login, começando em 0) em vez de `refresh-0` fixo; a primeira renovação continua devolvendo o próximo de `_refreshSeq` — **não** quebre `refresh_session_test.dart` (`refreshTokensSeen == ['refresh-0','refresh-1']`): se esses testes dependem do nome `refresh-0` para o primeiro login, faça o *primeiro* login devolver `refresh-0` e os seguintes `login-`. + +`test/support/gated_session_token_store.dart`: + +```dart +import 'dart:async'; + +import 'package:sinalacs_acs/core/security/session_token_store.dart'; + +/// Memória com uma comporta na escrita: enquanto [writeGate] não completa, o +/// `write` fica pendente. Serve para parar o login exatamente na janela entre +/// o incremento da época e a troca da sessão. +class GatedSessionTokenStore implements SessionTokenStore { + GatedSessionTokenStore([String? token]) : _inner = MemorySessionTokenStore(token); + + final MemorySessionTokenStore _inner; + Completer? writeGate; + int writes = 0; + + @override + Future read() => _inner.read(); + + @override + Future write(String token) async { + writes++; + final gate = writeGate; + if (gate != null) await gate.future; + await _inner.write(token); + } + + @override + Future clear() => _inner.clear(); +} +``` + +- [ ] **Step 2: Escrever os testes que falham** (em `refresh_session_test.dart`, grupo `corrida do login (janela da gravação)`; reaproveite o `setUp` do arquivo para `server`/`backend`, mas construa o `BackendClient` com `tokenStore: gated`): + +```dart +test('renovação iniciada com o login parado na gravação ESPERA e usa o token NOVO', () async { + final gated = GatedSessionTokenStore(); + final backend = BackendClient(host: server.host, tokenStore: gated, deviceIds: MemoryDeviceIdStore('aparelho-1')); + await backend.login(matricula: 'ACS-A', senha: 'senha-sintetica'); // token do login #0, gravado + server.tokenLifetime = const Duration(minutes: 15); + + gated.writeGate = Completer(); + final login = backend.login(matricula: 'ACS-B', senha: 'senha-sintetica'); // para na escrita + await pumpEventQueue(); + expect(gated.writes, 2, reason: 'o login B chegou à escrita e está parado nela'); + + final renovacao = backend.renewSession(); // começa DENTRO da janela + await pumpEventQueue(); + expect(server.refreshTokensSeen, isEmpty, reason: 'a renovação não pode ler o token antes de o login confirmar'); + + gated.writeGate!.complete(); + await login; + await renovacao; + + // Leu o token do login de B, não o de A: + expect(server.refreshTokensSeen, [server.loginTokens.last]); + expect(await gated.read(), isNot(server.loginTokens.first)); + expect(backend.session!.userId, isNotNull); +}); + +test('renovação que começou ANTES do login e termina DEPOIS da gravação continua obsoleta', () async { + // refreshDelay longo; login de B no meio; ao terminar, token e sessão são os de B + // e onSessionExpired não dispara (comportamento já coberto — não pode regredir). +}); + +test('logout: renovação iniciada durante a rede do logout não ressuscita a sessão', () async { + // logoutDelay + refreshDelay; ao final store vazio, sessão nula, sem onSessionExpired. +}); +``` + +(`server.loginTokens` é uma lista que você acrescenta ao fake com os `refreshToken` emitidos nos logins. Complete o segundo e o terceiro teste seguindo `refresh_session_test.dart` existente — eles já têm versões; aqui o objetivo é que **continuem verdes** com a seção crítica.) + +- [ ] **Step 3: Rodar e ver falhar** + +Run (em `apps/acs`): `flutter test test/refresh_session_test.dart --plain-name "corrida do login"` +Expected: FAIL no primeiro teste (a renovação lê `login-0`/`refresh-0` antes do login confirmar). + +- [ ] **Step 4: Implementar** + +Em `BackendClient`: + +```dart +/// Cauda da fila de seções críticas. Sempre completa sem erro. +Future _commitTail = Future.value(); + +/// Seção crítica **serial e não reentrante**: só uma por vez mexe em +/// `_session`, no token do Keystore e em `_epoch`. Dentro dela: apenas E/S +/// local e atribuições. Nunca rede, nunca `renewSession()`, nunca outro +/// `_exclusive` (deadlock). Fecha a corrida em que uma renovação do usuário +/// anterior, iniciada entre o incremento da época do login e a troca da +/// sessão, lia o token antigo e sobrescrevia o login novo. +Future _exclusive(Future Function() body) { + final done = Completer(); + final previous = _commitTail; + _commitTail = done.future; + return previous.then((_) => body()).whenComplete(done.complete); +} +``` + +Mudanças: +1. `login()`: depois do `AuthSession.tryParse` bem-sucedido, **toda** a parte final vira + ```dart + await _exclusive(() async { + _invalidateRenewal(); + try { + if (refreshToken == null || refreshToken.isEmpty) { await _tokenStore.clear(); } + else { await _tokenStore.write(refreshToken); } + } catch (_) { throw _storeLost; } + _session = session; + }); + return session; + ``` + (preserve exatamente a ordem e as mensagens de hoje; só muda que está dentro da seção.) Faça o mesmo em `developmentLogin`. +2. `_doRenew`: o **início** vira + ```dart + late final int epoch; + late final String? token; + await _exclusive(() async { + epoch = _epoch; + token = await _storage(_tokenStore.read); + }); + bool stale() => epoch != _epoch; + ``` + e cada ponto que **muda estado** (limpar o token na recusa do servidor, gravar o filho, `_expire(...)`, `_session = session`) é executado **dentro** de `_exclusive`, começando por `if (stale()) throw _staleRenewal;`. Dentro da seção `stale()` é definitivo (ninguém interleava). A leitura do `deviceId` e a chamada `refreshSession` ficam fora (rede). +3. `logout()`: a primeira metade (`_invalidateRenewal(); _session = null; token = await _tokenStore.read()`) vai numa seção; a chamada de rede `auth.logout` fica fora; a segunda metade (`_session = null; _invalidateRenewal(); await _tokenStore.clear()`) vai noutra seção. +4. `_invalidateRenewal()` continua sendo só `_epoch++; _renewal = null;` e só pode ser chamado dentro de `_exclusive`. +5. Atualize os comentários que citam "ANTES/DEPOIS da chamada de rede" para explicar a seção crítica. + +- [ ] **Step 5: Rodar e ver passar** + +Run: `flutter test test/refresh_session_test.dart test/session_expiry_mfa_test.dart test/backend_client_test.dart test/session_reauth_test.dart test/session_resume_test.dart && flutter analyze` +Expected: PASS (incluindo todos os testes de época/obsolescência que já existiam). + +- [ ] **Step 6: Commit** + +```bash +git add apps/acs/lib/core/network/backend_client.dart apps/acs/test +git commit -m "fix(acs): login, renovação e logout em seção crítica; renovação não lê token antigo na janela do login" +``` + +--- + +## Parte B — Fila de visitas por dono + +### Task 2: Schema v7 e armazenamento SQLCipher por dono + +**Files:** +- Modify: `apps/acs/lib/core/database/encrypted_database.dart` (`schemaVersion = 7`, `createOfflineVisits`, `_upgrade`) +- Modify: `apps/acs/lib/core/database/sqlcipher_visit_store.dart` +- Modify: `apps/acs/lib/core/services/offline_visit_queue.dart` (`VisitStorage`, `InMemoryVisitStorage`) +- Modify (assinatura): `apps/acs/test/encrypted_database_test.dart`, `apps/acs/integration_test/{smoke_test,red_alert_cycle_test,encrypted_storage_test}.dart` (passam `owner`) +- Test: `apps/acs/test/encrypted_database_test.dart`, `apps/acs/test/visit_storage_owner_test.dart` (novo) + +**Interfaces:** +- Produces: + ```dart + /// Fábrica de armazenamento por dono. `VisitStore` continua sendo load/save, mas + /// agora SEMPRE de um único dono. + abstract interface class VisitStorage { + VisitStore forOwner(String ownerId); + } + class InMemoryVisitStorage implements VisitStorage { /* um InMemoryVisitStore por dono */ } + + /// Abre (e recupera) o banco; uma só instância por processo. + class VisitDatabase { + VisitDatabase({required DatabaseKeyStore keyStore, String databaseName = 'sinalacs_acs.db', + bool allowUnencryptedForTesting = false}); + Future open(); + Future close(); + } + class SqlCipherVisitStorage implements VisitStorage { + SqlCipherVisitStorage({required DatabaseKeyStore keyStore, String databaseName = 'sinalacs_acs.db', + bool allowUnencryptedForTesting = false}); + VisitDatabase get database; + @override VisitStore forOwner(String ownerId); + Future close(); + } + /// Quarentena (D2): as linhas com `owner IS NULL`. Nenhum dono as enxerga. + abstract interface class LegacyVisitStore { + Future> load(); + /// Remove só estes `localId`s legados (depois do 200 do servidor). + Future remove(Iterable localIds); + } + // VisitStorage ganha: LegacyVisitStore get legacy; (SqlCipherVisitStorage e InMemoryVisitStorage) + class SqlCipherVisitStore implements VisitStore { // visão de UM dono + SqlCipherVisitStore({required DatabaseKeyStore keyStore, required String owner, + String databaseName = 'sinalacs_acs.db', bool allowUnencryptedForTesting = false}); + SqlCipherVisitStore.on(VisitDatabase database, {required String owner}); + Future close(); + } + ``` +- Contrato de dados: `offline_visits.owner TEXT` (nulo = legado em quarentena, D2). `load()` = `SELECT ... WHERE owner = ?` — **nunca** `owner IS NULL`; não existe adoção. `save(visits)` = em transação, `DELETE FROM offline_visits WHERE owner = ?` + `INSERT` com `owner`. Nenhuma outra linha é tocada. + +- [ ] **Step 1: Escrever os testes que falham** — `visit_storage_owner_test.dart`, com `SqlCipherVisitStorage(keyStore: MemoryDatabaseKeyStore(), allowUnencryptedForTesting: true)` (use o fake de `DatabaseKeyStore` que os testes de `encrypted_database_test.dart` já usam) e banco ffi em diretório temporário como eles fazem: + +```dart +test('cada dono vê só as próprias visitas', () async { + final a = storage.forOwner('acs-a'), b = storage.forOwner('acs-b'); + await a.save([_visita('local-a1'), _visita('local-a2', rejeitada: true)]); + expect(await b.load(), isEmpty); + expect((await a.load()).map((v) => v.localId), ['local-a1', 'local-a2']); +}); + +test('save de B nunca apaga nem altera as linhas de A', () async { + await a.save([_visita('local-a1')]); + await b.save([_visita('local-b1')]); + await b.save([]); // B esvazia a fila dele + expect((await a.load()).map((v) => v.localId), ['local-a1']); + // prova no arquivo, sem passar pela visão: conta direta por dono + final db = await storage.database.open(); + expect(Sqflite.firstIntValue(await db.rawQuery("SELECT COUNT(*) FROM offline_visits WHERE owner='acs-a'")), 1); +}); + +test('migração v6 → v7 preserva as linhas em QUARENTENA: nenhum dono as vê', () async { + // cria um banco v6 com 2 linhas SEM coluna owner (como encrypted_database_test.dart já faz + // para v3/v4/v5), abre via SqlCipherVisitStorage e: + expect(await storage.forOwner('acs-a').load(), isEmpty); + expect(await storage.forOwner('acs-b').load(), isEmpty); + expect((await storage.legacy.load()).map((v) => v.localId), ['local-v6-1', 'local-v6-2']); + // um save() de A não toca na quarentena: + await storage.forOwner('acs-a').save([_visita('local-a1')]); + expect((await storage.legacy.load()).length, 2); +}); + +test('legacy.remove apaga só os localIds pedidos e só os legados', () async { /* linhas de dono com o mesmo localId NÃO são apagadas */ }); + +test('local_id é único globalmente: B não consegue sobrescrever a visita de A', () async { /* mesma local_id em dois donos → o segundo save lança ou preserva a de A; documente o comportamento escolhido e teste-o */ }); +``` + +e em `encrypted_database_test.dart`: `schemaVersion == 7`, a coluna `owner` existe após a criação limpa e após o upgrade. + +- [ ] **Step 2: Rodar e ver falhar** — `flutter test test/visit_storage_owner_test.dart test/encrypted_database_test.dart` → FAIL (símbolos novos não existem). + +- [ ] **Step 3: Implementar** + - `encrypted_database.dart`: `schemaVersion = 7`; atualizar o comentário de versões (v7: `offline_visits.owner`, dono da visita; nulo = legado em quarentena, enviado por `visits.syncLegacy`); `createOfflineVisits` ganha `owner TEXT`; em `_upgrade`: `if (from < 7) { cols = PRAGMA table_info; if (!hasOwner) ALTER TABLE offline_visits ADD COLUMN owner TEXT; }` (e `CREATE INDEX IF NOT EXISTS offline_visits_owner_idx ON offline_visits(owner)`; coloque o índice também no caminho de criação). Lembre que `from < 2` recria a tabela e retorna: use `createOfflineVisits` já com `owner`. + - `VisitDatabase`: mover para cá a lógica de `_open()` do `SqlCipherVisitStore` atual (inclusive a recuperação "chave não abre o arquivo → apaga e recria"), sem alterar o comportamento. + - `SqlCipherVisitStore` vira visão de dono (`load`/`save` do contrato acima; **sem** `UPDATE` de adoção). O construtor com `keyStore` cria seu próprio `VisitDatabase` (mantém os testes de integração com poucas mudanças: só acrescentam `owner:`). + - `VisitStorage`/`InMemoryVisitStorage`/`LegacyVisitStore` em `offline_visit_queue.dart` (junto de `VisitStore`/`InMemoryVisitStore`); `SqlCipherLegacyVisitStore` sobre o mesmo `VisitDatabase`. + - Atualizar as chamadas de teste/integração que constroem `SqlCipherVisitStore(...)` para passar `owner: 'acs-teste'`. + +- [ ] **Step 4: Rodar e ver passar** — `flutter test test/visit_storage_owner_test.dart test/encrypted_database_test.dart test/offline_burst_test.dart test/micro_area_cache_store_test.dart && flutter analyze`. + +- [ ] **Step 5: Commit** + +```bash +git add apps/acs +git commit -m "feat(acs): visitas offline com dono (schema v7); save de um ACS nunca toca nas visitas de outro" +``` + +--- + +### Task 3: Cursor do `visits.pull` por dono + +**Files:** +- Modify: `apps/acs/lib/core/database/sync_cursor_store.dart` +- Modify: `apps/acs/lib/core/services/visit_pull_service_factory.dart` +- Test: `apps/acs/test/sync_cursor_store_test.dart`, `apps/acs/test/visit_pull_service_test.dart`, `apps/acs/test/visit_pull_service_factory_test.dart` + +**Interfaces:** +- Consumes: `VisitDatabase` / `SqlCipherVisitStorage.database` (Task 2). +- Produces: `SyncCursorStore({required DatabaseKeyStore keyStore, required String owner, String databaseName, bool allowUnencryptedForTesting})` — chave `'visits_pull|$owner'`; `buildVisitPullService({required AcsBackend backend, required VisitStore localVisits, required String cursorOwner, SyncCursorStore? cursorStore})`. + +- [ ] **Step 1: Testes que falham** + +```dart +test('o cursor de A não vale para B', () async { + await cursorA.write(DateTime.utc(2026, 10, 3)); + expect(await cursorB.read(), isNull); // B começa do epoch + expect(await cursorA.read(), DateTime.utc(2026, 10, 3)); +}); +test('gravar o cursor de B não altera o de A', () async { /* idem invertido */ }); +test('pullAndMerge de B usa since=epoch mesmo com cursor de A gravado', () async { + // FakeAcsBackend.pullVisits guarda o `since` recebido +}); +test('dedupe de localId do pull enxerga só a fila do dono', () async { + // visita local-a1 na fila de A; B recebe do servidor uma entrada com o mesmo localId + // → para B NÃO é duplicada (não existe na fila de B) e aparece em lastPulled +}); +``` + +- [ ] **Step 2:** `flutter test test/sync_cursor_store_test.dart test/visit_pull_service_test.dart` → FAIL. +- [ ] **Step 3: Implementar** — `_visitsPullKey` vira função `'visits_pull|$owner'`; remover do código a leitura da chave legada (D3: o cursor legado fica órfão, comente isso). Atualizar a fábrica (parâmetro `cursorOwner` obrigatório) e os testes existentes que a chamam. +- [ ] **Step 4:** `flutter test test/sync_cursor_store_test.dart test/visit_pull_service_test.dart test/visit_pull_service_factory_test.dart && flutter analyze` → PASS. +- [ ] **Step 5: Commit** + +```bash +git add apps/acs +git commit -m "feat(acs): cursor do visits.pull por usuário e microárea" +``` + +--- + +### Task 4: Fila e pull resolvidos por sessão; sincronizador só com a sessão do dono + +**Files:** +- Modify: `apps/acs/lib/core/services/visit_queue_factory.dart` +- Modify: `apps/acs/lib/core/services/backend_visit_synchronizer.dart` +- Modify: `apps/acs/lib/app/app.dart` (`SinalAcsApp` ~30-130, `LoginScreen` e `_openShell`, os pontos que constroem `LoginScreen(...)`/`AcsHomeShell(...)` com `visitQueue`/`visitPullService`) +- Modify: `apps/acs/lib/main.dart` (nada a injetar além do que o app já resolve; conferir) +- Test: `apps/acs/test/visit_owner_flow_test.dart` (novo), ajustes em `test/login_flow_test.dart` e `test/session_resume_test.dart` se a assinatura de `LoginScreen` mudar + +**Interfaces:** +- Consumes: `VisitStorage`, `InMemoryVisitStorage`, `SqlCipherVisitStorage` (Task 2); `buildVisitPullService(..., cursorOwner:)` (Task 3); `AuthSession.userId`, `AuthSession.microAreaId`. +- Produces: + ```dart + /// Fila + pull de UM dono, prontos para o painel dele. + class OwnerVisitScope { + const OwnerVisitScope({required this.queue, required this.pullService}); + final OfflineVisitQueue queue; + final VisitPullService pullService; + } + typedef VisitScopeResolver = OwnerVisitScope Function(AuthSession session); + + OfflineVisitQueue buildVisitQueue({required AcsBackend backend, required VisitStore store, required String ownerId}); + class BackendVisitSynchronizer { BackendVisitSynchronizer({required AcsBackend backend, required String ownerId}); } + // SinalAcsApp({..., VisitStorage? visitStorage, VisitScopeResolver? visitScopeResolver}) + ``` + +- [ ] **Step 1: Testes que falham** (`visit_owner_flow_test.dart`; use `InMemoryVisitStorage` real e `FakeAcsBackend`/`FakeRpcServer` conforme `session_resume_test.dart`; dados sintéticos): + +```dart +testWidgets('Sair e entrar com OUTRO ACS: o novo não vê, não envia nem descarta a visita do anterior', (t) async { + // A entra, registra 1 visita pendente e tem 1 recusada; sai ('Sair e encerrar o turno'). + // B entra: pendingCount == 0, rejectedCount == 0, tela 'Visita' sem as visitas de A; + // 'Sincronizar agora' de B não chama visits.sync com o localId de A; + // discardRejected (botão da tela) de B não altera o armazenamento de A: + expect(storage.forOwner('acs-a').load(), completion(hasLength(2))); +}); + +testWidgets('o mesmo ACS que sai e volta recupera as próprias visitas', (t) async { ... }); + +test('BackendVisitSynchronizer recusa enviar com a sessão de outro usuário', () async { + final backend = FakeAcsBackend()..session = _sessao(userId: 'acs-b'); + final sync = BackendVisitSynchronizer(backend: backend, ownerId: 'acs-a'); + await expectLater(sync.push([_visita('local-a1')]), throwsA(isA())); + expect(backend.syncedBatches, isEmpty); // nada saiu +}); + +test('a fila de A em voo, com B logado: o lote de A continua PENDENTE (não é enviado nem perdido)', () async { + final fila = OfflineVisitQueue(store: storage.forOwner('acs-a'), synchronizer: BackendVisitSynchronizer(backend: backend, ownerId: 'acs-a')); + await fila.add(_visita('local-a1')); + backend.session = _sessao(userId: 'acs-b'); + final r = await fila.sync(); + expect(r.kind, SyncOutcomeKind.error); + expect(fila.pendingCount, 1); +}); + +testWidgets('login de outro usuário pelo bloqueio ("Entrar com senha") também troca a fila', (t) async { ... }); +testWidgets('retomada de sessão na partida abre a fila do dono da sessão retomada', (t) async { ... }); +``` + +- [ ] **Step 2:** `flutter test test/visit_owner_flow_test.dart` → FAIL. +- [ ] **Step 3: Implementar** + - `BackendVisitSynchronizer`: no início de `push`, `if (backend.session?.userId != ownerId) throw const BackendFailure('A sessão atual não é a dona destas visitas. Entre com a conta que registrou as visitas.')` (recuperável: o lote fica pendente — a `OfflineVisitQueue.sync` já converte exceção em `SyncOutcomeKind.error` sem tocar na fila). + - `buildVisitQueue({backend, store, ownerId})` passa `ownerId` ao sincronizador. `store` deixa de ter padrão que abre `SqlCipherVisitStore` sem dono (remova o padrão; o app sempre passa). + - `SinalAcsApp`: `late final VisitStorage _storage = widget.visitStorage ?? SqlCipherVisitStorage(keyStore: SecureStorageDatabaseKeyStore())`; mapa `Map _scopes` (chave `userId|microAreaId`); `OwnerVisitScope _scopeFor(AuthSession s)`: se `widget.visitScopeResolver != null` usa-o; **senão, se `widget.visitQueue`/`widget.visitPullService` (seam de teste, D5) foram injetados, devolve esses**; senão constrói `queue = buildVisitQueue(backend, store: _storage.forOwner(s.userId), ownerId: s.userId)` e `pullService = buildVisitPullService(backend, localVisits: _storage.forOwner(s.userId), cursorOwner: '${s.userId}|${s.microAreaId}')`, memoizados. Remover `_visitStore`, `_visitQueue`, `_visitPullService` fixos. + - `LoginScreen` deixa de receber `visitQueue`/`visitPullService` e recebe `visitScopeFor` (`VisitScopeResolver`); `_openShell(session)` (e o `pushAndRemoveUntil` do outro usuário) usa `final scope = widget.visitScopeFor(session)` para montar o `AcsHomeShell(visitQueue: scope.queue, visitPullService: scope.pullService, ...)`. Os pontos que reconstroem `LoginScreen` (reauth por sessão expirada em `_sessionExpired`, reauth por bloqueio em `_reauthForLock`, logout) passam o **mesmo** resolvedor. O caso de reauth do **mesmo** usuário continua só fazendo `pop` (painel e fila preservados). + - Chame `restore()` da fila nova no `initState` do shell como hoje (`_restoreVisits`); confirme que a fila de um dono já restaurado e memoizado não é restaurada duas vezes (`_restored` já protege). + - Mensagem do diálogo "Sair": manter ("As visitas ainda não sincronizadas continuam salvas neste aparelho") — agora é literalmente verdadeira por dono; acrescente "e só aparecem quando você entrar de novo". + +- [ ] **Step 4: Rodar a suíte inteira** + +Run: `cd apps/acs && flutter analyze && flutter test` +Expected: tudo verde (os testes que injetam `visitQueue`/`visitPullService` seguem pelo seam D5). + +- [ ] **Step 5: Commit** + +```bash +git add apps/acs +git commit -m "feat(acs): fila e pull de visitas resolvidos por sessão; sincronizador recusa sessão de outro dono" +``` + +--- + +## Parte C — Envio do que está preso no aparelho (backend + app) + +### Task 5: Backend — autoria desconhecida e `visits.syncLegacy` + +**Files:** +- Modify: `backend/sinalacs_server/lib/src/models/visit.spy.yaml` (`acsId` nullable; `authorship`; `originDeviceId`) +- Create: `backend/sinalacs_server/lib/src/models/enums/visit_authorship.spy.yaml` +- Generated: `lib/src/generated/**`, `migrations//**`, `backend/sinalacs_client/**` +- Modify: `backend/sinalacs_server/lib/src/application/visits/visit_sync_service.dart` (`VisitRecord.acsId` nullable, `syncLegacy`, `authorship` no `pull`) +- Modify: `lib/src/infrastructure/database/orm_visit_store.dart` (mapear os campos novos) +- Modify: `lib/src/endpoints/visits_endpoint.dart` (`syncLegacy`) +- Modify: `test/unit/endpoint_auth_posture_test.dart` **só se** o método novo não chamar `authenticate(...)` (ele chama; não deve precisar) +- Modify: `spec/lgpd_data_audit.md` (colunas novas de `visits`) +- Test: `test/unit/visit_sync_service_test.dart` (ou o arquivo existente do serviço — achar com `grep -l VisitSyncService test/unit`), `test/integration/visit_legacy_sync_test.dart` (novo) + +**Interfaces:** +- Produces (modelo): `enum VisitAuthorship { acs, legacyUnclaimed }`; `Visit.acsId: UuidValue?` (nulo só quando `authorship == legacyUnclaimed`); `Visit.authorship: VisitAuthorship` (default `acs`); `Visit.originDeviceId: String?`. +- Produces (RPC): `visits.syncLegacy(Session, {required String accessToken, required String deviceId, required List visits}) → List`. +- Produces (serviço): `Future> VisitSyncService.syncLegacy({required AuthenticatedUser transporter, required String deviceId, required List entries})`. + +- [ ] **Step 1: Testes que falham** (unit com o store falso já usado no arquivo de teste do serviço; integração no padrão de `institutional_mfa_test.dart`). Casos: + +```dart +test('syncLegacy grava authorship legacyUnclaimed, acsId nulo e originDeviceId', ...); +test('syncLegacy NÃO grava o transportador como autor (acsId permanece nulo)', ...); +test('visita legada de paciente de OUTRA microárea: rejected, nada gravado', ...); +test('mesmo localId reenviado: idempotente (synced, sem duplicar, sem mudar autoria)', ...); +test('localId que já existe como visita COM autor ACS: conflito/rejected, autoria original preservada', ...); +test('papel diferente de acs, sem microárea ou conta inativa: StateError (AlertPermissionException no endpoint)', ...); +test('cada lote gera audit_logs visit_legacy_sync com o transportador, sem conteúdo clínico', ...); +test('pull entrega a visita legada à microárea sem expor autor (campo acsId ausente)', ...); +``` + +- [ ] **Step 2: Rodar e ver falhar** — `cd backend/sinalacs_server && dart test test/unit` (+ `dart test test/integration/visit_legacy_sync_test.dart`) → FAIL. +- [ ] **Step 3: Implementar** — YAML: + +```yaml +### visit_authorship.spy.yaml +enum: VisitAuthorship +values: + - acs + - legacyUnclaimed +``` + +`visit.spy.yaml`: `acsId: UuidValue?, relation(parent=acs)`; acrescentar +```yaml + ### `legacyUnclaimed` = visita gravada no aparelho antes de existir dono (migração v7): + ### a autoria é desconhecida e `acsId` é nulo. NUNCA preencher `acsId` com quem transportou. + authorship: VisitAuthorship, default=acs + originDeviceId: String? +``` +`serverpod generate && serverpod create-migration`. No serviço: `syncLegacy` reaproveita a validação de `sync` (UUIDs, território do paciente × `transporter.microAreaId`, versão, idempotência por `localId`) mas persiste com `authorship: legacyUnclaimed`, `acsId: null`, `originDeviceId: deviceId`; o ramo `existing.acsId != acsId` do `sync` normal passa a tratar `existing.acsId == null` como "visita sem autor": o ACS comum que reenvia o mesmo `localId` **não** a reivindica (`rejected`). Auditoria `AuditEvent(userId: transporter.id, actionType: 'write', resourceType: 'visit_legacy', result: 'visit_legacy_sync')`. Verificar todos os usos de `visit.acsId` (`grep -rn "acsId" lib/src/application/visits lib/src/infrastructure`) — o compilador aponta os que quebram com o tipo nulo. +- [ ] **Step 4: Rodar a suíte do backend** — `dart analyze && dart test` → PASS (a contagem do `validation_report` pode mudar: `python3 scripts/qa/contagem_validation_report.py`). +- [ ] **Step 5: Commit** + +```bash +git add backend spec/lgpd_data_audit.md +git commit -m "feat(visits): visitas de autoria desconhecida (legado v7) e visits.syncLegacy" +``` + +--- + +### Task 6: Backend — token de envio diferido (`acs_upload_tokens`) + +**Files:** +- Create: `backend/sinalacs_server/lib/src/models/acs_upload_token.spy.yaml` +- Modify: `lib/src/models/api/development_login_result.spy.yaml` (`uploadToken: String?`) +- Create: `lib/src/application/auth/upload_token_service.dart` +- Create: `lib/src/infrastructure/database/orm_upload_token_store.dart` +- Modify: `lib/src/runtime/alert_runtime.dart` (`uploadTokenServiceFor(session)`) +- Modify: `lib/src/endpoints/auth_endpoint.dart` (emitir no `loginInstitutional`), `lib/src/endpoints/visits_endpoint.dart` (`syncDeferred`, `revokeUploadToken`) +- Modify: `test/unit/endpoint_auth_posture_test.dart` (`AuthEndpoint` não muda de postura; `VisitsEndpoint.syncDeferred`/`revokeUploadToken` autenticam por token próprio, **não** por `authenticate(...)` → entram na allowlist com justificativa) +- Modify: `spec/lgpd_data_audit.md` (`acs_upload_tokens`) +- Test: `test/unit/upload_token_service_test.dart`, `test/integration/upload_token_test.dart` + +**Interfaces:** +- Produces (tabela): `acs_upload_tokens(id, userId→users, tokenHash unique, deviceId, issuedAt, expiresAt, revokedAt?)`. +- Produces (serviço): + ```dart + class UploadTokenService { + static const lifetime = Duration(days: 7); + static const deniedMessage = 'Envio não autorizado. Entre novamente.'; + Future issue(AuthenticatedUser user, {DateTime? now}); // revoga o token anterior do mesmo (userId, deviceId) + Future resolve({required String uploadToken, required String deviceId, DateTime? now}); + // devolve o usuário com microAreaId RELIDA do banco; lança SessionExpiredException(deniedMessage) em + // qualquer recusa (desconhecido, vencido, revogado, aparelho diferente, conta inativa/sem microárea) + Future revoke(String uploadToken, {DateTime? now}); // idempotente + } + ``` +- Produces (RPC): `DevelopmentLoginResult.uploadToken` (`String?`, só ACS com `deviceId` real); `visits.syncDeferred(Session, {required String uploadToken, required String deviceId, required List visits}) → List` (usa o mesmo `VisitSyncService.sync` com o usuário resolvido: **autoria do dono**, território do dono); `visits.revokeUploadToken(Session, {required String uploadToken}) → void`. + +- [ ] **Step 1: Testes que falham** — unit com store em memória (mesmo estilo de `refresh_token_service_test.dart`): `issue` guarda só o hash; `resolve` relê a microárea; aparelho diferente, vencido (7 dias + 1 s), revogado, conta inativa, sem microárea e token desconhecido → **mesma** `SessionExpiredException`; `issue` revoga o anterior do mesmo (usuário, aparelho); `revoke` idempotente. Integração (Postgres real, endpoints): login com `deviceId` devolve `uploadToken`; sem `deviceId` devolve nulo; `syncDeferred` grava a visita **com `acsId` = dono do token** e `authorship = acs`; **token de A com paciente de outra microárea → `rejected`**; token de A **não** consegue gravar visita cujo `localId` já pertence a B (`rejected`); `syncDeferred` com sessão de A já encerrada (logout, refresh revogado) **continua funcionando** (D7); depois de `revokeUploadToken` recusa. +- [ ] **Step 2:** `dart test test/unit/upload_token_service_test.dart` → FAIL. +- [ ] **Step 3: Implementar** — espelhar `RefreshTokenService`/`OrmRefreshTokenStore` (token = 32 bytes `Random.secure()` base64url, SHA-256 hex, `UPDATE`/`INSERT` em uma instrução; reutilize helpers em vez de copiar se já houver um gerador de token comum — procurar em `application/auth`). `AuthEndpoint.loginInstitutional`: após o refresh token, `uploadToken = temAparelho ? await runtime.uploadTokenServiceFor(session).issue(user) : null`. Auditoria `upload_token_issued` / `upload_token_denied_*` / `visit_deferred_sync` em `audit_logs` (sem token, sem conteúdo clínico). +- [ ] **Step 4:** `serverpod generate && serverpod create-migration`; `dart analyze && dart test` → PASS. +- [ ] **Step 5: Commit** + +```bash +git add backend spec/lgpd_data_audit.md +git commit -m "feat(auth): token de envio diferido do ACS e visits.syncDeferred com a autoria do dono" +``` + +--- + +### Task 7: App — `DeferredFlushService` (legado + donos com token) e gatilhos + +**Files:** +- Create: `apps/acs/lib/core/security/upload_token_store.dart` (interface + `MemoryUploadTokenStore`) e `apps/acs/lib/core/security/secure_upload_token_store.dart` (Keystore, **separado** para não puxar `flutter_secure_storage` para a VM — mesmo padrão de `secure_session_token_store.dart`) +- Create: `apps/acs/lib/core/services/deferred_flush_service.dart` +- Modify: `apps/acs/lib/core/network/backend_client.dart` (`AcsBackend`: `syncLegacyVisits`, `syncDeferredVisits`, `revokeUploadToken`; `login()` grava o `uploadToken` por dono), `MisconfiguredBackend`, `test/support/fakes.dart` (`FakeAcsBackend`), `test/support/fake_rpc_server.dart` +- Modify: `apps/acs/lib/app/app.dart` (instanciar o serviço no `SinalAcsApp`; disparar nos gatilhos), `apps/acs/lib/main.dart` +- Test: `apps/acs/test/deferred_flush_service_test.dart` (novo), `apps/acs/test/upload_token_store_test.dart` + +**Interfaces:** +- Consumes: `VisitStorage.forOwner`, `VisitStorage.legacy` (Task 2); RPCs `visits.syncLegacy`, `visits.syncDeferred`, `visits.revokeUploadToken`, `DevelopmentLoginResult.uploadToken` (Tasks 5–6); `DeviceIdStore`. +- Produces: + ```dart + abstract interface class UploadTokenStore { + Future read(String ownerId); + Future write(String ownerId, String token); + Future clear(String ownerId); + Future> owners(); // donos com token guardado + } + class FlushReport { final int sent; final int remaining; final List blockedOwners; } + class DeferredFlushService { + DeferredFlushService({required AcsBackend backend, required VisitStorage storage, + required UploadTokenStore tokens, required DeviceIdStore deviceIds}); + /// Sobe, nesta ordem: (1) legado, via a sessão ATUAL como transporte; (2) a fila de cada dono que tem + /// token de envio, via `syncDeferred`; (3) revoga e apaga o token de todo dono cuja fila zerou. + /// Uma chamada por vez (single-flight). Nunca lança: erros viram `remaining`/`blockedOwners`. + Future flushAll(); + Future pendingElsewhere(String currentOwnerId); // visitas de OUTROS donos + legado (só contagem) + } + ``` + +- [ ] **Step 1: Testes que falham** (`InMemoryVisitStorage`, `FakeAcsBackend` com lotes gravados, `MemoryUploadTokenStore`): + +```dart +test('legado sobe pela sessão atual e SAI do aparelho só depois do synced', ...); +test('legado de outra microárea: rejected → continua em quarentena', ...); +test('fila de A sobe com o token de A mesmo com B logado, e a autoria enviada é a de A', ...); +test('depois que a fila de A zera, o token de A é revogado no servidor e apagado do Keystore', ...); +test('erro de rede no meio: nada é apagado; o próximo flushAll reenvia o mesmo localId (idempotente)', ...); +test('token de A recusado (vencido/revogado): a fila de A permanece, A entra em blockedOwners', ...); +test('app morto depois do 200 e antes de apagar: o reenvio do mesmo localId volta synced e a linha é apagada', ...); +test('flushAll simultâneos viram uma só execução (single-flight)', ...); +test('flushAll nunca envia visita de A com o token de B (verifica o token usado por lote)', ...); +test('pendingElsewhere conta outros donos + legado e não expõe conteúdo', ...); +``` + +- [ ] **Step 2:** `flutter test test/deferred_flush_service_test.dart` → FAIL. +- [ ] **Step 3: Implementar** — reaproveitar a **própria `OfflineVisitQueue`** (fila temporária por dono, sobre `storage.forOwner(owner)`, com um sincronizador que chama `syncDeferred`) para não duplicar a semântica de `synced/conflict/rejected/error` — a visita só sai do disco quando o resultado é `synced`. O sincronizador diferido é uma classe pequena `DeferredVisitSynchronizer(backend, ownerToken, deviceId)`; o legado usa o mesmo desenho com `syncLegacyVisits` e `LegacyVisitStore.remove`. `BackendClient.login()`: depois de `refreshToken`, `if (result.uploadToken != null) await _uploadTokens.write(session.userId, result.uploadToken)` **dentro** da seção crítica da Task 1. Gatilhos em `app.dart`: (a) depois de abrir o painel (`_openShell`) e na retomada; (b) `connectivity_plus` `onConnectivityChanged` quando passa a haver rede (já usado no app — reaproveitar o ouvinte existente); (c) chamado pelo "Sair" (Task 8). Nenhum gatilho bloqueia a UI nem mostra conteúdo clínico. +- [ ] **Step 4:** `flutter analyze && flutter test` → PASS. +- [ ] **Step 5: Commit** + +```bash +git add apps/acs +git commit -m "feat(acs): envio diferido de visitas (legado e de ACS que saiu) com a autoria correta" +``` + +--- + +### Task 8: App — "Sair" que envia antes de sair, e "Limpar este aparelho" + +**Files:** +- Modify: `apps/acs/lib/app/app.dart` (`_logout`, aba Ajustes, novo botão e diálogo de wipe) +- Modify: `apps/acs/lib/core/database/encrypted_database.dart` / `sqlcipher_visit_store.dart` (`VisitDatabase.wipe()`: apaga todas as tabelas de dados do app **e** a chave do Keystore não — só as linhas; ver abaixo) +- Test: `apps/acs/test/session_resume_test.dart` (diálogo do Sair), `apps/acs/test/device_wipe_test.dart` (novo) + +**Interfaces:** +- Consumes: `DeferredFlushService.flushAll()` / `pendingElsewhere()` (Task 7), `AcsBackend.logout()`. +- Produces: `VisitDatabase.wipeAllData()` — `DELETE` de `offline_visits`, `sync_cursor`, `micro_area_cache`, `micro_area_cache_meta` em uma transação (o arquivo e a chave são mantidos; é limpeza de dados, não troca de chave); `UploadTokenStore.clearAll()`. + +- [ ] **Step 1: Testes que falham** + - "Sair": com visitas pendentes do próprio ACS e rede, o app chama `flushAll()` **antes** de `logout()`; se zerar, o diálogo diz "Tudo foi enviado"; se não zerar (offline), o texto diz **quantas** continuam no aparelho, "seguem protegidas e sobem sozinhas quando a conexão voltar" e o botão vira "Sair mesmo assim" (os alertas não confirmados continuam contados como hoje). O token de envio do dono **não** é apagado enquanto houver pendência. + - "Limpar este aparelho": botão em Ajustes; ao tocar roda `flushAll()`; se `remaining == 0` mostra confirmação ("Isto apaga as visitas já enviadas, o cache da microárea e as chaves de envio deste aparelho") e faz o wipe, `logout()` e volta ao login; se `remaining > 0` **bloqueia** com a contagem e o motivo ("N visitas ainda não foram enviadas; conecte e tente de novo. Se não puderem ser enviadas, procure a coordenação"). Teste: wipe com 1 visita não enviada **não apaga nada** (conferir o armazenamento); wipe com tudo enviado deixa as tabelas vazias; o painel e o feed MQTT são encerrados pelos caminhos normais de `dispose`. +- [ ] **Step 2:** `flutter test test/device_wipe_test.dart test/session_resume_test.dart` → FAIL. +- [ ] **Step 3: Implementar** — `_logout`: `final report = await flush.flushAll()` com indicador de progresso (já existe `logout_progress`), depois `backend.logout()`. Aviso curto e honesto no diálogo; não bloquear o "Sair" por falha de envio (offline é o caso de campo). Wipe só com `remaining == 0`. +- [ ] **Step 4:** `flutter analyze && flutter test` → PASS (incluir `text_scale_test` para o botão novo em 200%). +- [ ] **Step 5: Commit** + +```bash +git add apps/acs +git commit -m "feat(acs): Sair envia antes de sair; Limpar este aparelho só com tudo enviado" +``` + +--- + +## Parte D — Prova e documentação + +### Task 9: Prova no emulador e documentação + +**Files:** +- Modify: `apps/acs/integration_test/encrypted_storage_test.dart` (donos distintos + legado no SQLCipher real), `apps/acs/integration_test/full_journey_e2e.dart` (e `scripts/qa/acs_full_e2e.sh` se precisar de verificações psql) +- Modify: `PROGRESS.md` (item **(g)** e a corrida do login fechados; novas pendências), `apps/CLAUDE.md`, `backend/CLAUDE.md`, `CLAUDE.md` raiz, `spec/lgpd_design.md`, `spec/lgpd_data_audit.md` + +- [ ] **Step 1: Teste de armazenamento no emulador** — dois donos + quarentena no mesmo arquivo SQLCipher real; migração de um banco v6 criado no aparelho preserva as linhas em quarentena; o arquivo não contém o texto das notas em claro. +- [ ] **Step 2: E2E no emulador** (`emulator-5554` já ligado; skill `validacao-e2e`): A (com MFA) registra visita offline, sai; B entra e **não** a vê; a conexão volta, `flushAll` sobe a visita de A **com `acsId` de A** (verificar por `psql` em `visits`); uma visita legada plantada no banco v6 do aparelho sobe com `authorship = legacyUnclaimed` e `acsId` nulo. Rodar `./scripts/qa/e2e.sh --emulator` e `./scripts/qa/acs_full_e2e.sh`. Se algo não puder rodar, dizer o quê e por quê. +- [ ] **Step 3: Documentação** — o que muda de verdade: schema v7 e quarentena; `visits.syncLegacy` e `syncDeferred`; `acs_upload_tokens`; **o que NÃO foi feito e por quê**: (i) WorkManager/execução em background do SO (D8); (ii) wipe forçado por supervisor (D9: não há login de supervisor); (iii) token de envio vencido (7 dias) deixa a fila do dono presa — o wipe fica bloqueado; (iv) retenção LGPD de donos que nunca voltam continua dependendo do envio. Corrigir `spec/lgpd_design.md` (a política de retenção local passa a citar o envio + wipe). +- [ ] **Step 4: Verificação final** — `cd backend/sinalacs_server && dart analyze && dart test`; `cd apps/acs && flutter analyze && flutter test`; `./scripts/qa/ci_invariants.sh`; `python3 scripts/qa/contagem_validation_report.py`; `scripts/qa/check_documentation_links.sh` → tudo verde. +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "docs(acs): fecha fila de visitas por dono, corrida do login e retenção; registra o que segue aberto" +``` + +--- + +## Auto-revisão + +- **Cobertura da demanda:** corrida do `login()` = Task 1. Fila sem dono = Tasks 2–4. Pontos novos: **legado** não é mais adotado — quarentena (Task 2) + envio como "autoria desconhecida" (Task 5, D4) + upload no app (Task 7); **retenção** = isolamento por dono (Tasks 2–4) + envio desvinculado da sessão (Tasks 6–7, D7) + envio no "Sair" e wipe seguro (Task 8, D9). +- **Onde NÃO segui o pedido, e por quê (para você decidir):** + 1. *"Client Credentials / Device Token"* — não existe essa credencial no backend, e uma credencial só do aparelho não prova **quem** fez a visita. Troquei por um token de envio **por ACS**, de escopo único e 7 dias (D7), que mantém a autoria correta. Quem preferir a credencial de aparelho deve aceitar que a autoria vira "desconhecida" para tudo. + 2. *Worker em background* — exigiria `workmanager` (pacote fora de `spec/stack.md`) e um isolate sem UI, sem prova no emulador aqui. Entrego disparo em primeiro plano (login, retomada, conexão voltou, antes do Sair). É decisão sua autorizar o pacote num plano à parte. + 3. *Wipe por Supervisor* — não há login de supervisor/coordenador no sistema. O wipe é do ACS e só abre com tudo enviado; o que não subir bloqueia o wipe e vira pendência. + 4. *"Se o banco não for criptografado"* — o banco já é SQLCipher (INV-04); o risco que sobra é de **retenção de dado de terceiros**, que o plano reduz por envio + wipe. +- **Placeholders:** os testes marcados com `...` nas Tasks 5–8 têm a regra de aceite escrita na linha; o executor os completa seguindo o arquivo de teste vizinho nomeado em cada Task. +- **Consistência de tipos:** `VisitStorage.forOwner`/`legacy` (Task 2) → Tasks 4 e 7; `uploadToken` (Task 6) → Task 7; `DeferredFlushService.flushAll`/`pendingElsewhere` (Task 7) → Task 8; `VisitSyncEntry` não muda (a autoria é decidida no servidor). +- **Riscos assumidos e declarados:** a Task 1 mexe no caminho crítico de autenticação (revisar deadlock de `_exclusive`); as Tasks 5–6 têm migração e mudam o tipo de `Visit.acsId` para nulo (ripple em pull/consultas); token de envio de 7 dias é uma credencial a mais no aparelho — vazamento permite **só** subir visitas do território do dono sob a autoria dele. diff --git a/docs/superpowers/plans/2026-10-03-refresh-token-e-biometria-do-acs.md b/docs/superpowers/plans/2026-10-03-refresh-token-e-biometria-do-acs.md new file mode 100644 index 00000000..ba8e0ef8 --- /dev/null +++ b/docs/superpowers/plans/2026-10-03-refresh-token-e-biometria-do-acs.md @@ -0,0 +1,909 @@ +# Refresh token rotativo e desbloqueio biométrico do ACS — Plano de Implementação + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Com MFA ligada, o ACS digita matrícula + senha + código TOTP **uma vez por turno**, e não a cada 15 minutos; o desbloqueio entre uma casa e outra é por impressão digital. + +**Architecture:** O backend passa a emitir, junto com o JWT de 15 min, um refresh token **opaco, rotativo e com detecção de reuso** (família de tokens, só o hash no banco, amarrado ao aparelho). O app guarda esse token no Keystore (nunca a senha), renova o JWT sozinho por `auth.refreshSession` (uma chamada por vez) e deixa de reter a senha em memória. Um *portão de bloqueio* (`AppLockGate`), posto **acima** do `Navigator`, cobre o app após ficar em segundo plano e pede biometria (`local_auth`) — sem destruir a pilha de telas, o feed MQTT, a fila de visitas nem formulário em andamento. + +**Tech Stack:** Serverpod 3.4.13 (Dart), Postgres, Flutter (`flutter_secure_storage` já presente, `local_auth` novo), `crypto` (SHA-256). + +**Spec:** `spec/lgpd_design.md` LGPD-RT06 (linha 709: "JWT … 8h (ACS); refresh token rotativo; MFA com TOTP para ACS; autenticação biométrica nativa do dispositivo") e linha 669; `PROGRESS.md` seção "Pendências do ACS", D6 (refresh token adiado de propósito); `spec/PRD_system.md` (INV-01 território, RF07). + +## Global Constraints + +- Texto de UI, comentários e docs em **português**; nomes de código seguem o que o arquivo vizinho já usa. +- Território (INV-01): a `microAreaId` do novo JWT vem **sempre do banco, relida a cada refresh** — nunca copiada do token antigo. ACS desativado ou sem microárea não renova. +- Alerta vermelho nunca some em silêncio: **o bloqueio biométrico e a expiração de sessão não podem destruir o painel, o feed MQTT, a fila de alertas, a fila de visitas nem um formulário aberto.** Mesma regra de `session_reauth_test.dart`. +- Nunca persistir senha. O refresh token só no `flutter_secure_storage` (Keystore); nunca em log, nunca em mensagem de erro, nunca em `shared_preferences`. +- Backend guarda **somente o hash SHA-256** do refresh token (o token tem 256 bits aleatórios; sem pepper). Nunca o token em claro. +- Toda recusa de refresh tem **uma só** mensagem para o cliente (`Sessão expirada. Entre novamente.`); o motivo real vai só para `audit_logs`. +- Nunca editar `lib/src/generated/` nem `migrations/` à mão: `serverpod generate` + `serverpod create-migration`. +- Nunca dado real de paciente em teste, log ou config. Dados sintéticos, UUIDs próprios por suíte. +- Commits **sem** "Co-Authored-By" / "Generated with Claude Code" (regra do `CLAUDE.md` do repositório). Um commit por tarefa. +- `defaultTargetPlatform`/Android: `minSdk = 24` (já vigente); `local_auth` exige `FlutterFragmentActivity` e `USE_BIOMETRIC`. +- CI: não mexer em `.github/workflows/ci.yml` (vigiado por `scripts/qa/ci_invariants.sh`). + +## Decisões de produto (P2, P4 e P5 confirmadas pelo usuário em 2026-10-03; P1 e P3 seguem propostas — todas reversíveis por constante) + +| # | Decisão | Valor proposto | Por quê | +|---|---|---|---| +| P1 | Janela **ociosa** do refresh token | 2 h | Cada rotação estende até 2 h; aparelho esquecido no armário não renova. | +| P2 | Janela **absoluta** | **8 h** desde o login com senha+TOTP (confirmado: segue as 8 h que a spec LGPD-RT06 cita para o ACS) | Cobre o turno; o dia seguinte exige credencial completa. As 8 h da spec valem como duração da *sessão*; o JWT em si continua em 15 min e é renovado pelo refresh, para que um token vazado dure pouco. | +| P3 | Tolerância de reuso | 30 s | Resposta de refresh perdida em rede ruim: o app repete o token antigo. Sem tolerância, o campo derrubaria o ACS por falha de rede. Fora dos 30 s, reuso = suspeita de roubo → **a família inteira é revogada**. | +| P4 | Bloqueio por inatividade | 30 s em segundo plano | "A tela apagou entre uma casa e outra" volta sem digitar nada de novo; mais que isso pede digital. **Confirmado.** | +| P5 | Sem biometria cadastrada no aparelho | Cai para PIN/padrão/senha do aparelho (`biometricOnly: false`); sem **nenhum** bloqueio de tela no aparelho, o app **não retoma** sessão salva (exige login completo) | Sem trava física, o refresh token seria uma chave deixada na porta. **Confirmado.** | +| P6 | "Sair" | Revoga a família no servidor e apaga o token do Keystore | Resposta ao roubo/troca de turno; hoje não há botão. | + +## Review Focus + +1. **Resposta de refresh perdida** (rede cai depois de o servidor rotacionar): o app repete o token antigo dentro de 30 s → deve receber sessão nova, **não** ser deslogado. Teste na Task 2 (`reuso dentro da tolerância`) e na Task 5. +2. **Duas chamadas simultâneas renovando** (fila de visitas + `listMicroArea` + pull no mesmo instante com JWT vencido): o app deve fazer **um** refresh e compartilhar o resultado, senão ele mesmo dispara a detecção de reuso. Teste na Task 5 (`single-flight`). +3. **Token roubado reusado em outro aparelho** ou **após a tolerância**: família inteira revogada, os dois lados caem para login completo. Teste na Task 2. +4. **ACS desativado ou trocado de microárea durante o turno**: o próximo refresh recusa (desativado) ou emite JWT com a microárea nova (INV-01). Teste na Task 2. +5. **Biometria cancelada/falha/bloqueada pelo SO** com alerta chegando por MQTT por baixo: nada é descartado; "Entrar com senha" é sempre possível; sem laço de prompts. Testes na Task 7. + +--- + +## Estrutura de arquivos + +**Backend (`backend/sinalacs_server/`)** +- Criar `lib/src/models/acs_refresh_token.spy.yaml` — tabela `acs_refresh_tokens`. +- Criar `lib/src/models/exceptions/session_expired_exception.spy.yaml`. +- Modificar `lib/src/models/api/development_login_result.spy.yaml` — campo opcional `refreshToken`. +- Criar `lib/src/application/auth/refresh_token_service.dart` — regra de rotação, interface `RefreshTokenStore`. +- Criar `lib/src/infrastructure/database/orm_refresh_token_store.dart` — ORM + `UPDATE` atômico. +- Modificar `lib/src/runtime/alert_runtime.dart` — `refreshTokenServiceFor(session)`. +- Modificar `lib/src/endpoints/auth_endpoint.dart` — `loginInstitutional` emite refresh; novos `refreshSession` e `logout`. +- Modificar `test/unit/endpoint_auth_posture_test.dart` — allowlist dos dois métodos novos. +- Criar `test/unit/refresh_token_service_test.dart`, `test/integration/refresh_token_test.dart`. + +**App (`apps/acs/`)** +- Criar `lib/core/security/refresh_token_store.dart` — Keystore (`RefreshTokenStore`, `SecureStorageRefreshTokenStore`, `MemoryRefreshTokenStore`) e `DeviceIdStore`. +- Modificar `lib/core/network/backend_client.dart` — `AcsBackend` ganha `resumeSession`/`logout`; `BackendClient` renova por refresh, single-flight, sem `_credentials`. +- Criar `lib/core/security/biometric_gate.dart` — porta sobre `local_auth` (`BiometricGate`, `LocalAuthBiometricGate`). +- Criar `lib/app/app_lock_gate.dart` — overlay de bloqueio acima do `Navigator`. +- Modificar `lib/app/app.dart` — monta o gate; retomada de sessão na `LoginScreen`; "Sair" em Ajustes. +- Modificar `android/app/src/main/AndroidManifest.xml`, `MainActivity.kt`, `pubspec.yaml`. +- Modificar `test/support/fake_rpc_server.dart`; testes novos `refresh_session_test.dart`, `app_lock_gate_test.dart`, `session_resume_test.dart`; ajustar `session_expiry_mfa_test.dart`, `session_reauth_test.dart`, `android_manifest_test.dart`. + +**Docs:** `spec/lgpd_data_audit.md`, `spec/lgpd_design.md`, `backend/CLAUDE.md`, `apps/CLAUDE.md`, `CLAUDE.md` (parágrafo "Still missing"), `PROGRESS.md`. + +--- + +## Parte A — Refresh token + +### Task 1: Modelo, exceção, campo no resultado e migração + +**Files:** +- Create: `backend/sinalacs_server/lib/src/models/acs_refresh_token.spy.yaml` +- Create: `backend/sinalacs_server/lib/src/models/exceptions/session_expired_exception.spy.yaml` +- Modify: `backend/sinalacs_server/lib/src/models/api/development_login_result.spy.yaml` +- Generated: `lib/src/generated/**`, `migrations//**`, `backend/sinalacs_client/**` +- Modify: `spec/lgpd_data_audit.md` + +**Interfaces:** +- Produces: classes geradas `AcsRefreshToken` (tabela `acs_refresh_tokens`), `SessionExpiredException(message)`, `DevelopmentLoginResult.refreshToken` (`String?`). + +- [ ] **Step 1: Criar o modelo da tabela** + +```yaml +### Refresh token do ACS (LGPD-RT06). Uma linha por token emitido; os tokens de +### um mesmo login formam uma *família* (`familyId`) e a rotação encadeia +### dentro dela. +### +### **Nunca** o token: só o SHA-256 dele em hex. O token é 256 bits aleatórios, +### então um hash simples basta (não há senha humana para adivinhar). +class: AcsRefreshToken +table: acs_refresh_tokens +fields: + id: UuidValue?, defaultPersist=random + userId: UuidValue, relation(parent=users) + ### Todos os tokens nascidos do mesmo login por senha+TOTP. + familyId: UuidValue + tokenHash: String + ### Aparelho que recebeu o token. Token apresentado por outro aparelho + ### revoga a família. + deviceId: String + issuedAt: DateTime + ### Janela ociosa: renovada a cada rotação, nunca além de `absoluteExpiresAt`. + idleExpiresAt: DateTime + ### Teto do turno, fixado no login por senha+TOTP e herdado pelos filhos. + absoluteExpiresAt: DateTime + ### `null` = ainda não usado. Preenchido na rotação. + rotatedAt: DateTime? + ### `null` = vigente. Preenchido na revogação da família ou no logout. + revokedAt: DateTime? +indexes: + acs_refresh_tokens_hash_key: + fields: tokenHash + unique: true + acs_refresh_tokens_family_idx: + fields: familyId + acs_refresh_tokens_user_idx: + fields: userId +``` + +- [ ] **Step 2: Criar a exceção** (mesmo formato de `mfa_required_exception.spy.yaml`) + +```yaml +### O refresh token não vale mais (expirado, revogado, reusado, de outro +### aparelho ou de conta desativada). Mensagem única de propósito: o motivo +### real fica só em audit_logs. +exception: SessionExpiredException +fields: + message: String +``` + +- [ ] **Step 3: Acrescentar o campo ao resultado de login** + +Em `development_login_result.spy.yaml`, abaixo de `tokenType: String`: + +```yaml + ### Só no `loginInstitutional` e no `refreshSession`. `null` nos demais + ### emissores (paciente, `developmentLogin`). + refreshToken: String? +``` + +- [ ] **Step 4: Gerar e migrar** + +Run (em `backend/sinalacs_server`): `serverpod generate && serverpod create-migration` +Expected: nova pasta em `migrations/` criando `acs_refresh_tokens` com FK para `users` e três índices; `sinalacs_client` regenerado. + +- [ ] **Step 5: Classificar a tabela no audit de LGPD** + +Em `spec/lgpd_data_audit.md`, acrescentar a seção de `acs_refresh_tokens` no formato das demais: `userId` (identificador pessoal do ACS, pseudonimizado por UUID), `tokenHash` (credencial — hash, não reversível), `deviceId`, datas; sem dado de saúde. Atualizar a contagem de tabelas (re-medir em `definition.sql` da migração mais recente, como pede o `CLAUDE.md`). + +- [ ] **Step 6: Verificar e commitar** + +Run: `cd backend && dart pub get && dart analyze && cd sinalacs_server && dart test test/unit` +Expected: sem erros; testes unitários verdes (nada usa o campo novo ainda). + +```bash +git add backend spec/lgpd_data_audit.md +git commit -m "feat(auth): tabela acs_refresh_tokens, SessionExpiredException e refreshToken no resultado de login" +``` + +--- + +### Task 2: `RefreshTokenService` (regra de rotação) — TDD com store em memória + +**Files:** +- Create: `backend/sinalacs_server/lib/src/application/auth/refresh_token_service.dart` +- Test: `backend/sinalacs_server/test/unit/refresh_token_service_test.dart` + +**Interfaces:** +- Consumes: `AuthenticatedUser`, `AuditTrail.recordSafely(AuditEvent)` (`audit_trail.dart`), `UserRole`. +- Produces: + - `class RefreshAccount { final bool active; final String? microAreaId; }` + - `class RefreshTokenRecord` (campos da tabela, `String`/`DateTime`). + - `abstract interface class RefreshTokenStore { insert(RefreshTokenRecord, String tokenHash); Future findByHash(String); Future markRotated(String id, DateTime at); Future revokeFamily(String familyId, DateTime at); Future findAccount(String userId); Future deleteExpiredFor(String userId, DateTime before); }` + - `class RefreshTokenService` com `issue(AuthenticatedUser, {DateTime? now}) → Future`, `refresh({required String refreshToken, required String deviceId, DateTime? now}) → Future`, `revoke(String refreshToken, {DateTime? now}) → Future`; `class RefreshedSession { AuthenticatedUser user; String refreshToken; }`. + - Constantes `idleWindow = 2h`, `absoluteWindow = 8h`, `reuseGrace = 30s`; recusa lança `SessionExpiredException(message: RefreshTokenService.deniedMessage)`. + +- [ ] **Step 1: Escrever os testes que falham** — `refresh_token_service_test.dart` com um `_MemoryStore` (mapas em memória; `markRotated` devolve `false` se `rotatedAt != null` ou `revokedAt != null`, imitando o `UPDATE` condicional) e um `_AuditSpy` que acumula `AuditEvent`. Casos, cada um um `test`: + +```dart +const _acsId = '00000000-0000-4000-8000-000000000091'; +const _area = '00000000-0000-4000-8000-000000000092'; +const _user = AuthenticatedUser( + id: _acsId, role: UserRole.acs, microAreaId: _area, deviceId: 'aparelho-A'); + +test('issue grava só o hash e devolve token de 43+ caracteres', ...); // store nunca contém o token +test('refresh rotaciona: devolve token novo, marca o antigo, mesma família', ...); +test('refresh relê a microárea do banco, não a do token', ...); // account.microAreaId muda → user novo +test('conta desativada: recusa e revoga a família', ...); +test('conta sem microárea: recusa e revoga a família', ...); +test('aparelho diferente: recusa e revoga a família', ...); +test('janela ociosa vencida: recusa', ...); // now + 2h01 +test('teto absoluto vencido mesmo com rotação recente: recusa', ...); // 8h01 +test('filho nunca passa do teto absoluto do pai', ...); // idleExpiresAt == min(now+2h, absolute) +test('reuso dentro da tolerância (30 s): emite token novo e NÃO revoga', ...); +test('reuso fora da tolerância: revoga a família inteira e recusa', ...); // o filho vigente também cai +test('token desconhecido: recusa, sem auditoria (sem sujeito)', ...); +test('revoke apaga a família; revoke de token desconhecido não lança', ...); +test('toda recusa lança a MESMA mensagem', ...); // compara .message de 4 recusas distintas +test('cada desfecho relevante gera auditoria: refresh_granted, denied_reuse, denied_device, denied_inactive', ...); +``` + +- [ ] **Step 2: Rodar e ver falhar** + +Run: `dart test test/unit/refresh_token_service_test.dart` +Expected: FAIL (`refresh_token_service.dart` não existe). + +- [ ] **Step 3: Implementar** + +```dart +import 'dart:convert'; +import 'dart:math'; + +import 'package:crypto/crypto.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/generated/protocol.dart'; + +class RefreshAccount { + const RefreshAccount({required this.active, required this.microAreaId}); + final bool active; + final String? microAreaId; +} + +class RefreshTokenRecord { + const RefreshTokenRecord({ + required this.id, + required this.userId, + required this.familyId, + required this.deviceId, + required this.issuedAt, + required this.idleExpiresAt, + required this.absoluteExpiresAt, + this.rotatedAt, + this.revokedAt, + }); + + final String id; + final String userId; + final String familyId; + final String deviceId; + final DateTime issuedAt; + final DateTime idleExpiresAt; + final DateTime absoluteExpiresAt; + final DateTime? rotatedAt; + final DateTime? revokedAt; +} + +abstract interface class RefreshTokenStore { + Future insert(RefreshTokenRecord record, String tokenHash); + Future findByHash(String tokenHash); + + /// Marca `rotatedAt` **só se** a linha ainda não foi rotacionada nem + /// revogada (um `UPDATE` condicional). `false` = outra requisição ganhou. + Future markRotated(String id, DateTime at); + + Future revokeFamily(String familyId, DateTime at); + Future findAccount(String userId); + + /// Poda do próprio usuário: tokens cujo teto absoluto já passou. + Future deleteExpiredFor(String userId, DateTime before); +} + +class RefreshedSession { + const RefreshedSession({required this.user, required this.refreshToken}); + final AuthenticatedUser user; + final String refreshToken; +} + +class RefreshTokenService { + RefreshTokenService({required this.store, required this.audit, Random? random}) + : _random = random ?? Random.secure(); + + final RefreshTokenStore store; + final AuditTrail audit; + final Random _random; + + static const idleWindow = Duration(hours: 2); + static const absoluteWindow = Duration(hours: 8); + static const reuseGrace = Duration(seconds: 30); + static const deniedMessage = 'Sessão expirada. Entre novamente.'; + + /// Primeiro token de uma família nova (login por senha+TOTP). + Future issue(AuthenticatedUser user, {DateTime? now}) async { + final at = (now ?? DateTime.now()).toUtc(); + await store.deleteExpiredFor(user.id, at); + return _insert( + userId: user.id, + familyId: _uuid(), + deviceId: user.deviceId, + at: at, + absoluteExpiresAt: at.add(absoluteWindow), + ); + } + + Future refresh({ + required String refreshToken, + required String deviceId, + DateTime? now, + }) async { + final at = (now ?? DateTime.now()).toUtc(); + final record = await store.findByHash(_hash(refreshToken)); + if (record == null) throw _denied(); // sem sujeito para auditar + + if (record.revokedAt != null) { + await _audit(record.userId, 'denied_revoked'); + throw _denied(); + } + if (record.deviceId != deviceId) { + await store.revokeFamily(record.familyId, at); + await _audit(record.userId, 'denied_device'); + throw _denied(); + } + if (!at.isBefore(record.absoluteExpiresAt) || !at.isBefore(record.idleExpiresAt)) { + await _audit(record.userId, 'denied_expired'); + throw _denied(); + } + + final rotatedAt = record.rotatedAt; + if (rotatedAt != null && at.difference(rotatedAt) > reuseGrace) { + await store.revokeFamily(record.familyId, at); + await _audit(record.userId, 'denied_reuse'); + throw _denied(); + } + + final account = await store.findAccount(record.userId); + final microAreaId = account?.microAreaId; + if (account == null || !account.active || microAreaId == null) { + await store.revokeFamily(record.familyId, at); + await _audit(record.userId, 'denied_inactive'); + throw _denied(); + } + + // Quem perde a corrida do UPDATE é uma chamada concorrente com o mesmo + // token, no mesmo instante: cai na mesma tolerância do reuso, não é roubo. + if (rotatedAt == null) await store.markRotated(record.id, at); + + final child = await _insert( + userId: record.userId, + familyId: record.familyId, + deviceId: deviceId, + at: at, + absoluteExpiresAt: record.absoluteExpiresAt, + ); + await _audit(record.userId, 'refresh_granted'); + return RefreshedSession( + user: AuthenticatedUser( + id: record.userId, + role: UserRole.acs, + microAreaId: microAreaId, // relida agora (INV-01) + deviceId: deviceId, + ), + refreshToken: child, + ); + } + + Future revoke(String refreshToken, {DateTime? now}) async { + final at = (now ?? DateTime.now()).toUtc(); + final record = await store.findByHash(_hash(refreshToken)); + if (record == null) return; + await store.revokeFamily(record.familyId, at); + await _audit(record.userId, 'logout'); + } + + Future _insert({ + required String userId, + required String familyId, + required String deviceId, + required DateTime at, + required DateTime absoluteExpiresAt, + }) async { + final token = base64Url + .encode(List.generate(32, (_) => _random.nextInt(256))) + .replaceAll('=', ''); + final idle = at.add(idleWindow); + await store.insert( + RefreshTokenRecord( + id: _uuid(), + userId: userId, + familyId: familyId, + deviceId: deviceId, + issuedAt: at, + idleExpiresAt: idle.isAfter(absoluteExpiresAt) ? absoluteExpiresAt : idle, + absoluteExpiresAt: absoluteExpiresAt, + ), + _hash(token), + ); + return token; + } + + String _hash(String token) => sha256.convert(utf8.encode(token)).toString(); + + String _uuid() { + final b = List.generate(16, (_) => _random.nextInt(256)); + b[6] = (b[6] & 0x0f) | 0x40; + b[8] = (b[8] & 0x3f) | 0x80; + final h = b.map((x) => x.toRadixString(16).padLeft(2, '0')).join(); + return '${h.substring(0, 8)}-${h.substring(8, 12)}-${h.substring(12, 16)}-' + '${h.substring(16, 20)}-${h.substring(20)}'; + } + + SessionExpiredException _denied() => SessionExpiredException(message: deniedMessage); + + Future _audit(String userId, String result) => audit.recordSafely( + AuditEvent( + userId: userId, + actionType: 'login', + resourceType: 'session_refresh', + result: result, + ), + ); +} +``` + +> Nota ao executor: se o repositório já tem um gerador de UUID v4 (grep `Uuid()` em `lib/src`), use-o em vez de `_uuid()` e remova o método. + +- [ ] **Step 4: Rodar e ver passar** + +Run: `dart test test/unit/refresh_token_service_test.dart` +Expected: todos os casos do Step 1 verdes. Rodar `dart analyze`. + +- [ ] **Step 5: Commit** + +```bash +git add backend/sinalacs_server/lib/src/application/auth/refresh_token_service.dart backend/sinalacs_server/test/unit/refresh_token_service_test.dart +git commit -m "feat(auth): serviço de refresh token rotativo com detecção de reuso e tolerância de rede" +``` + +--- + +### Task 3: `OrmRefreshTokenStore` com rotação atômica — contra Postgres + +**Files:** +- Create: `backend/sinalacs_server/lib/src/infrastructure/database/orm_refresh_token_store.dart` +- Test: `backend/sinalacs_server/test/integration/refresh_token_test.dart` + +**Interfaces:** +- Consumes: `RefreshTokenStore`, `RefreshTokenRecord`, `RefreshAccount` (Task 2); `AcsRefreshToken`, `Acs`, `User` (gerados). +- Produces: `OrmRefreshTokenStore({required Session Function() session})`. + +- [ ] **Step 1: Teste de integração que falha** — seguir o arranjo de `test/integration/institutional_mfa_test.dart` (`withServerpod`, seed sintético de `Ubs`/`MicroArea`/`User`/`Acs`, ids próprios `...0a1`). Casos: + - `insert` + `findByHash` devolvem os mesmos campos; a linha **não contém** o token em claro (consultar a coluna `tokenHash` e comparar com o SHA-256). + - `markRotated` chamado duas vezes em paralelo (`Future.wait`) → exatamente uma devolve `true`. + - `markRotated` em linha revogada → `false`. + - `revokeFamily` revoga todos os tokens da família e só eles (outra família intacta). + - `findAccount` devolve `active`/`microAreaId` reais (usar `acs.active` e `users.microAreaId`, como `OrmAcsCredentialStore.findByEnrollmentId`). + - `deleteExpiredFor` só apaga linhas do próprio usuário com `absoluteExpiresAt < before`. + +- [ ] **Step 2: Rodar e ver falhar** + +Run: `docker compose --profile test up -d postgres-test && dart test test/integration/refresh_token_test.dart` +Expected: FAIL (arquivo do store não existe). + +- [ ] **Step 3: Implementar** — `markRotated` precisa ser **uma instrução**, no mesmo estilo de `registerFailedAttempt` (o ORM não expressa o `WHERE` condicional com contagem de linhas de forma atômica): + +```dart +@override +Future markRotated(String id, DateTime at) async { + final linhas = await _session().db.unsafeExecute( + 'UPDATE "acs_refresh_tokens" SET "rotatedAt" = @at ' + 'WHERE "id" = @id::uuid AND "rotatedAt" IS NULL AND "revokedAt" IS NULL;', + parameters: QueryParameters.named({'at': at, 'id': id}), + ); + return linhas == 1; +} + +@override +Future revokeFamily(String familyId, DateTime at) async { + await _session().db.unsafeExecute( + 'UPDATE "acs_refresh_tokens" SET "revokedAt" = @at ' + 'WHERE "familyId" = @familyId::uuid AND "revokedAt" IS NULL;', + parameters: QueryParameters.named({'at': at, 'familyId': familyId}), + ); +} +``` + +`insert`, `findByHash`, `findAccount`, `deleteExpiredFor` usam o ORM (`AcsRefreshToken.db.insertRow` / `findFirstRow` / `deleteWhere`; `findAccount` = `Acs.db.findFirstRow(id == userId)` + `User.db.findFirstRow(id == userId)`). + +- [ ] **Step 4: Rodar e ver passar** + +Run: `dart test test/integration/refresh_token_test.dart` +Expected: PASS, incluindo a corrida (`exatamente um true`). + +- [ ] **Step 5: Commit** + +```bash +git add backend/sinalacs_server/lib/src/infrastructure/database/orm_refresh_token_store.dart backend/sinalacs_server/test/integration/refresh_token_test.dart +git commit -m "feat(auth): store do refresh token com rotação atômica em uma instrução" +``` + +--- + +### Task 4: Endpoints — `loginInstitutional` emite refresh; `refreshSession` e `logout` + +**Files:** +- Modify: `backend/sinalacs_server/lib/src/runtime/alert_runtime.dart` (junto de `institutionalAuthServiceFor`) +- Modify: `backend/sinalacs_server/lib/src/endpoints/auth_endpoint.dart` +- Modify: `backend/sinalacs_server/test/unit/endpoint_auth_posture_test.dart` (`_publicMethodsByDesign`) +- Test: acrescentar a `backend/sinalacs_server/test/integration/refresh_token_test.dart`; ajustar `test/integration/institutional_login_test.dart` se afirmar a forma do resultado. + +**Interfaces:** +- Consumes: `RefreshTokenService`, `OrmRefreshTokenStore` (Tasks 2–3), `runtime.auth.issueToken`. +- Produces (RPC): `auth.loginInstitutional(...)` → `DevelopmentLoginResult` com `refreshToken` preenchido; `auth.refreshSession({required String refreshToken, required String deviceId}) → DevelopmentLoginResult`; `auth.logout({required String refreshToken}) → void`. + +- [ ] **Step 1: Testes de integração que falham** (via endpoint, no padrão de `institutional_login_test.dart`): + - login institucional (com MFA ligada e código válido) devolve `refreshToken != null`; o `deviceId` enviado é o `device_id` do JWT. + - `refreshSession` com o token devolvido → novo `accessToken` válido (`runtime.auth.verifyToken` ≠ null, mesma `sub`, `micro_area_id` do banco) e novo `refreshToken` diferente. + - `refreshSession` **sem TOTP e sem senha** funciona — é o objetivo. + - segundo uso do token antigo, > 30 s depois (passar `now` não é possível pelo endpoint: use o service direto com relógio injetado nesse caso, ou insira a linha com `rotatedAt` antigo), → `SessionExpiredException` e família revogada (o filho também recusa). + - mudar `users.microAreaId` no banco entre dois refresh → o segundo JWT carrega a microárea nova. + - `acs.active = false` → `SessionExpiredException`. + - `logout` → o token passa a recusar; `logout` com token inexistente não lança. + - login do **paciente** (`verifyOtp`) continua com `refreshToken == null`. + +- [ ] **Step 2: Rodar e ver falhar** + +Run: `dart test test/integration/refresh_token_test.dart` +Expected: FAIL (`refreshSession` não existe). + +- [ ] **Step 3: Implementar** + +`alert_runtime.dart`: + +```dart +RefreshTokenService refreshTokenServiceFor(Session session) => RefreshTokenService( + store: OrmRefreshTokenStore(session: () => session), + audit: auditTrailFor(session), + ); +``` + +`auth_endpoint.dart` — em `loginInstitutional`, depois de obter `user`: + +```dart +final refreshToken = await runtime.refreshTokenServiceFor(session).issue(user); +return DevelopmentLoginResult( + accessToken: runtime.auth.issueToken(user), + tokenType: 'Bearer', + refreshToken: refreshToken, +); +``` + +e os dois métodos novos: + +```dart +/// Renova a sessão do ACS sem pedir senha nem TOTP (LGPD-RT06). Público por +/// desenho: quem chama já perdeu o JWT de 15 min — o refresh token, opaco e de +/// uso único, é a credencial. Toda recusa é a mesma `SessionExpiredException`. +Future refreshSession( + Session session, { + required String refreshToken, + required String deviceId, +}) async { + final runtime = AlertRuntime.instance; + final renewed = await runtime + .refreshTokenServiceFor(session) + .refresh(refreshToken: refreshToken, deviceId: deviceId); + return DevelopmentLoginResult( + accessToken: runtime.auth.issueToken(renewed.user), + tokenType: 'Bearer', + refreshToken: renewed.refreshToken, + ); +} + +/// Encerra o turno: revoga a família inteira. Idempotente. +Future logout(Session session, {required String refreshToken}) => + AlertRuntime.instance.refreshTokenServiceFor(session).revoke(refreshToken); +``` + +`endpoint_auth_posture_test.dart` — acrescentar a `_publicMethodsByDesign`: + +```dart + 'AuthEndpoint.refreshSession': + 'renovação por refresh token (LGPD-RT06): quem chama já perdeu o JWT; o ' + 'token opaco, rotativo e amarrado ao aparelho É a credencial', + 'AuthEndpoint.logout': + 'revoga pela posse do refresh token; idempotente e sem efeito sobre ' + 'token desconhecido', +``` + +Atualizar o comentário de `AuthEndpoint.patientSessionLifetime` ("a renovação dele é silenciosa, por credencial em memória") para refletir o refresh token. + +- [ ] **Step 4: Rodar a suíte do backend inteira** + +Run: `serverpod generate` (se a assinatura mudou) e `dart test` +Expected: tudo verde, incluindo `endpoint_auth_posture_test` e `app_config_test`. + +- [ ] **Step 5: Commit** + +```bash +git add backend +git commit -m "feat(auth): loginInstitutional emite refresh token; refreshSession e logout" +``` + +--- + +### Task 5: App — armazenamento, renovação silenciosa e fim da senha em memória + +**Files:** +- Create: `apps/acs/lib/core/security/refresh_token_store.dart` +- Modify: `apps/acs/lib/core/network/backend_client.dart` (`AcsBackend`, `MisconfiguredBackend`, `BackendClient`) +- Modify: `apps/acs/test/support/fake_rpc_server.dart` +- Test: `apps/acs/test/refresh_session_test.dart` (novo); ajustar `test/session_expiry_mfa_test.dart` e `test/session_reauth_test.dart`. +- Regenerar o cliente: `sinalacs_client` já veio da Task 1; rodar `flutter pub get` em `apps/acs`. + +**Interfaces:** +- Consumes: RPC `auth.refreshSession`, `auth.logout`, `DevelopmentLoginResult.refreshToken` (cliente gerado). +- Produces: + - `abstract interface class RefreshTokenStore { Future read(); Future write(String token); Future clear(); }` + `SecureStorageRefreshTokenStore` (chave `acs_refresh_token`) + `MemoryRefreshTokenStore` (testes). + - `abstract interface class DeviceIdStore { Future readOrCreate(); }` + `SecureStorageDeviceIdStore` (UUID aleatório criado na 1ª vez, chave `acs_device_id`) + `MemoryDeviceIdStore`. + - `AcsBackend`: `Future resumeSession()` (tenta refresh com o token salvo; `null` se não há token ou foi recusado) e `Future logout()`. + - `BackendClient({String? host, List? trustedCaBytes, RefreshTokenStore? tokenStore, DeviceIdStore? deviceIds})`. + +- [ ] **Step 1: Estender o `FakeRpcServer`** — `refreshSession` devolve `{accessToken, tokenType, refreshToken: 'refresh-N'}` e conta chamadas; flags `rejectRefresh` (responde `SessionExpiredException`), `refreshDelay` (`Duration`, para provocar concorrência) e `failRefreshOnce` (derruba a conexão uma vez, simulando resposta perdida); `loginInstitutional` passa a devolver `refreshToken: 'refresh-0'`; `logout` responde 200 sem corpo e é registrado; expor `int get refreshCount`. + +- [ ] **Step 2: Testes que falham** — `refresh_session_test.dart`: + +```dart +test('JWT vencido é renovado por refresh, sem reenviar senha nem código', ...); +// server.tokenLifetime = -1 min → listPatients(); expect loginCount 1, refreshCount 1. + +test('chamadas simultâneas com JWT vencido compartilham UM refresh (single-flight)', ...); +// server.refreshDelay = 200ms; Future.wait([listPatients(), listPatients(), listPatients()]); +// expect refreshCount == 1. + +test('o refresh token rotativo é regravado a cada renovação', ...); +// MemoryRefreshTokenStore: após login = 'refresh-0'; após renovar = 'refresh-1'. + +test('recusa do refresh (SessionExpiredException) limpa o token e chama onSessionExpired', ...); +// BackendFailure(isRecoverable: false), avisos == 1, store.read() == null. + +test('falha de REDE no refresh é recuperável e NÃO apaga o token', ...); +// failRefreshOnce: 1ª chamada lança BackendFailure(isRecoverable: true); +// store ainda tem o token; a 2ª tentativa renova. + +test('resumeSession: sem token salvo devolve null; com token, devolve sessão', ...); + +test('logout revoga no servidor e apaga o token local, mesmo se o servidor estiver fora', ...); + +test('a senha não é guardada: BackendClient não mantém credencial após o login', ...); +// renova com loginCount == 1 e sem reenviar `password` (inspecionar server.requests). + +test('o deviceId enviado ao login e ao refresh é o mesmo e persiste entre instâncias', ...); +``` + +Ajustar `session_expiry_mfa_test.dart` (o comentário "Sem refresh token…" deixa de valer): agora o caso é "refresh recusado ⇒ `BackendFailure` não recuperável com a mensagem `Sua sessão expirou. Entre novamente com o código do autenticador.` e `onSessionExpired` chamado uma vez". Ajustar `session_reauth_test.dart` apenas onde ele dependia de `_credentials`. + +- [ ] **Step 3: Rodar e ver falhar** + +Run (em `apps/acs`): `flutter test test/refresh_session_test.dart` +Expected: FAIL (símbolos novos não existem). + +- [ ] **Step 4: Implementar** + +`refresh_token_store.dart` — espelhar `SecureStorageDatabaseKeyStore` (mesmas opções `AndroidOptions(encryptedSharedPreferences: true)` e `IOSOptions` `first_unlock_this_device`); **nunca** logar o valor. + +`BackendClient`: +- Remover `_credentials` e o laço de `renewSession` por senha. +- `login(...)`: ler `result.refreshToken`; se vier, `await _tokenStore.write(...)`. Enviar `deviceId: await _deviceIds.readOrCreate()` em `loginInstitutional` (o parâmetro já existe no RPC). +- Renovação **single-flight**: + +```dart +Future? _renewal; + +Future renewSession() => _renewal ??= _doRenew().whenComplete(() => _renewal = null); + +Future _doRenew() async { + final token = await _tokenStore.read(); + if (token == null) { + _expire(); + throw const BackendFailure('Sua sessão expirou. Entre novamente.', isRecoverable: false); + } + try { + final result = await _client.auth.refreshSession( + refreshToken: token, + deviceId: await _deviceIds.readOrCreate(), + ); + final session = AuthSession.tryParse(result.accessToken, result.tokenType); + final next = result.refreshToken; + if (session == null || next == null) { + throw const BackendFailure('O servidor devolveu um token que o aplicativo não entendeu.', isRecoverable: false); + } + await _tokenStore.write(next); // grava ANTES de expor a sessão: perder o filho = perder o turno + return _session = session; + } on SessionExpiredException { + await _tokenStore.clear(); + _expire(); + throw const BackendFailure('Sua sessão expirou. Entre novamente com o código do autenticador.', isRecoverable: false); + } + // Erros de rede/timeout passam pelo `_guard` já existente como recuperáveis, + // e NÃO apagam o token: a repetição cai na tolerância de 30 s do servidor. +} + +void _expire() { _session = null; onSessionExpired?.call(); } +``` + +(o `try` real envolve a chamada com `_guard`, mantendo a tradução de exceções existente; `SessionExpiredException` deve ser tratada **antes** de `_guard` convertê-la.) +- `resumeSession()`: se `_tokenStore.read()` é `null` → `null`; senão `try { return await renewSession(); } on BackendFailure { return null; }` — **sem** chamar `onSessionExpired` (na partida a UI ainda é a tela de login; um parâmetro interno `notify: false` evita empilhar login sobre login). +- `logout()`: ler o token, `try { await _client.auth.logout(refreshToken: t) } catch (_) {}` (best-effort, o aparelho pode estar sem rede), depois `clear()` e `_session = null`. +- `MisconfiguredBackend`: `resumeSession` → `null`, `logout` → no-op. + +- [ ] **Step 5: Rodar e ver passar** + +Run: `flutter test test/refresh_session_test.dart test/session_expiry_mfa_test.dart test/session_reauth_test.dart test/backend_client_test.dart test/login_flow_test.dart test/mfa_login_test.dart && flutter analyze` +Expected: PASS; `flutter analyze` limpo. Depois `flutter test` (suíte inteira do ACS). + +- [ ] **Step 6: Commit** + +```bash +git add apps/acs +git commit -m "feat(acs): renovação silenciosa por refresh token no Keystore; app deixa de reter a senha" +``` + +--- + +## Parte B — Desbloqueio biométrico + +### Task 6: Porta biométrica, manifesto e `AppLockGate` + +**Files:** +- Modify: `apps/acs/pubspec.yaml` (dependência `local_auth: ^2.3.0` — conferir a versão compatível com o Flutter do `.fvmrc`/CI rodando `flutter pub add local_auth`) +- Modify: `apps/acs/android/app/src/main/AndroidManifest.xml` (``) +- Modify: `apps/acs/android/app/src/main/kotlin/br/com/prismrr/sinalacs/acs/MainActivity.kt` (`FlutterActivity` → `FlutterFragmentActivity`; **manter** o `FLAG_SECURE`) +- Modify: `apps/acs/test/android_manifest_test.dart` +- Create: `apps/acs/lib/core/security/biometric_gate.dart` +- Create: `apps/acs/lib/app/app_lock_gate.dart` +- Test: `apps/acs/test/app_lock_gate_test.dart` + +**Interfaces:** +- Produces: + +```dart +enum UnlockResult { unlocked, cancelled, unavailable, lockedOut } + +abstract interface class BiometricGate { + /// `true` se o aparelho tem biometria cadastrada OU bloqueio de tela seguro. + Future get isAvailable; + Future authenticate({required String reason}); +} +``` + + - `class AppLockGate extends StatefulWidget { AppLockGate({required BiometricGate gate, required Widget child, Duration lockAfter = const Duration(seconds: 30), DateTime Function()? clock, VoidCallback? onUsePassword}) }` — **sempre** mantém `child` montado (Stack), cobrindo-o quando bloqueado. + +- [ ] **Step 1: Manifesto e atividade** + +`android_manifest_test.dart` — acrescentar: + +```dart +test('declara USE_BIOMETRIC para o desbloqueio por digital', () { + expect(declares('USE_BIOMETRIC'), isTrue); +}); +``` + +(e um teste que lê `MainActivity.kt` e exige `FlutterFragmentActivity` **e** `FLAG_SECURE` — o `local_auth` falha em tempo de execução com `FlutterActivity`, e o `FLAG_SECURE` não pode regredir.) + +- [ ] **Step 2: Testes do gate que falham** — `app_lock_gate_test.dart` com `FakeBiometricGate` (resultado programável, contador de chamadas) e relógio injetado: + +```dart +testWidgets('fica desbloqueado se voltar do segundo plano antes do prazo', ...); // 10 s < 30 s +testWidgets('bloqueia ao voltar depois do prazo e mostra "Desbloquear com digital"', ...); +testWidgets('o filho continua MONTADO e com o mesmo estado enquanto bloqueado', ...); // TextField com texto digitado sobrevive; Navigator não é recriado +testWidgets('desbloqueio bem-sucedido remove a cobertura', ...); +testWidgets('cancelamento mantém bloqueado, sem laço de prompts', ...); // authenticate chamado 1x por toque +testWidgets('lockedOut e unavailable oferecem "Entrar com senha" e NÃO desbloqueiam', ...); +testWidgets('a cobertura não expõe o conteúdo ao leitor de tela nem a toques', ...); // ExcludeSemantics / AbsorbPointer no filho +testWidgets('abre o prompt sozinho ao bloquear (um toque a menos na rua)', ...); +testWidgets('app que nunca saiu do primeiro plano não bloqueia', ...); +``` + +- [ ] **Step 3: Rodar e ver falhar** + +Run: `flutter test test/app_lock_gate_test.dart test/android_manifest_test.dart` +Expected: FAIL. + +- [ ] **Step 4: Implementar** + +`LocalAuthBiometricGate` sobre `LocalAuthentication`: +- `isAvailable` = `await auth.isDeviceSupported()` (cobre biometria **ou** PIN/padrão do aparelho — P5). +- `authenticate` = `auth.authenticate(localizedReason: reason, options: const AuthenticationOptions(biometricOnly: false, stickyAuth: true))`; mapear `LocalAuthException`/`PlatformException` de lockout (`LockedOut`, `PermanentlyLockedOut`) → `lockedOut`; `notAvailable`/`NotEnrolled` → `unavailable`; falso → `cancelled`. Nunca logar a causa com dado do usuário. + +`AppLockGate` — `WidgetsBindingObserver`; ao `paused`/`hidden` guarda `_leftAt = clock()`; ao `resumed`, se `clock() - _leftAt >= lockAfter` → `_locked = true` e chama `_prompt()`. Build: + +```dart +Stack(children: [ + ExcludeSemantics(excluding: _locked, child: IgnorePointer(ignoring: _locked, child: widget.child)), + if (_locked) _LockCover(...), // opaca, Material, cobre tudo (inclusive rotas empilhadas — por isso o gate vai no `MaterialApp.builder`) +]); +``` + +`_LockCover`: ícone de digital, texto "Aplicativo bloqueado", botão **"Desbloquear"** (re-chama `authenticate`) e botão **"Entrar com senha"** (chama `onUsePassword`). Texto: "Alertas continuam chegando; eles estarão no painel ao desbloquear." (sem nenhum dado de paciente na cobertura). Alvos de toque ≥ 48 dp; cores pelos tokens `*OnSurface` (ver `apps/CLAUDE.md` e `test/contrast_tokens_test.dart`). + +- [ ] **Step 5: Rodar e ver passar** + +Run: `flutter test test/app_lock_gate_test.dart test/android_manifest_test.dart test/no_fixed_white_text_test.dart test/contrast_tokens_test.dart test/text_scale_test.dart && flutter analyze` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add apps/acs +git commit -m "feat(acs): bloqueio por inatividade com biometria acima do Navigator, sem destruir o painel" +``` + +--- + +### Task 7: Integração — retomada de sessão na partida, "Entrar com senha" e "Sair" + +**Files:** +- Modify: `apps/acs/lib/app/app.dart` (`SinalAcsApp`, `_LoginScreenState`, `ThemeSettingsScreen`/aba Ajustes) +- Modify: `apps/acs/lib/main.dart` (injeção das fábricas reais) +- Test: `apps/acs/test/session_resume_test.dart` (novo); ajustar `test/session_reauth_test.dart` se o `MaterialApp.builder` mudar a árvore. + +**Interfaces:** +- Consumes: `AcsBackend.resumeSession()`/`logout()` (Task 5), `BiometricGate` e `AppLockGate` (Task 6). +- Produces: `SinalAcsApp({..., BiometricGate? biometricGate})` (padrão `LocalAuthBiometricGate`; testes injetam o fake). + +- [ ] **Step 1: Testes que falham** — `session_resume_test.dart`: + +```dart +testWidgets('partida com refresh token salvo: digital OK → entra no painel sem digitar nada', ...); +testWidgets('partida com token salvo e digital cancelada → fica na tela de login completa', ...); +testWidgets('partida sem token salvo → tela de login normal, sem prompt biométrico', ...); +testWidgets('aparelho sem nenhum bloqueio de tela (unavailable) NÃO retoma a sessão salva (P5)', ...); +testWidgets('refresh recusado na partida → tela de login com aviso, token apagado', ...); +testWidgets('"Entrar com senha" do bloqueio leva ao login completo e preserva o painel por baixo', ...); +testWidgets('após login completo, o token novo substitui o salvo', ...); +testWidgets('"Sair" em Ajustes pede confirmação, chama logout e volta ao login', ...); +testWidgets('alerta vermelho recebido com o app BLOQUEADO está na fila ao desbloquear', ...); +// usa FakeAlertFeed já existente em test/support/fakes.dart: emite o alerta durante o bloqueio; +// após desbloquear, o card está no painel. É o teste do invariante "nunca descartar alerta". +testWidgets('retomada da sessão respeita a microárea devolvida pelo servidor', ...); +``` + +- [ ] **Step 2: Rodar e ver falhar** + +Run: `flutter test test/session_resume_test.dart` +Expected: FAIL. + +- [ ] **Step 3: Implementar** + +1. `SinalAcsApp` recebe `biometricGate` e envolve a árvore **no `MaterialApp.builder`** (`builder: (context, child) => AppLockGate(gate: gate, onUsePassword: _usePassword, child: child!)`) — é isso que cobre rotas empilhadas (formulário de visita, reauth). +2. `_LoginScreenState.initState` → `_tryResume()` (só quando `widget.reauthUserId == null`, isto é, partida a frio): + - `final backend = BackendScope.of(context)` (em `didChangeDependencies`, como o resto do arquivo); + - `if (!await gate.isAvailable) return;` (P5); + - se não há token salvo (`resumeSession` devolverá `null` sem rede; checar antes com `backend.hasStoredSession` — acrescentar o getter assíncrono em `AcsBackend` se necessário) → return, sem prompt; + - `gate.authenticate(reason: 'Entrar no SinalACS')` → só se `unlocked`: `final s = await backend.resumeSession();` e, se `s?.microAreaId != null`, `pushReplacement` para `AcsHomeShell` com `acsId: s.userId`, exatamente como `_enter()` faz (extrair `_openShell(session)` para não duplicar). + - qualquer outro caminho: permanece no formulário; se o refresh foi recusado, `_aviso = 'Sua sessão expirou. Entre novamente.'`. +3. `_usePassword` (do gate): `Navigator.push` da `LoginScreen` com `reauthUserId` = usuário atual — **reaproveita** o caminho de reautenticação empilhada que já existe e é provado por `session_reauth_test.dart`; ao concluir, o gate se desbloqueia. +4. "Sair": na aba Ajustes (`AcsDestination.settings`), abaixo do tema, um `FilledButton` "Sair e encerrar o turno" → diálogo de confirmação ("As visitas ainda não sincronizadas continuam salvas neste aparelho.") → `backend.logout()` → `pushAndRemoveUntil` para `LoginScreen`. **Não** apagar a fila de visitas nem o banco (offline-first: visita não sincronizada não se perde por sair). +5. `main.dart`: instanciar `LocalAuthBiometricGate()`, `SecureStorageRefreshTokenStore()` e `SecureStorageDeviceIdStore()` e passá-los ao `BackendClient`/`SinalAcsApp`. + +- [ ] **Step 4: Rodar e ver passar** + +Run: `flutter test && flutter analyze` +Expected: suíte inteira do ACS verde (158+ testes anteriores mais os novos); `flutter analyze` limpo. + +- [ ] **Step 5: Commit** + +```bash +git add apps/acs +git commit -m "feat(acs): retomada de sessão por digital na partida, 'Entrar com senha' e 'Sair'" +``` + +--- + +### Task 8: Prova no emulador, documentação e fechamento + +**Files:** +- Modify: `apps/acs/integration_test/full_journey_e2e.dart` (e o script que o dispara — ver a skill `validacao-e2e`) +- Modify: `backend/CLAUDE.md`, `apps/CLAUDE.md`, `CLAUDE.md`, `PROGRESS.md`, `spec/lgpd_design.md` (linhas 669 e 709; §RT06), `spec/validation_report.md` só se o conferidor de contagem exigir (`scripts/qa/contagem_validation_report.py`) + +- [ ] **Step 1: E2E no emulador (`emulator-5554`)** — estender o último teste do `full_journey_e2e` (MFA ligada pelo RPC): + 1. login completo com TOTP; 2. forçar o vencimento do JWT (esperar o TTL ou reduzir o TTL do servidor de e2e via `docker-compose.e2e.yml`, se já houver variável — senão aguardar os 15 min **uma vez** e registrar o tempo); 3. disparar `listPatients` e verificar que **não** apareceu a tela de reautenticação e que o servidor registrou `refresh_granted` em `audit_logs`; 4. `adb shell input keyevent KEYCODE_SLEEP`/`WAKEUP` após > 30 s e verificar a cobertura de bloqueio (a biometria real fica fora do e2e: usar `adb -e emu finger touch 1` com a digital cadastrada no emulador, ou o `FakeBiometricGate` injetado por `--dart-define` de teste — escolher o que o harness já permitir e **dizer no PROGRESS qual foi**). + Run: `./scripts/qa/e2e.sh --emulator` (ver a skill `validacao-e2e` para o comando exato do full journey). + Expected: jornada verde; a prova da biometria real fica declarada como feita no emulador, **não** em aparelho físico. + +- [ ] **Step 2: Atualizar a documentação** — o que muda de verdade: + - `backend/CLAUDE.md`: endpoints `auth.refreshSession`/`auth.logout`; a tabela `acs_refresh_tokens`; as constantes P1–P3; a frase "a renovação dele é silenciosa, por credencial em memória" some; a contagem "17 tables" do parágrafo de camadas é re-medida. + - `apps/CLAUDE.md`: o app **não** retém mais a senha; refresh no Keystore; `AppLockGate`; `local_auth`; `FlutterFragmentActivity`. + - `CLAUDE.md` (raiz): tirar "refresh token" de "Still missing" e acrescentar o desbloqueio biométrico em "Already delivered"; deixar claro que "reset de MFA pela coordenação" segue aberto. + - `spec/lgpd_design.md` (linhas 669 e 709): marcar refresh token e biometria como implementados, com a ressalva de que a biometria é **portão de interface** (não liga a chave do SQLCipher nem a do refresh token ao `BiometricPrompt`). + - `PROGRESS.md`: seção "Pendências do ACS" — refresh token e a consequência dos 15 minutos viram "fechado"; **abertos:** (a) biometria ligada ao Keystore (`setUserAuthenticationRequired`) em vez de portão de UI; (b) revogação de todas as famílias pelo coordenador / MFA reset; (c) limpeza periódica de `acs_refresh_tokens` (hoje só poda o próprio usuário no login); (d) iOS não existe; (e) refresh token do **paciente** (OTP segue em 1 h). + - Apagar o item D6 ("refresh token… ficam fora") ou marcá-lo como superado. + +- [ ] **Step 3: Rodar tudo que a CI roda para estes pacotes** + +Run: `cd backend && dart analyze && cd sinalacs_server && dart test` ; `cd apps/acs && flutter analyze && flutter test` ; `./scripts/qa/ci_invariants.sh` ; `python3 scripts/qa/contagem_validation_report.py` (se falhar, corrigir a contagem que ele aponta). +Expected: tudo verde. + +- [ ] **Step 4: Commit** + +```bash +git add -A +git commit -m "docs(acs): fecha refresh token e desbloqueio biométrico; registra o que segue aberto" +``` + +--- + +## Auto-revisão + +- **Cobertura da demanda:** refresh token (Tasks 1–5) → ACS não refaz matrícula/senha/código a cada 15 min; biometria para desbloqueio rápido e como camada contra roubo (Tasks 6–7); "Sair" e revogação (Tasks 2, 4, 7) completam a história do roubo. +- **Placeholders:** nenhum "TBD"; as duas notas ao executor (gerador de UUID já existente; versão do `local_auth`; mecanismo de digital no e2e) apontam para uma verificação concreta de 1 comando, não para decisão em aberto. +- **Consistência de tipos:** `RefreshTokenStore` (backend, Task 2) e `RefreshTokenStore` (app, Task 5) são interfaces **diferentes** em pacotes diferentes — se o executor preferir evitar confusão, renomear a do app para `SessionTokenStore` na Task 5 e atualizar os usos (`BackendClient`, `main.dart`, `MemoryRefreshTokenStore`). `deniedMessage`, `SessionExpiredException`, `refreshSession(refreshToken, deviceId)` e `logout(refreshToken)` têm a mesma forma nas Tasks 2, 4 e 5. +- **Risco assumido e declarado:** a biometria é portão de interface, não chave criptográfica; um aparelho com root pode lê-la fora do app. O ganho real contra o roubo vem do portão **mais** de o token estar no Keystore, ter janela ociosa de 2 h/teto de 8 h e poder ser revogado por "Sair" e pela detecção de reuso. diff --git a/docs/superpowers/plans/2026-10-05-autoria-das-visitas-e-captura-de-tela-em-debug.md b/docs/superpowers/plans/2026-10-05-autoria-das-visitas-e-captura-de-tela-em-debug.md new file mode 100644 index 00000000..5143d15e --- /dev/null +++ b/docs/superpowers/plans/2026-10-05-autoria-das-visitas-e-captura-de-tela-em-debug.md @@ -0,0 +1,420 @@ +# Autoria das visitas sob outro token e opt-out de captura de tela só em debug — Plano de Implementação + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** (1) Confirmar, com testes, que visitas de um ACS nunca sobem sob o token de outro ACS nem são atribuídas a quem as transportou, e fechar o que sobrar; (2) permitir captura de tela do app do ACS **apenas** em build de debug, escolhida em tempo de compilação, mantendo `FLAG_SECURE` em release por construção. + +**Architecture:** O ponto 1 está **quase todo entregue** (fila por dono, token de envio diferido por dono, `syncLegacy` que nunca atribui autoria ao transportador): o plano mede, prende o que falta com testes de caracterização e deixa um único ponto como **decisão explícita** (as linhas legadas sem dono). O ponto 2 usa um `BuildConfig.ALLOW_SCREEN_CAPTURE` nativo, `false` por padrão, que só o build `debug` pode ligar por uma propriedade do Gradle; o Gradle recusa essa propriedade em release. + +**Tech Stack:** Flutter/Dart (`apps/acs`, `flutter test`), Kotlin (`MainActivity.kt`), Gradle Kotlin DSL (AGP 9.0.1), Serverpod/Dart (`backend/sinalacs_server`, `dart test`), bash (`scripts/qa`, `scripts/dev`). + +**Spec:** `PROGRESS.md` (seção "Fila de visitas por dono e corrida do login", linhas ~762–775, e "FLAG_SECURE escurece captura e gravação", linha ~612); `docs/superpowers/plans/2026-10-03-fila-de-visitas-por-dono-e-corrida-do-login.md` (D1–D9); `docs/superpowers/plans/2026-10-02-pendencias-do-acs-flag-secure-ubs-rf08-mfa-assinatura-mtls.md` (D1); `spec/lgpd_design.md` §5.6 e §5.11; `CLAUDE.md` (invariantes). + +Levantamento com `graphify query` e leitura do código em 2026-10-05, **antes** de executar (a Task 1 repete as medidas): + +| Ponto | Estado observado | +|-------|------------------| +| 1a. Fila amarrada ao dono | **Entregue.** `offline_visits.owner` (schema v7), `VisitStorage.forOwner(userId)` só lê/grava/apaga `WHERE owner = ?`; `BackendVisitSynchronizer` recusa sessão de outro dono (`visit_owner_flow_test.dart:421`, `:450`). | +| 1b. Nunca pegar carona no token de outro | **Entregue para linhas com dono.** `DeferredFlushService` sobe a fila de A por `visits.syncDeferred` com o **token de envio diferido de A** (`acs_upload_token|`), não pelo JWT de B; o servidor resolve o dono pelo token. | +| 1c. Servidor não credita B | **Entregue.** `VisitSyncService.syncLegacy` grava `acsId` nulo, `authorship = legacyUnclaimed`, `originDeviceId`, e audita `visit_legacy_sync` com o transportador como transportador (`visit_sync_service.dart:272-310`, `:396`). | +| 1d. Linhas **legadas** (sem dono, schema v6) sobem pela sessão de quem logar | **Aberto como decisão.** `DeferredFlushService._flushLegacy` usa `syncLegacyVisits` com o JWT do ACS logado como *transporte*. Só existem em aparelho que rodou uma build **anterior a 2026-10-04**; não há release publicada (`CLAUDE.md`: protótipo). | +| 2. Opt-out de captura só em debug | **Aberto.** `MainActivity.onCreate` aplica `FLAG_SECURE` sem condição; `build.gradle.kts` não habilita `buildConfig` (AGP 9 desliga por padrão). `acs_secure_window.sh` e `secure_window_test.dart` provam a flag num **APK de debug**, então um opt-out que abra o debug por padrão os quebraria. | + +**Por que `BuildConfig` nativo e não `kReleaseMode`:** a flag é aplicada em `Activity.onCreate`, antes de qualquer Dart rodar; `kReleaseMode` é uma constante do Dart e só poderia agir depois, por um `MethodChannel`, deixando a janela sem proteção até o primeiro quadro (e a miniatura dos recentes). `BuildConfig` é constante de compilação da plataforma, o mesmo princípio pedido, no lugar certo. + +## Global Constraints + +- Triagem determinística e nunca alterável à mão; alerta vermelho nunca é descartado em silêncio (`CLAUDE.md`). +- A microárea do ACS restringe o acesso aos dados daquele território (INV-01/RNF06). +- Nunca dado real de paciente em teste, log, captura ou configuração de dev; credenciais de teste sintéticas. +- Em release a janela do ACS é `FLAG_SECURE`, sem exceção, sem menu de desenvolvedor, sem propriedade em tempo de execução. +- Sincronização offline: preservar retry/fila/conflito (`offline_visit_queue.dart`, `SyncFsm`); a visita só sai do aparelho em `synced`. +- `-Psinalacs.*` só vale com o texto exato `true` (`licenca()` em `build.gradle.kts`). +- Documentação e UI em português; commits sem atribuição de IA (regra do repositório). Rodar `graphify update .` depois de modificar código. + +## Fora do escopo + +- Envio em segundo plano do SO (WorkManager) e wipe forçado por supervisor: já listados como abertos no `PROGRESS.md` (D8); exigem decisão fora de `spec/stack.md`. +- iOS (não existe) e o equivalente de `FLAG_SECURE` no App Switcher. +- Credencial de cliente de dispositivo (client credentials) como autenticação do envio: o token de envio por dono já cumpre o papel ("sou o dispositivo X enviando pendências do usuário A"), com revogação no servidor. + +## Review Focus + +- ACS B loga com visitas de A pendentes: nada de A sai com o JWT de B; o lote de A continua `PENDENTE` e `Sair` mostra o que ficou (já coberto; a Task 2 só prende o lado do servidor). +- Visita de A reenviada com o JWT de B por um cliente adulterado: o servidor recusa o `localId` de outro agente e não muda a autoria (Task 2). +- Linha legada sem dono enviada por B: fica `legacyUnclaimed`, `acsId` nulo, nunca aparece no `pull` de B como dele (Task 2). +- Build de **release** com `-Psinalacs.allowScreenCapture=true`: o Gradle falha com mensagem que diz o porquê (Task 3). +- Build de **debug** sem a propriedade: continua `FLAG_SECURE` (as provas de QA existentes não regridem) (Task 3). + +--- + +### Task 1: Verificar o ponto 1 com evidência (somente medir) + +**Files:** +- Modify: nenhum. O resultado vira a tabela do commit da Task 5 e decide a Task 2. + +**Interfaces:** +- Produces: `FECHADO`/`ABERTO` para 1a–1d, cada um com o comando e a saída. + +- [ ] **Step 1: Orientar-se no grafo (obrigatório antes de abrir código)** + +```bash +cd /home/rock/Documents/Dev/APPs/SinalACS +graphify query "DeferredFlushService flushAll syncDeferred syncLegacyVisits owner session" --budget 1200 +graphify query "VisitSyncService syncLegacy legacyUnclaimed authorship transporter" --budget 1000 +graphify explain "BackendVisitSynchronizer" +``` +Expected: nós `DeferredFlushService`, `VisitSyncService.syncLegacy`, `BackendVisitSynchronizer`, `sqlcipher_visit_store.dart`. + +- [ ] **Step 2: Rodar os testes que provam 1a–1c (nenhum código muda)** + +```bash +cd apps/acs && flutter test test/visit_owner_flow_test.dart test/visit_storage_owner_test.dart test/deferred_flush_service_test.dart test/deferred_flush_triggers_test.dart +cd ../../backend/sinalacs_server && dart test test/unit/visit_legacy_sync_test.dart test/unit/visit_sync_service_test.dart +``` +Expected: tudo passa. Anotar, do `visit_owner_flow_test.dart`, o teste `Sair e entrar com OUTRO ACS: o novo não vê, não envia nem descarta visita do anterior` e `BackendVisitSynchronizer recusa enviar com a sessão de outro usuário`; e do servidor, o teste que afirma `acsId` nulo e `legacyUnclaimed` na visita legada. + +- [ ] **Step 3: Medir 1d (quem pode ter linha legada)** + +```bash +cd /home/rock/Documents/Dev/APPs/SinalACS +grep -n "version:\|_dbVersion\|schemaVersion\|version = " apps/acs/lib/core/database/encrypted_database.dart | head +git log --diff-filter=A --format='%h %ad %s' --date=short -- apps/acs/lib/core/database/sqlcipher_visit_store.dart | tail -2 +git tag --list | head; grep -n "release\|publicad" PROGRESS.md | grep -i "play\|loja\|publicad" | head -3 +``` +Expected: schema local em 7; nenhuma tag de release e nenhuma publicação em loja → as linhas legadas só existem em aparelhos de desenvolvimento anteriores a 2026-10-04. **Decisão registrada:** com esse resultado, 1d **permanece como está** (a visita sobe sem autoria, auditada, e o servidor nunca a credita ao transportador). Se algum release tiver existido, parar e pedir decisão ao responsável antes da Task 2. + +- [ ] **Step 4: Registrar** o resultado literal; nada a commitar. + +--- + +### Task 2: Prender, no servidor e no cliente, o que a Task 1 só leu (testes de caracterização) + +**Files:** +- Test: `backend/sinalacs_server/test/unit/visit_legacy_sync_test.dart` (acrescentar) +- Test: `backend/sinalacs_server/test/unit/visit_sync_service_test.dart` (acrescentar) + +**Interfaces:** +- Consumes: os helpers e fakes já usados por esses dois arquivos (ler o `setUp` e o fake de store de cada um antes de escrever; usar os nomes deles, não os abaixo, se diferirem). + +São testes de **caracterização**: o comportamento já existe, então eles passam de primeira. Para cada um, provar que ele **pode** falhar com uma mutação (Step 3), senão não valem nada. + +- [ ] **Step 1: Escrever os testes** + +Em `visit_sync_service_test.dart`, junto dos testes de autoria: + +```dart + test('o ACS B não consegue reescrever visita do ACS A enviando o mesmo localId', () async { + // A grava a visita com `localId` L. + await serviceDeA.sync(user: acsA, deviceId: 'dev-1', entries: [entry(localId: 'L')]); + + // B reenvia o MESMO `localId` com o próprio JWT (cliente adulterado). + final resultados = await serviceDeB.sync(user: acsB, deviceId: 'dev-1', entries: [entry(localId: 'L')]); + + expect(resultados.single.status, SyncStatus.rejected); + // A autoria continua a de A, e nada foi regravado. + final gravada = await store.findByLocalId('L'); + expect(gravada!.acsId, UuidValue.fromString(acsA.id)); + expect(gravada.authorship, VisitAuthorship.acs); + }); +``` + +Em `visit_legacy_sync_test.dart`, junto do teste de autoria nula: + +```dart + test('visita legada enviada por B nunca vira de B: sem acsId, e fora do pull de B', () async { + await service.syncLegacy(transporter: acsB, deviceId: 'dev-1', entries: [entry(localId: 'LEG-1')]); + + final gravada = await store.findByLocalId('LEG-1'); + expect(gravada!.acsId, isNull); + expect(gravada.authorship, VisitAuthorship.legacyUnclaimed); + + final doPullDeB = await service.pull(user: acsB, since: null); + expect(doPullDeB.visits.map((v) => v.localId), isNot(contains('LEG-1'))); + }); +``` +(`serviceDeA`/`serviceDeB`, `acsA`/`acsB`, `entry(...)`, `store.findByLocalId` e `service.pull` são os nomes que o arquivo já usa ou o equivalente que ele expõe; se `findByLocalId` não existir no fake, ler o campo que o fake guarda — `written`/`rows` — e asseverar sobre ele.) + +- [ ] **Step 2: Rodar** + +Run: `cd backend/sinalacs_server && dart test test/unit/visit_sync_service_test.dart test/unit/visit_legacy_sync_test.dart` +Expected: passam (caracterização). + +- [ ] **Step 3: Provar que podem falhar (mutação, depois restaurar)** + +```bash +cd /home/rock/Documents/Dev/APPs/SinalACS/backend/sinalacs_server +cp lib/src/application/visits/visit_sync_service.dart /tmp/vss.bak +# Mutação: o legado passa a creditar o transportador. +sed -i 's/VisitAuthorship.legacyUnclaimed/VisitAuthorship.acs/' lib/src/application/visits/visit_sync_service.dart +dart test test/unit/visit_legacy_sync_test.dart 2>&1 | tail -3 +cp /tmp/vss.bak lib/src/application/visits/visit_sync_service.dart && git diff --stat lib | wc -l +``` +Expected: o teste novo (e o antigo de autoria) **falham** com a mutação; depois da restauração `git diff --stat` não lista nada (`0`). Registrar a saída. Repetir para o primeiro teste (mutação: remover a recusa de `localId` de outro agente em `_syncOne`); se `sed` não casar, anotar e usar uma edição manual equivalente. + +- [ ] **Step 4: Commit** + +```bash +git add backend/sinalacs_server/test/unit +git commit -m "test(visitas): prende que B nao reescreve visita de A e que a legada nao vira de B" +``` + +--- + +### Task 3: Opt-out de captura de tela só em debug (falha primeiro) + +**Files:** +- Modify: `apps/acs/android/app/build.gradle.kts` (buildFeatures, buildConfigField, guard) +- Modify: `apps/acs/android/app/src/main/kotlin/br/com/prismrr/sinalacs/acs/MainActivity.kt:12-21` +- Modify: `apps/acs/test/secure_window_test.dart` +- Modify: `scripts/qa/acs_release_signing.sh` (cenário 6), `scripts/qa/acs_secure_window.sh` (modo `--captura`) + +**Interfaces:** +- Produces: `BuildConfig.ALLOW_SCREEN_CAPTURE: Boolean` (`false` em todo build, exceto `debug` com `-Psinalacs.allowScreenCapture=true`); `MainActivity.onCreate` só aplica `FLAG_SECURE` quando ele é `false`; guard do Gradle que falha em `assembleRelease`/`bundleRelease`/`packageRelease` quando a propriedade vem. + +- [ ] **Step 1: Escrever os testes que falham** + +Em `apps/acs/test/secure_window_test.dart`, acrescentar (o arquivo lê `MainActivity.kt`; ler também o Gradle): + +```dart + final gradle = File('android/app/build.gradle.kts').readAsStringSync(); + + test('FLAG_SECURE só é dispensada pela constante de compilação ALLOW_SCREEN_CAPTURE', () { + final inicio = fonte.indexOf('override fun onCreate'); + final fim = fonte.indexOf('override fun configureFlutterEngine'); + final corpo = fonte.substring(inicio, fim); + // A aplicação da flag está condicionada à constante, e a constante não é + // lida de nenhum outro lugar (nada de SharedPreferences, intent, menu). + expect(corpo, matches(RegExp(r'if\s*\(\s*!BuildConfig\.ALLOW_SCREEN_CAPTURE\s*\)'))); + expect(fonte, isNot(contains('getSharedPreferences'))); + expect(fonte, isNot(contains('getIntent'))); + expect(fonte, isNot(contains('getBooleanExtra'))); + }); + + test('o Gradle liga ALLOW_SCREEN_CAPTURE só no debug e barra a propriedade em release', () { + expect(gradle, contains('buildConfig = true')); + // Padrão false para todo build. + expect(gradle, matches(RegExp(r'defaultConfig\s*\{[\s\S]*?ALLOW_SCREEN_CAPTURE",\s*"false"'))); + // Só o buildType debug lê a propriedade. + expect(gradle, matches(RegExp(r'debug\s*\{[\s\S]*?licenca\("sinalacs\.allowScreenCapture"\)'))); + // E o release que a recebe falha. + expect(gradle, contains('sinalacs.allowScreenCapture')); + expect(gradle, contains('captura de tela')); + }); +``` + +- [ ] **Step 2: Ver falhar** + +Run: `cd apps/acs && flutter test test/secure_window_test.dart` +Expected: os dois testes novos falham (`BuildConfig.ALLOW_SCREEN_CAPTURE` e `buildConfig = true` ainda não existem). + +- [ ] **Step 3: Implementar** + +`build.gradle.kts` — dentro de `android { ... }`, junto de `defaultConfig`: + +```kotlin + buildFeatures { + // AGP 9 desliga a geração de BuildConfig por padrão; a captura de tela + // do debug depende dela. + buildConfig = true + } +``` +em `defaultConfig { ... }`: + +```kotlin + // Captura de tela: FECHADA em todo build. Só o buildType debug pode abri-la. + buildConfigField("boolean", "ALLOW_SCREEN_CAPTURE", "false") +``` +e no bloco `buildTypes { ... }` (criar o `debug` se não existir; o `release` já existe): + +```kotlin + debug { + // Opt-out para QA, vídeo de demonstração e Firebase Test Lab: só aqui, só + // com `-Psinalacs.allowScreenCapture=true` (o texto exato `true`). Release + // e profile herdam o `false` do defaultConfig e não leem a propriedade. + buildConfigField("boolean", "ALLOW_SCREEN_CAPTURE", licenca("sinalacs.allowScreenCapture").toString()) + } +``` +No guard `gradle.taskGraph.whenReady` (depois dos dois guards de release existentes, antes do fim do bloco), acrescentar: + +```kotlin + if (buildaRelease && licenca("sinalacs.allowScreenCapture")) { + throw GradleException( + """ + |-Psinalacs.allowScreenCapture=true não vale em build de release: a janela do ACS + |(que mostra nome e condições crônicas de pacientes) fica sem captura de tela + |liberada SOMENTE em debug. Para gravar a demonstração, gere a build de debug: + | ./scripts/dev/run_acs.sh --build --captura + """.trimMargin() + ) + } +``` +`MainActivity.kt` — trocar o `window.setFlags(...)` de `onCreate` por: + +```kotlin + // A lista da microárea mostra nome e condições crônicas. FLAG_SECURE + // bloqueia captura de tela, gravação e a miniatura nos apps recentes, na + // janela INTEIRA. A ÚNICA exceção é a constante de compilação abaixo, que + // o Gradle só liga no buildType debug por `-Psinalacs.allowScreenCapture=true` + // e recusa em release: nenhum menu, intent ou preferência a altera. + if (!BuildConfig.ALLOW_SCREEN_CAPTURE) { + window.setFlags( + WindowManager.LayoutParams.FLAG_SECURE, + WindowManager.LayoutParams.FLAG_SECURE, + ) + } +``` +(manter o comentário original sobre Área/Visita/seletor em vez de apagá-lo; o `BuildConfig` do pacote `br.com.prismrr.sinalacs.acs` é o gerado e não precisa de `import`.) + +- [ ] **Step 4: Ver passar, e os testes antigos continuarem passando** + +Run: `cd apps/acs && flutter test test/secure_window_test.dart test/android_manifest_test.dart` +Expected: passam. O teste antigo "DENTRO de onCreate" procura `window.setFlags(` e `FLAG_SECURE` no corpo de `onCreate`: continua verdadeiro com o `if`. + +- [ ] **Step 5: Provar nos três builds (emulador `emulator-5554`)** + +Debug **sem** a propriedade (o padrão): `FLAG_SECURE` presente. +```bash +./scripts/qa/acs_secure_window.sh --reinstalar +``` +Expected: `OK — janela do ACS com FLAG_SECURE`. + +Debug **com** a propriedade: a flag **ausente**. Acrescentar ao `acs_secure_window.sh` um argumento `--captura` (no `case` dos argumentos, `captura=1`), que passa `-Psinalacs.allowScreenCapture=true` ao `flutter build apk --debug` e inverte a asserção: + +```bash +if [[ "$captura" -eq 1 ]]; then + if grep -qw SECURE <<<"$flags"; then + echo "erro: o build de debug com captura liberada ainda tem FLAG_SECURE: $flags" >&2 + exit 1 + fi + echo "OK — debug com --captura: janela do ACS SEM FLAG_SECURE (só para demonstração)" + exit 0 +fi +``` +(o ramo vai antes do `grep -qw SECURE` já existente, e o `flutter build` passa a receber `${captura:+-Psinalacs.allowScreenCapture=true}`). +```bash +./scripts/qa/acs_secure_window.sh --reinstalar --captura +``` +Expected: `OK — debug com --captura: janela do ACS SEM FLAG_SECURE`. + +Release: a propriedade é recusada. Em `scripts/qa/acs_release_signing.sh`, depois do cenário 5: + +```bash +echo "== cenário 6: -Psinalacs.allowScreenCapture=true NÃO vale em release ==" +if saida="$(construir -Psinalacs.allowDebugSigning=true -Psinalacs.allowDevClientKey=true -Psinalacs.allowScreenCapture=true 2>&1)"; then + echo "erro: o release aceitou allowScreenCapture=true." >&2 + exit 1 +fi +grep -q "captura de tela" <<<"$saida" || { echo "erro: a falha não explicou o motivo:" >&2; tail -n 15 <<<"$saida" >&2; exit 1; } +``` +Run: `./scripts/qa/acs_release_signing.sh` +Expected: cenários 1–6 passam. Antes de implementar o guard, o cenário 6 deve falhar com "o release aceitou allowScreenCapture=true" (ver o RED rodando o script antes do Step 3, com o `build.gradle.kts` intacto). + +Release **instalado**: a flag presente (o `FLAG_SECURE` de release é o ponto do plano). Com o APK que o cenário 2 deixa em `apps/acs/build/app/outputs/flutter-apk/app-release.apk` (assinado com a chave de debug de propósito), conferir no emulador, **sem** apagar nada que importe (o app de desenvolvimento é sacrificável; usar `--reinstalar` como nos outros scripts): +```bash +export PATH="$PATH:$HOME/Android/Sdk/platform-tools" +adb -s emulator-5554 uninstall br.com.prismrr.sinalacs.acs >/dev/null 2>&1 || true +adb -s emulator-5554 install -r apps/acs/build/app/outputs/flutter-apk/app-release.apk +adb -s emulator-5554 shell monkey -p br.com.prismrr.sinalacs.acs -c android.intent.category.LAUNCHER 1 >/dev/null 2>&1 +adb -s emulator-5554 shell dumpsys window windows | awk '/^ Window #/ { d = index($0,"br.com.prismrr.sinalacs.acs/")>0 } d && /^ +fl=/ && !a { print; a=1 }' +``` +Expected: a linha `fl=` contém `SECURE`. (Se o último `flutter build` do script foi o do cenário 6 e falhou, reconstruir o release antes: `cd apps/acs && flutter build apk --release --dart-define=SINALACS_MQTT_PASSWORD=$MQTT_ACS_PASSWORD -Psinalacs.allowDebugSigning=true -Psinalacs.allowDevClientKey=true`.) + +- [ ] **Step 6: Commit** + +```bash +git add apps/acs/android/app/build.gradle.kts apps/acs/android/app/src/main/kotlin apps/acs/test/secure_window_test.dart scripts/qa/acs_release_signing.sh scripts/qa/acs_secure_window.sh +git commit -m "feat(acs): captura de tela liberada so em debug, por constante de compilacao; release a recusa" +``` + +--- + +### Task 4: Caminho de uso para QA e vídeo (`--captura`) e documentação da captura + +**Files:** +- Modify: `scripts/dev/run_acs.sh` (flag `--captura`) +- Modify: `docs/telas-acs.md:59`, `video/capture/lib.sh` (cabeçalho/comentário), `PROGRESS.md` (linha ~612 e os "Minors adiados" T1) + +**Interfaces:** +- Consumes: `-Psinalacs.allowScreenCapture=true` (Task 3). +- Produces: `./scripts/dev/run_acs.sh [--build] --captura` → APK/execução de debug com captura liberada, e um aviso visível. + +- [ ] **Step 1: Teste que falha** — `scripts/dev/run_acs_captura_test.sh` (hermético: usa um `flutter` falso no `PATH` que só grava os argumentos recebidos): + +```bash +#!/usr/bin/env bash +# `run_acs.sh --captura` só acrescenta a propriedade do Gradle ao build de debug. +set -euo pipefail +aqui="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT +mkdir -p "$tmp/bin" +cat >"$tmp/bin/flutter" <"$tmp/args" +exit 0 +EOF +chmod +x "$tmp/bin/flutter" +falha() { echo "FALHOU: $*" >&2; exit 1; } + +# O wrapper exige .env e CAs; o teste só confere a montagem dos argumentos. +PATH="$tmp/bin:$PATH" RUN_ACS_SKIP_ENV_CHECK=1 "$aqui/run_acs.sh" --build --captura --skip-ca >"$tmp/saida" 2>&1 || true +grep -qx -- '-Psinalacs.allowScreenCapture=true' "$tmp/args" || falha "a propriedade não chegou ao flutter: $(cat "$tmp/args" 2>/dev/null)" +grep -q 'captura de tela' "$tmp/saida" || falha "faltou o aviso de que o APK é só para demonstração" + +PATH="$tmp/bin:$PATH" RUN_ACS_SKIP_ENV_CHECK=1 "$aqui/run_acs.sh" --build --skip-ca >/dev/null 2>&1 || true +grep -q 'allowScreenCapture' "$tmp/args" && falha "sem --captura a propriedade não pode ir" +echo "OK — run_acs.sh --captura só acrescenta a propriedade quando pedido" +``` +Run: `chmod +x scripts/dev/run_acs_captura_test.sh && ./scripts/dev/run_acs_captura_test.sh` +Expected: `FALHOU: a propriedade não chegou ao flutter` (o wrapper ainda não conhece `--captura`). **Antes de escrever o teste, ler `run_acs.sh` por inteiro:** ele exige `.env` e o `sync_dev_ca.sh`; `RUN_ACS_SKIP_ENV_CHECK` **não existe** hoje — se o wrapper abortar antes de chamar o `flutter`, em vez de inventar essa variável no script de produção, montar no teste um `.env` e uma `apps/acs/assets/certs/` falsos num diretório temporário e apontar o wrapper para ele (`SYNC_DEV_CA_ROOT` já existe para o `sync_dev_ca.sh`; para o resto, copiar o padrão de `scripts/dev/sync_dev_ca_test.sh`). Registrar qual caminho foi usado. + +- [ ] **Step 2: Implementar** — em `run_acs.sh`, no `case` dos argumentos: `--captura) captura=1; shift ;;`; e onde monta `flutter_args`: `[[ "${captura:-0}" -eq 1 ]] && flutter_args+=(-Psinalacs.allowScreenCapture=true) && echo 'AVISO: build de DEBUG com captura de tela liberada (FLAG_SECURE desligada). Só para QA e demonstração, com dados sintéticos; nunca instalar em aparelho com paciente real.' >&2`. Acrescentar `# ./scripts/dev/run_acs.sh --build --captura` ao cabeçalho de uso. + +- [ ] **Step 3: Ver passar** + +Run: `./scripts/dev/run_acs_captura_test.sh && bash -n scripts/dev/run_acs.sh` +Expected: `OK — run_acs.sh --captura ...`. Em seguida o fluxo real: `./scripts/dev/run_acs.sh --build --captura` imprime o aviso e gera o APK; instalar no emulador e `adb exec-out screencap -p > /tmp/claude-1000/ok.png` deve devolver um arquivo **não vazio** (antes, com `FLAG_SECURE`, saía preto/vazio). Abrir a imagem só para conferir que não é preta e **apagá-la** (dados sintéticos do seed, mas não a deixe no repositório). + +- [ ] **Step 4: Documentar** + +- `docs/telas-acs.md:59`: trocar "para regenerar capturas é preciso retirar a flag temporariamente da `MainActivity`" por "gere o APK com `./scripts/dev/run_acs.sh --build --captura` (só debug; o release recusa a propriedade)". +- `video/capture/lib.sh`: no cabeçalho, uma linha dizendo que o app do ACS precisa estar instalado a partir de uma build `--captura`, e um erro claro quando `screencap` volta vazio (em vez de gravar vídeo preto em silêncio) — **só se** a leitura do arquivo mostrar que ele já tem um ponto único onde a captura é feita; senão, só o comentário. +- `PROGRESS.md`: substituir a linha ~612 ("um build de debug com opt-out documentado NÃO foi adicionado (decisão pendente)") pelo desfecho; e o `T1` de "Minors adiados". + +- [ ] **Step 5: Commit** + +```bash +git add scripts/dev/run_acs.sh scripts/dev/run_acs_captura_test.sh docs/telas-acs.md video/capture/lib.sh PROGRESS.md +git commit -m "feat(dev): run_acs.sh --captura gera o APK de debug com captura de tela liberada" +``` + +--- + +### Task 5: Fechar a documentação e o grafo + +**Files:** +- Modify: `PROGRESS.md` (seção da fila por dono: o veredito do ponto 1), `CLAUDE.md` e `apps/CLAUDE.md` (a frase de `FLAG_SECURE`), `spec/lgpd_design.md` (nota sobre captura em debug), `.github/workflows/ci.yml` (adicionar `run_acs_captura_test.sh` ao passo "Testes dos scripts de CI", se o teste não depender de Docker nem de emulador) + +- [ ] **Step 1: Registrar o veredito do ponto 1** em `PROGRESS.md`, com o resultado literal da Task 1 e da Task 2: o servidor nunca credita o transportador (mutação vista falhar), o JWT de B nunca carrega fila de A (testes citados pelo nome), e a única exceção, as linhas legadas, sobe sem autoria e fica registrada como **decisão aceita** com o motivo medido (nenhuma release publicada), com a condição de reabrir se uma release existir. +- [ ] **Step 2: Atualizar** as frases de `FLAG_SECURE` em `CLAUDE.md` e `apps/CLAUDE.md` ("`FLAG_SECURE` on the ACS window" → "…on every build; only a debug build made with `--captura` opens it, by a compile-time constant that release refuses"). +- [ ] **Step 3: CI** — se `run_acs_captura_test.sh` é hermético, acrescentá-lo à lista do passo "Testes dos scripts de CI" de `workflow-lint` e rodar `./scripts/qa/ci_invariants.sh` (a lista de jobs não muda). +- [ ] **Step 4: Verificações finais** + +```bash +cd apps/acs && flutter analyze && flutter test +cd ../../backend/sinalacs_server && dart test test/unit +cd ../.. && ./scripts/dev/run_acs_captura_test.sh && ./scripts/qa/ci_invariants.sh && ./scripts/qa/check_documentation_links.sh && python3 scripts/qa/contagem_validation_report.py && graphify update . +``` +Expected: tudo verde; se `contagem_validation_report.py` apontar divergência, atualizar o número citado (os testes novos mudam o total). +- [ ] **Step 5: Commit** + +```bash +git add PROGRESS.md CLAUDE.md apps/CLAUDE.md spec/lgpd_design.md .github/workflows/ci.yml +git commit -m "docs: veredito da autoria das visitas e da captura de tela em debug" +``` + +## Self-Review + +- **Cobertura:** ponto 1 → Tasks 1 (medir), 2 (prender) e a decisão da linha legada; ponto 2 → Tasks 3 (constante, Gradle, guard, provas nos três builds), 4 (caminho de uso e docs) e 5 (fechamento). A proposta "Background Worker com client credentials" está em "Fora do escopo" com o motivo (o token de envio por dono já faz o papel; WorkManager exige decisão fora da stack). +- **Placeholders:** os nomes de fakes/helpers dos testes da Task 2 e a necessidade de ler `run_acs.sh` antes de escrever o teste da Task 4 estão marcados explicitamente como leitura obrigatória com arquivo e linha; nenhum passo diz "adicionar tratamento apropriado". +- **Consistência de tipos:** `BuildConfig.ALLOW_SCREEN_CAPTURE`, `licenca("sinalacs.allowScreenCapture")`, `--captura` e a propriedade `-Psinalacs.allowScreenCapture=true` aparecem com os mesmos nomes em todas as tasks. diff --git a/docs/superpowers/plans/2026-10-05-biometria-amarrada-ao-keystore.md b/docs/superpowers/plans/2026-10-05-biometria-amarrada-ao-keystore.md new file mode 100644 index 00000000..f929cad9 --- /dev/null +++ b/docs/superpowers/plans/2026-10-05-biometria-amarrada-ao-keystore.md @@ -0,0 +1,1128 @@ +# Biometria amarrada ao Keystore (refresh token do ACS) — Plano de Implementação + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fazer o refresh token do ACS só poder ser decifrado depois de uma autenticação biométrica/PIN **imposta pelo Keystore (TEE)**, e não apenas por um portão de interface. + +**Architecture:** Criptografia de envelope. Par RSA-2048 (OAEP) no Android Keystore com `setUserAuthenticationRequired(true)`. A cada `seal` gera-se uma chave AES-256-GCM descartável: ela cifra o refresh token (sem limite de tamanho) e é ela própria cifrada com a **chave pública** RSA (sem prompt: a renovação a cada 15 min grava o token rotacionado em silêncio). O blob salvo é `Base64(IV).Base64(chaveAES_RSA).Base64(token_AES)`. A **decifra** exige `BiometricPrompt` com `CryptoObject` sobre a chave privada RSA (libera a AES, que decifra o token), uma vez por partida a frio. Depois do desbloqueio o token fica em RAM (como já fica a sessão). Um plugin Kotlin próprio (`MethodChannel`) faz isso; o lado Dart é uma `SessionTokenStore` nova (`AuthBoundSessionTokenStore`) que nunca abre prompt dentro de `read()`. + +**Tech Stack:** Flutter/Dart (apps/acs), Kotlin + `androidx.biometric` 1.1.0, Android Keystore, `local_auth` 3.x (continua sendo o portão do `AppLockGate`), `flutter_test`. + +**Spec:** `PROGRESS.md` §"Refresh token rotativo e desbloqueio biométrico do ACS" (Limite de desenho + Aberto (a)); `docs/superpowers/plans/2026-10-03-refresh-token-e-biometria-do-acs.md`; `spec/lgpd_design.md` §5.1; `spec/stack.md` (sem framework novo: é invólucro de API de plataforma). + +## Global Constraints + +- minSdk 24 (`apps/acs/android/app/build.gradle*`); `compileSdk` 36. A solução deve degradar, não quebrar, em API 24–29. +- Nunca registrar token, chave ou mensagem de exceção do Keystore em log (LGPD-RF09). +- Falha fechada: qualquer erro/cancelamento do prompt **não** devolve o token. +- `BackendClient._exclusive` e a semântica de `read()` dentro dele não mudam: `read()` jamais abre UI. +- Rodar a migração do token legado **antes** do primeiro `_tryResume` (Task 4); se ela falhar, manter o legado (não deslogar). +- Fila de visitas por dono, token de envio diferido (`acs_upload_token|`) e chave do SQLCipher **ficam fora do escopo** (ver "Fora do escopo"). +- Textos de UI em português; commits sem atribuição de IA (regra do repositório). +- Rodar `graphify update .` depois de modificar código. + +## Review Focus + +1. **Nova digital cadastrada invalida a chave** (`KeyPermanentlyInvalidatedException`): esperado = token apagado, volta ao login completo com "Sua sessão expirou. Entre novamente.", nunca laço de prompt. (Task 1, Task 3) +2. **Cancelar o prompt não apaga o token**: tentar de novo funciona; só invalidação/recusa do servidor apaga. (Task 1) +3. **Aparelho sem bloqueio de tela/biometria (ou API < 30 sem biometria forte)**: o token não é persistido (só RAM) e a partida a frio exige login completo. (Task 1, Task 4) +4. **Token legado já gravado no `flutter_secure_storage`** (instalações atuais): migra para a chave vinculada sem perder a sessão e apaga o original. (Task 4) +5. **Blob corrompido, truncado ou com GCM adulterado** (falha de tag): tratado como `unavailable`, nunca crash nem token parcial; tokens de qualquer tamanho cabem (envelope). (Task 3) +6. **Logout explícito**: apaga o blob e o par RSA do Keystore, sem chave órfã. (Task 5) +7. **Migração interrompida no meio** (app morto entre selar e apagar o legado): na próxima partida o legado ainda existe e o cofre também; apagar o legado sem perder a sessão. (Task 4) + +--- + +## Arquivos + +| Arquivo | Responsabilidade | +|---|---| +| `apps/acs/lib/core/security/session_token_store.dart` (modificar) | acrescenta `SessionUnlock`, `contains()` e `unlock()` à interface; `Memory` os implementa | +| `apps/acs/lib/core/security/keystore_vault.dart` (criar) | porta `KeystoreVault`, `VaultException`, duplo `FakeKeystoreVault` e `MethodChannelKeystoreVault` | +| `apps/acs/lib/core/security/auth_bound_session_token_store.dart` (criar) | `SessionTokenStore` com cache em RAM + cofre; migração do legado | +| `apps/acs/lib/core/security/secure_session_token_store.dart` (modificar) | `contains`/`unlock` (`notRequired`) no store legado | +| `apps/acs/lib/core/network/backend_client.dart` (modificar) | `unlockStoredSession`; `hasStoredSession` usa `contains()` | +| `apps/acs/lib/app/app.dart` (modificar `_tryResume`) | usa o prompt do cofre em vez do `gate.authenticate` quando o store o fornece | +| `apps/acs/android/app/src/main/kotlin/br/com/prismrr/sinalacs/acs/KeystoreVault.kt` (criar) | Keystore + `BiometricPrompt`/`CryptoObject` | +| `apps/acs/android/app/src/main/kotlin/.../MainActivity.kt` (modificar) | registra o canal | +| `apps/acs/android/app/build.gradle(.kts)` (modificar) | `androidx.biometric:biometric:1.1.0` | +| `apps/acs/lib/main.dart` (modificar) | monta `AuthBoundSessionTokenStore` | +| `scripts/qa/acs_keystore_bound_e2e.sh` (criar) | prova no emulador | +| `PROGRESS.md`, `apps/CLAUDE.md`, `CLAUDE.md`, `spec/lgpd_design.md` (modificar) | documentação | + +## Fora do escopo (decisão, com motivo) + +- **Chave do SQLCipher**: o banco abre antes do login e a fila grava com o app bloqueado (alerta chegando); exigir prompt por uso quebraria o offline-first. Segue no Keystore sem vínculo de usuário. +- **Token de envio diferido**: existe exatamente para subir visitas **sem** sessão (depois do "Sair"). Vinculá-lo a biometria o inutiliza. +- **iOS**: não existe. + +--- + +### Task 1: Contrato Dart — porta do cofre e store vinculado + +**Files:** +- Modify: `apps/acs/lib/core/security/session_token_store.dart` +- Create: `apps/acs/lib/core/security/keystore_vault.dart` +- Create: `apps/acs/lib/core/security/auth_bound_session_token_store.dart` +- Modify: `apps/acs/test/support/gated_session_token_store.dart` +- Test: `apps/acs/test/auth_bound_session_token_store_test.dart` + +**Interfaces:** +- Produces: `enum SessionUnlock { unlocked, notRequired, cancelled, lockedOut, unavailable }`; em `SessionTokenStore`: `Future contains()` e `Future unlock({required String reason})`; `abstract interface class KeystoreVault { Future get isSupported; Future contains(String alias); Future seal(String alias, String plaintext); Future unseal(String alias, {required String reason}); Future delete(String alias); }`; `enum VaultFailure { cancelled, lockedOut, invalidated, unavailable }`; `class VaultException implements Exception { const VaultException(this.failure); final VaultFailure failure; }`; `class FakeKeystoreVault implements KeystoreVault` (campos de teste: `supported`, `failSeal`, `nextUnsealFailure`, `unsealCalls` (prompts abertos), `readCalls` (consultas a `contains`, nunca abrem prompt), `sealed`); `class AuthBoundSessionTokenStore implements SessionTokenStore { AuthBoundSessionTokenStore({required KeystoreVault vault, required SessionTokenStore legacy}); static const alias = 'acs_refresh_token'; }`. + +- [ ] **Step 1: Escrever os testes que falham** + +```dart +// apps/acs/test/auth_bound_session_token_store_test.dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/core/security/auth_bound_session_token_store.dart'; +import 'package:sinalacs_acs/core/security/keystore_vault.dart'; +import 'package:sinalacs_acs/core/security/session_token_store.dart'; + +void main() { + late FakeKeystoreVault vault; + late MemorySessionTokenStore legacy; + late AuthBoundSessionTokenStore store; + + setUp(() { + vault = FakeKeystoreVault(); + legacy = MemorySessionTokenStore(); + store = AuthBoundSessionTokenStore(vault: vault, legacy: legacy); + }); + + test('write sela no cofre e mantém em RAM; read nunca abre prompt', () async { + await store.write('tok-1'); + expect(vault.sealed[AuthBoundSessionTokenStore.alias], 'tok-1'); + expect(await store.read(), 'tok-1'); + expect(vault.unsealCalls, 0); + }); + + test('partida a frio: contains é true, read é null até unlock', () async { + vault.sealed[AuthBoundSessionTokenStore.alias] = 'tok-1'; + final cold = AuthBoundSessionTokenStore(vault: vault, legacy: legacy); + expect(await cold.contains(), isTrue); + expect(await cold.read(), isNull); + expect(await cold.unlock(reason: 'x'), SessionUnlock.unlocked); + expect(await cold.read(), 'tok-1'); + expect(vault.unsealCalls, 1); + }); + + test('cancelar o prompt NÃO apaga o token e permite tentar de novo', () async { + vault.sealed[AuthBoundSessionTokenStore.alias] = 'tok-1'; + final cold = AuthBoundSessionTokenStore(vault: vault, legacy: legacy); + vault.nextUnsealFailure = VaultFailure.cancelled; + expect(await cold.unlock(reason: 'x'), SessionUnlock.cancelled); + expect(await cold.contains(), isTrue); + expect(await cold.unlock(reason: 'x'), SessionUnlock.unlocked); + }); + + test('lockedOut e unavailable não devolvem token', () async { + vault.sealed[AuthBoundSessionTokenStore.alias] = 'tok-1'; + final cold = AuthBoundSessionTokenStore(vault: vault, legacy: legacy); + vault.nextUnsealFailure = VaultFailure.lockedOut; + expect(await cold.unlock(reason: 'x'), SessionUnlock.lockedOut); + vault.nextUnsealFailure = VaultFailure.unavailable; + expect(await cold.unlock(reason: 'x'), SessionUnlock.unavailable); + expect(await cold.read(), isNull); + }); + + test('chave invalidada (nova digital) apaga token e cofre', () async { + vault.sealed[AuthBoundSessionTokenStore.alias] = 'tok-1'; + final cold = AuthBoundSessionTokenStore(vault: vault, legacy: legacy); + vault.nextUnsealFailure = VaultFailure.invalidated; + expect(await cold.unlock(reason: 'x'), SessionUnlock.unavailable); + expect(await cold.contains(), isFalse); + expect(vault.sealed, isEmpty); + }); + + test('aparelho sem suporte: token só em RAM, nada persistido', () async { + vault.supported = false; + await store.write('tok-1'); + expect(await store.read(), 'tok-1'); + expect(vault.sealed, isEmpty); + final cold = AuthBoundSessionTokenStore(vault: vault, legacy: legacy); + expect(await cold.contains(), isFalse); + }); + + test('falha ao selar degrada para RAM e não lança', () async { + vault.failSeal = true; + await store.write('tok-1'); + expect(await store.read(), 'tok-1'); + expect(vault.sealed, isEmpty); + }); + + test('clear apaga RAM, cofre e legado', () async { + await store.write('tok-1'); + await legacy.write('velho'); + await store.clear(); + expect(await store.read(), isNull); + expect(await store.contains(), isFalse); + expect(await legacy.read(), isNull); + }); + + test('desbloqueios concorrentes abrem um único prompt', () async { + vault.sealed[AuthBoundSessionTokenStore.alias] = 'tok-1'; + final cold = AuthBoundSessionTokenStore(vault: vault, legacy: legacy); + final r = await Future.wait([cold.unlock(reason: 'x'), cold.unlock(reason: 'x')]); + expect(r, [SessionUnlock.unlocked, SessionUnlock.unlocked]); + expect(vault.unsealCalls, 1); + }); + + test('MemorySessionTokenStore: notRequired e contains', () async { + final m = MemorySessionTokenStore('a'); + expect(await m.contains(), isTrue); + expect(await m.unlock(reason: 'x'), SessionUnlock.notRequired); + }); +} +``` + +- [ ] **Step 2: Rodar e ver falhar** + +Run: `cd apps/acs && flutter test test/auth_bound_session_token_store_test.dart` +Expected: FAIL — arquivos/tipos inexistentes. + +- [ ] **Step 3: Implementar** + +`session_token_store.dart` — acrescentar acima de `SessionTokenStore` e na interface: + +```dart +/// Resultado do desbloqueio do token guardado. `notRequired`: este store não +/// tem prompt próprio (o chamador usa o portão de interface). +enum SessionUnlock { unlocked, notRequired, cancelled, lockedOut, unavailable } +``` + +Na interface acrescentar: + +```dart + /// `true` se há token guardado, **sem** decifrá-lo (nunca abre prompt). + Future contains(); + + /// Decifra o token guardado para a memória, abrindo o prompt do sistema se o + /// store o exige. `read()` só enxerga o token depois disto. + Future unlock({required String reason}); +``` + +Em `MemorySessionTokenStore`: + +```dart + @override + Future contains() async => _token != null; + + @override + Future unlock({required String reason}) async => SessionUnlock.notRequired; +``` + +`gated_session_token_store.dart`: delegar `contains` e `unlock` a `_inner`. + +`keystore_vault.dart`: + +```dart +import 'package:flutter/services.dart'; + +enum VaultFailure { cancelled, lockedOut, invalidated, unavailable } + +class VaultException implements Exception { + const VaultException(this.failure); + final VaultFailure failure; + // Sem mensagem de propósito: nunca carregar detalhe do Keystore para logs. + @override + String toString() => 'VaultException($failure)'; +} + +/// Cofre cuja decifra exige autenticação do usuário imposta pelo Keystore. +abstract interface class KeystoreVault { + Future get isSupported; + Future contains(String alias); + + /// Cifra com a chave pública: NÃO abre prompt. + Future seal(String alias, String plaintext); + + /// Abre o prompt biométrico/PIN e decifra. `null` se não há blob. + /// Lança [VaultException]. + Future unseal(String alias, {required String reason}); + + Future delete(String alias); +} + +class MethodChannelKeystoreVault implements KeystoreVault { + MethodChannelKeystoreVault([MethodChannel? channel]) + : _channel = channel ?? const MethodChannel('br.com.prismrr.sinalacs.acs/keystore_vault'); + + final MethodChannel _channel; + + @override + Future get isSupported async { + try { + return await _channel.invokeMethod('isSupported') ?? false; + } catch (_) { + return false; + } + } + + @override + Future contains(String alias) async { + try { + return await _channel.invokeMethod('contains', {'alias': alias}) ?? false; + } catch (_) { + return false; + } + } + + @override + Future seal(String alias, String plaintext) async { + try { + await _channel.invokeMethod('seal', {'alias': alias, 'plaintext': plaintext}); + } on PlatformException { + throw const VaultException(VaultFailure.unavailable); + } + } + + @override + Future unseal(String alias, {required String reason}) async { + try { + return await _channel.invokeMethod('unseal', {'alias': alias, 'reason': reason}); + } on PlatformException catch (e) { + throw VaultException(switch (e.code) { + 'cancelled' => VaultFailure.cancelled, + 'lockedOut' => VaultFailure.lockedOut, + 'invalidated' => VaultFailure.invalidated, + _ => VaultFailure.unavailable, + }); + } on MissingPluginException { + throw const VaultException(VaultFailure.unavailable); + } + } + + @override + Future delete(String alias) async { + try { + await _channel.invokeMethod('delete', {'alias': alias}); + } catch (_) {} + } +} + +/// Duplo de teste (RAM). +class FakeKeystoreVault implements KeystoreVault { + bool supported = true; + bool failSeal = false; + VaultFailure? nextUnsealFailure; + int unsealCalls = 0; // = unlockCalls: quantos prompts foram abertos + int readCalls = 0; // quantas vezes `contains` foi consultado (nunca abre prompt) + final Map sealed = {}; + + @override + Future get isSupported async => supported; + + @override + Future contains(String alias) async { + readCalls++; + return sealed.containsKey(alias); + } + + @override + Future seal(String alias, String plaintext) async { + if (failSeal) throw const VaultException(VaultFailure.unavailable); + sealed[alias] = plaintext; + } + + @override + Future unseal(String alias, {required String reason}) async { + unsealCalls++; + final failure = nextUnsealFailure; + if (failure != null) { + nextUnsealFailure = null; + if (failure == VaultFailure.invalidated) sealed.remove(alias); + throw VaultException(failure); + } + return sealed[alias]; + } + + @override + Future delete(String alias) async => sealed.remove(alias); +} +``` + +`auth_bound_session_token_store.dart`: + +```dart +import 'dart:async'; + +import 'keystore_vault.dart'; +import 'session_token_store.dart'; + +/// Refresh token do ACS cuja decifra só acontece depois de uma autenticação +/// biométrica/PIN imposta pelo Keystore (TEE), não por um portão de interface. +/// +/// - `write` cifra com a chave pública (sem prompt: a rotação de 15 min segue +/// silenciosa) e mantém o valor em RAM. +/// - `read` devolve só a RAM e **nunca** abre prompt (é chamado dentro de +/// `BackendClient._exclusive`). +/// - `unlock` é o único lugar que decifra, uma vez por partida a frio. +/// +/// Sem suporte (sem bloqueio de tela, API < 30 sem biometria forte) ou com +/// falha ao selar, o token fica só em RAM: a próxima partida a frio exige login +/// completo. Nunca registre o valor em log. +class AuthBoundSessionTokenStore implements SessionTokenStore { + AuthBoundSessionTokenStore({required KeystoreVault vault, required SessionTokenStore legacy}) + : _vault = vault, + _legacy = legacy; + + static const alias = 'acs_refresh_token'; + + final KeystoreVault _vault; + final SessionTokenStore _legacy; + String? _cache; + Future? _unlocking; + // Ligado quando a migração do legado terminou (ou não havia legado): daí em + // diante `contains()` só consulta o cofre, sem tocar no armazenamento legado. + bool _migrated = false; + + @override + Future read() async => _cache; + + @override + Future contains() async { + if (_cache != null) return true; + if (!await _vault.isSupported) return false; + if (!_migrated) await _migrateLegacy(); + return _vault.contains(alias); + } + + @override + Future unlock({required String reason}) { + if (_cache != null) return Future.value(SessionUnlock.unlocked); + return _unlocking ??= _unlock(reason).whenComplete(() => _unlocking = null); + } + + Future _unlock(String reason) async { + try { + await _migrateLegacy(); + final token = await _vault.unseal(alias, reason: reason); + if (token == null || token.isEmpty) return SessionUnlock.unavailable; + _cache = token; + return SessionUnlock.unlocked; + } on VaultException catch (e) { + switch (e.failure) { + case VaultFailure.cancelled: + return SessionUnlock.cancelled; + case VaultFailure.lockedOut: + return SessionUnlock.lockedOut; + case VaultFailure.invalidated: + // Nova digital cadastrada: a chave morreu, o token é irrecuperável. + await _vault.delete(alias); + return SessionUnlock.unavailable; + case VaultFailure.unavailable: + return SessionUnlock.unavailable; + } + } + } + + @override + Future write(String token) async { + _cache = token; + _migrated = true; // o legado é apagado abaixo; nada mais a migrar + try { + if (await _vault.isSupported) { + await _vault.seal(alias, token); + await _legacy.clear(); + return; + } + } on VaultException { + // Degrada para RAM. + } + // Sem cofre: não deixa um blob antigo ressuscitar um token já rotacionado. + await _vault.delete(alias); + await _legacy.clear(); + } + + @override + Future clear() async { + _cache = null; + await _vault.delete(alias); + await _legacy.clear(); + } + + /// Instalações antigas guardam o token no `flutter_secure_storage`. Sela-o + /// (sem prompt) e apaga o original; o `unseal` seguinte já pede biometria. + Future _migrateLegacy() async { + if (_migrated) return; + final old = await _legacy.read(); + if (old == null || old.isEmpty) { + _migrated = true; + return; + } + if (await _vault.contains(alias)) { + await _legacy.clear(); + _migrated = true; + return; + } + try { + await _vault.seal(alias, old); + await _legacy.clear(); + _migrated = true; + } on VaultException { + // Mantém o legado e NÃO liga o flag: a próxima chamada tenta de novo. + // Melhor um token no Keystore comum que perder a sessão. + } + } +} +``` + +> Nota: no ramo de falha de `seal`, o `catch` cai no fim e executa `_vault.delete` + `_legacy.clear()`. Isto é intencional (token rotacionado novo não pode conviver com um blob velho); `write` de um login novo substitui o blob. + +`secure_session_token_store.dart` — em `SecureStorageSessionTokenStore` acrescentar: + +```dart + @override + Future contains() async => await read() != null; + + @override + Future unlock({required String reason}) async => SessionUnlock.notRequired; +``` + +- [ ] **Step 4: Rodar e ver passar** + +Run: `cd apps/acs && flutter test test/auth_bound_session_token_store_test.dart && flutter analyze` +Expected: PASS; `analyze` pode apontar outros implementadores de `SessionTokenStore` (corrija com `contains`/`unlock` delegando, comportamento `notRequired`). + +- [ ] **Step 5: Commit** + +```bash +git add apps/acs/lib/core/security apps/acs/test +git commit -m "feat(acs): store do refresh token vinculado a um cofre do Keystore (contrato Dart)" +``` + +--- + +### Task 2: BackendClient e retomada a frio usam o prompt do cofre + +**Files:** +- Modify: `apps/acs/lib/core/network/backend_client.dart` (interface `AcsBackend` ~l.80-100, stub ~l.216, `hasStoredSession` ~l.616, `resumeSession` ~l.625) +- Modify: `apps/acs/lib/app/app.dart` (`_tryResume`, ~l.722-770) +- Modify: `apps/acs/test/support/fakes.dart` (implementadores de `AcsBackend`) +- Test: `apps/acs/test/session_resume_test.dart` + +**Interfaces:** +- Consumes: `SessionTokenStore.contains()/unlock()`, `SessionUnlock` (Task 1). +- Produces: `Future AcsBackend.unlockStoredSession({required String reason})`. + +- [ ] **Step 1: Testes que falham** (adicionar em `session_resume_test.dart`, usando os helpers já existentes do arquivo para montar o app com `biometricGate` falso e `BackendClient`) + +```dart +testWidgets('store com prompt próprio: o cofre desbloqueia e o gate de UI NÃO é chamado', + (tester) async { + final vault = FakeKeystoreVault()..sealed[AuthBoundSessionTokenStore.alias] = 'tok-1'; + final store = AuthBoundSessionTokenStore(vault: vault, legacy: MemorySessionTokenStore()); + final gate = FakeBiometricGate(); // já existe em test/support/fakes.dart + // monta app como nos testes vizinhos, passando tokenStore: store, biometricGate: gate + // ... + await tester.pumpAndSettle(); + expect(vault.unsealCalls, 1); + expect(gate.authenticateCalls, 0); + // e o painel abriu (mesma asserção dos testes vizinhos de retomada) +}); + +testWidgets('prompt do cofre cancelado: fica no login, token continua guardado', (tester) async { + final vault = FakeKeystoreVault() + ..sealed[AuthBoundSessionTokenStore.alias] = 'tok-1' + ..nextUnsealFailure = VaultFailure.cancelled; + // monta como acima + await tester.pumpAndSettle(); + expect(find.text('Entrar'), findsOneWidget); + expect(await store.contains(), isTrue); +}); + +testWidgets('chave invalidada: aviso de sessão expirada e login completo', (tester) async { + final vault = FakeKeystoreVault() + ..sealed[AuthBoundSessionTokenStore.alias] = 'tok-1' + ..nextUnsealFailure = VaultFailure.invalidated; + // monta como acima + await tester.pumpAndSettle(); + expect(find.text('Sua sessão expirou. Entre novamente.'), findsOneWidget); +}); +``` + +> Antes de escrever: abrir `test/session_resume_test.dart` e copiar o *setup* exato (nome do fake do gate, como o app é montado). O `BiometricGate` antigo mockava "pedir a digital"; com o cofre, essa responsabilidade passa ao `FakeKeystoreVault` (`unsealCalls`). Os nomes acima (`FakeBiometricGate`, `authenticateCalls`) devem ser conferidos nesse arquivo e em `test/support/fakes.dart`; ajuste para os nomes reais — a asserção é a mesma. + +- [ ] **Step 2: Rodar e ver falhar** + +Run: `cd apps/acs && flutter test test/session_resume_test.dart` +Expected: FAIL (`unlockStoredSession` inexistente). + +- [ ] **Step 3: Implementar** + +`AcsBackend`: + +```dart + /// Desbloqueia o refresh token guardado (prompt do Keystore, se o store o + /// exige). `notRequired`: o chamador usa o portão de interface. + Future unlockStoredSession({required String reason}); +``` + +Stub (`~l.216`): `Future unlockStoredSession({required String reason}) async => SessionUnlock.notRequired;`. Implementar o mesmo nos fakes de `test/support/fakes.dart`. + +`BackendClient`: + +```dart + @override + Future get hasStoredSession async { + try { + return await _tokenStore.contains(); + } catch (_) { + return false; + } + } + + @override + Future unlockStoredSession({required String reason}) async { + try { + return await _tokenStore.unlock(reason: reason); + } catch (_) { + return SessionUnlock.unavailable; // falha fechada + } + } +``` + +(Manter o `catch` original de `hasStoredSession` — ler o corpo atual antes de editar.) Em `resumeSession`, manter `if (await _tokenStore.read() == null) return null;` (agora só é não-nulo depois do `unlock`). + +`app.dart` `_tryResume`: substituir o bloco `UnlockResult unlock; try { unlock = await gate.authenticate(...) ... }` por: + +```dart + var unlocked = false; + final stored = await backend.unlockStoredSession(reason: 'Entrar no SinalACS'); + switch (stored) { + case SessionUnlock.unlocked: + unlocked = true; + case SessionUnlock.notRequired: + UnlockResult ui; + try { + ui = await gate.authenticate(reason: 'Entrar no SinalACS'); + } catch (_) { + ui = UnlockResult.unavailable; // falha fechada + } + unlocked = ui == UnlockResult.unlocked; + case SessionUnlock.cancelled || SessionUnlock.lockedOut || SessionUnlock.unavailable: + unlocked = false; + } + if (!unlocked || !mounted) return; +``` + +Depois do bloco `resumeSession`, o ramo `kept = await backend.hasStoredSession` já mostra "Sua sessão expirou." quando o token sumiu (caso `invalidated`). Conferir que `_resumePrompting` é desligado no `finally` (já é). + +- [ ] **Step 4: Rodar tudo do app** + +Run: `cd apps/acs && flutter test && flutter analyze` +Expected: PASS (a suíte inteira; os testes antigos usam `MemorySessionTokenStore` → `notRequired` → caminho antigo do gate). + +- [ ] **Step 5: Commit** + +```bash +git add apps/acs +git commit -m "feat(acs): retomada a frio desbloqueia o token pelo cofre quando o store o exige" +``` + +--- + +### Task 3: Plugin Kotlin — Keystore + BiometricPrompt/CryptoObject + +**Files:** +- Create: `apps/acs/android/app/src/main/kotlin/br/com/prismrr/sinalacs/acs/KeystoreVault.kt` +- Modify: `apps/acs/android/app/src/main/kotlin/br/com/prismrr/sinalacs/acs/MainActivity.kt` +- Modify: `apps/acs/android/app/build.gradle` (ou `.kts`) +- Test: `apps/acs/test/keystore_vault_channel_test.dart`, `apps/acs/test/android_manifest_test.dart` (já existe; estender) + +**Interfaces:** +- Produces: canal `br.com.prismrr.sinalacs.acs/keystore_vault` com métodos `isSupported`, `contains`, `seal`, `unseal`, `delete`; códigos de erro `cancelled`, `lockedOut`, `invalidated`, `unavailable` (os mesmos de `MethodChannelKeystoreVault`). + +- [ ] **Step 1: Teste do canal (Dart) que falha** + +```dart +// apps/acs/test/keystore_vault_channel_test.dart +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/core/security/keystore_vault.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('br.com.prismrr.sinalacs.acs/keystore_vault'); + final vault = MethodChannelKeystoreVault(); + + void mock(Future Function(MethodCall) h) => + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, h); + + tearDown(() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null)); + + for (final entry in { + 'cancelled': VaultFailure.cancelled, + 'lockedOut': VaultFailure.lockedOut, + 'invalidated': VaultFailure.invalidated, + 'qualquer-outro': VaultFailure.unavailable, + }.entries) { + test('unseal traduz o código ${entry.key}', () async { + mock((_) async => throw PlatformException(code: entry.key)); + expect( + () => vault.unseal('a', reason: 'x'), + throwsA(isA().having((e) => e.failure, 'failure', entry.value)), + ); + }); + } + + test('plugin ausente vira unavailable e isSupported false', () async { + expect(() => vault.unseal('a', reason: 'x'), throwsA(isA())); + expect(await vault.isSupported, isFalse); + }); + + test('seal que falha vira VaultException(unavailable)', () async { + mock((_) async => throw PlatformException(code: 'seal_failed')); + expect(() => vault.seal('a', 't'), throwsA(isA())); + }); +} +``` + +- [ ] **Step 2: Rodar** — Run: `cd apps/acs && flutter test test/keystore_vault_channel_test.dart` — Expected: PASS (a classe já existe da Task 1; este teste fixa o contrato que o Kotlin deve cumprir). + +- [ ] **Step 3: Gradle** + +Descobrir qual existe: `ls apps/acs/android/app/build.gradle*`. Fixar a **1.1.0** (estável; a 1.2.0-alpha é pré-lançamento e desnecessária). No bloco `dependencies` (criá-lo se não houver): + +Groovy (`build.gradle`): +```groovy +dependencies { + implementation "androidx.biometric:biometric:1.1.0" +} +``` + +Kotlin DSL (`build.gradle.kts`): +```kotlin +dependencies { + implementation("androidx.biometric:biometric:1.1.0") +} +``` + +- [ ] **Step 4: Kotlin** + +```kotlin +// KeystoreVault.kt +package br.com.prismrr.sinalacs.acs + +import android.content.Context +import android.os.Build +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyPermanentlyInvalidatedException +import android.security.keystore.KeyProperties +import android.util.Base64 +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import java.security.KeyFactory +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.PrivateKey +import java.security.PublicKey +import java.security.spec.MGF1ParameterSpec +import java.security.spec.X509EncodedKeySpec +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec +import javax.crypto.spec.OAEPParameterSpec +import javax.crypto.spec.PSource + +/** + * Cofre do refresh token (envelope): RSA-2048/OAEP no Android Keystore com + * autenticação do usuário exigida a CADA decifra (CryptoObject) protege uma + * chave AES-256-GCM descartável que cifra o token. Cifrar usa a chave pública e + * não pede prompt. Só o texto cifrado fica em SharedPreferences privado: sem a + * chave do TEE ele não vale nada. Nunca registre token, chave ou exceção. + */ +class KeystoreVault(private val activity: FragmentActivity) : MethodChannel.MethodCallHandler { + companion object { + const val CHANNEL = "br.com.prismrr.sinalacs.acs/keystore_vault" + private const val PROVIDER = "AndroidKeyStore" + private const val PREFS = "sinalacs_keystore_vault" + private const val TRANSFORMATION = "RSA/ECB/OAEPWithSHA-256AndMGF1Padding" + // O provedor do Keystore usa MGF1 com SHA-1 mesmo pedindo SHA-256. + private val OAEP = OAEPParameterSpec( + "SHA-256", "MGF1", MGF1ParameterSpec.SHA1, PSource.PSpecified.DEFAULT, + ) + } + + private val authenticators: Int = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + BiometricManager.Authenticators.BIOMETRIC_STRONG or + BiometricManager.Authenticators.DEVICE_CREDENTIAL + } else { + // Antes da API 30 o CryptoObject só aceita biometria forte. + BiometricManager.Authenticators.BIOMETRIC_STRONG + } + + private val prefs get() = activity.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + private fun keyAlias(alias: String) = "sinalacs.vault.$alias" + private fun keyStore() = KeyStore.getInstance(PROVIDER).apply { load(null) } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + val alias = call.argument("alias") + try { + when (call.method) { + "isSupported" -> result.success( + BiometricManager.from(activity).canAuthenticate(authenticators) == + BiometricManager.BIOMETRIC_SUCCESS, + ) + "contains" -> result.success(alias != null && prefs.contains(alias)) + "seal" -> { + seal(alias!!, call.argument("plaintext")!!) + result.success(null) + } + "unseal" -> unseal(alias!!, call.argument("reason") ?: "", result) + "delete" -> { + delete(alias!!) + result.success(null) + } + else -> result.notImplemented() + } + } catch (e: Exception) { + // Sem getMessage: o texto do Keystore não vai para o canal nem para log. + result.error("unavailable", null, null) + } + } + + private fun generateKey(alias: String) { + val builder = KeyGenParameterSpec.Builder( + keyAlias(alias), + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) + .setKeySize(2048) + .setDigests(KeyProperties.DIGEST_SHA256) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP) + .setUserAuthenticationRequired(true) + .setInvalidatedByBiometricEnrollment(true) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + builder.setUserAuthenticationParameters( + 0, // a cada uso + KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL, + ) + } + KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_RSA, PROVIDER).run { + initialize(builder.build()) + generateKeyPair() + } + } + + private fun publicKey(alias: String): PublicKey { + val ks = keyStore() + if (!ks.containsAlias(keyAlias(alias))) generateKey(alias) + val pub = ks.getCertificate(keyAlias(alias)).publicKey + // Recria a chave fora do Keystore: a original herda as restrições de + // autenticação e falharia ao cifrar sem prompt em alguns aparelhos. + return KeyFactory.getInstance(pub.algorithm).generatePublic(X509EncodedKeySpec(pub.encoded)) + } + + private fun b64(b: ByteArray) = Base64.encodeToString(b, Base64.NO_WRAP) + private fun unb64(s: String) = Base64.decode(s, Base64.NO_WRAP) + + /** + * Envelope: AES-256-GCM descartável cifra o token; a chave AES (32 bytes) + * é cifrada com a pública RSA. Blob: IV.chaveAES_RSA.token_AES (Base64). + */ + private fun seal(alias: String, plaintext: String) { + val aesKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + val aes = Cipher.getInstance("AES/GCM/NoPadding").apply { init(Cipher.ENCRYPT_MODE, aesKey) } + val body = aes.doFinal(plaintext.toByteArray(Charsets.UTF_8)) + val rsa = Cipher.getInstance(TRANSFORMATION).apply { init(Cipher.ENCRYPT_MODE, publicKey(alias), OAEP) } + val wrapped = rsa.doFinal(aesKey.encoded) + // `commit` (síncrono): o blob novo tem de estar em disco antes do legado ser apagado. + prefs.edit().putString(alias, b64(aes.iv) + "." + b64(wrapped) + "." + b64(body)).commit() + } + + private fun delete(alias: String) { + prefs.edit().remove(alias).apply() + val ks = keyStore() + if (ks.containsAlias(keyAlias(alias))) ks.deleteEntry(keyAlias(alias)) + } + + private fun unseal(alias: String, reason: String, result: MethodChannel.Result) { + val blob = prefs.getString(alias, null) + if (blob == null) { + result.success(null) + return + } + val cipher: Cipher + try { + val key = keyStore().getKey(keyAlias(alias), null) as? PrivateKey + if (key == null) { + delete(alias) + result.error("invalidated", null, null) + return + } + cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.DECRYPT_MODE, key, OAEP) + } catch (e: KeyPermanentlyInvalidatedException) { + delete(alias) + result.error("invalidated", null, null) + return + } catch (e: Exception) { + result.error("unavailable", null, null) + return + } + + val info = BiometricPrompt.PromptInfo.Builder() + .setTitle("SinalACS") + .setSubtitle(reason) + .setAllowedAuthenticators(authenticators) + .apply { + // Com DEVICE_CREDENTIAL o botão negativo é proibido. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) setNegativeButtonText("Cancelar") + } + .build() + + val callback = object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(r: BiometricPrompt.AuthenticationResult) { + try { + // O CryptoObject é o RSA privado: libera a chave AES, que decifra o token. + val parts = blob.split(".") + require(parts.size == 3) + val aesBytes = r.cryptoObject!!.cipher!!.doFinal(unb64(parts[1])) + val aes = Cipher.getInstance("AES/GCM/NoPadding").apply { + init(Cipher.DECRYPT_MODE, SecretKeySpec(aesBytes, "AES"), GCMParameterSpec(128, unb64(parts[0]))) + } + result.success(String(aes.doFinal(unb64(parts[2])), Charsets.UTF_8)) + } catch (e: Exception) { + result.error("unavailable", null, null) + } + } + + override fun onAuthenticationError(code: Int, msg: CharSequence) { + val mapped = when (code) { + BiometricPrompt.ERROR_USER_CANCELED, + BiometricPrompt.ERROR_NEGATIVE_BUTTON, + BiometricPrompt.ERROR_CANCELED -> "cancelled" + BiometricPrompt.ERROR_LOCKOUT, + BiometricPrompt.ERROR_LOCKOUT_PERMANENT -> "lockedOut" + else -> "unavailable" + } + result.error(mapped, null, null) + } + // onAuthenticationFailed (digital errada) não encerra: o prompt segue. + } + + activity.runOnUiThread { + BiometricPrompt(activity, ContextCompat.getMainExecutor(activity), callback) + .authenticate(info, BiometricPrompt.CryptoObject(cipher)) + } + } +} +``` + +`MainActivity.kt` — acrescentar imports `io.flutter.embedding.engine.FlutterEngine` e `io.flutter.plugin.common.MethodChannel` e o método: + +```kotlin + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, KeystoreVault.CHANNEL) + .setMethodCallHandler(KeystoreVault(this)) + } +``` + +- [ ] **Step 5: Compilar** + +Run: `cd apps/acs && flutter build apk --debug` — Expected: sucesso (compila o Kotlin). O envelope dispensa checar o tamanho do token. + +- [ ] **Step 6: Commit** + +```bash +git add apps/acs/android apps/acs/test +git commit -m "feat(acs): cofre Kotlin com envelope AES-GCM + RSA do Keystore e BiometricPrompt/CryptoObject" +``` + +--- + +### Task 4: Ligar no `main.dart` e migrar o token legado (antes da 1ª retomada) + +**Files:** +- Create: `apps/acs/test/support/counting_session_token_store.dart` +- Modify: `apps/acs/lib/main.dart` (~l.48) +- Test: `apps/acs/test/auth_bound_session_token_store_test.dart` (acrescentar), `apps/acs/test/session_store_wiring_test.dart` (estender) + +**Interfaces:** +- Consumes: `AuthBoundSessionTokenStore`, `MethodChannelKeystoreVault`, `SecureStorageSessionTokenStore`. + +- [ ] **Step 1: Testes de migração que falham** + +```dart +test('token legado é selado sem prompt e o original é apagado', () async { + await legacy.write('velho'); + expect(await store.contains(), isTrue); + expect(vault.sealed[AuthBoundSessionTokenStore.alias], 'velho'); + expect(await legacy.read(), isNull); + expect(vault.unsealCalls, 0); + // a leitura segue exigindo o prompt + expect(await store.read(), isNull); + expect(await store.unlock(reason: 'x'), SessionUnlock.unlocked); + expect(await store.read(), 'velho'); +}); + +test('migração que falha preserva o legado (a sessão não se perde)', () async { + vault.failSeal = true; + await legacy.write('velho'); + expect(await store.contains(), isFalse); + expect(await legacy.read(), 'velho'); +}); +``` + +- [ ] **Step 2:** `flutter test test/auth_bound_session_token_store_test.dart` — Expected: PASS (a lógica está na Task 1; se falhar, corrija `_migrateLegacy`). O segundo teste exige que `contains()` devolva `false` quando o legado ficou: confirme que não devolve `true` por engano. + +- [ ] **Step 2b: Teste de ordem e de interrupção** (acrescentar no mesmo arquivo) + +```dart +test('migração roda antes do 1º unlock: contains() já sela o legado', () async { + await legacy.write('velho'); + await store.contains(); // o que hasStoredSession chama, antes de _tryResume + expect(vault.sealed[AuthBoundSessionTokenStore.alias], 'velho'); + expect(await legacy.read(), isNull); +}); + +test('app morto entre selar e apagar o legado: legado e cofre coexistem, o legado é descartado', () async { + vault.sealed[AuthBoundSessionTokenStore.alias] = 'velho'; + await legacy.write('velho'); + expect(await store.contains(), isTrue); + expect(await legacy.read(), isNull); +}); +``` + +```dart +test('contains() é leve após a migração: não relê o legado', () async { + final counting = CountingSessionTokenStore(); // duplo: MemorySessionTokenStore que conta `read` + final s = AuthBoundSessionTokenStore(vault: vault, legacy: counting); + await s.contains(); + final reads = counting.reads; + await s.contains(); + await s.contains(); + expect(counting.reads, reads); +}); + +test('migração que falhou não liga o flag: a próxima contains() tenta de novo', () async { + vault.failSeal = true; + await legacy.write('velho'); + expect(await store.contains(), isFalse); + vault.failSeal = false; + expect(await store.contains(), isTrue); + expect(vault.sealed[AuthBoundSessionTokenStore.alias], 'velho'); +}); +``` + +`CountingSessionTokenStore` é um duplo de 10 linhas em `test/support/` (estende o comportamento de `MemorySessionTokenStore` e incrementa `reads` em `read()`); criá-lo junto. + +Esses testes passam com `_migrateLegacy` da Task 1, que é chamado dentro de `contains()` e de `unlock()`; `hasStoredSession` (chamado antes do prompt em `_tryResume`) garante a ordem. Se algum falhar, ajuste o store, não o teste. + +- [ ] **Step 3: Montar no `main.dart`** + +Trocar `tokenStore: SecureStorageSessionTokenStore(),` por: + +```dart + tokenStore: AuthBoundSessionTokenStore( + vault: MethodChannelKeystoreVault(), + legacy: SecureStorageSessionTokenStore(), + ), +``` + +(importar `core/security/auth_bound_session_token_store.dart` e `keystore_vault.dart`). Estender `session_store_wiring_test.dart` com um teste que confirme que o `main` (ou a função de montagem que o teste já usa) monta o store vinculado — seguir o padrão do arquivo. + +- [ ] **Step 4:** `cd apps/acs && flutter test && flutter analyze` — Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/acs +git commit -m "feat(acs): refresh token passa a viver no cofre vinculado à biometria; migra o legado" +``` + +--- + +### Task 5: Logout apaga o par RSA (sem chave órfã) + +**Files:** +- Test: `apps/acs/test/auth_bound_session_token_store_test.dart`, `apps/acs/test/refresh_session_test.dart` +- Conferir: `apps/acs/android/.../KeystoreVault.kt` (`delete`, já remove o blob e `deleteEntry`) + +**Interfaces:** +- Consumes: `BackendClient.logout()` chama `_tokenStore.clear()` (já existe), que no store vinculado chama `KeystoreVault.delete(alias)`. + +- [ ] **Step 1: Testes que falham/fixam** + +```dart +test('clear (logout) apaga o blob e a chave: contains é false e o cofre fica vazio', () async { + await store.write('tok-1'); + await store.clear(); + expect(vault.sealed, isEmpty); + expect(await store.contains(), isFalse); + expect(await store.read(), isNull); +}); + +test('após clear, novo write recria o ciclo normalmente', () async { + await store.write('a'); + await store.clear(); + await store.write('b'); + expect(vault.sealed[AuthBoundSessionTokenStore.alias], 'b'); +}); +``` + +Em `refresh_session_test.dart`, acrescentar um teste de `BackendClient.logout()` com `tokenStore: AuthBoundSessionTokenStore(vault: vault, legacy: MemorySessionTokenStore())` e `expect(vault.sealed, isEmpty)` (copiar o setup dos testes de logout vizinhos). + +- [ ] **Step 2:** `cd apps/acs && flutter test test/auth_bound_session_token_store_test.dart test/refresh_session_test.dart` — Expected: PASS. Se o de logout falhar, o `BackendClient` não está chamando `clear()` do store: corrigir lá. + +- [ ] **Step 3: Fallback documentado no código.** No doc-comment de `AuthBoundSessionTokenStore` (já descreve), acrescentar: "API 24–29 sem biometria forte: o token fica só em RAM e morre com o processo; a partida a frio exige login completo. É recurso de segurança para aparelhos antigos, não falha." E em `app.dart`, no ramo `_aviso`/login, nada de texto novo; a comunicação ao ACS vai em `PROGRESS.md` (Task 6). + +- [ ] **Step 4: Commit** + +```bash +git add apps/acs +git commit -m "test(acs): logout apaga o blob e o par RSA; documenta o fallback em RAM para APIs antigas" +``` + +--- + +### Task 6: Prova no emulador e documentação + +**Files:** +- Create: `scripts/qa/acs_keystore_bound_e2e.sh` +- Modify: `PROGRESS.md` (linhas "Limite de desenho" e "Aberto (a)" da seção do refresh token), `apps/CLAUDE.md` (parágrafo de `database_key_store`/token), `CLAUDE.md` (lista "Still missing": remover "biometric bound to the Keystore"; ajustar a frase "an interface gate, not a key binding"), `spec/lgpd_design.md` §5.1 +- Conferir: `scripts/qa/contagem_validation_report.py` (contagem de testes do ACS na documentação) + +**Interfaces:** nenhuma de código. + +- [ ] **Step 1: Escrever o script** `acs_keystore_bound_e2e.sh` seguindo o padrão de `scripts/qa/acs_secure_window.sh` e `acs_release_signing.sh` (ler um deles antes e reaproveitar `lib_*.sh`). Passos que o script automatiza com `adb`: instalar o debug, entrar com o ACS de dev, forçar `adb shell am force-stop`, abrir de novo e verificar com `uiautomator dump` que **o diálogo do BiometricPrompt aparece** antes do painel; `adb -e emu finger touch 1` (digital cadastrada) abre o painel; `finger touch 2` (digital errada) não abre. O passo de nova digital (invalidação) é manual, descrito abaixo. + +- [ ] **Step 2: Rodar no emulador** (`emulator-5554`, API 36, com PIN + digital cadastrados) + +Run: `./scripts/qa/acs_keystore_bound_e2e.sh` +Expected: 3 casos verdes — (1) partida a frio pede prompt; (2) digital certa entra; (3) digital errada/cancelar não entra e o token continua guardado (segunda tentativa entra). + +- [ ] **Step 3: Prova manual de invalidação** — no emulador, cadastrar uma segunda digital (Configurações → Segurança), abrir o app a frio. Expected: sem laço de prompt; tela de login com "Sua sessão expirou. Entre novamente."; login completo recria a chave e o ciclo volta ao normal. Anotar o resultado em `PROGRESS.md`. + +- [ ] **Step 4: Prova de não-regressão** — `./scripts/qa/e2e.sh --emulator` e `acs_full_e2e.sh` (o e2e usa `BiometricGate` de teste na retomada; confirmar que continua verde e, se o app do e2e usar `MemorySessionTokenStore`, que o caminho `notRequired` segue funcionando). `acs_secure_window.sh` continua verde. + +- [ ] **Step 5: Documentar** + - `PROGRESS.md`: reescrever "Limite de desenho" (o token agora é decifrado por chave do Keystore com `setUserAuthenticationRequired`; o AppLockGate continua sendo portão de UI para os 30 s em segundo plano — o token fica em RAM durante o processo), marcar (a) como fechado com data e a prova, e **registrar o que segue aberto**: chave do SQLCipher e token de envio diferido sem vínculo (por desenho), API 24–29 sem prova (item (f) já aberto), aparelho sem biometria/PIN = login completo a cada partida a frio. + - `CLAUDE.md` e `apps/CLAUDE.md`: mesma correção; atualizar a contagem de testes do ACS (rodar `flutter test` e usar o número real; conferir com `scripts/qa/contagem_validation_report.py`). + - `spec/lgpd_design.md` §5.1: acrescentar a decisão (RSA/OAEP, decifra por uso, invalidação por nova biometria, envelope AES-256-GCM + RSA-OAEP). + +- [ ] **Step 6: Atualizar o grafo e commitar** + +```bash +graphify update . +git add scripts/qa/acs_keystore_bound_e2e.sh PROGRESS.md CLAUDE.md apps/CLAUDE.md spec/lgpd_design.md +git commit -m "docs(acs): biometria amarrada ao Keystore (refresh token); prova no emulador" +``` + +--- + +## Auto-revisão + +- **Cobertura:** vínculo ao Keystore (Tasks 1+3), retomada a frio sem prompt duplo (Task 2), instalações existentes (Task 4), prova real e docs (Task 5). Review Focus 1→T1/T3/T6, 2→T1, 3→T1/T4, 4→T4, 5→T3, 6→T5, 7→T4. +- **Consistência de tipos:** `SessionUnlock`, `contains()`, `unlock({required String reason})`, `KeystoreVault`, `VaultFailure`, `AuthBoundSessionTokenStore.alias` usados com os mesmos nomes em todas as tasks. +- **Riscos que o plano não resolve por si:** (1) o comportamento de `CryptoObject` + `DEVICE_CREDENTIAL` varia por fabricante antes da API 30 — por isso API < 30 aceita só biometria forte e cai em login completo; só a Task 5 em emulador API 36 está provada, e os testes em API 24–29 continuam no item (f) aberto. (2) O OAEP do Keystore (MGF1-SHA1) é fonte conhecida de erro: o `OAEP` fixo no Kotlin cobre, mas só o emulador prova; o envelope AES-GCM só é provado no aparelho também. (3) Com o token em RAM depois do desbloqueio, um atacante com root e o app já aberto desbloqueado não é barrado — o ganho é contra a leitura **em repouso** e contra a partida a frio. diff --git a/docs/superpowers/plans/2026-10-05-pendencias-de-seguranca-e-robustez.md b/docs/superpowers/plans/2026-10-05-pendencias-de-seguranca-e-robustez.md new file mode 100644 index 00000000..453e804f --- /dev/null +++ b/docs/superpowers/plans/2026-10-05-pendencias-de-seguranca-e-robustez.md @@ -0,0 +1,775 @@ +# Pendências de segurança e robustez (TOTP, alertas sob a reautenticação, certificados de dev, scripts de QA) — Plano de Implementação + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Confirmar, com evidência, quais dos cinco pontos ainda estão abertos e corrigir os que estiverem: bloqueio progressivo contra adivinhação de TOTP, alertas visíveis sob a reautenticação, `sync_dev_ca.sh` que confere o par chave/certificado e não deixa `.tmp`, permissão das chaves privadas de dev e robustez dos scripts de `FLAG_SECURE` e de assinatura. + +**Architecture:** Cinco assuntos independentes, uma task por assunto (mais uma de verificação e uma de documentação). A Task 1 só mede. Cada correção segue TDD no menor teste que prova o defeito (teste Dart, teste de shell com PATH-shim, cenário do script de assinatura). O bloqueio progressivo adiciona **uma coluna** (`lockStreak`) e mantém a política no serviço e a contagem atômica numa instrução SQL, como já é hoje. + +**Tech Stack:** Dart/Serverpod (`backend/sinalacs_server`, `dart test`), Flutter (`apps/acs`, `flutter test`), bash + openssl (`scripts/dev`, `scripts/qa`), Gradle Kotlin DSL (`apps/acs/android/app/build.gradle.kts`). + +**Spec:** `PROGRESS.md` ("Adivinhação de TOTP" e "Minors adiados" T1, T2, T3, T5, T7, linhas ~610–630), `spec/security_assessment.md` (achado F6), `spec/lgpd_design.md` (controle de acesso e monitoramento), `spec/ux_accessibility_assessment.md` (SC 4.1.3 e contraste de texto vermelho), `CLAUDE.md` (invariante: "Red alerts must never be silently dropped"). + +Levantamento com `graphify query` e leitura do código em 2026-10-05, **antes** de executar (a Task 1 repete cada medida): + +| # | Ponto | Estado observado | +|---|-------|------------------| +| 1 | Bloqueio progressivo do TOTP | **Aberto.** `InstitutionalAuthService.maxFailedAttempts = 5`, `lockDuration = 15 min` fixos (`institutional_auth_service.dart:153-154`); `_registrarFalha` reinicia a contagem quando o bloqueio vence (`:402-415`); `user_credentials` só tem `failedAttempts` e `lockedUntil`. Senha errada e TOTP errado compartilham o mesmo contador. | +| 2 | Alerta sob a reautenticação | **A confirmar por teste.** `_openReauth` (`apps/acs/lib/app/app.dart:1188`) empilha um `LoginScreen` opaco; fila e feed seguem vivos por baixo (o comentário da linha ~1175 garante que nada é descartado), mas nada na rota de reautenticação mostra que há alerta novo. | +| 3 | `sync_dev_ca.sh` | **Aberto.** Confere CA→folha (`check_ca`) e CA→cert do cliente (`openssl verify`), mas **não** compara a chave privada com o certificado do cliente; não há `trap`, então uma falha ou Ctrl-C entre a fase 2 e a 3 deixa `*.tmp` nos assets. | +| 4 | Chaves em 644 | **Aberto.** `infra/docker/mosquitto/init.sh:128` e `infra/docker/traefik/init.sh:173` fazem `chmod 644` em `*.key` (inclui `acs-area-12.key`, `ca.key`, `server.key`). A cópia no asset já nasce 600. | +| 5a | `acs_secure_window.sh` | **Aberto.** `awk ... exit` consome o pipe do `dumpsys` com `set -o pipefail` (risco de SIGPIPE no `adb`) e `sleep 6` é fixo. | +| 5b | `acs_release_signing.sh` / Gradle | **Parcial.** Hermeticidade **já fechada** (`unset SINALACS_KEYSTORE_*` na linha 23). **Abertos:** `project.hasProperty("sinalacs.allowDebugSigning")` vale mesmo com `=false`; a mensagem não diz qual das quatro propriedades falta; `apksigner="$(ls .../build-tools/*/apksigner | tail -1)"` ordena por texto (`9.0.0` > `34.0.0`). | +| 5c | Comentários | **A confirmar.** `video/capture/capture-acs.sh` cita o rótulo antigo da UBS; `secure_window_test.dart` só confere string no fonte; falta documentar como regenerar capturas do ACS. | + +## Global Constraints + +- Triagem determinística e nunca alterável à mão; alerta vermelho **nunca** é descartado em silêncio (`CLAUDE.md`). +- Nunca dado real de paciente em teste, log, captura ou configuração de dev; credenciais de teste sintéticas. +- `registerFailedAttempt` continua sendo **uma instrução SQL**, sem leitura-antes-de-escrita; "bloqueio ativo não conta nem estende tentativa" continua valendo. +- Matrícula inexistente e senha errada continuam produzindo a mesma exceção e a mesma mensagem; o bloqueio só é revelado depois de a senha conferir (`institutional_auth_service.dart`, regra 1 e 3). +- Texto vermelho em UI usa `redOnSurface` medido contra a superfície onde renderiza (`apps/CLAUDE.md`, "WCAG contrast tokens"); mensagens dinâmicas usam `Semantics(liveRegion: true)`. +- Nunca editar `lib/src/generated/` nem `migrations/` à mão: `serverpod generate` e `serverpod create-migration` (`backend/CLAUDE.md`). +- Interface e documentação em português; commits sem atribuição de IA (regra do repositório). +- Rodar `graphify update .` depois de modificar código. + +## Fora do escopo + +- Limite por origem (IP) no `loginInstitutional`: é infraestrutura (middleware do Traefik), já listado no `PROGRESS.md`. +- Redefinição de MFA por coordenador e MFA trust-on-first-use. +- Qualquer mudança em produção: as chaves de dev e os scripts de QA só valem na stack local. + +## Review Focus + +- ACS legítimo que erra o código depois de um bloqueio vencido: o próximo bloqueio é mais longo, mas um login correto zera tudo (a escalada nunca pode virar bloqueio permanente). +- Duas requisições concorrentes que cruzam o limite: a contagem continua certa e a coluna `lockStreak` sobe **uma** vez só. +- Alerta que chega com a reautenticação aberta e o banner já visível: o banner some quando o ACS confirma o alerta ou entra, e nunca cobre o campo de matrícula/senha/código. +- `sync_dev_ca.sh` interrompido depois de promover o primeiro asset: nenhum `*.tmp` sobra e um novo `sync` funciona. +- Chave do cliente de outro par (regerada sem o certificado): `sync_dev_ca.sh` recusa **antes** de copiar qualquer asset, com a mensagem "nada foi copiado" verdadeira. + +--- + +### Task 1: Verificar o estado de cada ponto (somente medir) + +**Files:** +- Modify: nenhum. O resultado vira a tabela do commit da Task 8 e decide quais tasks 2–7 rodam. + +**Interfaces:** +- Produces: para cada ponto, `ABERTO` ou `FECHADO` com o comando e a saída que provam. + +- [ ] **Step 1: Orientar-se no grafo (obrigatório antes de abrir código)** + +```bash +cd /home/rock/Documents/Dev/APPs/SinalACS +graphify query "InstitutionalAuthService lockDuration maxFailedAttempts registerFailedAttempt" --budget 1200 +graphify query "AcsHomeShell _openReauth LoginScreen reauthUserId alert queue" --budget 1200 +graphify query "sync_dev_ca.sh acs_client key tmp" --budget 800 +``` +Expected: nós `InstitutionalAuthService`, `OrmAcsCredentialStore`, `AcsHomeShell`/`_AcsHomeShellState`, `sync_dev_ca.sh`. + +- [ ] **Step 2: Pontos 1, 3, 4 e 5 — medir** + +```bash +cd /home/rock/Documents/Dev/APPs/SinalACS +# 1: política fixa e sem coluna de escalada +grep -n "maxFailedAttempts = \|lockDuration = " backend/sinalacs_server/lib/src/application/auth/institutional_auth_service.dart +grep -n "lockStreak" -r backend/sinalacs_server/lib/src | grep -v generated || echo "1: sem lockStreak (ABERTO)" +# 3: par chave/certificado e trap +grep -n "pubkey\|pkey\|trap " scripts/dev/sync_dev_ca.sh || echo "3: sem conferência de par nem trap (ABERTO)" +# 4: modo das chaves (e dono: define se 600 é viável para o usuário do host) +grep -n "chmod 644" infra/docker/mosquitto/init.sh infra/docker/traefik/init.sh +ls -ln infra/docker/mosquitto/runtime/certs/*.key infra/docker/traefik/runtime/certs/*.key 2>/dev/null +# 5a/5b +grep -n "sleep 6\|awk -v pkg" scripts/qa/acs_secure_window.sh +grep -n "hasProperty" apps/acs/android/app/build.gradle.kts +grep -n "apksigner=" scripts/qa/acs_release_signing.sh +grep -n "UBS" video/capture/capture-acs.sh +``` +Expected: cada linha confirma a tabela acima. Se algo mostrar `FECHADO` (por exemplo `lockStreak` já existir), registrar e **pular** a task correspondente. + +- [ ] **Step 3: Ponto 2 — o teste da Task 4 é a medida**; registrar "a confirmar na Task 4". + +- [ ] **Step 4: Registrar** a tabela com o resultado literal; nada a commitar. + +--- + +### Task 2: Política de bloqueio progressivo no serviço (falha primeiro) + +**Files:** +- Modify: `backend/sinalacs_server/lib/src/application/auth/institutional_auth_service.dart:40-66` (registro), `:152-154` (constantes), `:402-415` (`_registrarFalha`) +- Test: `backend/sinalacs_server/test/unit/institutional_auth_service_test.dart` + +**Interfaces:** +- Produces: + - `AcsCredentialRecord.lockStreak` (`final int`, parâmetro nomeado com default `0`, para não quebrar os construtores existentes). + - `static Duration InstitutionalAuthService.lockDurationFor(int streak)` = `15 min * 2^streak`, teto de `maxLockDuration = Duration(hours: 24)`. + - `_registrarFalha` passa a calcular `lockUntil: at.add(lockDurationFor(record.lockStreak))`. +- Decisões (ledger): a escalada reinicia **só** num login válido (`registerSuccessfulLogin`); senha e TOTP seguem no mesmo contador (um atacante que já tem a senha gasta o mesmo orçamento); o teto de 24 h evita bloqueio permanente de ACS legítimo; a leitura de `lockStreak` pelo serviço pode estar uma rodada atrasada sob concorrência (a mesma tolerância que o código já documenta para `lockedUntil`). + +- [ ] **Step 1: Escrever os testes que falham** + +No arquivo de teste, junto dos testes de política (use o `group`/helpers do primeiro teste do arquivo para montar serviço e store; o `_FakeStore` está em `institutional_auth_service_test.dart:13`). Primeiro a função pura: + +```dart + group('lockDurationFor (bloqueio progressivo)', () { + test('dobra a cada rodada de bloqueio, a partir de 15 min', () { + expect(InstitutionalAuthService.lockDurationFor(0), const Duration(minutes: 15)); + expect(InstitutionalAuthService.lockDurationFor(1), const Duration(minutes: 30)); + expect(InstitutionalAuthService.lockDurationFor(2), const Duration(hours: 1)); + expect(InstitutionalAuthService.lockDurationFor(3), const Duration(hours: 2)); + }); + + test('nunca passa de 24 h, mesmo com sequência enorme', () { + expect(InstitutionalAuthService.lockDurationFor(7), const Duration(hours: 24)); + expect(InstitutionalAuthService.lockDurationFor(500), const Duration(hours: 24)); + }); + + test('sequência negativa (linha corrompida) vale como zero', () { + expect(InstitutionalAuthService.lockDurationFor(-3), const Duration(minutes: 15)); + }); + }); +``` + +Depois, no `_FakeStore`, espelhar o SQL do store real: onde o fake aplica `lockedUntil` ao atingir o limite, incrementar também `lockStreak`; `registerSuccessfulLogin` do fake zera `lockStreak`; o `AcsCredentialRecord` que o fake devolve passa `lockStreak: lockStreak`. E o teste de serviço (mesma forma dos testes de bloqueio existentes: cinco logins com a senha errada, depois repetir depois do vencimento): + +```dart + test('a segunda rodada de bloqueio dura o dobro; o login válido zera a escalada', () async { + final t0 = DateTime.utc(2026, 10, 5, 12); + for (var i = 0; i < InstitutionalAuthService.maxFailedAttempts; i++) { + await expectLater( + service.login(matricula: 'ACS-001', password: 'errada', now: t0), + throwsA(isA()), + ); + } + expect(store.lockedUntil, t0.add(const Duration(minutes: 15))); + + // 16 min depois: o bloqueio venceu; mais cinco falhas bloqueiam por 30 min. + final t1 = t0.add(const Duration(minutes: 16)); + for (var i = 0; i < InstitutionalAuthService.maxFailedAttempts; i++) { + await expectLater( + service.login(matricula: 'ACS-001', password: 'errada', now: t1), + throwsA(isA()), + ); + } + expect(store.lockedUntil, t1.add(const Duration(minutes: 30))); + expect(store.lockStreak, 2); + + // Vencido o segundo bloqueio, a senha certa entra e zera a escalada. + final t2 = t1.add(const Duration(minutes: 31)); + await service.login(matricula: 'ACS-001', password: senhaCerta, now: t2); + expect(store.lockStreak, 0); + expect(store.lockedUntil, isNull); + }); +``` +(`service`, `store` e `senhaCerta` são os nomes que o primeiro teste do arquivo usa; se forem outros, use os dele.) + +- [ ] **Step 2: Ver falhar** + +Run: `cd backend/sinalacs_server && dart test test/unit/institutional_auth_service_test.dart` +Expected: erro de compilação `The method 'lockDurationFor' isn't defined` / `lockStreak`. + +- [ ] **Step 3: Implementar** + +`AcsCredentialRecord`: adicionar `this.lockStreak = 0,` ao construtor e `final int lockStreak;` com o comentário `/// Quantas vezes seguidas a conta bloqueou sem um login válido no meio.` + +Constantes e função, ao lado de `lockDuration`: + +```dart + static const maxFailedAttempts = 5; + static const lockDuration = Duration(minutes: 15); + + /// Teto do bloqueio progressivo: bloquear para sempre transformaria o limite + /// num ataque ao acesso do próprio ACS. + static const maxLockDuration = Duration(hours: 24); + + /// Bloqueio da [streak]-ésima rodada seguida (0 = a primeira): `15 min × 2^streak`, + /// no máximo [maxLockDuration]. Sequência negativa vale como zero. + static Duration lockDurationFor(int streak) { + final n = streak < 0 ? 0 : streak; + // 2^7 × 15 min já passa de 24 h; limitar o expoente evita estouro em `<<`. + if (n >= 7) return maxLockDuration; + final d = lockDuration * (1 << n); + return d > maxLockDuration ? maxLockDuration : d; + } +``` + +`_registrarFalha`: trocar `lockUntil: at.add(lockDuration),` por `lockUntil: at.add(lockDurationFor(record.lockStreak)),`. Atualizar o doc comment da classe (regra 2) para citar a escalada. + +- [ ] **Step 4: Ver passar** + +Run: `cd backend/sinalacs_server && dart test test/unit/institutional_auth_service_test.dart test/unit/institutional_auth_mfa_test.dart` +Expected: passam. Se o `_Store` de `institutional_auth_mfa_test.dart:28` não compilar, acrescentar `lockStreak` a ele da mesma forma (campo + incremento + zerar). + +- [ ] **Step 5: Commit** + +```bash +git add backend/sinalacs_server/lib/src/application/auth/institutional_auth_service.dart backend/sinalacs_server/test/unit +git commit -m "feat(auth): bloqueio progressivo (15 min x 2^rodada, teto 24 h) no login institucional" +``` + +--- + +### Task 3: Coluna `lockStreak` e a instrução SQL atômica + +**Files:** +- Modify: `backend/sinalacs_server/lib/src/models/user_credential.spy.yaml` +- Create (gerados): nova pasta em `backend/sinalacs_server/migrations/` e `lib/src/generated/*` via `serverpod` +- Modify: `backend/sinalacs_server/lib/src/infrastructure/database/orm_acs_credential_store.dart` (`findByEnrollmentId`, `registerFailedAttempt`, `registerSuccessfulLogin`) +- Modify: `spec/lgpd_data_audit.md` (a linha de `user_credentials`) +- Test: o teste do `OrmAcsCredentialStore` contra o banco de teste (`grep -rln OrmAcsCredentialStore backend/sinalacs_server/test`) + +**Interfaces:** +- Consumes: `AcsCredentialRecord.lockStreak` e `lockDurationFor` (Task 2). +- Produces: coluna `user_credentials."lockStreak" bigint NOT NULL DEFAULT 0`; o store lê a coluna para o registro, incrementa **no mesmo `UPDATE`** quando o bloqueio é aplicado e zera no login válido. + +- [ ] **Step 1: Teste que falha (banco de teste)** — ao lado dos testes de `registerFailedAttempt` do store ORM, usando a mesma montagem de credencial sintética: + +```dart + test('lockStreak sobe uma vez por bloqueio aplicado e zera no login válido', () async { + final at = DateTime.utc(2026, 10, 5, 12); + for (var i = 0; i < 5; i++) { + await store.registerFailedAttempt(acsId, + restartCounter: false, maxFailedAttempts: 5, lockUntil: at.add(const Duration(minutes: 15)), at: at); + } + expect((await store.findByEnrollmentId(matricula))!.lockStreak, 1); + + // Rajada concorrente na linha já bloqueada: o WHERE recusa, a sequência não sobe de novo. + await Future.wait([ + for (var i = 0; i < 5; i++) + store.registerFailedAttempt(acsId, + restartCounter: false, maxFailedAttempts: 5, lockUntil: at.add(const Duration(minutes: 15)), at: at), + ]); + expect((await store.findByEnrollmentId(matricula))!.lockStreak, 1); + + await store.registerSuccessfulLogin(acsId, at.add(const Duration(minutes: 20))); + expect((await store.findByEnrollmentId(matricula))!.lockStreak, 0); + }); +``` + +- [ ] **Step 2: Ver falhar** + +Run: `cd backend/sinalacs_server && dart test -N "lockStreak"` +Expected: erro de compilação (`lockStreak` não existe no registro devolvido) ou coluna inexistente. + +- [ ] **Step 3: Implementar** + +`user_credential.spy.yaml`, depois de `lockedUntil`: + +```yaml + ### Rodadas de bloqueio seguidas, sem um login válido no meio. Alimenta o + ### bloqueio progressivo (15 min x 2^lockStreak, teto 24 h). Zera no login válido. + lockStreak: int, default=0 +``` + +Gerar (nunca à mão): + +```bash +cd backend/sinalacs_server +serverpod generate +serverpod create-migration +``` +Expected: nova pasta em `migrations/` cujo `migration.sql` faz `ADD COLUMN "lockStreak" bigint NOT NULL DEFAULT 0` (aditiva, preserva as linhas de dev). Abrir o `migration.sql` e conferir isso antes de seguir. + +`registerFailedAttempt` — acrescentar a coluna ao `SET`, só no ramo em que o bloqueio é aplicado: + +```sql + "lockStreak" = CASE + WHEN @restart THEN "lockStreak" + WHEN "failedAttempts" + 1 >= @maxFailedAttempts THEN "lockStreak" + 1 + ELSE "lockStreak" + END, +``` +(`@restart` preserva a sequência: o bloqueio vencido recomeça a contagem de falhas, **não** a escalada.) `registerSuccessfulLogin`: `SET "failedAttempts" = 0, "lockedUntil" = NULL, "lockStreak" = 0, ...`. `findByEnrollmentId`: passar `lockStreak: row.lockStreak` ao `AcsCredentialRecord`. + +- [ ] **Step 4: Ver passar e rodar a suíte do backend que toca no login** + +Run: `cd backend/sinalacs_server && dart test test/unit/institutional_auth_service_test.dart test/unit/institutional_auth_mfa_test.dart ` +Expected: passam. Em seguida `docker compose` com `pg_data/` antigo: a migração é aditiva, então **não** exige `rm -rf pg_data/` (ao contrário da Track E, que o `CLAUDE.md` avisa). + +- [ ] **Step 5: Documentar a coluna** em `spec/lgpd_data_audit.md` (linha de `user_credentials`: `lockStreak`, não sensível, contador de segurança) e atualizar a contagem de colunas/tabelas citada, se o texto trouxer uma. + +- [ ] **Step 6: Commit** + +```bash +git add backend/sinalacs_server/lib backend/sinalacs_server/migrations backend/sinalacs_server/test spec/lgpd_data_audit.md +git commit -m "feat(auth): coluna lockStreak e escalada atômica do bloqueio do login institucional" +``` + +--- + +### Task 4: Alerta visível sob a tela de reautenticação + +**Files:** +- Create: `apps/acs/lib/app/pending_alerts_banner.dart` +- Modify: `apps/acs/lib/app/app.dart:1188-1215` (`_openReauth`, o `page` da rota) +- Test: `apps/acs/test/pending_alerts_banner_test.dart` (novo); `apps/acs/test/session_reauth_test.dart` (montagem do shell já existente) + +**Interfaces:** +- Produces: `PendingAlertsBanner({required Listenable listenable, required int Function() count})` — some com `count() == 0`; texto `"1 alerta vermelho aguardando. Entre para ver."` ou `"N alertas vermelhos aguardando. Entre para ver."`; `Key('reauth_pending_alerts')`; `Semantics(liveRegion: true)`; cor do texto `context.acsRisk.redOnSurface` sobre `Theme.of(context).cardColor`. + +- [ ] **Step 1: Teste do widget isolado (falha)** + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/app/acs_theme.dart'; +import 'package:sinalacs_acs/app/pending_alerts_banner.dart'; + +void main() { + Widget host(ValueNotifier n) => MaterialApp( + theme: buildAcsDarkTheme(), + home: Scaffold( + body: PendingAlertsBanner(listenable: n, count: () => n.value), + ), + ); + + testWidgets('sem alerta pendente não mostra nada', (t) async { + await t.pumpWidget(host(ValueNotifier(0))); + expect(find.byKey(const Key('reauth_pending_alerts')), findsNothing); + }); + + testWidgets('mostra a contagem e acompanha as mudanças', (t) async { + final n = ValueNotifier(1); + await t.pumpWidget(host(n)); + expect(find.text('1 alerta vermelho aguardando. Entre para ver.'), findsOneWidget); + + n.value = 3; + await t.pump(); + expect(find.text('3 alertas vermelhos aguardando. Entre para ver.'), findsOneWidget); + + n.value = 0; + await t.pump(); + expect(find.byKey(const Key('reauth_pending_alerts')), findsNothing); + }); + + testWidgets('é região viva (SC 4.1.3)', (t) async { + final h = t.ensureSemantics(); + await t.pumpWidget(host(ValueNotifier(2))); + expect( + t.getSemantics(find.byKey(const Key('reauth_pending_alerts'))), + matchesSemantics(isLiveRegion: true, label: '2 alertas vermelhos aguardando. Entre para ver.'), + ); + h.dispose(); + }); +} +``` + +- [ ] **Step 2: Ver falhar** + +Run: `cd apps/acs && flutter test test/pending_alerts_banner_test.dart` +Expected: `Target of URI doesn't exist: '.../pending_alerts_banner.dart'`. + +- [ ] **Step 3: Implementar** — `lib/app/pending_alerts_banner.dart`: + +```dart +import 'package:flutter/material.dart'; + +import 'acs_theme.dart'; + +/// Aviso fixo no topo da reautenticação (sessão vencida ou bloqueio): o painel +/// com a fila de alertas fica por baixo da rota opaca, e um alerta vermelho que +/// chega ali não pode esperar o novo login para ser notado. +class PendingAlertsBanner extends StatelessWidget { + const PendingAlertsBanner({super.key, required this.listenable, required this.count}); + + final Listenable listenable; + final int Function() count; + + @override + Widget build(BuildContext context) => ListenableBuilder( + listenable: listenable, + builder: (context, _) { + final n = count(); + if (n <= 0) return const SizedBox.shrink(); + final texto = n == 1 + ? '1 alerta vermelho aguardando. Entre para ver.' + : '$n alertas vermelhos aguardando. Entre para ver.'; + return Material( + key: const Key('reauth_pending_alerts'), + color: Theme.of(context).cardColor, + child: Semantics( + liveRegion: true, + label: texto, + excludeSemantics: true, + child: Padding( + padding: const EdgeInsets.all(12), + child: Text( + texto, + textAlign: TextAlign.center, + style: TextStyle(color: context.acsRisk.redOnSurface, fontWeight: FontWeight.w700), + ), + ), + ), + ); + }, + ); +} +``` + +Run: `cd apps/acs && flutter test test/pending_alerts_banner_test.dart` → Expected: passam. + +- [ ] **Step 4: Ligar na rota de reautenticação e provar com o shell** + +Em `_openReauth`, o `page` passa a ser: + +```dart + Widget page(BuildContext _) => Column( + children: [ + SafeArea( + bottom: false, + child: PendingAlertsBanner(listenable: _queue, count: () => _queue.unacknowledgedCount), + ), + Expanded( + child: LoginScreen( + visitScopeFor: widget.visitScopeFor, + themeController: widget.themeController, + directory: widget.directory, + feedBuilder: widget.feedBuilder, + initialAlert: widget.initialAlert, + initialPosition: widget.initialPosition, + syncInterval: widget.syncInterval, + aviso: aviso, + reauthUserId: widget.acsId, + ), + ), + ], + ); +``` +(importar `pending_alerts_banner.dart`). Em `test/session_reauth_test.dart`, acrescentar um teste com a **mesma montagem** do primeiro teste desse arquivo que abre a reautenticação: depois de a rota abrir, entregar um alerta à fila (pelo `feedBuilder`/fila que o teste já injeta) e afirmar `find.byKey(const Key('reauth_pending_alerts'))` `findsOneWidget`, e que o campo de matrícula continua tocável. Rodar `flutter test test/session_reauth_test.dart test/login_flow_test.dart`. +Expected: verde. **Se o teste do shell passar sem a mudança do `_openReauth`** (ex.: o painel já aparece por outro caminho), registrar no ledger que o ponto 2 estava fechado e **reverter** o passo 4, mantendo só a prova. + +- [ ] **Step 5: Contraste** — conferir se `test/contrast_tokens_test.dart` já mede `redOnSurface` contra `cardColor`; se não medir, acrescentar o par à matriz do teste (padrão do arquivo) e rodar `flutter test test/contrast_tokens_test.dart`. + +- [ ] **Step 6: Commit** + +```bash +git add apps/acs/lib/app apps/acs/test +git commit -m "feat(acs): aviso de alertas pendentes sobre a reautenticação" +``` + +--- + +### Task 5: `sync_dev_ca.sh` confere o par chave/certificado e não deixa `.tmp` + +**Files:** +- Modify: `scripts/dev/sync_dev_ca.sh` +- Create: `scripts/dev/sync_dev_ca_test.sh` +- Modify: `.github/workflows/ci.yml` (job `workflow-lint`, junto dos outros `*_test.sh`) **somente se** esse job já roda scripts de teste de shell; o job list guardado por `ci_invariants.sh` não muda. + +**Interfaces:** +- Produces: variável opcional `SYNC_DEV_CA_ROOT` (padrão: a raiz do repositório, como hoje) para o teste apontar a uma árvore temporária; `trap` que remove os seis `*.tmp`; conferência `chave pública do certificado == chave pública da chave` antes da fase 2. + +- [ ] **Step 1: Escrever o teste de shell (falha)** — `scripts/dev/sync_dev_ca_test.sh`: + +```bash +#!/usr/bin/env bash +# +# Prova o sync_dev_ca.sh numa árvore temporária (nunca toca nos assets reais). +set -euo pipefail + +aqui="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +script="$aqui/sync_dev_ca.sh" +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +falha() { echo "FALHOU: $*" >&2; exit 1; } + +# CA + folha de servidor + certificado e chave do cliente, tudo descartável. +gera_runtime() { + local dir="$1" ; mkdir -p "$dir" + openssl req -x509 -newkey rsa:2048 -nodes -keyout "$dir/ca.key" -out "$dir/ca.crt" -days 2 -subj "/CN=ca-teste" 2>/dev/null + openssl req -newkey rsa:2048 -nodes -keyout "$dir/server.key" -out "$dir/server.csr" -subj "/CN=localhost" 2>/dev/null + openssl x509 -req -in "$dir/server.csr" -CA "$dir/ca.crt" -CAkey "$dir/ca.key" -CAcreateserial -out "$dir/server.crt" -days 2 2>/dev/null +} +monta() { + local raiz="$1" ; rm -rf "$raiz" + gera_runtime "$raiz/infra/docker/mosquitto/runtime/certs" + gera_runtime "$raiz/infra/docker/traefik/runtime/certs" + local m="$raiz/infra/docker/mosquitto/runtime/certs" + openssl req -newkey rsa:2048 -nodes -keyout "$m/acs-area-12.key" -out "$m/c.csr" -subj "/CN=acs-area-12" 2>/dev/null + openssl x509 -req -in "$m/c.csr" -CA "$m/ca.crt" -CAkey "$m/ca.key" -CAcreateserial -out "$m/acs-area-12.crt" -days 2 2>/dev/null + mkdir -p "$raiz/apps/acs/assets/certs" "$raiz/apps/patient/assets/certs" +} +sem_tmp() { [[ -z "$(find "$1/apps" -name '*.tmp' 2>/dev/null)" ]]; } + +echo "== caso 1: tudo certo copia os assets" +monta "$tmp/ok" +SYNC_DEV_CA_ROOT="$tmp/ok" "$script" >/dev/null || falha "o sync falhou numa árvore válida" +[[ -f "$tmp/ok/apps/acs/assets/certs/acs_client.key" ]] || falha "a chave do cliente não foi copiada" +[[ "$(stat -c %a "$tmp/ok/apps/acs/assets/certs/acs_client.key")" == 600 ]] || falha "a chave do cliente não está em 600" +sem_tmp "$tmp/ok" || falha "sobrou .tmp no caminho feliz" + +echo "== caso 2: chave que não é o par do certificado é recusada antes de copiar" +monta "$tmp/par" +m="$tmp/par/infra/docker/mosquitto/runtime/certs" +openssl genrsa -out "$m/acs-area-12.key" 2048 2>/dev/null # outra chave, mesmo certificado +if SYNC_DEV_CA_ROOT="$tmp/par" "$script" >"$tmp/saida" 2>&1; then falha "aceitou chave de outro par"; fi +grep -q "não é o par" "$tmp/saida" || falha "a mensagem não explicou o motivo: $(cat "$tmp/saida")" +grep -q "nada foi copiado" "$tmp/saida" || falha "a mensagem não diz que nada foi copiado" +[[ -z "$(find "$tmp/par/apps" -type f 2>/dev/null)" ]] || falha "algum asset foi copiado apesar do erro" + +echo "== caso 3: falha no meio da publicação não deixa .tmp" +monta "$tmp/meio" +mkdir -p "$tmp/shim" +real="$(command -v openssl)" +# O `openssl x509 -subject` roda DEPOIS do primeiro `mv` (promote_ca): é a falha no meio. +cat >"$tmp/shim/openssl" </dev/null 2>&1; then falha "o sync não falhou com o shim"; fi +sem_tmp "$tmp/meio" || falha "sobrou .tmp depois da falha no meio: $(find "$tmp/meio/apps" -name '*.tmp')" + +echo "OK — sync_dev_ca.sh: par conferido, nada copiado no erro, sem .tmp" +``` + +- [ ] **Step 2: Ver falhar** + +Run: `chmod +x scripts/dev/sync_dev_ca_test.sh && ./scripts/dev/sync_dev_ca_test.sh` +Expected: `FALHOU:` no caso 1 (o script ignora `SYNC_DEV_CA_ROOT` e procura a árvore real) — ou, depois do passo 3 parcial, no caso 2 ("aceitou chave de outro par") e no caso 3 ("sobrou .tmp"). + +- [ ] **Step 3: Implementar** — em `sync_dev_ca.sh`: + +(a) a raiz passa a ser sobrescrevível (o `repo_root=` existente fica como padrão): + +```bash +repo_root="${SYNC_DEV_CA_ROOT:-$repo_root}" +``` +(logo depois da linha que define `repo_root`; se o script a define antes de `set -euo pipefail`, colocar a nova linha depois dessa definição). + +(b) `trap` no início das fases, cobrindo os seis temporários: + +```bash +limpar_tmp() { + rm -f "$repo_root"/apps/{acs,patient}/assets/certs/{dev_ca.crt,dev_rpc_ca.crt}.tmp \ + "$repo_root"/apps/acs/assets/certs/acs_client.{crt,key}.tmp +} +trap limpar_tmp EXIT +``` +O `mv` bem-sucedido já consumiu o `.tmp` correspondente, então o `rm -f` do trap só apaga o que sobrou. Um `trap ... EXIT` roda também em Ctrl-C porque o `bash` sai pelo `EXIT`. + +(c) a conferência do par, logo depois do `openssl verify` do certificado do cliente: + +```bash +pub_crt="$(openssl x509 -in "$mqtt_certs/acs-area-12.crt" -noout -pubkey)" +pub_key="$(openssl pkey -in "$mqtt_certs/acs-area-12.key" -pubout 2>/dev/null || true)" +if [[ -z "$pub_key" || "$pub_crt" != "$pub_key" ]]; then + echo "erro: $mqtt_certs/acs-area-12.key não é o par do certificado acs-area-12.crt — nada foi copiado." >&2 + echo "Apague infra/docker/mosquitto/runtime e suba a stack de novo para regerar o par." >&2 + exit 1 +fi +``` + +- [ ] **Step 4: Ver passar** + +Run: `./scripts/dev/sync_dev_ca_test.sh && bash -n scripts/dev/sync_dev_ca.sh` +Expected: os três casos `OK` e a linha final `OK — sync_dev_ca.sh: ...`. Conferir também que o script **real** ainda funciona com a stack no ar: `./scripts/dev/sync_dev_ca.sh` imprime as quatro cópias e `ls apps/acs/assets/certs/*.tmp` não lista nada. + +- [ ] **Step 5: CI** — se `workflow-lint` já executa `scripts/qa/*_test.sh`, acrescentar `scripts/dev/sync_dev_ca_test.sh` na mesma lista de passos (um passo por teste, como `lib_rele_test.sh`); rodar `./scripts/qa/ci_invariants.sh` e `actionlint` (o job continua o mesmo, só ganha um passo). + +- [ ] **Step 6: Commit** + +```bash +git add scripts/dev/sync_dev_ca.sh scripts/dev/sync_dev_ca_test.sh .github/workflows/ci.yml +git commit -m "fix(dev): sync_dev_ca.sh confere o par da chave do cliente e limpa os .tmp" +``` + +--- + +### Task 6: Permissão das chaves privadas de desenvolvimento (medir e decidir) + +**Files:** +- Modify: `infra/docker/mosquitto/init.sh:127-128`, `infra/docker/traefik/init.sh:172-173` (**somente** se a medida permitir) e `spec/lgpd_design.md`/`PROGRESS.md` (a decisão) + +**Interfaces:** +- Consumes: `sync_dev_ca.sh` (Task 5), que copia `acs-area-12.key` com o usuário do host. +- Produces: uma decisão registrada: quais chaves ficam 600/640 e quais precisam de 644, e por quê. + +O 644 existe porque o `init.sh` roda **dentro de um contêiner como root** e os arquivos ficam num bind mount lido por outro usuário (o `mosquitto`, o Traefik e o usuário do host que roda o `sync_dev_ca.sh`). Apertar sem medir quebra a stack em silêncio. + +- [ ] **Step 1: Medir quem lê o quê** + +```bash +cd /home/rock/Documents/Dev/APPs/SinalACS +ls -ln infra/docker/mosquitto/runtime/certs infra/docker/traefik/runtime/certs +id -u +docker compose exec -T mosquitto id 2>/dev/null; docker compose exec -T traefik id 2>/dev/null +grep -n "user:\|USER " docker-compose.yml infra/docker/mosquitto/Dockerfile infra/docker/traefik/Dockerfile 2>/dev/null +``` +Expected: dono (`uid`) das `.key`, o usuário de cada contêiner e o `uid` do host. Quadro de decisão: + +| Chave | Quem lê | Se lê como outro uid que o dono | Resultado | +|-------|---------|---------------------------------|-----------| +| `acs-area-12.key` | só `sync_dev_ca.sh` e `tool/live_check.dart` (host) | dono = root, host ≠ root | 600 quebra a cópia → manter 644 **e documentar**; ou `chown` para o uid do host no `init.sh` (variável `HOST_UID`) | +| `server.key` (broker/Traefik) | o próprio serviço | contêiner não-root ≠ dono | 640 com grupo, ou manter 644 | +| `ca.key` | ninguém depois de assinar as folhas | — | 600 | + +- [ ] **Step 2: Teste que falha para o que for apertável** — `scripts/qa/dev_key_permissions_test.sh` (só roda com a stack gerada; sem `runtime/`, sai 0 com aviso, como os outros scripts de QA que dependem da stack): + +```bash +#!/usr/bin/env bash +set -euo pipefail +cd "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +dirs=(infra/docker/mosquitto/runtime/certs infra/docker/traefik/runtime/certs) +[[ -d "${dirs[0]}" ]] || { echo "sem runtime/ (suba a stack antes); nada a conferir"; exit 0; } +ruim=0 +for k in "${dirs[0]}/ca.key" "${dirs[1]}/ca.key"; do + [[ -f "$k" ]] || continue + m="$(stat -c %a "$k")" + [[ "$m" == 600 ]] || { echo "ca.key em $m (esperado 600): $k" >&2; ruim=1; } +done +exit "$ruim" +``` +Expected antes da correção: falha (`ca.key em 644`). + +- [ ] **Step 3: Implementar o que a medida permitir** — em cada `init.sh`, separar o `chmod`: + +```sh +chmod 644 "$certs_dir"/*.crt +chmod 644 "$certs_dir/server.key" # lida pelo serviço, que roda como outro uid (medido na Task 6) +chmod 600 "$certs_dir/ca.key" # só assina folhas; ninguém precisa lê-la depois +``` +(no Mosquitto, `acs-area-12.key` e demais `*.key` seguem o resultado do quadro: 644 documentado, ou `chown` ao uid do host.) Subir a stack do zero e provar que nada quebrou: + +```bash +docker compose down && rm -rf infra/docker/mosquitto/runtime infra/docker/traefik/runtime +docker compose up -d --build && ./scripts/dev/sync_dev_ca.sh && ./scripts/qa/dev_key_permissions_test.sh && ./scripts/qa/mtls_invariants.sh && ./scripts/qa/tls_invariants.sh +``` +Expected: stack saudável; os dois invariantes de TLS/mTLS passam; o teste de permissão passa. **Se qualquer um quebrar, desfazer a mudança correspondente** e registrar a razão: o plano aceita "manter 644 e documentar" como desfecho válido para as chaves lidas por outro uid. + +- [ ] **Step 4: Registrar a decisão** em `PROGRESS.md` (substituindo o minor T7 "chaves privadas de cliente em 644") e em `spec/lgpd_design.md` (nota de dev: gitignored, máquina de um usuário, nunca em produção). + +- [ ] **Step 5: Commit** + +```bash +git add infra/docker scripts/qa/dev_key_permissions_test.sh PROGRESS.md spec/lgpd_design.md +git commit -m "fix(dev): ca.key em 600; decisão registrada para as demais chaves de dev" +``` + +--- + +### Task 7: Robustez dos scripts de `FLAG_SECURE` e de assinatura, e comentários + +**Files:** +- Modify: `scripts/qa/acs_secure_window.sh:54-66` +- Modify: `scripts/qa/acs_release_signing.sh:30`, mais um cenário 5 +- Modify: `apps/acs/android/app/build.gradle.kts:170,188,133` (`hasProperty`) e `:172-182` (mensagem) +- Modify: `apps/acs/test/secure_window_test.dart` +- Modify: `video/capture/capture-acs.sh:9` (rótulo) e a documentação de captura + +**Interfaces:** +- Produces: `acs_secure_window.sh` sem pipe com `exit` e sem `sleep` fixo; guard do Gradle que só libera com `=true`; `apksigner` escolhido por versão. + +- [ ] **Step 1: `acs_secure_window.sh` — espera ativa e awk sem SIGPIPE** + +Trocar `sleep 6` e o pipe por: + +```bash +# Espera a janela do app aparecer (até 40 s), em vez de dormir um tempo fixo. +dump="" +for _ in $(seq 1 40); do + dump="$(adb -s "$dev" shell dumpsys window windows 2>/dev/null || true)" + grep -q "^ Window #.*$pkg/" <<<"$dump" && break + sleep 1 +done +# O dumpsys já está numa variável: o awk lê tudo, sem `exit` sobre um pipe +# (com pipefail isso podia matar o adb por SIGPIPE e abortar o script). +flags="$(awk -v pkg="$pkg" ' + /^ Window #/ { dentro = index($0, pkg "/") > 0 } + dentro && /^ +fl=/ && !achou { print; achou = 1 }' <<<"$dump")" +``` +Run: `bash -n scripts/qa/acs_secure_window.sh` (o teste de ponta a ponta exige o emulador: `./scripts/qa/acs_secure_window.sh --reinstalar` no `emulator-5554`). +Expected: `OK — janela do ACS com FLAG_SECURE`. + +- [ ] **Step 2: Guard do Gradle só com `=true` (cenário que falha primeiro)** + +Em `scripts/qa/acs_release_signing.sh`, depois do cenário 4, acrescentar: + +```bash +echo "== cenário 5: -Psinalacs.allowDebugSigning=false NÃO libera a assinatura de debug ==" +if saida="$(construir -Psinalacs.allowDebugSigning=false -Psinalacs.allowDevClientKey=true 2>&1)"; then + echo "erro: allowDebugSigning=false liberou a build de release sem chave." >&2 + exit 1 +fi +grep -q "assinatura de release" <<<"$saida" || { echo "erro: a falha não explicou o motivo:" >&2; tail -n 15 <<<"$saida" >&2; exit 1; } +``` +Run (precisa do SDK e da stack para as senhas do `.env`): `./scripts/qa/acs_release_signing.sh` +Expected antes da correção: o cenário 5 falha com "allowDebugSigning=false liberou a build" (porque `hasProperty` ignora o valor). + +Em `build.gradle.kts`, uma função só para as três licenças e a mensagem que lista o que falta: + +```kotlin +// `-Pfoo=false` não pode liberar nada: só o texto "true" vale. +fun licenca(nome: String): Boolean = project.findProperty(nome)?.toString() == "true" +``` +e trocar `project.hasProperty("sinalacs.allowDebugSigning")` por `licenca("sinalacs.allowDebugSigning")`, o mesmo para `sinalacs.allowDevClientKey` (`:188`) e `sinalacs.allowMissingMqttPassword` (`:133`). Na mensagem do guard (`:172-182`), acrescentar antes do "Informe a chave…": + +```kotlin +val faltam = listOf( + "storeFile (SINALACS_KEYSTORE_PATH)" to releaseStoreFile, + "storePassword (SINALACS_KEYSTORE_PASSWORD)" to releaseStorePassword, + "keyAlias (SINALACS_KEY_ALIAS)" to releaseKeyAlias, + "keyPassword (SINALACS_KEY_PASSWORD)" to releaseKeyPassword, +).filter { it.second == null }.joinToString(", ") { it.first } +``` +com a linha `|Faltando: $faltam` na mensagem (o texto "assinatura de release" que os cenários 1 e 5 procuram permanece). + +- [ ] **Step 3: `apksigner` pela maior versão** + +Em `acs_release_signing.sh:30`: + +```bash +apksigner="$(ls -d "$HOME"/Android/Sdk/build-tools/*/ | sort -V | tail -1)apksigner" +[[ -x "$apksigner" ]] || { echo "erro: apksigner não encontrado em $HOME/Android/Sdk/build-tools" >&2; exit 4; } +``` + +- [ ] **Step 4: Rodar a prova completa do release** + +Run: `./scripts/qa/acs_release_signing.sh` +Expected: cenários 1–5 passam e a linha final `OK — release exige chave própria; ...`. Se o ambiente não tiver SDK/emulador, registrar que a prova **não** foi executada e rodar só `bash -n` nos dois scripts e `cd apps/acs && flutter test test/android_manifest_test.dart`. + +- [ ] **Step 5: Comentários e teste que só olha string** + +- `video/capture/capture-acs.sh:9`: comparar a lista de rótulos do comentário com o texto real do botão no app (`grep -rn "Encaminhar para" apps/acs/lib`) e corrigir o comentário para o rótulo atual. +- `apps/acs/test/secure_window_test.dart`: além da string no fonte, acrescentar a asserção de que `MainActivity.kt` chama `window.setFlags(` com `FLAG_SECURE` **dentro de `onCreate`** (regex sobre o corpo de `onCreate`, não só a presença da palavra), seguindo o estilo do arquivo; rodar `cd apps/acs && flutter test test/secure_window_test.dart`. +- Documentar como regenerar capturas do ACS (que saem pretas com `FLAG_SECURE`): um parágrafo em `docs/telas-acs.md` apontando para a seção "FLAG_SECURE escurece captura e gravação" do `PROGRESS.md` e dizendo que a captura exige a janela desprotegida (remover a flag temporariamente) ou foto do aparelho. + +- [ ] **Step 6: Commit** + +```bash +git add scripts/qa apps/acs/android/app/build.gradle.kts apps/acs/test/secure_window_test.dart video/capture/capture-acs.sh docs/telas-acs.md +git commit -m "fix(qa): scripts de FLAG_SECURE e de assinatura mais robustos; guard só com =true" +``` + +--- + +### Task 8: Fechar a documentação e o grafo + +**Files:** +- Modify: `PROGRESS.md` (bullet "Adivinhação de TOTP", "Minors adiados" T1/T2/T5/T7, bloco de pendências) +- Modify: `CLAUDE.md`/`backend/CLAUDE.md`/`apps/CLAUDE.md` (a frase de "5 tentativas por 15 min", a contagem de tabelas/colunas, se citadas) +- Modify: `spec/security_assessment.md` (F6: bloqueio progressivo) + +- [ ] **Step 1: Atualizar o texto com o resultado medido** — para cada um dos cinco pontos, "fechado em 2026-10-05" com a prova (comando e saída) **ou** "continua aberto: ". O orçamento de adivinhação do TOTP passa a ser calculado na nova política (primeira rodada 5 tentativas; depois 5 a cada 30 min, 1 h, 2 h… até 24 h) e a conta do "0,14% ao dia" é refeita com a mesma fórmula do texto original, mostrando o valor novo. +- [ ] **Step 2: Verificações finais** + +```bash +cd backend/sinalacs_server && dart analyze && dart test test/unit +cd ../../apps/acs && flutter analyze && flutter test +cd ../.. && ./scripts/dev/sync_dev_ca_test.sh && ./scripts/qa/check_documentation_links.sh && python3 scripts/qa/contagem_validation_report.py && graphify update . +``` +Expected: tudo verde; se `contagem_validation_report.py` apontar divergência de contagem de testes, atualizar o número citado. +- [ ] **Step 3: Commit** + +```bash +git add PROGRESS.md CLAUDE.md backend/CLAUDE.md apps/CLAUDE.md spec/security_assessment.md +git commit -m "docs: fecha pendências de TOTP, reautenticação, certificados de dev e scripts de QA" +``` + +## Self-Review + +- **Cobertura:** (1) Tasks 2–3; (2) Task 4; (3) Task 5; (4) Task 6; (5) Task 7; verificação Task 1 e fechamento Task 8. O que o levantamento já mostrou fechado (hermeticidade do `acs_release_signing.sh`) está na tabela, não em task. +- **Placeholders:** os pontos que dependem de nomes não lidos estão explícitos como instrução de leitura com o arquivo e a linha (`_FakeStore` em `institutional_auth_service_test.dart:13`, `_Store` em `institutional_auth_mfa_test.dart:28`, o teste do store ORM por `grep`, a montagem de `session_reauth_test.dart`). O formato de `dumpsys` e o uid dos contêineres são medidos antes de qualquer mudança (Tasks 1 e 6). +- **Consistência de tipos:** `lockStreak`, `lockDurationFor`, `maxLockDuration`, `PendingAlertsBanner(listenable:, count:)`, `SYNC_DEV_CA_ROOT` e `licenca()` aparecem com os mesmos nomes em todas as tasks. diff --git a/docs/superpowers/plans/2026-10-05-tema-appcompat-api-24-27.md b/docs/superpowers/plans/2026-10-05-tema-appcompat-api-24-27.md new file mode 100644 index 00000000..d9ddd335 --- /dev/null +++ b/docs/superpowers/plans/2026-10-05-tema-appcompat-api-24-27.md @@ -0,0 +1,250 @@ +# Tema AppCompat e prompt biométrico em Android 8.1 ou anterior (API 24–27) — Plano de Implementação + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Garantir que o `BiometricPrompt` do cofre do refresh token (`KeystoreVault.kt`) abra e devolva resultado em API 24–27, provando em emulador, e fechar o item (f) de `PROGRESS.md`. + +**Architecture:** Primeiro **reproduzir** (a hipótese "quebra" vem do README do `local_auth_android` e ainda não foi observada): instalar uma imagem API 27 e rodar o fluxo de partida a frio. Só então trocar o pai dos temas `LaunchTheme`/`NormalTheme` de `@android:style/Theme.*.NoTitleBar` para `Theme.AppCompat.*.NoActionBar` (mantendo o fundo/splash atuais), declarar `androidx.appcompat` explicitamente e cobrir a regressão com um teste que lê os `styles.xml`. + +**Tech Stack:** Kotlin (`MainActivity : FlutterFragmentActivity`, `KeystoreVault.kt`), `androidx.biometric:1.1.0`, Android SDK `cmdline-tools/latest`, emulador, bash (`scripts/qa`), `flutter_test`. + +**Spec:** `PROGRESS.md` §"Refresh token rotativo e desbloqueio biométrico do ACS" (Não provado, item Aberto (f), linha ~747–748), §"Biometria amarrada ao Keystore" (a3, linha ~759); `docs/superpowers/plans/2026-10-05-biometria-amarrada-ao-keystore.md`. + +Contexto levantado com `graphify query`/`explain`: `BiometricPrompt` é usado só em `KeystoreVault.unseal()` (`KeystoreVault.kt:264`, comunidade `KeystoreVault`); `MainActivity` já é `FlutterFragmentActivity`, então o requisito de `FragmentActivity` está atendido — falta só o tema. Temas atuais: `apps/acs/android/app/src/main/res/values/styles.xml` (pai `Theme.Light.NoTitleBar`) e `values-night/styles.xml` (pai `Theme.Black.NoTitleBar`). + +## Global Constraints + +- `minSdk 24`, `compileSdk 36` (`apps/acs/android/app/build.gradle.kts`). A solução deve degradar, não quebrar, em API 24–29. +- `androidx.biometric` fica em `1.1.0` (a 1.2.0 é alpha); sem framework novo (`spec/stack.md`). +- Nunca registrar token, chave ou mensagem de exceção do Keystore em log (LGPD-RF09). Falha fechada: erro do prompt nunca devolve o token. +- Splash (`@drawable/launch_background`), `FLAG_SECURE` e o fundo escuro/claro por `values-night` não mudam de aparência. +- Textos de UI em português; commits sem atribuição de IA (regra do repositório). +- Rodar `graphify update .` depois de modificar código. + +## Review Focus + +- API 24–27 **sem** biometria cadastrada nem bloqueio de tela: o app deve cair em login completo (`unavailable`), sem travar nem fechar. +- API 24–27 com prompt aberto e o app indo para o fundo (rotação, `HOME`): sem prompt órfão nem espera eterna no Dart (relacionado a (a5)). +- Tema AppCompat sobre widgets do Flutter: sem barra de ação, sem faixa branca no splash, sem mudança de cor da barra de status em modo escuro. +- Dispositivo API 28+ (prompt do sistema) e API 30+ já provados: o tema novo não pode regredir `acs_keystore_bound_e2e.sh`. +- Imagem com `google_apis` vs AOSP: o prompt de digital em API ≤ 27 usa o diálogo do `androidx.biometric`; um falso negativo por falta de Play Services não pode ser lido como "tema quebrado". + +--- + +### Task 1: Obter imagem API 27 e reproduzir o defeito + +**Files:** +- Create: `scripts/qa/acs_api27_avd.sh` + +**Interfaces:** +- Produces: AVD `sinalacs_api27` (x86_64) que as Tasks 2–4 usam; `emulator-` visível em `adb devices`. O script aceita `--api <24..27>` (padrão 27) e `--criar`/`--iniciar`. + +Só há `android-36/google_apis_playstore` instalado e `sdkmanager` fora do `PATH` (está em `~/Android/Sdk/cmdline-tools/latest/bin`). A imagem **não está disponível por padrão**, mas é baixável. + +- [ ] **Step 1: Escrever o script** + +```bash +#!/usr/bin/env bash +# +# Cria/inicia um AVD em API 24–27 para provar o BiometricPrompt abaixo do +# prompt do sistema (API 28+). Usa google_apis (Play Services, como a CI). +# +# ./scripts/qa/acs_api27_avd.sh [--api 27] [--criar] [--iniciar] +set -euo pipefail + +sdk="$HOME/Android/Sdk" +export PATH="$PATH:$sdk/cmdline-tools/latest/bin:$sdk/emulator:$sdk/platform-tools" + +api=27; criar=0; iniciar=0 +while [[ $# -gt 0 ]]; do + case "$1" in + --api) api="${2:?--api exige 24..27}"; shift 2 ;; + --criar) criar=1; shift ;; + --iniciar) iniciar=1; shift ;; + *) echo "uso: $0 [--api 24..27] [--criar] [--iniciar]" >&2; exit 2 ;; + esac +done +[[ "$api" =~ ^2[4-7]$ ]] || { echo "API deve ser 24..27" >&2; exit 2; } + +avd="sinalacs_api${api}" +img="system-images;android-${api};google_apis;x86_64" + +if [[ $criar -eq 1 ]]; then + yes | sdkmanager --licenses >/dev/null || true + sdkmanager "$img" + echo no | avdmanager create avd -n "$avd" -k "$img" --force +fi +if [[ $iniciar -eq 1 ]]; then + # Porta fixa 5556 para não colidir com o emulador-5554 dos outros scripts. + nohup emulator -avd "$avd" -port 5556 -no-snapshot -no-audio >/dev/null 2>&1 & + adb -s emulator-5556 wait-for-device + until [[ "$(adb -s emulator-5556 shell getprop sys.boot_completed | tr -d '\r')" == 1 ]]; do sleep 2; done + echo "emulator-5556 pronto (API $api)" +fi +``` + +- [ ] **Step 2: Criar e iniciar** + +Run: `chmod +x scripts/qa/acs_api27_avd.sh && ./scripts/qa/acs_api27_avd.sh --api 27 --criar --iniciar` +Expected: `emulator-5556 pronto (API 27)`. Se o download falhar por rede/KVM, registre o erro e pare: sem imagem não há prova, e o plano **não** declara o item fechado. + +- [ ] **Step 3: Reproduzir com o tema atual (sem editar nada)** + +```bash +export PATH="$PATH:$HOME/Android/Sdk/platform-tools:$HOME/flutter/bin" +adb -s emulator-5556 shell locksettings set-pin 1111 +adb -s emulator-5556 shell am start -a android.settings.SECURITY_SETTINGS +# cadastra digital 1 pela UI e toca o sensor com: adb -s emulator-5556 emu finger touch 1 +cd apps/acs && flutter run -d emulator-5556 --dart-define-from-file=... +``` + +Use as mesmas `--dart-define` do `scripts/qa/acs_keystore_bound_e2e.sh` (ler o script; ele fixa `dev=emulator-5554`, então copie só a montagem do build). Faça login (ACS-001), `adb shell input keyevent KEYCODE_HOME`, espere o bloqueio de 30 s ou force-stop e reabra. +Expected (hipótese): `adb logcat -d | grep -iE "AppCompat|IllegalStateException|BiometricPrompt|FingerprintDialog"` mostra `You need to use a Theme.AppCompat theme` **ou** o prompt abre normalmente. +**Anote o resultado literal** em `PROGRESS.md` (Task 4); se o prompt abrir e devolver o token, a hipótese é falsa: pule a Task 3 e vá direto à Task 4, registrando "provado sem mudança" (o teste da Task 2 ainda vale como guarda). + +- [ ] **Step 4: Commit** + +```bash +git add scripts/qa/acs_api27_avd.sh +git commit -m "chore(qa): script para criar AVD API 24-27 e provar o BiometricPrompt" +``` + +--- + +### Task 2: Teste que trava o tema AppCompat (falha primeiro) + +**Files:** +- Create: `apps/acs/test/android_theme_test.dart` + +**Interfaces:** +- Consumes: os arquivos `apps/acs/android/app/src/main/res/values/styles.xml` e `values-night/styles.xml`. +- Produces: guarda de regressão que roda no job `acs-app` (CI). + +- [ ] **Step 1: Escrever o teste** + +```dart +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +// BiometricPrompt abaixo da API 28 desenha o próprio diálogo e exige um tema +// AppCompat na Activity (README do local_auth_android: "Android 8.1 or +// earlier"). Esta guarda impede que alguém volte aos temas @android:style. +void main() { + const pastas = ['values', 'values-night']; + for (final pasta in pastas) { + for (final tema in ['LaunchTheme', 'NormalTheme']) { + test('$pasta/$tema herda de AppCompat', () { + final xml = File('android/app/src/main/res/$pasta/styles.xml') + .readAsStringSync(); + final m = RegExp(' + +``` + +- [ ] **Step 2: `values-night/styles.xml`** — mesma troca com `Theme.AppCompat.NoActionBar` (escuro). Manter os comentários do template. + +- [ ] **Step 3: Declarar a dependência** (hoje só chega transitiva por `androidx.biometric`; explícita evita sumir com um bump): + +```kotlin +dependencies { + implementation("androidx.biometric:biometric:1.1.0") + // Tema AppCompat exigido pelo diálogo do BiometricPrompt em API < 28. + implementation("androidx.appcompat:appcompat:1.7.0") +} +``` + +- [ ] **Step 4: Teste verde + build** + +Run: `cd apps/acs && flutter test test/android_theme_test.dart && flutter build apk --debug --dart-define-from-file=` +Expected: 4 testes passam; build ok. Se o Gradle reclamar de versão do appcompat, usar a que `./gradlew :app:dependencies | grep appcompat` já resolve. + +- [ ] **Step 5: Provar no API 27** + +Repetir o passo 3 da Task 1 em `emulator-5556`. +Expected: prompt de digital (diálogo do `androidx.biometric`) aparece; `adb -s emulator-5556 emu finger touch 1` libera e o app volta logado sem pedir senha; `emu finger touch 2` mantém o prompt aberto; "Cancelar" resulta em tela de bloqueio com "Tentar de novo" (`cancelled`). Capture `adb logcat -d | grep -iE "AppCompat|IllegalState"` vazio. + +- [ ] **Step 6: Regressão em API 36** + +Run: `./scripts/qa/acs_keystore_bound_e2e.sh --pin 1111 --preparar` (emulador 5554, ver cabeçalho do script — aborta se o app já estiver instalado). +Expected: mesma saída verde de antes; sem faixa branca/barra de ação (screenshot com `adb exec-out screencap`, sem dados reais). + +- [ ] **Step 7: Commit** + +```bash +git add apps/acs/test/android_theme_test.dart apps/acs/android/app/src/main/res apps/acs/android/app/build.gradle.kts +git commit -m "fix(acs): tema AppCompat para o BiometricPrompt em API 24-27" +``` + +--- + +### Task 4: Casos de borda em API ≤ 27 e documentação + +**Files:** +- Modify: `PROGRESS.md` (linhas ~747, 748 item (f), 759 (a3), 770) +- Modify: `CLAUDE.md` (parágrafo "Still missing": remover "the Android theme/AppCompat check on Android ≤ 8.1 (API 24–27)" se provado) +- Modify: `scripts/qa/acs_keystore_bound_e2e.sh` (somente se aceitar `--dev emulator-5556`; hoje `dev` é fixo) + +- [ ] **Step 1: Sem biometria nem PIN (API 27)** — `adb -s emulator-5556 shell locksettings clear --old 1111`, reabrir o app a frio. +Expected: login completo (item (a4)), sem crash. Anotar. + +- [ ] **Step 2: Prompt aberto e `HOME`** — abrir o prompt, `KEYCODE_HOME`, voltar. +Expected: sem travar; se o prompt ficar órfão, registrar como o (a5) já listado, não corrigir aqui. + +- [ ] **Step 3: Repetir em API 24** (`--api 24 --criar --iniciar`) — a menor `minSdk`. Se falhar só na 24, abrir o item à parte. + +- [ ] **Step 4: Atualizar `PROGRESS.md`** com o resultado **literal** de cada passo (API testada, imagem `google_apis`, o que foi e não foi exercitado). Fechar (f) e reescrever (a3) para "API 24–27 provada; 28–29 sem prova". Manter "aparelho físico" e "iOS" como não provados. Rodar `./scripts/qa/check_documentation_links.sh` e `python3 scripts/qa/contagem_validation_report.py` se o número de testes citado mudar (+4 testes). + +- [ ] **Step 5: Grafo** + +Run: `graphify update .` +Expected: sem erro; `graphify query "BiometricPrompt theme AppCompat"` passa a listar `styles.xml`. + +- [ ] **Step 6: Commit** + +```bash +git add PROGRESS.md CLAUDE.md scripts/qa +git commit -m "docs(acs): prova do prompt biométrico em API 24-27; fecha o item (f)" +``` + +## Self-Review + +- Cobertura: "não há imagem" → Task 1 (imagem baixável via `sdkmanager`); "tema não é AppCompat" → Tasks 2–3; prova e docs → Task 4. Limite honesto: se o download da imagem falhar, o item permanece aberto. +- Placeholders: o único valor a copiar do ambiente é o `--dart-define-from-file`, que vem do `acs_keystore_bound_e2e.sh` existente (Task 1, passo 3 e Task 3, passo 4). +- Consistência: AVD `sinalacs_api`, porta 5556, `android_theme_test.dart` usados de forma igual nas tasks. diff --git a/docs/superpowers/plans/2026-10-06-admin-autenticacao-real.md b/docs/superpowers/plans/2026-10-06-admin-autenticacao-real.md new file mode 100644 index 00000000..4fa0c399 --- /dev/null +++ b/docs/superpowers/plans/2026-10-06-admin-autenticacao-real.md @@ -0,0 +1,611 @@ +# Autenticação real do backoffice (issue #39) — Plano de implementação + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Trocar o login local do `apps/admin` por login institucional real (matrícula + senha Argon2id + TOTP obrigatório) para os papéis `coordinator` e `admin`, emitindo JWT com esse papel. + +**Architecture:** Reaproveitar `InstitutionalAuthService` (senha, bloqueio progressivo, TOTP, auditoria) parametrizado por uma audiência (`acs` | `staff`), em vez de duplicá-lo. Contas de staff ficam em uma tabela nova `staff_accounts` (matrícula única + `active`), 1:1 com `users` (papel `coordinator`/`admin`, sem microárea) e `user_credentials`. Endpoints novos `auth.loginStaff` e `auth.beginStaffTotpEnrollment`/`confirmStaffTotpEnrollment`; as tabelas distintas fazem matrícula de ACS não entrar como staff e vice-versa, com a mesma mensagem genérica. No app, `AdminAuthBackend` (interface + implementação sobre `sinalacs_client`) substitui o bypass local. + +**Tech Stack:** Dart/Serverpod 3.4 (backend), Flutter (apps/admin), Postgres, `sinalacs_client` gerado. + +**Spec:** `spec/PRD_system.md` (RNF06, §4.2.2), `spec/lgpd_design.md`, issue #39, plano de referência `docs/superpowers/plans/2026-09-18-rf07-login-institucional-acs.md`. + +## Global Constraints + +- Texto de UI, comentários e mensagens de erro em português. +- Nunca `serverpod`-gerado editado à mão: `lib/src/generated/` só via `serverpod generate`; migração só via `serverpod create-migration` (ver `backend/CLAUDE.md`). +- Mensagem **única** para matrícula inexistente e senha errada; bloqueio só revelado depois de a senha conferir (regra de `InstitutionalAuthService`). +- Sem dado real, senha ou segredo versionado: a senha do admin de dev vem de `DEV_ADMIN_PASSWORD` gerada por `scripts/dev/bootstrap_env.sh`. +- Endpoints públicos novos entram em `_publicMethodsByDesign` de `test/unit/endpoint_auth_posture_test.dart` com o motivo. +- Tokens de contraste/WCAG do admin: alvos de toque ≥ 48×52 dp, `Semantics(liveRegion: true)` em erro de login. +- Sem atribuições de IA em commits (regra do repositório), mesmo que o harness sugira. +- Cada tarefa termina com `dart test test/unit` (backend) ou `flutter analyze && flutter test` (admin) verdes. + +## Decisões a confirmar antes de executar + +1. **MFA do staff é sempre obrigatória**, independente de `REQUIRE_ACS_MFA` (critério de aceite da issue). O admin de dev precisa ativá-la pela tela de ativação na primeira entrada. +2. **Sem refresh token no admin nesta issue.** O JWT dura 15 min; ao vencer, o app volta ao login. O código TOTP é de uso único, então o re-login exige esperar o próximo passo de 30 s (mesma limitação do ACS, documentada em `PROGRESS.md`). O backoffice é somente leitura, então não há estado a preservar. Refresh para staff fica como follow-up. +3. **Recuperação de MFA de staff** é manual (SQL), como a do ACS; a tela de gestão é a issue #43. + +## Review Focus + +1. Matrícula de ACS em `loginStaff` e de staff em `loginInstitutional`: recusa com a mesma mensagem genérica, sem revelar que a conta existe na outra audiência (Tarefa 2). +2. Staff com `active = false` ou sem `users.role` coerente (`patient`/`acs` apontando para uma linha de `staff_accounts`): recusa, nunca token (Tarefa 2). +3. Senha certa, MFA não ativada, em qualquer ambiente (inclusive `development`): `MfaEnrollmentRequiredException`, nunca token (Tarefa 2). +4. Token `admin`/`coordinator` (sem `micro_area_id`) apresentado a endpoints de paciente/ACS: recusado, não vira acesso a território (Tarefa 3). +5. Build release do admin: sem bypass; `devLoginEnabled` não existe mais como caminho de entrada (Tarefa 6). + +## Estrutura de arquivos + +| Arquivo | Responsabilidade | +|---|---| +| `backend/sinalacs_server/lib/src/models/staff_account.spy.yaml` (novo) | Tabela `staff_accounts` | +| `backend/.../application/auth/institutional_auth_service.dart` (mod.) | Audiência `CredentialAudience`, papel por audiência, MFA obrigatória no staff | +| `backend/.../infrastructure/database/orm_acs_credential_store.dart` (mod.) | Flag `staff` que troca a tabela de busca da matrícula | +| `backend/.../application/auth/authorization.dart` (mod.) | `Authorization.staffRoles` | +| `backend/.../runtime/alert_runtime.dart` (mod.) | `staffAuthServiceFor(session)` | +| `backend/.../endpoints/auth_endpoint.dart` (mod.) | `loginStaff`, `beginStaffTotpEnrollment`, `confirmStaffTotpEnrollment` | +| `backend/.../bin/seed_acs_credentials.dart` (mod.) + `seeds/development.sql` + `docker-compose.yml` + `scripts/dev/bootstrap_env.sh` + `.env.example` | Admin de desenvolvimento | +| `apps/admin/lib/core/auth/admin_auth_backend.dart` (novo) | Interface + `BackendAdminAuth` sobre `sinalacs_client` | +| `apps/admin/lib/app/login_screen.dart` (novo, extraído de `app.dart`) | Login real + campo TOTP | +| `apps/admin/lib/app/mfa_enrollment_screen.dart` (novo) | Ativação da MFA | +| `apps/admin/lib/app/app.dart` (mod.) | Injeta `AdminAuthBackend`, remove `devLoginEnabled` | +| docs | `spec/PRD_system.md` RNF06, `backend/CLAUDE.md`, `apps/CLAUDE.md`, `spec/lgpd_data_audit.md`, `PROGRESS.md` | + +--- + +### Task 1: Modelo `staff_accounts` e migração + +**Files:** +- Create: `backend/sinalacs_server/lib/src/models/staff_account.spy.yaml` +- Create (gerados): `lib/src/generated/*`, `migrations//*`, `backend/sinalacs_client/lib/src/protocol/*` +- Modify: `spec/lgpd_data_audit.md`, `backend/CLAUDE.md` (contagem de tabelas) +- Test: `backend/sinalacs_server/test/integration/staff_account_schema_test.dart` + +**Interfaces:** +- Produces: classe gerada `StaffAccount { UuidValue? id, String enrollmentId, bool active }`, com `StaffAccount.db`. + +- [ ] **Step 1: Escrever o teste de integração que falha** + +Siga o arranjo de `test/integration/institutional_login_test.dart` (mesmo `withServerpod`/helpers de lá — leia as 40 primeiras linhas e copie o setup). + +```dart +test('matrícula de staff é única', () async { + final userId = await _insertStaffUser(session, role: UserRole.admin); // helper local: INSERT em users + await StaffAccount.db.insertRow( + session, + StaffAccount(id: userId, enrollmentId: 'ADM-T1', active: true), + ); + expect( + () => StaffAccount.db.insertRow( + session, + StaffAccount(id: await _insertStaffUser(session, role: UserRole.admin), enrollmentId: 'ADM-T1', active: true), + ), + throwsA(anything), + ); +}); +``` + +- [ ] **Step 2: Rodar e ver falhar** + +Run: `cd backend/sinalacs_server && dart test test/integration/staff_account_schema_test.dart` +Expected: FAIL (`StaffAccount` não definido). + +- [ ] **Step 3: Criar o modelo** + +```yaml +### Conta de staff do backoffice (coordenador ou administrador). +### +### Tabela própria, e não uma coluna em `acs`: staff não é ACS, não tem UBS nem +### sincronização, e misturar os dois faria `acs.enrollmentId` virar um +### namespace compartilhado. O id é o UUID do usuário (mesma decisão de `Acs`). +### O papel mora em `users.role` (`coordinator` | `admin`); a credencial, em +### `user_credentials`. +class: StaffAccount +table: staff_accounts +fields: + id: UuidValue?, defaultPersist=random + enrollmentId: String + active: bool +indexes: + staff_accounts_enrollment_id_key: + fields: enrollmentId + unique: true +``` + +- [ ] **Step 4: Gerar código e migração** + +Run: `cd backend/sinalacs_server && serverpod generate && serverpod create-migration` +Expected: migração nova em `migrations/`, `StaffAccount` em `generated/` e no `sinalacs_client`. Inspecione o SQL: só `CREATE TABLE "staff_accounts"` e o índice único; nenhum `DROP`. + +- [ ] **Step 5: Rodar o teste e a suíte unitária** + +Run: `dart test test/integration/staff_account_schema_test.dart && dart test test/unit` +Expected: PASS. (Integração precisa do Postgres de teste; ver `backend/CLAUDE.md`.) + +- [ ] **Step 6: Documentar e commitar** + +Adicione `staff_accounts` a `spec/lgpd_data_audit.md` (`enrollmentId`: identificador funcional, baixa sensibilidade; `active`: operacional) e atualize a contagem de tabelas citada em `backend/CLAUDE.md` (re-meça no `definition.sql` da migração nova, não copie número). + +```bash +git add backend spec/lgpd_data_audit.md +git commit -m "feat(backend): tabela staff_accounts para coordenador e administrador (#39)" +``` + +--- + +### Task 2: `InstitutionalAuthService` por audiência + +**Files:** +- Modify: `backend/sinalacs_server/lib/src/application/auth/institutional_auth_service.dart` (`AcsCredentialRecord`, construtor, `login`, `_authenticatePassword`, `beginTotpEnrollment`, `confirmTotpEnrollment`) +- Modify: `backend/sinalacs_server/lib/src/infrastructure/database/orm_acs_credential_store.dart` (`findByEnrollmentId`) +- Test: `backend/sinalacs_server/test/unit/institutional_auth_staff_test.dart` +- Test: `backend/sinalacs_server/test/integration/staff_login_test.dart` + +**Interfaces:** +- Produces: `enum CredentialAudience { acs, staff }`; construtor `InstitutionalAuthService(..., this.audience = CredentialAudience.acs)`; `AcsCredentialRecord` ganha `final UserRole role` com default `UserRole.acs` (os fakes dos testes existentes ficam intactos); `OrmAcsCredentialStore({required Session Function() session, bool staff = false})`. +- Comportamento: com `audience == staff`: (a) `login` devolve `AuthenticatedUser(role: record.role, microAreaId: null)`; (b) `_authenticatePassword` **não** exige microárea; (c) `requireMfa` é tratado como `true`; (d) recusa com a mensagem genérica se `record.role` não for `coordinator`/`admin`. + +- [ ] **Step 1: Escrever os testes unitários que falham** + +Copie o `_Store`/`_Cofre` de `test/unit/institutional_auth_mfa_test.dart` (mesmos imports) e acrescente: + +```dart +AcsCredentialRecord _staff({UserRole role = UserRole.admin, bool active = true, TotpEnrollment? totp}) => + AcsCredentialRecord( + acsId: '00000000-0000-4000-8000-000000000090', + microAreaId: null, + active: active, + role: role, + digest: _digest, // o mesmo helper do teste de MFA + failedAttempts: 0, + lockedUntil: null, + totp: totp, + ); + +test('staff sem MFA ativa recebe MfaEnrollmentRequired mesmo com requireMfa=false', () async { + final service = _service(_Store(_staff()), audience: CredentialAudience.staff, requireMfa: false); + expect( + () => service.login(matricula: 'ADM-001', password: _senha), + throwsA(isA()), + ); +}); + +test('staff com MFA ativa e código válido recebe token sem microárea e com o papel da conta', () async { + final user = await _service(_Store(_staff(totp: _totpAtivo())), audience: CredentialAudience.staff) + .login(matricula: 'ADM-001', password: _senha, totpCode: _codigoAtual()); + expect(user.role, UserRole.admin); + expect(user.microAreaId, isNull); +}); + +test('conta cujo papel não é de staff é recusada com a mensagem genérica', () async { + final service = _service(_Store(_staff(role: UserRole.acs)), audience: CredentialAudience.staff); + expect( + () => service.login(matricula: 'ADM-001', password: _senha, totpCode: '000000'), + throwsA(isA().having((e) => e.message, 'message', 'Matrícula ou senha inválidos.')), + ); +}); + +test('conta de staff inativa é recusada', () async { + final service = _service(_Store(_staff(active: false, totp: _totpAtivo())), audience: CredentialAudience.staff); + expect( + () => service.login(matricula: 'ADM-001', password: _senha, totpCode: _codigoAtual()), + throwsA(isA()), + ); +}); + +test('audiência acs continua exigindo microárea (regressão)', () async { + // reaproveite o caso existente de 'denied_no_territory' com audience: acs +}); +``` + +`_service`, `_digest`, `_senha`, `_totpAtivo`, `_codigoAtual` são os helpers que o arquivo de MFA já define: extraia-os para `test/support/institutional_auth_fixtures.dart` se não estiverem acessíveis e faça os dois testes importarem de lá (não duplicar). + +- [ ] **Step 2: Rodar e ver falhar** + +Run: `cd backend/sinalacs_server && dart test test/unit/institutional_auth_staff_test.dart` +Expected: FAIL (`CredentialAudience`/`role` não existem). + +- [ ] **Step 3: Implementar no serviço** + +Em `institutional_auth_service.dart`: + +```dart +/// Quem está entrando: o ACS (com território) ou o staff do backoffice. +enum CredentialAudience { acs, staff } +``` + +`AcsCredentialRecord`: acrescentar `this.role = UserRole.acs` e `final UserRole role;`. + +Construtor: `this.audience = CredentialAudience.acs`; campo `final CredentialAudience audience;` e getter + +```dart +bool get _mfaObrigatoria => audience == CredentialAudience.staff || requireMfa; +``` + +Em `login`, trocar `else if (requireMfa)` por `else if (_mfaObrigatoria)`; no `return`: + +```dart +return AuthenticatedUser( + id: record.acsId, + role: audience == CredentialAudience.staff ? record.role : UserRole.acs, + microAreaId: audience == CredentialAudience.staff ? null : record.microAreaId!, + deviceId: deviceId ?? deviceIdAbsent, +); +``` + +Em `_authenticatePassword`, depois do `record == null` e **antes** de qualquer outro uso do papel, logo após `hasher.matches` conferir não — a checagem de papel deve valer só quando a senha conferiu (não revelar nada antes). Coloque-a após o bloco `if (!record.active)`: + +```dart +if (audience == CredentialAudience.staff && + record.role != UserRole.coordinator && + record.role != UserRole.admin) { + await _recordAudit(record.acsId, 'denied_role'); + throw AuthenticationFailedException(message: _invalidCredentials); +} +``` + +e envolva o bloco de `microAreaId == null` em `if (audience == CredentialAudience.acs) { ... }`. + +- [ ] **Step 4: Implementar no store** + +Em `OrmAcsCredentialStore`: `OrmAcsCredentialStore({required Session Function() session, this.staff = false})`. Em `findByEnrollmentId`, buscar o id e o `active` conforme a flag: + +```dart +final UuidValue id; +final bool active; +if (staff) { + final conta = await StaffAccount.db.findFirstRow( + session, + where: (t) => t.enrollmentId.equals(enrollmentId), + ); + if (conta == null) return null; + id = conta.id!; + active = conta.active; +} else { + final acs = await Acs.db.findFirstRow( + session, + where: (t) => t.enrollmentId.equals(enrollmentId), + ); + if (acs == null) return null; + id = acs.id!; + active = acs.active; +} +``` + +O restante (credencial, `User`) usa `id`. Preencha `role: user.role` no `AcsCredentialRecord`. No ramo ACS, o papel gravado em `users` deve ser `acs`: se não for, devolva `null` (um usuário `admin` com linha em `acs` não pode entrar como ACS). + +- [ ] **Step 5: Rodar os unitários** + +Run: `dart test test/unit` +Expected: PASS, incluindo os testes antigos de `institutional_auth_*`. + +- [ ] **Step 6: Teste de integração (banco real)** + +`test/integration/staff_login_test.dart`, mesmo setup de `institutional_login_test.dart`: insere `users` (`role: admin`, `microAreaId: null`), `staff_accounts`, `user_credentials` (hash via `Argon2PasswordHasher`) com MFA já ativada (como `institutional_mfa_test.dart` faz). Casos: login com código válido emite JWT cujo payload tem `"role":"admin"` e `micro_area_id` nulo; matrícula de ACS (`ACS-001` do seed de teste) no store `staff: true` → `AuthenticationFailedException` genérica; matrícula de staff no store `staff: false` → idem. + +Run: `dart test test/integration/staff_login_test.dart` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add backend +git commit -m "feat(backend): InstitutionalAuthService por audiência (acs|staff) com MFA obrigatória no staff (#39)" +``` + +--- + +### Task 3: Endpoints `loginStaff`/ativação de MFA e guarda de papel + +**Files:** +- Modify: `backend/sinalacs_server/lib/src/application/auth/authorization.dart` +- Modify: `backend/sinalacs_server/lib/src/runtime/alert_runtime.dart` (junto de `institutionalAuthServiceFor`, ~linha 325) +- Modify: `backend/sinalacs_server/lib/src/endpoints/auth_endpoint.dart` +- Modify: `backend/sinalacs_server/test/unit/endpoint_auth_posture_test.dart` (`_publicMethodsByDesign`) +- Test: `backend/sinalacs_server/test/unit/authorization_test.dart`, `test/integration/staff_login_test.dart` +- Generated: `serverpod generate` (client) + +**Interfaces:** +- Consumes: `CredentialAudience`, `OrmAcsCredentialStore(staff: true)` (Tarefa 2). +- Produces: `Authorization.staffRoles` (`const {UserRole.coordinator, UserRole.admin}`); `AlertRuntime.staffAuthServiceFor(Session)`; RPC `auth.loginStaff(matricula, password, totpCode?) → DevelopmentLoginResult` (só `accessToken`, `tokenType`; sem refresh/upload token), `auth.beginStaffTotpEnrollment(matricula, password) → TotpEnrollmentStart`, `auth.confirmStaffTotpEnrollment(matricula, password, code) → void`. + +- [ ] **Step 1: Testes que falham** + +Em `authorization_test.dart`: + +```dart +test('staffRoles contém coordinator e admin, e só eles', () { + expect(Authorization.staffRoles, {UserRole.coordinator, UserRole.admin}); +}); + +test('token de admin (sem microárea) é recusado onde só o ACS pode', () { + expect( + () => Authorization.require( + _user(UserRole.admin, microAreaId: null), + roles: {UserRole.acs}, + onDenied: () => StateError('Somente ACS podem fazer isto.'), + ), + throwsA(isA()), + ); +}); + +test('requireMicroArea:false com staffRoles aceita admin sem território', () { + expect( + () => Authorization.require( + _user(UserRole.admin, microAreaId: null), + roles: Authorization.staffRoles, + requireMicroArea: false, + onDenied: () => StateError('x'), + ), + returnsNormally, + ); +}); +``` + +Em `integration/staff_login_test.dart`: `auth.loginStaff` com credenciais certas e código devolve token e `refreshToken == null`; senha errada ×5 bloqueia (reuso do bloqueio); `AuthEndpoint.loginStaff`, `beginStaffTotpEnrollment`, `confirmStaffTotpEnrollment` aparecem na allowlist. + +- [ ] **Step 2: Rodar e ver falhar** — `dart test test/unit/authorization_test.dart` → FAIL. + +- [ ] **Step 3: Implementar** + +`authorization.dart`, dentro de `Authorization`: + +```dart +/// Papéis do backoffice. Os endpoints de staff (issue #40) usam este conjunto +/// com `requireMicroArea: false`: staff não é territorializado. +static const Set staffRoles = {UserRole.coordinator, UserRole.admin}; +``` + +`alert_runtime.dart`: + +```dart +/// Login do backoffice: mesma regra do ACS, outra tabela de matrículas, MFA +/// sempre obrigatória e token sem microárea. +InstitutionalAuthService staffAuthServiceFor(Session session) { + final store = OrmAcsCredentialStore(session: () => session, staff: true); + return InstitutionalAuthService( + store: store, + hasher: passwordHasher, + audit: auditTrailFor(session), + totpStore: store, + vault: HealthCipherTotpVault(healthDataCipher), + audience: CredentialAudience.staff, + ); +} +``` + +`auth_endpoint.dart` (documente cada método com o mesmo cuidado dos vizinhos): + +```dart +/// Login do backoffice (coordenador/administrador): matrícula + senha + TOTP. +/// A MFA é obrigatória em todo ambiente. Sem refresh token: a sessão dura os +/// 15 min do JWT. +Future loginStaff( + Session session, { + required String matricula, + required String password, + String? totpCode, +}) async { + final runtime = AlertRuntime.instance; + final user = await runtime.staffAuthServiceFor(session).login( + matricula: matricula, + password: password, + totpCode: totpCode, + ); + return DevelopmentLoginResult( + accessToken: runtime.auth.issueToken(user), + tokenType: 'Bearer', + ); +} + +Future beginStaffTotpEnrollment( + Session session, {required String matricula, required String password}) => + AlertRuntime.instance.staffAuthServiceFor(session) + .beginTotpEnrollment(matricula: matricula, password: password); + +Future confirmStaffTotpEnrollment( + Session session, {required String matricula, required String password, required String code}) => + AlertRuntime.instance.staffAuthServiceFor(session) + .confirmTotpEnrollment(matricula: matricula, password: password, code: code); +``` + +`endpoint_auth_posture_test.dart`: três entradas em `_publicMethodsByDesign` ("emissor de token do backoffice: matrícula e senha SÃO credencial", e "ativação da MFA do staff: sem token antes da MFA, como a do ACS"). + +- [ ] **Step 4: Gerar o cliente** — `cd backend/sinalacs_server && serverpod generate`. + +- [ ] **Step 5: Rodar tudo** — `dart test test/unit && dart test test/integration/staff_login_test.dart` → PASS. + +- [ ] **Step 6: Commit** + +```bash +git add backend +git commit -m "feat(backend): auth.loginStaff e ativação de MFA do staff, com Authorization.staffRoles (#39)" +``` + +--- + +### Task 4: Admin de desenvolvimento no seed + +**Files:** +- Modify: `backend/sinalacs_server/lib/src/infrastructure/database/seeds/development.sql` +- Modify: `backend/sinalacs_server/bin/seed_acs_credentials.dart` +- Modify: `docker-compose.yml` (serviço `acs-credential-seed`), `scripts/dev/bootstrap_env.sh`, `.env.example` +- Test: `backend/sinalacs_server/test/unit/seed_birth_date_agreement_test.dart` (estilo de teste de acordo do seed; leia-o antes) ou novo `test/unit/seed_staff_agreement_test.dart` + +**Interfaces:** +- Produces: usuário `00000000-0000-4000-8000-000000000090`, papel `admin`, matrícula `ADM-001`, `microAreaId` nulo; senha de `DEV_ADMIN_PASSWORD`. + +- [ ] **Step 1: Teste de acordo que falha** — o UUID `…0090` e a matrícula `ADM-001` aparecem tanto em `development.sql` quanto em `seed_acs_credentials.dart`, e `DEV_ADMIN_PASSWORD` em `docker-compose.yml`, `bootstrap_env.sh` e `.env.example` (mesma técnica de `compose_secret_agreement_test.dart`). Run: `dart test test/unit/seed_staff_agreement_test.dart` → FAIL. + +- [ ] **Step 2: SQL** — em `development.sql`, siga o INSERT do ACS já presente (copie as colunas obrigatórias de `users`, incluindo um `cpfHash` literal único tipo `'development-admin'`, que o `cpf-hash-seed` não toca por só atualizar linhas de paciente) com `role = 'admin'`, `microAreaId = NULL`, e: + +```sql +INSERT INTO staff_accounts (id, "enrollmentId", active) +VALUES ('00000000-0000-4000-8000-000000000090', 'ADM-001', true) +ON CONFLICT DO NOTHING; +``` + +- [ ] **Step 3: Credencial** — em `seed_acs_credentials.dart`, depois de gravar a do ACS, ler `DEV_ADMIN_PASSWORD` (mesma recusa se vazia, mesma trava `APP_ENV=development`) e gravar a de `…0090` pelo mesmo caminho de upsert. Sem literal de senha. + +- [ ] **Step 4: Ambiente** — `DEV_ADMIN_PASSWORD="$(secret)"` em `bootstrap_env.sh` (ao lado de `DEV_ACS_PASSWORD`, linha ~71 e mensagem ~89), `DEV_ADMIN_PASSWORD=` em `.env.example`, e a variável `${DEV_ADMIN_PASSWORD:?defina em .env — rode ./scripts/dev/bootstrap_env.sh}` no serviço `acs-credential-seed`. + +- [ ] **Step 5: Verificar** — `dart test test/unit` → PASS; `./scripts/dev/bootstrap_env.sh --force` num clone limpo, `docker compose up --build`, depois confirmar no Postgres que existe a credencial: `docker compose exec postgres psql -U sinalacs_user -d sinalacs_db -c "select \"userId\" from user_credentials"` lista os dois ids. + +- [ ] **Step 6: Commit** — `git commit -m "feat(dev): admin ADM-001 no seed de desenvolvimento (#39)"`. + +--- + +### Task 5: `AdminAuthBackend` no app + +**Files:** +- Modify: `apps/admin/pubspec.yaml` (SDK `^3.8.0` e `sinalacs_client` por path, como `apps/acs/pubspec.yaml`), regenerar `pubspec.lock` +- Create: `apps/admin/lib/core/auth/admin_auth_backend.dart` +- Create: `apps/admin/lib/core/auth/backend_config.dart` (host por `--dart-define=SINALACS_HOST`, default `https://10.0.2.2/`; copie a regra HTTPS-only de `apps/acs/lib/core/network/backend_config.dart`, incluindo a recusa de `http://`) +- Test: `apps/admin/test/support/fake_admin_auth.dart`, `apps/admin/test/admin_auth_backend_test.dart` + +**Interfaces:** +- Produces: + +```dart +class AdminSession { + const AdminSession({required this.accessToken, required this.userId, required this.role, required this.expiresAt}); + final String accessToken, userId, role; // role: 'coordinator' | 'admin' + final DateTime expiresAt; +} + +class AdminAuthFailure implements Exception { + const AdminAuthFailure(this.message); + final String message; +} +class AdminMfaCodeRequired extends AdminAuthFailure { const AdminMfaCodeRequired() : super('Informe o código do aplicativo autenticador.'); } +class AdminMfaEnrollmentRequired extends AdminAuthFailure { const AdminMfaEnrollmentRequired() : super('Ative a verificação em duas etapas antes de entrar.'); } + +abstract interface class AdminAuthBackend { + Future login({required String matricula, required String senha, String? totpCode}); + Future<({String secret, String otpauthUri})> beginMfaEnrollment({required String matricula, required String senha}); + Future confirmMfaEnrollment({required String matricula, required String senha, required String code}); +} +``` + +- [ ] **Step 1: Teste que falha** — `admin_auth_backend_test.dart` testa a leitura do JWT (sem verificar assinatura, como `AuthSession.fromToken` do ACS): um token montado no teste com `role: admin` vira `AdminSession`; token com `role: patient` ou `acs` → `AdminAuthFailure` (o app **recusa** papel que não é de staff mesmo se o servidor emitisse); token malformado → `AdminAuthFailure`; `expiresAt` vem de `exp`. + +```dart +String _jwt(Map payload) { + String b64(Object o) => base64Url.encode(utf8.encode(jsonEncode(o))).replaceAll('=', ''); + return '${b64({'alg': 'HS256'})}.${b64(payload)}.sig'; +} + +test('papel de ACS no token é recusado pelo app', () { + final token = _jwt({'sub': 'u', 'role': 'acs', 'exp': 4102444800}); + expect(() => AdminSession.fromToken(token), throwsA(isA())); +}); +``` + +- [ ] **Step 2: Ver falhar** — `cd apps/admin && flutter pub get && flutter test test/admin_auth_backend_test.dart` → FAIL. + +- [ ] **Step 3: Implementar** — `AdminSession.fromToken` (decodifica o payload, exige `role ∈ {coordinator, admin}`), e `BackendAdminAuth(Client client)` que chama `client.auth.loginStaff(...)` e traduz: `MfaRequiredException` → `AdminMfaCodeRequired`, `MfaEnrollmentRequiredException` → `AdminMfaEnrollmentRequired`, `AuthenticationFailedException` → `AdminAuthFailure(e.message)`, erro de rede/`SocketException` → `AdminAuthFailure('Não foi possível conectar ao servidor.')`. Nunca logar o token. + +- [ ] **Step 4: Rodar** — `flutter analyze && flutter test` → PASS (os testes de dados continuam no mock). + +- [ ] **Step 5: Commit** — `git commit -m "feat(admin): AdminAuthBackend sobre sinalacs_client (#39)"`. + +--- + +### Task 6: Login real, TOTP e ativação de MFA nas telas + +**Files:** +- Create: `apps/admin/lib/app/login_screen.dart` (move `LoginScreen` de `app.dart`) +- Create: `apps/admin/lib/app/mfa_enrollment_screen.dart` (base: `apps/acs/lib/app/mfa_enrollment_screen.dart`; QR com `qr_flutter`, fundo branco/módulos escuros; adicionar `qr_flutter` ao `pubspec.yaml` com a mesma versão do ACS) +- Modify: `apps/admin/lib/app/app.dart` (`SinalAdminApp({AdminDataSource? dataSource, required AdminAuthBackend auth})`, remover `devLoginEnabled`), `apps/admin/lib/main.dart` (injeta `BackendAdminAuth`) +- Modify: testes existentes `login_flow_test.dart`, `login_dev_gate_test.dart`, `admin_home_shell_test.dart`, `text_scale_test.dart`, `touch_targets_test.dart`, `responsive_layout_test.dart` etc. (qualquer um que faça `SinalAdminApp()` passa `auth: FakeAdminAuth()`) +- Test: `apps/admin/test/login_real_test.dart` (substitui `login_dev_gate_test.dart`, que deixa de fazer sentido: apague-o) + +**Interfaces:** +- Consumes: `AdminAuthBackend`, `AdminSession`, `AdminAuthFailure`, `AdminMfaCodeRequired`, `AdminMfaEnrollmentRequired` (Tarefa 5). +- Produces: `FakeAdminAuth` em `test/support` com campos públicos `Object? failWith`, `bool requiresTotp`, `String? lastTotpCode`, e `login` que devolve uma `AdminSession` de papel `admin`. +- Chaves de widget: `matricula_field`, `senha_field`, `totp_field`, `login_button`, `login_error`, `mfa_secret`, `mfa_code_field`, `mfa_confirm_button`. + +- [ ] **Step 1: Testes que falham** (`login_real_test.dart`) + +```dart +testWidgets('credencial recusada mostra o erro em região viva e não abre o painel', (tester) async { + final auth = FakeAdminAuth()..failWith = const AdminAuthFailure('Matrícula ou senha inválidos.'); + await tester.pumpWidget(SinalAdminApp(auth: auth)); + await tester.enterText(find.byKey(const Key('matricula_field')), 'ADM-001'); + await tester.enterText(find.byKey(const Key('senha_field')), 'x'); + await tester.tap(find.byKey(const Key('login_button'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('login_error')), findsOneWidget); + expect(find.text('Painel de Indicadores'), findsNothing); +}); + +testWidgets('MfaCodeRequired mostra totp_field e reenvia com o código', (tester) async { + final auth = FakeAdminAuth()..requiresTotp = true; + await tester.pumpWidget(SinalAdminApp(auth: auth)); + // preenche matrícula/senha, toca em entrar → aparece totp_field + // preenche '123456', toca em entrar → painel abre e auth.lastTotpCode == '123456' +}); + +testWidgets('mudar matrícula ou senha descarta o campo do código', (tester) async { /* ... */ }); + +testWidgets('MfaEnrollmentRequired abre a tela de ativação, sem token', (tester) async { /* ... */ }); + +testWidgets('não existe caminho de entrada sem chamar o backend (sem bypass)', (tester) async { + await tester.pumpWidget(SinalAdminApp(auth: FakeAdminAuth())); + await tester.tap(find.byKey(const Key('login_button'))); + await tester.pumpAndSettle(); + expect(find.text('Painel de Indicadores'), findsNothing); // campos vazios não autenticam +}); + +testWidgets('sessão vencida volta ao login', (tester) async { /* FakeAdminAuth com expiresAt no passado → ao abrir o painel, o shell detecta e volta */ }); +``` + +Complete cada corpo comentado seguindo exatamente o padrão do primeiro (sem placeholders ao executar). + +- [ ] **Step 2: Ver falhar** — `flutter test test/login_real_test.dart` → FAIL. + +- [ ] **Step 3: Implementar** + +`LoginScreen` recebe `AdminAuthBackend auth` e `AdminDataSource dataSource`. `_login` é assíncrono: valida não-vazio, chama `auth.login`; captura `AdminMfaCodeRequired` → `setState(_pedeCodigo = true)`; `AdminMfaEnrollmentRequired` → `Navigator.push(MfaEnrollmentScreen(...))`; `AdminAuthFailure` → mensagem em `Semantics(liveRegion: true)` com `Key('login_error')`; sucesso → `pushReplacement(AdminHomeShell(dataSource: dataSource, session: session))`. Desabilitar o botão durante a chamada (evita duplo envio). Remover o banner "ambiente de desenvolvimento" e o aviso `dev_login_disabled_notice`. `AdminHomeShell` recebe `AdminSession session` e, ao construir cada aba, verifica `session.expiresAt`; vencida → `Navigator.pushAndRemoveUntil` para o login com a mensagem "Sessão encerrada. Entre novamente.". O campo TOTP: `keyboardType: number`, `maxLength: 6`, `FilteringTextInputFormatter.digitsOnly`. + +`MfaEnrollmentScreen`: chama `beginMfaEnrollment`, mostra `mfa_secret` + QR do `otpauthUri`, `mfa_code_field`, `mfa_confirm_button` → `confirmMfaEnrollment` → volta ao login com a mensagem "Verificação ativada. Entre com o código do aplicativo."; o segredo não é gravado no aparelho. + +- [ ] **Step 4: Rodar** — `flutter analyze && flutter test` → PASS (toda a suíte, incluindo `contrast_tokens_test`, `touch_targets_test`, `text_scale_test`: o login ganhou um campo, confira a 130%/200% de fonte e 320 dp de largura). + +- [ ] **Step 5: Commit** — `git commit -m "feat(admin): login institucional com TOTP e ativação de MFA; remove o bypass local (#39)"`. + +--- + +### Task 7: Prova ponta a ponta, documentação e fechamento + +**Files:** +- Create: `apps/admin/integration_test/admin_login_e2e.dart` (sufixo `_e2e` de propósito, para `flutter test integration_test` não o descobrir; ver `apps/CLAUDE.md`) +- Modify: `spec/PRD_system.md` (RNF06 e §4.2.2), `backend/CLAUDE.md` (endpoints `auth`, `DEV_ADMIN_PASSWORD`), `apps/CLAUDE.md` (seção Admin: o login deixou de ser local), `docs/telas-admin.md`, `PROGRESS.md`, `apps/admin/README.md` + +- [ ] **Step 1: E2E contra o backend local** — com a stack no ar (`docker compose up --build`): ativar a MFA do `ADM-001` pela tela, depois entrar com o código; recusar senha errada. Siga o padrão de `apps/acs/integration_test/full_journey_e2e.dart` (gerar TOTP com `test/support/totp.dart` copiado para `integration_test/support/`, esperar o passo avançar entre a ativação e o login: o código da ativação já foi usado e o replay é barrado). + +Run: `cd apps/admin && flutter test integration_test/admin_login_e2e.dart -d emulator-5554 --dart-define=SINALACS_HOST=https://10.0.2.2/ --dart-define=...` (confira em `scripts/qa/e2e.sh` como o ACS passa o host e o CA de desenvolvimento; o admin precisa confiar no mesmo certificado, ver `apps/acs/lib/core/network/backend_config.dart`). +Expected: PASS. Se o emulador ou a stack não estiverem disponíveis, registre em `PROGRESS.md` que a prova ficou pendente — não declare feito. + +- [ ] **Step 2: Build release** — `cd apps/admin && flutter build apk --release` (ou `flutter build web`): confirme que o app sobe no login e que nenhuma rota abre o painel sem `AdminSession` (teste de widget da Tarefa 6 já cobre; aqui só a verificação manual de que o `devLoginEnabled` não existe mais: `grep -rn devLoginEnabled apps/admin` retorna vazio). + +- [ ] **Step 3: Documentação** — RNF06 em `spec/PRD_system.md`: `coordinator`/`admin` agora têm caminho de emissão (`auth.loginStaff`), RBAC dos endpoints de staff segue na issue #40. Corrija o parágrafo da seção Admin de `apps/CLAUDE.md` ("Login is intentionally local-only…"). Atualize `PROGRESS.md` (o que foi provado e onde, o que ficou aberto: refresh token do staff, reset de MFA por tela, endpoints de dados). + +- [ ] **Step 4: Suítes completas e CI** — `cd backend/sinalacs_server && dart test`; `cd apps/admin && flutter analyze && flutter test`; `./scripts/qa/ci_invariants.sh`. + +- [ ] **Step 5: `graphify update .`** (regra do `CLAUDE.md`) e commit: + +```bash +git add -A +git commit -m "docs(admin): login real do backoffice documentado e provado (#39)" +``` + +--- + +## Auto-revisão + +- **Cobertura da issue:** emissão de JWT `admin`/`coordinator` com Argon2id e TOTP (T2–T3); app troca o login local, trata `MfaRequiredException` e expiração (T5–T6); `Authorization` cobre os novos papéis (T3); "sem credencial, nenhuma tela em release" (T6, T7); "MFA obrigatória" (T2, caso `requireMfa=false`); "testes de backend e widget cobrindo papel errado" (T2, T3, T5, T6); PRD RNF06 atualizado (T7). +- **Tipos consistentes:** `CredentialAudience`, `AcsCredentialRecord.role`, `OrmAcsCredentialStore(staff:)`, `Authorization.staffRoles`, `staffAuthServiceFor`, `AdminSession`/`AdminAuthBackend` usados com os mesmos nomes em todas as tarefas. +- **Pontos que o executor deve confirmar no código antes de seguir** (não verificados na análise): colunas obrigatórias de `users` no INSERT do seed; helpers (`_service`, `_digest`, `_totpAtivo`) de `institutional_auth_mfa_test.dart` e se já são reutilizáveis; como `e2e.sh` injeta host e CA; assinatura exata de `Client` do `sinalacs_client` para o admin; se o workspace `backend/` aceita o admin como membro (o ACS resolve por path, mesmo arranjo). +- **Fora do escopo (issues próprias):** endpoints de dados do staff (#40), troca do mock (#41), reset de MFA e gestão de contas (#43), refresh token do staff. diff --git a/docs/superpowers/plans/2026-10-06-admin-minors-adiados-39.md b/docs/superpowers/plans/2026-10-06-admin-minors-adiados-39.md new file mode 100644 index 00000000..4b6e3929 --- /dev/null +++ b/docs/superpowers/plans/2026-10-06-admin-minors-adiados-39.md @@ -0,0 +1,427 @@ +# Minors adiados da issue #39 — verificação e fechamento — Plano de implementação + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fechar os cinco itens adiados na revisão da issue #39 (login real do backoffice), depois de confirmar no código que cada um ainda está aberto. + +**Architecture:** Cada item é uma mudança pequena e independente, na ordem: teste de endpoint (só teste), auditoria com audiência (backend), cabeçalho do admin (app), política de bloqueio (decisão + teste + doc), captura de tela (emulador + doc). Cada tarefa começa por um passo de **verificação** que confirma o estado atual; se o item já estiver fechado, a tarefa termina ali. + +**Tech Stack:** Dart/Serverpod 3.4 (backend), Flutter (apps/admin), Postgres de teste (`sinalacs-postgres-test`), emulador Android `emulator-5554`. + +**Spec:** `docs/superpowers/plans/2026-10-06-admin-autenticacao-real.md` (plano da #39), `spec/lgpd_design.md` (trilha de auditoria), `spec/ux_accessibility_assessment.md`. + +## Resultado da verificação inicial (2026-10-06, branch `fix/app_admin` @ `8b077be`) + +Feita com `graphify query` para localizar os arquivos e leitura direta do código. **Os cinco pontos continuam abertos:** + +| # | Ponto | Evidência | +|---|---|---| +| 1 | Auditoria sem audiência | `institutional_auth_service.dart:476-483`: `_recordAudit` grava `actionType: 'login'`, `resourceType: 'session'` para ACS e staff. | +| 2 | Bloqueio compartilhado | Mesma política para as duas audiências: `maxFailedAttempts = 5`, `lockDuration = 15 min`, dobra por rodada (`institutional_auth_service.dart:175-188`). **Achado:** o contador mora em `user_credentials`, uma linha por usuário (`orm_acs_credential_store.dart:236-248`), então **não é compartilhado entre contas** — só a política é a mesma. O item estava mal descrito. | +| 3 | Sem teste de endpoint | `beginStaffTotpEnrollment`/`confirmStaffTotpEnrollment` só aparecem em `endpoint_auth_posture_test.dart` e no `serverpod_test_tools.dart` gerado. | +| 4 | Cabeçalho fixo | `apps/admin/lib/app/app.dart:132`: `AdminHeader('Backoffice • admin.dev', ...)`; `session` já está disponível no `AdminHomeShell`. | +| 5 | Captura antiga | `docs/telas-admin.md:11` aponta `screenshots/admin/01-login.png` (de 2026-09-15) e a linha 13 admite que não foi recapturada. | + +## Global Constraints + +- Texto de UI, comentários e mensagens de erro em português. +- Nada de `serverpod`-gerado editado à mão; esta mudança não altera modelos, então não há migração nem `serverpod generate`. +- Dado de teste sintético; senha e segredo nunca versionados. +- Sem atribuições de IA em commits (regra do repositório). +- Alvos de toque ≥ 48×52 dp e contraste pelos tokens `*OnSurface` do admin (`apps/CLAUDE.md`). +- Não rodar `docker compose down`, nem tocar em `pg_data/`; o emulador `emulator-5554` pode ser usado, o E2E que recria os containers de dev **não** faz parte deste plano. + +## Review Focus + +1. Consumidores que filtram `audit_logs` por `resourceType == 'session'` (tela de Auditoria do admin, seed, testes): mudar o valor para o staff não pode esconder as linhas de ACS nem as de staff (Tarefa 2). +2. `session.role` com valor inesperado no cabeçalho: não pode lançar nem mostrar texto cru; cai num rótulo genérico (Tarefa 3). +3. Cabeçalho a 200% de fonte e 320 dp com o rótulo novo, mais longo que "admin.dev": não pode estourar (Tarefa 3). +4. `confirmStaffTotpEnrollment` com código errado: conta tentativa e não ativa a MFA; com a conta bloqueada, nenhuma resposta distingue "bloqueada" de "senha errada" antes de a senha conferir (Tarefa 1). +5. Captura nova do login não pode conter dado real nem segredo (só campos vazios) (Tarefa 5). + +## Estrutura de arquivos + +| Arquivo | Responsabilidade | +|---|---| +| `backend/sinalacs_server/test/integration/staff_login_test.dart` (mod.) | Testes de endpoint de ativação de MFA do staff (T1) | +| `backend/sinalacs_server/lib/src/application/auth/institutional_auth_service.dart` (mod.) | `_recordAudit` com recurso por audiência (T2) | +| `backend/sinalacs_server/test/unit/institutional_auth_staff_test.dart` (mod.), `RecordingAudit` em `test/support` (mod.) | Asserção do recurso gravado (T2) | +| `apps/admin/lib/app/app.dart` (mod.), `apps/admin/test/admin_header_test.dart` (mod.) | Cabeçalho com o papel da sessão (T3) | +| `backend/CLAUDE.md`, `spec/lgpd_design.md` (mod., só se a política mudar) | Política de bloqueio por audiência (T4) | +| `docs/screenshots/admin/01-login.png`, `docs/telas-admin.md` (mod.) | Captura e texto do login (T5) | + +--- + +### Task 1: Teste de endpoint para `begin`/`confirmStaffTotpEnrollment` + +**Files:** +- Modify: `backend/sinalacs_server/test/integration/staff_login_test.dart` + +**Interfaces:** +- Consumes: `endpoints.auth.beginStaffTotpEnrollment(sessionBuilder, matricula:, password:)` → `TotpEnrollmentStart` (campos `secretBase32`, `otpauthUri`); `endpoints.auth.confirmStaffTotpEnrollment(sessionBuilder, matricula:, password:, code:)`; helpers e fixtures já definidos no arquivo (`_adminId`, `_adminMatricula`, `_senha`, e o helper que insere admin sem MFA — leia as linhas 30-120 do arquivo e use o mesmo `withServerpod`/`sessionBuilder` dos testes de `loginStaff`). + +- [ ] **Step 0: Verificar que o item está aberto** + +Run: `cd backend/sinalacs_server && grep -n "beginStaffTotpEnrollment\|confirmStaffTotpEnrollment" test/integration/staff_login_test.dart` +Expected: sem saída (aberto). Se houver saída, ler os testes: se já cobrem os casos abaixo, encerrar a tarefa. + +- [ ] **Step 1: Escrever os testes que falham** + +Dentro do grupo existente de `withServerpod`, acrescente (ajuste só os nomes dos helpers ao que o arquivo já usa; o corpo de cada teste é este): + +```dart +group('ativação de MFA do staff pelo endpoint', () { + test('begin devolve segredo e URI otpauth e não ativa a MFA', () async { + await _inserirAdminSemMfa(session); + + final inicio = await endpoints.auth.beginStaffTotpEnrollment( + sessionBuilder, + matricula: _adminMatricula, + password: _senha, + ); + + expect(inicio.secretBase32, isNotEmpty); + expect(inicio.otpauthUri, startsWith('otpauth://totp/')); + final cred = await UserCredential.db.findFirstRow( + session, + where: (t) => t.userId.equals(UuidValue.fromString(_adminId)), + ); + expect(cred!.totpSecretEncrypted, isNotNull, reason: 'segredo pendente gravado'); + expect(cred.totpEnabledAt, isNull, reason: 'MFA só vale depois do confirm'); + }); + + test('confirm com código certo ativa; depois disso loginStaff exige o código', () async { + await _inserirAdminSemMfa(session); + final inicio = await endpoints.auth.beginStaffTotpEnrollment( + sessionBuilder, matricula: _adminMatricula, password: _senha); + final agora = DateTime.now().toUtc(); + final codigo = Totp.codeAt(base32Decode(inicio.secretBase32), agora); // helper de TOTP usado em institutional_mfa_test.dart + + await endpoints.auth.confirmStaffTotpEnrollment( + sessionBuilder, matricula: _adminMatricula, password: _senha, code: codigo); + + expect( + () => endpoints.auth.loginStaff(sessionBuilder, matricula: _adminMatricula, password: _senha), + throwsA(isA()), + ); + }); + + test('confirm com código errado não ativa a MFA e conta uma tentativa', () async { + await _inserirAdminSemMfa(session); + await endpoints.auth.beginStaffTotpEnrollment( + sessionBuilder, matricula: _adminMatricula, password: _senha); + + await expectLater( + endpoints.auth.confirmStaffTotpEnrollment( + sessionBuilder, matricula: _adminMatricula, password: _senha, code: '000000'), + throwsA(isA()), + ); + + final cred = await UserCredential.db.findFirstRow( + session, where: (t) => t.userId.equals(UuidValue.fromString(_adminId))); + expect(cred!.totpEnabledAt, isNull); + expect(cred.failedAttempts, 1); + }); + + test('begin com senha errada é recusado com a mensagem genérica', () async { + await _inserirAdminSemMfa(session); + await expectLater( + endpoints.auth.beginStaffTotpEnrollment( + sessionBuilder, matricula: _adminMatricula, password: 'senha-errada'), + throwsA(isA() + .having((e) => e.message, 'message', 'Matrícula ou senha inválidos.')), + ); + }); + + test('begin com matrícula de ACS é recusado como matrícula inexistente', () async { + await _inserirAcsComCredencial(session); // helper já usado pelos testes de cruzamento deste arquivo + await expectLater( + endpoints.auth.beginStaffTotpEnrollment( + sessionBuilder, matricula: _acsMatricula, password: _senha), + throwsA(isA() + .having((e) => e.message, 'message', 'Matrícula ou senha inválidos.')), + ); + }); +}); +``` + +`Totp.codeAt`/`base32Decode`/`_inserirAdminSemMfa`/`_inserirAcsComCredencial` são nomes de referência: use os que `institutional_mfa_test.dart` e este arquivo realmente definem (se o gerador de código TOTP tiver outro nome, use-o; se não houver helper para o admin sem MFA, extraia-o do setup dos testes de `loginStaff`, sem duplicar). + +- [ ] **Step 2: Rodar e conferir** + +Run: `cd backend/sinalacs_server && dart test test/integration/staff_login_test.dart` +Expected: os testes novos **passam de primeira** — o código já existe (Tarefa 3 da #39); o valor desta tarefa é a cobertura. Para provar que não são vácuos, faça uma mutação temporária em `auth_endpoint.dart` (`beginStaffTotpEnrollment` chamando `institutionalAuthServiceFor` em vez de `staffAuthServiceFor`): os testes 1, 4 e 5 devem falhar. Reverta a mutação (`git checkout -- lib`) e registre o resultado no commit. + +- [ ] **Step 3: Commit** + +```bash +git add backend/sinalacs_server/test/integration/staff_login_test.dart +git commit -m "test(backend): endpoint de ativação de MFA do staff (begin/confirm) contra Postgres (#39)" +``` + +--- + +### Task 2: Auditoria que distingue a audiência + +**Files:** +- Modify: `backend/sinalacs_server/lib/src/application/auth/institutional_auth_service.dart:476-483` +- Modify: `backend/sinalacs_server/test/support/` (o `RecordingAudit` usado em `institutional_auth_staff_test.dart`; guarde também `resourceTypes`) +- Test: `backend/sinalacs_server/test/unit/institutional_auth_staff_test.dart`, `test/unit/institutional_auth_mfa_test.dart` + +**Interfaces:** +- Produces: eventos de login gravam `resourceType: 'session'` para ACS (inalterado) e `'staff_session'` para staff. `actionType` continua `'login'`. + +- [ ] **Step 0: Verificar que o item está aberto e achar consumidores do valor** + +Run: `cd /home/rock/Documents/Dev/APPs/SinalACS && grep -rn "'session'" backend/sinalacs_server/lib backend/sinalacs_server/test apps/admin/lib apps/admin/test | grep -v generated` +Expected: `institutional_auth_service.dart` e testes. Anote **todo consumidor que filtre ou espere** `'session'` (tela de Auditoria do admin só exibe o texto; confirme). Se algum filtro existir, ele entra nesta tarefa. + +- [ ] **Step 1: Escrever o teste que falha** (em `institutional_auth_staff_test.dart`) + +```dart +test('eventos de login do staff gravam o recurso staff_session', () async { + final m = await _montar(/* mesmos argumentos do teste 'granted' do staff */); + await m.servico.login(matricula: 'ADM-001', password: _senha, totpCode: _codigoAtual()); + expect(m.audit.resourceTypes, everyElement('staff_session')); + expect(m.audit.results, contains('granted')); +}); + +test('eventos de login do ACS continuam gravando o recurso session', () async { + // mesmo cenário de login ACS bem-sucedido já existente em institutional_auth_mfa_test.dart + expect(m.audit.resourceTypes, everyElement('session')); +}); +``` + +- [ ] **Step 2: Rodar e ver falhar** + +Run: `cd backend/sinalacs_server && dart test test/unit/institutional_auth_staff_test.dart` +Expected: FAIL (`resourceTypes` não existe / valor `session`). + +- [ ] **Step 3: Implementar** + +`RecordingAudit`: acrescente `List get resourceTypes => events.map((e) => e.resourceType).toList();` (use o campo de eventos que a classe já guarda). + +`institutional_auth_service.dart`: + +```dart + /// Distingue a audiência na trilha: revisar a atividade do backoffice não + /// pode exigir juntar `userId` com `staff_accounts` à mão. + String get _recursoDeAuditoria => + audience == CredentialAudience.staff ? 'staff_session' : 'session'; + + Future _recordAudit(String userId, String result) => audit.recordSafely( + AuditEvent( + userId: userId, + actionType: 'login', + resourceType: _recursoDeAuditoria, + result: result, + ), + ); +``` + +- [ ] **Step 4: Rodar tudo do backend** + +Run: `dart test test/unit && dart test test/integration/staff_login_test.dart test/integration/institutional_login_test.dart test/integration/institutional_mfa_test.dart` +Expected: PASS. Qualquer teste existente que espere `'session'` para o staff é atualizado aqui (e só ele). + +- [ ] **Step 5: Documentar e commitar** + +`backend/CLAUDE.md`: troque a frase "audit rows do not yet distinguish audience" (seção do staff) por "o login do staff grava `resourceType: 'staff_session'`; o do ACS, `'session'`". Atualize `spec/lgpd_data_audit.md` só se ele enumerar valores de `resourceType`. + +```bash +git add backend CLAUDE.md +git commit -m "feat(backend): auditoria de login distingue staff (staff_session) de ACS (session) (#39)" +``` + +--- + +### Task 3: Cabeçalho do admin mostra o papel da sessão + +**Files:** +- Modify: `apps/admin/lib/app/app.dart:132` (e `AdminSession` só se faltar um getter) +- Test: `apps/admin/test/admin_header_test.dart`, `apps/admin/test/text_scale_test.dart` + +**Interfaces:** +- Consumes: `AdminSession.role` (`'coordinator'` | `'admin'`), `widget.session` no `AdminHomeShell`. +- Produces: função de topo `String adminRoleLabel(String role)` em `app.dart` → `'Administrador'`, `'Coordenador'`, ou `'Equipe'` para qualquer outro valor. + +- [ ] **Step 0: Verificar que o item está aberto** + +Run: `cd /home/rock/Documents/Dev/APPs/SinalACS && grep -n "admin.dev" apps/admin/lib/app/app.dart` +Expected: linha 132. Sem saída = fechado; encerrar. + +- [ ] **Step 1: Escrever os testes que falham** (em `admin_header_test.dart`, mesmo estilo dos testes existentes e `FakeAdminAuth`) + +```dart +test('adminRoleLabel traduz os papéis e cai num rótulo genérico', () { + expect(adminRoleLabel('admin'), 'Administrador'); + expect(adminRoleLabel('coordinator'), 'Coordenador'); + expect(adminRoleLabel('qualquer-coisa'), 'Equipe'); +}); + +testWidgets('o cabeçalho mostra o papel da sessão e não "admin.dev"', (tester) async { + await entrarComCredenciais(tester, /* FakeAdminAuth com papel admin */); + expect(find.textContaining('Administrador'), findsWidgets); + expect(find.textContaining('admin.dev'), findsNothing); +}); + +testWidgets('sessão de coordenador mostra "Coordenador"', (tester) async { + await entrarComCredenciais(tester, /* FakeAdminAuth com papel coordinator */); + expect(find.textContaining('Coordenador'), findsWidgets); +}); +``` + +Se `FakeAdminAuth` não permite escolher o papel, acrescente o campo público `String role = 'admin'` nele (em `test/support/fake_admin_auth.dart`) e use-o no `AdminSession` que devolve. + +- [ ] **Step 2: Rodar e ver falhar** + +Run: `cd apps/admin && flutter test test/admin_header_test.dart` +Expected: FAIL (`adminRoleLabel` indefinido). + +- [ ] **Step 3: Implementar** + +Em `app.dart`: + +```dart +/// Rótulo do papel para o cabeçalho. Valor desconhecido não quebra a tela nem +/// aparece cru: o token só chega aqui com papel de staff, mas o cabeçalho não +/// deve depender disso. +String adminRoleLabel(String role) => switch (role) { + 'admin' => 'Administrador', + 'coordinator' => 'Coordenador', + _ => 'Equipe', + }; +``` + +e na linha 132: `AdminHeader('Backoffice • ${adminRoleLabel(widget.session.role)}', 'Painel administrativo', height: adminHeaderHeight(context))`. Não exibir `userId` (é um UUID sem valor para quem lê e vai parar em captura de tela). + +- [ ] **Step 4: Rodar a suíte do admin, incluindo fonte grande** + +Run: `cd apps/admin && flutter analyze && flutter test` +Expected: PASS. Confirme que `text_scale_test.dart` e `admin_header_test.dart` cobrem o painel a 200% e 320 dp com o rótulo novo (o de "Administrador" é mais longo que "admin.dev"); se não cobrirem, acrescente um caso no mesmo estilo dos existentes. Nenhum teste existente pode ter asserção afrouxada. + +- [ ] **Step 5: Commit** + +```bash +git add apps/admin +git commit -m "feat(admin): cabeçalho mostra o papel da sessão em vez de admin.dev fixo (#39)" +``` + +--- + +### Task 4: Política de bloqueio do staff — decidir, provar e documentar + +**Files:** +- Test: `backend/sinalacs_server/test/integration/staff_login_test.dart` +- Modify: `backend/CLAUDE.md` (e `institutional_auth_service.dart` **somente** se o usuário escolher política diferente) + +**Interfaces:** +- Consumes: `InstitutionalAuthService.maxFailedAttempts`, `lockDuration`, `lockDurationFor(streak)`. + +- [ ] **Step 0: Confirmar o achado e pedir a decisão** + +A verificação mostrou que o contador **não** é compartilhado: é por linha de `user_credentials`, e staff e ACS são usuários distintos. O que existe é a **mesma política**. Antes de codar, pergunte ao usuário (AskUserQuestion): manter a mesma política para as duas audiências (recomendado: já é o mais seguro razoável, 5 tentativas e 15 min dobrando até 24 h) **ou** endurecer o staff (por exemplo 3 tentativas). Sem resposta, siga com "manter". + +- [ ] **Step 1: Teste que prova a independência dos contadores** (decisão "manter") + +```dart +test('falhas de um ACS não bloqueiam o staff e vice-versa (contador por conta)', () async { + await _inserirAdminComMfa(session); + await _inserirAcsComCredencial(session); + + for (var i = 0; i < InstitutionalAuthService.maxFailedAttempts; i++) { + await expectLater( + endpoints.auth.loginInstitutional(sessionBuilder, matricula: _acsMatricula, password: 'errada'), + throwsA(isA()), + ); + } + + final admin = await UserCredential.db.findFirstRow( + session, where: (t) => t.userId.equals(UuidValue.fromString(_adminId))); + expect(admin!.failedAttempts, 0); + expect(admin.lockedUntil, isNull); +}); +``` + +- [ ] **Step 2: Rodar** + +Run: `cd backend/sinalacs_server && dart test test/integration/staff_login_test.dart` +Expected: PASS de primeira (comportamento já existente; o teste fixa a propriedade). Mutação para provar que não é vácuo: faça temporariamente `registerFailedAttempt` atualizar todas as linhas (remova o `WHERE "userId"`) e confirme que o teste falha; reverta. + +- [ ] **Step 3: Documentar** + +`backend/CLAUDE.md`, seção do staff: "Staff e ACS seguem a **mesma política** de bloqueio (5 falhas, 15 min dobrando por rodada até 24 h), mas o contador é **por conta** (`user_credentials` por usuário): falhas de uma conta não afetam outra." Se o usuário escolheu endurecer o staff: implemente `maxFailedAttempts`/`lockDuration` por audiência no serviço com teste unitário próprio, em tarefa separada revisada. + +- [ ] **Step 4: Commit** + +```bash +git add backend +git commit -m "test(backend): contador de bloqueio é por conta; política do staff documentada (#39)" +``` + +--- + +### Task 5: Recapturar a tela de login do admin + +**Files:** +- Modify: `docs/screenshots/admin/01-login.png`, `docs/telas-admin.md:11-13` + +**Interfaces:** nenhuma (documentação). + +- [ ] **Step 0: Verificar que o item está aberto** + +Run: `cd /home/rock/Documents/Dev/APPs/SinalACS && git log -1 --format='%ad %s' --date=short -- docs/screenshots/admin/01-login.png; grep -n "não foi recapturada" docs/telas-admin.md` +Expected: data de 2026-09-15 e a frase presente (aberto). + +- [ ] **Step 1: Subir o app no emulador** + +Run: `export PATH="$HOME/Android/Sdk/platform-tools:$HOME/flutter/bin:$PATH"; cd apps/admin && mkdir -p assets/certs && flutter run -d emulator-5554` +A tela de login não depende do backend; não digite credencial. Se o app não compilar por falta de `assets/certs/dev_rpc_ca.crt`, rode `./scripts/dev/sync_dev_ca.sh` (copia só a CA pública) ou deixe a pasta vazia (a conexão falha fechada, mas a tela abre). + +- [ ] **Step 2: Capturar** + +Run: `adb -s emulator-5554 exec-out screencap -p > docs/screenshots/admin/01-login.png` +Abra a imagem (Read) e confira: campos vazios, sem banner "ambiente de desenvolvimento", botão "Entrar", e **nenhum** dado real ou segredo. Compare o tamanho/orientação com `docs/screenshots/admin/06-android-retrato.png` para manter o padrão do documento. + +- [ ] **Step 3: Atualizar o texto** + +Em `docs/telas-admin.md`, remova a frase "A imagem acima é da versão anterior (formulário local) e não foi recapturada." e acrescente a data da captura. **Atenção:** as capturas `02` a `08` mostram o painel com o cabeçalho antigo "admin.dev"; depois da Tarefa 3 elas ficam desatualizadas e só podem ser refeitas com uma sessão real (depende da prova E2E pendente da #39). Registre isso numa nota curta no mesmo arquivo, sem afirmar que foram recapturadas. + +- [ ] **Step 4: Verificar links e commitar** + +Run: `./scripts/qa/check_documentation_links.sh; echo "exit=$?"` +Expected: `exit=0`. + +```bash +git add docs/screenshots/admin/01-login.png docs/telas-admin.md +git commit -m "docs(admin): recaptura a tela de login do backoffice (#39)" +``` + +--- + +### Task 6: Fechamento + +- [ ] **Step 1: Suítes completas** + +Run: `cd backend/sinalacs_server && dart test; cd ../../apps/admin && flutter analyze && flutter test; cd ../.. && ./scripts/qa/ci_invariants.sh` +Expected: tudo verde. + +- [ ] **Step 2: Atualizar `PROGRESS.md`** com o que foi fechado e o que segue aberto (prova E2E no emulador, capturas 02–08, ativação de TOTP por código de uso único antes da #40). + +- [ ] **Step 3: `graphify update .` e commit** + +```bash +graphify update . +git add PROGRESS.md +git commit -m "docs: fecha os minors adiados da #39 em PROGRESS.md" +``` + +--- + +## Auto-revisão + +- **Cobertura:** os cinco pontos têm tarefa (T1 teste de endpoint, T2 auditoria, T3 cabeçalho, T4 bloqueio, T5 captura); T6 fecha. Cada tarefa abre com passo de verificação do estado atual. +- **Placeholders:** os nomes de helper marcados como "de referência" (`_inserirAdminSemMfa`, `Totp.codeAt`, `_codigoAtual`, `RecordingAudit.events`) existem sob outros nomes nos arquivos citados; o executor deve usar os reais, e o plano diz onde achá-los. Não verifiquei os nomes exatos. +- **Consistência:** `staff_session`, `adminRoleLabel`, `resourceTypes` usados igualmente nas tarefas. +- **Desvio do pedido:** o item 2 estava mal descrito (contador não é compartilhado); a T4 reflete o achado e pede decisão sobre a política em vez de "separar contadores". +- **Fora de escopo:** E2E no emulador da #39, recaptura das telas 02–08, ativação de TOTP por código de uso único (follow-up antes da #40). diff --git a/docs/superpowers/plans/2026-10-06-verificacao-fonte-200-acs-resultado.md b/docs/superpowers/plans/2026-10-06-verificacao-fonte-200-acs-resultado.md new file mode 100644 index 00000000..f4b30939 --- /dev/null +++ b/docs/superpowers/plans/2026-10-06-verificacao-fonte-200-acs-resultado.md @@ -0,0 +1,7 @@ +# Resultado da verificação — lacunas da fonte a 200% no ACS (2026-10-06) + +| Lacuna | Estado | Evidência | +|---|---|---| +| L1: só 360x800 | ABERTO | `text_scale_test.dart` varre o painel só em `Size(360, 800)`; o único 320 dp é `app_lock_gate_test.dart:413` (320x480, só a tela de bloqueio); nenhuma ocorrência de paisagem em `test/` nem `integration_test/` | +| L2: `save_visit` alcançável | ABERTO | `save_visit` aparece em `login_flow_test.dart`, `visit_owner_flow_test.dart`, `device_wipe_test.dart` e `full_journey_e2e.dart`, todos em escala 1.0; `text_scale_test.dart` não o cita | +| L3: rótulo do chip | ABERTO | nenhuma ocorrência de `broker_status` em `test/` nem `integration_test/`; o `Chip` (`app.dart` ~3590) usa `maxLines: 1` com elipse e nenhum `Semantics` próprio | diff --git a/docs/superpowers/plans/2026-10-06-verificacao-fonte-200-acs.md b/docs/superpowers/plans/2026-10-06-verificacao-fonte-200-acs.md new file mode 100644 index 00000000..1c59bc1f --- /dev/null +++ b/docs/superpowers/plans/2026-10-06-verificacao-fonte-200-acs.md @@ -0,0 +1,294 @@ +# Verificação das três lacunas da fonte a 200% no ACS — Plano de Implementação + +> **Para quem executa:** SUB-SKILL OBRIGATÓRIA: use superpowers:subagent-driven-development (recomendado) ou superpowers:executing-plans para executar este plano tarefa a tarefa. Os passos usam checkbox (`- [ ]`). + +**Objetivo:** Para cada uma das três lacunas, decidir com evidência se ela segue em aberto e, se sim, fechá-la com um teste que falha quando o defeito existe. + +**Arquitetura:** Primeiro uma tarefa de verificação (grafo + código) que classifica cada ponto como ABERTO, FECHADO ou PARCIAL, sem mudar código. Depois uma tarefa de teste por ponto aberto, todas em `apps/acs/test/text_scale_test.dart` e reutilizando `test/support/layout_harness.dart`. Só se um teste novo falhar contra o código atual é que se corrige `apps/acs/lib/app/app.dart`. + +**Stack:** Flutter, `flutter_test` (widget tests), `SemanticsHandle` / `tester.getSemantics`. + +**Spec:** `spec/ux_accessibility_assessment.md` (WCAG 1.4.4, 2.5.8, 4.1.2) e `RNF05` em `spec/PRD_system.md`. + +## Global Constraints + +- Textos, rótulos e mensagens de teste em português. +- Nada de `MediaQuery.withClampedTextScaling`: o layout aguenta a escala (comentário de `text_scale_test.dart`). +- Alvo de toque mínimo de 48 dp; o `save_visit` declara `minimumSize: Size(48, 52)`. +- Sem dados reais de paciente: só `FakeAcsBackend` e `testAlert` de `test/support/fakes.dart`. +- Commits sem linha de atribuição de IA nem `Co-Authored-By` (regra do `CLAUDE.md` do projeto). +- Antes de `grep` cru, rodar `graphify query ""` (hook do repositório); depois de mudar código, `graphify update .`. + +## Review Focus + +- Janela de **320 dp** com 200%: é a combinação mais apertada, e só existe um teste dela (`app_lock_gate_test.dart:413`, 320x480, só a cobertura de bloqueio). O painel inteiro nunca foi varrido ali. +- **Paisagem** (800x360, altura menor que a largura): o `AppBar` cresce com a fonte (`acsHeaderHeight`) e a área rolável encolhe; o risco é o corpo ficar sem altura útil. +- `save_visit` **desabilitado** (`onPressed: null` sem paciente ou sem chegada confirmada) não pode contar como "alcançável": o teste precisa deixá-lo habilitado antes de afirmar. +- Chip `broker_status` com `maxLines: 1` e `ellipsis`: o texto visual é cortado, mas o rótulo semântico tem de continuar o texto inteiro, nos três estados (`null`, `true`, `false`). +- Fonte a **1.3** e a **2.0** em 320 dp: a 1.3 também precisa passar, porque é o ajuste mais comum. + +--- + +### Task 1: Classificar cada lacuna (somente leitura) + +**Files:** +- Read: `apps/acs/test/text_scale_test.dart`, `apps/acs/test/support/layout_harness.dart`, `apps/acs/test/visit_owner_flow_test.dart`, `apps/acs/test/app_lock_gate_test.dart` +- Read: `apps/acs/lib/app/app.dart` (cabeçalho `~3570-3612`, formulário de visita `~3085-3125`) +- Create: `docs/superpowers/plans/2026-10-06-verificacao-fonte-200-acs-resultado.md` + +**Interfaces:** +- Produces: tabela com as linhas L1 (320dp/paisagem), L2 (`save_visit` alcançável), L3 (rótulo acessível do chip), cada uma ABERTO, FECHADO ou PARCIAL, com `arquivo:linha` como evidência. As Tarefas 2 a 4 só rodam para linhas ABERTO ou PARCIAL. + +- [ ] **Step 1: Orientar-se pelo grafo** + +Run: +```bash +cd /home/rock/Documents/Dev/APPs/SinalACS +graphify query "teste de fonte 200% layout_harness text_scale_test" --budget 1500 +graphify explain "layout_harness.dart" +graphify query "broker_status chip cabeçalho Semantics" +graphify query "save_visit aba Visita" +``` +Expected: nós de `text_scale_test.dart`, `layout_harness.dart` e `app.dart`. Se o grafo vier vago, seguir com `grep` direcionado nesses arquivos. + +- [ ] **Step 2: Confirmar L1 (tamanhos varridos)** + +Run: +```bash +cd apps/acs +grep -rnE "Size\((320|800, *360|360|411)" test integration_test +grep -rniE "paisagem|landscape" test integration_test +``` +Esperado hoje: `Size(360, 800)` em `text_scale_test.dart`, `Size(320, 480)` só em `app_lock_gate_test.dart:413`, nenhuma ocorrência de paisagem. Se aparecer um teste que varre o painel em 320 ou em paisagem, marcar L1 como FECHADO ou PARCIAL e citar a linha. + +- [ ] **Step 3: Confirmar L2 (alcance do `save_visit`)** + +Run: +```bash +grep -nE "save_visit" test/*.dart integration_test/*.dart +grep -nE "hitTestable" test/text_scale_test.dart test/visit_owner_flow_test.dart +``` +Esperado hoje: `save_visit` aparece em `visit_owner_flow_test.dart` com `ensureVisible`, mas em escala 1.0; `text_scale_test.dart` não o cita. Marcar L2 ABERTO se nenhum teste combina fonte a 2.0, `ensureVisible` e `hitTestable` no `save_visit`. + +- [ ] **Step 4: Confirmar L3 (rótulo do chip)** + +Run: +```bash +grep -rnE "broker_status" test integration_test +``` +Esperado hoje: nenhuma ocorrência. No código, o `Chip` tem `label: Text(..., overflow: ellipsis, maxLines: 1)` e nenhum `Semantics` próprio. Marcar L3 ABERTO se nenhum teste lê o rótulo semântico. + +- [ ] **Step 5: Gravar o resultado** + +Escrever a tabela L1/L2/L3 em `2026-10-06-verificacao-fonte-200-acs-resultado.md`, com a evidência de cada linha. Se alguma linha der FECHADO, dizer isso e pular a tarefa correspondente. + +- [ ] **Step 6: Commit** + +```bash +git add docs/superpowers/plans/2026-10-06-verificacao-fonte-200-acs-resultado.md +git commit -m "docs: resultado da verificação das lacunas da fonte a 200% no ACS" +``` + +--- + +### Task 2: L1 — varrer o painel em 320 dp e em paisagem + +**Files:** +- Modify: `apps/acs/test/text_scale_test.dart:14-20` +- Test: `apps/acs/test/text_scale_test.dart` + +**Interfaces:** +- Consumes: `abrirPainel(tester, tamanho:, escalaDeFonte:)` e `percorrerPainelInteiro(tester, contexto)` de `test/support/layout_harness.dart`. +- Produces: testes `não estoura o layout com fonte a N% em WxH`. + +- [ ] **Step 1: Escrever o teste que varre os três tamanhos** + +Trocar o laço atual (que só cobre 360x800) por: + +```dart + const janelas = { + '360x800': Size(360, 800), + '320x640': Size(320, 640), + '800x360 (paisagem)': Size(800, 360), + }; + for (final janela in janelas.entries) { + for (final escala in const [1.3, 2.0]) { + final rotulo = '${(escala * 100).toInt()}%'; + testWidgets('não estoura o layout com fonte a $rotulo em ${janela.key}', (tester) async { + await abrirPainel(tester, tamanho: janela.value, escalaDeFonte: escala); + await percorrerPainelInteiro(tester, 'fonte a $rotulo em ${janela.key}'); + }); + } + } +``` + +- [ ] **Step 2: Rodar e ver o resultado** + +Run: `cd apps/acs && flutter test test/text_scale_test.dart --plain-name "não estoura o layout com fonte"` +Expected: se algum tamanho estourar, FAIL com `estouro de layout em fonte a 200% em 320x640 / (...)`. Se passar tudo, a lacuna era só de cobertura: o teste fica como prova e não há correção. + +- [ ] **Step 3: Corrigir só o que falhar** + +Para cada falha, abrir o widget citado na mensagem em `apps/acs/lib/app/app.dart` e trocar a largura ou altura fixa por `Flexible`/`Expanded`/`Wrap`, ou envolver em `SingleChildScrollView`. Não usar `withClampedTextScaling`. Um caso por vez, rodando de novo o Step 2. + +- [ ] **Step 4: Rodar a suíte inteira do arquivo** + +Run: `cd apps/acs && flutter test test/text_scale_test.dart` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/acs/test/text_scale_test.dart apps/acs/lib/app/app.dart +git commit -m "test(acs): painel varrido a 130% e 200% em 320 dp e em paisagem" +``` + +--- + +### Task 3: L2 — afirmar que `save_visit` fica alcançável a 200% + +**Files:** +- Modify: `apps/acs/test/text_scale_test.dart` (novo `testWidgets` antes do bloco da MFA) +- Read: `apps/acs/test/visit_owner_flow_test.dart:60-130` (como chegar a um formulário com paciente e chegada confirmada) + +**Interfaces:** +- Consumes: `abrirPainel`, `irParaDaBarra`, `assentar`, `esperarSemEstouro`. +- Produces: teste `save_visit fica alcançável a 200% em `. + +- [ ] **Step 1: Ver como o teste existente habilita o botão** + +Run: `cd apps/acs && sed -n 60,130p test/visit_owner_flow_test.dart` +Anotar os passos que selecionam um paciente (chave `patient_search`, linha 188) e marcam `arrival_confirmation`. O botão só habilita com `hasPatient && _arrivalConfirmed` (`app.dart:3111`). + +- [ ] **Step 2: Escrever o teste** + +Substituir `` pelos passos anotados no Step 1 (copiados, não resumidos): + +```dart + for (final janela in const {'360x800': Size(360, 800), '320x640': Size(320, 640), '800x360': Size(800, 360)}.entries) { + testWidgets('save_visit fica alcançável a 200% em ${janela.key}', (tester) async { + await abrirPainel(tester, tamanho: janela.value, escalaDeFonte: 2.0); + await irParaDaBarra(tester, 'Visita'); + // : escolher o paciente e marcar `arrival_confirmation`. + await assentar(tester); + + final salvar = find.byKey(const Key('save_visit')); + await tester.ensureVisible(salvar); + await assentar(tester); + expect(tester.widget(salvar).onPressed, isNotNull, + reason: 'desabilitado não prova alcance'); + expect(salvar.hitTestable(), findsOneWidget); + expect(tester.getSize(salvar).height, greaterThanOrEqualTo(48)); + esperarSemEstouro(tester, 'botão Salvar a 200% em ${janela.key}'); + }); + } +``` + +- [ ] **Step 3: Rodar** + +Run: `cd apps/acs && flutter test test/text_scale_test.dart --plain-name "save_visit fica alcançável"` +Expected: PASS se o botão já é alcançável. FAIL em `hitTestable` (botão coberto pelo `NavigationBar` ou fora da rolagem) é o defeito real; nesse caso, ajustar o padding inferior ou a rolagem da aba em `app.dart` e rodar de novo. + +- [ ] **Step 4: Provar que o teste pega o defeito** + +Trocar temporariamente `key: const Key('save_visit')` por outra chave em `app.dart:3110`, rodar o teste, ver FAIL, desfazer. Isso confirma que o `find` não passa em vazio. + +- [ ] **Step 5: Commit** + +```bash +git add apps/acs/test/text_scale_test.dart apps/acs/lib/app/app.dart +git commit -m "test(acs): save_visit alcançável e habilitado com fonte a 200%" +``` + +--- + +### Task 4: L3 — testar o rótulo acessível do chip de conexão + +**Files:** +- Modify: `apps/acs/test/text_scale_test.dart` +- Possivelmente modify: `apps/acs/lib/app/app.dart:3590-3612` (cabeçalho) + +**Interfaces:** +- Consumes: `abrirPainel`, `assentar`; chave `broker_status`. +- Produces: teste `chip de conexão mantém o rótulo semântico completo a 200%`. + +- [ ] **Step 1: Escrever o teste do estado `true` e do truncamento** + +```dart + testWidgets('chip de conexão mantém o rótulo semântico completo a 200% em 360x800', (tester) async { + final semantica = tester.ensureSemantics(); + addTearDown(semantica.dispose); + await abrirPainel(tester, tamanho: const Size(360, 800), escalaDeFonte: 2.0); + + final chip = find.byKey(const Key('broker_status')); + expect(chip, findsOneWidget); + // O texto visual é cortado por `maxLines: 1`; o rótulo semântico não pode ser. + final rotulo = tester.getSemantics(chip).label; + expect(rotulo, contains('Alertas em tempo real')); + esperarSemEstouro(tester, 'chip de conexão a 200%'); + }); +``` + +- [ ] **Step 2: Rodar** + +Run: `cd apps/acs && flutter test test/text_scale_test.dart --plain-name "chip de conexão"` +Expected: PASS se o `Text` do `Chip` propaga o rótulo inteiro. Se falhar porque o `label` vem vazio ou cortado, ir ao Step 3. + +- [ ] **Step 3: Cobrir os outros dois estados (`false` e `null`)** + +Descobrir como o `FakeAcsBackend` ou o `feedBuilder` de `fakes.dart` controla `connected`: +```bash +cd apps/acs && grep -nE "connected|brokerConnected|connectionStream" test/support/fakes.dart lib/app/app.dart | head -20 +``` +Escrever um teste por estado, afirmando `'Sem central'` e `'Offline ready'` no rótulo semântico do `broker_status`. Se o estado não puder ser forçado pelos fakes, registrar isso no arquivo de resultado em vez de inventar um acoplamento novo. + +- [ ] **Step 4: Corrigir só se o rótulo semântico falhar** + +Em `app.dart`, envolver o `Chip` em `Semantics(label: , liveRegion: true, excludeSemantics: true, child: ...)`, no mesmo padrão de `app.dart:2325-2330`, e extrair o texto do `switch` para uma variável usada pelos dois lugares (texto visual e `Semantics`). `liveRegion` faz o leitor de tela anunciar a perda da central, o que combina com o texto do cabeçalho: "um ACS precisa saber, sem procurar, que parou de receber alertas". + +- [ ] **Step 5: Rodar a suíte do ACS** + +Run: `cd apps/acs && flutter test` +Expected: PASS, sem regressão em `semantics_scan` (o `Semantics` novo não pode virar botão inerte). + +- [ ] **Step 6: Commit** + +```bash +git add apps/acs/test/text_scale_test.dart apps/acs/lib/app/app.dart +git commit -m "test(acs): rótulo semântico do chip de conexão a 200%" +``` + +--- + +### Task 5: Fechar o ciclo + +**Files:** +- Modify: `spec/ux_accessibility_assessment.md` e `PROGRESS.md`, só nas linhas que citam essas três lacunas (achar com `grep -nE "360x800|paisagem|save_visit|elipse" spec/ux_accessibility_assessment.md PROGRESS.md`). + +- [ ] **Step 1: Atualizar o grafo e os testes** + +Run: +```bash +cd /home/rock/Documents/Dev/APPs/SinalACS && graphify update . +cd apps/acs && flutter analyze && flutter test +``` +Expected: sem avisos novos; todos os testes passam. + +- [ ] **Step 2: Atualizar a documentação** + +Para cada lacuna fechada, trocar a frase de "não coberto" pela evidência (nome do teste). Para a que seguir aberta, dizer por quê. Não tocar em outras linhas do documento. + +- [ ] **Step 3: Commit** + +```bash +git add spec/ux_accessibility_assessment.md PROGRESS.md +git commit -m "docs: lacunas da fonte a 200% no ACS atualizadas com a evidência dos testes" +``` + +--- + +## Autorrevisão + +- **Cobertura:** L1 na Tarefa 2, L2 na Tarefa 3, L3 na Tarefa 4; a Tarefa 1 decide se cada uma ainda está em aberto, que era o pedido. +- **Placeholders:** o único marcador é `` na Tarefa 3. É deliberado: os passos dependem de `visit_owner_flow_test.dart`, e o Step 1 manda copiá-los de lá. +- **Consistência:** chaves usadas (`save_visit`, `broker_status`, `arrival_confirmation`, `patient_search`) existem em `app.dart`; helpers (`abrirPainel`, `irParaDaBarra`, `assentar`, `esperarSemEstouro`) existem em `layout_harness.dart`. diff --git a/docs/superpowers/plans/2026-10-07-issue-40-endpoints-admin.md b/docs/superpowers/plans/2026-10-07-issue-40-endpoints-admin.md new file mode 100644 index 00000000..91d6a1dc --- /dev/null +++ b/docs/superpowers/plans/2026-10-07-issue-40-endpoints-admin.md @@ -0,0 +1,550 @@ +# Issue #40 — Endpoints de backend para o backoffice — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Trocar o `MockAdminDataSource` por dados reais: um endpoint `admin` somente leitura, protegido por papel de staff, com indicadores (contagens por risco e TMRAV), microáreas, alertas e auditoria paginados, com minimização de PII e leitura auditada em `audit_logs`. + +**Architecture:** `AdminEndpoint extends AuthenticatedEndpoint` exige `Authorization.staffRoles` (`requireMicroArea: false`), resolve o **escopo** do chamador (administrador = sistema inteiro; coordenador = a sua UBS, vinda de `staff_accounts.ubsId`, e sem UBS é recusado) e delega a um `AdminReadService` sobre uma interface `AdminReadStore` (implementação ORM/SQL). Cada leitura grava um `AuditEvent` **antes** de devolver dado (falha de auditoria = sem dado). No app, `BackendAdminDataSource` implementa `AdminDataSource` sobre o `sinalacs_client` com o token da sessão. + +**Tech Stack:** Dart/Serverpod 3.4.13 (migrações, `dart test` com `postgres-test`), Flutter (`apps/admin`), `scripts/qa/admin_login_e2e.sh` no emulador. + +**Spec:** Issue #40 (`gh issue view 40`); `spec/PRD_system.md` §4.2.2 (matriz de permissões) e §1.3 (TMRAV); `spec/lgpd_design.md` (minimização, RBAC); `backend/CLAUDE.md` (bloco Staff, `Authorization.staffRoles`); `PROGRESS.md` seção "Ativação do TOTP do staff (#48)". + +## Global Constraints + +- **Pré-requisito:** a #40 não pode ser entregue antes da #48 (comentário da issue). Executar a partir de `develop` **depois** do merge do PR #50. +- Só leitura: nenhum endpoint do `admin` escreve em tabela de domínio; a única escrita é a linha de auditoria. +- Papéis aceitos: `coordinator` e `admin` (`Authorization.staffRoles`). `acs` e `patient` recebem `AlertPermissionException`, inclusive com token válido. +- Escopo (PRD §4.2.2): `admin` vê o sistema; `coordinator` vê só a UBS de `staff_accounts.ubsId`; coordenador **sem** UBS é recusado (fail-closed). +- Minimização de PII: nenhum nome, CPF, telefone ou endereço de paciente na resposta. `patientLabel` = `#` + últimos 4 hex do UUID, em maiúsculas (ex.: `#A18F`); é rótulo, não identificador. +- `AuditLog`: nunca devolver `ipHash`, `previousHash`, `entryHash`. +- TMRAV: média de `acknowledgedAt − triggeredAt`, em segundos inteiros, dos alertas **vermelhos** com `acknowledgedAt` não nulo e `triggeredAt` nos últimos 30 dias; sem nenhum, `null`. +- Paginação: `limit` de 1 a 100 (padrão 50). Fora disso, `AdminInvalidRequestException` com a mensagem `Parâmetro de paginação inválido.` (não é erro de permissão). +- Dados sintéticos apenas; nada de dado real em teste, log ou captura. +- Migração só aditiva. Não editar `generated/` nem `migrations/` à mão (`serverpod generate` / `serverpod create-migration`, com `PATH` incluindo `~/.pub-cache/bin`). +- Commits sem `Co-Authored-By` e sem "Generated with Claude Code". PR contra `develop`. +- `scripts/qa/e2e_stack.sh up` recria os containers de dev e o ambiente recusa que o agente o rode: o usuário roda com `!`. Testes em aparelho: emulador `emulator-5554` (AVD `Medium_Phone`); o celular `0087014315` é opcional. + +## Review Focus + +- Coordenador de UBS A nunca vê alerta, microárea ou indicador da UBS B (teste com duas UBS). +- Coordenador sem `ubsId` é recusado em **todos** os métodos, não só em alguns. +- Token de `acs`/`patient` (inclusive com microárea) recusado em todos os métodos; token expirado/adulterado também. +- Paginação: página além do fim devolve lista vazia e `nextOffset == null`; `limit` 0, 101 e negativo recusados; ordem estável com `triggeredAt` repetido (desempate por `id`). +- Auditoria: cada leitura grava exatamente uma linha (`read`, recurso `admin_*`, `result: success`); recusa por papel grava `denied`; se a gravação da auditoria falha, nenhuma linha de dado sai. +- Sem alerta vermelho reconhecido: `tmravSeconds` é `null` e a tela mostra "—", nunca "0s". +- A resposta de auditoria não vaza hash nem IP, e o rótulo do usuário não contém CPF/nome de paciente. + +--- + +## File Structure + +- Modify: `backend/sinalacs_server/lib/src/models/staff_account.spy.yaml` — `ubsId: UuidValue?`. +- Create: `backend/sinalacs_server/lib/src/models/api/admin_*.spy.yaml` — 6 modelos de resposta (ver Task 1). +- Create: `backend/sinalacs_server/lib/src/models/exceptions/admin_invalid_request_exception.spy.yaml`. +- Create: `backend/sinalacs_server/lib/src/application/admin/admin_labels.dart` — rótulos minimizados (puro). +- Create: `backend/sinalacs_server/lib/src/application/admin/admin_read_service.dart` — escopo, paginação, auditoria; interface `AdminReadStore`. +- Create: `backend/sinalacs_server/lib/src/infrastructure/database/orm_admin_read_store.dart` — SQL. +- Create: `backend/sinalacs_server/lib/src/endpoints/admin_endpoint.dart`; Modify: `lib/src/runtime/alert_runtime.dart` (`adminReadServiceFor`). +- Modify: `apps/admin/lib/core/data/admin_data_source.dart`, `mock_admin_data_source.dart`, `lib/app/app.dart`, `lib/app/login_screen.dart`; Create: `apps/admin/lib/core/data/backend_admin_data_source.dart`. +- Modify: `backend/sinalacs_server/lib/src/infrastructure/testing/e2e_fixtures.dart`, `bin/seed_e2e_fixtures.dart`, `apps/admin/integration_test/admin_login_e2e.dart`, `scripts/qa/admin_login_e2e.sh`. +- Modify: `backend/CLAUDE.md`, `apps/CLAUDE.md`, `PROGRESS.md`, `docs/telas-admin.md`, `spec/lgpd_data_audit.md` (se citar `staff_accounts`). +- Tests: `test/unit/admin_labels_test.dart`, `test/unit/admin_read_service_test.dart`, `test/integration/admin_read_store_test.dart`, `test/integration/admin_endpoint_test.dart`, `test/unit/endpoint_auth_posture_test.dart`, `apps/admin/test/backend_admin_data_source_test.dart` e as telas. + +--- + +### Task 0: Pré-requisitos e confirmação da lacuna + +**Files:** nenhum modificado. + +- [ ] **Step 1: Conferir que a #48 está em `develop`** + +Run: `gh pr view 50 --json state,mergedAt -q '.state+" "+(.mergedAt//"")'; git fetch -q origin && git log origin/develop --oneline | grep -c "#48"` +Expected: `MERGED …` e contagem ≥ 1. Se o PR ainda estiver aberto, **parar**: a #40 depende dele. + +- [ ] **Step 2: Branch a partir de `develop`** + +Run: `git switch develop && git pull --ff-only && git switch -c feat/admin-endpoints-40` +Expected: branch nova. + +- [ ] **Step 3: Provar a lacuna** + +Run: +```bash +ls backend/sinalacs_server/lib/src/endpoints | grep -c admin +grep -rn "staffRoles" backend/sinalacs_server/lib/src/endpoints | wc -l +grep -n "MockAdminDataSource()" apps/admin/lib/app/app.dart +``` +Expected: `0`, `0` e a linha do `app.dart`. Confirma: nenhum endpoint `admin`, nenhum uso de `staffRoles`, app ainda no mock. + +- [ ] **Step 4: Linha de base** + +Run: `(cd backend/sinalacs_server && dart test 2>&1 | tail -1); (cd apps/admin && flutter test 2>&1 | tail -1)` +Expected: ambas verdes. Anotar as contagens para comparar no fim. + +--- + +### Task 1: Modelos, migração e `ubsId` do staff + +**Files:** +- Modify: `backend/sinalacs_server/lib/src/models/staff_account.spy.yaml` +- Create: `backend/sinalacs_server/lib/src/models/api/admin_indicators.spy.yaml`, `admin_micro_area.spy.yaml`, `admin_alert.spy.yaml`, `admin_alert_page.spy.yaml`, `admin_audit_entry.spy.yaml`, `admin_audit_page.spy.yaml` +- Create: `backend/sinalacs_server/lib/src/models/exceptions/admin_invalid_request_exception.spy.yaml` +- Regenerate: `generated/`, `sinalacs_client`, `migrations/` + +**Interfaces:** +- Produces (classes Dart geradas, usadas em todas as tarefas seguintes): + - `AdminIndicators{ int red; int yellow; int green; int openRedAlerts; int acknowledgedRedAlerts; int? tmravSeconds; }` + - `AdminMicroArea{ String id; String name; String acsName; String acsEnrollmentId; bool acsActive; }` + - `AdminAlert{ String id; String patientLabel; String microAreaName; RiskLevel riskLevel; AlertStatus status; DateTime triggeredAt; }` + - `AdminAlertPage{ List items; int? nextOffset; }` + - `AdminAuditEntry{ String id; int sequence; String userLabel; String actionType; String resourceType; DateTime timestamp; String result; }` + - `AdminAuditPage{ List items; int? nextBeforeSequence; }` + - `AdminInvalidRequestException{ String message; }` + - `StaffAccount.ubsId: UuidValue?` + +- [ ] **Step 1: Escrever os modelos** + +`staff_account.spy.yaml`: depois de `active: bool`, acrescentar ` ubsId: UuidValue?` (comentário: `### UBS do coordenador; nulo para administrador. Coordenador sem UBS é recusado.`). + +Cada `api/admin_*.spy.yaml` segue o formato dos existentes em `models/api/` (ler um deles antes). Exemplo: +```yaml +class: AdminAlert +fields: + id: String + patientLabel: String + microAreaName: String + riskLevel: RiskLevel + status: AlertStatus + triggeredAt: DateTime +``` +e os outros com os campos da lista acima (`List` em `AdminAlertPage.items`, `int?` em `nextOffset`). A exceção segue `models/exceptions/mfa_enrollment_required_exception.spy.yaml`: +```yaml +class: AdminInvalidRequestException +serverOnly: false +fields: + message: String +``` +(copiar as chaves exatas do arquivo-modelo; se ele não tiver `serverOnly`, não acrescentar.) + +- [ ] **Step 2: Gerar e conferir** + +Run: +```bash +cd backend/sinalacs_server && export PATH="$PATH:$HOME/.pub-cache/bin" && serverpod generate && serverpod create-migration +git status --short | head -20; grep -c DROP migrations/$(ls migrations | grep -v registry | tail -1)/migration.sql +``` +Expected: arquivos gerados do servidor e do cliente; migração com `ALTER TABLE "staff_accounts" ADD COLUMN "ubsId" uuid;` e **0** `DROP`. + +- [ ] **Step 3: Compilar e rodar a suíte** + +Run: `dart analyze lib | tail -2; dart test 2>&1 | tail -1` +Expected: sem erros novos; suíte verde (nada usa os modelos ainda). + +- [ ] **Step 4: Commit** + +```bash +git add backend && git commit -m "feat(db): modelos de resposta do admin e ubsId do staff (#40)" +``` + +--- + +### Task 2: Rótulos minimizados (função pura) + +**Files:** +- Create: `backend/sinalacs_server/lib/src/application/admin/admin_labels.dart` +- Test: `backend/sinalacs_server/test/unit/admin_labels_test.dart` + +**Interfaces:** +- Produces: `abstract final class AdminLabels { static String patient(String patientId); static String user({required String role, required String id, String? enrollmentId}); }` + - `patient('5b6f…a18f')` → `#A18F`; id com menos de 4 caracteres → `#????`. + - `user(role: 'patient', id: …)` → `Paciente #A18F`; `user(role: 'acs'|'coordinator'|'admin', enrollmentId: 'ADM-001')` → `ADM-001 (Administrador)` / `(ACS)` / `(Coordenador)`; sem matrícula → ` #A18F`. + +- [ ] **Step 1: Teste que falha** + +```dart +import 'package:sinalacs_server/src/application/admin/admin_labels.dart'; +import 'package:test/test.dart'; + +void main() { + const id = '5b6f2c1e-0000-4000-8000-00000000a18f'; + test('rótulo do paciente = # + últimos 4 hex em maiúsculas', () { + expect(AdminLabels.patient(id), '#A18F'); + expect(AdminLabels.patient('abc'), '#????'); + }); + test('rótulo do usuário nunca contém nome; paciente vira Paciente #XXXX', () { + expect(AdminLabels.user(role: 'patient', id: id), 'Paciente #A18F'); + expect(AdminLabels.user(role: 'admin', id: id, enrollmentId: 'ADM-001'), 'ADM-001 (Administrador)'); + expect(AdminLabels.user(role: 'acs', id: id, enrollmentId: 'ACS-001'), 'ACS-001 (ACS)'); + expect(AdminLabels.user(role: 'coordinator', id: id), 'Coordenador #A18F'); + }); + test('paciente ignora matrícula (não há matrícula de paciente a mostrar)', () { + expect(AdminLabels.user(role: 'patient', id: id, enrollmentId: 'X'), 'Paciente #A18F'); + }); +} +``` + +- [ ] **Step 2: Rodar e ver falhar** — `dart test test/unit/admin_labels_test.dart` → FAIL (arquivo inexistente). + +- [ ] **Step 3: Implementar** + +```dart +/// Rótulos minimizados do backoffice (spec/lgpd_design.md): o admin identifica, +/// não qualifica. Nunca recebe nem devolve nome, CPF ou contato. +abstract final class AdminLabels { + static String _sufixo(String id) { + final limpo = id.replaceAll('-', ''); + return limpo.length < 4 ? '????' : limpo.substring(limpo.length - 4).toUpperCase(); + } + + static String patient(String patientId) => '#${_sufixo(patientId)}'; + + static const _papeis = { + 'admin': 'Administrador', + 'coordinator': 'Coordenador', + 'acs': 'ACS', + 'patient': 'Paciente', + }; + + static String user({required String role, required String id, String? enrollmentId}) { + final papel = _papeis[role] ?? role; + if (role == 'patient') return 'Paciente ${patient(id)}'; + if (enrollmentId != null && enrollmentId.isNotEmpty) return '$enrollmentId ($papel)'; + return '$papel ${patient(id)}'; + } +} +``` + +- [ ] **Step 4: Rodar e ver passar** — `dart test test/unit/admin_labels_test.dart` → 3 testes. + +- [ ] **Step 5: Commit** — `git add backend && git commit -m "feat(admin): rótulos minimizados de paciente e usuário (#40)"` + +--- + +### Task 3: Store de leitura (SQL) contra Postgres + +**Files:** +- Create: `backend/sinalacs_server/lib/src/application/admin/admin_read_service.dart` (só a interface e o tipo de escopo, nesta tarefa) +- Create: `backend/sinalacs_server/lib/src/infrastructure/database/orm_admin_read_store.dart` +- Test: `backend/sinalacs_server/test/integration/admin_read_store_test.dart` + +**Interfaces:** +- Produces (em `admin_read_service.dart`): +```dart +/// Até onde o chamador enxerga. `ubsId == null` = sistema inteiro (administrador). +class AdminScope { + const AdminScope.system() : ubsId = null; + const AdminScope.ubs(String this.ubsId); + final String? ubsId; +} + +abstract interface class AdminReadStore { + Future indicators(AdminScope scope, {required DateTime now}); + Future> microAreas(AdminScope scope); + Future alerts(AdminScope scope, {String? microAreaId, AlertStatus? status, required int limit, required int offset}); + Future auditLogs({required int limit, int? beforeSequence}); + /// UBS do coordenador (`staff_accounts.ubsId`), ou `null` se não houver. + Future ubsOf(String staffId); +} +``` +- `OrmAdminReadStore({required Session Function() session})` implementa `AdminReadStore`. + +Os SQLs usam `session.db.unsafeQuery(sql, parameters: QueryParameters.named({...}))` (padrão de `orm_alert_outbox.dart`); enums `byName` são texto (`'red'`, `'pending'`…); colunas camelCase entre aspas. + +- [ ] **Step 1: Teste que falha** + +Seguir o harness de `staff_activation_store_test.dart` (`withServerpod`, UUIDs próprios `…0000d1..`). `_seed` cria: 2 UBS (`ubsA`, `ubsB`), 1 microárea em cada, 1 ACS (`users`+`acs`) na área A com nome sintético, 1 paciente por área (`users`+`patients`; ler `patient.spy.yaml` para os campos obrigatórios) e alertas **determinísticos** na área A: vermelho `pending` (triggeredAt T), vermelho `acknowledged` (triggeredAt T−2h, acknowledgedAt T−2h+60s), vermelho `acknowledged` (T−1h, +120s), amarelo e verde `pending`; e 1 vermelho `pending` na área B. Casos: + +```dart +test('indicadores do sistema contam por risco e calculam o TMRAV', () async { + final r = await store.indicators(const AdminScope.system(), now: t); + expect(r.red, 4); expect(r.yellow, 1); expect(r.green, 1); + expect(r.openRedAlerts, 2); // A pending + B pending + expect(r.acknowledgedRedAlerts, 2); + expect(r.tmravSeconds, 90); // média de 60 s e 120 s +}); +test('indicadores da UBS A não enxergam a UBS B', () async { + final r = await store.indicators(AdminScope.ubs(_ubsA), now: t); + expect(r.red, 3); expect(r.openRedAlerts, 1); +}); +test('TMRAV é null sem vermelho reconhecido e ignora alerta fora de 30 dias', ...); +test('microáreas trazem o ACS vinculado e respeitam a UBS', ...); // A: 1 item; sistema: 2 +test('alertas: ordem triggeredAt desc, id desc; rótulo do paciente, sem PII', () async { + final p = await store.alerts(const AdminScope.system(), limit: 3, offset: 0); + expect(p.items, hasLength(3)); expect(p.nextOffset, 3); + expect(p.items.first.patientLabel, matches(RegExp(r'^#[0-9A-F]{4}$'))); + final fim = await store.alerts(const AdminScope.system(), limit: 3, offset: 3); + expect(fim.items, hasLength(3)); expect(fim.nextOffset, isNull); +}); +test('alertas filtram por microárea e status e respeitam a UBS', ...); +test('auditoria: sequence desc, keyset por beforeSequence, sem hash nem IP', () async { + final p = await store.auditLogs(limit: 2); + expect(p.items, hasLength(2)); expect(p.nextBeforeSequence, p.items.last.sequence); + final proxima = await store.auditLogs(limit: 2, beforeSequence: p.nextBeforeSequence); + expect(proxima.items.first.sequence, lessThan(p.items.last.sequence)); +}); +test('ubsOf devolve a UBS do coordenador e null para administrador', ...); +``` +O `_seed` das linhas de `audit_logs` usa `AuditTrail` real (`AlertRuntime.instance.auditTrailFor(session)`) para respeitar a cadeia de hash. + +- [ ] **Step 2: Rodar e ver falhar** — `dart test test/integration/admin_read_store_test.dart` → erro de compilação (tipos inexistentes). + +- [ ] **Step 3: Implementar o store** + +Pontos que o implementador precisa respeitar: +```dart +// Filtro de escopo, comum às consultas: alerta -> micro_areas.ubsId. +// Sistema: sem filtro. UBS: "AND m.\"ubsId\" = @ubs". +const _escopoAlerta = '(@ubs::uuid IS NULL OR m."ubsId" = @ubs::uuid)'; + +// Indicadores — uma única instrução: +// SELECT +// count(*) FILTER (WHERE a."riskLevel"='red'), +// count(*) FILTER (WHERE a."riskLevel"='yellow'), +// count(*) FILTER (WHERE a."riskLevel"='green'), +// count(*) FILTER (WHERE a."riskLevel"='red' AND a."status"='pending'), +// count(*) FILTER (WHERE a."riskLevel"='red' AND a."status"='acknowledged'), +// round(avg(extract(epoch FROM (a."acknowledgedAt" - a."triggeredAt"))) +// FILTER (WHERE a."riskLevel"='red' AND a."acknowledgedAt" IS NOT NULL +// AND a."triggeredAt" >= @since))::int +// FROM alerts a LEFT JOIN micro_areas m ON m.id = a."microAreaId" +// WHERE (@ubs::uuid IS NULL OR m."ubsId" = @ubs::uuid) +// `@since` = now - 30 dias (parâmetro, não `now()` do banco: o teste fixa o relógio). +``` +- `microAreas`: `micro_areas m LEFT JOIN users u ON u."microAreaId" = m.id AND u.role='acs' LEFT JOIN acs ON acs.id = u.id`; nome do ACS vem de `users.name` (nome do **profissional**, não de paciente; permitido), `acsEnrollmentId` de `acs.enrollmentId`, `acsActive` de `acs.active`; sem ACS → `acsName='Sem ACS vinculado'`, `acsEnrollmentId='—'`, `acsActive=false` (igual ao mock). +- `alerts`: `ORDER BY a."triggeredAt" DESC, a.id DESC LIMIT @limit+1 OFFSET @offset`; se vierem `limit+1` linhas, descartar a última e `nextOffset = offset + limit`. +- `auditLogs`: `ORDER BY l.sequence DESC LIMIT @limit+1`, `WHERE (@before::bigint IS NULL OR l.sequence < @before)`; `userLabel` via `AdminLabels.user(role, id, enrollmentId)` com `LEFT JOIN users u` e `LEFT JOIN acs`/`staff_accounts` para a matrícula. **Selecionar só** `id, sequence, userId, actionType, resourceType, timestamp, result`. +- `ubsOf`: `StaffAccount.db.findById` → `ubsId?.toString()`. +- Montar `AdminAlert`/`AdminAuditEntry` a partir das linhas (índices por posição, como em `orm_alert_outbox.dart`); `riskLevel`/`status` com `RiskLevel.fromJson`/`AlertStatus.fromJson` (conferir o nome do construtor gerado em `generated/risk_level.dart`). + +- [ ] **Step 4: Rodar e ver passar** — `dart test test/integration/admin_read_store_test.dart && dart analyze lib/src/infrastructure/database/orm_admin_read_store.dart | tail -2` → verde, sem avisos novos. + +- [ ] **Step 5: Commit** — `git add backend && git commit -m "feat(admin): store de leitura com escopo, paginação e TMRAV (#40)"` + +--- + +### Task 4: Serviço, endpoint, papéis e auditoria da leitura + +**Files:** +- Modify: `backend/sinalacs_server/lib/src/application/admin/admin_read_service.dart` (acrescentar `AdminReadService`) +- Create: `backend/sinalacs_server/lib/src/endpoints/admin_endpoint.dart` +- Modify: `backend/sinalacs_server/lib/src/runtime/alert_runtime.dart` (`adminReadServiceFor`), `test/unit/endpoint_auth_posture_test.dart` se a allowlist exigir +- Test: `backend/sinalacs_server/test/unit/admin_read_service_test.dart`, `test/integration/admin_endpoint_test.dart` + +**Interfaces:** +- Consumes: `AdminReadStore`, `AdminScope` (Task 3); `AuditEvent`/`AuditTrail.record`. +- Produces: +```dart +class AdminReadService { + AdminReadService({required AdminReadStore store, required AuditTrail audit, DateTime Function()? clock}); + Future indicators(AuthenticatedUser user); + Future> microAreas(AuthenticatedUser user); + Future alerts(AuthenticatedUser user, {String? microAreaId, AlertStatus? status, int limit = 50, int offset = 0}); + Future auditLogs(AuthenticatedUser user, {int limit = 50, int? beforeSequence}); +} +``` +e o endpoint `AdminEndpoint` com `indicators`, `microAreas`, `alerts`, `auditLogs`, todos `(Session, {required String accessToken, …})`. +Regras do serviço: (a) papel fora de `staffRoles` → `AlertPermissionException` + auditoria `denied` (`resourceType` do método); (b) `admin` → `AdminScope.system()`; (c) `coordinator` → `ubsOf(user.id)`; `null` → `AlertPermissionException` + `denied`; (d) `auditLogs` só para `admin` (coordenador recebe `AlertPermissionException`: a visão por UBS dos logs fica para a #43) — **decisão a confirmar**; (e) `limit` fora de 1..100 → `AdminInvalidRequestException`; (f) `await audit.record(AuditEvent(userId: user.id, actionType: 'read', resourceType: 'admin_', result: 'success'))` **antes** de chamar o store; se `record` lançar, a exceção propaga e o store não é chamado. + +- [ ] **Step 1: Testes de unidade que falham** (`admin_read_service_test.dart`, com `FakeAdminReadStore` e `RecordingAudit` de `test/support/institutional_auth_fixtures.dart`) + +```dart +test('admin lê com escopo de sistema e a leitura é auditada ANTES do dado', ...); // ordem: audit.events.length==1 antes de store.chamadas +test('coordenador lê só a sua UBS', ...); // store.ultimoEscopo.ubsId == 'ubs-a' +test('coordenador sem UBS é recusado em indicators, microAreas, alerts', ...); // 3 expects +test('coordenador não lê auditoria (só admin)', ...); +test('acs e patient são recusados em todos os métodos e a recusa é auditada', ...); // result 'denied' +test('se a auditoria falha, o store não é chamado', () async { + audit.falhar = true; + await expectLater(servico.alerts(admin), throwsA(anything)); + expect(store.chamadas, 0); +}); +test('limit 0, 101 e -1 são recusados com a mensagem de paginação', ...); +``` + +- [ ] **Step 2: Rodar e ver falhar** → erro de compilação. + +- [ ] **Step 3: Implementar o serviço** + +```dart +class AdminReadService { + AdminReadService({required this.store, required this.audit, DateTime Function()? clock}) + : _clock = clock ?? DateTime.now; + final AdminReadStore store; + final AuditTrail audit; + final DateTime Function() _clock; + + static const _negado = 'acesso restrito ao backoffice'; + + Future _escopo(AuthenticatedUser user, String recurso) async { + if (!Authorization.staffRoles.contains(user.role)) { + await _auditar(user, recurso, 'denied'); + throw AlertPermissionException(message: _negado); + } + if (user.role == UserRole.admin) return const AdminScope.system(); + final ubs = await store.ubsOf(user.id); + if (ubs == null) { + await _auditar(user, recurso, 'denied'); + throw AlertPermissionException(message: _negado); + } + return AdminScope.ubs(ubs); + } + + Future _auditar(AuthenticatedUser user, String recurso, String result) => + audit.record(AuditEvent(userId: user.id, actionType: 'read', resourceType: recurso, result: result)); + // …cada método: escopo -> validação de limit -> _auditar(...,'success') -> store. +} +``` +`Authorization.require(user, roles: Authorization.staffRoles, requireMicroArea: false, onDenied: …)` pode substituir a checagem manual **se** a auditoria da recusa for mantida (a regra (a) exige gravar `denied` antes de lançar). + +- [ ] **Step 4: Endpoint e fiação** + +```dart +class AdminEndpoint extends AuthenticatedEndpoint { + Future indicators(Session session, {required String accessToken}) => + AlertRuntime.instance.adminReadServiceFor(session).indicators(authenticate(accessToken)); + Future> microAreas(Session session, {required String accessToken}) => + AlertRuntime.instance.adminReadServiceFor(session).microAreas(authenticate(accessToken)); + Future alerts(Session session, + {required String accessToken, String? microAreaId, AlertStatus? status, int limit = 50, int offset = 0}) => + AlertRuntime.instance.adminReadServiceFor(session).alerts(authenticate(accessToken), + microAreaId: microAreaId, status: status, limit: limit, offset: offset); + Future auditLogs(Session session, + {required String accessToken, int limit = 50, int? beforeSequence}) => + AlertRuntime.instance.adminReadServiceFor(session).auditLogs(authenticate(accessToken), + limit: limit, beforeSequence: beforeSequence); +} +``` +`adminReadServiceFor(Session session)` no `AlertRuntime`: `AdminReadService(store: OrmAdminReadStore(session: () => session), audit: auditTrailFor(session))`. Depois `serverpod generate`. + +- [ ] **Step 5: Teste de integração do endpoint (falha → passa)** + +`admin_endpoint_test.dart` com tokens reais (`AlertRuntime.instance.auth.issueToken(...)`, como em `staff_login_test.dart`), 2 UBS, 1 admin, 1 coordenador por UBS, 1 coordenador sem UBS, 1 ACS com microárea, 1 paciente. Casos: +```dart +test('admin lê os quatro endpoints e cada leitura vira uma linha read/success em audit_logs', ...); +test('coordenador da UBS A não vê a UBS B (alerts, microAreas, indicators)', ...); +test('coordenador sem UBS recebe AlertPermissionException nos quatro', ...); +test('acs e patient com token válido recebem AlertPermissionException nos quatro', ...); +test('token adulterado e token expirado são recusados', ...); +test('auditoria não devolve hash nem IP: o JSON da resposta não contém entryHash/ipHash/previousHash', () async { + final j = jsonEncode((await endpoints.admin.auditLogs(sb, accessToken: t)).toJson()); + expect(j, isNot(contains('Hash'))); expect(j, isNot(contains('ip'))); +}); +test('a resposta de alertas não contém nome nem CPF do paciente semeado', ...); // nome sintético distintivo +test('página além do fim: items vazio e nextOffset null', ...); +``` +Atualizar `endpoint_auth_posture_test.dart` só se ele exigir inscrição do endpoint novo (ele já classifica `AuthenticatedEndpoint` como autenticado). + +- [ ] **Step 6: Rodar tudo** — `dart test 2>&1 | tail -2; dart analyze lib | tail -1` → verde. + +- [ ] **Step 7: Commit** — `git add backend && git commit -m "feat(admin): endpoint admin somente leitura com papel, escopo e auditoria (#40)"` + +--- + +### Task 5: App admin sobre o backend real + +**Files:** +- Modify: `apps/admin/lib/core/data/admin_data_source.dart`, `mock_admin_data_source.dart`, `apps/admin/lib/app/app.dart`, `login_screen.dart` +- Create: `apps/admin/lib/core/data/backend_admin_data_source.dart` +- Test: `apps/admin/test/backend_admin_data_source_test.dart` e ajustes em `alerts_screen_test.dart`, `alerts_filter_dynamic_test.dart`, `mock_admin_data_source_test.dart`, `audit_log_screen_test.dart`, `micro_areas_screen_test.dart`, `error_handling_test.dart` + +**Interfaces:** +- Consumes: cliente gerado `client.admin.indicators/microAreas/alerts/auditLogs` (Task 4) e `AdminSession.accessToken`. +- Produces — mudanças na interface `AdminDataSource` (o mock acompanha): + - `DashboardIndicators.tmravSeconds` passa a `int?` (a tela mostra `—` quando nulo). + - `Future> fetchAlerts({String? microAreaId, AlertStatus? status, int limit = 50, int offset = 0})` e `Future> fetchAuditLogs({int limit = 50})`; `MicroAreaSummary.id` (já existe) é o filtro. + - `recordAccess` **permanece** na interface; `BackendAdminDataSource.recordAccess` é no-op **documentado**: o servidor já audita cada leitura (a chamada não vira escrita duplicada). + - `typedef AdminDataSourceFactory = AdminDataSource Function(AdminSession session);`; `SinalAdminApp({AdminDataSourceFactory? dataSourceFor, required auth})` — default `(_) => MockAdminDataSource()` **somente** para testes de widget; `main.dart` passa `BackendAdminDataSource.forSession`. + - `class BackendAdminDataSource implements AdminDataSource { BackendAdminDataSource({required AdminEndpointCaller caller, required String accessToken}); }` onde `AdminEndpointCaller` é a costura de teste (como `BackendAdminAuth`/`FakeEndpointCaller`): `Future call(Future Function(EndpointAdmin admin) f)`. + +- [ ] **Step 1: Testes que falham** + +`backend_admin_data_source_test.dart` com um caller falso: (a) `fetchDashboardIndicators` mapeia `red/yellow/green` para `countsByRisk` e `tmravSeconds: null` permanece nulo; (b) `fetchAlerts(microAreaId: 'x')` passa o filtro e o token; (c) `AlertPermissionException` vira `AdminAuthFailure('Acesso restrito ao backoffice.')` (sem texto do servidor); (d) `SocketException` vira `AdminAuthFailure('Não foi possível conectar ao servidor.')`; (e) `recordAccess` não chama o servidor. Teste de widget: indicadores com `tmravSeconds: null` mostram `—` e **não** `0s`. + +- [ ] **Step 2: Rodar e ver falhar** — `cd apps/admin && flutter test test/backend_admin_data_source_test.dart` → FAIL. + +- [ ] **Step 3: Implementar** + +Mapear `AdminIndicators` → `DashboardIndicators` (`{RiskLevel.red: r.red, …}`), `AdminAlert` → `AlertSummary` (enum do cliente → enum local por nome), `AdminAuditEntry` → `AuditLogEntry`. `LoginScreen` passa a receber `dataSourceFor` e cria `dataSourceFor(session)` ao abrir o `AdminHomeShell`. Na tela de alertas trocar `microAreaName` por `microAreaId` no filtro (o dropdown já usa `fetchMicroAreas`). Indicadores: `tmravSeconds == null ? '—' : '${s}s'`, com `Semantics` equivalente ("sem alertas vermelhos reconhecidos"). `main.dart`: `dataSourceFor: (s) => BackendAdminDataSource.forSession(s, auth)`; reaproveitar o `Client` já criado em `buildAdminAuth` (mesma CA) — expor `Client` pelo `BackendAdminAuth` se necessário, em vez de criar um segundo. + +- [ ] **Step 4: Suíte do app** — `flutter test && flutter analyze` → verde (contagem ≥ a da linha de base); `contrast_tokens_test`, `touch_targets_test`, `text_scale_test` passam. + +- [ ] **Step 5: Commit** — `git add apps/admin && git commit -m "feat(admin): painel lê do backend real; TMRAV nulo vira traço (#40)"` + +--- + +### Task 6: E2E no emulador com dados reais + +**Files:** +- Modify: `backend/sinalacs_server/lib/src/infrastructure/testing/e2e_fixtures.dart`, `bin/seed_e2e_fixtures.dart`, `test/unit/e2e_fixtures_test.dart` +- Modify: `apps/admin/integration_test/admin_login_e2e.dart`, `scripts/qa/admin_login_e2e.sh` + +**Interfaces:** +- Produces: o seed de e2e semeia, na microárea das fixtures, 3 alertas determinísticos: vermelho `pending`, vermelho `acknowledged` (`acknowledgedAt − triggeredAt` = 60 s, `triggeredAt` = agora − 1 h) e verde `pending`. Esperado no painel: Vermelho **2**, Verde **1**, Amarelo **0**, Abertos **1**, Reconhecidos **1**, TMRAV **60s**. + +- [ ] **Step 1: Teste das fixtures (falha)** — em `e2e_fixtures_test.dart`: `generateE2eFixtures(...)` expõe `f.adminAlerts` com 3 itens, UUIDs v4 distintos dos fixos do seed de dev; e o `toJson` não os perde. + +- [ ] **Step 2: Implementar** — fixtures + `INSERT INTO "alerts"` no seed (campos obrigatórios conforme `alert.spy.yaml`: `patientId` do paciente `main`, `acsId` do ACS das fixtures, `microAreaId`, `riskLevel`, `status`, `mqttTopic`, `deviceId`, `retryCount 0`, `version 1`, `locationHash`). + +- [ ] **Step 3: Estender o roteiro** — no teste final de `admin_login_e2e.dart`, depois de "Painel de Indicadores": esperar `find.text('60s')` e a contagem `2` ao lado de `Vermelho`; abrir **Alertas** e conferir 3 cartões, nenhum com nome de paciente (`#` + 4 hex); abrir **Auditoria** e conferir uma entrada `read • admin_indicators` do administrador da fixture. No `.sh`, conferir no banco: `select count(*) from audit_logs where "resourceType" like 'admin_%' and result='success'` ≥ 3. + +- [ ] **Step 4: Rodar no emulador (o usuário executa; recria containers de dev)** + +Preflight: `adb -s emulator-5554 get-state`, porta 8765 livre (`ss -ltn | grep :8765 || echo livre`). Rodar: +``` +! DEVICE=emulator-5554 ./scripts/qa/admin_login_e2e.sh 2>&1 | tee .superpowers/admin40_emulador.log +``` +Expected: todos os testes passam (os 5 da #48 + os novos de dados) e `OK — login real do backoffice`. Em falha: `superpowers:systematic-debugging` **antes** de editar; guardar sempre o log. + +- [ ] **Step 5 (opcional, celular):** mesma execução com `DEVICE=0087014315` e `tee .superpowers/admin40_celular.log`. + +- [ ] **Step 6: Commit** — `git add backend apps/admin scripts/qa && git commit -m "test(e2e): painel do admin com dados reais no emulador (#40)"` + +--- + +### Task 7: Documentação, capturas e PR + +**Files:** +- Modify: `backend/CLAUDE.md`, `apps/CLAUDE.md`, `PROGRESS.md`, `docs/telas-admin.md`, `docs/screenshots/admin/02..05`, `spec/lgpd_data_audit.md` (coluna `staff_accounts.ubsId`) + +- [ ] **Step 1: Capturar as telas com dados reais no emulador** + +Com a stack de e2e no ar (`! ./scripts/qa/e2e_stack.sh up && ./scripts/qa/e2e_stack.sh seed`), `flutter run -d emulator-5554 --dart-define=SINALACS_HOST=https://localhost:8443/`, relé em `/admin`, ativar a MFA com o código do manifesto e gerar o TOTP com um script Python (RFC 6238, como na #39). Para a barra de status limpa: `adb shell settings put global sysui_demo_allowed 1` **antes** dos `am broadcast … com.android.systemui.demo` (`notifications -e visible false`, `clock -e hhmm 1000`); digitar a senha em blocos de 3 caracteres conferindo o tamanho por `uiautomator dump` antes de enviar. Capturar 02–05 com `adb exec-out screencap -p`, abrir cada PNG com `Read` e rejeitar qualquer dado que não seja sintético. Ao fim: `am broadcast … -e command exit`, `settings put global sysui_demo_allowed 0`, `am force-stop`. + +- [ ] **Step 2: Documentar** + +`backend/CLAUDE.md`: seção do `AdminEndpoint` (métodos, escopo por papel, `staff_accounts.ubsId`, TMRAV, paginação, auditoria fail-closed, rótulos). `apps/CLAUDE.md`: `BackendAdminDataSource`, `dataSourceFor`, TMRAV nulo. `docs/telas-admin.md`: trocar "dados mockados atrás de `AdminDataSource`" pela descrição real e dizer que as capturas 02–05 são de dados sintéticos de e2e. `PROGRESS.md`: seção da #40 com o que foi provado **nesta** execução (contagens de testes, log do emulador) e o que segue aberto: visão de auditoria por UBS para coordenador, gestão de `ubsId` pela UI (#43), `ADM-001`/coordenador no seed de desenvolvimento, paginação por offset nos alertas. + +- [ ] **Step 3: Conferências finais** + +Run: `./scripts/qa/check_documentation_links.sh && ./scripts/qa/ci_invariants.sh && (cd backend/sinalacs_server && dart test 2>&1 | tail -1) && (cd apps/admin && flutter test 2>&1 | tail -1 && flutter analyze | tail -1)` +Expected: tudo verde. + +- [ ] **Step 4: Commit, push e PR (push e PR só com confirmação do usuário)** + +```bash +git add -A docs backend/CLAUDE.md apps/CLAUDE.md PROGRESS.md spec +git commit -m "docs: endpoints do backoffice e capturas com dados reais (#40)" +``` +PR contra `develop`, corpo com `Closes #40`, evidências (contagens, log do emulador resumido, sem segredos) e as decisões abertas abaixo. + +- [ ] **Step 5: Devolver a stack de dev (o usuário executa)** — `! ./scripts/qa/e2e_stack.sh down && docker compose up -d`. + +--- + +## Decisões que o plano toma pelo usuário (confirmar antes de executar) + +1. **Escopo do coordenador por `staff_accounts.ubsId`** (coluna nova, nula para admin); coordenador sem UBS é recusado. Alternativa: coordenador igual a administrador — mais simples, mas contraria a matriz do PRD §4.2.2. +2. **Auditoria só para `admin`.** O PRD dá ao coordenador "Logs de auditoria (UBS)"; filtrar logs por UBS exige juntar `users`→`micro_areas`. Fica para a #43. +3. **Auditoria fail-closed** (`record`, não `recordSafely`): se a linha não grava, o dado não sai. +4. **TMRAV:** janela de 30 dias, só alertas vermelhos com `acknowledgedAt`, `null` quando não há amostra (PRD §1.3 não fixa janela). +5. **Paginação:** `limit` 1–100 (padrão 50); alertas por `offset`, auditoria por `beforeSequence` (log só cresce; `offset` repetiria linhas). +6. **`recordAccess` do app vira no-op:** o servidor audita cada leitura. +7. **Sem coordenador no seed de desenvolvimento** (exigiria nova senha em `.env`/`bootstrap_env.sh`); coordenador só nos testes. + +## Self-Review + +- **Cobertura da issue:** endpoint(s) somente leitura por papel (Tasks 3–4); TMRAV e contagens reais (Task 3); paginação (Tasks 3–4); minimização de PII (`AdminLabels`, Task 2, e os testes de "sem nome/CPF/hash"); testes por endpoint com recusa para `acs`/`patient` (Task 4); leitura auditada em `audit_logs` (Task 4, conferida no e2e da Task 6); trocar o mock (Task 5); prova no emulador (Task 6). +- **Placeholders:** os casos com `...` na Task 3/4 são enunciados completos do que cada teste deve afirmar, com o primeiro de cada grupo escrito por extenso; o implementador deve escrevê-los inteiros. O nome do construtor `fromJson` dos enums gerados e as chaves do modelo de exceção dependem de arquivos que a Task indica ler antes. +- **Tipos:** `AdminScope`, `AdminReadStore.{indicators,microAreas,alerts,auditLogs,ubsOf}`, `AdminReadService.*` e as classes geradas têm os mesmos nomes nas Tasks 1, 3, 4, 5 e 6; `tmravSeconds` é `int?` no servidor e no app. +- **Riscos:** `serverpod generate` altera muitos arquivos gerados (revisar só o que é da #40). `alerts.microAreaId` é anulável: as consultas de indicadores e alertas usam `LEFT JOIN micro_areas`, para que o administrador conte também o alerta sem microárea; o coordenador, filtrado por `m."ubsId" = @ubs`, não o enxerga. Acrescentar à Task 3 o caso "alerta sem microárea: conta no sistema, não aparece na UBS" (escrito junto dos outros, antes do SQL). diff --git a/docs/superpowers/plans/2026-10-07-issue-41-admin-sinalacs-client.md b/docs/superpowers/plans/2026-10-07-issue-41-admin-sinalacs-client.md new file mode 100644 index 00000000..28a1cdb6 --- /dev/null +++ b/docs/superpowers/plans/2026-10-07-issue-41-admin-sinalacs-client.md @@ -0,0 +1,178 @@ +# Issue #41 — admin consome o `sinalacs_client` Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fechar a #41: o painel `apps/admin` lê do backend pelo `sinalacs_client`, trata falha de rede/sessão de forma visível ao usuário, e isso é provado no emulador, no `scripts/qa/e2e.sh` e no job `admin-app` da CI. + +**Architecture:** A #40 já entregou a dependência `sinalacs_client`, o `BackendAdminDataSource`, o `buildAdminWiring` e o mock mantido para testes. Sobra o que o `PROGRESS.md` (seção "Endpoints do backoffice…", itens 3 e 4) deixou aberto: (a) verificar o estado real contra os critérios da issue; (b) mostrar o texto de `AdminDataFailure` e levar ao login quando o token vence no meio de uma chamada; (c) ligar o admin ao `e2e.sh` e conferir o job `admin-app`; (d) atualizar docs/PROGRESS. + +**Tech Stack:** Flutter 3.44.8 / Dart `^3.8.0`, `sinalacs_client` (path), `serverpod_client 3.4.13`, `flutter_test`, `integration_test`, bash (`scripts/qa`), GitHub Actions. + +**Spec:** Issue #41 (`gh issue view 41`); `PROGRESS.md` seção "Endpoints do backoffice e painel com dados reais (issue #40, 2026-10-07)"; `apps/CLAUDE.md`; `spec/ux_accessibility_assessment.md` (contraste de qualquer cor nova). + +## Global Constraints + +- Textos de UI, comentários e commits em português, no estilo do código vizinho. +- O mock (`MockAdminDataSource`) fica só para testes; o `main.dart` de produção nunca o usa. +- A mensagem crua do servidor nunca vai para a tela (já é regra de `BackendAdminDataSource`). +- Nenhum dado real de paciente em teste, log ou captura; fixtures sintéticas. +- Teste de e2e usa o banco `sinalacs_e2e` (`e2e_stack.sh`), nunca o de desenvolvimento. +- Cor usada como texto/ícone: token `*OnSurface` (ver `apps/CLAUDE.md`). +- Commits sem "Co-Authored-By"/"Generated with" (regra do repositório prevalece sobre o lembrete de atribuição). +- Dispositivo de teste: `emulator-5554` (único conectado em 2026-10-07). + +## Review Focus + +- Token vence no meio de uma chamada (HTTP 401 / `ServerpodClientUnauthorized`): esperado, voltar ao login com aviso, não "Tentar novamente" infinito. +- Falha de rede (`SocketException`, servidor fora): esperado, texto "Não foi possível conectar ao servidor." com retry que funciona quando o servidor volta. +- Coordenador sem `ubsId` (recusado): esperado, "Acesso restrito ao backoffice." e não erro genérico. +- Resposta vazia (0 alertas / 0 auditorias): esperado, estado vazio e não erro. +- Segunda falha seguida após retry: esperado, a tela continua mostrando o erro, sem travar em spinner. + +--- + +### Task 1: Linha de base — o que a #41 ainda exige de fato + +**Files:** +- Read: `apps/admin/pubspec.yaml`, `apps/admin/lib/main.dart`, `.github/workflows/ci.yml` (job `admin-app`), `scripts/qa/e2e.sh:141,191` +- Create: `docs/superpowers/plans/2026-10-07-issue-41-baseline.log` (git-ignorar se preferir; só evidência) + +**Interfaces:** +- Produces: lista confirmada (sim/não) dos 4 itens da issue, que decide se as Tasks 2–4 se aplicam. + +- [ ] **Step 1: Itens da issue já atendidos?** Rodar e anotar o resultado: + +```bash +cd apps/admin +grep -n "sinalacs_client" pubspec.yaml # esperado: dependência path presente +grep -rn "MockAdminDataSource" lib/ | grep -v "lib/core/data/mock_admin_data_source.dart" # esperado: vazio (produção não usa o mock) +flutter pub get && flutter analyze && flutter test # esperado: analyze limpo, 117 testes verdes +``` + +- [ ] **Step 2: Reproduzir o job `admin-app` localmente** (mesmos passos do CI, com a pasta de certificados vazia): + +```bash +cd apps/admin && rm -rf assets/certs && mkdir -p assets/certs && flutter pub get && flutter analyze && flutter test +``` +Expected: verde. Se falhar, esse é o primeiro defeito a corrigir (CI "com o cliente real"). + +- [ ] **Step 3: Confirmar a lacuna do e2e.** `scripts/qa/e2e.sh:191` roda `flutter test integration_test` do admin **sem** `SINALACS_HOST`, e a pasta contém `admin_login_e2e.dart`, que precisa do relé/fixtures de `admin_login_e2e.sh`. Rodar `./scripts/qa/e2e.sh --help` e ler o que `--full` faz; anotar se o admin hoje é exercitado de verdade. + +- [ ] **Step 4: Prova de base no emulador** (só leitura do estado atual): + +```bash +~/Android/Sdk/platform-tools/adb devices # esperado: emulator-5554 device +./scripts/qa/admin_login_e2e.sh # esperado: 5 testes passam (painel com dados reais) +docker compose up -d # restaura a stack de desenvolvimento ao final +``` + +- [ ] **Step 5: Commit** apenas se algo for corrigido; sem mudança, seguir. + +--- + +### Task 2: Mostrar `AdminDataFailure` e tratar token vencido em chamada + +**Files:** +- Modify: `apps/admin/lib/core/data/admin_data_source.dart` (nova `AdminSessionExpired implements Exception`) +- Modify: `apps/admin/lib/core/data/backend_admin_data_source.dart:112-121` (`_guard`: mapear 401) +- Modify: `apps/admin/lib/app/app.dart:248-300,395-500,600-640` (`_AsyncError` recebe a mensagem da falha; telas passam `snapshot.error`; `AdminHomeShell` reage a `AdminSessionExpired`) +- Modify: `apps/admin/test/support/failing_admin_data_source.dart` (campo `nextError` para lançar uma exceção escolhida) +- Test: `apps/admin/test/backend_admin_data_source_test.dart`, `apps/admin/test/error_handling_test.dart` + +**Interfaces:** +- Produces: `class AdminSessionExpired implements Exception { const AdminSessionExpired(); }`; função `String mensagemDaFalha(Object? erro)` em `app.dart` que devolve `erro.message` se `AdminDataFailure`, senão o texto genérico da tela. +- Consumes: `AdminDataFailure(message)`; `api.ServerpodClientUnauthorized` (confirmar o nome exato em `backend/sinalacs_client/lib/src/` ou em `serverpod_client` ao implementar; se o 401 vier como `ServerpodClientException` com `statusCode == 401`, mapear por esse campo). + +- [ ] **Step 1: Teste falhando no data source** (`backend_admin_data_source_test.dart`): um `EndpointAdmin` falso cujo `indicators` lança o erro de 401 deve resultar em `throwsA(isA())`: + +```dart +test('401 do servidor vira AdminSessionExpired', () async { + final fonte = BackendAdminDataSource(_AdminQue401(), accessToken: 't'); + expect(fonte.fetchDashboardIndicators(), throwsA(isA())); +}); +``` +(`_AdminQue401` segue o padrão dos falsos já existentes nesse arquivo.) + +- [ ] **Step 2: Rodar** `cd apps/admin && flutter test test/backend_admin_data_source_test.dart` — Expected: FAIL (`AdminSessionExpired` não definida). + +- [ ] **Step 3: Implementar.** Em `admin_data_source.dart` adicionar a classe; em `_guard` acrescentar, antes do `SocketException`, o `on` do 401 lançando `const AdminSessionExpired()`. + +- [ ] **Step 4: Rodar** o mesmo teste — Expected: PASS. + +- [ ] **Step 5: Teste de tela falhando** (`error_handling_test.dart`): com `FailingAdminDataSource.nextError = AdminDataFailure('Acesso restrito ao backoffice.')`, a tela de indicadores mostra exatamente esse texto e "Tentar novamente"; após o retry (sem erro) mostra os contadores. Outro teste: `nextError = AdminSessionExpired()` leva à `LoginScreen` com o aviso "Sessão encerrada. Entre novamente.". + +- [ ] **Step 6: Rodar** `flutter test test/error_handling_test.dart` — Expected: FAIL. + +- [ ] **Step 7: Implementar na UI.** `_AsyncError(message: mensagemDaFalha(snapshot.error, 'Não foi possível carregar os indicadores.'), …)` nas 4 telas; quando `snapshot.error is AdminSessionExpired`, agendar (`addPostFrameCallback`) a mesma navegação de `_encerrar()` do `AdminHomeShell` (expor um callback `onSessionExpired` passado pelo shell às telas, em vez de duplicar a navegação). + +- [ ] **Step 8: Rodar** `flutter analyze && flutter test` no admin — Expected: tudo verde (os 117 anteriores + os novos). + +- [ ] **Step 9: Commit** + +```bash +git add apps/admin +git commit -m "feat(admin): mostra a falha de leitura e volta ao login com token vencido (#41)" +``` + +--- + +### Task 3: Admin no `e2e.sh` e CI `admin-app` + +**Files:** +- Modify: `scripts/qa/e2e.sh:141-143,190-193` +- Modify: `scripts/qa/admin_login_e2e.sh` (cabeçalho: remover "Os dados do painel seguem no MockAdminDataSource (#41)", já falso) +- Modify: `.github/workflows/ci.yml` (job `admin-app`, só se o Step 2 da Task 1 mostrou falha) +- Test: `scripts/qa/admin_login_e2e_test.sh` (existente) e `scripts/qa/ci_invariants.sh` + +**Interfaces:** +- Consumes: resultado da Task 1 (Steps 2–3). + +- [ ] **Step 1: Decidir a forma do e2e do admin.** O `admin_login_e2e.sh` troca a stack de dev pela de e2e e exige relé; não cabe dentro do `e2e.sh` (que usa a stack de dev). Decisão recomendada: em `e2e.sh --full`, o admin passa a rodar só `integration_test/admin_mobile_smoke_test.dart` (hermético, sem stack) e o `e2e.sh` imprime que o fluxo com backend real do admin é `scripts/qa/admin_login_e2e.sh` (e roda sob `--admin-real` opcional, que o invoca ao final, já que ele derruba a stack de dev). Registrar a escolha no `PROGRESS.md`. + +- [ ] **Step 2: Teste do script.** Estender `scripts/qa/admin_login_e2e_test.sh` (ou criar um caso em `e2e.sh`) para afirmar que o texto "MockAdminDataSource" não aparece mais no cabeçalho de `admin_login_e2e.sh` e que `--full` referencia `admin_mobile_smoke_test.dart`. Rodar `bash scripts/qa/admin_login_e2e_test.sh` — Expected: FAIL antes da edição. + +- [ ] **Step 3: Implementar** as edições de `e2e.sh` e do cabeçalho do script. + +- [ ] **Step 4: Rodar** `bash scripts/qa/admin_login_e2e_test.sh && ./scripts/qa/ci_invariants.sh` — Expected: PASS / ok (9 grupos). + +- [ ] **Step 5: Prova no emulador** (critério de aceite): + +```bash +./scripts/qa/admin_login_e2e.sh # 5/5 no emulator-5554, incluindo painel com dados reais e 4 recursos admin_* auditados +``` +Com a Task 2 pronta, acrescentar a `apps/admin/integration_test/admin_login_e2e.dart` um caso: parar o backend (`docker compose stop backend` via o script) e conferir que o painel mostra "Não foi possível conectar ao servidor." com "Tentar novamente". Se tornar o script instável, deixar o caso só como teste de widget (Task 2) e documentar. + +- [ ] **Step 6: Commit** + +```bash +git add scripts/qa apps/admin/integration_test .github/workflows/ci.yml +git commit -m "test(e2e): admin no e2e.sh e cabeçalho do admin_login_e2e sem o mock (#41)" +``` + +--- + +### Task 4: Documentação e fechamento + +**Files:** +- Modify: `PROGRESS.md` (seção da #40, itens "Continua aberto" 3 e 4; linha 697 "apps/admin ainda não"; linha 1232 sobre `MockAdminDataSource`) +- Modify: `apps/CLAUDE.md` (admin lê do backend; mock só em teste), `docs/telas-admin.md` (capturas 06–08 ainda no mock — recapturar no emulador com `adb exec-out screencap`, só dados sintéticos) + +- [ ] **Step 1:** Recapturar as telas 06–08 no emulador com sessão real e substituir os PNGs em `docs/screenshots/admin/`. +- [ ] **Step 2:** Atualizar as linhas do `PROGRESS.md` acima com data (2026-10-07), testes rodados (números reais da saída) e o que continua aberto (paginação "carregar mais", refresh token do staff, celular físico). +- [ ] **Step 3:** `./scripts/qa/check_documentation_links.sh` — Expected: exit 0. +- [ ] **Step 4:** `graphify update .` +- [ ] **Step 5: Commit** e abrir PR para `develop` com `Closes #41`. + +```bash +git add PROGRESS.md apps/CLAUDE.md docs +git commit -m "docs: admin sobre o sinalacs_client e prova no emulador (#41)" +``` + +--- + +## Self-review + +- **Cobertura da issue:** dependência local (já feita, verificada na Task 1) · `BackendAdminDataSource` + mock mantido (já feito, Task 1) · tratamento de falha de rede/sessão (Task 2) · job `admin-app` e e2e (Tasks 1 e 3). +- **Sem placeholders:** a única dependência aberta é o nome exato da exceção 401 do `serverpod_client`, marcada na Task 2 para confirmar ao implementar. +- **Consistência de tipos:** `AdminSessionExpired` e `mensagemDaFalha` usados com as mesmas assinaturas nas Tasks 2 e 3. +- **Risco principal:** `admin_login_e2e.sh` derruba a stack de dev; restaurar com `docker compose up -d` ao final de cada rodada. diff --git a/docs/superpowers/plans/2026-10-07-issue-48-ativacao-totp-staff.md b/docs/superpowers/plans/2026-10-07-issue-48-ativacao-totp-staff.md new file mode 100644 index 00000000..2340867d --- /dev/null +++ b/docs/superpowers/plans/2026-10-07-issue-48-ativacao-totp-staff.md @@ -0,0 +1,660 @@ +# Issue #48 — Ativação do TOTP do staff com código de uso único — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fechar o trust-on-first-use da primeira ativação do TOTP do staff: `beginStaffTotpEnrollment`/`confirmStaffTotpEnrollment` passam a exigir, além de matrícula e senha, um **código de ativação de uso único**, emitido fora de banda por quem opera o banco. + +**Architecture:** Três colunas aditivas em `staff_accounts` guardam o hash SHA-256 do código, a expiração e quem o emitiu. `InstitutionalAuthService` (audiência `staff`) valida o código em `begin` e `confirm`, conta erro como tentativa falha e o apaga quando a MFA é ativada. Uma CLI Dart (`bin/issue_staff_activation_code.dart`) emite o código e o mostra uma única vez. O fluxo do ACS não muda. O app admin ganha um campo de código antes do QR. + +**Tech Stack:** Dart/Serverpod (backend, migrações, `dart test` com `postgres-test`), Flutter (`apps/admin`, widget tests, `integration_test`), bash/adb, Docker Compose. + +**Spec:** Issue #48 (`gh issue view 48`); `PROGRESS.md` seções "Login real do backoffice (issue #39)" e "Minors adiados da #39"; `backend/CLAUDE.md` (bloco "Staff (backoffice, issue #39)"); `spec/security_assessment.md`; `spec/lgpd_design.md`. + +## Global Constraints + +- Idioma: mensagens de UI/erro, comentários e docs em português. +- Só dados sintéticos. Código de ativação, senha e segredo TOTP **nunca** vão para log, auditoria, mensagem de exceção nem para o git. +- Mensagem de recusa de código errado, expirado, ausente ou já usado: uma só, `Código de ativação inválido ou expirado.` (não distingue os casos). +- Política de bloqueio: erro de código conta como tentativa falha da conta, pelo mesmo `_registrarFalha` do login (limite `maxFailedAttempts = 5`, `lockDurationFor`). +- O código tem 128 bits aleatórios (`Random.secure()`), é guardado só como SHA-256 e comparado em tempo constante; validade padrão 24 h. +- A ativação do **ACS** (`beginTotpEnrollment`/`confirmTotpEnrollment` da audiência `acs`) não muda de assinatura nem de comportamento. +- Migração só **aditiva** (3 colunas anuláveis); nada de `DROP`. +- Commits sem `Co-Authored-By` e sem "Generated with Claude Code" (regra do `CLAUDE.md` do projeto). Execução do plano em uma branch nova a partir de `main` (ex.: `fix/staff-totp-activation-48`), não em `fix/app_admin`. +- `scripts/qa/e2e_stack.sh up` recria os containers de dev e o ambiente recusa que o agente o rode: o usuário roda com `!`. Ao fim, `docker compose up -d` devolve a stack de dev. +- Testes em aparelho: celular `0087014315` (Motorola edge 40 neo, Android 15) **e** emulador `emulator-5554` (AVD `Medium_Phone`). O `admin_login_e2e.sh` aceita `DEVICE=`. +- `backend/CLAUDE.md` e `PROGRESS.md` só afirmam o que foi executado. + +## Review Focus + +- Código expirado (1 s depois do prazo) → recusado com a mensagem única e conta como falha. +- Mesmo código usado duas vezes: depois do `confirm` bem-sucedido a coluna fica nula, então o segundo uso é recusado. +- Reemissão: um código novo invalida o anterior (o antigo deixa de funcionar). +- Código digitado com minúsculas, espaços ou hífens (`abcd-efgh ...`) deve valer como o original. +- Conta já com MFA ativa pede redefinição à coordenação **antes** de olhar o código (nenhum vazamento de que o código existe). +- Matrícula inexistente/inativa na CLI: recusa sem emitir nem imprimir nada. +- `begin` repetido com o mesmo código (usuário volta e fecha a tela) continua funcionando até a confirmação. +- ACS: `begin`/`confirm` sem código continuam funcionando como hoje. + +--- + +## File Structure + +- Modify: `backend/sinalacs_server/lib/src/models/staff_account.spy.yaml` — 3 colunas novas. +- Create: `backend/sinalacs_server/migrations//` — gerada por `serverpod create-migration`. +- Create: `backend/sinalacs_server/lib/src/application/auth/staff_activation_code.dart` — gerar, normalizar, hashear e comparar o código (função pura, sem ORM). +- Modify: `backend/sinalacs_server/lib/src/application/auth/institutional_auth_service.dart` — interface `StaffActivationStore`, parâmetro `activationCode`, validação, consumo. +- Modify: `backend/sinalacs_server/lib/src/infrastructure/database/orm_acs_credential_store.dart` — `implements StaffActivationStore`. +- Modify: `backend/sinalacs_server/lib/src/runtime/alert_runtime.dart` (`staffAuthServiceFor`) e `lib/src/endpoints/auth_endpoint.dart` — fiação e parâmetro. +- Create: `backend/sinalacs_server/bin/issue_staff_activation_code.dart` — CLI do operador. +- Modify: `backend/sinalacs_server/bin/seed_e2e_fixtures.dart`, fixtures `E2eStaff` — o e2e semeia um código. +- Modify: `apps/admin/lib/core/auth/admin_auth_backend.dart`, `lib/app/mfa_enrollment_screen.dart`, `lib/app/login_screen.dart`, fakes dos testes; `apps/admin/integration_test/admin_login_e2e.dart`. +- Modify: `backend/CLAUDE.md`, `PROGRESS.md`, `docs/telas-admin.md`, `docs/screenshots/admin/` (captura da tela do código). +- Tests: `backend/sinalacs_server/test/unit/staff_activation_code_test.dart`, `test/unit/institutional_auth_staff_test.dart`, `test/integration/staff_login_test.dart`, `apps/admin/test/*`. + +--- + +### Task 0: Verificar a issue contra o código atual (somente leitura) + +**Files:** nenhum modificado. + +- [ ] **Step 1: Provar o TOFU hoje** + +Run: +```bash +cd backend/sinalacs_server && dart test test/integration/staff_login_test.dart -n "begin devolve segredo|confirm com código certo" +``` +Expected: 2 testes passam usando só `matricula`+`password` (a falha da issue existe). + +- [ ] **Step 2: Provar o "segredo pendente é sobrescrito"** + +Run: `grep -n "saveSecret" -B2 -A12 lib/src/infrastructure/database/orm_acs_credential_store.dart | sed -n 1,30p` +Expected: o `WHERE` só barra quando `totpEnabledAt` não é nulo; um segundo `begin` sobrescreve o pendente. + +- [ ] **Step 3: Provar que a #40 ainda não existe** + +Run: `grep -rn "staffRoles" backend/sinalacs_server/lib/src/endpoints | head` +Expected: nenhum endpoint de dados aceita staff (confirma que o risco é "futuro, bloqueia a #40"). + +- [ ] **Step 4: Decidir branch** + +Run: `git switch main && git pull --ff-only && git switch -c fix/staff-totp-activation-48` +Expected: branch nova. Sem commit. + +--- + +### Task 1: Gerar, normalizar e conferir o código (função pura) + +**Files:** +- Create: `backend/sinalacs_server/lib/src/application/auth/staff_activation_code.dart` +- Test: `backend/sinalacs_server/test/unit/staff_activation_code_test.dart` + +**Interfaces:** +- Produces: + - `class StaffActivationCode { static String generate([Random? random]); static String normalize(String input); static String hash(String code); static bool matches(String input, String storedHash); static const Duration defaultValidity = Duration(hours: 24); }` + - `generate` devolve 26 caracteres base32 (alfabeto `A-Z2-7`, 130 bits ⇒ usa 26 símbolos) em grupos de 4 separados por hífen, ex.: `ABCD-EFGH-…`. + - `normalize` tira espaços e hífens e põe em maiúsculas. + - `hash` = SHA-256 hex de `normalize(code)`. + - `matches` compara `hash(input)` com `storedHash` em tempo constante. + +- [ ] **Step 1: Escrever o teste que falha** + +```dart +import 'dart:math'; +import 'package:sinalacs_server/src/application/auth/staff_activation_code.dart'; +import 'package:test/test.dart'; + +void main() { + test('generate devolve grupos de 4 base32 e sempre muda', () { + final a = StaffActivationCode.generate(Random(1)); + final b = StaffActivationCode.generate(Random(2)); + expect(a, matches(RegExp(r'^([A-Z2-7]{4}-){6}[A-Z2-7]{2}$'))); + expect(a, isNot(b)); + }); + + test('normalize ignora caixa, espaços e hífens', () { + expect(StaffActivationCode.normalize(' ab cd-ef '), 'ABCDEF'); + }); + + test('matches aceita o mesmo código em outra formatação e recusa o errado', () { + final c = StaffActivationCode.generate(Random(3)); + final h = StaffActivationCode.hash(c); + expect(StaffActivationCode.matches(c.toLowerCase().replaceAll('-', ' '), h), isTrue); + expect(StaffActivationCode.matches('${c}X', h), isFalse); + expect(StaffActivationCode.matches('', h), isFalse); + }); + + test('o hash não contém o código', () { + final c = StaffActivationCode.generate(Random(4)); + expect(StaffActivationCode.hash(c), isNot(contains(StaffActivationCode.normalize(c)))); + expect(StaffActivationCode.hash(c), hasLength(64)); + }); +} +``` + +- [ ] **Step 2: Rodar e ver falhar** + +Run: `cd backend/sinalacs_server && dart test test/unit/staff_activation_code_test.dart` +Expected: FAIL (arquivo/classe inexistente). + +- [ ] **Step 3: Implementar** + +```dart +import 'dart:convert'; +import 'dart:math'; + +import 'package:crypto/crypto.dart'; + +/// Código de ativação de uso único da MFA do staff (issue #48). +/// +/// 130 bits de entropia: SHA-256 puro basta como hash (não há senha fraca a +/// proteger com KDF lento), e o código só existe em claro na saída da CLI. +class StaffActivationCode { + const StaffActivationCode._(); + + static const defaultValidity = Duration(hours: 24); + static const _alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + static const _length = 26; + + static String generate([Random? random]) { + final r = random ?? Random.secure(); + final raw = List.generate(_length, (_) => _alphabet[r.nextInt(_alphabet.length)]); + final grupos = []; + for (var i = 0; i < raw.length; i += 4) { + grupos.add(raw.sublist(i, i + 4 > raw.length ? raw.length : i + 4).join()); + } + return grupos.join('-'); + } + + static String normalize(String input) => + input.replaceAll(RegExp(r'[\s-]'), '').toUpperCase(); + + static String hash(String code) => + sha256.convert(utf8.encode(normalize(code))).toString(); + + /// Comparação em tempo constante do hash de [input] com [storedHash]. + static bool matches(String input, String storedHash) { + if (normalize(input).isEmpty) return false; + final a = utf8.encode(hash(input)); + final b = utf8.encode(storedHash); + if (a.length != b.length) return false; + var diff = 0; + for (var i = 0; i < a.length; i++) { + diff |= a[i] ^ b[i]; + } + return diff == 0; + } +} +``` + +- [ ] **Step 4: Rodar e ver passar** + +Run: `dart test test/unit/staff_activation_code_test.dart` +Expected: 4 testes passam. Se `crypto` não estiver em `pubspec.yaml` do backend, o erro de import diz; adicionar com `dart pub add crypto` (já é dependência transitiva usada por `totp.dart`). + +- [ ] **Step 5: Commit** + +```bash +git add backend/sinalacs_server/lib/src/application/auth/staff_activation_code.dart backend/sinalacs_server/test/unit/staff_activation_code_test.dart +git commit -m "feat(auth): código de ativação de uso único do staff (#48)" +``` + +--- + +### Task 2: Colunas, migração e store + +**Files:** +- Modify: `backend/sinalacs_server/lib/src/models/staff_account.spy.yaml` +- Create: migração gerada +- Modify: `backend/sinalacs_server/lib/src/application/auth/institutional_auth_service.dart` (só a interface) +- Modify: `backend/sinalacs_server/lib/src/infrastructure/database/orm_acs_credential_store.dart` +- Test: `backend/sinalacs_server/test/integration/staff_activation_store_test.dart` + +**Interfaces:** +- Consumes: `StaffActivationCode.hash` (Task 1). +- Produces (em `institutional_auth_service.dart`): + +```dart +class StaffActivationRecord { + const StaffActivationRecord({required this.codeHash, required this.expiresAt}); + final String codeHash; + final DateTime expiresAt; +} + +abstract interface class StaffActivationStore { + /// Grava o hash do código e a expiração, substituindo qualquer anterior. + Future issue(String staffId, {required String codeHash, required DateTime expiresAt, required String issuedBy, required DateTime at}); + /// Código vigente da conta, ou `null` (nunca emitido ou já consumido). + Future find(String staffId); + /// Apaga o código (hash e expiração); `issuedBy`/`issuedAt` ficam como último registro. + Future clear(String staffId); +} +``` + +- [ ] **Step 1: Adicionar as colunas** + +Em `staff_account.spy.yaml`, depois de `active: bool`: +```yaml + activationCodeHash: String? + activationCodeExpiresAt: DateTime? + activationCodeIssuedBy: String? + activationCodeIssuedAt: DateTime? +``` +(4 colunas, todas anuláveis; ajustar "3 colunas" do resumo para 4 ao documentar.) + +- [ ] **Step 2: Gerar código e migração** + +Run (comandos exatos em `backend/CLAUDE.md`, seção Serverpod): +```bash +cd backend/sinalacs_server && serverpod generate && serverpod create-migration +git status --short | head +``` +Expected: nova pasta em `migrations/`, `definition.sql` com as 4 colunas, `ALTER TABLE ... ADD COLUMN` sem `DROP`. Conferir: `grep -n "DROP" migrations//migration.sql` não devolve nada. + +- [ ] **Step 3: Escrever o teste de integração que falha** + +Em `test/integration/staff_activation_store_test.dart`, seguindo `staff_login_test.dart` (mesmo `withServerpod`, mesma semeadura de `staff_accounts`/`users` com UUID próprio `…0000b1`): + +```dart +test('issue grava; find devolve; issue de novo substitui; clear apaga', () async { + final store = OrmAcsCredentialStore(session: () => session, staff: true); + final t0 = DateTime.utc(2026, 10, 7, 12); + await store.issue(_staffId, codeHash: 'h1', expiresAt: t0.add(const Duration(hours: 1)), issuedBy: 'operador', at: t0); + expect((await store.find(_staffId))?.codeHash, 'h1'); + await store.issue(_staffId, codeHash: 'h2', expiresAt: t0.add(const Duration(hours: 2)), issuedBy: 'operador', at: t0); + expect((await store.find(_staffId))?.codeHash, 'h2'); + await store.clear(_staffId); + expect(await store.find(_staffId), isNull); +}); +``` + +- [ ] **Step 4: Rodar e ver falhar** + +Run: `dart test test/integration/staff_activation_store_test.dart` +Expected: FAIL (`issue` não existe). + +- [ ] **Step 5: Implementar** + +Adicionar a interface acima ao `institutional_auth_service.dart` e, em `OrmAcsCredentialStore`, `implements AcsCredentialStore, TotpStore, StaffActivationStore`: + +```dart +@override +Future issue(String staffId, {required String codeHash, required DateTime expiresAt, required String issuedBy, required DateTime at}) async { + await StaffAccount.db.updateWhere( + _session(), + columnValues: (t) => [ + t.activationCodeHash(codeHash), + t.activationCodeExpiresAt(expiresAt), + t.activationCodeIssuedBy(issuedBy), + t.activationCodeIssuedAt(at), + ], + where: (t) => t.id.equals(UuidValue.fromString(staffId)), + ); +} + +@override +Future find(String staffId) async { + final c = await StaffAccount.db.findById(_session(), UuidValue.fromString(staffId)); + if (c?.activationCodeHash == null || c?.activationCodeExpiresAt == null) return null; + return StaffActivationRecord(codeHash: c!.activationCodeHash!, expiresAt: c.activationCodeExpiresAt!); +} + +@override +Future clear(String staffId) async { + await StaffAccount.db.updateWhere( + _session(), + columnValues: (t) => [t.activationCodeHash(null), t.activationCodeExpiresAt(null)], + where: (t) => t.id.equals(UuidValue.fromString(staffId)), + ); +} +``` + +- [ ] **Step 6: Rodar e ver passar** + +Run: `dart test test/integration/staff_activation_store_test.dart && dart analyze lib test | tail -3` +Expected: teste passa, sem avisos. + +- [ ] **Step 7: Commit** + +```bash +git add backend/sinalacs_server +git commit -m "feat(db): colunas e store do código de ativação do staff (#48)" +``` + +--- + +### Task 3: Serviço exige o código (`begin` e `confirm`) + +**Files:** +- Modify: `backend/sinalacs_server/lib/src/application/auth/institutional_auth_service.dart` (`InstitutionalAuthService`, `beginTotpEnrollment`, `confirmTotpEnrollment`) +- Test: `backend/sinalacs_server/test/unit/institutional_auth_staff_test.dart` + +**Interfaces:** +- Consumes: `StaffActivationStore`, `StaffActivationRecord` (Task 2); `StaffActivationCode.matches` (Task 1). +- Produces: `InstitutionalAuthService({..., this.activationStore})`; `beginTotpEnrollment({required matricula, required password, String? activationCode, DateTime? now})` e `confirmTotpEnrollment({..., String? activationCode, ...})`. Para `audience == staff`, `activationCode` é obrigatório (nulo/ausente = recusa). Para `acs`, é ignorado. + +- [ ] **Step 1: Escrever os testes que falham** + +Ler as primeiras ~60 linhas de `institutional_auth_staff_test.dart` e reaproveitar a construção do serviço de staff e os fakes ali usados. Acrescentar um `FakeStaffActivationStore` em memória: + +```dart +class FakeStaffActivationStore implements StaffActivationStore { + final _m = {}; + @override + Future issue(String id, {required String codeHash, required DateTime expiresAt, required String issuedBy, required DateTime at}) async => + _m[id] = StaffActivationRecord(codeHash: codeHash, expiresAt: expiresAt); + @override + Future find(String id) async => _m[id]; + @override + Future clear(String id) async => _m.remove(id); +} +``` + +Casos (cada um com o serviço de staff + `activationStore` passado ao construtor): + +```dart +const generica = 'Código de ativação inválido ou expirado.'; +final agora = DateTime.utc(2026, 10, 7, 12); + +test('begin sem código é recusado com a mensagem única e conta falha', ...); // activationCode: null → AuthenticationFailedException(message: generica); failedAttempts == 1 +test('begin com código errado é recusado', ...); +test('begin com código expirado é recusado (1 s depois)', ...); // now: expiresAt + 1s +test('begin com o código certo devolve segredo; repetido com o mesmo código também', ...); +test('confirm com código certo e TOTP certo ativa a MFA e apaga o código', ...); // store.find == null depois +test('o mesmo código não serve depois da ativação', ...); // novo begin → 'já está ativa' (conta com MFA) +test('conta com MFA já ativa recebe "já está ativa" mesmo com código errado', ...); +test('reemitir invalida o código anterior', ...); +test('código com minúsculas e hífens funciona', ...); +test('ACS: begin sem código continua funcionando', ...); // serviço com audience acs +test('5 códigos errados bloqueiam a conta', ...); // lockedUntil != null +``` + +- [ ] **Step 2: Rodar e ver falhar** + +Run: `dart test test/unit/institutional_auth_staff_test.dart` +Expected: FAIL (parâmetro `activationCode`/`activationStore` inexistentes). + +- [ ] **Step 3: Implementar** + +No construtor: `this.activationStore,` e `final StaffActivationStore? activationStore;`. Adicionar o helper e chamá-lo logo depois do bloco "MFA já ativa" de `begin`, e logo depois de `totp == null || totp.enabled` de `confirm` (no `confirm`, **antes** de `Totp.verify`): + +```dart +static const _invalidActivation = 'Código de ativação inválido ou expirado.'; + +/// Só a audiência staff exige o código. Erro conta como falha de login e vira +/// auditoria; o texto é um só para não dizer se o código existiu ou expirou. +Future _exigirCodigoDeAtivacao(AcsCredentialRecord record, String? code, DateTime at) async { + if (audience != CredentialAudience.staff) return; + final store = activationStore ?? (throw StateError('staff sem store de código de ativação')); + final vigente = await store.find(record.acsId); + final ok = vigente != null && + code != null && + at.isBefore(vigente.expiresAt) && + StaffActivationCode.matches(code, vigente.codeHash); + if (ok) return; + await _registrarFalha(record, at); + await _recordAudit(record.acsId, 'denied_staff_activation_code'); + throw AuthenticationFailedException(message: _invalidActivation); +} +``` + +Em `beginTotpEnrollment`: adicionar `String? activationCode,` à assinatura e `await _exigirCodigoDeAtivacao(record, activationCode, at);` depois do `if (record.totp?.enabled ...)`. Em `confirmTotpEnrollment`: idem depois do `if (totp == null || totp.enabled)`, e, **depois** de `_totpStore().enable(...)` bem-sucedido: `await activationStore?.clear(record.acsId);` antes do `_recordAudit(... 'mfa_enabled')`. Importar `staff_activation_code.dart`. + +- [ ] **Step 4: Rodar e ver passar** + +Run: `dart test test/unit/institutional_auth_staff_test.dart test/unit/institutional_auth_service_test.dart test/unit/institutional_auth_mfa_test.dart` +Expected: todos passam (os do ACS provam que nada regrediu). + +- [ ] **Step 5: Commit** + +```bash +git add backend/sinalacs_server/lib backend/sinalacs_server/test +git commit -m "feat(auth): ativação da MFA do staff exige código de uso único (#48)" +``` + +--- + +### Task 4: Fiação do endpoint e teste contra Postgres + +**Files:** +- Modify: `backend/sinalacs_server/lib/src/runtime/alert_runtime.dart` (`staffAuthServiceFor`: `activationStore: store`) +- Modify: `backend/sinalacs_server/lib/src/endpoints/auth_endpoint.dart` (`beginStaffTotpEnrollment`, `confirmStaffTotpEnrollment`: parâmetro `required String activationCode`) +- Modify: `backend/sinalacs_server/test/integration/staff_login_test.dart`, `test/unit/endpoint_auth_posture_test.dart` se citar a assinatura +- Regenerar: `serverpod generate` + +**Interfaces:** +- Consumes: Task 3. +- Produces: RPC `auth.beginStaffTotpEnrollment({matricula, password, activationCode})` e `auth.confirmStaffTotpEnrollment({matricula, password, activationCode, code})` — o cliente Dart gerado (`sinalacs_client`) ganha o parâmetro; `apps/admin` consome na Task 6. + +- [ ] **Step 1: Atualizar os testes de endpoint (falham até a fiação)** + +Em `staff_login_test.dart` ("ativação de MFA do staff pelo endpoint"), semear um código antes de cada caso com `OrmAcsCredentialStore(staff:true).issue(...)` e passar `activationCode:`. Adicionar: + +```dart +test('begin sem o código certo é recusado e não grava segredo', () async { ... }); +test('confirm com código certo ativa; o código some do banco', () async { ... }); +``` + +- [ ] **Step 2: Rodar e ver falhar** + +Run: `dart test test/integration/staff_login_test.dart` +Expected: FAIL (assinatura antiga). + +- [ ] **Step 3: Implementar** + +`staffAuthServiceFor` ganha `activationStore: store,`. Os dois métodos do endpoint passam `activationCode: activationCode`. Rodar `serverpod generate`. + +- [ ] **Step 4: Rodar a suíte do backend** + +Run: `cd backend/sinalacs_server && dart test 2>&1 | tail -5` +Expected: tudo verde (a suíte inteira, ~650 testes; os de integração exigem `postgres-test` de pé: `docker compose --profile test up -d postgres-test`). + +- [ ] **Step 5: Commit** + +```bash +git add backend +git commit -m "feat(auth): endpoints do staff recebem o código de ativação (#48)" +``` + +--- + +### Task 5: CLI do operador que emite o código + +**Files:** +- Create: `backend/sinalacs_server/bin/issue_staff_activation_code.dart` +- Test: `backend/sinalacs_server/test/unit/issue_staff_activation_code_test.dart` + +**Interfaces:** +- Consumes: `StaffActivationCode` (Task 1); colunas da Task 2. +- Produces: `dart run bin/issue_staff_activation_code.dart --issued-by [--ttl-hours 24]`; saída: uma linha `Código de ativação (mostrado uma única vez): XXXX-…` e a validade. Código de saída `2` para matrícula inexistente/inativa ou argumento ausente. Lê o banco como `seed_acs_credentials.dart` (variáveis `SERVERPOD_DATABASE_*`, `package:postgres`). Lógica testável em `Future issueStaffActivationCode(Connection db, {required String matricula, required String issuedBy, required Duration validity, DateTime? now})` exportada do próprio arquivo. + +- [ ] **Step 1: Escrever o teste que falha** + +Teste com conexão ao `postgres-test` (como outros testes de `bin/`; usar o padrão de `seed_staff_agreement_test.dart`): matrícula inexistente → `IssueResult.notFound`; conta inativa → `notFound`; conta ativa → retorna código, e no banco `activationCodeHash == StaffActivationCode.hash(codigo)`, `activationCodeIssuedBy == 'operador'`, e o hash **não** é igual ao código. + +- [ ] **Step 2: Rodar e ver falhar** + +Run: `dart test test/unit/issue_staff_activation_code_test.dart` +Expected: FAIL (arquivo inexistente). + +- [ ] **Step 3: Implementar** + +`issueStaffActivationCode` faz `SELECT id, active FROM staff_accounts WHERE "enrollmentId"=@m`; se não achar ou `active=false` devolve `notFound`; senão gera o código, e roda `UPDATE staff_accounts SET "activationCodeHash"=@h, "activationCodeExpiresAt"=@e, "activationCodeIssuedBy"=@by, "activationCodeIssuedAt"=@at WHERE id=@id`. `main` lê os argumentos (`--issued-by` obrigatório e não vazio), imprime só o código e a validade, **nunca** a matrícula+hash. Documentar no topo do arquivo: "ferramenta de operação; roda com acesso ao banco; a emissão fica registrada em `activationCodeIssuedBy/At` — a trilha `audit_logs` exige a cadeia de auditoria do servidor, que a CLI não carrega; o registro da emissão é essa coluna". + +- [ ] **Step 4: Rodar e ver passar** + +Run: `dart test test/unit/issue_staff_activation_code_test.dart && dart analyze bin | tail -2` +Expected: passa, sem avisos. + +- [ ] **Step 5: Commit** + +```bash +git add backend/sinalacs_server/bin/issue_staff_activation_code.dart backend/sinalacs_server/test/unit/issue_staff_activation_code_test.dart +git commit -m "feat(ops): CLI que emite o código de ativação do staff (#48)" +``` + +--- + +### Task 6: App admin pede o código antes do QR + +**Files:** +- Modify: `apps/admin/lib/core/auth/admin_auth_backend.dart` (`beginMfaEnrollment`/`confirmMfaEnrollment` ganham `required String activationCode`; `BackendAdminAuth` repassa a `beginStaffTotpEnrollment`/`confirmStaffTotpEnrollment`) +- Modify: `apps/admin/lib/app/mfa_enrollment_screen.dart` (etapa 1: campo `activation_code_field` + botão `activation_continue`; etapa 2: o QR atual) +- Modify: `apps/admin/lib/app/login_screen.dart` (nada além da fiação, se `MfaEnrollmentScreen` mudar de assinatura) +- Modify: fakes e testes: `apps/admin/test/admin_auth_backend_test.dart`, `login_flow_test.dart`, `login_real_test.dart` +- Atualizar o cliente: `apps/admin` depende de `sinalacs_client` (regenerado na Task 4). + +**Interfaces:** +- Consumes: RPC da Task 4. +- Produces: `AdminAuthBackend.beginMfaEnrollment({required String matricula, required String senha, required String activationCode})` e `confirmMfaEnrollment({..., required String activationCode, required String code})`. Chaves de widget: `activation_code_field`, `activation_continue`, `activation_error`. + +- [ ] **Step 1: Escrever os testes de widget que falham** + +Em `login_flow_test.dart`/`login_real_test.dart` (com o `AdminAuthBackend` falso que já existe): a tela de ativação **não** chama `beginMfaEnrollment` ao abrir; mostra o campo de código; com o código vazio, o botão fica sem ação e mostra `Informe o código de ativação.`; com um código, chama `beginMfaEnrollment(..., activationCode: )` e só então mostra o QR; falha `AdminAuthFailure('Código de ativação inválido ou expirado.')` aparece em `activation_error` e o QR **não** aparece; `confirmMfaEnrollment` recebe o mesmo código. + +- [ ] **Step 2: Rodar e ver falhar** + +Run: `cd apps/admin && flutter test test/login_flow_test.dart test/login_real_test.dart test/admin_auth_backend_test.dart` +Expected: FAIL (assinaturas antigas). + +- [ ] **Step 3: Implementar** + +`MfaEnrollmentScreen`: estado `_inicio == null && !_pediuCodigo` mostra o campo (rótulo `Código de ativação`, texto de ajuda `Peça o código à coordenação. Ele vale uma única vez.`, `autofillHints` vazio, `enableSuggestions: false`, `autocorrect: false`, `textCapitalization: characters`), e `_iniciar()` só roda no botão. Guardar `_codigoAtivacao` e repassá-lo a `confirmMfaEnrollment`. Cor de erro e alvos de toque seguem os tokens existentes (`*OnSurface`; alvo ≥ 48 dp): copiar o estilo do campo `Código de 6 dígitos` já na tela. + +- [ ] **Step 4: Rodar o app admin inteiro** + +Run: `cd apps/admin && flutter test && flutter analyze` +Expected: tudo verde (era 95 testes), sem avisos. `contrast_tokens_test`, `touch_targets_test` e `text_scale_test` passam com a nova etapa. + +- [ ] **Step 5: Commit** + +```bash +git add apps/admin +git commit -m "feat(admin): tela de ativação pede o código de uso único (#48)" +``` + +--- + +### Task 7: E2E e prova no emulador e no celular + +**Files:** +- Modify: `backend/sinalacs_server/bin/seed_e2e_fixtures.dart` e a classe de fixtures (`E2eStaff`): gera e semeia um código; o bloco `staff` do manifesto ganha `activationCode` +- Modify: `apps/admin/integration_test/support/e2e_admin.dart`, `apps/admin/integration_test/admin_login_e2e.dart` +- Modify: `scripts/qa/admin_login_e2e.sh` (conferir no banco que o código foi apagado e que `activationCodeIssuedBy` ficou preenchido) +- Test: `backend/sinalacs_server/test/unit/` do seed de e2e (o teste unitário das fixtures já existente) + +**Interfaces:** +- Consumes: Tasks 1–6. +- Produces: o relé `/admin` devolve `{matricula, senha, activationCode}`. + +- [ ] **Step 1: Teste unitário das fixtures (falha)** + +Estender o teste existente de fixtures para exigir `staff.activationCode` no formato `^([A-Z2-7]{4}-){6}[A-Z2-7]{2}$` e que o manifesto JSON o contenha. + +- [ ] **Step 2: Rodar e ver falhar; implementar; rodar e ver passar** + +Run: `cd backend/sinalacs_server && dart test test/unit -n "fixtures"` (ajustar ao nome real do arquivo do teste de fixtures; `grep -rln E2eStaff test`). +Expected: FAIL e depois PASS com o gerador (`StaffActivationCode.generate()`) e o `UPDATE` do seed. + +- [ ] **Step 3: Estender o roteiro de integração** + +Em `admin_login_e2e.dart`, no teste "primeiro acesso": digitar o código de ativação (`activation_code_field`), tocar `activation_continue`, só então ler o QR; novo teste **antes** dele: "código de ativação errado: recusado e o QR não aparece" (usa `` alterado em 1 caractere). Ordem dos testes continua obrigatória (comentário do arquivo). + +- [ ] **Step 4: Conferência no banco (script)** + +Em `admin_login_e2e.sh`, após o login, acrescentar: +```bash +igual 'código de ativação apagado após a ativação' 'true' \ + "select (\"activationCodeHash\" is null)::text from staff_accounts where id='$staff_id'" +``` + +- [ ] **Step 5: Rodar no celular (o usuário executa; recria os containers de dev)** + +Preflight: `adb -s 0087014315 get-state`, tela acordada, porta 8765 livre (`ss -ltn | grep :8765 || echo livre`). +``` +! DEVICE=0087014315 ./scripts/qa/admin_login_e2e.sh 2>&1 | tee .superpowers/admin48_celular.log +``` +Expected: todos os testes passam (incl. o do código errado), `TOTP ativado e passo registrado: true|true`, `código de ativação apagado após a ativação: true`. + +- [ ] **Step 6: Rodar no emulador (o usuário sobe o AVD; o agente confere)** + +```bash +export PATH="$PATH:$HOME/Android/Sdk/emulator:$HOME/Android/Sdk/platform-tools" +nohup emulator -avd Medium_Phone -no-snapshot -no-boot-anim >/dev/null 2>&1 & +adb wait-for-device && adb devices +``` +Expected: `emulator-5554 device`. Depois: +``` +! DEVICE=emulator-5554 ./scripts/qa/admin_login_e2e.sh 2>&1 | tee .superpowers/admin48_emulador.log +``` +Expected: mesmo resultado. Se algum dos dois falhar, `superpowers:systematic-debugging` antes de editar; a primeira rodada do #39 falhou uma vez sem causa conhecida: **guardar o log sempre** (`tee`). + +- [ ] **Step 7: Prova manual da CLI contra a stack de e2e (sem app)** + +Com a stack de e2e no ar (`./scripts/qa/e2e_stack.sh up && ./scripts/qa/e2e_stack.sh seed`, o usuário roda), emitir um código e provar a recusa/uso por RPC é redundante com o teste de integração; aqui só conferir a CLI contra o banco real: +```bash +cd backend/sinalacs_server +SERVERPOD_DATABASE_HOST=localhost SERVERPOD_DATABASE_PORT=9090 SERVERPOD_DATABASE_NAME=sinalacs_e2e SERVERPOD_DATABASE_USER=postgres SERVERPOD_DATABASE_PASSWORD="$TEST_DATABASE_PASSWORD" \ + dart run bin/issue_staff_activation_code.dart ADM-INEXISTENTE --issued-by teste; echo "exit=$?" +``` +Expected: `exit=2` e nenhuma linha com código. + +- [ ] **Step 8: Commit** + +```bash +git add backend apps/admin scripts/qa +git commit -m "test(e2e): login do staff com código de ativação, celular e emulador (#48)" +``` + +--- + +### Task 8: Telas, documentação e fechamento da issue + +**Files:** +- Modify: `docs/telas-admin.md`; Create: `docs/screenshots/admin/01b-codigo-ativacao.png` +- Modify: `backend/CLAUDE.md` (bloco "Staff (backoffice, issue #39)"), `PROGRESS.md`, `apps/CLAUDE.md` se citar a ativação do staff + +- [ ] **Step 1: Capturar a tela do código no celular** + +Com a stack de e2e no ar e o app rodando (`cd apps/admin && flutter run -d 0087014315 --dart-define=SINALACS_HOST=https://localhost:8443/`), esconder as notificações (`adb shell settings put global sysui_demo_allowed 1` + `am broadcast -a com.android.systemui.demo -e command notifications -e visible false`), entrar com matrícula e senha do relé `/admin` (digitar a senha em blocos de 5 caracteres e conferir o tamanho: o `adb input text` perde caracteres) e capturar a tela do código com `adb exec-out screencap -p`. Abrir a imagem com `Read` e rejeitar qualquer dado real. Depois `am broadcast ... -e command exit` e `settings put global sysui_demo_allowed 0`. +Expected: PNG da etapa "Código de ativação". + +- [ ] **Step 2: Atualizar `docs/telas-admin.md`** + +Na lista dos três desfechos do login, acrescentar a etapa do código de ativação com a imagem nova, e dizer que o código é emitido por `bin/issue_staff_activation_code.dart`. + +- [ ] **Step 3: Atualizar `backend/CLAUDE.md` e `PROGRESS.md`** + +`backend/CLAUDE.md`: remover o risco residual de TOFU do staff; documentar o novo parâmetro, as 4 colunas, a mensagem única, a CLI (`dart run bin/issue_staff_activation_code.dart --issued-by `) e a regra "emissão registrada em `activationCodeIssuedBy/At`, não em `audit_logs`". Dev: `ADM-001` precisa de um código emitido pela CLI (`docker compose exec`/`dart run` conforme o resto do arquivo) antes da primeira ativação. `PROGRESS.md`: mover o item "primeira ativação do TOTP do staff" de "Continua aberto" para fechado, citando commits, a data, os dois aparelhos (celular e emulador) e as contagens de testes rodados **nesta** execução. Item que sobrar (a emissão pela UI de coordenador depende da #43) permanece aberto, com esse nome. + +- [ ] **Step 4: Conferências finais** + +Run: +```bash +./scripts/qa/check_documentation_links.sh && ./scripts/qa/ci_invariants.sh && cd backend/sinalacs_server && dart test 2>&1 | tail -2 && cd ../../apps/admin && flutter test 2>&1 | tail -2 +``` +Expected: tudo verde. + +- [ ] **Step 5: Commit, push e PR** + +```bash +git add docs backend/CLAUDE.md PROGRESS.md apps +git commit -m "docs: ativação do TOTP do staff com código de uso único; fecha o risco da #48" +``` +Push e abertura do PR só com confirmação do usuário (ação externa). Corpo do PR com `Closes #48` e as evidências (logs `.superpowers/admin48_*.log` resumidos, sem segredos). + +- [ ] **Step 6: Devolver a stack de dev (o usuário executa)** + +``` +! ./scripts/qa/e2e_stack.sh down && docker compose up -d +``` +Expected: serviços `healthy`; `adb -s 0087014315 shell wm size` sem `Override`. + +--- + +## Self-Review + +- **Cobertura da issue:** TOFU fechado (Tasks 1–4); fluxo do app (6); prova em aparelho real e emulador (7); "registra quem emitiu o código" (coluna `activationCodeIssuedBy/At`, Task 2/5, com a ressalva de não ir para `audit_logs`); `backend/CLAUDE.md` e `PROGRESS.md` (8). A emissão por tela de coordenador/administrador (a parte "emitido por coordenador/administrador com MFA (#43)" da opção 2) **não** entra: depende da #43; fica aberto e nomeado. +- **Decisão que o plano toma pelo usuário (confirmar):** opção 2 com emissão por CLI de operador, em vez da opção 1 (segredo pré-registrado) ou da emissão por UI (#43). Custo se errado: Tasks 3–6 trocam o formato do segredo/código; Tasks 1 e 2 em boa parte se reaproveitam. +- **Placeholders:** o nome do arquivo de teste das fixtures (Task 7 Step 2) vem de um `grep` indicado, pois não foi lido; os fakes do `institutional_auth_staff_test.dart` (Task 3) são reaproveitados lendo o arquivo, e só o `FakeStaffActivationStore` é novo e está completo. +- **Tipos:** `StaffActivationStore.issue/find/clear` e `StaffActivationRecord{codeHash,expiresAt}` iguais nas Tasks 2, 3, 4 e 5; `activationCode` é `String?` no serviço e `required String` no endpoint e no app. +- **Riscos conhecidos:** `serverpod generate` altera muitos arquivos gerados (revisar o diff só pelo que é do #48); `ADM-001` do desenvolvimento deixa de ativar sem código (documentado na Task 8). diff --git a/docs/superpowers/plans/2026-10-07-verificacao-pendencias-admin-39.md b/docs/superpowers/plans/2026-10-07-verificacao-pendencias-admin-39.md new file mode 100644 index 00000000..703a4ce7 --- /dev/null +++ b/docs/superpowers/plans/2026-10-07-verificacao-pendencias-admin-39.md @@ -0,0 +1,338 @@ +# Verificação das pendências do backoffice (#39) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Confirmar, com evidência executada, quais das cinco pendências registradas em `PROGRESS.md` (seção "Minors adiados da #39") continuam abertas, usando o smartphone `0087014315`, e registrar o resultado. + +**Architecture:** Este é um plano de **verificação**, não de correção. Os itens 1 e 2 são executáveis agora (prova E2E e recaptura de telas no aparelho). Os itens 3 e 4 são lacunas de projeto: o plano as **confirma com testes/leitura de código** e deixa a correção para um plano próprio (exige decisão de produto). O item 5 é fechado se for trivial. A única mudança de código é parametrizar o aparelho no script de E2E (hoje fixo em `emulator-5554`). + +**Tech Stack:** bash, adb, Flutter (`integration_test`), Serverpod (Dart), Docker Compose, `oathtool`/Dart para TOTP. + +**Spec:** `PROGRESS.md` (seção "Minors adiados da #39 (2026-10-06)" e "Login real do backoffice (issue #39)"); `scripts/qa/admin_login_e2e.sh`; `docs/telas-admin.md`. + +## Global Constraints + +- Dispositivo de teste: serial `0087014315` (Motorola edge 40 neo, Android 15, API 35, 1080x2400, densidade 400), via USB. +- Só dados sintéticos: o script usa o banco `sinalacs_e2e` e apaga banco e manifesto ao final. Nada no banco de desenvolvimento. +- Commits **sem** `Co-Authored-By` nem "Generated with Claude Code" (regra do `CLAUDE.md` do projeto). +- Texto de documentação em português; `PROGRESS.md` e `docs/telas-admin.md` só afirmam o que foi executado. +- `scripts/qa/e2e_stack.sh up` **recria** `sinalacs-serverpod`, `-traefik`, `-mosquitto`, `-gorush-1` da stack de desenvolvimento. O ambiente recusou isso numa tentativa anterior: o usuário deve rodar esses comandos com o prefixo `!`. Ao final, `docker compose up -d` devolve a stack de desenvolvimento. +- Alterar `scripts/qa/*.sh` não pode quebrar `./scripts/qa/ci_invariants.sh` (CI vigia o workflow, não estes scripts, mas rode-o). + +## Review Focus + +- Aparelho físico não alcança `localhost` do host: sem `adb reverse tcp:8443 tcp:443` o app falha com erro de rede, não de login. O script já faz o `reverse`; o teste do Task 1 confere que ele usa o serial escolhido. +- Dois aparelhos conectados (emulador + celular): `adb` sem `-s` falha com "more than one device". Todo `adb`/`flutter -d` do plano usa o serial. +- Tela bloqueada ou "Instalar via USB" negado no Motorola: a instalação do `integration_test` falha silenciosamente longa; o preflight confere. +- Relé de credencial entrega a senha do admin **uma vez**: rodar o script duas vezes sem recriar fixtures dá 404 no relé. +- Densidade/tamanho alterados com `wm` precisam ser revertidos mesmo se a captura falhar (trap). +- O segredo TOTP aparece só na tela de ativação (`Key('mfa_secret')`): perder essa tela perde a conta da sessão manual. + +--- + +## File Structure + +- Modify: `scripts/qa/admin_login_e2e.sh` — aceitar `DEVICE` (serial); padrão continua `emulator-5554`. +- Create: `scripts/qa/admin_login_e2e_test.sh` — teste de shell (sem Docker) da seleção do aparelho. +- Modify: `docs/telas-admin.md`, `docs/screenshots/admin/02..08-*.png` — recaptura (Task 3). +- Modify: `PROGRESS.md` — registrar o resultado de cada item (Task 7). +- Modify (só se o `dart format` apontar): `backend/sinalacs_server/test/integration/staff_login_test.dart`. + +--- + +### Task 1: Parametrizar o aparelho do `admin_login_e2e.sh` + +**Files:** +- Modify: `scripts/qa/admin_login_e2e.sh` (linha `dev=emulator-5554`) +- Create: `scripts/qa/admin_login_e2e_test.sh` + +**Interfaces:** +- Produces: variável de ambiente `DEVICE` (serial do `adb`); sem ela, `emulator-5554`. + +- [ ] **Step 1: Escrever o teste que falha** + +```bash +#!/usr/bin/env bash +# Confere que admin_login_e2e.sh escolhe o aparelho por DEVICE e usa o serial em +# todo adb/flutter. Sem Docker, sem aparelho: lê o texto do script. +set -euo pipefail +cd "$(dirname "$0")/../.." +s=scripts/qa/admin_login_e2e.sh +falhas=0 +conferir() { if ! eval "$2"; then echo "FALHOU: $1" >&2; falhas=$((falhas+1)); fi; } + +conferir 'DEVICE com padrão emulator-5554' "grep -qE '^dev=\"\\$\\{DEVICE:-emulator-5554\\}\"' $s" +conferir 'nenhum serial fixo fora do padrão' "[[ \$(grep -c 'emulator-5554' $s) -le 3 ]]" +conferir 'adb reverse usa \$dev' "grep -q 'adb -s \"\$dev\" reverse tcp:8443 tcp:443' $s" +conferir 'flutter test usa -d \$dev' "grep -q -- '-d \"\$dev\"' $s" +[[ $falhas -eq 0 ]] && echo 'ok' || exit 1 +``` + +Salvar em `scripts/qa/admin_login_e2e_test.sh`, `chmod +x`. + +- [ ] **Step 2: Rodar e ver falhar** + +Run: `bash scripts/qa/admin_login_e2e_test.sh` +Expected: `FALHOU: DEVICE com padrão emulator-5554` (o script tem `dev=emulator-5554`). + +- [ ] **Step 3: Implementar** + +Em `scripts/qa/admin_login_e2e.sh`, trocar `dev=emulator-5554` por: + +```bash +dev="${DEVICE:-emulator-5554}" +``` + +e ajustar a mensagem de erro `emulador $dev não encontrado` para `aparelho $dev não encontrado (adb devices)`. Cabeçalho: acrescentar a linha `# DEVICE=0087014315 ./scripts/qa/admin_login_e2e.sh # aparelho físico`. + +- [ ] **Step 4: Rodar e ver passar** + +Run: `bash scripts/qa/admin_login_e2e_test.sh && bash -n scripts/qa/admin_login_e2e.sh && ./scripts/qa/ci_invariants.sh` +Expected: `ok`, sem erro de sintaxe, invariantes ok. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/qa/admin_login_e2e.sh scripts/qa/admin_login_e2e_test.sh +git commit -m "test(qa): admin_login_e2e.sh aceita o aparelho por DEVICE" +``` + +--- + +### Task 2: Item 1 — rodar a prova E2E do login real no celular + +**Files:** nenhum modificado (gera evidência). + +**Interfaces:** +- Consumes: `DEVICE` do Task 1. +- Produces: saída do script (cole no Task 7). + +- [ ] **Step 1: Preflight do aparelho** + +Run: +```bash +export PATH="$PATH:$HOME/Android/Sdk/platform-tools" +adb -s 0087014315 get-state +adb -s 0087014315 shell dumpsys power | grep -E "mWakefulness=|Display Power" +adb -s 0087014315 shell settings put global stay_on_while_plugged_in 3 +adb -s 0087014315 shell input keyevent KEYCODE_WAKEUP && adb -s 0087014315 shell wm dismiss-keyguard +``` +Expected: `device`; `mWakefulness=Awake`. Se o celular pedir "Permitir instalação via USB", aceitar na tela. + +- [ ] **Step 2: Conferir que nenhuma instância do app de teste ficou instalada e a porta 8765 está livre** + +Run: `adb -s 0087014315 shell pm list packages | grep -i sinalacs; ss -ltn | grep -E ':8765\b' || echo livre` +Expected: sem pacote do admin de teste ou o usuário concorda em reinstalar; `livre`. + +- [ ] **Step 3: Rodar a prova (o usuário executa; recria containers de dev)** + +Pedir ao usuário que rode no prompt: +``` +! DEVICE=0087014315 ./scripts/qa/admin_login_e2e.sh +``` +Expected (resumo): `== login do backoffice no emulador` termina sem erro; linhas `TOTP ativado e passo registrado: true|true` e `tentativas falhas zeradas pelo login: 0`; exit 0. Qualquer outra coisa: usar `superpowers:systematic-debugging` antes de editar; anotar a causa. + +- [ ] **Step 4: Devolver a stack de desenvolvimento** + +Pedir: `! docker compose up -d && docker compose ps` +Expected: serviços `healthy`. + +- [ ] **Step 5: Decidir o estado do item 1** + +Aprovado: item 1 **fechado em aparelho físico (Android 15)**, não no emulador — registrar exatamente isso. Reprovado: item 1 segue aberto, com a causa. + +--- + +### Task 3: Item 2 — recapturar as telas 02–08 com sessão real + +**Files:** +- Modify: `docs/screenshots/admin/02-indicadores.png` … `08-tablet-rail.png` +- Modify: `docs/telas-admin.md` (parágrafo da linha 13 e legendas) + +**Interfaces:** +- Consumes: stack de e2e **no ar** (não rodar o `down` do Task 2 até terminar) e fixtures do admin. O `admin_login_e2e.sh` apaga tudo ao sair; portanto esta tarefa usa a stack manualmente. + +Os fixtures do admin vêm de `./scripts/qa/e2e_stack.sh seed`; a senha é entregue pelo relé em `/admin` uma única vez. + +- [ ] **Step 1: Subir a stack de e2e e semear (o usuário executa)** + +``` +! ./scripts/qa/e2e_stack.sh up && ./scripts/qa/e2e_stack.sh seed +``` +Depois, com o relé (`scripts/qa/otp_relay.py`, `E2E_FIXTURES_FILE=$PWD/.e2e/fixtures.json`) em segundo plano: +```bash +adb -s 0087014315 reverse tcp:8443 tcp:443 +adb -s 0087014315 reverse tcp:8765 tcp:8765 +python3 -c "import json;d=json.load(open('.e2e/fixtures.json'))['staff'];print(d['id'])" +curl -fs http://127.0.0.1:8765/admin # credencial, UMA vez: anotar matrícula e senha +``` +Expected: JSON com matrícula e senha sintéticas. + +- [ ] **Step 2: Instalar e abrir o app admin no celular** + +Run: `cd apps/admin && flutter run -d 0087014315 --dart-define=SINALACS_HOST=https://localhost:8443/` +Expected: tela de login do backoffice. + +- [ ] **Step 3: Ativar a MFA e entrar** + +Na tela, entrar com matrícula/senha → tela de ativação mostra `SelectableText` com a chave (`Key('mfa_secret')`). **Copiar a chave** e gerar o código: +```bash +oathtool --base32 --totp "" # se não houver oathtool: sudo dnf install -y oathtool +``` +Digitar o código; esperar o "Painel de Indicadores". Para os logins seguintes, gerar novo código (passo de 30 s; o servidor recusa reutilizar o mesmo passo). + +- [ ] **Step 4: Capturar** + +```bash +d=0087014315; out=docs/screenshots/admin +adb -s $d exec-out screencap -p > $out/02-indicadores.png # tela Indicadores +# navegar: Microáreas -> 03, Alertas -> 04, Auditoria -> 05 (mesmo comando) +adb -s $d exec-out screencap -p > $out/06-android-retrato.png +# girar: adb -s $d shell settings put system accelerometer_rotation 0; adb -s $d shell settings put system user_rotation 1 +adb -s $d exec-out screencap -p > $out/07-android-paisagem.png +adb -s $d shell settings put system user_rotation 0 +``` +Expected: PNGs com o cabeçalho novo ("Backoffice • Administrador"), sem dados reais (dados do `MockAdminDataSource`, sintéticos). + +- [ ] **Step 5: Captura 08 (tablet com rail) por simulação de tamanho** + +```bash +trap 'adb -s 0087014315 shell wm size reset; adb -s 0087014315 shell wm density reset' EXIT +adb -s 0087014315 shell wm size 1600x2560 && adb -s 0087014315 shell wm density 280 +adb -s 0087014315 exec-out screencap -p > docs/screenshots/admin/08-tablet-rail.png +``` +Expected: layout com navigation rail. Conferir a imagem com Read. Se o app não relayouta sem reiniciar, reabrir o app (nova sessão exige novo código TOTP). + +- [ ] **Step 6: Revisar cada PNG** + +Abrir cada uma das sete com `Read`. Rejeitar captura com notificação do sistema, barra de status com dados pessoais, ou nome de rede. Recapturar se houver. + +- [ ] **Step 7: Atualizar `docs/telas-admin.md`** + +Na linha 3 e no parágrafo da linha 13, trocar a afirmação de "capturas 02 a 08 ainda mostram o texto antigo" por: capturas 02–08 recapturadas em 2026-10-07 num Motorola edge 40 neo (Android 15) com sessão real; 08 com `wm size` 1600x2560 simulado (não é um tablet físico). Ajustar também a menção de "emulator-5554" na linha 3 para dizer aparelho físico. + +- [ ] **Step 8: Derrubar a stack e voltar à de desenvolvimento (o usuário executa)** + +``` +! ./scripts/qa/e2e_stack.sh down && docker compose up -d +``` +Run depois: `adb -s 0087014315 shell wm size; adb -s 0087014315 shell settings get system user_rotation` +Expected: `Physical size: 1080x2400` sem `Override`; rotação 0. + +- [ ] **Step 9: Commit** + +```bash +git add docs/screenshots/admin docs/telas-admin.md +git commit -m "docs(admin): recaptura as telas 02-08 com sessão real em aparelho físico (#39)" +``` + +--- + +### Task 4: Item 3 — confirmar o trust-on-first-use da ativação do TOTP do staff + +**Files:** +- Read: `backend/sinalacs_server/lib/src/application/auth/institutional_auth_service.dart:269-336`, `lib/src/endpoints/auth_endpoint.dart:151-170` +- Test (temporário, não commitado): `/tmp/…/scratchpad/tofu_check_test.dart` ou execução do teste existente + +- [ ] **Step 1: Ler o fluxo** + +Confirmar no código que `beginTotpEnrollment` e `confirmTotpEnrollment` só chamam `_authenticatePassword(matricula, password)` (nenhum fator extra) e que `beginStaffTotpEnrollment` não exige token de sessão (o doc comment diz "Sem token"). + +- [ ] **Step 2: Provar contra Postgres de teste** + +Run: +```bash +cd backend/sinalacs_server && dart test test/integration/staff_login_test.dart -N "ativação" +``` +Expected: os testes de ativação passam com `matricula + password` apenas, o que demonstra que qualquer um que conheça a senha inicial pode registrar o **próprio** autenticador antes do dono. Se `-N` não casar, listar nomes (`grep -n "test(" -A1 test/integration/staff_login_test.dart`) e usar o nome real. + +- [ ] **Step 3: Registrar o desenho de correção (sem implementar)** + +Anotar para o plano de correção, a ser feito com `superpowers:brainstorming` antes de codar (decisão de produto): código de ativação de uso único, gerado fora de banda (CLI do administrador), guardado só como hash em `staff_accounts` com expiração curta, exigido por `beginStaffTotpEnrollment`/`confirmStaffTotpEnrollment` e consumido na confirmação; ativação por quem já tem sessão de administrador (#43) como alternativa. **Bloqueia a #40.** + +- [ ] **Step 4: Estado** + +Item 3 **continua aberto** (esperado). Sem commit neste task. + +--- + +### Task 5: Item 4 — confirmar que o staff não tem refresh token + +**Files:** +- Read: `backend/sinalacs_server/lib/src/endpoints/auth_endpoint.dart:120-150,170-200` + +- [ ] **Step 1: Provar no código e no teste** + +Run: +```bash +grep -n "refreshToken" backend/sinalacs_server/lib/src/endpoints/auth_endpoint.dart | sed -n 1,12p +cd backend/sinalacs_server && dart test test/unit/endpoint_auth_posture_test.dart +``` +Expected: `loginStaff` devolve `DevelopmentLoginResult` sem `refreshToken`; `refreshSession` é só do ACS (token amarrado a aparelho). + +- [ ] **Step 2: Observar no aparelho (opcional, 15 min)** + +Com a sessão do Task 3 aberta, aguardar 15 min e abrir qualquer tela que chame o backend. Expected: a sessão expira e o app volta ao login. Só vale se o `AdminDataSource` real existir; hoje os dados são `MockAdminDataSource` (#41), então **não há chamada autenticada para falhar** — registrar isso em vez de forçar o teste. + +- [ ] **Step 3: Estado** + +Item 4 **continua aberto**. Dependência: o refresh do staff só importa quando a #40/#41 trouxerem chamadas autenticadas; decidir o desenho (cap absoluto, aparelho sem `deviceId` real → hoje sentinela) em plano próprio. + +--- + +### Task 6: Item 5 — minors cosméticos + +**Files:** +- Modify (se necessário): `backend/sinalacs_server/test/integration/staff_login_test.dart` + +- [ ] **Step 1: Medir o ruído de formatação** + +Run: `cd backend/sinalacs_server && dart format --output=none --set-exit-if-changed test/integration/staff_login_test.dart; echo $?` +Expected: `0` (nada a fazer) ou `1` com o arquivo listado. + +- [ ] **Step 2: Se `1`, formatar e rodar o teste** + +Run: `dart format test/integration/staff_login_test.dart && dart test test/integration/staff_login_test.dart` +Expected: formatação aplicada, testes verdes. + +- [ ] **Step 3: Varrer o resto dos "minors" da revisão** + +Run: `grep -n "minor" -i PROGRESS.md | sed -n '/#39/,$p' | head -20`. Só o `dart format` está nomeado; qualquer outro item que não apareça aqui não pode ser afirmado como aberto nem fechado — anotar "sem lista nomeada". + +- [ ] **Step 4: Commit (se houve mudança)** + +```bash +git add backend/sinalacs_server/test/integration/staff_login_test.dart +git commit -m "style(backend): dart format em staff_login_test.dart" +``` + +--- + +### Task 7: Registrar o resultado + +**Files:** +- Modify: `PROGRESS.md` (seção "Minors adiados da #39", subseção "Continua aberto") + +- [ ] **Step 1: Atualizar a seção** + +Mover para "Fechados" o que foi comprovado (itens 1, 2 e 5, se os Tasks 2, 3 e 6 passaram) citando: aparelho (Motorola edge 40 neo, Android 15), data 2026-10-07, linhas de saída do script. Manter em "Continua aberto" os itens 3 e 4 com a nota do Task 4/5. Se o Task 2 falhou, o item 1 permanece aberto com a causa. Ajustar também o parágrafo "Prova no emulador: NÃO EXECUTADA" da seção #39 para refletir o resultado. Não escrever "provado no emulador": foi aparelho físico. + +- [ ] **Step 2: Conferir links e contagens** + +Run: `./scripts/qa/check_documentation_links.sh && ./scripts/qa/ci_invariants.sh` +Expected: ok. + +- [ ] **Step 3: Commit** + +```bash +git add PROGRESS.md +git commit -m "docs: registra a verificação das pendências da #39 em aparelho físico" +``` + +--- + +## Self-Review + +- **Cobertura:** item 1 → Tasks 1–2; item 2 → Task 3; item 3 → Task 4; item 4 → Task 5; item 5 → Task 6; registro → Task 7. +- **Placeholders:** o nome do teste de ativação no Task 4 Step 2 depende do arquivo (há comando para listar). O restante é comando literal. +- **Limite honesto:** itens 3 e 4 são só *verificados*; corrigi-los é escopo de outros planos (precisam de decisão de produto). A captura 08 é simulada por `wm size`, e o plano exige dizer isso na documentação. diff --git a/docs/superpowers/plans/2026-10-08-issue-42-baseline.log b/docs/superpowers/plans/2026-10-08-issue-42-baseline.log new file mode 100644 index 00000000..32523e68 --- /dev/null +++ b/docs/superpowers/plans/2026-10-08-issue-42-baseline.log @@ -0,0 +1,16 @@ +Linha de base da #42 (2026-10-08, branch feat/admin-pedidos-titular-42) + +Passo 1 - nada de atendimento hoje + grep DataSubjectRequestStatus.(completed|rejected) em lib/src (sem generated): vazio + grep dataSubject em endpoints/admin_endpoint.dart: vazio + +Passo 2 - suites + backend: dart analyze -> 55 issues, todas info (0 error, 0 warning), pre-existentes + backend: dart test test/unit -> +499 All tests passed + apps/admin: flutter analyze -> No issues; flutter test -> +126 All tests passed + apps/patient: flutter analyze -> No issues; flutter test -> +255 All tests passed + +Passo 3 - emulador + adb devices: emulator-5554 device + scripts/qa/admin_login_e2e.sh -> +5 All tests passed; OK login real do backoffice + docker compose up -d executado ao final para restaurar a stack de dev diff --git a/docs/superpowers/plans/2026-10-08-issue-42-pedidos-do-titular-admin.md b/docs/superpowers/plans/2026-10-08-issue-42-pedidos-do-titular-admin.md new file mode 100644 index 00000000..a7c68a78 --- /dev/null +++ b/docs/superpowers/plans/2026-10-08-issue-42-pedidos-do-titular-admin.md @@ -0,0 +1,487 @@ +# Issue #42 — admin atende pedidos do titular (LGPD) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fechar a #42: coordenador/administrador do backoffice listam, analisam, atendem ou recusam (com motivo) os pedidos de exclusão e correção do titular, com prazo de 15 dias destacado, auditoria de cada decisão e aviso ao paciente, provado no emulador. + +**Architecture:** Hoje `data_subject_requests` só recebe `open` (escrito por `DataSubjectRightsService`, lado do paciente). Entra um `DataSubjectCaseService` (lado do backoffice) em `application/admin/`, com interface de store à parte do ORM (padrão `AdminReadStore`), exposto por novos métodos em `AdminEndpoint`. A decisão (status + nota + auditoria) e a execução da exclusão (anonimização) acontecem na mesma transação do store. O `apps/admin` ganha o destino "Pedidos do titular" (quinta aba), e o app do paciente passa a mostrar a nota de resposta e o novo status. + +**Tech Stack:** Serverpod 3.4.13 (Dart), Postgres, `HealthDataCipher` (AES-256-GCM), Flutter 3.44.8 (`apps/admin`, `apps/patient`), `sinalacs_client` (regenerado), `integration_test` no `emulator-5554`, bash (`scripts/qa`). + +**Spec:** Issue #42 (`gh issue view 42`); `spec/lgpd_design.md` §5 (linhas 583–597, prazo de 15 dias) e `spec/lgpd_data_audit.md`; `PROGRESS.md` seções "Direitos do titular no app paciente — LGPD-RF05 e LGPD-RF08 (2026-09-28)" e "Endpoints do backoffice… (issue #40)"; `backend/CLAUDE.md`; `apps/CLAUDE.md`; `spec/ux_accessibility_assessment.md` (cor usada como texto). + +## Decisões assumidas (corrija antes de executar, se discordar) + +1. **Quem decide:** `coordinator` (só pedidos de pacientes da própria UBS, via `patients → micro_areas.ubsId`) e `admin` (sistema inteiro). `acs` e `patient` são recusados. Mesmo critério fail-closed de `AdminReadService`. +2. **Exclusão = anonimização, não `DELETE`:** ao atender, o store zera a identidade do titular (`users.name` → rótulo `Titular removido`, `users.cpfHash` → hash aleatório não derivável do CPF, `patients.chronicConditionsEncrypted` → `''`), apaga `push_tokens`, `otp_challenges`, `user_credentials` do titular e as `triage_sessions.answers*`. Linhas de `alerts`, `visits`, `audit_logs` e `consent_logs` ficam (são trilha e estatística pseudonimizada; `spec/lgpd_design.md` 5.7). Isso é uma decisão jurídica: marcada como "a confirmar com o encarregado" no PROGRESS. +3. **Correção:** o texto do pedido é decifrado para o analista; "atender" exige uma nota de resposta e **não** reescreve campo automaticamente (o texto é livre; reescrever por regra seria adivinhar). O analista corrige pelos canais existentes e registra a nota. +4. **Transições permitidas:** `open → inReview → completed|rejected` e `open → completed|rejected`. `completed` e `rejected` são finais. `rejected` exige motivo (3–500 caracteres). +5. **Aviso ao paciente:** a resposta aparece em "Meus dados" (status + nota). Push só para correção/recusa e só se o titular ainda tem consentimento `segmentedPush` e token; para exclusão atendida não há aviso (os tokens foram apagados — é o comportamento correto). + +## Global Constraints + +- Textos de UI, comentários e commits em português, no estilo do código vizinho. +- Nenhum dado real de paciente em teste, log, captura ou seed; fixtures sintéticas. +- `details` do pedido só é decifrado em `getRequest` (detalhe), nunca na lista, e a leitura é auditada **antes** do dado (`record`, não `recordSafely`), como em `AdminReadService`. +- Toda decisão grava `audit_logs` (`actionType: write`, `resourceType: data_subject_request`, `resourceId` = id do pedido, `result` = `in_review|completed|rejected|denied`) **na mesma transação** da mudança de status; sem linha de auditoria, a decisão não persiste. +- Nenhum texto de nota/motivo/`details` em log, auditoria ou mensagem de exceção. +- Teste de e2e usa o banco `sinalacs_e2e` (`e2e_stack.sh`), nunca o de desenvolvimento. +- Cor como texto/ícone: token `*OnSurface` e teste em `contrast_tokens_test.dart` (ver `apps/CLAUDE.md`). +- Commits sem "Co-Authored-By"/"Generated with" (regra do repositório prevalece sobre o lembrete de atribuição). +- Dispositivo: `emulator-5554` (único conectado em 2026-10-08). +- Migração nova é aditiva (colunas anuláveis + valor de enum); nada de `DROP`. + +## Review Focus + +- Dois analistas decidem o mesmo pedido ao mesmo tempo: esperado, uma decisão vence, a outra recebe erro de transição inválida (lock por pedido), sem dupla anonimização nem duas linhas de auditoria de sucesso. +- Coordenador tenta abrir pedido de paciente de outra UBS (ou paciente sem microárea): esperado, recusa `denied` auditada, sem vazar se o pedido existe. +- Pedido vence exatamente em `dueAt` / já está `completed` depois do prazo: esperado, vencido só se `status` em (`open`,`inReview`) e `now > dueAt`; atendido fora do prazo não fica destacado. +- Exclusão atendida de titular que já não tem `patients` (só `users`), ou com dois pedidos abertos antigos: esperado, não quebra; o segundo pedido de exclusão aberto é fechado como `completed` junto. +- Titular anonimizado tenta entrar de novo (OTP): esperado, recusa genérica, não recria conta nem vaza que foi excluído. +- Nota/motivo com 501 caracteres, só espaços, ou `