From 45ae6672f491fe80a7459b61ac3aa1312692641c Mon Sep 17 00:00:00 2001 From: vinimartinsufrr Date: Wed, 16 Sep 2026 09:23:36 -0400 Subject: [PATCH 001/498] feat(theme): suporte a tema claro/escuro/automatico nos apps ACS e Paciente Implementa a issue #9: ThemeMode (claro/escuro/automatico) persistido via shared_preferences, tela de Preferencias acessivel pela aba Mais, e paleta clara testada contra WCAG 2.2 AA nos dois apps. As cores clinicas de RiskLevel mantem o mesmo preenchimento nos dois temas; a variante usada como texto/icone passou a ser um ThemeExtension (AcsRiskColors / PatientRiskColors), ja que a cor certa depende do tema ativo em tempo de execucao. Co-Authored-By: Claude Sonnet 5 --- apps/acs/.flutter-plugins-dependencies | 2 +- apps/acs/lib/app/acs_theme.dart | 173 +++++++++++++---- apps/acs/lib/app/app.dart | 174 ++++++++++++------ .../lib/core/services/theme_controller.dart | 51 +++++ apps/acs/pubspec.lock | 56 ++++++ apps/acs/pubspec.yaml | 3 + apps/acs/test/contrast_tokens_light_test.dart | 57 ++++++ apps/acs/test/contrast_tokens_test.dart | 36 ++-- apps/patient/.flutter-plugins-dependencies | 2 +- apps/patient/lib/app/app.dart | 113 +++++++++--- apps/patient/lib/app/patient_theme.dart | 163 +++++++++++++--- .../lib/core/services/theme_controller.dart | 51 +++++ apps/patient/pubspec.lock | 84 ++++++++- apps/patient/pubspec.yaml | 3 + .../test/contrast_tokens_light_test.dart | 52 ++++++ apps/patient/test/contrast_tokens_test.dart | 26 +-- 16 files changed, 869 insertions(+), 177 deletions(-) create mode 100644 apps/acs/lib/core/services/theme_controller.dart create mode 100644 apps/acs/test/contrast_tokens_light_test.dart create mode 100644 apps/patient/lib/core/services/theme_controller.dart create mode 100644 apps/patient/test/contrast_tokens_light_test.dart diff --git a/apps/acs/.flutter-plugins-dependencies b/apps/acs/.flutter-plugins-dependencies index 45ffef89..ccbe13e5 100644 --- a/apps/acs/.flutter-plugins-dependencies +++ b/apps/acs/.flutter-plugins-dependencies @@ -1 +1 @@ -{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"connectivity_plus","path":"/home/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 +{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"connectivity_plus","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\connectivity_plus-7.3.1\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_secure_storage-9.2.4\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"geolocator_apple","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\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":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\google_maps_flutter_ios-2.18.6\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"integration_test","path":"C:\\\\src\\\\flutter\\\\packages\\\\integration_test\\\\","native_build":true,"dependencies":[],"dev_dependency":true},{"name":"path_provider_foundation","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_foundation-2.5.1\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_foundation","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_foundation-2.5.7\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\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":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_sqlcipher-3.2.0\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"connectivity_plus","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\connectivity_plus-7.3.1\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_plugin_android_lifecycle","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_plugin_android_lifecycle-2.0.35\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_secure_storage-9.2.4\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"geolocator_android","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\geolocator_android-4.6.2\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"google_maps_flutter_android","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\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":"C:\\\\src\\\\flutter\\\\packages\\\\integration_test\\\\","native_build":true,"dependencies":[],"dev_dependency":true},{"name":"jni","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\jni-1.0.3\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni_flutter","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\jni_flutter-1.0.3\\\\","native_build":true,"dependencies":["jni"],"dev_dependency":false},{"name":"path_provider_android","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_android-2.3.1\\\\","native_build":false,"dependencies":["jni","jni_flutter"],"dev_dependency":false},{"name":"shared_preferences_android","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_android-2.4.28\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_android","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_android-2.4.0\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_sqlcipher","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_sqlcipher-3.2.0\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"connectivity_plus","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\connectivity_plus-7.3.1\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_macos","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_secure_storage_macos-3.1.3\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"geolocator_apple","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\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":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_foundation-2.5.1\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_foundation","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_foundation-2.5.7\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\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":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_sqlcipher-3.2.0\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"connectivity_plus","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\connectivity_plus-7.3.1\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_linux","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_secure_storage_linux-1.2.3\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\jni-1.0.3\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_linux","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_linux-2.2.2\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_linux","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_linux-2.4.1\\\\","native_build":false,"dependencies":["path_provider_linux"],"dev_dependency":false}],"windows":[{"name":"connectivity_plus","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\connectivity_plus-7.3.1\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_windows","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_secure_storage_windows-3.1.2\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"geolocator_windows","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\geolocator_windows-0.2.5\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\jni-1.0.3\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_windows","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_windows-2.3.0\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_windows","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_windows-2.4.1\\\\","native_build":false,"dependencies":["path_provider_windows"],"dev_dependency":false}],"web":[{"name":"connectivity_plus","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\connectivity_plus-7.3.1\\\\","dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_web","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_secure_storage_web-1.2.1\\\\","dependencies":[],"dev_dependency":false},{"name":"geolocator_web","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\geolocator_web-4.1.4\\\\","dependencies":[],"dev_dependency":false},{"name":"google_maps_flutter_web","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\google_maps_flutter_web-0.6.3+1\\\\","dependencies":[],"dev_dependency":false},{"name":"shared_preferences_web","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_web-2.4.3\\\\","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":"shared_preferences","dependencies":["shared_preferences_android","shared_preferences_foundation","shared_preferences_linux","shared_preferences_web","shared_preferences_windows"]},{"name":"shared_preferences_android","dependencies":[]},{"name":"shared_preferences_foundation","dependencies":[]},{"name":"shared_preferences_linux","dependencies":["path_provider_linux"]},{"name":"shared_preferences_web","dependencies":[]},{"name":"shared_preferences_windows","dependencies":["path_provider_windows"]},{"name":"sqflite","dependencies":["sqflite_android","sqflite_darwin"]},{"name":"sqflite_android","dependencies":[]},{"name":"sqflite_darwin","dependencies":[]},{"name":"sqflite_sqlcipher","dependencies":[]}],"date_created":"2026-09-16 09:13:19.254755","version":"3.44.8","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file 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 7a089bf8..4c7c4ed0 100644 --- a/apps/acs/lib/app/app.dart +++ b/apps/acs/lib/app/app.dart @@ -13,6 +13,7 @@ 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_queue_factory.dart'; class SinalAcsApp extends StatefulWidget { @@ -23,6 +24,7 @@ class SinalAcsApp extends StatefulWidget { this.visitQueue, this.initialAlert, this.currentPosition, + this.themeController, }); /// Injetáveis para teste. Em execução normal são as implementações reais. @@ -31,6 +33,7 @@ class SinalAcsApp extends StatefulWidget { final OfflineVisitQueue? visitQueue; final PrioritizedAlert? initialAlert; final LatLng? currentPosition; + final ThemeController? themeController; @override State createState() => _SinalAcsAppState(); @@ -54,24 +57,39 @@ class _SinalAcsAppState extends State { /// estava confirmado nunca era apagado do disco. OfflineVisitQueue _persistentQueue() => buildVisitQueue(backend: _backend); + late final ThemeController _themeController = widget.themeController ?? ThemeController(); + + @override + void initState() { + super.initState(); + _themeController.restore(); + } + @override void dispose() { if (widget.backend == null) _backend.close(); + if (widget.themeController == null) _themeController.dispose(); super.dispose(); } @override Widget build(BuildContext context) => BackendScope( backend: _backend, - child: MaterialApp( - title: 'SinalACS ACS', - debugShowCheckedModeBanner: false, - theme: buildAcsTheme(), - home: LoginScreen( - feedBuilder: widget.feedBuilder, - visitQueue: _visitQueue, - initialAlert: widget.initialAlert, - initialPosition: widget.currentPosition, + child: ValueListenableBuilder( + valueListenable: _themeController, + builder: (context, mode, _) => MaterialApp( + title: 'SinalACS ACS', + debugShowCheckedModeBanner: false, + theme: buildAcsLightTheme(), + darkTheme: buildAcsDarkTheme(), + themeMode: mode, + home: LoginScreen( + feedBuilder: widget.feedBuilder, + visitQueue: _visitQueue, + initialAlert: widget.initialAlert, + initialPosition: widget.currentPosition, + themeController: _themeController, + ), ), ), ); @@ -80,6 +98,7 @@ class _SinalAcsAppState extends State { class LoginScreen extends StatefulWidget { const LoginScreen({ required this.visitQueue, + required this.themeController, super.key, this.feedBuilder, this.initialAlert, @@ -90,6 +109,7 @@ class LoginScreen extends StatefulWidget { final OfflineVisitQueue visitQueue; final PrioritizedAlert? initialAlert; final LatLng? initialPosition; + final ThemeController themeController; @override State createState() => _LoginScreenState(); @@ -132,6 +152,7 @@ class _LoginScreenState extends State { visitQueue: widget.visitQueue, initialAlert: widget.initialAlert, initialPosition: widget.initialPosition, + themeController: widget.themeController, ), )); } on BackendFailure catch (failure) { @@ -169,7 +190,7 @@ class _LoginScreenState extends State { 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)), ), ), ]))), @@ -178,7 +199,7 @@ class _LoginScreenState extends State { ); } -enum AcsDestination { area, queue, map, visit, escalation, geofencing, notices } +enum AcsDestination { area, queue, map, visit, escalation, geofencing, notices, settings } /// Aviso de infraestrutura: o que quebrou e a consequência prática. /// @@ -192,6 +213,7 @@ class AcsHomeShell extends StatefulWidget { required this.microAreaId, required this.acsId, required this.visitQueue, + required this.themeController, super.key, this.feedBuilder, this.initialAlert, @@ -203,6 +225,7 @@ class AcsHomeShell extends StatefulWidget { final AlertFeed Function(AlertQueue queue)? feedBuilder; final PrioritizedAlert? initialAlert; final LatLng? initialPosition; + final ThemeController themeController; /// Obrigatória: a tela de visita usava `widget.visitQueue ?? OfflineVisitQueue()`, /// e um dia em que o shell fosse construído sem fila voltaria a descartar a @@ -483,6 +506,7 @@ class _AcsHomeShellState extends State with WidgetsBindingObserver }), ), AcsDestination.notices => const NoticesScreen(), + AcsDestination.settings => ThemeSettingsScreen(controller: widget.themeController), }), bottomNavigationBar: NavigationBar( selectedIndex: destination.index <= 3 ? destination.index : 4, @@ -494,6 +518,7 @@ class _AcsHomeShellState extends State with WidgetsBindingObserver _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.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); }); } @@ -610,7 +635,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( @@ -637,18 +662,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( @@ -676,12 +704,12 @@ class _AlertCard extends StatelessWidget { Text(label, 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)), ]), ), ]), @@ -826,9 +854,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( @@ -860,13 +888,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), ), ], ], @@ -913,7 +941,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), @@ -972,12 +1000,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, + }; + } String _riskLabel(String riskLevel) => switch (riskLevel.toLowerCase()) { 'red' || 'vermelho' => 'Vermelho', @@ -1017,7 +1048,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( @@ -1244,7 +1275,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( @@ -1264,7 +1295,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), ), ), ), @@ -1275,14 +1306,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), ), ), ]; @@ -1337,7 +1368,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( @@ -1353,11 +1384,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), ), ), ), @@ -1391,9 +1422,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), @@ -1408,7 +1439,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) ...[ @@ -1422,7 +1453,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), ), ), ), @@ -1491,9 +1522,9 @@ class EscalationScreen extends StatelessWidget { 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), ), ], ]); @@ -1550,7 +1581,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, ), ), @@ -1565,6 +1596,31 @@ class GeofencingScreen extends StatelessWidget { }, ); } +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, _) => _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)'))), + ]), + ), + ]), + ); +} 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'))]); } @@ -1598,7 +1654,7 @@ class _Header extends StatelessWidget implements PreferredSizeWidget { @override Widget build(BuildContext context) => AppBar( title: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(eyebrow.toUpperCase(), style: const TextStyle(fontSize: 10, color: AcsColors.accentOnSurface, fontWeight: FontWeight.bold)), + Text(eyebrow.toUpperCase(), style: TextStyle(fontSize: 10, color: context.acsRisk.accentOnSurface, fontWeight: FontWeight.bold)), Text(title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), ]), actions: [ @@ -1609,7 +1665,7 @@ class _Header extends StatelessWidget implements PreferredSizeWidget { avatar: connected == null ? null : Icon( connected! ? Icons.cloud_done_outlined : Icons.cloud_off_outlined, size: 18, - color: connected! ? AcsColors.green : AcsColors.red, + color: connected! ? context.acsRisk.greenOnSurface : context.acsRisk.redOnSurface, ), label: Text(switch (connected) { null => 'Offline ready', 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/pubspec.lock b/apps/acs/pubspec.lock index 0f1089bb..4488322e 100644 --- a/apps/acs/pubspec.lock +++ b/apps/acs/pubspec.lock @@ -591,6 +591,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: diff --git a/apps/acs/pubspec.yaml b/apps/acs/pubspec.yaml index fed18778..a480d871 100644 --- a/apps/acs/pubspec.yaml +++ b/apps/acs/pubspec.yaml @@ -30,6 +30,9 @@ dependencies: mqtt_client: ^10.0.0 geolocator: ^12.0.0 google_maps_flutter: ^2.10.1 + # 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 dev_dependencies: 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/patient/.flutter-plugins-dependencies b/apps/patient/.flutter-plugins-dependencies index fd6a93f8..53917f0a 100644 --- a/apps/patient/.flutter-plugins-dependencies +++ b/apps/patient/.flutter-plugins-dependencies @@ -1 +1 @@ -{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"connectivity_plus","path":"/home/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 +{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"connectivity_plus","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\connectivity_plus-7.3.1\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_local_notifications","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_local_notifications-17.2.4\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"geolocator_apple","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\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":"C:\\\\src\\\\flutter\\\\packages\\\\integration_test\\\\","native_build":true,"dependencies":[],"dev_dependency":true},{"name":"shared_preferences_foundation","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_foundation-2.5.7\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\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":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_sqlcipher-3.2.0\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"connectivity_plus","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\connectivity_plus-7.3.1\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_local_notifications","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_local_notifications-17.2.4\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"geolocator_android","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\geolocator_android-4.6.2\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"integration_test","path":"C:\\\\src\\\\flutter\\\\packages\\\\integration_test\\\\","native_build":true,"dependencies":[],"dev_dependency":true},{"name":"shared_preferences_android","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_android-2.4.28\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_android","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_android-2.4.0\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_sqlcipher","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_sqlcipher-3.2.0\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"connectivity_plus","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\connectivity_plus-7.3.1\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_local_notifications","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_local_notifications-17.2.4\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"geolocator_apple","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\geolocator_apple-2.3.14\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_foundation","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_foundation-2.5.7\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\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":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_sqlcipher-3.2.0\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"connectivity_plus","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\connectivity_plus-7.3.1\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"flutter_local_notifications_linux","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_local_notifications_linux-4.0.1\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"path_provider_linux","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_linux-2.2.2\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_linux","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_linux-2.4.1\\\\","native_build":false,"dependencies":["path_provider_linux"],"dev_dependency":false}],"windows":[{"name":"connectivity_plus","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\connectivity_plus-7.3.1\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"geolocator_windows","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\geolocator_windows-0.2.5\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_windows","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_windows-2.3.0\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_windows","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_windows-2.4.1\\\\","native_build":false,"dependencies":["path_provider_windows"],"dev_dependency":false}],"web":[{"name":"connectivity_plus","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\connectivity_plus-7.3.1\\\\","dependencies":[],"dev_dependency":false},{"name":"geolocator_web","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\geolocator_web-4.1.4\\\\","dependencies":[],"dev_dependency":false},{"name":"shared_preferences_web","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_web-2.4.3\\\\","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":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"shared_preferences","dependencies":["shared_preferences_android","shared_preferences_foundation","shared_preferences_linux","shared_preferences_web","shared_preferences_windows"]},{"name":"shared_preferences_android","dependencies":[]},{"name":"shared_preferences_foundation","dependencies":[]},{"name":"shared_preferences_linux","dependencies":["path_provider_linux"]},{"name":"shared_preferences_web","dependencies":[]},{"name":"shared_preferences_windows","dependencies":["path_provider_windows"]},{"name":"sqflite","dependencies":["sqflite_android","sqflite_darwin"]},{"name":"sqflite_android","dependencies":[]},{"name":"sqflite_darwin","dependencies":[]},{"name":"sqflite_sqlcipher","dependencies":[]}],"date_created":"2026-09-16 09:14:43.014473","version":"3.44.8","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file diff --git a/apps/patient/lib/app/app.dart b/apps/patient/lib/app/app.dart index fdfcc8c2..c607cd3b 100644 --- a/apps/patient/lib/app/app.dart +++ b/apps/patient/lib/app/app.dart @@ -5,12 +5,14 @@ import 'package:sinalacs_patient/core/network/backend_client.dart'; import 'package:sinalacs_patient/core/network/backend_scope.dart'; import 'package:sinalacs_patient/core/network/idempotency.dart'; import 'package:sinalacs_patient/core/privacy/location_hash.dart'; +import 'package:sinalacs_patient/core/services/theme_controller.dart'; class SinalAcsApp extends StatefulWidget { - const SinalAcsApp({super.key, this.backend}); + const SinalAcsApp({super.key, this.backend, this.themeController}); - /// Injetável para teste. Em execução normal é o [BackendClient] real. + /// Injetáveis para teste. Em execução normal são as implementações reais. final PatientBackend? backend; + final ThemeController? themeController; @override State createState() => _SinalAcsAppState(); @@ -18,11 +20,19 @@ class SinalAcsApp extends StatefulWidget { class _SinalAcsAppState extends State { late final PatientBackend _backend = widget.backend ?? BackendClient(); + late final ThemeController _themeController = widget.themeController ?? ThemeController(); + + @override + void initState() { + super.initState(); + _themeController.restore(); + } @override void dispose() { // Só fecha o que este widget criou; um backend injetado é de quem injetou. if (widget.backend == null) _backend.close(); + if (widget.themeController == null) _themeController.dispose(); super.dispose(); } @@ -30,18 +40,25 @@ class _SinalAcsAppState extends State { Widget build(BuildContext context) { return BackendScope( backend: _backend, - child: MaterialApp( - title: 'SinalACS Paciente', - debugShowCheckedModeBanner: false, - theme: buildPatientTheme(), - home: const PatientLoginScreen(), + child: ValueListenableBuilder( + valueListenable: _themeController, + builder: (context, mode, _) => MaterialApp( + title: 'SinalACS Paciente', + debugShowCheckedModeBanner: false, + theme: buildPatientLightTheme(), + darkTheme: buildPatientDarkTheme(), + themeMode: mode, + home: PatientLoginScreen(themeController: _themeController), + ), ), ); } } class PatientLoginScreen extends StatefulWidget { - const PatientLoginScreen({super.key}); + const PatientLoginScreen({required this.themeController, super.key}); + + final ThemeController themeController; @override State createState() => _PatientLoginScreenState(); @@ -65,8 +82,9 @@ class _PatientLoginScreenState extends State { if (!mounted) return; Navigator.of(context).pushReplacement( MaterialPageRoute( - builder: (_) => const PatientHomeShell( + builder: (_) => PatientHomeShell( initialDestination: PatientDestination.triage, + themeController: widget.themeController, ), ), ); @@ -108,10 +126,10 @@ class _PatientLoginScreenState extends State { const SizedBox(height: 8), const Text('Acesso sem senha', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), const SizedBox(height: 8), - const Text( + Text( 'Use CPF e data de nascimento para receber o código de acesso.', textAlign: TextAlign.center, - style: TextStyle(color: Colors.white70), + style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), ), const SizedBox(height: 24), const TextField( @@ -156,8 +174,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, ), ), @@ -185,12 +203,17 @@ class _PatientLoginScreenState extends State { } } -enum PatientDestination { emergency, triage, questions, profile, status, reminders } +enum PatientDestination { emergency, triage, questions, profile, status, reminders, 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(); @@ -216,6 +239,7 @@ class _PatientHomeShellState extends State { PatientDestination.profile => const ClinicalProfileScreen(), PatientDestination.status => const StatusScreen(), PatientDestination.reminders => const RemindersScreen(), + PatientDestination.settings => ThemeSettingsScreen(controller: widget.themeController), }; final title = switch (_destination) { PatientDestination.emergency => 'Alerta de urgência', @@ -224,6 +248,7 @@ class _PatientHomeShellState extends State { PatientDestination.profile => 'Perfil clínico', PatientDestination.status => 'Acompanhamento', PatientDestination.reminders => 'Lembretes', + PatientDestination.settings => 'Preferências', }; return Scaffold( @@ -359,7 +384,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)), ), ]))), ], @@ -461,7 +486,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), @@ -508,7 +533,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), ), ), ), @@ -530,24 +555,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.', ), }; @@ -570,10 +596,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), ), ], ), @@ -707,7 +733,7 @@ class StatusScreen extends StatelessWidget { } } -class _StatusStep extends StatelessWidget { const _StatusStep(this.label, this.done); final String label; final bool done; @override Widget build(BuildContext context) => Column(children: [Icon(done ? Icons.check_circle : Icons.calendar_today_outlined, color: done ? PatientColors.accent : Colors.white54), const SizedBox(height: 6), SizedBox(width: 65, child: Text(label, textAlign: TextAlign.center, style: const TextStyle(fontSize: 11)))]); } +class _StatusStep extends StatelessWidget { const _StatusStep(this.label, this.done); final String label; final bool done; @override Widget build(BuildContext context) => Column(children: [Icon(done ? Icons.check_circle : Icons.calendar_today_outlined, color: done ? context.patientRisk.accentOnSurface : Theme.of(context).colorScheme.onSurfaceVariant), const SizedBox(height: 6), SizedBox(width: 65, child: Text(label, textAlign: TextAlign.center, style: const TextStyle(fontSize: 11)))]); } class RemindersScreen extends StatefulWidget { const RemindersScreen({super.key}); @override State createState() => _RemindersScreenState(); } class _RemindersScreenState extends State { @@ -726,14 +752,43 @@ class _RemindersScreenState extends State { } } +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.chat_bubble_outline), title: const Text('Dúvidas'), onTap: () { Navigator.pop(sheetContext); select(PatientDestination.questions); }), 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); })]))); + showModalBottomSheet(context: context, builder: (sheetContext) => SafeArea(child: Column(mainAxisSize: MainAxisSize.min, children: [ListTile(leading: const Icon(Icons.chat_bubble_outline), title: const Text('Dúvidas'), onTap: () { Navigator.pop(sheetContext); select(PatientDestination.questions); }), 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.tune_outlined), title: const Text('Preferências'), onTap: () { Navigator.pop(sheetContext); select(PatientDestination.settings); })]))); } void _showPrototypeMessage(BuildContext context, String message) => ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); \ No newline at end of file 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/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/pubspec.lock b/apps/patient/pubspec.lock index 0ec166c6..9890a59d 100644 --- a/apps/patient/pubspec.lock +++ b/apps/patient/pubspec.lock @@ -311,6 +311,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: @@ -375,6 +399,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: @@ -612,5 +692,5 @@ packages: source: hosted version: "3.1.4" sdks: - dart: ">=3.10.0-0 <4.0.0" - flutter: ">=3.24.0" + dart: ">=3.12.0 <4.0.0" + flutter: ">=3.44.0" diff --git a/apps/patient/pubspec.yaml b/apps/patient/pubspec.yaml index f17ed02d..13151465 100644 --- a/apps/patient/pubspec.yaml +++ b/apps/patient/pubspec.yaml @@ -25,6 +25,9 @@ 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 dev_dependencies: flutter_test: 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..5dd8b609 --- /dev/null +++ b/apps/patient/test/contrast_tokens_light_test.dart @@ -0,0 +1,52 @@ +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; + + 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), + // 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..784aed96 100644 --- a/apps/patient/test/contrast_tokens_test.dart +++ b/apps/patient/test/contrast_tokens_test.dart @@ -6,22 +6,24 @@ 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), + 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), // 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 +44,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)); }); } From 6593b7d70206fb8a6f81c07f4a7f1d93e5c9a914 Mon Sep 17 00:00:00 2001 From: sarah Date: Wed, 23 Sep 2026 12:29:01 -0400 Subject: [PATCH 002/498] docs: execute ux/ui test plan and record product decisions --- spec/ui_design.md | 8 ++++++++ spec/ux_ui_test_plan.md | 22 ++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/spec/ui_design.md b/spec/ui_design.md index 7da7e58b..5ca1ee21 100644 --- a/spec/ui_design.md +++ b/spec/ui_design.md @@ -7,3 +7,11 @@ Focado em baixíssima fricção, contraste elevado e clareza na urgência. O "Bo # 🩺 App do ACS (Fila Dinâmica e Offline-first) O design restringe completamente as cores de emergência (Vermelho, Amarelo, Verde) ao ranqueamento clínico determinístico. O container é um pouco mais largo (max-w-2xl) para acomodar dados de triagem mantendo a estrutura UI de aplicativo. + +## Atualizações de Design e Decisões de Produto (Pós-Auditoria de UX) + +* **Containers e Largura (max-w-2xl) (§1.1 e §3.4):** Fica decidido que o uso do `ConstrainedBox` com `Center` aplica-se **exclusivamente à tela de Login**. Telas pós-login, incluindo o formulário de Visita do ACS, não possuem restrição de largura máxima, estendendo-se de borda a borda. A menção ao `max-w-2xl` neste documento é revogada para as telas pós-login mobile. +* **Dark Mode (§1.2):** Fica documentado explicitamente que a definição `brightness: Brightness.dark` nos temas é **fixa e permanente**. O app não reage às preferências de modo claro/escuro do Sistema Operacional. +* **Microinterações (§1.4):** Registra-se a equivalência intencional: o efeito de *ripple* nativo do Material 3 é o equivalente adotado no Flutter para a microinteração descrita nos protótipos como `active:scale`. +* **Exceção de Cor (§3.1):** O uso da cor verde (`AcsColors.green`) no botão/texto de "Local alcançado" (geofencing) é uma **exceção documentada** à restrição clínica. O verde atua aqui como status operacional universal ("liberado") e não entra em conflito cognitivo com o risco clínico. +* **Fricção do Fluxo de Emergência (§2.1):** Define-se como limiar quantitativo no PRD: o fluxo de acionamento do Botão de Emergência do Paciente deve custar **no máximo 4 toques** e durar **no máximo 10 segundos** em condições normais de rede. diff --git a/spec/ux_ui_test_plan.md b/spec/ux_ui_test_plan.md index eee363e9..aa38b91e 100644 --- a/spec/ux_ui_test_plan.md +++ b/spec/ux_ui_test_plan.md @@ -160,3 +160,25 @@ Complementa (não duplica) o teste funcional de `offline_visit_queue.dart` já c - **UXtweak** (já planejado em `spec/ux_accessibility_assessment.md` §4): estender o Cenário 2 (priorização na fila) para cobrir também o item 3.3 (entendimento do estado offline), em vez de abrir uma frente de pesquisa nova. + +--- +## Execução e Resolução (Atualizado pós-auditoria) + +**Decisão Geral:** Todos os achados foram formalizados através de atualizações explícitas nas regras do arquivo `spec/ui_design.md`, sem necessidade de regressão no código nativo. + +* **[Resolvido] §1.1 e §3.4 (Container Responsivo):** Decisão de Produto (A) acatada. Confirmado que a restrição de container aplica-se apenas ao Login. Telas pós-login mantêm comportamento expansível nativo (borda a borda). Regra atualizada em `ui_design.md`. +* **[Resolvido] §1.2 (Dark Mode):** Documentado formalmente como fixo/permanente no documento de design. +* **[Resolvido] §1.3 (Foco Suave):** Inspeção visual validada: o anel M3 padrão apresenta constraste funcional sobre o `surfaceRaised` sem necessidade de overrides. +* **[Resolvido] §1.4 (Microinterações):** *Ripple* formalizado como equivalência intencional ao `active:scale`. +* **[Resolvido] §2.1 (Fricção de Emergência):** Limiar formalizado de "≤ 4 toques e ≤ 10s" no design. +* **[Resolvido] §2.3 (Hierarquia do Botão de Emergência):** Validado estaticamente. Dimensão de 208x208dp domina o viewport mobile, sem concorrência de tamanho/saturação. +* **[Resolvido] §3.1 (Cor Restrita):** Exceção aceitável do geofencing (status geográfico) devidamente documentada no design. +* **[Resolvido] §3.2 (Reordenação da Fila):** Rebuild nativo da lista atende à necessidade inicial. Implementação de `AnimatedList` é classificada como melhoria futura não-bloqueante. +* **[Resolvido] §3.3 (Compreensão do Estado Offline):** Estendido via roteiro remoto no UXtweak (vide abaixo). + +### Cenário 3 - UXtweak: Compreensão do Estado Offline (§3.3) +*(Adendo ao Planejamento de Testes de Usabilidade Remota - spec/ux_accessibility_assessment.md §4)* + +* **Objetivo:** Avaliar a legibilidade narrativa da tela de Visita e se o ACS compreende intuitivamente que o registro foi salvo localmente mas carece de sincronização (`pending_visits_count` e banner de erro de storage). +* **Métrica:** Taxa de compreensão correta (Sim/Não) e contagem de cliques no botão `sync_visits`. +* **Instrução dada ao usuário:** *"Você preencheu e salvou os dados da triagem em um local sem sinal de internet. Volte para a tela inicial do aplicativo e verifique o status dessa visita. Ela foi enviada para o posto de saúde, perdida ou está salva no seu aparelho aguardando conexão?"* From 74d89c09abcfece901a9c9218cfcb310b3436f99 Mon Sep 17 00:00:00 2001 From: Herbert Date: Fri, 25 Sep 2026 19:46:48 +0000 Subject: [PATCH 003/498] perf(ci): android-e2e com KVM, caches e smoke de um teste por app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O job levava de 22 a 60+ min: boot do emulador sem KVM (~12 min), Gradle sem cache (311 s), um build + adb install por arquivo de integration_test e retry da suíte inteira em qualquer falha. - habilita KVM via regra do udev; cache de Flutter, pub, Gradle, CMake e snapshot do AVD; build da stack e pub get antes do emulador - e2e.sh --emulator roda só integration_test/smoke_test.dart (paciente e ACS); a bateria completa fica em --full - retry do flutter test só quando a saída tem "Broken pipe" - GOOGLE_MAPS_API_KEY deixa de ser obrigatória no run_android_e2e.sh Co-Authored-By: Claude Opus 5.5 --- .claude/skills/validacao-e2e/SKILL.md | 7 +- .github/workflows/ci.yml | 59 +++++- apps/CLAUDE.md | 2 +- apps/acs/integration_test/smoke_test.dart | 171 ++++++++++++++++++ apps/patient/integration_test/smoke_test.dart | 85 +++++++++ scripts/qa/e2e.sh | 65 +++++-- scripts/qa/run_android_e2e.sh | 6 +- 7 files changed, 374 insertions(+), 21 deletions(-) create mode 100644 apps/acs/integration_test/smoke_test.dart create mode 100644 apps/patient/integration_test/smoke_test.dart diff --git a/.claude/skills/validacao-e2e/SKILL.md b/.claude/skills/validacao-e2e/SKILL.md index 4f33d472..f58d5393 100644 --- a/.claude/skills/validacao-e2e/SKILL.md +++ b/.claude/skills/validacao-e2e/SKILL.md @@ -10,12 +10,15 @@ description: Como validar a conexão real dos apps Flutter com o backend do Sina ```bash ./scripts/qa/e2e.sh # sobe a stack, valida na VM, derruba ./scripts/qa/e2e.sh --keep # mantém a stack de pé -./scripts/qa/e2e.sh --emulator # inclui integration_test em um emulador já aberto +./scripts/qa/e2e.sh --emulator # + smoke (integration_test/smoke_test.dart) num emulador já aberto +./scripts/qa/e2e.sh --emulator --full # + a bateria integration_test inteira dos três apps ``` The script brings up Docker Compose, waits for the healthcheck, applies the seed, runs `scripts/dev/sync_dev_ca.sh` (copies the **two** development CAs — the broker's and the RPC's — into `apps/acs/assets/certs/` and `apps/patient/assets/certs/`, which are gitignored and regenerated), runs `scripts/qa/tls_invariants.sh` (the four groups that measure RNF04/L-08 on the live stack: HTTPS with the dev CA, TLS 1.3 only, no cleartext path to the RPC, and the script-side https host rule), and then runs each app's `tool/live_check.dart`. Those scripts run on the plain Dart VM — no emulator — using the apps' own network code: the patient one covers health/login/triage/alert/idempotency, and the ACS one covers the full cycle including the MQTT/TLS subscription and `visits.sync`. Both tools read the RPC CA from `infra/docker/traefik/runtime/certs/ca.crt` themselves, and neither takes an https host for granted — a missing CA stops the leg with the path in the message instead of reporting "the backend did not answer". -`integration_test/` in each app holds the on-device version, excluded from `flutter test` by construction. Run it against an already-running emulator with `--dart-define=SINALACS_HOST=https://10.0.2.2/` (and, for the ACS, `SINALACS_MQTT_HOST=10.0.2.2` plus `SINALACS_MQTT_PASSWORD="$MQTT_ACS_PASSWORD"` — without the password the suite fails in `setUpAll` with the command you need). `scripts/qa/e2e.sh --emulator` already passes all three. CI has eight jobs; seven of them never need a live backend, and the eighth — `android-e2e` — is the one that does: it brings the whole stack up inside the runner (`bootstrap_env.sh`, then `e2e.sh --emulator --keep`) before running `measure_latency.dart`. Don't write "the CI never needs a backend": that job exists precisely to exercise the real path, which is also why `tls_invariants.sh` runs from inside `e2e.sh` rather than only by hand. +`integration_test/` in each app holds the on-device version, excluded from `flutter test` by construction. Run it against an already-running emulator with `--dart-define=SINALACS_HOST=https://10.0.2.2/` (and, for the ACS, `SINALACS_MQTT_HOST=10.0.2.2` plus `SINALACS_MQTT_PASSWORD="$MQTT_ACS_PASSWORD"` — without the password the suite fails in `setUpAll` with the command you need). `scripts/qa/e2e.sh --emulator` already passes all three. + +`--emulator` alone runs only `integration_test/smoke_test.dart` in the patient and ACS apps — one test each, chaining what only the device proves (patient: health → login → red triage → idempotent alert over HTTPS with the asset CA; ACS: login → red alert via MQTT/TLS + ACK → visit unreadable in the real SQLCipher file → sync removes it from disk). Every file under `integration_test/` costs a build plus an `adb install`, which dominated the CI job's time, so the rest of the suite (`backend_connection_test.dart`, `red_alert_cycle_test.dart`, `encrypted_storage_test.dart`, `map_flow_test.dart` and the admin's hermetic smoke) runs only with `--full`. Run `--full` by hand before touching network, local crypto, map or admin layout on device — CI no longer covers wrong-key / v1-migration / lost-key SQLCipher cases, the in-app micro-area rejection, the ACK of a missing alert, visit resend idempotency, or the map flow. Retries of `flutter test` happen only when the output contains `Broken pipe` (the known transient `adb install` failure); a real red test fails at once. CI has eight jobs; seven of them never need a live backend, and the eighth — `android-e2e` — is the one that does: it brings the whole stack up inside the runner (`bootstrap_env.sh`, then `e2e.sh --emulator --keep` — smoke only, KVM enabled via udev rule, AVD snapshot and pub/Gradle caches) before running `measure_latency.dart`. Don't write "the CI never needs a backend": that job exists precisely to exercise the real path, which is also why `tls_invariants.sh` runs from inside `e2e.sh` rather than only by hand. The RPC is **HTTPS on 443** (RNF04/L-08): Traefik terminates TLS with a development certificate, and publishing 8080 in cleartext was REMOVED, not restricted. Anything that still points at `http://…:8080/` does not connect. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e07f764..308f9d46 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -183,6 +183,17 @@ jobs: GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }} steps: - uses: actions/checkout@v4 + # 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"' \ + | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm - uses: actions/setup-java@v4 with: distribution: temurin @@ -190,19 +201,65 @@ jobs: - uses: subosito/flutter-action@v2 with: flutter-version: '3.44.8' + cache: true + # O 1º `assembleDebug` levava 311 s sem cache, 24 deles instalando o + # CMake do NDK. Chave pelos pubspec.lock e scripts Gradle dos dois apps + # que rodam no emulador — mesmo padrão do job admin-android-build. + - uses: actions/cache@v4 + with: + path: | + ~/.pub-cache + ~/.gradle/caches + ~/.gradle/wrapper + /usr/local/lib/android/sdk/cmake + key: e2e-deps-${{ hashFiles('apps/patient/pubspec.lock', 'apps/acs/pubspec.lock', 'apps/patient/android/**/*.gradle.kts', 'apps/acs/android/**/*.gradle.kts', 'apps/*/android/gradle/wrapper/gradle-wrapper.properties') }} + restore-keys: e2e-deps- + # Snapshot do AVD já inicializado: sem ele, todo run paga o primeiro + # boot a frio. Os parâmetros do emulador precisam ser os mesmos aqui e + # no step que roda os testes, ou o snapshot é descartado. + - uses: actions/cache@v4 + id: avd-cache + with: + path: | + ~/.android/avd/* + ~/.android/adb* + key: avd-36-x86_64-pixel_7 + - 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 + arch: x86_64 + profile: pixel_7 + force-avd-creation: false + disable-animations: false + emulator-options: -no-window -no-audio -gpu swiftshader_indirect -no-boot-anim + script: echo "snapshot do AVD gerado para o cache" - name: Bootstrap da stack local run: ./scripts/dev/bootstrap_env.sh + # O que não depende do emulador sai de dentro do step dele: o + # `docker compose up --build` e os `flutter pub get` do e2e.sh viram + # cache hit (os `pub get` sequenciais levavam ~4 min lá dentro). + - name: Pré-build da stack e dependências dos apps + run: | + # `wait` sem argumento sempre sai 0; esperar cada PID propaga a falha. + docker compose build & p1=$! + (cd apps/patient && flutter pub get) & p2=$! + (cd apps/acs && flutter pub get) & p3=$! + wait $p1 && wait $p2 && wait $p3 - name: E2E no emulador Android uses: reactivecircus/android-emulator-runner@v2 with: api-level: 36 arch: x86_64 profile: pixel_7 + force-avd-creation: false disable-animations: true - emulator-options: -no-window -no-audio -gpu swiftshader_indirect -no-snapshot -no-boot-anim + emulator-options: -no-window -no-audio -gpu swiftshader_indirect -no-snapshot-save -no-boot-anim # Uma linha só: a action roda cada linha de "script" como um `sh -c` # separado, então lógica multi-linha (if/fi, set -a + source .env) # precisa viver num script de verdade — ver run_android_e2e.sh. + # Roda só o smoke de cada app (e2e.sh --emulator, sem --full). script: ./scripts/qa/run_android_e2e.sh - uses: actions/upload-artifact@v4 if: always() diff --git a/apps/CLAUDE.md b/apps/CLAUDE.md index 8d64c289..4ca7340a 100644 --- a/apps/CLAUDE.md +++ b/apps/CLAUDE.md @@ -21,7 +21,7 @@ For the ACS app, run `./scripts/dev/run_acs.sh` (or `--build` for the APK) inste 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. -- **Encryption is only actually proven on a device**: `apps/acs/integration_test/encrypted_storage_test.dart` reads the raw database file and asserts it neither starts with `SQLite format 3` nor contains the visit content. `flutter test` cannot prove this — on Linux everything goes through the unencrypted FFI path, which is the trap that produced the original gap. +- **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`). Topic namespace must agree in three places: the server's `AlertDelivery.topicPrefix`, the broker ACL (`infra/docker/mosquitto/aclfile`), and `alertTopicFor()` in `mqtt_secure_client.dart` — all `sinalacs/v1/microareas//alerts`, where `` is the seed UUID, not `area-12`. diff --git a/apps/acs/integration_test/smoke_test.dart b/apps/acs/integration_test/smoke_test.dart new file mode 100644 index 00000000..0300aa27 --- /dev/null +++ b/apps/acs/integration_test/smoke_test.dart @@ -0,0 +1,171 @@ +/// Smoke do app do ACS no dispositivo — o único arquivo que a CI roda. +/// +/// Um teste só, de propósito: cada arquivo de `integration_test/` custa um +/// build e um `adb install` do APK no emulador da CI, e é isso que domina o +/// tempo do job `android-e2e`. Aqui fica encadeado o que só o aparelho prova: +/// +/// 1. o ACS autentica e recebe a própria microárea; +/// 2. o alerta vermelho publicado pelo backend chega pelo MQTT com TLS (CA do +/// asset) e é confirmado — alerta vermelho nunca some em silêncio; +/// 3. a visita gravada no SQLCipher real não aparece legível no arquivo do +/// banco (INV-04); +/// 4. a fila offline sincroniza contra o servidor e a visita confirmada sai +/// do disco (minimização, LGPD-RF07). +/// +/// O resto da bateria — chave errada, migração v1, chave perdida, fila +/// recusando outra microárea, ACK inexistente, reenvio idempotente, mapa — +/// continua em `red_alert_cycle_test.dart`, `encrypted_storage_test.dart` e +/// `map_flow_test.dart`, rodados por `./scripts/qa/e2e.sh --emulator --full`. +/// +/// Mesmos pré-requisitos e `--dart-define` de `red_alert_cycle_test.dart`. +/// +/// PRIVACIDADE: só os UUIDs sintéticos do seed e um marcador sintético. +@Timeout(Duration(minutes: 2)) +library; + +import 'dart:async'; +import 'dart:io'; + +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/core/database/encrypted_database.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/network/backend_config.dart'; +import 'package:sinalacs_acs/core/security/database_key_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/backend_visit_synchronizer.dart'; +import 'package:sinalacs_acs/core/services/offline_visit_queue.dart'; +import 'package:sinalacs_client/sinalacs_client.dart' as api; + +const seedMicroAreaId = '00000000-0000-4000-8000-000000000003'; +const seedPatientId = '00000000-0000-4000-8000-000000000001'; + +/// Marcador sintético que precisa NÃO aparecer no arquivo do banco. +const marcador = 'MARCADOR-SINTETICO-NAO-DEVE-VAZAR'; + +const databaseName = 'sinalacs_smoke_probe.db'; + +/// Mesma leitura de `red_alert_cycle_test.dart`. A CA do broker é outra, e o +/// `MqttAlertFeed` a carrega sozinho. +Future?> _devRpcCaBytes() async { + try { + final data = await rootBundle.load(BackendConfig.rpcCaAsset); + return data.buffer.asUint8List(); + } catch (_) { + return null; + } +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + test('smoke: alerta pelo broker, visita cifrada no disco e sincronizada', () async { + if (BackendConfig.mqttPasswordMissing) { + fail( + 'Compile com --dart-define=SINALACS_MQTT_PASSWORD (o valor está em ' + 'MQTT_ACS_PASSWORD no .env). O caminho pronto é ' + './scripts/qa/e2e.sh --emulator.', + ); + } + 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); + // O paciente entra em cena só para dar ao ACS o que receber. + final patientClient = api.Client( + const String.fromEnvironment('SINALACS_HOST', defaultValue: 'https://10.0.2.2/'), + securityContext: SecurityContext()..setTrustedCertificatesBytes(caBytes), + )..connectivityMonitor = null; + addTearDown(patientClient.close); + + // 1. Login e microárea. + final session = await backend.developmentLogin(role: 'acs'); + expect(session.role, 'acs'); + expect(session.microAreaId, seedMicroAreaId); + final microAreaId = session.microAreaId!; + + // 2. Alerta vermelho pelo broker, com ACK. + final alertQueue = AlertQueue(microAreaId: microAreaId); + final feed = MqttAlertFeed(queue: alertQueue); + await feed.start(microAreaId: microAreaId, acsId: session.userId); + expect(feed.isConnected, isTrue, reason: 'o ACS não conectou ao broker'); + + try { + final login = await patientClient.auth.developmentLogin(role: 'patient'); + final created = await patientClient.alerts.createRedAlert( + accessToken: login.accessToken, + idempotencyKey: 'smoke-${DateTime.now().microsecondsSinceEpoch}', + locationHash: 'sem-local-00', + ); + + // Espera o alerta ESPECÍFICO: o broker reentrega backlog de execuções + // anteriores, e a fila ordena por antiguidade. + PrioritizedAlert? alert; + final deadline = DateTime.now().add(const Duration(seconds: 20)); + while (alert == null && DateTime.now().isBefore(deadline)) { + for (final candidate in alertQueue.alerts) { + if (candidate.alertId == created.alertId) alert = candidate; + } + if (alert == null) await Future.delayed(const Duration(milliseconds: 200)); + } + if (alert == null) { + throw TimeoutException('o alerta ${created.alertId} não chegou pelo broker'); + } + expect(alert.riskLevel, 'red'); + expect(alert.microAreaId, microAreaId); + + final ack = await backend.acknowledge(alertId: alert.alertId); + expect(ack.acknowledged, isTrue); + expect(ack.status, api.AlertStatus.acknowledged); + } finally { + feed.stop(); + alertQueue.dispose(); + } + + // 3. Visita gravada no SQLCipher real, ilegível no arquivo. + await EncryptedLocalDatabase.deleteDatabaseFile(databaseName); + addTearDown(() => EncryptedLocalDatabase.deleteDatabaseFile(databaseName)); + final store = SqlCipherVisitStore( + keyStore: InMemoryDatabaseKeyStore(), + databaseName: databaseName, + ); + final visitQueue = OfflineVisitQueue( + store: store, + synchronizer: BackendVisitSynchronizer(backend: backend), + ); + await visitQueue.add(OfflineVisitRecord( + patientId: seedPatientId, + risk: 'red', + status: 'PENDENTE', + outcome: 'realizada', + notes: marcador, + )); + // Fechar garante que tudo foi para o disco; o store reabre sozinho depois. + await store.close(); + + final bytes = await File(await EncryptedLocalDatabase.pathFor(databaseName)).readAsBytes(); + expect(String.fromCharCodes(bytes.take(15)), isNot('SQLite format 3'), + reason: 'o banco está em texto plano — viola o INV-04 do PRD'); + final conteudo = String.fromCharCodes(bytes); + expect(conteudo.contains(marcador), isFalse, + reason: 'o conteúdo da visita aparece legível no arquivo do banco'); + expect(conteudo.contains(seedPatientId), isFalse, + reason: 'o identificador do paciente aparece legível no arquivo do banco'); + + // 4. Sincroniza e a visita confirmada sai do aparelho. + final result = await visitQueue.sync(); + expect(result.kind, SyncOutcomeKind.synced, reason: result.message); + expect(visitQueue.pendingCount, 0); + expect(await store.load(), isEmpty, reason: 'a visita continua no aparelho'); + await store.close(); + }); +} diff --git a/apps/patient/integration_test/smoke_test.dart b/apps/patient/integration_test/smoke_test.dart new file mode 100644 index 00000000..edee8c85 --- /dev/null +++ b/apps/patient/integration_test/smoke_test.dart @@ -0,0 +1,85 @@ +/// Smoke do app do paciente no dispositivo — o único arquivo que a CI roda. +/// +/// Um teste só, de propósito: cada arquivo de `integration_test/` custa um +/// build e um `adb install` do APK no emulador da CI, e é isso que domina o +/// tempo do job `android-e2e`, não o teste em si. Aqui fica apenas o que só o +/// aparelho prova — o RPC por HTTPS saindo do app, com a CA de desenvolvimento +/// vinda do asset — encadeado no caminho que importa: saúde → login → triagem +/// vermelha → alerta criado e reenvio idempotente. +/// +/// O resto da bateria continua em `backend_connection_test.dart`, rodado por +/// `./scripts/qa/e2e.sh --emulator --full`; determinismo e idempotência também +/// são cobertos pelo `dart test` do backend, e o hash de localização por +/// `test/location_hash_test.dart`. +/// +/// Mesmos pré-requisitos e `--dart-define` de `backend_connection_test.dart`. +/// +/// PRIVACIDADE: só os UUIDs sintéticos do seed; o token nunca é impresso. +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'; +import 'package:sinalacs_patient/core/network/backend_client.dart'; +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'; + +/// Mesma leitura de `backend_connection_test.dart`: sem a CA no bundle o +/// handshake falha, e o sintoma culparia a rede. +Future?> _devRpcCaBytes() async { + try { + final data = await rootBundle.load(BackendConfig.rpcCaAsset); + return data.buffer.asUint8List(); + } catch (_) { + return null; + } +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + test('smoke: do app ao alerta vermelho, por HTTPS com a CA do asset', () 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); + + final health = await backend.health(); + expect(health.status, 'ok'); + expect(health.dbConnected, isTrue, + reason: 'sem banco, createRedAlert falha por chave estrangeira'); + + final session = await backend.developmentLogin(role: 'patient'); + expect(session.role, 'patient'); + expect(session.microAreaId, seedMicroAreaId); + + final risk = await backend.evaluateTriage( + chestPain: true, + difficultyBreathing: false, + fever: false, + persistentVomiting: false, + bleeding: false, + severeWeakness: false, + ); + expect(risk, RiskLevel.red); + + final key = newIdempotencyKey(); + final hash = locationHashFrom(-23.55052, -46.633308); + final first = await backend.createRedAlert(idempotencyKey: key, locationHash: hash); + final retry = await backend.createRedAlert(idempotencyKey: key, locationHash: hash); + + expect(first.alertId, isNotEmpty); + expect(first.status, AlertStatus.pending); + // Retry da MESMA tentativa: um segundo alerta seria um chamado duplicado. + expect(retry.alertId, first.alertId); + }); +} diff --git a/scripts/qa/e2e.sh b/scripts/qa/e2e.sh index 4740f2ff..4558c8ad 100755 --- a/scripts/qa/e2e.sh +++ b/scripts/qa/e2e.sh @@ -4,7 +4,14 @@ # # ./scripts/qa/e2e.sh # sobe a stack, valida na VM, derruba # ./scripts/qa/e2e.sh --keep # mantém a stack de pé ao final -# ./scripts/qa/e2e.sh --emulator # inclui os testes de integração no emulador +# ./scripts/qa/e2e.sh --emulator # + smoke no emulador (integration_test/smoke_test.dart) +# ./scripts/qa/e2e.sh --emulator --full # + TODA a bateria integration_test dos três apps +# +# --emulator sozinho é o que a CI roda: um arquivo, um teste e um APK por app +# (paciente e ACS). Cada arquivo de integration_test/ custa um build e um +# `adb install`, que é o que domina o tempo no emulador; o resto da bateria +# fica para --full, rodado à mão antes de mexer em rede, criptografia local ou +# mapa. # # Sem --emulator, roda as verificações que não precisam de dispositivo # (tool/live_check.dart dos dois apps), que já exercitam o RPC sobre TLS, o MQTT @@ -16,13 +23,19 @@ cd "$repo_root" keep_stack=0 run_emulator=0 +run_full=0 for arg in "$@"; do case "$arg" in --keep) keep_stack=1 ;; --emulator) run_emulator=1 ;; + --full) run_full=1 ;; *) echo "argumento desconhecido: $arg" >&2; exit 2 ;; esac done +if [[ "$run_full" -eq 1 && "$run_emulator" -eq 0 ]]; then + echo 'erro: --full só faz sentido junto com --emulator.' >&2 + exit 2 +fi cleanup() { if [[ "$keep_stack" -eq 0 ]]; then @@ -95,28 +108,47 @@ echo '== ACS: ciclo completo (RPC + MQTT/TLS + sincronização de visita) ==' dart run tool/live_check.dart --mqtt-password "$MQTT_ACS_PASSWORD") # `adb install` falha de forma transiente e conhecida em emuladores sem -# aceleração de hardware (o caso deste runner — medido, o boot já loga "Linux -# VM where hardware acceleration is not available") com "cmd: Failure calling -# service package: Broken pipe (32)" — a instalação do APK cai no meio, sem -# relação com rede/certificado; uma segunda tentativa costuma passar. Só o -# `flutter test integration_test` (que reinstala o APK) é envolvido — o -# `docker compose`/seed acima não tem esse modo de falha. +# aceleração de hardware (medido no runner da CI antes de o job habilitar o +# KVM: o boot logava "Linux VM where hardware acceleration is not available") +# com "cmd: Failure calling service package: Broken pipe (32)" — a instalação +# do APK cai no meio, sem relação com rede/certificado; uma segunda tentativa +# costuma passar. Só o `flutter test integration_test` (que reinstala o APK) é +# envolvido — o `docker compose`/seed acima não tem esse modo de falha. +# +# A nova tentativa acontece SÓ quando a saída tem "Broken pipe". Antes ela +# disparava em qualquer falha: um teste realmente vermelho rodava a suíte +# inteira duas vezes, e foi o que empurrou o job para além dos 60 min. tentar_flutter_test() { - if "$@"; then + local saida + saida="$(mktemp)" + if "$@" 2>&1 | tee "$saida"; then + rm -f "$saida" return 0 fi - echo 'aviso: flutter test integration_test falhou (possível "Broken pipe" do' >&2 - echo 'adb install em emulador sem aceleração de hardware); tentando de novo.' >&2 + if ! grep -q 'Broken pipe' "$saida"; then + rm -f "$saida" + return 1 + fi + rm -f "$saida" + echo 'aviso: "Broken pipe" no adb install; tentando de novo.' >&2 sleep 5 "$@" } if [[ "$run_emulator" -eq 1 ]]; then echo - echo '== testes de integração no dispositivo ==' + # Smoke: um arquivo por app, que é um build e um `adb install` por app. + # --full: a pasta inteira, como era antes, mais o admin (hermético, roda + # sobre o MockAdminDataSource e não precisa da stack). + if [[ "$run_full" -eq 1 ]]; then + alvo_integracao=integration_test + echo '== testes de integração no dispositivo (bateria completa) ==' + else + alvo_integracao=integration_test/smoke_test.dart + echo '== smoke de integração no dispositivo ==' + fi (cd apps/patient && flutter pub get >/dev/null) (cd apps/acs && flutter pub get >/dev/null) - (cd apps/admin && flutter pub get >/dev/null) # Medido no runner da CI: o app falha ao falar com o RPC via 10.0.2.2:443 — # "Não foi possível falar com o servidor (-1)" (ServerpodClientException com # statusCode -1, o wrap genérico do cliente gerado para quando a conexão TCP @@ -145,9 +177,9 @@ if [[ "$run_emulator" -eq 1 ]]; then # privilegiada, então mantém a mesma porta dos dois lados. adb -s emulator-5554 reverse tcp:8443 tcp:443 adb -s emulator-5554 reverse tcp:8883 tcp:8883 - (cd apps/patient && tentar_flutter_test flutter test integration_test -d emulator-5554 \ + (cd apps/patient && tentar_flutter_test flutter test "$alvo_integracao" -d emulator-5554 \ --dart-define=SINALACS_HOST=https://localhost:8443/) - acs_cmd=(flutter test integration_test -d emulator-5554 + acs_cmd=(flutter test "$alvo_integracao" -d emulator-5554 --dart-define=SINALACS_HOST=https://localhost:8443/ --dart-define=SINALACS_MQTT_HOST=localhost --dart-define=SINALACS_MQTT_PASSWORD="$MQTT_ACS_PASSWORD") @@ -155,7 +187,10 @@ if [[ "$run_emulator" -eq 1 ]]; then acs_cmd+=(--dart-define=GOOGLE_MAPS_API_KEY="$GOOGLE_MAPS_API_KEY") fi (cd apps/acs && tentar_flutter_test "${acs_cmd[@]}") - (cd apps/admin && tentar_flutter_test flutter test integration_test -d emulator-5554) + if [[ "$run_full" -eq 1 ]]; then + (cd apps/admin && flutter pub get >/dev/null && \ + tentar_flutter_test flutter test integration_test -d emulator-5554) + fi fi echo diff --git a/scripts/qa/run_android_e2e.sh b/scripts/qa/run_android_e2e.sh index 8e7227d3..597891aa 100755 --- a/scripts/qa/run_android_e2e.sh +++ b/scripts/qa/run_android_e2e.sh @@ -14,9 +14,11 @@ cd "$repo_root" trap 'docker compose down' EXIT +# GOOGLE_MAPS_API_KEY não é mais obrigatória: a CI roda só o smoke de cada app +# (e2e.sh --emulator, sem --full), e o map_flow_test.dart ficou fora dele. Se +# vier no ambiente, o e2e.sh ainda a repassa ao build do ACS. if [[ -z "${GOOGLE_MAPS_API_KEY:-}" ]]; then - echo 'erro: GOOGLE_MAPS_API_KEY é obrigatória para o E2E Android.' >&2 - exit 1 + echo 'aviso: GOOGLE_MAPS_API_KEY ausente — o mapa do ACS fica sem tiles.' >&2 fi set -a From 9860c1b2e5496979ba8f0784ccc43d8b0162b28d Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Mon, 28 Sep 2026 09:42:48 -0400 Subject: [PATCH 004/498] docs(ci-audit): inventariar jobs do CI e comparar com AGENTS.md Co-Authored-By: Claude Sonnet 5 --- .../2026-09-28-avaliacao-ci-develop.md | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 docs/ci-audit/2026-09-28-avaliacao-ci-develop.md diff --git a/docs/ci-audit/2026-09-28-avaliacao-ci-develop.md b/docs/ci-audit/2026-09-28-avaliacao-ci-develop.md new file mode 100644 index 00000000..9b769312 --- /dev/null +++ b/docs/ci-audit/2026-09-28-avaliacao-ci-develop.md @@ -0,0 +1,24 @@ +# Avaliação do CI adotado no branch develop + +Data da avaliação: 2026-09-28 +Workflow avaliado: `.github/workflows/ci.yml` (branch `develop`, HEAD em `74d89c0`) +Repositório: `hbgit/SinalACS` + +## 1. Inventário do workflow + +| Job | Working dir | Runner | Ações principais | Observação | +|---|---|---|---|---| +| `serverpod-backend` | `backend` | ubuntu-latest | dart-lang/setup-dart@v1 (Dart 3.12.2) | Postgres efêmero na porta 9090; gera `config/passwords.yaml` inline | +| `backend-docker-build` | raiz do workspace | ubuntu-latest | `docker build` | Contexto na raiz porque o Dockerfile precisa do `pubspec.lock` compartilhado | +| `patient-app` | `apps/patient` | ubuntu-latest | subosito/flutter-action@v2 (Flutter 3.44.8) | Cria `assets/certs/` vazio antes do `flutter analyze`/`flutter test` | +| `acs-app` | `apps/acs` | ubuntu-latest | subosito/flutter-action@v2 (Flutter 3.44.8) | Mesmo motivo do `patient-app` | +| `admin-app` | `apps/admin` | ubuntu-latest | subosito/flutter-action@v2 (Flutter 3.44.8) | Sem o passo de `assets/certs/` — admin não declara esse asset | +| `coverage-report` | raiz | ubuntu-latest | dart-lang/setup-dart@v1 + subosito/flutter-action@v2 | Sobe `docker compose --profile test up -d postgres-test`; roda `scripts/qa/measure_coverage.sh`; upload de artifact `coverage-reports` | +| `android-e2e` | raiz | ubuntu-latest | actions/setup-java@v4, subosito/flutter-action@v2, reactivecircus/android-emulator-runner@v2 | `timeout-minutes: 60`; único job com emulador Android; usa `secrets.GOOGLE_MAPS_API_KEY` | +| `admin-android-build` | `apps/admin` | ubuntu-latest | actions/setup-java@v4, subosito/flutter-action@v2 | Único job que roda Gradle de verdade (`flutter build apk --debug`) | + +**FINDING-1 (severidade: baixa — documentação desatualizada).** `AGENTS.md:138` descreve o CI como "seis jobs separados", listando `serverpod-backend`, `backend-docker-build`, `patient-app`, `acs-app`, `admin-app` e `admin-android-build`. O workflow atual tem **8 jobs**: os seis citados mais `coverage-report` (adicionado em `c27ffe3`) e `android-e2e` (adicionado depois, hoje o job mais instável — ver FINDING-4). `AGENTS.md` nunca foi atualizado quando esses dois jobs entraram. Recomendação: atualizar a linha 138 para listar os 8 jobs, ou trocar a enumeração por uma referência ao arquivo (`ver .github/workflows/ci.yml`) para não repetir o mesmo tipo de drift. + +**FINDING-2 (severidade: informativa).** `push` só dispara CI em `main`/`master` — um push direto em `develop` (sem PR aberto) **não** roda o workflow. A cobertura de `develop` hoje depende inteiramente de haver um `pull_request` aberto tendo `develop` como head ou base. Na prática há sempre uma PR de integração `develop → main` aberta (ver Seção 2), então o branch é coberto — mas isso é um efeito colateral do fluxo de trabalho atual, não uma garantia do workflow. Se alguém commitar direto em `develop` sem PR, o CI simplesmente não roda para aquele commit. + +actionlint: sem achados (2026-09-28) From 57f52ae10d5e6896919a8f6ede55afe41b95ab6e Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Mon, 28 Sep 2026 09:47:00 -0400 Subject: [PATCH 005/498] docs(ci-audit): registrar saude historica das execucoes em develop Co-Authored-By: Claude Sonnet 5 --- .../2026-09-28-avaliacao-ci-develop.md | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) 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 9b769312..e93c44d9 100644 --- a/docs/ci-audit/2026-09-28-avaliacao-ci-develop.md +++ b/docs/ci-audit/2026-09-28-avaliacao-ci-develop.md @@ -22,3 +22,47 @@ Repositório: `hbgit/SinalACS` **FINDING-2 (severidade: informativa).** `push` só dispara CI em `main`/`master` — um push direto em `develop` (sem PR aberto) **não** roda o workflow. A cobertura de `develop` hoje depende inteiramente de haver um `pull_request` aberto tendo `develop` como head ou base. Na prática há sempre uma PR de integração `develop → main` aberta (ver Seção 2), então o branch é coberto — mas isso é um efeito colateral do fluxo de trabalho atual, não uma garantia do workflow. Se alguém commitar direto em `develop` sem PR, o CI simplesmente não roda para aquele commit. actionlint: sem achados (2026-09-28) + +## 2. Saúde histórica das execuções (develop) + +Comando executado em 2026-09-28 (mesma data da Seção 1, mas em nova chamada — reconfirmado, não reaproveitado): + +```bash +gh run list --branch develop --limit 15 --json databaseId,status,conclusion,createdAt,event,headBranch,displayTitle +``` + +Resultado: 15 execuções, todas do evento `pull_request` contra a PR #7 (`develop → main`, "docs: add lgpd_data_audit.md ..."), cobrindo 2026-09-15T14:41:50Z a 2026-09-25T17:52:44Z: +- 1 `cancelled` (a mais recente, `36169873626`, criada 2026-09-25T17:52:44Z) +- 14 `failure` +- 0 `success` + +Esses números **batem exatamente** com o snapshot capturado no brief em 2026-09-28 — não houve drift. Nenhum novo run apareceu contra `develop` apesar dos commits `74d89c0` e `2eae254` terem sido pushados depois do run mais recente (`36169873626`, 17:52:44Z); isso é consistente com o FINDING-2 (Seção 1): sem um evento de PR novo, esses commits não disparam CI em `develop`. + +Job que falhou em cada run, para uma amostra de 7 runs cobrindo do início ao fim do intervalo (comando `gh run view --json jobs -q '.jobs[] | select(.conclusion!="success") | "\(.name): \(.conclusion) (\(.startedAt) -> \(.completedAt))"'`, reexecutado e confirmado idêntico ao do brief para as 6 amostras originais; a 7ª, o run cancelado, foi acrescentada por esta execução para fechar a lacuna): + +``` +34983384439 (2026-09-15): acs-app: failure +34990215500 (2026-09-15): acs-app: failure +35107542667 (2026-09-16): acs-app: failure +35619580425 (2026-09-21): android-e2e: failure +35867558228 (2026-09-23): android-e2e: failure +36143685083 (2026-09-25): acs-app: failure, android-e2e: failure +36169873626 (2026-09-25, mais recente): android-e2e: cancelled (60m01s, 17:52:48Z -> 18:53:00Z — bate com o timeout de 60 min do job) +``` + +Log da falha de `acs-app` no run `36143685083` (`gh run view --log-failed --job=108099411186`), idêntico ao trecho capturado no brief: + +``` +info • The import of 'package:test_api/scaffolding.dart' is unnecessary because all of the used elements are also provided by the import of 'package:flutter_test/flutter_test.dart' • integration_test/red_alert_cycle_test.dart:63:8 • unnecessary_import +info • The imported package 'test_api' isn't a dependency of the importing package • integration_test/red_alert_cycle_test.dart:63:8 • depend_on_referenced_packages +2 issues found. (ran in 9.1s) +##[error]Process completed with exit code 1. +``` + +Confirmação adicional (além do que o brief já constatava): o commit `9f21b9d` (2026-09-25T17:22:56Z) remove exatamente esse import em `apps/acs/integration_test/red_alert_cycle_test.dart`, e é anterior ao run cancelado `36169873626` (criado 2026-09-25T17:52:44Z). Nesse run mais recente, `gh run view 36169873626 --json jobs` mostra **todos os outros 7 jobs com `success`, incluindo `acs-app`** — só `android-e2e` foi `cancelled` por timeout. Ou seja, a causa 1 abaixo está confirmada como corrigida na prática (não só pela leitura do diff), e a única falha que sobrevive na execução mais recente observada é o timeout do `android-e2e`. + +**FINDING-3 (severidade: alta — CI vermelho não é exceção, é a norma).** As últimas 15 execuções do CI contra a PR de integração `develop → main` (PR #7), cobrindo 10 dias corridos (2026-09-15 a 2026-09-25), tiveram **0 sucessos**: 14 falhas e 1 cancelamento por timeout. Duas causas se alternam: +1. `acs-app` falhando em `flutter analyze` por lints `info` em `integration_test/red_alert_cycle_test.dart` (corrigido em `9f21b9d`; confirmado nesta reexecução que o run seguinte a esse commit, `36169873626`, já passa `acs-app` com sucesso). +2. `android-e2e` falhando ou estourando o timeout de 60 minutos (recorrente em 21, 23 e 25/09, inclusive no run mais recente observado; ver FINDING-4 para o estado após a última tentativa de correção em `74d89c0`). + +Conclusão: durante toda a janela observada, não houve um único run totalmente verde da PR que leva `develop` para `main`. Isso é consistente com a ausência de branch protection (FINDING-5): nada no GitHub bloqueou o trabalho de continuar avançando apesar do CI vermelho, então o sinal vermelho não gerou pressão para corrigir a causa raiz — apenas se acumulou. From bca4ec6d087275d34c6aa6e744a40fd1ea5c1e60 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Mon, 28 Sep 2026 09:57:22 -0400 Subject: [PATCH 006/498] docs(ci-audit): confirmar estado atual do job android-e2e na HEAD Co-Authored-By: Claude Sonnet 5 --- .../2026-09-28-avaliacao-ci-develop.md | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) 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 e93c44d9..3c610760 100644 --- a/docs/ci-audit/2026-09-28-avaliacao-ci-develop.md +++ b/docs/ci-audit/2026-09-28-avaliacao-ci-develop.md @@ -66,3 +66,84 @@ Confirmação adicional (além do que o brief já constatava): o commit `9f21b9d 2. `android-e2e` falhando ou estourando o timeout de 60 minutos (recorrente em 21, 23 e 25/09, inclusive no run mais recente observado; ver FINDING-4 para o estado após a última tentativa de correção em `74d89c0`). Conclusão: durante toda a janela observada, não houve um único run totalmente verde da PR que leva `develop` para `main`. Isso é consistente com a ausência de branch protection (FINDING-5): nada no GitHub bloqueou o trabalho de continuar avançando apesar do CI vermelho, então o sinal vermelho não gerou pressão para corrigir a causa raiz — apenas se acumulou. + +## 3. Estado atual da HEAD + +Comandos executados em 2026-09-28 (nova chamada, para esta seção): + +```bash +git log -1 --format='%H %ci' HEAD +gh run list --limit 10 --json databaseId,status,conclusion,createdAt,headBranch,event,headSha +``` + +`HEAD` local é `9ed4f92` (2026-09-28 09:47:00 -0400) — **dois commits à frente** do snapshot do brief (`74d89c0`, 2026-09-25 19:46:48 +0000): `2eae254` e `9ed4f92`, que são exatamente os commits das Tasks 1 e 2 deste próprio plano (só tocam `docs/ci-audit/2026-09-28-avaliacao-ci-develop.md`, `git show --stat` confirma). `git status --short --branch` mostra `develop...origin/develop [ahead 2]` — esses dois commits ainda não foram enviados ao remoto; `origin/develop` continua em `74d89c0`. + +Nenhuma execução do CI cobre `74d89c0`, `2eae254` ou `9ed4f92`. Confirmado por busca direta na API, não só pela lista dos últimos 10/15 runs: + +```bash +gh api "repos/hbgit/SinalACS/actions/runs?per_page=100" \ + --jq '.workflow_runs[] | select(.head_sha | startswith("74d89c0") or startswith("2eae254") or startswith("9ed4f92"))' +``` + +Retorna vazio para os três SHAs. Isso já era previsível pela FINDING-2 (Seção 1) e pelo texto da Seção 2 ("nenhum novo run apareceu contra `develop` apesar dos commits `74d89c0` e `2eae254`..."): `push` só dispara CI em `main`/`master`, e a única PR `develop → main` (#7) já foi mesclada e fechada em 2026-09-25T19:24:17Z — não há PR aberta hoje que pegue esses commits. `gh pr list --state all` confirma: as únicas PRs abertas no momento são `#12` (`docs/ui-ux-test-plan-execution → develop`) e `#11` (`feat/theme-light-dark → develop`), nenhuma delas `develop → main`. + +**Correção ao brief: o run `36179475405` usado como "estado da HEAD" não testa `74d89c0`.** O brief presumiu que o run `36179475405` (`push` em `main`, `headSha` `543cded`, criado pela mesclagem da PR #7) incluía `74d89c0` por ser o run mais recente disponível. Não inclui: + +```bash +git merge-base --is-ancestor 74d89c0 543cded && echo ancestor || echo "not ancestor" +# → not ancestor +git log --oneline -1 543cded^ # pai do merge commit +# → 9f21b9d fix(ci): remove import obsoleto de test_api que quebrava o flutter analyze +``` + +A PR #7 foi mesclada às 19:24:17Z; o commit `74d89c0` só foi enviado a `develop` 22 minutos depois, às 19:46:48Z — ou seja, `74d89c0` (o commit "perf(ci): android-e2e com KVM, caches e smoke de um teste por app", que é a própria tentativa de correção do `android-e2e`) **fisicamente não existia ainda** quando a PR #7 foi mesclada. `36179475405` testa o código em `9f21b9d`, um commit *antes* da tentativa de correção — não depois dela. Não existe, e não existirá até uma nova PR `develop → main` ser aberta, nenhuma execução de CI que exercite `74d89c0` ou qualquer commit posterior. + +Dado esse limite (ver "Quando você estiver sobrecarregado" no brief da Task 3), uso `36179475405` como run mais recente disponível — é o mesmo run que o brief usou, mas seus resultados descrevem o estado do `android-e2e` **antes** de `74d89c0`, não depois. Trato isso como o achado principal desta seção. + +```bash +gh run view 36179475405 +``` + +``` +JOBS +✓ coverage-report ✓ admin-app ✓ admin-android-build +✓ acs-app ✓ serverpod-backend ✓ backend-docker-build +✓ patient-app +X android-e2e in 21m58s (ID 108218045718) +``` + +7 de 8 jobs verdes; `android-e2e` é o único vermelho, e termina em 21m58s — não é o timeout de 60 min visto no run anterior da Seção 2. + +```bash +gh run view --job=108218045718 | grep -A5 -i "error\|failed\|passed" +``` + +``` +X The process '/usr/bin/sh' failed with exit code 1 +X 0 tests passed, 1 failed. +X 0 tests passed, 1 failed. +``` + +**Segunda correção ao brief: as duas linhas "0 tests passed, 1 failed" não são uma por app.** O brief presumiu uma linha para `apps/patient` e outra para `apps/acs`, já que o smoke roda um arquivo por app. Lendo o log completo do passo que falhou (`gh run view --log-failed --job=108218045718`), as duas linhas correspondem à **mesma tentativa, duas vezes, no mesmo app**: + +``` +19:45:40 adb: failed to install .../apps/patient/build/app/outputs/flutter-apk/app-debug.apk: cmd: Failure calling service package: Broken pipe (32) +19:45:44 ❌ loading .../apps/patient/integration_test/backend_connection_test.dart (failed): Unable to start the app on the device. +19:45:44 0 tests passed, 1 failed. +19:45:44 aviso: flutter test integration_test falhou (possível "Broken pipe" do adb install em emulador sem aceleração de hardware); tentando de novo. +19:46:12 adb: failed to install .../apps/patient/build/app/outputs/flutter-apk/app-debug.apk: cmd: Can't find service: package +19:46:14 ❌ loading .../apps/patient/integration_test/backend_connection_test.dart (failed): Unable to start the app on the device. +19:46:14 0 tests passed, 1 failed. +``` + +(o texto do aviso e o alvo `integration_test` — a pasta inteira, não `smoke_test.dart` — confirmam de novo que este run roda o script de `9f21b9d`, anterior ao "smoke de um teste por app" de `74d89c0`.) As duas falhas são a tentativa inicial e a única retentativa de `apps/patient` — nenhuma delas é `apps/acs`. O script (`scripts/qa/e2e.sh`, `set -euo pipefail`) roda `apps/patient` primeiro; como a retentativa também falhou por incapacidade de instalar o APK no emulador (mesma causa "Broken pipe"/"Can't find service", raiz em emulador sem aceleração de hardware — documentada nos comentários do próprio `e2e.sh`), o script abortou ali. **`apps/acs` e `apps/admin` nunca chegaram a rodar neste run.** Não sabemos, a partir dele, se o smoke do `acs` passaria ou falharia. + +Comparação com a Seção 2: o run imediatamente anterior na mesma cadeia de commits, `36143685083` (`40035a6`, um commit antes de `9f21b9d`), teve "10 tests passed, 4 failed" — passagem parcial, sinal de que ao menos alguns testes chegaram a rodar em pelo menos um dos apps. Entre `40035a6` e `9f21b9d` a única mudança é a remoção do import não usado em `acs-app` (lint, não toca `e2e.sh`); ainda assim o `android-e2e` piorou de "passagem parcial" para "aborta na primeira instalação do APK, zero testes chegam a rodar". Isso é consistente com falha de infraestrutura não determinística (emulador sem KVM, mencionada nos comentários de `e2e.sh` como causa já identificada antes de `74d89c0`), não com uma regressão introduzida por código de teste. + +**FINDING-4 (severidade: crítica — o único job que exercita o device de verdade está com o resultado da correção tentada ainda não verificado por CI).** No HEAD atual (`9ed4f92`) e em todo commit a partir de `74d89c0` (inclusive), não existe **nenhuma** execução de CI: a PR `develop → main` que cobria esses commits já foi mesclada antes de `74d89c0` existir, e sem uma PR nova aberta o workflow não dispara para `develop` (FINDING-2). A execução mais recente disponível, `36179475405`, na verdade testa `9f21b9d` — o commit imediatamente *anterior* a `74d89c0`, a própria tentativa de correção (KVM, cache, smoke de um teste por app) que o time aplicou para resolver a instabilidade do `android-e2e` registrada na Seção 2. Nessa execução, `android-e2e` falhou em 21m58s (não por timeout): a instalação do APK do `apps/patient` no emulador falhou duas vezes seguidas ("Broken pipe (32)", depois "Can't find service: package") e o script abortou antes de sequer tentar `apps/acs` ou `apps/admin` — um retrocesso em relação ao run anterior da mesma cadeia (`36143685083`, "10 tests passed, 4 failed", passagem parcial), mas causado por flakiness de infraestrutura documentada (emulador sem aceleração de hardware), não por uma mudança de código de teste. + +Ou seja: o time já escreveu e commitou (`74d89c0`) a correção mais provável para essa causa raiz — mas essa correção nunca rodou no CI, nem uma única vez. Não há como hoje afirmar se `android-e2e` está corrigido, seguindo quebrado do mesmo jeito, ou pior — o dado simplesmente não existe. Isso é mais grave do que uma regressão confirmada: é um item que a equipe acredita ter resolvido, sem qualquer verificação. Combinado com o FINDING-2 (push direto em `develop` não dispara CI) e o fato de não haver hoje PR aberta `develop → main`, essa lacuna de verificação persiste indefinidamente até que alguém abra essa PR (ou dispare o workflow manualmente) — não é algo que se resolva sozinho com o tempo. + +`android-e2e` continua sendo o único job do repositório que sobe um emulador Android real e exercita o ciclo completo (per `AGENTS.md`/`CLAUDE.md`: MQTT com TLS, fila offline, ciclo de alerta vermelho). Os outros 7 jobs (`flutter analyze`/`flutter test` unitário, build de APK do admin, testes de integração do backend) não cobrem o que `android-e2e` cobre, e enquanto ele não roda de verdade contra `74d89c0`, essa cobertura fim-a-fim está, na prática, sem sinal algum — nem verde, nem vermelho, ausente. + +Recomendação: antes de qualquer sessão de debugging, gerar o dado que falta — abrir a PR `develop → main` (ou disparar o workflow manualmente contra `develop`) para obter uma execução real de CI contra `74d89c0`. Só então decidir o próximo passo: se `android-e2e` passar, o KVM resolveu a causa já documentada; se falhar, aí sim abrir uma sessão dedicada com a skill `superpowers:systematic-debugging`, usando `scripts/qa/run_android_e2e.sh` como ponto de entrada. O artifact `android-e2e-metrics` não é uma fonte confiável de dados de latência enquanto o job abortar antes do fim: neste run ele nem foi gerado (`No files were found with the provided path: build/qa/latency. No artifacts will be uploaded.` — só o artifact `coverage-reports` apareceu na lista de artifacts do run). From ffe6599b6d7b476379b358ed8d758e498c689d58 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Mon, 28 Sep 2026 10:02:25 -0400 Subject: [PATCH 007/498] docs(ci-audit): corrigir comando de evidencia do parent do merge na Secao 3 Co-Authored-By: Claude Sonnet 5 --- docs/ci-audit/2026-09-28-avaliacao-ci-develop.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 3c610760..19d32e30 100644 --- a/docs/ci-audit/2026-09-28-avaliacao-ci-develop.md +++ b/docs/ci-audit/2026-09-28-avaliacao-ci-develop.md @@ -92,10 +92,13 @@ Retorna vazio para os três SHAs. Isso já era previsível pela FINDING-2 (Seç ```bash git merge-base --is-ancestor 74d89c0 543cded && echo ancestor || echo "not ancestor" # → not ancestor -git log --oneline -1 543cded^ # pai do merge commit +git log --oneline -1 543cded^2 # segundo pai do merge commit = ponta de develop mesclada pela PR #7 # → 9f21b9d fix(ci): remove import obsoleto de test_api que quebrava o flutter analyze ``` +(`543cded^1`, o primeiro pai, é `9293536` — a ponta de `main` antes da mesclagem, não usado aqui; +o commit relevante para esta comparação é o segundo pai, a ponta de `develop` que a PR #7 trouxe.) + A PR #7 foi mesclada às 19:24:17Z; o commit `74d89c0` só foi enviado a `develop` 22 minutos depois, às 19:46:48Z — ou seja, `74d89c0` (o commit "perf(ci): android-e2e com KVM, caches e smoke de um teste por app", que é a própria tentativa de correção do `android-e2e`) **fisicamente não existia ainda** quando a PR #7 foi mesclada. `36179475405` testa o código em `9f21b9d`, um commit *antes* da tentativa de correção — não depois dela. Não existe, e não existirá até uma nova PR `develop → main` ser aberta, nenhuma execução de CI que exercite `74d89c0` ou qualquer commit posterior. Dado esse limite (ver "Quando você estiver sobrecarregado" no brief da Task 3), uso `36179475405` como run mais recente disponível — é o mesmo run que o brief usou, mas seus resultados descrevem o estado do `android-e2e` **antes** de `74d89c0`, não depois. Trato isso como o achado principal desta seção. From 83175e73d76d4af7e794b320867d88bfc381738b Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Mon, 28 Sep 2026 10:07:09 -0400 Subject: [PATCH 008/498] docs(ci-audit): avaliar governanca, acoes desatualizadas e segredos do CI Co-Authored-By: Claude Sonnet 5 --- .../2026-09-28-avaliacao-ci-develop.md | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) 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 19d32e30..e6153341 100644 --- a/docs/ci-audit/2026-09-28-avaliacao-ci-develop.md +++ b/docs/ci-audit/2026-09-28-avaliacao-ci-develop.md @@ -150,3 +150,93 @@ Ou seja: o time já escreveu e commitou (`74d89c0`) a correção mais provável `android-e2e` continua sendo o único job do repositório que sobe um emulador Android real e exercita o ciclo completo (per `AGENTS.md`/`CLAUDE.md`: MQTT com TLS, fila offline, ciclo de alerta vermelho). Os outros 7 jobs (`flutter analyze`/`flutter test` unitário, build de APK do admin, testes de integração do backend) não cobrem o que `android-e2e` cobre, e enquanto ele não roda de verdade contra `74d89c0`, essa cobertura fim-a-fim está, na prática, sem sinal algum — nem verde, nem vermelho, ausente. Recomendação: antes de qualquer sessão de debugging, gerar o dado que falta — abrir a PR `develop → main` (ou disparar o workflow manualmente contra `develop`) para obter uma execução real de CI contra `74d89c0`. Só então decidir o próximo passo: se `android-e2e` passar, o KVM resolveu a causa já documentada; se falhar, aí sim abrir uma sessão dedicada com a skill `superpowers:systematic-debugging`, usando `scripts/qa/run_android_e2e.sh` como ponto de entrada. O artifact `android-e2e-metrics` não é uma fonte confiável de dados de latência enquanto o job abortar antes do fim: neste run ele nem foi gerado (`No files were found with the provided path: build/qa/latency. No artifacts will be uploaded.` — só o artifact `coverage-reports` apareceu na lista de artifacts do run). + +## 4. Governança e segurança + +Comandos executados em 2026-09-28 (nova chamada, para esta seção): + +### 4.1 Proteção de branch + +```bash +gh api repos/:owner/:repo/branches/develop/protection +gh api repos/:owner/:repo/branches/main/protection +``` + +Ambos retornam o mesmo 404, hoje: + +```json +{"message":"Branch not protected","documentation_url":"https://docs.github.com/rest/branches/branch-protection#get-branch-protection","status":"404"} +``` + +Idêntico ao snapshot do brief — sem drift. + +**FINDING-5 (severidade: alta — CI não é um gate, é só um relatório).** Nem `develop` nem `main` têm branch protection configurada no GitHub (ambos retornam 404 "Branch not protected"). Isso significa que nenhum check de CI é obrigatório para merge — combinado ao FINDING-3 (15 execuções seguidas vermelhas na PR de integração), fica claro que o CI vermelho nunca impediu ninguém de continuar commitando ou, potencialmente, de fazer merge. O invariante do projeto "Red alerts must never be silently dropped" (`CLAUDE.md`) é sobre o produto, mas o mesmo princípio de "nunca falhar silenciosamente" vale para o pipeline que valida esse produto — hoje ele pode falhar silenciosamente (ninguém é bloqueado) por dias. + +### 4.2 Ações desatualizadas e prazos de infraestrutura + +**Correção ao brief: as annotations não estavam de fato transcritas nas Seções 2/3 do arquivo.** Reli as Seções 2 e 3 antes de escrever este trecho (conforme pedido) e nenhuma delas contém o texto das annotations de depreciação — só o job inventory da Seção 1 lista as ações (`actions/setup-java@v4` etc.), sem as mensagens de aviso. Em vez de copiar o texto do brief sem verificação, refiz a captura ao vivo contra o run mais recente disponível (`36179475405`, o mesmo identificado na Seção 3 como o run que efetivamente testa `9f21b9d`, não `74d89c0` — mas as annotations de infraestrutura independem do commit testado, já que vêm do runner/GitHub, não do código): + +```bash +gh run view 36179475405 +``` + +Bloco `ANNOTATIONS` (trecho relevante, agregado por tipo de aviso — cada aviso aparece uma vez por job que o dispara): + +``` +! Node.js 20 is deprecated. The following actions target Node.js 20 but are being forced to run on Node.js 24: actions/checkout@v4, actions/upload-artifact@v4 (coverage-report, android-e2e) +! Node.js 20 is deprecated. [...]: actions/checkout@v4 (admin-app, acs-app, serverpod-backend, backend-docker-build, patient-app) +! Node.js 20 is deprecated. [...]: actions/cache@v4, actions/checkout@v4, actions/setup-java@v4 (admin-android-build) +! Node.js 20 is deprecated. [...]: actions/checkout@v4, actions/setup-java@v4, actions/upload-artifact@v4 (android-e2e) +! setup-java v4 is deprecated and will no longer receive updates. Please migrate to actions/setup-java@v5. (admin-android-build, android-e2e) +- "The ubuntu-latest label will migrate to Ubuntu 26 beginning October 19, 2026. [...]" (todos os 8 jobs) +``` + +Confirmado ao vivo: as quatro ações citadas no brief (`checkout@v4`, `cache@v4`, `setup-java@v4`, `upload-artifact@v4`) aparecem de fato nos avisos de Node.js 20, distribuídas entre os jobs conforme o uso de cada uma (nem toda ação aparece em todo job — `cache@v4` só é usada por `admin-android-build`, por exemplo); o aviso de depreciação do `setup-java v4` em si aparece nos dois jobs que o usam (`admin-android-build`, `android-e2e`); e o aviso de migração para Ubuntu 26 em 2026-10-19 aparece nos 8 jobs, já que todos usam `ubuntu-latest`. + +**Recomputado: hoje (2026-09-28) até 2026-10-19 são exatamente 21 dias corridos — 3 semanas, sem arredondamento.** A data do contexto desta sessão confirma a mesma data usada no brief; não houve drift. + +**FINDING-6 (severidade: média — dívida de infraestrutura com prazo concreto).** Toda execução do CI emite estas annotations, hoje ignoradas: +- `actions/checkout@v4`, `actions/cache@v4`, `actions/setup-java@v4`, `actions/upload-artifact@v4` — todas ainda no Node.js 20, forçadas a rodar em Node 24 pelo runner (aviso de depreciação da GitHub, não bloqueante ainda). +- `setup-java v4` está deprecated; o vendor recomenda migrar para `setup-java@v5`. +- `ubuntu-latest` migra para Ubuntu 26 a partir de **2026-10-19** — a **três semanas** da data desta avaliação (2026-09-28). Como nenhum job fixa a versão da imagem (todos usam `ubuntu-latest`), essa migração vai acontecer automaticamente e sem aviso prévio no dia, podendo quebrar qualquer job que dependa implicitamente de pacotes/versões do Ubuntu 24 atual (candidatos mais prováveis: `android-e2e`, que já depende de KVM e de uma regra de udev específica, e `admin-android-build`/`acs-app`/`patient-app`, que dependem do NDK/CMake cacheado). + +Recomendação: fixar as ações em versões mais novas (`checkout@v5`, `setup-java@v5`, etc.) num PR pequeno e isolado, e rodar o pipeline uma vez contra uma imagem `ubuntu-24.04` explícita antes de 2026-10-19 para confirmar que nada quebra com a migração. + +### 4.3 Uso de segredos e controle de concorrência + +```bash +grep -n "secrets\." .github/workflows/ci.yml +grep -n "^concurrency:" .github/workflows/ci.yml +``` + +Saída: + +``` +183: GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }} +``` + +(`concurrency:` não retorna nenhuma linha — ausente do arquivo.) Idêntico ao esperado pelo brief: só uma ocorrência de `secrets.`, nenhum bloco `concurrency:`. + +**Nota positiva (não é finding).** O único segredo real referenciado é `secrets.GOOGLE_MAPS_API_KEY`. `CI_POSTGRES_PASSWORD` é deliberadamente hardcoded (`.github/workflows/ci.yml:27`, valor literal `ci-ephemeral-not-a-secret`) e documentado no próprio workflow como não-segredo (banco efêmero, existe só dentro do runner) — não há vazamento de credencial de dev/prod no pipeline. + +**FINDING-7 (severidade: baixa — desperdício de minutos de CI).** Não há bloco `concurrency:` no workflow. Cada novo push à mesma PR dispara uma execução completa sem cancelar a anterior. Isso é particularmente caro para `android-e2e`, que sozinho pode consumir até 60 minutos por tentativa (timeout configurado) — e a Seção 2 mostrou 15 tentativas em 10 dias na mesma PR. Um bloco como: + +```yaml +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +``` + +cancelaria runs obsoletos assim que um novo commit chegasse na mesma PR/branch. + +### 4.4 Verificação de log por segredo exposto + +Job usado como amostra: `serverpod-backend` do run `36169873626` (job `108186514572`), por ser o que gera `config/passwords.yaml`. ID confirmado ao vivo com `gh run view 36169873626 --json jobs -q '.jobs[] | select(.name=="serverpod-backend") | .databaseId'` → `108186514572`, igual ao do brief. + +```bash +gh run view --log --job=108186514572 2>&1 | grep -i "senha\|password\|secret\|token" | grep -v "CI_POSTGRES_PASSWORD\|GOOGLE_MAPS_API_KEY\|passwords.yaml" +``` + +A saída não veio vazia como no brief, mas nada nela é um segredo real: as linhas são (a) `Secret source: Actions` e `##[group]GITHUB_TOKEN Permissions` — texto padrão do runner sobre o próprio `GITHUB_TOKEN` efêmero, não um valor; (b) `token: ***` — já mascarado pelo GitHub; (c) o comando `docker create` do Postgres de teste, contendo `-e "POSTGRES_PASSWORD=ci-ephemeral-not-a-secret"` — o mesmo valor não-secreto da nota positiva acima, só que sem o texto literal `CI_POSTGRES_PASSWORD` que meu filtro excluía (o nome da env var vira `POSTGRES_PASSWORD` dentro do comando docker); e (d) dezenas de nomes de teste em português contendo "senha"/"token"/"segredo" (ex.: `login institucional aceita matrícula e senha corretas`, `JWT_SECRET development aceita um segredo próprio`) — são descrições de casos de teste de autenticação, não credenciais. Nenhum valor de segredo real (JWT_SECRET, AUDIT_CHAIN_SECRET, senha de paciente/ACS real, etc.) aparece no log. + +Nenhum segredo exposto em log encontrado na amostra verificada. From f37be2fb0072c5ce62ff9d066d17f7ac7b8ad87b Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Mon, 28 Sep 2026 10:13:25 -0400 Subject: [PATCH 009/498] docs(ci-audit): consolidar backlog priorizado e resumo executivo da avaliacao de CI Co-Authored-By: Claude Sonnet 5 --- .../2026-09-28-avaliacao-ci-develop.md | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) 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 e6153341..6314f1bf 100644 --- a/docs/ci-audit/2026-09-28-avaliacao-ci-develop.md +++ b/docs/ci-audit/2026-09-28-avaliacao-ci-develop.md @@ -4,6 +4,10 @@ Data da avaliação: 2026-09-28 Workflow avaliado: `.github/workflows/ci.yml` (branch `develop`, HEAD em `74d89c0`) Repositório: `hbgit/SinalACS` +## Resumo executivo + +O pipeline tem 8 jobs (Seção 1), mas a documentação (`AGENTS.md`) só conhece 6 — sinal de que o workflow evoluiu sem o resto do repositório acompanhar. Nos últimos 10 dias, a PR que integra `develop` em `main` nunca teve uma execução 100% verde (Seção 2): 15 execuções, 14 falhas e 1 cancelamento por timeout, 0 sucessos, alternando entre um lint em `acs-app` (já corrigido em `9f21b9d`) e o job `android-e2e`, que segue instável. A equipe já commitou uma tentativa de correção para o `android-e2e` (`74d89c0`: KVM, cache, smoke de um teste por app) — mas a PR de integração que cobria esse trecho de `develop` já havia sido mesclada 22 minutos *antes* desse commit existir, e sem uma nova PR `develop → main` aberta o workflow não dispara para `develop` (FINDING-2). Resultado: nenhuma execução de CI jamais cobriu `74d89c0` nem qualquer commit posterior a ele — incluindo os commits desta própria avaliação, que só tocam este arquivo de documentação e não alteram esse quadro (Seção 3). Isso não é uma regressão confirmada; é uma lacuna de verificação que não se fecha sozinha com o tempo. Isso importa mais do que pareceria à primeira vista porque nem `main` nem `develop` têm branch protection (Seção 4) — ou seja, o CI vermelho nunca foi, de fato, um gate; foi só um relatório que ninguém era obrigado a atender. Prioridade imediata: gerar o dado que falta para o FINDING-4 (abrir a PR `develop → main` ou disparar o workflow manualmente contra `develop`, antes de qualquer sessão de debugging); estrutural: FINDING-5 (branch protection), para que corrigir o CI passe a valer a pena. + ## 1. Inventário do workflow | Job | Working dir | Runner | Ações principais | Observação | @@ -240,3 +244,19 @@ gh run view --log --job=108186514572 2>&1 | grep -i "senha\|password\|secret\|to A saída não veio vazia como no brief, mas nada nela é um segredo real: as linhas são (a) `Secret source: Actions` e `##[group]GITHUB_TOKEN Permissions` — texto padrão do runner sobre o próprio `GITHUB_TOKEN` efêmero, não um valor; (b) `token: ***` — já mascarado pelo GitHub; (c) o comando `docker create` do Postgres de teste, contendo `-e "POSTGRES_PASSWORD=ci-ephemeral-not-a-secret"` — o mesmo valor não-secreto da nota positiva acima, só que sem o texto literal `CI_POSTGRES_PASSWORD` que meu filtro excluía (o nome da env var vira `POSTGRES_PASSWORD` dentro do comando docker); e (d) dezenas de nomes de teste em português contendo "senha"/"token"/"segredo" (ex.: `login institucional aceita matrícula e senha corretas`, `JWT_SECRET development aceita um segredo próprio`) — são descrições de casos de teste de autenticação, não credenciais. Nenhum valor de segredo real (JWT_SECRET, AUDIT_CHAIN_SECRET, senha de paciente/ACS real, etc.) aparece no log. Nenhum segredo exposto em log encontrado na amostra verificada. + +## 5. Backlog priorizado + +Ordenado por severidade (crítica → alta → média → baixa → informativa). Nenhum item foi implementado por este plano — é avaliação, não execução. + +| # | Severidade | Achado | Ação recomendada | Como executar | +|---|---|---|---|---| +| FINDING-4 | Crítica | A única correção tentada para `android-e2e` (`74d89c0`: KVM, cache, smoke de um teste por app) nunca rodou no CI — a PR `develop → main` que cobriria esses commits já havia sido mesclada 22 minutos antes de `74d89c0` existir. A execução mais recente disponível (`36179475405`) testa o commit anterior (`9f21b9d`) e mostra a instalação do APK do `apps/patient` falhando duas vezes seguidas no emulador; o script aborta ali e `apps/acs`/`apps/admin` nunca chegam a rodar. | Antes de qualquer sessão de debugging, gerar o dado que falta: abrir a PR `develop → main` ou disparar o workflow manualmente contra `develop` para obter uma execução real de CI contra `74d89c0`. Só depois, se falhar, investigar a causa. | Abrir PR de integração ou `gh workflow run` contra `develop`; só se o resultado for vermelho, `superpowers:systematic-debugging` com `scripts/qa/run_android_e2e.sh` como entry point | +| FINDING-3 | Alta | As 15 execuções do CI contra a PR de integração `develop → main` nos últimos 10 dias tiveram 0 sucessos (14 falhas, 1 cancelamento por timeout); uma das duas causas alternadas (lint em `acs-app`) já foi corrigida em `9f21b9d`, a outra (`android-e2e`) persiste sem verificação (ver FINDING-4). | Tratar CI vermelho como bloqueante de fato, não como ruído de configuração; não fechar/mesclar a PR de integração sem um run 100% verde | Prática de time / reforçado por branch protection (FINDING-5), não requer mudança de código | +| FINDING-5 | Alta | Nem `main` nem `develop` têm branch protection — nenhum check de CI é obrigatório para merge, então o CI vermelho documentado no FINDING-3 nunca bloqueou ninguém | Configurar required status checks (mínimo: todos os jobs exceto talvez `android-e2e` até FINDING-4 ser resolvido) | Configuração no GitHub (Settings → Branches), fora do repositório | +| FINDING-6 | Média | Ações do workflow desatualizadas (`checkout@v4`, `cache@v4`, `setup-java@v4` deprecated, `upload-artifact@v4` em Node 20 forçado a Node 24); `ubuntu-latest` migra para Ubuntu 26 em 2026-10-19 (3 semanas a partir da data desta avaliação) | PR isolado fixando `checkout@v5`, `setup-java@v5` etc.; testar contra `ubuntu-24.04` explícito antes do prazo | PR pequeno em `.github/workflows/ci.yml` | +| FINDING-1 | Baixa | `AGENTS.md:138` descreve o CI como "seis jobs separados"; o workflow atual tem 8 (`coverage-report` e `android-e2e` nunca foram incorporados à documentação) | Atualizar a linha 138 para listar os 8 jobs, ou trocar por referência ao arquivo (`ver .github/workflows/ci.yml`) para não repetir o mesmo tipo de drift | Edição de doc | +| FINDING-7 | Baixa | Sem bloco `concurrency:` no workflow; cada novo push à mesma PR dispara uma execução completa sem cancelar a anterior, custo alto para `android-e2e` (até 60 min por tentativa, 15 tentativas em 10 dias na mesma PR) | Adicionar bloco `concurrency` ao workflow | PR pequeno em `.github/workflows/ci.yml` | +| FINDING-2 | Informativa | `push` só dispara CI em `main`/`master`; um push direto em `develop` sem PR aberta não roda o workflow — hoje isso é mitigado só porque sempre há uma PR de integração `develop → main` aberta | Nenhuma ação obrigatória; documentar o comportamento esperado se o time quiser confiar nisso | N/A | + +Follow-up sugerido: um plano de implementação separado (via `superpowers:writing-plans` de novo) para os itens de severidade alta/média. A prioridade imediata **não** é depurar `android-e2e` (FINDING-4) — é gerar o dado que falta, abrindo a PR `develop → main` ou disparando o workflow manualmente, já que hoje não existe nenhuma execução de CI contra a única correção já tentada. Em paralelo, o item estrutural é FINDING-5 (branch protection), para que corrigir o CI passe a valer a pena de fato. From b606b94820d598a835bda244779563ff167aa0c1 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Mon, 28 Sep 2026 10:26:02 -0400 Subject: [PATCH 010/498] docs(ci-audit): corrigir contradicoes internas apontadas na revisao final Co-Authored-By: Claude Sonnet 5 --- docs/ci-audit/2026-09-28-avaliacao-ci-develop.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 6314f1bf..f35d7ed3 100644 --- a/docs/ci-audit/2026-09-28-avaliacao-ci-develop.md +++ b/docs/ci-audit/2026-09-28-avaliacao-ci-develop.md @@ -1,7 +1,7 @@ # Avaliação do CI adotado no branch develop Data da avaliação: 2026-09-28 -Workflow avaliado: `.github/workflows/ci.yml` (branch `develop`, HEAD em `74d89c0`) +Workflow avaliado: `.github/workflows/ci.yml` (branch `develop`, snapshot inicial em `74d89c0` — este próprio relatório soma commits adicionais sobre esse ponto, sem alterar o workflow) Repositório: `hbgit/SinalACS` ## Resumo executivo @@ -23,7 +23,7 @@ O pipeline tem 8 jobs (Seção 1), mas a documentação (`AGENTS.md`) só conhec **FINDING-1 (severidade: baixa — documentação desatualizada).** `AGENTS.md:138` descreve o CI como "seis jobs separados", listando `serverpod-backend`, `backend-docker-build`, `patient-app`, `acs-app`, `admin-app` e `admin-android-build`. O workflow atual tem **8 jobs**: os seis citados mais `coverage-report` (adicionado em `c27ffe3`) e `android-e2e` (adicionado depois, hoje o job mais instável — ver FINDING-4). `AGENTS.md` nunca foi atualizado quando esses dois jobs entraram. Recomendação: atualizar a linha 138 para listar os 8 jobs, ou trocar a enumeração por uma referência ao arquivo (`ver .github/workflows/ci.yml`) para não repetir o mesmo tipo de drift. -**FINDING-2 (severidade: informativa).** `push` só dispara CI em `main`/`master` — um push direto em `develop` (sem PR aberto) **não** roda o workflow. A cobertura de `develop` hoje depende inteiramente de haver um `pull_request` aberto tendo `develop` como head ou base. Na prática há sempre uma PR de integração `develop → main` aberta (ver Seção 2), então o branch é coberto — mas isso é um efeito colateral do fluxo de trabalho atual, não uma garantia do workflow. Se alguém commitar direto em `develop` sem PR, o CI simplesmente não roda para aquele commit. +**FINDING-2 (severidade: informativa).** `push` só dispara CI em `main`/`master` — um push direto em `develop` (sem PR aberto) **não** roda o workflow. A cobertura de `develop` hoje depende inteiramente de haver um `pull_request` aberto tendo `develop` como head ou base. Na prática, no momento em que esta seção foi escrita, havia sempre uma PR de integração `develop → main` aberta (ver Seção 2), então o branch estava coberto — mas isso era um efeito colateral do fluxo de trabalho então vigente, não uma garantia do workflow; essa cobertura, aliás, já deixou de existir (ver Seção 3/FINDING-4). Se alguém commitar direto em `develop` sem PR, o CI simplesmente não roda para aquele commit. actionlint: sem achados (2026-09-28) @@ -40,7 +40,7 @@ Resultado: 15 execuções, todas do evento `pull_request` contra a PR #7 (`devel - 14 `failure` - 0 `success` -Esses números **batem exatamente** com o snapshot capturado no brief em 2026-09-28 — não houve drift. Nenhum novo run apareceu contra `develop` apesar dos commits `74d89c0` e `2eae254` terem sido pushados depois do run mais recente (`36169873626`, 17:52:44Z); isso é consistente com o FINDING-2 (Seção 1): sem um evento de PR novo, esses commits não disparam CI em `develop`. +Esses números **batem exatamente** com o snapshot capturado no brief em 2026-09-28 — não houve drift. Nenhum novo run apareceu contra `develop`: `74d89c0` foi enviado a `origin/develop` depois do run mais recente (`36169873626`, 17:52:44Z) e, mesmo assim, não disparou CI; `2eae254` e os commits seguintes, por sua vez, sequer chegaram a ser enviados ao remoto (`origin/develop` continua em `74d89c0` — ver Seção 3). Em ambos os casos isso é consistente com o FINDING-2 (Seção 1): sem um evento de PR novo, push para `develop` não dispara CI. Job que falhou em cada run, para uma amostra de 7 runs cobrindo do início ao fim do intervalo (comando `gh run view --json jobs -q '.jobs[] | select(.conclusion!="success") | "\(.name): \(.conclusion) (\(.startedAt) -> \(.completedAt))"'`, reexecutado e confirmado idêntico ao do brief para as 6 amostras originais; a 7ª, o run cancelado, foi acrescentada por esta execução para fechar a lacuna): @@ -105,7 +105,7 @@ o commit relevante para esta comparação é o segundo pai, a ponta de `develop` A PR #7 foi mesclada às 19:24:17Z; o commit `74d89c0` só foi enviado a `develop` 22 minutos depois, às 19:46:48Z — ou seja, `74d89c0` (o commit "perf(ci): android-e2e com KVM, caches e smoke de um teste por app", que é a própria tentativa de correção do `android-e2e`) **fisicamente não existia ainda** quando a PR #7 foi mesclada. `36179475405` testa o código em `9f21b9d`, um commit *antes* da tentativa de correção — não depois dela. Não existe, e não existirá até uma nova PR `develop → main` ser aberta, nenhuma execução de CI que exercite `74d89c0` ou qualquer commit posterior. -Dado esse limite (ver "Quando você estiver sobrecarregado" no brief da Task 3), uso `36179475405` como run mais recente disponível — é o mesmo run que o brief usou, mas seus resultados descrevem o estado do `android-e2e` **antes** de `74d89c0`, não depois. Trato isso como o achado principal desta seção. +Diante da impossibilidade de obter um run que cubra `74d89c0`, uso `36179475405` como run mais recente disponível mesmo sabendo que ele reflete o commit anterior — é o mesmo run que o brief usou, mas seus resultados descrevem o estado do `android-e2e` **antes** de `74d89c0`, não depois. Trato isso como o achado principal desta seção. ```bash gh run view 36179475405 @@ -147,7 +147,7 @@ X 0 tests passed, 1 failed. Comparação com a Seção 2: o run imediatamente anterior na mesma cadeia de commits, `36143685083` (`40035a6`, um commit antes de `9f21b9d`), teve "10 tests passed, 4 failed" — passagem parcial, sinal de que ao menos alguns testes chegaram a rodar em pelo menos um dos apps. Entre `40035a6` e `9f21b9d` a única mudança é a remoção do import não usado em `acs-app` (lint, não toca `e2e.sh`); ainda assim o `android-e2e` piorou de "passagem parcial" para "aborta na primeira instalação do APK, zero testes chegam a rodar". Isso é consistente com falha de infraestrutura não determinística (emulador sem KVM, mencionada nos comentários de `e2e.sh` como causa já identificada antes de `74d89c0`), não com uma regressão introduzida por código de teste. -**FINDING-4 (severidade: crítica — o único job que exercita o device de verdade está com o resultado da correção tentada ainda não verificado por CI).** No HEAD atual (`9ed4f92`) e em todo commit a partir de `74d89c0` (inclusive), não existe **nenhuma** execução de CI: a PR `develop → main` que cobria esses commits já foi mesclada antes de `74d89c0` existir, e sem uma PR nova aberta o workflow não dispara para `develop` (FINDING-2). A execução mais recente disponível, `36179475405`, na verdade testa `9f21b9d` — o commit imediatamente *anterior* a `74d89c0`, a própria tentativa de correção (KVM, cache, smoke de um teste por app) que o time aplicou para resolver a instabilidade do `android-e2e` registrada na Seção 2. Nessa execução, `android-e2e` falhou em 21m58s (não por timeout): a instalação do APK do `apps/patient` no emulador falhou duas vezes seguidas ("Broken pipe (32)", depois "Can't find service: package") e o script abortou antes de sequer tentar `apps/acs` ou `apps/admin` — um retrocesso em relação ao run anterior da mesma cadeia (`36143685083`, "10 tests passed, 4 failed", passagem parcial), mas causado por flakiness de infraestrutura documentada (emulador sem aceleração de hardware), não por uma mudança de código de teste. +**FINDING-4 (severidade: crítica — o único job que exercita o device de verdade está com o resultado da correção tentada ainda não verificado por CI).** Em todo commit a partir de `74d89c0` (inclusive) — incluindo os desta própria avaliação —, não existe **nenhuma** execução de CI: a PR `develop → main` que cobria esses commits já foi mesclada antes de `74d89c0` existir, e sem uma PR nova aberta o workflow não dispara para `develop` (FINDING-2). A execução mais recente disponível, `36179475405`, na verdade testa `9f21b9d` — o commit imediatamente *anterior* a `74d89c0`, a própria tentativa de correção (KVM, cache, smoke de um teste por app) que o time aplicou para resolver a instabilidade do `android-e2e` registrada na Seção 2. Nessa execução, `android-e2e` falhou em 21m58s (não por timeout): a instalação do APK do `apps/patient` no emulador falhou duas vezes seguidas ("Broken pipe (32)", depois "Can't find service: package") e o script abortou antes de sequer tentar `apps/acs` ou `apps/admin` — um retrocesso em relação ao run anterior da mesma cadeia (`36143685083`, "10 tests passed, 4 failed", passagem parcial), mas causado por flakiness de infraestrutura documentada (emulador sem aceleração de hardware), não por uma mudança de código de teste. Ou seja: o time já escreveu e commitou (`74d89c0`) a correção mais provável para essa causa raiz — mas essa correção nunca rodou no CI, nem uma única vez. Não há como hoje afirmar se `android-e2e` está corrigido, seguindo quebrado do mesmo jeito, ou pior — o dado simplesmente não existe. Isso é mais grave do que uma regressão confirmada: é um item que a equipe acredita ter resolvido, sem qualquer verificação. Combinado com o FINDING-2 (push direto em `develop` não dispara CI) e o fato de não haver hoje PR aberta `develop → main`, essa lacuna de verificação persiste indefinidamente até que alguém abra essa PR (ou dispare o workflow manualmente) — não é algo que se resolva sozinho com o tempo. @@ -257,6 +257,6 @@ Ordenado por severidade (crítica → alta → média → baixa → informativa) | FINDING-6 | Média | Ações do workflow desatualizadas (`checkout@v4`, `cache@v4`, `setup-java@v4` deprecated, `upload-artifact@v4` em Node 20 forçado a Node 24); `ubuntu-latest` migra para Ubuntu 26 em 2026-10-19 (3 semanas a partir da data desta avaliação) | PR isolado fixando `checkout@v5`, `setup-java@v5` etc.; testar contra `ubuntu-24.04` explícito antes do prazo | PR pequeno em `.github/workflows/ci.yml` | | FINDING-1 | Baixa | `AGENTS.md:138` descreve o CI como "seis jobs separados"; o workflow atual tem 8 (`coverage-report` e `android-e2e` nunca foram incorporados à documentação) | Atualizar a linha 138 para listar os 8 jobs, ou trocar por referência ao arquivo (`ver .github/workflows/ci.yml`) para não repetir o mesmo tipo de drift | Edição de doc | | FINDING-7 | Baixa | Sem bloco `concurrency:` no workflow; cada novo push à mesma PR dispara uma execução completa sem cancelar a anterior, custo alto para `android-e2e` (até 60 min por tentativa, 15 tentativas em 10 dias na mesma PR) | Adicionar bloco `concurrency` ao workflow | PR pequeno em `.github/workflows/ci.yml` | -| FINDING-2 | Informativa | `push` só dispara CI em `main`/`master`; um push direto em `develop` sem PR aberta não roda o workflow — hoje isso é mitigado só porque sempre há uma PR de integração `develop → main` aberta | Nenhuma ação obrigatória; documentar o comportamento esperado se o time quiser confiar nisso | N/A | +| FINDING-2 | Informativa | `push` só dispara CI em `main`/`master`; um push direto em `develop` sem PR aberta não roda o workflow — e hoje não há nenhuma PR `develop → main` aberta (a única, #7, foi mesclada em 2026-09-25), então essa cobertura simplesmente não existe agora. É exatamente essa ausência que abre a lacuna de verificação descrita no FINDING-4 | Nenhuma ação obrigatória por si só, mas é a causa estrutural do FINDING-4: considerar adicionar `push: branches: [..., develop]` ou um trigger `workflow_dispatch` para que a cobertura de CI não dependa de haver uma PR aberta por acaso | N/A por padrão; se adotada, PR pequeno em `.github/workflows/ci.yml` | Follow-up sugerido: um plano de implementação separado (via `superpowers:writing-plans` de novo) para os itens de severidade alta/média. A prioridade imediata **não** é depurar `android-e2e` (FINDING-4) — é gerar o dado que falta, abrindo a PR `develop → main` ou disparando o workflow manualmente, já que hoje não existe nenhuma execução de CI contra a única correção já tentada. Em paralelo, o item estrutural é FINDING-5 (branch protection), para que corrigir o CI passe a valer a pena de fato. From e46cefb0dc64e4a374e19225e79872469db80f43 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Mon, 28 Sep 2026 10:29:23 -0400 Subject: [PATCH 011/498] docs(plano): plano de avaliacao do CI adotado em develop Co-Authored-By: Claude Sonnet 5 --- .../plans/2026-09-28-avaliacao-ci-develop.md | 424 ++++++++++++++++++ 1 file changed, 424 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-28-avaliacao-ci-develop.md diff --git a/docs/superpowers/plans/2026-09-28-avaliacao-ci-develop.md b/docs/superpowers/plans/2026-09-28-avaliacao-ci-develop.md new file mode 100644 index 00000000..9342fa8c --- /dev/null +++ b/docs/superpowers/plans/2026-09-28-avaliacao-ci-develop.md @@ -0,0 +1,424 @@ +# Avaliação do CI adotado no branch develop — 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:** Produzir uma avaliação estruturada e baseada em evidência do pipeline de CI (`.github/workflows/ci.yml`) tal como configurado e tal como vem operando contra o branch `develop`, cobrindo config estática, saúde histórica das execuções, estado atual, e governança/segurança — culminando num backlog priorizado de achados. Este plano NÃO altera o workflow nem configurações do GitHub; produz apenas o relatório de avaliação (mudanças concretas ficam para um plano de follow-up, fora deste escopo). + +**Architecture:** Um único relatório (`docs/ci-audit/2026-09-28-avaliacao-ci-develop.md`) é construído incrementalmente, uma seção por task. Cada task roda comandos reais (via `gh` CLI, já autenticado como `hbgit`, e leitura direta do workflow), captura a saída como evidência, e escreve um achado numerado (`FINDING-N`) com severidade. A Task 5 consolida tudo num backlog ordenado por severidade. + +**Tech Stack:** `gh` CLI (GitHub REST/GraphQL via `gh api`, `gh run list`, `gh run view`), `git`, bash, leitura estática de `.github/workflows/ci.yml` e `AGENTS.md`. Opcionalmente `docker run rhysd/actionlint` para lint de sintaxe do workflow (não há `actionlint` instalado localmente). + +**Spec:** Não há spec formal para esta avaliação — é uma auditoria ad hoc solicitada pelo usuário. Os documentos de referência usados para comparação são `AGENTS.md:138` (inventário de jobs do CI, desatualizado — ver Task 1) e o próprio `.github/workflows/ci.yml` (fonte de verdade viva). + +## Global Constraints + +- Não editar `.github/workflows/ci.yml`, `AGENTS.md`, nem qualquer configuração de branch protection do GitHub neste plano — é avaliação, não implementação. +- Todo o relatório é escrito em português, alinhado com a documentação do projeto (`CLAUDE.md`: "Project documentation, code comments, and UI copy are in Portuguese"). +- `gh` já está autenticado nesta máquina como `hbgit` contra `hbgit/SinalACS` — todos os comandos abaixo assumem isso; não é necessário `gh auth login`. +- Os números, IDs de run e trechos de log abaixo foram capturados em 2026-09-28. Ao executar este plano, rode os comandos novamente: se os números tiverem mudado, registre o valor atual e anote a divergência como parte do achado (não copie os números antigos cegamente). +- Nenhum dado de paciente real é tocado por este plano — não se aplica a regra de nunca commitar dados de paciente, mas vale registrar se algum log de CI expuser algo sensível (ver Task 4). + +--- + +## File Structure + +- Create: `docs/ci-audit/2026-09-28-avaliacao-ci-develop.md` — o relatório de avaliação, construído seção por seção ao longo das 5 tasks. É o único artefato produzido por este plano. + +**Interfaces (convenção usada em todas as tasks):** +- Cada task **produz** uma seção `## N. ` no relatório, numerada sequencialmente, e zero ou mais achados `FINDING-N` (numeração global e crescente entre seções, não reiniciando por seção). +- Cada task **consome** o arquivo do jeito que a task anterior o deixou (append-only; nenhuma task edita uma seção escrita por outra). +- A Task 5 consome todos os `FINDING-N` das Tasks 1–4 e produz a seção final `## 5. Backlog priorizado`. + +--- + +### Task 1: Inventário estático do workflow e comparação com a documentação + +**Files:** +- Create: `docs/ci-audit/2026-09-28-avaliacao-ci-develop.md` +- Read: `.github/workflows/ci.yml`, `AGENTS.md:138` + +**Interfaces:** +- Consumes: nada (primeira task). +- Produces: seção `## 1. Inventário do workflow`, achados `FINDING-1` e `FINDING-2`. Tasks seguintes (2–5) referenciam a lista de 8 jobs estabelecida aqui. + +- [ ] **Step 1: Criar o esqueleto do relatório** + +Crie o arquivo com o cabeçalho abaixo (não inclua ainda as seções 2–5, elas vêm nas próximas tasks): + +```markdown +# Avaliação do CI adotado no branch develop + +Data da avaliação: 2026-09-28 +Workflow avaliado: `.github/workflows/ci.yml` (branch `develop`, HEAD em `74d89c0`) +Repositório: `hbgit/SinalACS` + +## 1. Inventário do workflow +``` + +- [ ] **Step 2: Listar os 8 jobs atuais com trigger, runner e ações-chave** + +Leia `.github/workflows/ci.yml` e preencha esta tabela no relatório (dados já extraídos, confirme contra o arquivo atual antes de colar): + +```markdown +| Job | Working dir | Runner | Ações principais | Observação | +|---|---|---|---|---| +| `serverpod-backend` | `backend` | ubuntu-latest | dart-lang/setup-dart@v1 (Dart 3.12.2) | Postgres efêmero na porta 9090; gera `config/passwords.yaml` inline | +| `backend-docker-build` | raiz do workspace | ubuntu-latest | `docker build` | Contexto na raiz porque o Dockerfile precisa do `pubspec.lock` compartilhado | +| `patient-app` | `apps/patient` | ubuntu-latest | subosito/flutter-action@v2 (Flutter 3.44.8) | Cria `assets/certs/` vazio antes do `flutter analyze`/`flutter test` | +| `acs-app` | `apps/acs` | ubuntu-latest | subosito/flutter-action@v2 (Flutter 3.44.8) | Mesmo motivo do `patient-app` | +| `admin-app` | `apps/admin` | ubuntu-latest | subosito/flutter-action@v2 (Flutter 3.44.8) | Sem o passo de `assets/certs/` — admin não declara esse asset | +| `coverage-report` | raiz | ubuntu-latest | dart-lang/setup-dart@v1 + subosito/flutter-action@v2 | Sobe `docker compose --profile test up -d postgres-test`; roda `scripts/qa/measure_coverage.sh`; upload de artifact `coverage-reports` | +| `android-e2e` | raiz | ubuntu-latest | actions/setup-java@v4, subosito/flutter-action@v2, reactivecircus/android-emulator-runner@v2 | `timeout-minutes: 60`; único job com emulador Android; usa `secrets.GOOGLE_MAPS_API_KEY` | +| `admin-android-build` | `apps/admin` | ubuntu-latest | actions/setup-java@v4, subosito/flutter-action@v2 | Único job que roda Gradle de verdade (`flutter build apk --debug`) | +``` + +- [ ] **Step 3: Comparar com o inventário documentado em `AGENTS.md`** + +Rode: + +```bash +grep -n "o CI do projeto valida" AGENTS.md +``` + +Saída esperada (linha 138, confirme o número atual): + +``` +138:- o CI do projeto valida seis jobs separados: `serverpod-backend`, `backend-docker-build`, `patient-app`, `acs-app`, `admin-app` e `admin-android-build` (único que executa Gradle, compilando o APK do admin). +``` + +Escreva no relatório: + +```markdown +**FINDING-1 (severidade: baixa — documentação desatualizada).** `AGENTS.md:138` descreve o CI como "seis jobs separados", listando `serverpod-backend`, `backend-docker-build`, `patient-app`, `acs-app`, `admin-app` e `admin-android-build`. O workflow atual tem **8 jobs**: os seis citados mais `coverage-report` (adicionado em `c27ffe3`) e `android-e2e` (adicionado depois, hoje o job mais instável — ver FINDING-4). `AGENTS.md` nunca foi atualizado quando esses dois jobs entraram. Recomendação: atualizar a linha 138 para listar os 8 jobs, ou trocar a enumeração por uma referência ao arquivo (`ver .github/workflows/ci.yml`) para não repetir o mesmo tipo de drift. +``` + +- [ ] **Step 4: Verificar a cobertura de triggers** + +Leia o bloco `on:` do workflow (linhas 3–6): + +```yaml +on: + push: + branches: [main, master] + pull_request: +``` + +Escreva no relatório: + +```markdown +**FINDING-2 (severidade: informativa).** `push` só dispara CI em `main`/`master` — um push direto em `develop` (sem PR aberto) **não** roda o workflow. A cobertura de `develop` hoje depende inteiramente de haver um `pull_request` aberto tendo `develop` como head ou base. Na prática há sempre uma PR de integração `develop → main` aberta (ver Seção 2), então o branch é coberto — mas isso é um efeito colateral do fluxo de trabalho atual, não uma garantia do workflow. Se alguém commitar direto em `develop` sem PR, o CI simplesmente não roda para aquele commit. +``` + +- [ ] **Step 5 (opcional, validação de sintaxe): rodar actionlint via Docker** + +```bash +docker run --rm -v "$PWD":/repo -w /repo rhysd/actionlint:latest -color +``` + +Se a imagem não estiver disponível localmente, o `docker run` vai baixá-la (~15 MB). Se o comando reportar problemas, adicione-os ao relatório como `FINDING-2a` (mesma severidade que o achado mais grave reportado pelo actionlint); se sair limpo, registre uma linha `actionlint: sem achados (2026-09-28)` ao final da Seção 1, sem criar um FINDING novo. + +- [ ] **Step 6: Commit** + +```bash +git add docs/ci-audit/2026-09-28-avaliacao-ci-develop.md +git commit -m "docs(ci-audit): inventariar jobs do CI e comparar com AGENTS.md" +``` + +--- + +### Task 2: Levantar a saúde histórica das execuções de CI em develop + +**Files:** +- Modify: `docs/ci-audit/2026-09-28-avaliacao-ci-develop.md` (append) + +**Interfaces:** +- Consumes: lista de 8 jobs da Seção 1 (Task 1) para nomear os jobs que falharam. +- Produces: seção `## 2. Saúde histórica das execuções (develop)`, achado `FINDING-3`. A Task 3 depende do run ID mais recente identificado aqui para investigar o estado atual. + +- [ ] **Step 1: Listar as últimas execuções de CI cujo head branch é `develop`** + +```bash +gh run list --branch develop --limit 15 --json databaseId,status,conclusion,createdAt,event,headBranch,displayTitle +``` + +Saída capturada em 2026-09-28 (confirme se mudou — se houver runs mais recentes, use-as e ajuste as contagens abaixo): + +``` +15 execuções, todas do evento "pull_request" (PR #7, "docs: add lgpd_data_audit.md ..."), de 2026-09-15T14:41:50Z a 2026-09-25T17:52:44Z: +- 1 "cancelled" (a mais recente, 36169873626 — android-e2e estourou o timeout de 60 min) +- 14 "failure" +- 0 "success" +``` + +- [ ] **Step 2: Para cada execução com falha, identificar qual job falhou** + +Rode para cada `databaseId` da lista (substitua pelos IDs reais obtidos no Step 1): + +```bash +gh run view --json jobs -q '.jobs[] | select(.conclusion!="success") | "\(.name): \(.conclusion) (\(.startedAt) -> \(.completedAt))"' +``` + +Amostra já coletada (6 de 15 runs, cobrindo do início ao fim do intervalo): + +``` +34983384439 (2026-09-15): acs-app: failure +34990215500 (2026-09-15): acs-app: failure +35107542667 (2026-09-16): acs-app: failure +35619580425 (2026-09-21): android-e2e: failure +35867558228 (2026-09-23): android-e2e: failure +36143685083 (2026-09-25): acs-app: failure, android-e2e: failure +``` + +- [ ] **Step 3: Ler o log da falha mais recente de `acs-app` na amostra** + +```bash +gh run view 36143685083 --json jobs -q '.jobs[] | select(.name=="acs-app") | .databaseId' +# use o ID retornado no comando abaixo +gh run view --log-failed --job= +``` + +Trecho relevante já capturado: + +``` +info • The import of 'package:test_api/scaffolding.dart' is unnecessary because all of the used elements are also provided by the import of 'package:flutter_test/flutter_test.dart' • integration_test/red_alert_cycle_test.dart:63:8 • unnecessary_import +info • The imported package 'test_api' isn't a dependency of the importing package • integration_test/red_alert_cycle_test.dart:63:8 • depend_on_referenced_packages +2 issues found. (ran in 9.1s) +##[error]Process completed with exit code 1. +``` + +Nota: ambos os lints são de severidade `info`, mas `flutter analyze` mesmo assim saiu com exit code 1 — ou o `analysis_options.yaml` de `apps/acs` eleva esses lints a erro, ou o comportamento padrão do analyzer nesta versão do Flutter trata qualquer achado (mesmo `info`) como falha do processo. Isso já foi corrigido no commit `9f21b9d` ("fix(ci): remove import obsoleto de test_api que quebrava o flutter analyze"), que é posterior a este run — ou seja, esta causa específica já está resolvida na branch atual. Registre isso no achado como contexto, não como problema em aberto. + +- [ ] **Step 4: Escrever o achado consolidado** + +```markdown +## 2. Saúde histórica das execuções (develop) + +**FINDING-3 (severidade: alta — CI vermelho não é exceção, é a norma).** As últimas 15 execuções do CI contra a PR de integração `develop → main` (PR #7), cobrindo 10 dias corridos (2026-09-15 a 2026-09-25), tiveram **0 sucessos**: 14 falhas e 1 cancelamento por timeout. Duas causas se alternam: +1. `acs-app` falhando em `flutter analyze` por lints `info` em `integration_test/red_alert_cycle_test.dart` (corrigido em `9f21b9d`, posterior à maioria destas execuções). +2. `android-e2e` falhando ou estourando o timeout de 60 minutos (recorrente em 21, 23 e 25/09; ver FINDING-4 para o estado após a última tentativa de correção). + +Conclusão: durante toda a janela observada, não houve um único run totalmente verde da PR que leva `develop` para `main`. Isso é consistente com a ausência de branch protection (FINDING-5): nada no GitHub bloqueou o trabalho de continuar avançando apesar do CI vermelho, então o sinal vermelho não gerou pressão para corrigir a causa raiz — apenas se acumulou. +``` + +- [ ] **Step 5: Commit** + +```bash +git add docs/ci-audit/2026-09-28-avaliacao-ci-develop.md +git commit -m "docs(ci-audit): registrar saude historica das execucoes em develop" +``` + +--- + +### Task 3: Confirmar o estado atual (HEAD) e isolar a causa aberta em android-e2e + +**Files:** +- Modify: `docs/ci-audit/2026-09-28-avaliacao-ci-develop.md` (append) + +**Interfaces:** +- Consumes: nada de tasks anteriores além do arquivo do relatório em si. +- Produces: seção `## 3. Estado atual da HEAD`, achado `FINDING-4`. A Task 5 eleva este achado a topo do backlog (é o único `crítica`). + +- [ ] **Step 1: Identificar a execução de CI mais recente contra o HEAD atual** + +```bash +git log -1 --format='%H %ci' HEAD +gh run list --limit 10 --json databaseId,status,conclusion,createdAt,headBranch,event,headSha +``` + +Capturado em 2026-09-28: HEAD é `74d89c0` (2026-09-25 19:46:48 +0000). O run mais recente e relevante é `36179475405`, evento `push` em `main`, `headSha` `543cded` (merge commit que inclui `74d89c0`). + +- [ ] **Step 2: Inspecionar os jobs dessa execução** + +```bash +gh run view 36179475405 +``` + +Saída capturada: + +``` +JOBS +✓ coverage-report ✓ admin-app ✓ admin-android-build +✓ acs-app ✓ serverpod-backend ✓ backend-docker-build +✓ patient-app +X android-e2e in 21m58s +``` + +7 de 8 jobs verdes. `android-e2e` é o único vermelho — e desta vez não é timeout (terminou em 21m58s, bem dentro do limite de 60 min): é falha real de teste. + +- [ ] **Step 3: Ler o detalhe da falha de `android-e2e`** + +```bash +gh run view --job=108218045718 | grep -A5 -i "error\|failed\|passed" +``` + +Saída capturada: + +``` +X The process '/usr/bin/sh' failed with exit code 1 +X 0 tests passed, 1 failed. +X 0 tests passed, 1 failed. +``` + +Duas linhas "0 tests passed, 1 failed" — uma por app (patient e acs), já que `scripts/qa/run_android_e2e.sh` roda o smoke de cada um. Compare com o estado da Task 2 (run `36143685083`, 2026-09-25T13:51): lá a mensagem era **"10 tests passed, 4 failed"** — passagem parcial. Agora, após o commit `74d89c0` ("perf(ci): android-e2e com KVM, caches e smoke de um teste por app"), o resultado é **0 passando** nos dois apps. + +- [ ] **Step 4: Escrever o achado** + +```markdown +## 3. Estado atual da HEAD + +**FINDING-4 (severidade: crítica — regressão no único job que exercita o device de verdade).** No HEAD atual (`74d89c0`, run `36179475405`), 7 dos 8 jobs do CI estão verdes. O único vermelho é `android-e2e`, e a falha não é mais o timeout de 60 min observado na Seção 2 — o job termina em 21m58s com **"0 tests passed, 1 failed"** tanto para `apps/patient` quanto para `apps/acs`. Isso é uma regressão em relação ao estado imediatamente anterior (run `36143685083`, mesma PR, "10 tests passed, 4 failed" — passagem parcial). O commit mais recente na branch, `74d89c0`, reduziu o escopo do job para "smoke de um teste por app" com o objetivo de reduzir o tempo de execução (documentado no próprio commit e nos comentários do workflow sobre cache de AVD/Gradle) — mas o efeito colateral observado é que os dois smokes agora falham 100% das vezes, não uma fração. + +`android-e2e` é o único job do repositório que sobe um emulador Android real e exercita o ciclo completo (per `AGENTS.md`/`CLAUDE.md`: MQTT com TLS, fila offline, ciclo de alerta vermelho). Com ele quebrado, o CI perdeu a única cobertura que valida esse comportamento fim-a-fim — os outros 7 jobs (`flutter analyze`/`flutter test` unitário, build de APK do admin, testes de integração do backend) não cobrem o que `android-e2e` cobre. + +Recomendação: tratar isso como o item de maior prioridade do backlog (Seção 5) e abrir uma sessão dedicada com a skill `superpowers:systematic-debugging`, usando `scripts/qa/run_android_e2e.sh` como ponto de entrada e o artifact `android-e2e-metrics` (quando presente) como fonte de dados de latência. +``` + +- [ ] **Step 5: Commit** + +```bash +git add docs/ci-audit/2026-09-28-avaliacao-ci-develop.md +git commit -m "docs(ci-audit): confirmar regressao atual do job android-e2e na HEAD" +``` + +--- + +### Task 4: Avaliar governança e segurança do pipeline + +**Files:** +- Modify: `docs/ci-audit/2026-09-28-avaliacao-ci-develop.md` (append) +- Read: `.github/workflows/ci.yml` + +**Interfaces:** +- Consumes: nada de tasks anteriores além do arquivo do relatório. +- Produces: seção `## 4. Governança e segurança`, achados `FINDING-5`, `FINDING-6`, `FINDING-7`. + +- [ ] **Step 1: Verificar proteção de branch** + +```bash +gh api repos/:owner/:repo/branches/develop/protection +gh api repos/:owner/:repo/branches/main/protection +``` + +Saída capturada em 2026-09-28 (ambos idênticos): + +```json +{"message":"Branch not protected","documentation_url":"https://docs.github.com/rest/branches/branch-protection#get-branch-protection","status":"404"} +``` + +Escreva: + +```markdown +## 4. Governança e segurança + +**FINDING-5 (severidade: alta — CI não é um gate, é só um relatório).** Nem `develop` nem `main` têm branch protection configurada no GitHub (ambos retornam 404 "Branch not protected"). Isso significa que nenhum check de CI é obrigatório para merge — combinado ao FINDING-3 (15 execuções seguidas vermelhas na PR de integração), fica claro que o CI vermelho nunca impediu ninguém de continuar commitando ou, potencialmente, de fazer merge. O invariante do projeto "Red alerts must never be silently dropped" (`CLAUDE.md`) é sobre o produto, mas o mesmo princípio de "nunca falhar silenciosamente" vale para o pipeline que valida esse produto — hoje ele pode falhar silenciosamente (ninguém é bloqueado) por dias. +``` + +- [ ] **Step 2: Verificar ações desatualizadas e prazos de infraestrutura** + +Reaproveite as annotations já coletadas nas Tasks 2 e 3 (aparecem em toda execução, independente do job): + +```markdown +**FINDING-6 (severidade: média — dívida de infraestrutura com prazo concreto).** Toda execução do CI emite estas annotations, hoje ignoradas: +- `actions/checkout@v4`, `actions/cache@v4`, `actions/setup-java@v4`, `actions/upload-artifact@v4` — todas ainda no Node.js 20, forçadas a rodar em Node 24 pelo runner (aviso de depreciação da GitHub, não bloqueante ainda). +- `setup-java v4` está deprecated; o vendor recomenda migrar para `setup-java@v5`. +- `ubuntu-latest` migra para Ubuntu 26 a partir de **2026-10-19** — a **três semanas** da data desta avaliação (2026-09-28). Como nenhum job fixa a versão da imagem (todos usam `ubuntu-latest`), essa migração vai acontecer automaticamente e sem aviso prévio no dia, podendo quebrar qualquer job que dependa implicitamente de pacotes/versões do Ubuntu 24 atual (candidatos mais prováveis: `android-e2e`, que já depende de KVM e de uma regra de udev específica, e `admin-android-build`/`acs-app`/`patient-app`, que dependem do NDK/CMake cacheado). + +Recomendação: fixar as ações em versões mais novas (`checkout@v5`, `setup-java@v5`, etc.) num PR pequeno e isolado, e rodar o pipeline uma vez contra uma imagem `ubuntu-24.04` explícita antes de 2026-10-19 para confirmar que nada quebra com a migração. +``` + +- [ ] **Step 3: Verificar uso de segredos e ausência de controle de concorrência** + +```bash +grep -n "secrets\." .github/workflows/ci.yml +grep -n "^concurrency:" .github/workflows/ci.yml +``` + +Saída esperada: só uma ocorrência de `secrets.` (`GOOGLE_MAPS_API_KEY` em `android-e2e`), e nenhuma ocorrência de `concurrency:`. Escreva: + +```markdown +**Nota positiva (não é finding).** O único segredo real referenciado é `secrets.GOOGLE_MAPS_API_KEY`. `CI_POSTGRES_PASSWORD` é deliberadamente hardcoded e documentado no próprio workflow como não-segredo (banco efêmero, existe só dentro do runner) — não há vazamento de credencial de dev/prod no pipeline. + +**FINDING-7 (severidade: baixa — desperdício de minutos de CI).** Não há bloco `concurrency:` no workflow. Cada novo push à mesma PR dispara uma execução completa sem cancelar a anterior. Isso é particularmente caro para `android-e2e`, que sozinho pode consumir até 60 minutos por tentativa (timeout configurado) — e a Seção 2 mostrou 15 tentativas em 10 dias na mesma PR. Um bloco como: + +\`\`\`yaml +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +\`\`\` + +cancelaria runs obsoletos assim que um novo commit chegasse na mesma PR/branch. +``` + +- [ ] **Step 4: Checar se algum log de CI expôs algo sensível** + +```bash +gh run view --log --job=108186514572 2>&1 | grep -i "senha\|password\|secret\|token" | grep -v "CI_POSTGRES_PASSWORD\|GOOGLE_MAPS_API_KEY\|passwords.yaml" +``` + +(Job usado como amostra: `serverpod-backend` do run `36169873626`, por ser o que gera `config/passwords.yaml`.) Se a saída for vazia, registre uma linha `Nenhum segredo exposto em log encontrado na amostra verificada.` ao final da Seção 4. Se aparecer algo, isso vira um `FINDING` novo de severidade `crítica` (vazamento de segredo em log é sempre crítico) — pare e escale para o usuário antes de continuar, já que pode exigir rotação imediata de credencial fora do escopo deste plano. + +- [ ] **Step 5: Commit** + +```bash +git add docs/ci-audit/2026-09-28-avaliacao-ci-develop.md +git commit -m "docs(ci-audit): avaliar governanca, acoes desatualizadas e segredos do CI" +``` + +--- + +### Task 5: Consolidar o backlog priorizado e fechar a avaliação + +**Files:** +- Modify: `docs/ci-audit/2026-09-28-avaliacao-ci-develop.md` (append seção final + inserir resumo executivo no topo) + +**Interfaces:** +- Consumes: `FINDING-1` a `FINDING-7` (e qualquer `FINDING-2a`/finding extra das Tasks 1 e 4) escritos pelas tasks anteriores. +- Produces: seção `## 5. Backlog priorizado` e um bloco `## Resumo executivo` inserido logo após o título do relatório. Este é o artefato final entregue ao usuário. + +- [ ] **Step 1: Reler o relatório inteiro e listar todos os FINDINGs por número** + +```bash +grep -n "^\*\*FINDING-" docs/ci-audit/2026-09-28-avaliacao-ci-develop.md +``` + +Confirme que a sequência é contígua (FINDING-1, FINDING-2, [FINDING-2a opcional], FINDING-3 ... FINDING-7, mais qualquer achado extra da Task 4/Step 4). Se algum número estiver faltando ou duplicado, corrija antes de prosseguir — não adivinhe o que estava lá, releia a seção correspondente. + +- [ ] **Step 2: Escrever a seção de backlog, ordenada por severidade** + +Insira ao final do arquivo: + +```markdown +## 5. Backlog priorizado + +Ordenado por severidade (crítica → alta → média → baixa → informativa). Nenhum item foi implementado por este plano — é avaliação, não execução. + +| # | Severidade | Achado | Ação recomendada | Como executar | +|---|---|---|---|---| +| FINDING-4 | Crítica | `android-e2e` com 0 testes passando nos dois apps na HEAD atual | Investigar a regressão introduzida por `74d89c0` | `superpowers:systematic-debugging`, entry point `scripts/qa/run_android_e2e.sh` | +| FINDING-3 | Alta | CI da PR de integração develop→main ficou vermelho em 15/15 execuções ao longo de 10 dias | Não fechar a PR até um run 100% verde; tratar vermelho como bloqueante de fato, já que não é bloqueante de configuração | Prática de time, não requer mudança de código | +| FINDING-5 | Alta | Nem `main` nem `develop` têm branch protection | Configurar required status checks (mínimo: todos os jobs exceto talvez `android-e2e` até FINDING-4 ser resolvido) | Configuração no GitHub (Settings → Branches), fora do repositório | +| FINDING-6 | Média | Ações do workflow desatualizadas; `ubuntu-latest` migra para Ubuntu 26 em 2026-10-19 (3 semanas) | PR isolado fixando `checkout@v5`, `setup-java@v5` etc.; testar contra `ubuntu-24.04` explícito antes do prazo | PR pequeno em `.github/workflows/ci.yml` | +| FINDING-1 | Baixa | `AGENTS.md:138` descreve 6 jobs, workflow tem 8 | Atualizar a linha ou trocar por referência ao arquivo | Edição de doc | +| FINDING-7 | Baixa | Sem `concurrency:`, runs obsoletos não são cancelados | Adicionar bloco `concurrency` ao workflow | PR pequeno em `.github/workflows/ci.yml` | +| FINDING-2 | Informativa | Push direto em `develop` sem PR não dispara CI | Nenhuma ação obrigatória; documentar o comportamento esperado se o time quiser confiar nisso | N/A | + +Follow-up sugerido: um plano de implementação separado (via `superpowers:writing-plans` de novo) para os itens de severidade alta/média, começando por FINDING-4 já que ele está bloqueando a única cobertura de dispositivo real do projeto. +``` + +- [ ] **Step 3: Inserir o resumo executivo no topo do relatório** + +Logo após a linha `Repositório: \`hbgit/SinalACS\`` e antes de `## 1. Inventário do workflow`, insira: + +```markdown + +## Resumo executivo + +O pipeline tem 8 jobs (Seção 1), mas a documentação (`AGENTS.md`) só conhece 6 — sinal de que o workflow evoluiu sem o resto do repositório acompanhar. Nos últimos 10 dias, a PR que integra `develop` em `main` nunca teve uma execução 100% verde (Seção 2): a causa mudou ao longo do tempo, mas hoje (HEAD `74d89c0`) está concentrada inteiramente no job `android-e2e`, que regrediu para 0 testes passando em ambos os apps depois da última tentativa de otimização de tempo de execução (Seção 3). Isso importa mais do que pareceria à primeira vista porque nem `main` nem `develop` têm branch protection (Seção 4) — ou seja, o CI vermelho nunca foi, de fato, um gate; foi só um relatório que ninguém era obrigado a atender. Prioridade imediata: FINDING-4 (regressão do `android-e2e`); estrutural: FINDING-5 (branch protection) para que corrigir o CI passe a valer a pena. +``` + +- [ ] **Step 4: Commit final** + +```bash +git add docs/ci-audit/2026-09-28-avaliacao-ci-develop.md +git commit -m "docs(ci-audit): consolidar backlog priorizado e resumo executivo da avaliacao de CI" +``` From be2dd95ab4579cd8eac1a319b853e938953debec Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Mon, 28 Sep 2026 18:10:07 -0400 Subject: [PATCH 012/498] chore: add headroom setting up Signed-off-by: Herbert Rocha --- .claude/.headroom_wrap_settings.lock | 0 .serena/.gitignore | 2 + .serena/project.yml | 169 +++ node_modules/.package-lock.json | 36 + node_modules/headroom-ai/README.md | 394 ++++++ .../headroom-ai/dist/adapters/anthropic.cjs | 176 +++ .../dist/adapters/anthropic.cjs.map | 1 + .../headroom-ai/dist/adapters/anthropic.d.cts | 29 + .../headroom-ai/dist/adapters/anthropic.d.ts | 29 + .../headroom-ai/dist/adapters/anthropic.js | 174 +++ .../dist/adapters/anthropic.js.map | 1 + .../headroom-ai/dist/adapters/gemini.cjs | 41 + .../headroom-ai/dist/adapters/gemini.cjs.map | 1 + .../headroom-ai/dist/adapters/gemini.d.cts | 31 + .../headroom-ai/dist/adapters/gemini.d.ts | 31 + .../headroom-ai/dist/adapters/gemini.js | 39 + .../headroom-ai/dist/adapters/gemini.js.map | 1 + .../headroom-ai/dist/adapters/openai.cjs | 45 + .../headroom-ai/dist/adapters/openai.cjs.map | 1 + .../headroom-ai/dist/adapters/openai.d.cts | 31 + .../headroom-ai/dist/adapters/openai.d.ts | 31 + .../headroom-ai/dist/adapters/openai.js | 43 + .../headroom-ai/dist/adapters/openai.js.map | 1 + .../headroom-ai/dist/adapters/vercel-ai.cjs | 60 + .../dist/adapters/vercel-ai.cjs.map | 1 + .../headroom-ai/dist/adapters/vercel-ai.d.cts | 62 + .../headroom-ai/dist/adapters/vercel-ai.d.ts | 62 + .../headroom-ai/dist/adapters/vercel-ai.js | 55 + .../dist/adapters/vercel-ai.js.map | 1 + .../headroom-ai/dist/chunk-24W6JPRV.js | 1105 ++++++++++++++++ .../headroom-ai/dist/chunk-24W6JPRV.js.map | 1 + .../headroom-ai/dist/chunk-26N6BVLT.cjs | 1136 +++++++++++++++++ .../headroom-ai/dist/chunk-26N6BVLT.cjs.map | 1 + node_modules/headroom-ai/dist/index.cjs | 458 +++++++ node_modules/headroom-ai/dist/index.cjs.map | 1 + node_modules/headroom-ai/dist/index.d.cts | 708 ++++++++++ node_modules/headroom-ai/dist/index.d.ts | 708 ++++++++++ node_modules/headroom-ai/dist/index.js | 323 +++++ node_modules/headroom-ai/dist/index.js.map | 1 + .../headroom-ai/dist/types-Cjdl75F4.d.cts | 327 +++++ .../headroom-ai/dist/types-Cjdl75F4.d.ts | 327 +++++ node_modules/headroom-ai/package.json | 112 ++ package-lock.json | 41 + package.json | 5 + 44 files changed, 6802 insertions(+) create mode 100644 .claude/.headroom_wrap_settings.lock create mode 100644 .serena/.gitignore create mode 100644 .serena/project.yml create mode 100644 node_modules/.package-lock.json create mode 100644 node_modules/headroom-ai/README.md create mode 100644 node_modules/headroom-ai/dist/adapters/anthropic.cjs create mode 100644 node_modules/headroom-ai/dist/adapters/anthropic.cjs.map create mode 100644 node_modules/headroom-ai/dist/adapters/anthropic.d.cts create mode 100644 node_modules/headroom-ai/dist/adapters/anthropic.d.ts create mode 100644 node_modules/headroom-ai/dist/adapters/anthropic.js create mode 100644 node_modules/headroom-ai/dist/adapters/anthropic.js.map create mode 100644 node_modules/headroom-ai/dist/adapters/gemini.cjs create mode 100644 node_modules/headroom-ai/dist/adapters/gemini.cjs.map create mode 100644 node_modules/headroom-ai/dist/adapters/gemini.d.cts create mode 100644 node_modules/headroom-ai/dist/adapters/gemini.d.ts create mode 100644 node_modules/headroom-ai/dist/adapters/gemini.js create mode 100644 node_modules/headroom-ai/dist/adapters/gemini.js.map create mode 100644 node_modules/headroom-ai/dist/adapters/openai.cjs create mode 100644 node_modules/headroom-ai/dist/adapters/openai.cjs.map create mode 100644 node_modules/headroom-ai/dist/adapters/openai.d.cts create mode 100644 node_modules/headroom-ai/dist/adapters/openai.d.ts create mode 100644 node_modules/headroom-ai/dist/adapters/openai.js create mode 100644 node_modules/headroom-ai/dist/adapters/openai.js.map create mode 100644 node_modules/headroom-ai/dist/adapters/vercel-ai.cjs create mode 100644 node_modules/headroom-ai/dist/adapters/vercel-ai.cjs.map create mode 100644 node_modules/headroom-ai/dist/adapters/vercel-ai.d.cts create mode 100644 node_modules/headroom-ai/dist/adapters/vercel-ai.d.ts create mode 100644 node_modules/headroom-ai/dist/adapters/vercel-ai.js create mode 100644 node_modules/headroom-ai/dist/adapters/vercel-ai.js.map create mode 100644 node_modules/headroom-ai/dist/chunk-24W6JPRV.js create mode 100644 node_modules/headroom-ai/dist/chunk-24W6JPRV.js.map create mode 100644 node_modules/headroom-ai/dist/chunk-26N6BVLT.cjs create mode 100644 node_modules/headroom-ai/dist/chunk-26N6BVLT.cjs.map create mode 100644 node_modules/headroom-ai/dist/index.cjs create mode 100644 node_modules/headroom-ai/dist/index.cjs.map create mode 100644 node_modules/headroom-ai/dist/index.d.cts create mode 100644 node_modules/headroom-ai/dist/index.d.ts create mode 100644 node_modules/headroom-ai/dist/index.js create mode 100644 node_modules/headroom-ai/dist/index.js.map create mode 100644 node_modules/headroom-ai/dist/types-Cjdl75F4.d.cts create mode 100644 node_modules/headroom-ai/dist/types-Cjdl75F4.d.ts create mode 100644 node_modules/headroom-ai/package.json create mode 100644 package-lock.json create mode 100644 package.json diff --git a/.claude/.headroom_wrap_settings.lock b/.claude/.headroom_wrap_settings.lock new file mode 100644 index 00000000..e69de29b diff --git a/.serena/.gitignore b/.serena/.gitignore new file mode 100644 index 00000000..2e510aff --- /dev/null +++ b/.serena/.gitignore @@ -0,0 +1,2 @@ +/cache +/project.local.yml diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 00000000..595ed722 --- /dev/null +++ b/.serena/project.yml @@ -0,0 +1,169 @@ +# 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/node_modules/.package-lock.json b/node_modules/.package-lock.json new file mode 100644 index 00000000..89f23485 --- /dev/null +++ b/node_modules/.package-lock.json @@ -0,0 +1,36 @@ +{ + "name": "SinalACS", + "lockfileVersion": 3, + "requires": true, + "packages": { + "node_modules/headroom-ai": { + "version": "0.39.1", + "resolved": "https://registry.npmjs.org/headroom-ai/-/headroom-ai-0.39.1.tgz", + "integrity": "sha512-jMItSdHA444lCXxibw/XlyhPt9vp09LuLgLNVG6/1Ay0lOZEUhrwlezsMMhr98RMgIe9AejhIQef91cvUgiWgA==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@ai-sdk/provider": ">=1.0.0", + "@anthropic-ai/sdk": ">=0.30.0", + "ai": ">=6.0.0", + "openai": ">=4.0.0" + }, + "peerDependenciesMeta": { + "@ai-sdk/provider": { + "optional": true + }, + "@anthropic-ai/sdk": { + "optional": true + }, + "ai": { + "optional": true + }, + "openai": { + "optional": true + } + } + } + } +} diff --git a/node_modules/headroom-ai/README.md b/node_modules/headroom-ai/README.md new file mode 100644 index 00000000..b4200bca --- /dev/null +++ b/node_modules/headroom-ai/README.md @@ -0,0 +1,394 @@ +# headroom-ai + +Compress LLM context. Save tokens. Fit more into every request. + +## Install + +```bash +npm install headroom-ai +``` + +## Quick Start + +```typescript +import { compress } from 'headroom-ai'; + +const result = await compress(messages, { model: 'gpt-4o' }); +console.log(`Saved ${result.tokensSaved} tokens (${((1 - result.compressionRatio) * 100).toFixed(0)}%)`); + +// Use compressed messages with any LLM client +const response = await openai.chat.completions.create({ + model: 'gpt-4o', + messages: result.messages, +}); +``` + +Requires a running Headroom proxy (`headroom proxy`). + +## Framework Adapters + +### Vercel AI SDK + +```typescript +import { withHeadroom } from 'headroom-ai/vercel-ai'; +import { openai } from '@ai-sdk/openai'; +import { generateText } from 'ai'; + +const model = withHeadroom(openai('gpt-4o')); +const { text } = await generateText({ model, messages }); +``` + +
+Advanced: using middleware directly + +```typescript +import { headroomMiddleware } from 'headroom-ai/vercel-ai'; +import { wrapLanguageModel } from 'ai'; + +const model = wrapLanguageModel({ + model: openai('gpt-4o'), + middleware: headroomMiddleware({ baseUrl: 'http://localhost:8787' }), +}); +``` + +
+ +### OpenAI SDK + +```typescript +import { withHeadroom } from 'headroom-ai/openai'; +import OpenAI from 'openai'; + +const client = withHeadroom(new OpenAI()); +const response = await client.chat.completions.create({ + model: 'gpt-4o', + messages: longConversation, +}); +``` + +### Anthropic SDK + +```typescript +import { withHeadroom } from 'headroom-ai/anthropic'; +import Anthropic from '@anthropic-ai/sdk'; + +const client = withHeadroom(new Anthropic()); +const response = await client.messages.create({ + model: 'claude-sonnet-4-5-20250929', + messages: longConversation, + max_tokens: 1024, +}); +``` + +### Google Gemini + +```typescript +import { withHeadroom } from 'headroom-ai/gemini'; +import { GoogleGenerativeAI } from '@google/generative-ai'; + +const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY!); +const model = withHeadroom(genAI.getGenerativeModel({ model: 'gemini-2.0-flash' })); + +const result = await model.generateContent({ + contents: longConversation, +}); +``` + +## HeadroomClient + +The full client provides direct access to the proxy's OpenAI and Anthropic passthrough endpoints, plus metrics, CCR, and observability. + +```typescript +import { HeadroomClient } from 'headroom-ai'; + +const client = new HeadroomClient({ + baseUrl: 'http://localhost:8787', + providerApiKey: process.env.OPENAI_API_KEY, + config: { + smartCrusher: { enabled: true, maxItemsAfterCrush: 10 }, + ccr: { enabled: true }, + }, +}); +``` + +### Chat Completions (OpenAI-style) + +```typescript +const response = await client.chat.completions.create({ + model: 'gpt-4o', + messages: longConversation, + headroomMode: 'optimize', +}); +``` + +### Messages (Anthropic-style) + +```typescript +const response = await client.messages.create({ + model: 'claude-sonnet-4-5-20250929', + messages: longConversation, + max_tokens: 1024, + headroomMode: 'optimize', +}); +``` + +### Direct Compression + +```typescript +const result = await client.compress(messages, { model: 'gpt-4o', tokenBudget: 4000 }); +``` + +## Simulation (Dry Run) + +See what compression would do without calling the LLM. + +```typescript +import { simulate } from 'headroom-ai'; + +const sim = await simulate(messages, { model: 'gpt-4o' }); +console.log(`Would save ${sim.tokensSaved} tokens (${sim.estimatedSavings})`); +console.log('Transforms:', sim.transforms); +console.log('Waste signals:', sim.wasteSignals); +console.log('Cache alignment:', sim.cacheAlignmentScore); +``` + +Also available on the client: + +```typescript +const sim = await client.chat.completions.simulate({ + model: 'gpt-4o', + messages, +}); +``` + +## Compression Hooks + +Customize compression with pre/post hooks — matching the Python `CompressionHooks` API. + +```typescript +import { compress, CompressionHooks } from 'headroom-ai'; +import type { CompressContext, CompressEvent } from 'headroom-ai'; + +class MyHooks extends CompressionHooks { + // Modify messages before compression + preCompress(messages: any[], ctx: CompressContext) { + return [{ role: 'system', content: 'Always preserve error details.' }, ...messages]; + } + + // Set per-message importance biases + computeBiases(messages: any[], ctx: CompressContext) { + return { 0: 2.0 }; // preserve first message + } + + // Observe compression results + postCompress(event: CompressEvent) { + console.log(`Saved ${event.tokensSaved} tokens via ${event.transformsApplied.join(', ')}`); + } +} + +const result = await compress(messages, { model: 'gpt-4o', hooks: new MyHooks() }); +``` + +## SharedContext (Multi-Agent) + +Compressed inter-agent context sharing — matching the Python `SharedContext` API. + +```typescript +import { SharedContext } from 'headroom-ai'; + +const ctx = new SharedContext({ model: 'gpt-4o', ttl: 3600, maxEntries: 100 }); + +// Agent A stores data (automatically compressed) +const entry = await ctx.put('research', bigAgentOutput, { agent: 'researcher' }); +console.log(`Compressed: ${entry.savingsPercent.toFixed(0)}% savings`); + +// Agent B reads it (~80% smaller) +const summary = ctx.get('research'); + +// Agent B gets original if needed +const full = ctx.get('research', { full: true }); + +// Stats +const stats = ctx.stats(); +console.log(`${stats.entries} entries, ${stats.totalTokensSaved} tokens saved`); +``` + +## CCR Retrieve (Compress-Cache-Retrieve) + +Retrieve original content when the LLM needs full details. + +```typescript +const result = await client.compress(messages, { model: 'gpt-4o' }); + +// Later, when the LLM calls headroom_retrieve: +for (const hash of result.ccrHashes) { + const original = await client.retrieve(hash); + console.log(`${original.originalTokens} original tokens for ${original.toolName}`); +} + +// Search within compressed content +const search = await client.retrieve('abc123', { query: 'error logs' }); + +// Handle LLM tool calls in an agent loop +const toolResult = await client.handleToolCall({ + toolCall: assistantMessage.tool_calls[0], + provider: 'openai', +}); +``` + +## Metrics & Observability + +```typescript +// Proxy health +const health = await client.health(); +// → { status: 'healthy', version: '0.5.18', config: { optimize: true, ... } } + +// Proxy stats +const stats = await client.proxyStats(); +// → { requests: { total, cached, failed }, tokens: { saved, savingsPercent }, ... } + +// Request metrics +const metrics = await client.getMetrics({ model: 'gpt-4o', limit: 10 }); + +// Summary +const summary = await client.getSummary(); + +// Validate setup +const validation = await client.validateSetup(); + +// Clear cache +await client.clearCache(); + +// Prometheus metrics +const prom = await client.prometheusMetrics(); +``` + +## Telemetry, Feedback & TOIN + +Access the proxy's learning systems. + +```typescript +// Telemetry +const telemetry = await client.telemetry.getStats(); +const tools = await client.telemetry.getTools(); + +// Feedback — per-tool compression hints +const hints = await client.feedback.getHints('list_servers'); +// → { hints: { maxItems: 8, skipCompression: false, preserveFields: ['id', 'status'] } } + +// TOIN (Tool Output Intelligence Network) +const toinStats = await client.toin.getStats(); +const patterns = await client.toin.getPatterns(20); +``` + +## Configuration Types + +Full TypeScript interfaces for every Python config dataclass. + +```typescript +import type { HeadroomConfig, SmartCrusherConfig, CCRConfig } from 'headroom-ai'; + +const config: HeadroomConfig = { + defaultMode: 'optimize', + smartCrusher: { + enabled: true, + minItemsToAnalyze: 5, + maxItemsAfterCrush: 10, + varianceThreshold: 2.0, + relevance: { tier: 'hybrid', relevanceThreshold: 0.25 }, + anchor: { anchorBudgetPct: 0.25 }, + }, + ccr: { enabled: true, injectTool: true }, + cacheOptimizer: { enabled: true, autoDetectProvider: true }, + intelligentContext: { enabled: true, useImportanceScoring: true }, +}; + +const client = new HeadroomClient({ config }); +``` + +## Error Handling + +Full error hierarchy matching the Python SDK. + +```typescript +import { + HeadroomError, + HeadroomConnectionError, + HeadroomAuthError, + HeadroomCompressError, + ConfigurationError, + ProviderError, + StorageError, + TokenizationError, + CacheError, + ValidationError, + TransformError, +} from 'headroom-ai'; + +try { + await client.compress(messages); +} catch (err) { + if (err instanceof HeadroomAuthError) { + console.error('Auth failed — check HEADROOM_API_KEY'); + } else if (err instanceof HeadroomCompressError) { + console.error(`Compression error ${err.statusCode}: ${err.errorType}`); + } else if (err instanceof ConfigurationError) { + console.error('Bad config:', err.details); + } +} +``` + +## Format Detection & Conversion + +Auto-detects and converts between OpenAI, Anthropic, Vercel AI SDK, and Gemini formats. + +```typescript +import { detectFormat, toOpenAI, fromOpenAI } from 'headroom-ai'; + +const format = detectFormat(messages); // 'openai' | 'anthropic' | 'vercel' | 'gemini' +const openaiMessages = toOpenAI(messages); +const back = fromOpenAI(openaiMessages, format); +``` + +The `compress()` function handles this automatically — pass any format and get the same format back. + +## Configuration + +```typescript +import { compress } from 'headroom-ai'; + +const result = await compress(messages, { + model: 'gpt-4o', + baseUrl: 'http://localhost:8787', // proxy URL + apiKey: 'your-api-key', // optional, for authenticated endpoints + timeout: 30000, // ms + fallback: true, // return uncompressed if proxy is down (default) + retries: 1, // retry on transient failures (default) + tokenBudget: 4000, // compress to fit this limit + hooks: new MyHooks(), // pre/post compression hooks +}); +``` + +Or use environment variables: +- `HEADROOM_BASE_URL` — proxy URL +- `HEADROOM_API_KEY` — optional API key for authenticated endpoints + +## Utilities + +```typescript +// Case conversion for proxy communication +import { deepCamelCase, deepSnakeCase } from 'headroom-ai'; + +const tsObj = deepCamelCase({ tokens_before: 100 }); // { tokensBefore: 100 } +const pyObj = deepSnakeCase({ tokensBefore: 100 }); // { tokens_before: 100 } + +// SSE stream parsing +import { parseSSE, collectStream } from 'headroom-ai'; + +// Hook helpers +import { extractUserQuery, countTurns, extractToolCalls } from 'headroom-ai'; +``` + +## License + +Apache-2.0 diff --git a/node_modules/headroom-ai/dist/adapters/anthropic.cjs b/node_modules/headroom-ai/dist/adapters/anthropic.cjs new file mode 100644 index 00000000..025cbe72 --- /dev/null +++ b/node_modules/headroom-ai/dist/adapters/anthropic.cjs @@ -0,0 +1,176 @@ +'use strict'; + +var chunk26N6BVLT_cjs = require('../chunk-26N6BVLT.cjs'); + +// src/adapters/anthropic.ts +function anthropicImageUrl(source) { + if (!source || typeof source !== "object") return null; + if (source.type === "base64" && source.media_type && source.data) { + return `data:${source.media_type};base64,${source.data}`; + } + if (source.type === "url" && typeof source.url === "string") return source.url; + return null; +} +function openAIImageBlock(url) { + const m = /^data:([^;,]+);base64,(.*)$/s.exec(url); + if (m) return { type: "image", source: { type: "base64", media_type: m[1], data: m[2] } }; + return { type: "image", source: { type: "url", url } }; +} +function anthropicToOpenAI(messages) { + const result = []; + for (const msg of messages) { + if (msg.role === "user") { + if (typeof msg.content === "string") { + result.push({ role: "user", content: msg.content }); + } else if (Array.isArray(msg.content)) { + const toolResults = msg.content.filter( + (b) => b.type === "tool_result" + ); + const hasImages = msg.content.some((b) => b.type === "image"); + if (hasImages) { + const parts = msg.content.filter((b) => b.type === "text" || b.type === "image").map((b) => { + if (b.type === "text") return { type: "text", text: b.text }; + const url = anthropicImageUrl(b.source); + return url ? { type: "image_url", image_url: { url } } : null; + }).filter((p) => p !== null); + if (parts.length > 0) { + result.push({ role: "user", content: parts }); + } + } else { + const textBlocks = msg.content.filter((b) => b.type === "text"); + if (textBlocks.length > 0) { + result.push({ + role: "user", + content: textBlocks.map((b) => b.text).join("\n") + }); + } + } + for (const tr of toolResults) { + result.push({ + role: "tool", + content: typeof tr.content === "string" ? tr.content : JSON.stringify(tr.content), + tool_call_id: tr.tool_use_id + }); + } + } + continue; + } + if (msg.role === "assistant") { + if (typeof msg.content === "string") { + result.push({ role: "assistant", content: msg.content }); + } else if (Array.isArray(msg.content)) { + const textBlocks = msg.content.filter((b) => b.type === "text"); + const toolUseBlocks = msg.content.filter( + (b) => b.type === "tool_use" + ); + const content = textBlocks.length > 0 ? textBlocks.map((b) => b.text).join("\n") : null; + const openaiMsg = { role: "assistant", content }; + if (toolUseBlocks.length > 0) { + openaiMsg.tool_calls = toolUseBlocks.map( + (b) => ({ + id: b.id, + type: "function", + function: { + name: b.name, + arguments: JSON.stringify(b.input) + } + }) + ); + } + result.push(openaiMsg); + } + continue; + } + } + return result; +} +function openAIToAnthropic(messages) { + const result = []; + for (const msg of messages) { + if (msg.role === "user") { + if (typeof msg.content === "string") { + result.push({ role: "user", content: msg.content }); + } else if (Array.isArray(msg.content)) { + result.push({ + role: "user", + content: msg.content.map((p) => { + if (p.type === "text") return { type: "text", text: p.text }; + if (p.type === "image_url") return openAIImageBlock(p.image_url.url); + return { type: "text", text: "" }; + }) + }); + } + continue; + } + if (msg.role === "assistant") { + const blocks = []; + if (msg.content) blocks.push({ type: "text", text: msg.content }); + if (msg.tool_calls) { + for (const tc of msg.tool_calls) { + blocks.push({ + type: "tool_use", + id: tc.id, + name: tc.function.name, + input: JSON.parse(tc.function.arguments) + }); + } + } + result.push({ + role: "assistant", + content: blocks.length === 1 && blocks[0].type === "text" ? blocks[0].text : blocks + }); + continue; + } + if (msg.role === "tool") { + result.push({ + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: msg.tool_call_id, + content: msg.content + } + ] + }); + continue; + } + if (msg.role === "system") { + result.push({ role: "user", content: msg.content }); + } + } + return result; +} +function withHeadroom(client, options = {}) { + const originalCreate = client.messages.create.bind(client.messages); + const compressedCreate = async (params) => { + const messages = params.messages; + const model = options.model ?? params.model ?? "claude-sonnet-4-5-20250929"; + const openaiMessages = anthropicToOpenAI(messages); + const result = await chunk26N6BVLT_cjs.compress(openaiMessages, { + stack: "adapter_ts_anthropic", + ...options, + model + }); + const anthropicMessages = result.compressed ? openAIToAnthropic(result.messages) : messages; + return originalCreate({ + ...params, + messages: anthropicMessages + }); + }; + const messagesProxy = new Proxy(client.messages, { + get(target, prop) { + if (prop === "create") return compressedCreate; + return target[prop]; + } + }); + return new Proxy(client, { + get(target, prop) { + if (prop === "messages") return messagesProxy; + return target[prop]; + } + }); +} + +exports.withHeadroom = withHeadroom; +//# sourceMappingURL=anthropic.cjs.map +//# sourceMappingURL=anthropic.cjs.map \ No newline at end of file diff --git a/node_modules/headroom-ai/dist/adapters/anthropic.cjs.map b/node_modules/headroom-ai/dist/adapters/anthropic.cjs.map new file mode 100644 index 00000000..ad90b4bb --- /dev/null +++ b/node_modules/headroom-ai/dist/adapters/anthropic.cjs.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../src/adapters/anthropic.ts"],"names":["compress"],"mappings":";;;;;AAcA,SAAS,kBAAkB,MAAA,EAA4B;AACrD,EAAA,IAAI,CAAC,MAAA,IAAU,OAAO,MAAA,KAAW,UAAU,OAAO,IAAA;AAClD,EAAA,IAAI,OAAO,IAAA,KAAS,QAAA,IAAY,MAAA,CAAO,UAAA,IAAc,OAAO,IAAA,EAAM;AAChE,IAAA,OAAO,CAAA,KAAA,EAAQ,MAAA,CAAO,UAAU,CAAA,QAAA,EAAW,OAAO,IAAI,CAAA,CAAA;AAAA,EACxD;AACA,EAAA,IAAI,MAAA,CAAO,SAAS,KAAA,IAAS,OAAO,OAAO,GAAA,KAAQ,QAAA,SAAiB,MAAA,CAAO,GAAA;AAC3E,EAAA,OAAO,IAAA;AACT;AAGA,SAAS,iBAAiB,GAAA,EAAkB;AAC1C,EAAA,MAAM,CAAA,GAAI,8BAAA,CAA+B,IAAA,CAAK,GAAG,CAAA;AACjD,EAAA,IAAI,GAAG,OAAO,EAAE,IAAA,EAAM,OAAA,EAAS,QAAQ,EAAE,IAAA,EAAM,QAAA,EAAU,UAAA,EAAY,EAAE,CAAC,CAAA,EAAG,MAAM,CAAA,CAAE,CAAC,GAAE,EAAE;AACxF,EAAA,OAAO,EAAE,MAAM,OAAA,EAAS,MAAA,EAAQ,EAAE,IAAA,EAAM,KAAA,EAAO,KAAI,EAAE;AACvD;AASA,SAAS,kBAAkB,QAAA,EAAkC;AAC3D,EAAA,MAAM,SAA0B,EAAC;AAEjC,EAAA,KAAA,MAAW,OAAO,QAAA,EAAU;AAC1B,IAAA,IAAI,GAAA,CAAI,SAAS,MAAA,EAAQ;AACvB,MAAA,IAAI,OAAO,GAAA,CAAI,OAAA,KAAY,QAAA,EAAU;AACnC,QAAA,MAAA,CAAO,KAAK,EAAE,IAAA,EAAM,QAAQ,OAAA,EAAS,GAAA,CAAI,SAAS,CAAA;AAAA,MACpD,CAAA,MAAA,IAAW,KAAA,CAAM,OAAA,CAAQ,GAAA,CAAI,OAAO,CAAA,EAAG;AACrC,QAAA,MAAM,WAAA,GAAc,IAAI,OAAA,CAAQ,MAAA;AAAA,UAC9B,CAAC,CAAA,KAAW,CAAA,CAAE,IAAA,KAAS;AAAA,SACzB;AACA,QAAA,MAAM,SAAA,GAAY,IAAI,OAAA,CAAQ,IAAA,CAAK,CAAC,CAAA,KAAW,CAAA,CAAE,SAAS,OAAO,CAAA;AAEjE,QAAA,IAAI,SAAA,EAAW;AACb,UAAA,MAAM,KAAA,GAAQ,GAAA,CAAI,OAAA,CACf,MAAA,CAAO,CAAC,CAAA,KAAW,CAAA,CAAE,IAAA,KAAS,MAAA,IAAU,EAAE,IAAA,KAAS,OAAO,CAAA,CAC1D,GAAA,CAAI,CAAC,CAAA,KAAW;AACf,YAAA,IAAI,CAAA,CAAE,SAAS,MAAA,EAAQ,OAAO,EAAE,IAAA,EAAM,MAAA,EAAiB,IAAA,EAAM,CAAA,CAAE,IAAA,EAAK;AACpE,YAAA,MAAM,GAAA,GAAM,iBAAA,CAAkB,CAAA,CAAE,MAAM,CAAA;AACtC,YAAA,OAAO,GAAA,GAAM,EAAE,IAAA,EAAM,WAAA,EAAsB,WAAW,EAAE,GAAA,IAAM,GAAI,IAAA;AAAA,UACpE,CAAC,CAAA,CACA,MAAA,CAAO,CAAC,CAAA,KAAuC,MAAM,IAAI,CAAA;AAE5D,UAAA,IAAI,KAAA,CAAM,SAAS,CAAA,EAAG;AACpB,YAAA,MAAA,CAAO,KAAK,EAAE,IAAA,EAAM,MAAA,EAAQ,OAAA,EAAS,OAAO,CAAA;AAAA,UAC9C;AAAA,QACF,CAAA,MAAO;AACL,UAAA,MAAM,UAAA,GAAa,IAAI,OAAA,CAAQ,MAAA,CAAO,CAAC,CAAA,KAAW,CAAA,CAAE,SAAS,MAAM,CAAA;AACnE,UAAA,IAAI,UAAA,CAAW,SAAS,CAAA,EAAG;AACzB,YAAA,MAAA,CAAO,IAAA,CAAK;AAAA,cACV,IAAA,EAAM,MAAA;AAAA,cACN,OAAA,EAAS,WAAW,GAAA,CAAI,CAAC,MAAW,CAAA,CAAE,IAAI,CAAA,CAAE,IAAA,CAAK,IAAI;AAAA,aACtD,CAAA;AAAA,UACH;AAAA,QACF;AACA,QAAA,KAAA,MAAW,MAAM,WAAA,EAAa;AAC5B,UAAA,MAAA,CAAO,IAAA,CAAK;AAAA,YACV,IAAA,EAAM,MAAA;AAAA,YACN,OAAA,EACE,OAAO,EAAA,CAAG,OAAA,KAAY,QAAA,GAClB,GAAG,OAAA,GACH,IAAA,CAAK,SAAA,CAAU,EAAA,CAAG,OAAO,CAAA;AAAA,YAC/B,cAAc,EAAA,CAAG;AAAA,WAClB,CAAA;AAAA,QACH;AAAA,MACF;AACA,MAAA;AAAA,IACF;AAEA,IAAA,IAAI,GAAA,CAAI,SAAS,WAAA,EAAa;AAC5B,MAAA,IAAI,OAAO,GAAA,CAAI,OAAA,KAAY,QAAA,EAAU;AACnC,QAAA,MAAA,CAAO,KAAK,EAAE,IAAA,EAAM,aAAa,OAAA,EAAS,GAAA,CAAI,SAAS,CAAA;AAAA,MACzD,CAAA,MAAA,IAAW,KAAA,CAAM,OAAA,CAAQ,GAAA,CAAI,OAAO,CAAA,EAAG;AACrC,QAAA,MAAM,UAAA,GAAa,IAAI,OAAA,CAAQ,MAAA,CAAO,CAAC,CAAA,KAAW,CAAA,CAAE,SAAS,MAAM,CAAA;AACnE,QAAA,MAAM,aAAA,GAAgB,IAAI,OAAA,CAAQ,MAAA;AAAA,UAChC,CAAC,CAAA,KAAW,CAAA,CAAE,IAAA,KAAS;AAAA,SACzB;AAEA,QAAA,MAAM,OAAA,GACJ,UAAA,CAAW,MAAA,GAAS,CAAA,GAChB,UAAA,CAAW,GAAA,CAAI,CAAC,CAAA,KAAW,CAAA,CAAE,IAAI,CAAA,CAAE,IAAA,CAAK,IAAI,CAAA,GAC5C,IAAA;AAEN,QAAA,MAAM,SAAA,GAA8B,EAAE,IAAA,EAAM,WAAA,EAAa,OAAA,EAAQ;AACjE,QAAA,IAAI,aAAA,CAAc,SAAS,CAAA,EAAG;AAC5B,UAAA,SAAA,CAAU,aAAa,aAAA,CAAc,GAAA;AAAA,YACnC,CAAC,CAAA,MAAsB;AAAA,cACrB,IAAI,CAAA,CAAE,EAAA;AAAA,cACN,IAAA,EAAM,UAAA;AAAA,cACN,QAAA,EAAU;AAAA,gBACR,MAAM,CAAA,CAAE,IAAA;AAAA,gBACR,SAAA,EAAW,IAAA,CAAK,SAAA,CAAU,CAAA,CAAE,KAAK;AAAA;AACnC,aACF;AAAA,WACF;AAAA,QACF;AACA,QAAA,MAAA,CAAO,KAAK,SAAS,CAAA;AAAA,MACvB;AACA,MAAA;AAAA,IACF;AAAA,EACF;AAEA,EAAA,OAAO,MAAA;AACT;AAKA,SAAS,kBAAkB,QAAA,EAAkC;AAC3D,EAAA,MAAM,SAAgB,EAAC;AAEvB,EAAA,KAAA,MAAW,OAAO,QAAA,EAAU;AAC1B,IAAA,IAAI,GAAA,CAAI,SAAS,MAAA,EAAQ;AACvB,MAAA,IAAI,OAAO,GAAA,CAAI,OAAA,KAAY,QAAA,EAAU;AACnC,QAAA,MAAA,CAAO,KAAK,EAAE,IAAA,EAAM,QAAQ,OAAA,EAAS,GAAA,CAAI,SAAS,CAAA;AAAA,MACpD,CAAA,MAAA,IAAW,KAAA,CAAM,OAAA,CAAQ,GAAA,CAAI,OAAO,CAAA,EAAG;AACrC,QAAA,MAAA,CAAO,IAAA,CAAK;AAAA,UACV,IAAA,EAAM,MAAA;AAAA,UACN,OAAA,EAAS,GAAA,CAAI,OAAA,CAAQ,GAAA,CAAI,CAAC,CAAA,KAAM;AAC9B,YAAA,IAAI,CAAA,CAAE,SAAS,MAAA,EAAQ,OAAO,EAAE,IAAA,EAAM,MAAA,EAAQ,IAAA,EAAM,CAAA,CAAE,IAAA,EAAK;AAC3D,YAAA,IAAI,EAAE,IAAA,KAAS,WAAA,SAAoB,gBAAA,CAAiB,CAAA,CAAE,UAAU,GAAG,CAAA;AACnE,YAAA,OAAO,EAAE,IAAA,EAAM,MAAA,EAAQ,IAAA,EAAM,EAAA,EAAG;AAAA,UAClC,CAAC;AAAA,SACF,CAAA;AAAA,MACH;AACA,MAAA;AAAA,IACF;AAEA,IAAA,IAAI,GAAA,CAAI,SAAS,WAAA,EAAa;AAC5B,MAAA,MAAM,SAAgB,EAAC;AACvB,MAAA,IAAI,GAAA,CAAI,OAAA,EAAS,MAAA,CAAO,IAAA,CAAK,EAAE,MAAM,MAAA,EAAQ,IAAA,EAAM,GAAA,CAAI,OAAA,EAAS,CAAA;AAChE,MAAA,IAAI,IAAI,UAAA,EAAY;AAClB,QAAA,KAAA,MAAW,EAAA,IAAM,IAAI,UAAA,EAAY;AAC/B,UAAA,MAAA,CAAO,IAAA,CAAK;AAAA,YACV,IAAA,EAAM,UAAA;AAAA,YACN,IAAI,EAAA,CAAG,EAAA;AAAA,YACP,IAAA,EAAM,GAAG,QAAA,CAAS,IAAA;AAAA,YAClB,KAAA,EAAO,IAAA,CAAK,KAAA,CAAM,EAAA,CAAG,SAAS,SAAS;AAAA,WACxC,CAAA;AAAA,QACH;AAAA,MACF;AACA,MAAA,MAAA,CAAO,IAAA,CAAK;AAAA,QACV,IAAA,EAAM,WAAA;AAAA,QACN,OAAA,EACE,MAAA,CAAO,MAAA,KAAW,CAAA,IAAK,MAAA,CAAO,CAAC,CAAA,CAAE,IAAA,KAAS,MAAA,GACtC,MAAA,CAAO,CAAC,CAAA,CAAE,IAAA,GACV;AAAA,OACP,CAAA;AACD,MAAA;AAAA,IACF;AAEA,IAAA,IAAI,GAAA,CAAI,SAAS,MAAA,EAAQ;AACvB,MAAA,MAAA,CAAO,IAAA,CAAK;AAAA,QACV,IAAA,EAAM,MAAA;AAAA,QACN,OAAA,EAAS;AAAA,UACP;AAAA,YACE,IAAA,EAAM,aAAA;AAAA,YACN,aAAa,GAAA,CAAI,YAAA;AAAA,YACjB,SAAS,GAAA,CAAI;AAAA;AACf;AACF,OACD,CAAA;AACD,MAAA;AAAA,IACF;AAEA,IAAA,IAAI,GAAA,CAAI,SAAS,QAAA,EAAU;AAGzB,MAAA,MAAA,CAAO,KAAK,EAAE,IAAA,EAAM,QAAQ,OAAA,EAAS,GAAA,CAAI,SAAS,CAAA;AAAA,IACpD;AAAA,EACF;AAEA,EAAA,OAAO,MAAA;AACT;AAoBO,SAAS,YAAA,CACd,MAAA,EACA,OAAA,GAA2B,EAAC,EACzB;AACH,EAAA,MAAM,iBAAiB,MAAA,CAAO,QAAA,CAAS,MAAA,CAAO,IAAA,CAAK,OAAO,QAAQ,CAAA;AAElE,EAAA,MAAM,gBAAA,GAAmB,OAAO,MAAA,KAAgB;AAC9C,IAAA,MAAM,WAAW,MAAA,CAAO,QAAA;AACxB,IAAA,MAAM,KAAA,GACJ,OAAA,CAAQ,KAAA,IAAS,MAAA,CAAO,KAAA,IAAS,4BAAA;AAEnC,IAAA,MAAM,cAAA,GAAiB,kBAAkB,QAAQ,CAAA;AACjD,IAAA,MAAM,MAAA,GAAS,MAAMA,0BAAA,CAAS,cAAA,EAAgB;AAAA,MAC5C,KAAA,EAAO,sBAAA;AAAA,MACP,GAAG,OAAA;AAAA,MACH;AAAA,KACD,CAAA;AAED,IAAA,MAAM,oBAAoB,MAAA,CAAO,UAAA,GAC7B,iBAAA,CAAkB,MAAA,CAAO,QAAQ,CAAA,GACjC,QAAA;AAEJ,IAAA,OAAO,cAAA,CAAe;AAAA,MACpB,GAAG,MAAA;AAAA,MACH,QAAA,EAAU;AAAA,KACX,CAAA;AAAA,EACH,CAAA;AAEA,EAAA,MAAM,aAAA,GAAgB,IAAI,KAAA,CAAM,MAAA,CAAO,QAAA,EAAU;AAAA,IAC/C,GAAA,CAAI,QAAQ,IAAA,EAAM;AAChB,MAAA,IAAI,IAAA,KAAS,UAAU,OAAO,gBAAA;AAC9B,MAAA,OAAQ,OAAe,IAAI,CAAA;AAAA,IAC7B;AAAA,GACD,CAAA;AAED,EAAA,OAAO,IAAI,MAAM,MAAA,EAAQ;AAAA,IACvB,GAAA,CAAI,QAAQ,IAAA,EAAM;AAChB,MAAA,IAAI,IAAA,KAAS,YAAY,OAAO,aAAA;AAChC,MAAA,OAAQ,OAAe,IAAI,CAAA;AAAA,IAC7B;AAAA,GACD,CAAA;AACH","file":"anthropic.cjs","sourcesContent":["import { compress } from \"../compress.js\";\nimport type { CompressOptions, OpenAIMessage } from \"../types.js\";\nimport type { AssistantMessage, ToolCall } from \"../types.js\";\n\n/* eslint-disable @typescript-eslint/no-explicit-any */\n\ninterface AnthropicLike {\n messages: {\n create: (params: any) => any;\n };\n [key: string]: any;\n}\n\n// Anthropic image block `source` -> OpenAI image_url url string, or null if unconvertible.\nfunction anthropicImageUrl(source: any): string | null {\n if (!source || typeof source !== \"object\") return null;\n if (source.type === \"base64\" && source.media_type && source.data) {\n return `data:${source.media_type};base64,${source.data}`;\n }\n if (source.type === \"url\" && typeof source.url === \"string\") return source.url;\n return null;\n}\n\n// OpenAI image_url url -> Anthropic image block. data: URIs become a base64 source; other urls a url source.\nfunction openAIImageBlock(url: string): any {\n const m = /^data:([^;,]+);base64,(.*)$/s.exec(url);\n if (m) return { type: \"image\", source: { type: \"base64\", media_type: m[1], data: m[2] } };\n return { type: \"image\", source: { type: \"url\", url } };\n}\n\n/**\n * Convert Anthropic messages to OpenAI format for compression.\n *\n * Anthropic format:\n * { role: 'user' | 'assistant', content: string | ContentBlock[] }\n * ContentBlock = { type: 'text', text } | { type: 'tool_use', ... } | { type: 'tool_result', ... }\n */\nfunction anthropicToOpenAI(messages: any[]): OpenAIMessage[] {\n const result: OpenAIMessage[] = [];\n\n for (const msg of messages) {\n if (msg.role === \"user\") {\n if (typeof msg.content === \"string\") {\n result.push({ role: \"user\", content: msg.content });\n } else if (Array.isArray(msg.content)) {\n const toolResults = msg.content.filter(\n (b: any) => b.type === \"tool_result\",\n );\n const hasImages = msg.content.some((b: any) => b.type === \"image\");\n\n if (hasImages) {\n const parts = msg.content\n .filter((b: any) => b.type === \"text\" || b.type === \"image\")\n .map((b: any) => {\n if (b.type === \"text\") return { type: \"text\" as const, text: b.text };\n const url = anthropicImageUrl(b.source);\n return url ? { type: \"image_url\" as const, image_url: { url } } : null;\n })\n .filter((p: any): p is NonNullable => p !== null);\n\n if (parts.length > 0) {\n result.push({ role: \"user\", content: parts });\n }\n } else {\n const textBlocks = msg.content.filter((b: any) => b.type === \"text\");\n if (textBlocks.length > 0) {\n result.push({\n role: \"user\",\n content: textBlocks.map((b: any) => b.text).join(\"\\n\"),\n });\n }\n }\n for (const tr of toolResults) {\n result.push({\n role: \"tool\",\n content:\n typeof tr.content === \"string\"\n ? tr.content\n : JSON.stringify(tr.content),\n tool_call_id: tr.tool_use_id,\n });\n }\n }\n continue;\n }\n\n if (msg.role === \"assistant\") {\n if (typeof msg.content === \"string\") {\n result.push({ role: \"assistant\", content: msg.content });\n } else if (Array.isArray(msg.content)) {\n const textBlocks = msg.content.filter((b: any) => b.type === \"text\");\n const toolUseBlocks = msg.content.filter(\n (b: any) => b.type === \"tool_use\",\n );\n\n const content =\n textBlocks.length > 0\n ? textBlocks.map((b: any) => b.text).join(\"\\n\")\n : null;\n\n const openaiMsg: AssistantMessage = { role: \"assistant\", content };\n if (toolUseBlocks.length > 0) {\n openaiMsg.tool_calls = toolUseBlocks.map(\n (b: any): ToolCall => ({\n id: b.id,\n type: \"function\",\n function: {\n name: b.name,\n arguments: JSON.stringify(b.input),\n },\n }),\n );\n }\n result.push(openaiMsg);\n }\n continue;\n }\n }\n\n return result;\n}\n\n/**\n * Convert compressed OpenAI messages back to Anthropic format.\n */\nfunction openAIToAnthropic(messages: OpenAIMessage[]): any[] {\n const result: any[] = [];\n\n for (const msg of messages) {\n if (msg.role === \"user\") {\n if (typeof msg.content === \"string\") {\n result.push({ role: \"user\", content: msg.content });\n } else if (Array.isArray(msg.content)) {\n result.push({\n role: \"user\",\n content: msg.content.map((p) => {\n if (p.type === \"text\") return { type: \"text\", text: p.text };\n if (p.type === \"image_url\") return openAIImageBlock(p.image_url.url);\n return { type: \"text\", text: \"\" };\n }),\n });\n }\n continue;\n }\n\n if (msg.role === \"assistant\") {\n const blocks: any[] = [];\n if (msg.content) blocks.push({ type: \"text\", text: msg.content });\n if (msg.tool_calls) {\n for (const tc of msg.tool_calls) {\n blocks.push({\n type: \"tool_use\",\n id: tc.id,\n name: tc.function.name,\n input: JSON.parse(tc.function.arguments),\n });\n }\n }\n result.push({\n role: \"assistant\",\n content:\n blocks.length === 1 && blocks[0].type === \"text\"\n ? blocks[0].text\n : blocks,\n });\n continue;\n }\n\n if (msg.role === \"tool\") {\n result.push({\n role: \"user\",\n content: [\n {\n type: \"tool_result\",\n tool_use_id: msg.tool_call_id,\n content: msg.content,\n },\n ],\n });\n continue;\n }\n\n if (msg.role === \"system\") {\n // System messages are handled separately in Anthropic (as `system` param)\n // Pass through as user message if it appears in the messages array\n result.push({ role: \"user\", content: msg.content });\n }\n }\n\n return result;\n}\n\n/**\n * Wrap an Anthropic client to auto-compress messages before each request.\n *\n * Intercepts `client.messages.create()` only. All other methods pass through.\n *\n * @example\n * ```typescript\n * import { withHeadroom } from 'headroom-ai/anthropic';\n * import Anthropic from '@anthropic-ai/sdk';\n *\n * const client = withHeadroom(new Anthropic());\n * const response = await client.messages.create({\n * model: 'claude-sonnet-4-5-20250929',\n * messages: longConversation,\n * max_tokens: 1024,\n * });\n * ```\n */\nexport function withHeadroom(\n client: T,\n options: CompressOptions = {},\n): T {\n const originalCreate = client.messages.create.bind(client.messages);\n\n const compressedCreate = async (params: any) => {\n const messages = params.messages;\n const model =\n options.model ?? params.model ?? \"claude-sonnet-4-5-20250929\";\n\n const openaiMessages = anthropicToOpenAI(messages);\n const result = await compress(openaiMessages, {\n stack: \"adapter_ts_anthropic\",\n ...options,\n model,\n });\n\n const anthropicMessages = result.compressed\n ? openAIToAnthropic(result.messages)\n : messages;\n\n return originalCreate({\n ...params,\n messages: anthropicMessages,\n });\n };\n\n const messagesProxy = new Proxy(client.messages, {\n get(target, prop) {\n if (prop === \"create\") return compressedCreate;\n return (target as any)[prop];\n },\n });\n\n return new Proxy(client, {\n get(target, prop) {\n if (prop === \"messages\") return messagesProxy;\n return (target as any)[prop];\n },\n }) as T;\n}\n"]} \ No newline at end of file diff --git a/node_modules/headroom-ai/dist/adapters/anthropic.d.cts b/node_modules/headroom-ai/dist/adapters/anthropic.d.cts new file mode 100644 index 00000000..40d4b6d3 --- /dev/null +++ b/node_modules/headroom-ai/dist/adapters/anthropic.d.cts @@ -0,0 +1,29 @@ +import { C as CompressOptions } from '../types-Cjdl75F4.cjs'; + +interface AnthropicLike { + messages: { + create: (params: any) => any; + }; + [key: string]: any; +} +/** + * Wrap an Anthropic client to auto-compress messages before each request. + * + * Intercepts `client.messages.create()` only. All other methods pass through. + * + * @example + * ```typescript + * import { withHeadroom } from 'headroom-ai/anthropic'; + * import Anthropic from '@anthropic-ai/sdk'; + * + * const client = withHeadroom(new Anthropic()); + * const response = await client.messages.create({ + * model: 'claude-sonnet-4-5-20250929', + * messages: longConversation, + * max_tokens: 1024, + * }); + * ``` + */ +declare function withHeadroom(client: T, options?: CompressOptions): T; + +export { withHeadroom }; diff --git a/node_modules/headroom-ai/dist/adapters/anthropic.d.ts b/node_modules/headroom-ai/dist/adapters/anthropic.d.ts new file mode 100644 index 00000000..80c948d7 --- /dev/null +++ b/node_modules/headroom-ai/dist/adapters/anthropic.d.ts @@ -0,0 +1,29 @@ +import { C as CompressOptions } from '../types-Cjdl75F4.js'; + +interface AnthropicLike { + messages: { + create: (params: any) => any; + }; + [key: string]: any; +} +/** + * Wrap an Anthropic client to auto-compress messages before each request. + * + * Intercepts `client.messages.create()` only. All other methods pass through. + * + * @example + * ```typescript + * import { withHeadroom } from 'headroom-ai/anthropic'; + * import Anthropic from '@anthropic-ai/sdk'; + * + * const client = withHeadroom(new Anthropic()); + * const response = await client.messages.create({ + * model: 'claude-sonnet-4-5-20250929', + * messages: longConversation, + * max_tokens: 1024, + * }); + * ``` + */ +declare function withHeadroom(client: T, options?: CompressOptions): T; + +export { withHeadroom }; diff --git a/node_modules/headroom-ai/dist/adapters/anthropic.js b/node_modules/headroom-ai/dist/adapters/anthropic.js new file mode 100644 index 00000000..80725a3d --- /dev/null +++ b/node_modules/headroom-ai/dist/adapters/anthropic.js @@ -0,0 +1,174 @@ +import { compress } from '../chunk-24W6JPRV.js'; + +// src/adapters/anthropic.ts +function anthropicImageUrl(source) { + if (!source || typeof source !== "object") return null; + if (source.type === "base64" && source.media_type && source.data) { + return `data:${source.media_type};base64,${source.data}`; + } + if (source.type === "url" && typeof source.url === "string") return source.url; + return null; +} +function openAIImageBlock(url) { + const m = /^data:([^;,]+);base64,(.*)$/s.exec(url); + if (m) return { type: "image", source: { type: "base64", media_type: m[1], data: m[2] } }; + return { type: "image", source: { type: "url", url } }; +} +function anthropicToOpenAI(messages) { + const result = []; + for (const msg of messages) { + if (msg.role === "user") { + if (typeof msg.content === "string") { + result.push({ role: "user", content: msg.content }); + } else if (Array.isArray(msg.content)) { + const toolResults = msg.content.filter( + (b) => b.type === "tool_result" + ); + const hasImages = msg.content.some((b) => b.type === "image"); + if (hasImages) { + const parts = msg.content.filter((b) => b.type === "text" || b.type === "image").map((b) => { + if (b.type === "text") return { type: "text", text: b.text }; + const url = anthropicImageUrl(b.source); + return url ? { type: "image_url", image_url: { url } } : null; + }).filter((p) => p !== null); + if (parts.length > 0) { + result.push({ role: "user", content: parts }); + } + } else { + const textBlocks = msg.content.filter((b) => b.type === "text"); + if (textBlocks.length > 0) { + result.push({ + role: "user", + content: textBlocks.map((b) => b.text).join("\n") + }); + } + } + for (const tr of toolResults) { + result.push({ + role: "tool", + content: typeof tr.content === "string" ? tr.content : JSON.stringify(tr.content), + tool_call_id: tr.tool_use_id + }); + } + } + continue; + } + if (msg.role === "assistant") { + if (typeof msg.content === "string") { + result.push({ role: "assistant", content: msg.content }); + } else if (Array.isArray(msg.content)) { + const textBlocks = msg.content.filter((b) => b.type === "text"); + const toolUseBlocks = msg.content.filter( + (b) => b.type === "tool_use" + ); + const content = textBlocks.length > 0 ? textBlocks.map((b) => b.text).join("\n") : null; + const openaiMsg = { role: "assistant", content }; + if (toolUseBlocks.length > 0) { + openaiMsg.tool_calls = toolUseBlocks.map( + (b) => ({ + id: b.id, + type: "function", + function: { + name: b.name, + arguments: JSON.stringify(b.input) + } + }) + ); + } + result.push(openaiMsg); + } + continue; + } + } + return result; +} +function openAIToAnthropic(messages) { + const result = []; + for (const msg of messages) { + if (msg.role === "user") { + if (typeof msg.content === "string") { + result.push({ role: "user", content: msg.content }); + } else if (Array.isArray(msg.content)) { + result.push({ + role: "user", + content: msg.content.map((p) => { + if (p.type === "text") return { type: "text", text: p.text }; + if (p.type === "image_url") return openAIImageBlock(p.image_url.url); + return { type: "text", text: "" }; + }) + }); + } + continue; + } + if (msg.role === "assistant") { + const blocks = []; + if (msg.content) blocks.push({ type: "text", text: msg.content }); + if (msg.tool_calls) { + for (const tc of msg.tool_calls) { + blocks.push({ + type: "tool_use", + id: tc.id, + name: tc.function.name, + input: JSON.parse(tc.function.arguments) + }); + } + } + result.push({ + role: "assistant", + content: blocks.length === 1 && blocks[0].type === "text" ? blocks[0].text : blocks + }); + continue; + } + if (msg.role === "tool") { + result.push({ + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: msg.tool_call_id, + content: msg.content + } + ] + }); + continue; + } + if (msg.role === "system") { + result.push({ role: "user", content: msg.content }); + } + } + return result; +} +function withHeadroom(client, options = {}) { + const originalCreate = client.messages.create.bind(client.messages); + const compressedCreate = async (params) => { + const messages = params.messages; + const model = options.model ?? params.model ?? "claude-sonnet-4-5-20250929"; + const openaiMessages = anthropicToOpenAI(messages); + const result = await compress(openaiMessages, { + stack: "adapter_ts_anthropic", + ...options, + model + }); + const anthropicMessages = result.compressed ? openAIToAnthropic(result.messages) : messages; + return originalCreate({ + ...params, + messages: anthropicMessages + }); + }; + const messagesProxy = new Proxy(client.messages, { + get(target, prop) { + if (prop === "create") return compressedCreate; + return target[prop]; + } + }); + return new Proxy(client, { + get(target, prop) { + if (prop === "messages") return messagesProxy; + return target[prop]; + } + }); +} + +export { withHeadroom }; +//# sourceMappingURL=anthropic.js.map +//# sourceMappingURL=anthropic.js.map \ No newline at end of file diff --git a/node_modules/headroom-ai/dist/adapters/anthropic.js.map b/node_modules/headroom-ai/dist/adapters/anthropic.js.map new file mode 100644 index 00000000..75a88131 --- /dev/null +++ b/node_modules/headroom-ai/dist/adapters/anthropic.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../src/adapters/anthropic.ts"],"names":[],"mappings":"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","file":"anthropic.js","sourcesContent":["import { compress } from \"../compress.js\";\nimport type { CompressOptions, OpenAIMessage } from \"../types.js\";\nimport type { AssistantMessage, ToolCall } from \"../types.js\";\n\n/* eslint-disable @typescript-eslint/no-explicit-any */\n\ninterface AnthropicLike {\n messages: {\n create: (params: any) => any;\n };\n [key: string]: any;\n}\n\n// Anthropic image block `source` -> OpenAI image_url url string, or null if unconvertible.\nfunction anthropicImageUrl(source: any): string | null {\n if (!source || typeof source !== \"object\") return null;\n if (source.type === \"base64\" && source.media_type && source.data) {\n return `data:${source.media_type};base64,${source.data}`;\n }\n if (source.type === \"url\" && typeof source.url === \"string\") return source.url;\n return null;\n}\n\n// OpenAI image_url url -> Anthropic image block. data: URIs become a base64 source; other urls a url source.\nfunction openAIImageBlock(url: string): any {\n const m = /^data:([^;,]+);base64,(.*)$/s.exec(url);\n if (m) return { type: \"image\", source: { type: \"base64\", media_type: m[1], data: m[2] } };\n return { type: \"image\", source: { type: \"url\", url } };\n}\n\n/**\n * Convert Anthropic messages to OpenAI format for compression.\n *\n * Anthropic format:\n * { role: 'user' | 'assistant', content: string | ContentBlock[] }\n * ContentBlock = { type: 'text', text } | { type: 'tool_use', ... } | { type: 'tool_result', ... }\n */\nfunction anthropicToOpenAI(messages: any[]): OpenAIMessage[] {\n const result: OpenAIMessage[] = [];\n\n for (const msg of messages) {\n if (msg.role === \"user\") {\n if (typeof msg.content === \"string\") {\n result.push({ role: \"user\", content: msg.content });\n } else if (Array.isArray(msg.content)) {\n const toolResults = msg.content.filter(\n (b: any) => b.type === \"tool_result\",\n );\n const hasImages = msg.content.some((b: any) => b.type === \"image\");\n\n if (hasImages) {\n const parts = msg.content\n .filter((b: any) => b.type === \"text\" || b.type === \"image\")\n .map((b: any) => {\n if (b.type === \"text\") return { type: \"text\" as const, text: b.text };\n const url = anthropicImageUrl(b.source);\n return url ? { type: \"image_url\" as const, image_url: { url } } : null;\n })\n .filter((p: any): p is NonNullable => p !== null);\n\n if (parts.length > 0) {\n result.push({ role: \"user\", content: parts });\n }\n } else {\n const textBlocks = msg.content.filter((b: any) => b.type === \"text\");\n if (textBlocks.length > 0) {\n result.push({\n role: \"user\",\n content: textBlocks.map((b: any) => b.text).join(\"\\n\"),\n });\n }\n }\n for (const tr of toolResults) {\n result.push({\n role: \"tool\",\n content:\n typeof tr.content === \"string\"\n ? tr.content\n : JSON.stringify(tr.content),\n tool_call_id: tr.tool_use_id,\n });\n }\n }\n continue;\n }\n\n if (msg.role === \"assistant\") {\n if (typeof msg.content === \"string\") {\n result.push({ role: \"assistant\", content: msg.content });\n } else if (Array.isArray(msg.content)) {\n const textBlocks = msg.content.filter((b: any) => b.type === \"text\");\n const toolUseBlocks = msg.content.filter(\n (b: any) => b.type === \"tool_use\",\n );\n\n const content =\n textBlocks.length > 0\n ? textBlocks.map((b: any) => b.text).join(\"\\n\")\n : null;\n\n const openaiMsg: AssistantMessage = { role: \"assistant\", content };\n if (toolUseBlocks.length > 0) {\n openaiMsg.tool_calls = toolUseBlocks.map(\n (b: any): ToolCall => ({\n id: b.id,\n type: \"function\",\n function: {\n name: b.name,\n arguments: JSON.stringify(b.input),\n },\n }),\n );\n }\n result.push(openaiMsg);\n }\n continue;\n }\n }\n\n return result;\n}\n\n/**\n * Convert compressed OpenAI messages back to Anthropic format.\n */\nfunction openAIToAnthropic(messages: OpenAIMessage[]): any[] {\n const result: any[] = [];\n\n for (const msg of messages) {\n if (msg.role === \"user\") {\n if (typeof msg.content === \"string\") {\n result.push({ role: \"user\", content: msg.content });\n } else if (Array.isArray(msg.content)) {\n result.push({\n role: \"user\",\n content: msg.content.map((p) => {\n if (p.type === \"text\") return { type: \"text\", text: p.text };\n if (p.type === \"image_url\") return openAIImageBlock(p.image_url.url);\n return { type: \"text\", text: \"\" };\n }),\n });\n }\n continue;\n }\n\n if (msg.role === \"assistant\") {\n const blocks: any[] = [];\n if (msg.content) blocks.push({ type: \"text\", text: msg.content });\n if (msg.tool_calls) {\n for (const tc of msg.tool_calls) {\n blocks.push({\n type: \"tool_use\",\n id: tc.id,\n name: tc.function.name,\n input: JSON.parse(tc.function.arguments),\n });\n }\n }\n result.push({\n role: \"assistant\",\n content:\n blocks.length === 1 && blocks[0].type === \"text\"\n ? blocks[0].text\n : blocks,\n });\n continue;\n }\n\n if (msg.role === \"tool\") {\n result.push({\n role: \"user\",\n content: [\n {\n type: \"tool_result\",\n tool_use_id: msg.tool_call_id,\n content: msg.content,\n },\n ],\n });\n continue;\n }\n\n if (msg.role === \"system\") {\n // System messages are handled separately in Anthropic (as `system` param)\n // Pass through as user message if it appears in the messages array\n result.push({ role: \"user\", content: msg.content });\n }\n }\n\n return result;\n}\n\n/**\n * Wrap an Anthropic client to auto-compress messages before each request.\n *\n * Intercepts `client.messages.create()` only. All other methods pass through.\n *\n * @example\n * ```typescript\n * import { withHeadroom } from 'headroom-ai/anthropic';\n * import Anthropic from '@anthropic-ai/sdk';\n *\n * const client = withHeadroom(new Anthropic());\n * const response = await client.messages.create({\n * model: 'claude-sonnet-4-5-20250929',\n * messages: longConversation,\n * max_tokens: 1024,\n * });\n * ```\n */\nexport function withHeadroom(\n client: T,\n options: CompressOptions = {},\n): T {\n const originalCreate = client.messages.create.bind(client.messages);\n\n const compressedCreate = async (params: any) => {\n const messages = params.messages;\n const model =\n options.model ?? params.model ?? \"claude-sonnet-4-5-20250929\";\n\n const openaiMessages = anthropicToOpenAI(messages);\n const result = await compress(openaiMessages, {\n stack: \"adapter_ts_anthropic\",\n ...options,\n model,\n });\n\n const anthropicMessages = result.compressed\n ? openAIToAnthropic(result.messages)\n : messages;\n\n return originalCreate({\n ...params,\n messages: anthropicMessages,\n });\n };\n\n const messagesProxy = new Proxy(client.messages, {\n get(target, prop) {\n if (prop === \"create\") return compressedCreate;\n return (target as any)[prop];\n },\n });\n\n return new Proxy(client, {\n get(target, prop) {\n if (prop === \"messages\") return messagesProxy;\n return (target as any)[prop];\n },\n }) as T;\n}\n"]} \ No newline at end of file diff --git a/node_modules/headroom-ai/dist/adapters/gemini.cjs b/node_modules/headroom-ai/dist/adapters/gemini.cjs new file mode 100644 index 00000000..92242736 --- /dev/null +++ b/node_modules/headroom-ai/dist/adapters/gemini.cjs @@ -0,0 +1,41 @@ +'use strict'; + +var chunk26N6BVLT_cjs = require('../chunk-26N6BVLT.cjs'); + +// src/adapters/gemini.ts +function withHeadroom(model, options = {}) { + const originalGenerate = model.generateContent.bind(model); + const originalStream = model.generateContentStream?.bind(model); + const compressedGenerate = async (params) => { + const contents = params.contents ?? params; + const modelName = options.model ?? "gemini-2.0-flash"; + const result = await chunk26N6BVLT_cjs.compress( + Array.isArray(contents) ? contents : [contents], + { stack: "adapter_ts_gemini", ...options, model: modelName } + ); + const newParams = Array.isArray(params) ? result.messages : { ...params, contents: result.messages }; + return originalGenerate(newParams); + }; + const compressedStream = originalStream ? async (params) => { + const contents = params.contents ?? params; + const modelName = options.model ?? "gemini-2.0-flash"; + const result = await chunk26N6BVLT_cjs.compress( + Array.isArray(contents) ? contents : [contents], + { stack: "adapter_ts_gemini", ...options, model: modelName } + ); + const newParams = Array.isArray(params) ? result.messages : { ...params, contents: result.messages }; + return originalStream(newParams); + } : void 0; + return new Proxy(model, { + get(target, prop) { + if (prop === "generateContent") return compressedGenerate; + if (prop === "generateContentStream" && compressedStream) + return compressedStream; + return target[prop]; + } + }); +} + +exports.withHeadroom = withHeadroom; +//# sourceMappingURL=gemini.cjs.map +//# sourceMappingURL=gemini.cjs.map \ No newline at end of file diff --git a/node_modules/headroom-ai/dist/adapters/gemini.cjs.map b/node_modules/headroom-ai/dist/adapters/gemini.cjs.map new file mode 100644 index 00000000..56ce995c --- /dev/null +++ b/node_modules/headroom-ai/dist/adapters/gemini.cjs.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../src/adapters/gemini.ts"],"names":["compress"],"mappings":";;;;;AAgCO,SAAS,YAAA,CACd,KAAA,EACA,OAAA,GAA2B,EAAC,EACzB;AACH,EAAA,MAAM,gBAAA,GAAmB,KAAA,CAAM,eAAA,CAAgB,IAAA,CAAK,KAAK,CAAA;AACzD,EAAA,MAAM,cAAA,GAAiB,KAAA,CAAM,qBAAA,EAAuB,IAAA,CAAK,KAAK,CAAA;AAE9D,EAAA,MAAM,kBAAA,GAAqB,OAAO,MAAA,KAAgB;AAChD,IAAA,MAAM,QAAA,GAAW,OAAO,QAAA,IAAY,MAAA;AACpC,IAAA,MAAM,SAAA,GAAY,QAAQ,KAAA,IAAS,kBAAA;AAGnC,IAAA,MAAM,SAAS,MAAMA,0BAAA;AAAA,MACnB,MAAM,OAAA,CAAQ,QAAQ,CAAA,GAAI,QAAA,GAAW,CAAC,QAAQ,CAAA;AAAA,MAC9C,EAAE,KAAA,EAAO,mBAAA,EAAqB,GAAG,OAAA,EAAS,OAAO,SAAA;AAAU,KAC7D;AAEA,IAAA,MAAM,SAAA,GAAY,KAAA,CAAM,OAAA,CAAQ,MAAM,CAAA,GAClC,MAAA,CAAO,QAAA,GACP,EAAE,GAAG,MAAA,EAAQ,QAAA,EAAU,MAAA,CAAO,QAAA,EAAS;AAE3C,IAAA,OAAO,iBAAiB,SAAS,CAAA;AAAA,EACnC,CAAA;AAEA,EAAA,MAAM,gBAAA,GAAmB,cAAA,GACrB,OAAO,MAAA,KAAgB;AACrB,IAAA,MAAM,QAAA,GAAW,OAAO,QAAA,IAAY,MAAA;AACpC,IAAA,MAAM,SAAA,GAAY,QAAQ,KAAA,IAAS,kBAAA;AAEnC,IAAA,MAAM,SAAS,MAAMA,0BAAA;AAAA,MACnB,MAAM,OAAA,CAAQ,QAAQ,CAAA,GAAI,QAAA,GAAW,CAAC,QAAQ,CAAA;AAAA,MAC9C,EAAE,KAAA,EAAO,mBAAA,EAAqB,GAAG,OAAA,EAAS,OAAO,SAAA;AAAU,KAC7D;AAEA,IAAA,MAAM,SAAA,GAAY,KAAA,CAAM,OAAA,CAAQ,MAAM,CAAA,GAClC,MAAA,CAAO,QAAA,GACP,EAAE,GAAG,MAAA,EAAQ,QAAA,EAAU,MAAA,CAAO,QAAA,EAAS;AAE3C,IAAA,OAAO,eAAe,SAAS,CAAA;AAAA,EACjC,CAAA,GACA,MAAA;AAEJ,EAAA,OAAO,IAAI,MAAM,KAAA,EAAO;AAAA,IACtB,GAAA,CAAI,QAAQ,IAAA,EAAM;AAChB,MAAA,IAAI,IAAA,KAAS,mBAAmB,OAAO,kBAAA;AACvC,MAAA,IAAI,SAAS,uBAAA,IAA2B,gBAAA;AACtC,QAAA,OAAO,gBAAA;AACT,MAAA,OAAQ,OAAe,IAAI,CAAA;AAAA,IAC7B;AAAA,GACD,CAAA;AACH","file":"gemini.cjs","sourcesContent":["/**\n * Gemini adapter — wraps Google Generative AI model with auto-compression.\n * Matches the pattern of openai.ts and anthropic.ts adapters.\n */\n\nimport { compress } from \"../compress.js\";\nimport type { CompressOptions } from \"../types.js\";\n\n/* eslint-disable @typescript-eslint/no-explicit-any */\n\ninterface GeminiModelLike {\n generateContent: (params: any) => any;\n generateContentStream?: (params: any) => any;\n [key: string]: any;\n}\n\n/**\n * Wrap a Google Generative AI model to auto-compress before each request.\n *\n * Intercepts `model.generateContent()` and `model.generateContentStream()`.\n *\n * @example\n * ```typescript\n * import { withHeadroom } from 'headroom-ai/gemini';\n * import { GoogleGenerativeAI } from '@google/generative-ai';\n *\n * const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY!);\n * const model = withHeadroom(genAI.getGenerativeModel({ model: 'gemini-2.0-flash' }));\n *\n * const result = await model.generateContent({ contents: longConversation });\n * ```\n */\nexport function withHeadroom(\n model: T,\n options: CompressOptions = {},\n): T {\n const originalGenerate = model.generateContent.bind(model);\n const originalStream = model.generateContentStream?.bind(model);\n\n const compressedGenerate = async (params: any) => {\n const contents = params.contents ?? params;\n const modelName = options.model ?? \"gemini-2.0-flash\";\n\n // compress() auto-detects Gemini format\n const result = await compress(\n Array.isArray(contents) ? contents : [contents],\n { stack: \"adapter_ts_gemini\", ...options, model: modelName },\n );\n\n const newParams = Array.isArray(params)\n ? result.messages\n : { ...params, contents: result.messages };\n\n return originalGenerate(newParams);\n };\n\n const compressedStream = originalStream\n ? async (params: any) => {\n const contents = params.contents ?? params;\n const modelName = options.model ?? \"gemini-2.0-flash\";\n\n const result = await compress(\n Array.isArray(contents) ? contents : [contents],\n { stack: \"adapter_ts_gemini\", ...options, model: modelName },\n );\n\n const newParams = Array.isArray(params)\n ? result.messages\n : { ...params, contents: result.messages };\n\n return originalStream(newParams);\n }\n : undefined;\n\n return new Proxy(model, {\n get(target, prop) {\n if (prop === \"generateContent\") return compressedGenerate;\n if (prop === \"generateContentStream\" && compressedStream)\n return compressedStream;\n return (target as any)[prop];\n },\n }) as T;\n}\n"]} \ No newline at end of file diff --git a/node_modules/headroom-ai/dist/adapters/gemini.d.cts b/node_modules/headroom-ai/dist/adapters/gemini.d.cts new file mode 100644 index 00000000..dac33e4d --- /dev/null +++ b/node_modules/headroom-ai/dist/adapters/gemini.d.cts @@ -0,0 +1,31 @@ +import { C as CompressOptions } from '../types-Cjdl75F4.cjs'; + +/** + * Gemini adapter — wraps Google Generative AI model with auto-compression. + * Matches the pattern of openai.ts and anthropic.ts adapters. + */ + +interface GeminiModelLike { + generateContent: (params: any) => any; + generateContentStream?: (params: any) => any; + [key: string]: any; +} +/** + * Wrap a Google Generative AI model to auto-compress before each request. + * + * Intercepts `model.generateContent()` and `model.generateContentStream()`. + * + * @example + * ```typescript + * import { withHeadroom } from 'headroom-ai/gemini'; + * import { GoogleGenerativeAI } from '@google/generative-ai'; + * + * const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY!); + * const model = withHeadroom(genAI.getGenerativeModel({ model: 'gemini-2.0-flash' })); + * + * const result = await model.generateContent({ contents: longConversation }); + * ``` + */ +declare function withHeadroom(model: T, options?: CompressOptions): T; + +export { withHeadroom }; diff --git a/node_modules/headroom-ai/dist/adapters/gemini.d.ts b/node_modules/headroom-ai/dist/adapters/gemini.d.ts new file mode 100644 index 00000000..fda88763 --- /dev/null +++ b/node_modules/headroom-ai/dist/adapters/gemini.d.ts @@ -0,0 +1,31 @@ +import { C as CompressOptions } from '../types-Cjdl75F4.js'; + +/** + * Gemini adapter — wraps Google Generative AI model with auto-compression. + * Matches the pattern of openai.ts and anthropic.ts adapters. + */ + +interface GeminiModelLike { + generateContent: (params: any) => any; + generateContentStream?: (params: any) => any; + [key: string]: any; +} +/** + * Wrap a Google Generative AI model to auto-compress before each request. + * + * Intercepts `model.generateContent()` and `model.generateContentStream()`. + * + * @example + * ```typescript + * import { withHeadroom } from 'headroom-ai/gemini'; + * import { GoogleGenerativeAI } from '@google/generative-ai'; + * + * const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY!); + * const model = withHeadroom(genAI.getGenerativeModel({ model: 'gemini-2.0-flash' })); + * + * const result = await model.generateContent({ contents: longConversation }); + * ``` + */ +declare function withHeadroom(model: T, options?: CompressOptions): T; + +export { withHeadroom }; diff --git a/node_modules/headroom-ai/dist/adapters/gemini.js b/node_modules/headroom-ai/dist/adapters/gemini.js new file mode 100644 index 00000000..eeb7c16d --- /dev/null +++ b/node_modules/headroom-ai/dist/adapters/gemini.js @@ -0,0 +1,39 @@ +import { compress } from '../chunk-24W6JPRV.js'; + +// src/adapters/gemini.ts +function withHeadroom(model, options = {}) { + const originalGenerate = model.generateContent.bind(model); + const originalStream = model.generateContentStream?.bind(model); + const compressedGenerate = async (params) => { + const contents = params.contents ?? params; + const modelName = options.model ?? "gemini-2.0-flash"; + const result = await compress( + Array.isArray(contents) ? contents : [contents], + { stack: "adapter_ts_gemini", ...options, model: modelName } + ); + const newParams = Array.isArray(params) ? result.messages : { ...params, contents: result.messages }; + return originalGenerate(newParams); + }; + const compressedStream = originalStream ? async (params) => { + const contents = params.contents ?? params; + const modelName = options.model ?? "gemini-2.0-flash"; + const result = await compress( + Array.isArray(contents) ? contents : [contents], + { stack: "adapter_ts_gemini", ...options, model: modelName } + ); + const newParams = Array.isArray(params) ? result.messages : { ...params, contents: result.messages }; + return originalStream(newParams); + } : void 0; + return new Proxy(model, { + get(target, prop) { + if (prop === "generateContent") return compressedGenerate; + if (prop === "generateContentStream" && compressedStream) + return compressedStream; + return target[prop]; + } + }); +} + +export { withHeadroom }; +//# sourceMappingURL=gemini.js.map +//# sourceMappingURL=gemini.js.map \ No newline at end of file diff --git a/node_modules/headroom-ai/dist/adapters/gemini.js.map b/node_modules/headroom-ai/dist/adapters/gemini.js.map new file mode 100644 index 00000000..84d95bc7 --- /dev/null +++ b/node_modules/headroom-ai/dist/adapters/gemini.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../src/adapters/gemini.ts"],"names":[],"mappings":";;;AAgCO,SAAS,YAAA,CACd,KAAA,EACA,OAAA,GAA2B,EAAC,EACzB;AACH,EAAA,MAAM,gBAAA,GAAmB,KAAA,CAAM,eAAA,CAAgB,IAAA,CAAK,KAAK,CAAA;AACzD,EAAA,MAAM,cAAA,GAAiB,KAAA,CAAM,qBAAA,EAAuB,IAAA,CAAK,KAAK,CAAA;AAE9D,EAAA,MAAM,kBAAA,GAAqB,OAAO,MAAA,KAAgB;AAChD,IAAA,MAAM,QAAA,GAAW,OAAO,QAAA,IAAY,MAAA;AACpC,IAAA,MAAM,SAAA,GAAY,QAAQ,KAAA,IAAS,kBAAA;AAGnC,IAAA,MAAM,SAAS,MAAM,QAAA;AAAA,MACnB,MAAM,OAAA,CAAQ,QAAQ,CAAA,GAAI,QAAA,GAAW,CAAC,QAAQ,CAAA;AAAA,MAC9C,EAAE,KAAA,EAAO,mBAAA,EAAqB,GAAG,OAAA,EAAS,OAAO,SAAA;AAAU,KAC7D;AAEA,IAAA,MAAM,SAAA,GAAY,KAAA,CAAM,OAAA,CAAQ,MAAM,CAAA,GAClC,MAAA,CAAO,QAAA,GACP,EAAE,GAAG,MAAA,EAAQ,QAAA,EAAU,MAAA,CAAO,QAAA,EAAS;AAE3C,IAAA,OAAO,iBAAiB,SAAS,CAAA;AAAA,EACnC,CAAA;AAEA,EAAA,MAAM,gBAAA,GAAmB,cAAA,GACrB,OAAO,MAAA,KAAgB;AACrB,IAAA,MAAM,QAAA,GAAW,OAAO,QAAA,IAAY,MAAA;AACpC,IAAA,MAAM,SAAA,GAAY,QAAQ,KAAA,IAAS,kBAAA;AAEnC,IAAA,MAAM,SAAS,MAAM,QAAA;AAAA,MACnB,MAAM,OAAA,CAAQ,QAAQ,CAAA,GAAI,QAAA,GAAW,CAAC,QAAQ,CAAA;AAAA,MAC9C,EAAE,KAAA,EAAO,mBAAA,EAAqB,GAAG,OAAA,EAAS,OAAO,SAAA;AAAU,KAC7D;AAEA,IAAA,MAAM,SAAA,GAAY,KAAA,CAAM,OAAA,CAAQ,MAAM,CAAA,GAClC,MAAA,CAAO,QAAA,GACP,EAAE,GAAG,MAAA,EAAQ,QAAA,EAAU,MAAA,CAAO,QAAA,EAAS;AAE3C,IAAA,OAAO,eAAe,SAAS,CAAA;AAAA,EACjC,CAAA,GACA,MAAA;AAEJ,EAAA,OAAO,IAAI,MAAM,KAAA,EAAO;AAAA,IACtB,GAAA,CAAI,QAAQ,IAAA,EAAM;AAChB,MAAA,IAAI,IAAA,KAAS,mBAAmB,OAAO,kBAAA;AACvC,MAAA,IAAI,SAAS,uBAAA,IAA2B,gBAAA;AACtC,QAAA,OAAO,gBAAA;AACT,MAAA,OAAQ,OAAe,IAAI,CAAA;AAAA,IAC7B;AAAA,GACD,CAAA;AACH","file":"gemini.js","sourcesContent":["/**\n * Gemini adapter — wraps Google Generative AI model with auto-compression.\n * Matches the pattern of openai.ts and anthropic.ts adapters.\n */\n\nimport { compress } from \"../compress.js\";\nimport type { CompressOptions } from \"../types.js\";\n\n/* eslint-disable @typescript-eslint/no-explicit-any */\n\ninterface GeminiModelLike {\n generateContent: (params: any) => any;\n generateContentStream?: (params: any) => any;\n [key: string]: any;\n}\n\n/**\n * Wrap a Google Generative AI model to auto-compress before each request.\n *\n * Intercepts `model.generateContent()` and `model.generateContentStream()`.\n *\n * @example\n * ```typescript\n * import { withHeadroom } from 'headroom-ai/gemini';\n * import { GoogleGenerativeAI } from '@google/generative-ai';\n *\n * const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY!);\n * const model = withHeadroom(genAI.getGenerativeModel({ model: 'gemini-2.0-flash' }));\n *\n * const result = await model.generateContent({ contents: longConversation });\n * ```\n */\nexport function withHeadroom(\n model: T,\n options: CompressOptions = {},\n): T {\n const originalGenerate = model.generateContent.bind(model);\n const originalStream = model.generateContentStream?.bind(model);\n\n const compressedGenerate = async (params: any) => {\n const contents = params.contents ?? params;\n const modelName = options.model ?? \"gemini-2.0-flash\";\n\n // compress() auto-detects Gemini format\n const result = await compress(\n Array.isArray(contents) ? contents : [contents],\n { stack: \"adapter_ts_gemini\", ...options, model: modelName },\n );\n\n const newParams = Array.isArray(params)\n ? result.messages\n : { ...params, contents: result.messages };\n\n return originalGenerate(newParams);\n };\n\n const compressedStream = originalStream\n ? async (params: any) => {\n const contents = params.contents ?? params;\n const modelName = options.model ?? \"gemini-2.0-flash\";\n\n const result = await compress(\n Array.isArray(contents) ? contents : [contents],\n { stack: \"adapter_ts_gemini\", ...options, model: modelName },\n );\n\n const newParams = Array.isArray(params)\n ? result.messages\n : { ...params, contents: result.messages };\n\n return originalStream(newParams);\n }\n : undefined;\n\n return new Proxy(model, {\n get(target, prop) {\n if (prop === \"generateContent\") return compressedGenerate;\n if (prop === \"generateContentStream\" && compressedStream)\n return compressedStream;\n return (target as any)[prop];\n },\n }) as T;\n}\n"]} \ No newline at end of file diff --git a/node_modules/headroom-ai/dist/adapters/openai.cjs b/node_modules/headroom-ai/dist/adapters/openai.cjs new file mode 100644 index 00000000..f4b323ce --- /dev/null +++ b/node_modules/headroom-ai/dist/adapters/openai.cjs @@ -0,0 +1,45 @@ +'use strict'; + +var chunk26N6BVLT_cjs = require('../chunk-26N6BVLT.cjs'); + +// src/adapters/openai.ts +function withHeadroom(client, options = {}) { + const originalCreate = client.chat.completions.create.bind( + client.chat.completions + ); + const compressedCreate = async (params) => { + const messages = params.messages; + const model = options.model ?? params.model ?? "gpt-4o"; + const result = await chunk26N6BVLT_cjs.compress(messages, { + stack: "adapter_ts_openai", + ...options, + model + }); + return originalCreate({ + ...params, + messages: result.messages + }); + }; + const completionsProxy = new Proxy(client.chat.completions, { + get(target, prop) { + if (prop === "create") return compressedCreate; + return target[prop]; + } + }); + const chatProxy = new Proxy(client.chat, { + get(target, prop) { + if (prop === "completions") return completionsProxy; + return target[prop]; + } + }); + return new Proxy(client, { + get(target, prop) { + if (prop === "chat") return chatProxy; + return target[prop]; + } + }); +} + +exports.withHeadroom = withHeadroom; +//# sourceMappingURL=openai.cjs.map +//# sourceMappingURL=openai.cjs.map \ No newline at end of file diff --git a/node_modules/headroom-ai/dist/adapters/openai.cjs.map b/node_modules/headroom-ai/dist/adapters/openai.cjs.map new file mode 100644 index 00000000..04ee1da9 --- /dev/null +++ b/node_modules/headroom-ai/dist/adapters/openai.cjs.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../src/adapters/openai.ts"],"names":["compress"],"mappings":";;;;;AAgCO,SAAS,YAAA,CACd,MAAA,EACA,OAAA,GAA2B,EAAC,EACzB;AACH,EAAA,MAAM,cAAA,GAAiB,MAAA,CAAO,IAAA,CAAK,WAAA,CAAY,MAAA,CAAO,IAAA;AAAA,IACpD,OAAO,IAAA,CAAK;AAAA,GACd;AAEA,EAAA,MAAM,gBAAA,GAAmB,OAAO,MAAA,KAAgB;AAC9C,IAAA,MAAM,WAA4B,MAAA,CAAO,QAAA;AACzC,IAAA,MAAM,KAAA,GAAQ,OAAA,CAAQ,KAAA,IAAS,MAAA,CAAO,KAAA,IAAS,QAAA;AAE/C,IAAA,MAAM,MAAA,GAAS,MAAMA,0BAAA,CAAS,QAAA,EAAU;AAAA,MACtC,KAAA,EAAO,mBAAA;AAAA,MACP,GAAG,OAAA;AAAA,MACH;AAAA,KACD,CAAA;AAED,IAAA,OAAO,cAAA,CAAe;AAAA,MACpB,GAAG,MAAA;AAAA,MACH,UAAU,MAAA,CAAO;AAAA,KAClB,CAAA;AAAA,EACH,CAAA;AAEA,EAAA,MAAM,gBAAA,GAAmB,IAAI,KAAA,CAAM,MAAA,CAAO,KAAK,WAAA,EAAa;AAAA,IAC1D,GAAA,CAAI,QAAQ,IAAA,EAAM;AAChB,MAAA,IAAI,IAAA,KAAS,UAAU,OAAO,gBAAA;AAC9B,MAAA,OAAQ,OAAe,IAAI,CAAA;AAAA,IAC7B;AAAA,GACD,CAAA;AAED,EAAA,MAAM,SAAA,GAAY,IAAI,KAAA,CAAM,MAAA,CAAO,IAAA,EAAM;AAAA,IACvC,GAAA,CAAI,QAAQ,IAAA,EAAM;AAChB,MAAA,IAAI,IAAA,KAAS,eAAe,OAAO,gBAAA;AACnC,MAAA,OAAQ,OAAe,IAAI,CAAA;AAAA,IAC7B;AAAA,GACD,CAAA;AAED,EAAA,OAAO,IAAI,MAAM,MAAA,EAAQ;AAAA,IACvB,GAAA,CAAI,QAAQ,IAAA,EAAM;AAChB,MAAA,IAAI,IAAA,KAAS,QAAQ,OAAO,SAAA;AAC5B,MAAA,OAAQ,OAAe,IAAI,CAAA;AAAA,IAC7B;AAAA,GACD,CAAA;AACH","file":"openai.cjs","sourcesContent":["import { compress } from \"../compress.js\";\nimport type { CompressOptions, OpenAIMessage } from \"../types.js\";\n\n/* eslint-disable @typescript-eslint/no-explicit-any */\n\ninterface OpenAILike {\n chat: {\n completions: {\n create: (params: any) => any;\n };\n };\n [key: string]: any;\n}\n\n/**\n * Wrap an OpenAI client to auto-compress messages before each request.\n *\n * Intercepts `client.chat.completions.create()` only. All other methods\n * (embeddings, images, audio, etc.) pass through unchanged.\n *\n * @example\n * ```typescript\n * import { withHeadroom } from 'headroom-ai/openai';\n * import OpenAI from 'openai';\n *\n * const client = withHeadroom(new OpenAI());\n * const response = await client.chat.completions.create({\n * model: 'gpt-4o',\n * messages: longConversation,\n * });\n * ```\n */\nexport function withHeadroom(\n client: T,\n options: CompressOptions = {},\n): T {\n const originalCreate = client.chat.completions.create.bind(\n client.chat.completions,\n );\n\n const compressedCreate = async (params: any) => {\n const messages: OpenAIMessage[] = params.messages;\n const model = options.model ?? params.model ?? \"gpt-4o\";\n\n const result = await compress(messages, {\n stack: \"adapter_ts_openai\",\n ...options,\n model,\n });\n\n return originalCreate({\n ...params,\n messages: result.messages,\n });\n };\n\n const completionsProxy = new Proxy(client.chat.completions, {\n get(target, prop) {\n if (prop === \"create\") return compressedCreate;\n return (target as any)[prop];\n },\n });\n\n const chatProxy = new Proxy(client.chat, {\n get(target, prop) {\n if (prop === \"completions\") return completionsProxy;\n return (target as any)[prop];\n },\n });\n\n return new Proxy(client, {\n get(target, prop) {\n if (prop === \"chat\") return chatProxy;\n return (target as any)[prop];\n },\n }) as T;\n}\n"]} \ No newline at end of file diff --git a/node_modules/headroom-ai/dist/adapters/openai.d.cts b/node_modules/headroom-ai/dist/adapters/openai.d.cts new file mode 100644 index 00000000..2f460950 --- /dev/null +++ b/node_modules/headroom-ai/dist/adapters/openai.d.cts @@ -0,0 +1,31 @@ +import { C as CompressOptions } from '../types-Cjdl75F4.cjs'; + +interface OpenAILike { + chat: { + completions: { + create: (params: any) => any; + }; + }; + [key: string]: any; +} +/** + * Wrap an OpenAI client to auto-compress messages before each request. + * + * Intercepts `client.chat.completions.create()` only. All other methods + * (embeddings, images, audio, etc.) pass through unchanged. + * + * @example + * ```typescript + * import { withHeadroom } from 'headroom-ai/openai'; + * import OpenAI from 'openai'; + * + * const client = withHeadroom(new OpenAI()); + * const response = await client.chat.completions.create({ + * model: 'gpt-4o', + * messages: longConversation, + * }); + * ``` + */ +declare function withHeadroom(client: T, options?: CompressOptions): T; + +export { withHeadroom }; diff --git a/node_modules/headroom-ai/dist/adapters/openai.d.ts b/node_modules/headroom-ai/dist/adapters/openai.d.ts new file mode 100644 index 00000000..72acff19 --- /dev/null +++ b/node_modules/headroom-ai/dist/adapters/openai.d.ts @@ -0,0 +1,31 @@ +import { C as CompressOptions } from '../types-Cjdl75F4.js'; + +interface OpenAILike { + chat: { + completions: { + create: (params: any) => any; + }; + }; + [key: string]: any; +} +/** + * Wrap an OpenAI client to auto-compress messages before each request. + * + * Intercepts `client.chat.completions.create()` only. All other methods + * (embeddings, images, audio, etc.) pass through unchanged. + * + * @example + * ```typescript + * import { withHeadroom } from 'headroom-ai/openai'; + * import OpenAI from 'openai'; + * + * const client = withHeadroom(new OpenAI()); + * const response = await client.chat.completions.create({ + * model: 'gpt-4o', + * messages: longConversation, + * }); + * ``` + */ +declare function withHeadroom(client: T, options?: CompressOptions): T; + +export { withHeadroom }; diff --git a/node_modules/headroom-ai/dist/adapters/openai.js b/node_modules/headroom-ai/dist/adapters/openai.js new file mode 100644 index 00000000..3bfa4fee --- /dev/null +++ b/node_modules/headroom-ai/dist/adapters/openai.js @@ -0,0 +1,43 @@ +import { compress } from '../chunk-24W6JPRV.js'; + +// src/adapters/openai.ts +function withHeadroom(client, options = {}) { + const originalCreate = client.chat.completions.create.bind( + client.chat.completions + ); + const compressedCreate = async (params) => { + const messages = params.messages; + const model = options.model ?? params.model ?? "gpt-4o"; + const result = await compress(messages, { + stack: "adapter_ts_openai", + ...options, + model + }); + return originalCreate({ + ...params, + messages: result.messages + }); + }; + const completionsProxy = new Proxy(client.chat.completions, { + get(target, prop) { + if (prop === "create") return compressedCreate; + return target[prop]; + } + }); + const chatProxy = new Proxy(client.chat, { + get(target, prop) { + if (prop === "completions") return completionsProxy; + return target[prop]; + } + }); + return new Proxy(client, { + get(target, prop) { + if (prop === "chat") return chatProxy; + return target[prop]; + } + }); +} + +export { withHeadroom }; +//# sourceMappingURL=openai.js.map +//# sourceMappingURL=openai.js.map \ No newline at end of file diff --git a/node_modules/headroom-ai/dist/adapters/openai.js.map b/node_modules/headroom-ai/dist/adapters/openai.js.map new file mode 100644 index 00000000..641fd329 --- /dev/null +++ b/node_modules/headroom-ai/dist/adapters/openai.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../src/adapters/openai.ts"],"names":[],"mappings":";;;AAgCO,SAAS,YAAA,CACd,MAAA,EACA,OAAA,GAA2B,EAAC,EACzB;AACH,EAAA,MAAM,cAAA,GAAiB,MAAA,CAAO,IAAA,CAAK,WAAA,CAAY,MAAA,CAAO,IAAA;AAAA,IACpD,OAAO,IAAA,CAAK;AAAA,GACd;AAEA,EAAA,MAAM,gBAAA,GAAmB,OAAO,MAAA,KAAgB;AAC9C,IAAA,MAAM,WAA4B,MAAA,CAAO,QAAA;AACzC,IAAA,MAAM,KAAA,GAAQ,OAAA,CAAQ,KAAA,IAAS,MAAA,CAAO,KAAA,IAAS,QAAA;AAE/C,IAAA,MAAM,MAAA,GAAS,MAAM,QAAA,CAAS,QAAA,EAAU;AAAA,MACtC,KAAA,EAAO,mBAAA;AAAA,MACP,GAAG,OAAA;AAAA,MACH;AAAA,KACD,CAAA;AAED,IAAA,OAAO,cAAA,CAAe;AAAA,MACpB,GAAG,MAAA;AAAA,MACH,UAAU,MAAA,CAAO;AAAA,KAClB,CAAA;AAAA,EACH,CAAA;AAEA,EAAA,MAAM,gBAAA,GAAmB,IAAI,KAAA,CAAM,MAAA,CAAO,KAAK,WAAA,EAAa;AAAA,IAC1D,GAAA,CAAI,QAAQ,IAAA,EAAM;AAChB,MAAA,IAAI,IAAA,KAAS,UAAU,OAAO,gBAAA;AAC9B,MAAA,OAAQ,OAAe,IAAI,CAAA;AAAA,IAC7B;AAAA,GACD,CAAA;AAED,EAAA,MAAM,SAAA,GAAY,IAAI,KAAA,CAAM,MAAA,CAAO,IAAA,EAAM;AAAA,IACvC,GAAA,CAAI,QAAQ,IAAA,EAAM;AAChB,MAAA,IAAI,IAAA,KAAS,eAAe,OAAO,gBAAA;AACnC,MAAA,OAAQ,OAAe,IAAI,CAAA;AAAA,IAC7B;AAAA,GACD,CAAA;AAED,EAAA,OAAO,IAAI,MAAM,MAAA,EAAQ;AAAA,IACvB,GAAA,CAAI,QAAQ,IAAA,EAAM;AAChB,MAAA,IAAI,IAAA,KAAS,QAAQ,OAAO,SAAA;AAC5B,MAAA,OAAQ,OAAe,IAAI,CAAA;AAAA,IAC7B;AAAA,GACD,CAAA;AACH","file":"openai.js","sourcesContent":["import { compress } from \"../compress.js\";\nimport type { CompressOptions, OpenAIMessage } from \"../types.js\";\n\n/* eslint-disable @typescript-eslint/no-explicit-any */\n\ninterface OpenAILike {\n chat: {\n completions: {\n create: (params: any) => any;\n };\n };\n [key: string]: any;\n}\n\n/**\n * Wrap an OpenAI client to auto-compress messages before each request.\n *\n * Intercepts `client.chat.completions.create()` only. All other methods\n * (embeddings, images, audio, etc.) pass through unchanged.\n *\n * @example\n * ```typescript\n * import { withHeadroom } from 'headroom-ai/openai';\n * import OpenAI from 'openai';\n *\n * const client = withHeadroom(new OpenAI());\n * const response = await client.chat.completions.create({\n * model: 'gpt-4o',\n * messages: longConversation,\n * });\n * ```\n */\nexport function withHeadroom(\n client: T,\n options: CompressOptions = {},\n): T {\n const originalCreate = client.chat.completions.create.bind(\n client.chat.completions,\n );\n\n const compressedCreate = async (params: any) => {\n const messages: OpenAIMessage[] = params.messages;\n const model = options.model ?? params.model ?? \"gpt-4o\";\n\n const result = await compress(messages, {\n stack: \"adapter_ts_openai\",\n ...options,\n model,\n });\n\n return originalCreate({\n ...params,\n messages: result.messages,\n });\n };\n\n const completionsProxy = new Proxy(client.chat.completions, {\n get(target, prop) {\n if (prop === \"create\") return compressedCreate;\n return (target as any)[prop];\n },\n });\n\n const chatProxy = new Proxy(client.chat, {\n get(target, prop) {\n if (prop === \"completions\") return completionsProxy;\n return (target as any)[prop];\n },\n });\n\n return new Proxy(client, {\n get(target, prop) {\n if (prop === \"chat\") return chatProxy;\n return (target as any)[prop];\n },\n }) as T;\n}\n"]} \ No newline at end of file diff --git a/node_modules/headroom-ai/dist/adapters/vercel-ai.cjs b/node_modules/headroom-ai/dist/adapters/vercel-ai.cjs new file mode 100644 index 00000000..048e560c --- /dev/null +++ b/node_modules/headroom-ai/dist/adapters/vercel-ai.cjs @@ -0,0 +1,60 @@ +'use strict'; + +var chunk26N6BVLT_cjs = require('../chunk-26N6BVLT.cjs'); +var module$1 = require('module'); + +var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null; +function headroomMiddleware(options = {}) { + return { + transformParams: async ({ + params + }) => { + const prompt = params.prompt; + if (!prompt || prompt.length === 0) return params; + const model = options.model ?? params.modelId ?? "gpt-4o"; + const openaiMessages = chunk26N6BVLT_cjs.vercelToOpenAI(prompt); + const result = await chunk26N6BVLT_cjs.compress(openaiMessages, { + stack: "adapter_ts_vercel_ai", + ...options, + model + }); + if (!result.compressed) return params; + const compressedPrompt = chunk26N6BVLT_cjs.openAIToVercel(result.messages); + return { ...params, prompt: compressedPrompt }; + } + }; +} +async function compressVercelMessages(messages, options = {}) { + const openaiMessages = chunk26N6BVLT_cjs.vercelToOpenAI(messages); + const result = await chunk26N6BVLT_cjs.compress(openaiMessages, { + stack: "adapter_ts_vercel_ai", + ...options + }); + const vercelMessages = chunk26N6BVLT_cjs.openAIToVercel(result.messages); + return { + ...result, + messages: vercelMessages + }; +} +function withHeadroom(model, options = {}) { + let wrapLanguageModel; + try { + const require2 = module$1.createRequire((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('vercel-ai.cjs', document.baseURI).href))); + const ai = require2("ai"); + wrapLanguageModel = ai.wrapLanguageModel; + } catch { + throw new Error( + 'withHeadroom() requires the "ai" package. Install it with: npm install ai' + ); + } + return wrapLanguageModel({ + model, + middleware: headroomMiddleware(options) + }); +} + +exports.compressVercelMessages = compressVercelMessages; +exports.headroomMiddleware = headroomMiddleware; +exports.withHeadroom = withHeadroom; +//# sourceMappingURL=vercel-ai.cjs.map +//# sourceMappingURL=vercel-ai.cjs.map \ No newline at end of file diff --git a/node_modules/headroom-ai/dist/adapters/vercel-ai.cjs.map b/node_modules/headroom-ai/dist/adapters/vercel-ai.cjs.map new file mode 100644 index 00000000..48760c1b --- /dev/null +++ b/node_modules/headroom-ai/dist/adapters/vercel-ai.cjs.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../src/adapters/vercel-ai.ts"],"names":["vercelToOpenAI","compress","openAIToVercel","require","createRequire"],"mappings":";;;;;;AAqCO,SAAS,kBAAA,CAAmB,OAAA,GAA2B,EAAC,EAAG;AAChE,EAAA,OAAO;AAAA,IACL,iBAAiB,OAAO;AAAA,MACtB;AAAA,KACF,KAIM;AACJ,MAAA,MAAM,SAA0B,MAAA,CAAO,MAAA;AACvC,MAAA,IAAI,CAAC,MAAA,IAAU,MAAA,CAAO,MAAA,KAAW,GAAG,OAAO,MAAA;AAE3C,MAAA,MAAM,KAAA,GAAQ,OAAA,CAAQ,KAAA,IAAS,MAAA,CAAO,OAAA,IAAW,QAAA;AAGjD,MAAA,MAAM,cAAA,GAAiBA,iCAAe,MAAM,CAAA;AAG5C,MAAA,MAAM,MAAA,GAAS,MAAMC,0BAAA,CAAS,cAAA,EAAgB;AAAA,QAC5C,KAAA,EAAO,sBAAA;AAAA,QACP,GAAG,OAAA;AAAA,QACH;AAAA,OACD,CAAA;AAED,MAAA,IAAI,CAAC,MAAA,CAAO,UAAA,EAAY,OAAO,MAAA;AAG/B,MAAA,MAAM,gBAAA,GAAmBC,gCAAA,CAAe,MAAA,CAAO,QAAQ,CAAA;AAEvD,MAAA,OAAO,EAAE,GAAG,MAAA,EAAQ,MAAA,EAAQ,gBAAA,EAAiB;AAAA,IAC/C;AAAA,GACF;AACF;AAMA,eAAsB,sBAAA,CACpB,QAAA,EACA,OAAA,GAA2B,EAAC,EAC6B;AACzD,EAAA,MAAM,cAAA,GAAiBF,iCAAe,QAAQ,CAAA;AAC9C,EAAA,MAAM,MAAA,GAAS,MAAMC,0BAAA,CAAS,cAAA,EAAgB;AAAA,IAC5C,KAAA,EAAO,sBAAA;AAAA,IACP,GAAG;AAAA,GACJ,CAAA;AACD,EAAA,MAAM,cAAA,GAAiBC,gCAAA,CAAe,MAAA,CAAO,QAAQ,CAAA;AAErD,EAAA,OAAO;AAAA,IACL,GAAG,MAAA;AAAA,IACH,QAAA,EAAU;AAAA,GACZ;AACF;AAgBO,SAAS,YAAA,CACd,KAAA,EACA,OAAA,GAA2B,EAAC,EACzB;AACH,EAAA,IAAI,iBAAA;AAKJ,EAAA,IAAI;AACF,IAAA,MAAMC,QAAAA,GAAUC,sBAAA,CAAc,+PAAe,CAAA;AAC7C,IAAA,MAAM,EAAA,GAAKD,SAAQ,IAAI,CAAA;AACvB,IAAA,iBAAA,GAAoB,EAAA,CAAG,iBAAA;AAAA,EACzB,CAAA,CAAA,MAAQ;AACN,IAAA,MAAM,IAAI,KAAA;AAAA,MACR;AAAA,KACF;AAAA,EACF;AAEA,EAAA,OAAO,iBAAA,CAAkB;AAAA,IACvB,KAAA;AAAA,IACA,UAAA,EAAY,mBAAmB,OAAO;AAAA,GACvC,CAAA;AACH","file":"vercel-ai.cjs","sourcesContent":["import { createRequire } from \"node:module\";\nimport { compress } from \"../compress.js\";\nimport type { CompressOptions, CompressResult } from \"../types.js\";\nimport { vercelToOpenAI, openAIToVercel } from \"../utils/format.js\";\n\n/**\n * Minimal structural type for Vercel AI SDK language models.\n * Compatible with LanguageModelV1, LanguageModelV3, and LanguageModelV4.\n */\ninterface LanguageModel {\n readonly specificationVersion: string;\n readonly provider: string;\n readonly modelId: string;\n doGenerate: (...args: any[]) => any;\n doStream: (...args: any[]) => any;\n [key: string]: any;\n}\n\n/* eslint-disable @typescript-eslint/no-explicit-any */\ntype VercelMessage = any;\n\n/**\n * Vercel AI SDK language model middleware that compresses messages\n * before they reach the LLM.\n *\n * @example\n * ```typescript\n * import { headroomMiddleware } from 'headroom-ai/vercel-ai';\n * import { wrapLanguageModel } from 'ai';\n * import { openai } from '@ai-sdk/openai';\n *\n * const model = wrapLanguageModel({\n * model: openai('gpt-4o'),\n * middleware: headroomMiddleware(),\n * });\n * ```\n */\nexport function headroomMiddleware(options: CompressOptions = {}) {\n return {\n transformParams: async ({\n params,\n }: {\n params: any;\n model: any;\n type: string;\n }) => {\n const prompt: VercelMessage[] = params.prompt;\n if (!prompt || prompt.length === 0) return params;\n\n const model = options.model ?? params.modelId ?? \"gpt-4o\";\n\n // Convert Vercel format → OpenAI format\n const openaiMessages = vercelToOpenAI(prompt);\n\n // Compress via Headroom\n const result = await compress(openaiMessages, {\n stack: \"adapter_ts_vercel_ai\",\n ...options,\n model,\n });\n\n if (!result.compressed) return params;\n\n // Convert back to Vercel format\n const compressedPrompt = openAIToVercel(result.messages);\n\n return { ...params, prompt: compressedPrompt };\n },\n };\n}\n\n/**\n * Standalone: compress Vercel AI SDK ModelMessage[] directly.\n * Returns compressed messages in Vercel format + compression stats.\n */\nexport async function compressVercelMessages(\n messages: VercelMessage[],\n options: CompressOptions = {},\n): Promise {\n const openaiMessages = vercelToOpenAI(messages);\n const result = await compress(openaiMessages, {\n stack: \"adapter_ts_vercel_ai\",\n ...options,\n });\n const vercelMessages = openAIToVercel(result.messages);\n\n return {\n ...result,\n messages: vercelMessages,\n };\n}\n\n/**\n * Wrap a Vercel AI SDK language model with Headroom compression.\n * Convenience wrapper around `wrapLanguageModel` + `headroomMiddleware`.\n *\n * @example\n * ```typescript\n * import { withHeadroom } from 'headroom-ai/vercel-ai';\n * import { openai } from '@ai-sdk/openai';\n * import { generateText } from 'ai';\n *\n * const model = withHeadroom(openai('gpt-4o'));\n * const { text } = await generateText({ model, messages });\n * ```\n */\nexport function withHeadroom(\n model: T,\n options: CompressOptions = {},\n): T {\n let wrapLanguageModel: (opts: {\n model: T;\n middleware: any;\n }) => T;\n\n try {\n const require = createRequire(import.meta.url);\n const ai = require(\"ai\");\n wrapLanguageModel = ai.wrapLanguageModel;\n } catch {\n throw new Error(\n 'withHeadroom() requires the \"ai\" package. Install it with: npm install ai',\n );\n }\n\n return wrapLanguageModel({\n model,\n middleware: headroomMiddleware(options),\n });\n}\n"]} \ No newline at end of file diff --git a/node_modules/headroom-ai/dist/adapters/vercel-ai.d.cts b/node_modules/headroom-ai/dist/adapters/vercel-ai.d.cts new file mode 100644 index 00000000..1e5db2dd --- /dev/null +++ b/node_modules/headroom-ai/dist/adapters/vercel-ai.d.cts @@ -0,0 +1,62 @@ +import { C as CompressOptions, a as CompressResult } from '../types-Cjdl75F4.cjs'; + +/** + * Minimal structural type for Vercel AI SDK language models. + * Compatible with LanguageModelV1, LanguageModelV3, and LanguageModelV4. + */ +interface LanguageModel { + readonly specificationVersion: string; + readonly provider: string; + readonly modelId: string; + doGenerate: (...args: any[]) => any; + doStream: (...args: any[]) => any; + [key: string]: any; +} +type VercelMessage = any; +/** + * Vercel AI SDK language model middleware that compresses messages + * before they reach the LLM. + * + * @example + * ```typescript + * import { headroomMiddleware } from 'headroom-ai/vercel-ai'; + * import { wrapLanguageModel } from 'ai'; + * import { openai } from '@ai-sdk/openai'; + * + * const model = wrapLanguageModel({ + * model: openai('gpt-4o'), + * middleware: headroomMiddleware(), + * }); + * ``` + */ +declare function headroomMiddleware(options?: CompressOptions): { + transformParams: ({ params, }: { + params: any; + model: any; + type: string; + }) => Promise; +}; +/** + * Standalone: compress Vercel AI SDK ModelMessage[] directly. + * Returns compressed messages in Vercel format + compression stats. + */ +declare function compressVercelMessages(messages: VercelMessage[], options?: CompressOptions): Promise; +/** + * Wrap a Vercel AI SDK language model with Headroom compression. + * Convenience wrapper around `wrapLanguageModel` + `headroomMiddleware`. + * + * @example + * ```typescript + * import { withHeadroom } from 'headroom-ai/vercel-ai'; + * import { openai } from '@ai-sdk/openai'; + * import { generateText } from 'ai'; + * + * const model = withHeadroom(openai('gpt-4o')); + * const { text } = await generateText({ model, messages }); + * ``` + */ +declare function withHeadroom(model: T, options?: CompressOptions): T; + +export { compressVercelMessages, headroomMiddleware, withHeadroom }; diff --git a/node_modules/headroom-ai/dist/adapters/vercel-ai.d.ts b/node_modules/headroom-ai/dist/adapters/vercel-ai.d.ts new file mode 100644 index 00000000..05db7f56 --- /dev/null +++ b/node_modules/headroom-ai/dist/adapters/vercel-ai.d.ts @@ -0,0 +1,62 @@ +import { C as CompressOptions, a as CompressResult } from '../types-Cjdl75F4.js'; + +/** + * Minimal structural type for Vercel AI SDK language models. + * Compatible with LanguageModelV1, LanguageModelV3, and LanguageModelV4. + */ +interface LanguageModel { + readonly specificationVersion: string; + readonly provider: string; + readonly modelId: string; + doGenerate: (...args: any[]) => any; + doStream: (...args: any[]) => any; + [key: string]: any; +} +type VercelMessage = any; +/** + * Vercel AI SDK language model middleware that compresses messages + * before they reach the LLM. + * + * @example + * ```typescript + * import { headroomMiddleware } from 'headroom-ai/vercel-ai'; + * import { wrapLanguageModel } from 'ai'; + * import { openai } from '@ai-sdk/openai'; + * + * const model = wrapLanguageModel({ + * model: openai('gpt-4o'), + * middleware: headroomMiddleware(), + * }); + * ``` + */ +declare function headroomMiddleware(options?: CompressOptions): { + transformParams: ({ params, }: { + params: any; + model: any; + type: string; + }) => Promise; +}; +/** + * Standalone: compress Vercel AI SDK ModelMessage[] directly. + * Returns compressed messages in Vercel format + compression stats. + */ +declare function compressVercelMessages(messages: VercelMessage[], options?: CompressOptions): Promise; +/** + * Wrap a Vercel AI SDK language model with Headroom compression. + * Convenience wrapper around `wrapLanguageModel` + `headroomMiddleware`. + * + * @example + * ```typescript + * import { withHeadroom } from 'headroom-ai/vercel-ai'; + * import { openai } from '@ai-sdk/openai'; + * import { generateText } from 'ai'; + * + * const model = withHeadroom(openai('gpt-4o')); + * const { text } = await generateText({ model, messages }); + * ``` + */ +declare function withHeadroom(model: T, options?: CompressOptions): T; + +export { compressVercelMessages, headroomMiddleware, withHeadroom }; diff --git a/node_modules/headroom-ai/dist/adapters/vercel-ai.js b/node_modules/headroom-ai/dist/adapters/vercel-ai.js new file mode 100644 index 00000000..b8f692a9 --- /dev/null +++ b/node_modules/headroom-ai/dist/adapters/vercel-ai.js @@ -0,0 +1,55 @@ +import { vercelToOpenAI, compress, openAIToVercel } from '../chunk-24W6JPRV.js'; +import { createRequire } from 'module'; + +function headroomMiddleware(options = {}) { + return { + transformParams: async ({ + params + }) => { + const prompt = params.prompt; + if (!prompt || prompt.length === 0) return params; + const model = options.model ?? params.modelId ?? "gpt-4o"; + const openaiMessages = vercelToOpenAI(prompt); + const result = await compress(openaiMessages, { + stack: "adapter_ts_vercel_ai", + ...options, + model + }); + if (!result.compressed) return params; + const compressedPrompt = openAIToVercel(result.messages); + return { ...params, prompt: compressedPrompt }; + } + }; +} +async function compressVercelMessages(messages, options = {}) { + const openaiMessages = vercelToOpenAI(messages); + const result = await compress(openaiMessages, { + stack: "adapter_ts_vercel_ai", + ...options + }); + const vercelMessages = openAIToVercel(result.messages); + return { + ...result, + messages: vercelMessages + }; +} +function withHeadroom(model, options = {}) { + let wrapLanguageModel; + try { + const require2 = createRequire(import.meta.url); + const ai = require2("ai"); + wrapLanguageModel = ai.wrapLanguageModel; + } catch { + throw new Error( + 'withHeadroom() requires the "ai" package. Install it with: npm install ai' + ); + } + return wrapLanguageModel({ + model, + middleware: headroomMiddleware(options) + }); +} + +export { compressVercelMessages, headroomMiddleware, withHeadroom }; +//# sourceMappingURL=vercel-ai.js.map +//# sourceMappingURL=vercel-ai.js.map \ No newline at end of file diff --git a/node_modules/headroom-ai/dist/adapters/vercel-ai.js.map b/node_modules/headroom-ai/dist/adapters/vercel-ai.js.map new file mode 100644 index 00000000..aec34d52 --- /dev/null +++ b/node_modules/headroom-ai/dist/adapters/vercel-ai.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../src/adapters/vercel-ai.ts"],"names":["require"],"mappings":";;;AAqCO,SAAS,kBAAA,CAAmB,OAAA,GAA2B,EAAC,EAAG;AAChE,EAAA,OAAO;AAAA,IACL,iBAAiB,OAAO;AAAA,MACtB;AAAA,KACF,KAIM;AACJ,MAAA,MAAM,SAA0B,MAAA,CAAO,MAAA;AACvC,MAAA,IAAI,CAAC,MAAA,IAAU,MAAA,CAAO,MAAA,KAAW,GAAG,OAAO,MAAA;AAE3C,MAAA,MAAM,KAAA,GAAQ,OAAA,CAAQ,KAAA,IAAS,MAAA,CAAO,OAAA,IAAW,QAAA;AAGjD,MAAA,MAAM,cAAA,GAAiB,eAAe,MAAM,CAAA;AAG5C,MAAA,MAAM,MAAA,GAAS,MAAM,QAAA,CAAS,cAAA,EAAgB;AAAA,QAC5C,KAAA,EAAO,sBAAA;AAAA,QACP,GAAG,OAAA;AAAA,QACH;AAAA,OACD,CAAA;AAED,MAAA,IAAI,CAAC,MAAA,CAAO,UAAA,EAAY,OAAO,MAAA;AAG/B,MAAA,MAAM,gBAAA,GAAmB,cAAA,CAAe,MAAA,CAAO,QAAQ,CAAA;AAEvD,MAAA,OAAO,EAAE,GAAG,MAAA,EAAQ,MAAA,EAAQ,gBAAA,EAAiB;AAAA,IAC/C;AAAA,GACF;AACF;AAMA,eAAsB,sBAAA,CACpB,QAAA,EACA,OAAA,GAA2B,EAAC,EAC6B;AACzD,EAAA,MAAM,cAAA,GAAiB,eAAe,QAAQ,CAAA;AAC9C,EAAA,MAAM,MAAA,GAAS,MAAM,QAAA,CAAS,cAAA,EAAgB;AAAA,IAC5C,KAAA,EAAO,sBAAA;AAAA,IACP,GAAG;AAAA,GACJ,CAAA;AACD,EAAA,MAAM,cAAA,GAAiB,cAAA,CAAe,MAAA,CAAO,QAAQ,CAAA;AAErD,EAAA,OAAO;AAAA,IACL,GAAG,MAAA;AAAA,IACH,QAAA,EAAU;AAAA,GACZ;AACF;AAgBO,SAAS,YAAA,CACd,KAAA,EACA,OAAA,GAA2B,EAAC,EACzB;AACH,EAAA,IAAI,iBAAA;AAKJ,EAAA,IAAI;AACF,IAAA,MAAMA,QAAAA,GAAU,aAAA,CAAc,MAAA,CAAA,IAAA,CAAY,GAAG,CAAA;AAC7C,IAAA,MAAM,EAAA,GAAKA,SAAQ,IAAI,CAAA;AACvB,IAAA,iBAAA,GAAoB,EAAA,CAAG,iBAAA;AAAA,EACzB,CAAA,CAAA,MAAQ;AACN,IAAA,MAAM,IAAI,KAAA;AAAA,MACR;AAAA,KACF;AAAA,EACF;AAEA,EAAA,OAAO,iBAAA,CAAkB;AAAA,IACvB,KAAA;AAAA,IACA,UAAA,EAAY,mBAAmB,OAAO;AAAA,GACvC,CAAA;AACH","file":"vercel-ai.js","sourcesContent":["import { createRequire } from \"node:module\";\nimport { compress } from \"../compress.js\";\nimport type { CompressOptions, CompressResult } from \"../types.js\";\nimport { vercelToOpenAI, openAIToVercel } from \"../utils/format.js\";\n\n/**\n * Minimal structural type for Vercel AI SDK language models.\n * Compatible with LanguageModelV1, LanguageModelV3, and LanguageModelV4.\n */\ninterface LanguageModel {\n readonly specificationVersion: string;\n readonly provider: string;\n readonly modelId: string;\n doGenerate: (...args: any[]) => any;\n doStream: (...args: any[]) => any;\n [key: string]: any;\n}\n\n/* eslint-disable @typescript-eslint/no-explicit-any */\ntype VercelMessage = any;\n\n/**\n * Vercel AI SDK language model middleware that compresses messages\n * before they reach the LLM.\n *\n * @example\n * ```typescript\n * import { headroomMiddleware } from 'headroom-ai/vercel-ai';\n * import { wrapLanguageModel } from 'ai';\n * import { openai } from '@ai-sdk/openai';\n *\n * const model = wrapLanguageModel({\n * model: openai('gpt-4o'),\n * middleware: headroomMiddleware(),\n * });\n * ```\n */\nexport function headroomMiddleware(options: CompressOptions = {}) {\n return {\n transformParams: async ({\n params,\n }: {\n params: any;\n model: any;\n type: string;\n }) => {\n const prompt: VercelMessage[] = params.prompt;\n if (!prompt || prompt.length === 0) return params;\n\n const model = options.model ?? params.modelId ?? \"gpt-4o\";\n\n // Convert Vercel format → OpenAI format\n const openaiMessages = vercelToOpenAI(prompt);\n\n // Compress via Headroom\n const result = await compress(openaiMessages, {\n stack: \"adapter_ts_vercel_ai\",\n ...options,\n model,\n });\n\n if (!result.compressed) return params;\n\n // Convert back to Vercel format\n const compressedPrompt = openAIToVercel(result.messages);\n\n return { ...params, prompt: compressedPrompt };\n },\n };\n}\n\n/**\n * Standalone: compress Vercel AI SDK ModelMessage[] directly.\n * Returns compressed messages in Vercel format + compression stats.\n */\nexport async function compressVercelMessages(\n messages: VercelMessage[],\n options: CompressOptions = {},\n): Promise {\n const openaiMessages = vercelToOpenAI(messages);\n const result = await compress(openaiMessages, {\n stack: \"adapter_ts_vercel_ai\",\n ...options,\n });\n const vercelMessages = openAIToVercel(result.messages);\n\n return {\n ...result,\n messages: vercelMessages,\n };\n}\n\n/**\n * Wrap a Vercel AI SDK language model with Headroom compression.\n * Convenience wrapper around `wrapLanguageModel` + `headroomMiddleware`.\n *\n * @example\n * ```typescript\n * import { withHeadroom } from 'headroom-ai/vercel-ai';\n * import { openai } from '@ai-sdk/openai';\n * import { generateText } from 'ai';\n *\n * const model = withHeadroom(openai('gpt-4o'));\n * const { text } = await generateText({ model, messages });\n * ```\n */\nexport function withHeadroom(\n model: T,\n options: CompressOptions = {},\n): T {\n let wrapLanguageModel: (opts: {\n model: T;\n middleware: any;\n }) => T;\n\n try {\n const require = createRequire(import.meta.url);\n const ai = require(\"ai\");\n wrapLanguageModel = ai.wrapLanguageModel;\n } catch {\n throw new Error(\n 'withHeadroom() requires the \"ai\" package. Install it with: npm install ai',\n );\n }\n\n return wrapLanguageModel({\n model,\n middleware: headroomMiddleware(options),\n });\n}\n"]} \ No newline at end of file diff --git a/node_modules/headroom-ai/dist/chunk-24W6JPRV.js b/node_modules/headroom-ai/dist/chunk-24W6JPRV.js new file mode 100644 index 00000000..72c753c4 --- /dev/null +++ b/node_modules/headroom-ai/dist/chunk-24W6JPRV.js @@ -0,0 +1,1105 @@ +var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, { + get: (a, b) => (typeof require !== "undefined" ? require : a)[b] +}) : x)(function(x) { + if (typeof require !== "undefined") return require.apply(this, arguments); + throw Error('Dynamic require of "' + x + '" is not supported'); +}); + +// src/errors.ts +var HeadroomError = class extends Error { + details; + constructor(message, details) { + super(message); + this.name = "HeadroomError"; + this.details = details; + } +}; +var HeadroomConnectionError = class extends HeadroomError { + constructor(message, details) { + super(message, details); + this.name = "HeadroomConnectionError"; + } +}; +var HeadroomAuthError = class extends HeadroomError { + constructor(message, details) { + super(message, details); + this.name = "HeadroomAuthError"; + } +}; +var HeadroomCompressError = class extends HeadroomError { + statusCode; + errorType; + constructor(statusCode, errorType, message, details) { + super(message, details); + this.name = "HeadroomCompressError"; + this.statusCode = statusCode; + this.errorType = errorType; + } +}; +var ConfigurationError = class extends HeadroomError { + constructor(message, details) { + super(message, details); + this.name = "ConfigurationError"; + } +}; +var ProviderError = class extends HeadroomError { + constructor(message, details) { + super(message, details); + this.name = "ProviderError"; + } +}; +var StorageError = class extends HeadroomError { + constructor(message, details) { + super(message, details); + this.name = "StorageError"; + } +}; +var TokenizationError = class extends HeadroomError { + constructor(message, details) { + super(message, details); + this.name = "TokenizationError"; + } +}; +var CacheError = class extends HeadroomError { + constructor(message, details) { + super(message, details); + this.name = "CacheError"; + } +}; +var ValidationError = class extends HeadroomError { + constructor(message, details) { + super(message, details); + this.name = "ValidationError"; + } +}; +var TransformError = class extends HeadroomError { + constructor(message, details) { + super(message, details); + this.name = "TransformError"; + } +}; +var ERROR_TYPE_MAP = { + configuration_error: ConfigurationError, + provider_error: ProviderError, + storage_error: StorageError, + tokenization_error: TokenizationError, + cache_error: CacheError, + validation_error: ValidationError, + transform_error: TransformError +}; +function mapProxyError(status, type, message) { + if (status === 401) return new HeadroomAuthError(message); + const ErrorClass = ERROR_TYPE_MAP[type]; + if (ErrorClass) return new ErrorClass(message, { statusCode: status, errorType: type }); + return new HeadroomCompressError(status, type, message); +} + +// src/utils/case.ts +function snakeToCamel(str) { + return str.replace(/_([a-z0-9])/g, (_, c) => c.toUpperCase()); +} +function camelToSnake(str) { + return str.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`); +} +function deepCamelCase(obj) { + if (obj === null || obj === void 0) return obj; + if (Array.isArray(obj)) return obj.map(deepCamelCase); + if (typeof obj === "object" && !(obj instanceof Date)) { + return Object.fromEntries( + Object.entries(obj).map(([k, v]) => [snakeToCamel(k), deepCamelCase(v)]) + ); + } + return obj; +} +function deepSnakeCase(obj) { + if (obj === null || obj === void 0) return obj; + if (Array.isArray(obj)) return obj.map(deepSnakeCase); + if (typeof obj === "object" && !(obj instanceof Date)) { + return Object.fromEntries( + Object.entries(obj).map(([k, v]) => [camelToSnake(k), deepSnakeCase(v)]) + ); + } + return obj; +} + +// src/utils/stream.ts +async function* parseSSE(response) { + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + try { + while (true) { + const { done, value } = await reader.read(); + buffer += done ? decoder.decode() : decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = done ? "" : lines.pop(); + for (const line of lines) { + if (line.startsWith("data: ")) { + const data = line.slice(6).trim(); + if (data === "[DONE]") return; + try { + yield JSON.parse(data); + } catch { + } + } + } + if (done) break; + } + } finally { + reader.releaseLock(); + } +} +async function collectStream(stream) { + const chunks = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + return chunks; +} + +// src/client.ts +var DEFAULT_BASE_URL = "http://localhost:8787"; +var DEFAULT_TIMEOUT = 3e4; +var DEFAULT_RETRIES = 1; +function getEnv(key) { + if (typeof process !== "undefined" && process.env) { + return process.env[key]; + } + return void 0; +} +function makeFallbackResult(messages) { + return { + messages, + tokensBefore: 0, + tokensAfter: 0, + tokensSaved: 0, + compressionRatio: 1, + transformsApplied: [], + ccrHashes: [], + compressed: false + }; +} +var ChatCompletions = class { + constructor(client) { + this.client = client; + } + client; + /** + * Create a chat completion with automatic compression. + * Routes through proxy's POST /v1/chat/completions. + */ + async create(params) { + const { headroomMode, headroomCachePrefixTokens, headroomOutputBufferTokens, headroomKeepTurns, headroomToolProfiles, ...apiParams } = params; + const headers = {}; + if (headroomMode) headers["x-headroom-mode"] = headroomMode; + const providerKey = this.client.providerApiKey ?? getEnv("OPENAI_API_KEY"); + if (providerKey) headers["Authorization"] = `Bearer ${providerKey}`; + const response = await this.client.rawFetch("/v1/chat/completions", { + method: "POST", + headers, + body: apiParams, + stream: params.stream + }); + if (params.stream) { + return parseSSE(response); + } + return response.json(); + } + /** + * Simulate compression without calling the LLM. + */ + async simulate(params) { + const body = { + messages: params.messages, + model: params.model, + config: { default_mode: "simulate", generate_diff_artifact: true } + }; + const result = await this.client.compressRaw(body); + return deepCamelCase(result); + } +}; +var Messages = class { + constructor(client) { + this.client = client; + } + client; + /** + * Create a message with automatic compression. + * Routes through proxy's POST /v1/messages (Anthropic). + */ + async create(params) { + const { headroomMode, headroomCachePrefixTokens, headroomOutputBufferTokens, headroomKeepTurns, headroomToolProfiles, ...apiParams } = params; + const headers = { + "anthropic-version": "2023-06-01" + }; + if (headroomMode) headers["x-headroom-mode"] = headroomMode; + const providerKey = this.client.providerApiKey ?? getEnv("ANTHROPIC_API_KEY"); + if (providerKey) headers["x-api-key"] = providerKey; + if (!apiParams.max_tokens) apiParams.max_tokens = 1024; + const response = await this.client.rawFetch("/v1/messages", { + method: "POST", + headers, + body: apiParams, + stream: params.stream + }); + if (params.stream) { + return parseSSE(response); + } + return response.json(); + } + /** + * Stream a message with automatic compression. + */ + stream(params) { + return this.create({ ...params, stream: true }); + } + /** + * Simulate compression without calling the LLM. + */ + async simulate(params) { + const body = { + messages: params.messages, + model: params.model, + config: { default_mode: "simulate", generate_diff_artifact: true } + }; + const result = await this.client.compressRaw(body); + return deepCamelCase(result); + } +}; +var HeadroomClient = class { + baseUrl; + apiKey; + timeout; + fallback; + retries; + config; + stack; + /** @internal */ + providerApiKey; + /** OpenAI-style chat completions API. */ + chat; + /** Anthropic-style messages API. */ + messages; + constructor(options = {}) { + this.baseUrl = (options.baseUrl ?? getEnv("HEADROOM_BASE_URL") ?? DEFAULT_BASE_URL).replace(/\/+$/, ""); + this.apiKey = options.apiKey ?? getEnv("HEADROOM_API_KEY"); + this.timeout = options.timeout ?? DEFAULT_TIMEOUT; + this.fallback = options.fallback ?? true; + this.retries = options.retries ?? DEFAULT_RETRIES; + this.providerApiKey = options.providerApiKey; + this.config = options.config; + this.stack = options.stack; + this.chat = { completions: new ChatCompletions(this) }; + this.messages = new Messages(this); + } + // ============================================================ + // Core: compress + // ============================================================ + async compress(messages, options = {}) { + const model = options.model ?? "gpt-4o"; + let lastError; + const maxAttempts = 1 + this.retries; + for (let attempt = 0; attempt < maxAttempts; attempt++) { + try { + return await this._doCompress(messages, model, options.tokenBudget, options.config); + } catch (error) { + lastError = error; + if (error instanceof HeadroomAuthError) throw error; + if (error instanceof HeadroomCompressError && error.statusCode < 500) { + throw error; + } + } + } + if (this.fallback) { + return makeFallbackResult(messages); + } + if (lastError instanceof HeadroomConnectionError) throw lastError; + if (lastError instanceof HeadroomCompressError) throw lastError; + throw new HeadroomConnectionError( + `Failed after ${maxAttempts} attempts: ${lastError}` + ); + } + /** + * Raw compress call — sends body directly to /v1/compress. + * Used by simulate() and other advanced features. + * @internal + */ + async compressRaw(body) { + const response = await this._fetch("/v1/compress", { + method: "POST", + body: JSON.stringify(body) + }); + return response.json(); + } + // ============================================================ + // Health & Stats + // ============================================================ + /** Check if the proxy is running and healthy. */ + async health() { + const resp = await this._fetch("/health", { method: "GET" }); + return deepCamelCase(await resp.json()); + } + /** Get comprehensive proxy statistics. */ + async proxyStats() { + const resp = await this._fetch("/stats", { method: "GET" }); + return deepCamelCase(await resp.json()); + } + /** Get Prometheus-format metrics. */ + async prometheusMetrics() { + const resp = await this._fetch("/metrics", { method: "GET" }); + return resp.text(); + } + /** Get historical stats. */ + async statsHistory(query) { + const params = new URLSearchParams(); + if (query?.format) params.set("format", query.format); + if (query?.series) params.set("series", query.series); + const qs = params.toString(); + const resp = await this._fetch(`/stats-history${qs ? `?${qs}` : ""}`, { method: "GET" }); + return resp.json(); + } + /** Get proxy memory usage. */ + async memoryUsage() { + const resp = await this._fetch("/debug/memory", { method: "GET" }); + return deepCamelCase(await resp.json()); + } + /** Clear the response cache. */ + async clearCache() { + const resp = await this._fetch("/cache/clear", { method: "POST" }); + return resp.json(); + } + // ============================================================ + // Metrics & Observability + // ============================================================ + /** Get request metrics from the proxy. */ + async getMetrics(query) { + const resp = await this._fetch("/stats", { method: "GET" }); + const stats = await resp.json(); + let metrics = stats.recent_requests ?? []; + if (query?.model) { + metrics = metrics.filter((m) => m.model === query.model); + } + if (query?.mode) { + metrics = metrics.filter((m) => m.mode === query.mode); + } + if (query?.limit) { + metrics = metrics.slice(0, query.limit); + } + return metrics.map((m) => deepCamelCase(m)); + } + /** Get aggregated metrics summary. */ + async getSummary(query) { + const resp = await this._fetch("/stats", { method: "GET" }); + const stats = await resp.json(); + return deepCamelCase({ + total_requests: stats.requests?.total ?? 0, + total_tokens_before: stats.tokens?.total_before_compression ?? 0, + total_tokens_after: (stats.tokens?.total_before_compression ?? 0) - (stats.tokens?.saved ?? 0), + total_tokens_saved: stats.tokens?.saved ?? 0, + average_compression_ratio: stats.tokens?.savings_percent ? stats.tokens.savings_percent / 100 : 0, + models: stats.requests?.by_model ?? {}, + modes: {}, + error_count: stats.requests?.failed ?? 0 + }); + } + /** Get in-memory session stats. */ + async getStats() { + const resp = await this._fetch("/stats", { method: "GET" }); + const stats = await resp.json(); + return deepCamelCase({ + total_requests: stats.requests?.total ?? 0, + total_tokens_before: stats.tokens?.total_before_compression ?? 0, + total_tokens_after: (stats.tokens?.total_before_compression ?? 0) - (stats.tokens?.saved ?? 0), + total_tokens_saved: stats.tokens?.saved ?? 0, + average_compression_ratio: stats.tokens?.savings_percent ? stats.tokens.savings_percent / 100 : 0, + cache_hits: stats.requests?.cached ?? 0, + by_mode: {} + }); + } + /** Validate proxy configuration. */ + async validateSetup() { + const resp = await this._fetch("/health", { method: "GET" }); + const health = await resp.json(); + return { + valid: health.status === "healthy", + provider: "", + errors: health.status !== "healthy" ? ["Proxy unhealthy"] : [], + warnings: [], + config: health.config ?? {} + }; + } + // ============================================================ + // CCR Retrieve + // ============================================================ + /** Retrieve original content from the CCR compression store. */ + async retrieve(hash, options) { + const body = { hash }; + if (options?.query) body.query = options.query; + const resp = await this._fetch("/v1/retrieve", { + method: "POST", + body: JSON.stringify(body) + }); + return deepCamelCase(await resp.json()); + } + /** Get CCR store statistics. */ + async getCCRStats() { + const resp = await this._fetch("/v1/retrieve/stats", { method: "GET" }); + return deepCamelCase(await resp.json()); + } + /** Handle an LLM tool call for headroom_retrieve. */ + async handleToolCall(request) { + const resp = await this._fetch("/v1/retrieve/tool_call", { + method: "POST", + body: JSON.stringify(deepSnakeCase(request)) + }); + return deepCamelCase(await resp.json()); + } + // ============================================================ + // Telemetry & Feedback + // ============================================================ + telemetry = { + getStats: async () => { + const resp = await this._fetch("/v1/telemetry", { method: "GET" }); + return deepCamelCase(await resp.json()); + }, + export: async () => { + const resp = await this._fetch("/v1/telemetry/export", { method: "GET" }); + return resp.json(); + }, + import: async (data) => { + const resp = await this._fetch("/v1/telemetry/import", { + method: "POST", + body: JSON.stringify(data) + }); + return resp.json(); + }, + getTools: async () => { + const resp = await this._fetch("/v1/telemetry/tools", { method: "GET" }); + return deepCamelCase(await resp.json()); + }, + getTool: async (signatureHash) => { + const resp = await this._fetch(`/v1/telemetry/tools/${signatureHash}`, { method: "GET" }); + return deepCamelCase(await resp.json()); + } + }; + feedback = { + getStats: async () => { + const resp = await this._fetch("/v1/feedback", { method: "GET" }); + return deepCamelCase(await resp.json()); + }, + getHints: async (toolName) => { + const resp = await this._fetch(`/v1/feedback/${encodeURIComponent(toolName)}`, { method: "GET" }); + return deepCamelCase(await resp.json()); + } + }; + toin = { + getStats: async () => { + const resp = await this._fetch("/v1/toin/stats", { method: "GET" }); + return deepCamelCase(await resp.json()); + }, + getPatterns: async (limit) => { + const qs = limit ? `?limit=${limit}` : ""; + const resp = await this._fetch(`/v1/toin/patterns${qs}`, { method: "GET" }); + return deepCamelCase(await resp.json()); + }, + getPattern: async (hashPrefix) => { + const resp = await this._fetch(`/v1/toin/pattern/${encodeURIComponent(hashPrefix)}`, { method: "GET" }); + return deepCamelCase(await resp.json()); + } + }; + // ============================================================ + // Lifecycle + // ============================================================ + /** Close the client (no-op for HTTP client, included for API parity). */ + close() { + } + // ============================================================ + // Internal HTTP helpers + // ============================================================ + /** + * Raw fetch with proxy base URL, auth, and timeout. + * A string body is sent as-is; anything else is JSON-serialized. + * @internal + */ + async rawFetch(path, options) { + const url = `${this.baseUrl}${path}`; + const headers = { + "Content-Type": "application/json", + ...options.headers + }; + if (this.apiKey) { + headers["X-Headroom-Proxy-Token"] = this.apiKey; + if (!headers["Authorization"] && !headers["x-api-key"]) { + headers["Authorization"] = `Bearer ${this.apiKey}`; + } + } + if (this.stack && !headers["X-Headroom-Stack"]) { + headers["X-Headroom-Stack"] = this.stack; + } + let response; + try { + response = await fetch(url, { + method: options.method, + headers, + body: typeof options.body === "string" ? options.body : options.body ? JSON.stringify(options.body) : void 0, + signal: AbortSignal.timeout(this.timeout) + }); + } catch (error) { + throw new HeadroomConnectionError( + `Failed to connect to Headroom at ${this.baseUrl}: ${error}` + ); + } + if (!response.ok) { + let errorBody; + try { + errorBody = await response.json(); + } catch { + } + throw mapProxyError( + response.status, + errorBody?.error?.type ?? "unknown", + errorBody?.error?.message ?? `HTTP ${response.status}` + ); + } + return response; + } + /** Proxy-only endpoints, whose bodies are already serialized. @internal */ + _fetch(path, init) { + return this.rawFetch(path, init); + } + async _doCompress(messages, model, tokenBudget, config) { + const body = { messages, model }; + if (tokenBudget) { + body.token_budget = tokenBudget; + } + const mergedConfig = { ...this.config, ...config }; + if (Object.keys(mergedConfig).length > 0) { + body.config = deepSnakeCase(mergedConfig); + } + const response = await this._fetch("/v1/compress", { + method: "POST", + body: JSON.stringify(body) + }); + const data = await response.json(); + return { + messages: data.messages, + tokensBefore: data.tokens_before, + tokensAfter: data.tokens_after, + tokensSaved: data.tokens_saved, + compressionRatio: data.compression_ratio, + transformsApplied: data.transforms_applied, + ccrHashes: data.ccr_hashes, + compressed: true + }; + } +}; + +// src/utils/format.ts +function detectFormat(messages) { + for (const msg of messages) { + if ("parts" in msg && !("content" in msg)) return "gemini"; + if (msg.role === "model") return "gemini"; + if (msg.tool_calls && msg.role === "assistant") return "openai"; + if (msg.role === "tool" && "tool_call_id" in msg && typeof msg.content === "string") return "openai"; + if (Array.isArray(msg.content)) { + for (const part of msg.content) { + if (part.type === "tool-call" || part.type === "tool-result") return "vercel"; + if (part.type === "tool_use" || part.type === "tool_result") return "anthropic"; + if (part.type === "image" && part.source?.type) return "anthropic"; + } + } + } + return "openai"; +} +function anthropicImageUrl(source) { + if (!source || typeof source !== "object") return null; + if (source.type === "base64" && source.media_type && source.data) { + return `data:${source.media_type};base64,${source.data}`; + } + if (source.type === "url" && typeof source.url === "string") return source.url; + return null; +} +function openAIImageBlock(url) { + const m = /^data:([^;,]+);base64,(.*)$/s.exec(url); + if (m) return { type: "image", source: { type: "base64", media_type: m[1], data: m[2] } }; + return { type: "image", source: { type: "url", url } }; +} +function anthropicToOpenAI(messages) { + const result = []; + for (const msg of messages) { + if (msg.role === "user") { + if (typeof msg.content === "string") { + result.push({ role: "user", content: msg.content }); + continue; + } + if (Array.isArray(msg.content)) { + const toolResults = msg.content.filter((b) => b.type === "tool_result"); + const hasImages = msg.content.some((b) => b.type === "image"); + if (hasImages) { + const parts = msg.content.filter((b) => b.type === "text" || b.type === "image").map((b) => { + if (b.type === "text") return { type: "text", text: b.text }; + const url = anthropicImageUrl(b.source); + return url ? { type: "image_url", image_url: { url } } : null; + }).filter((p) => p !== null); + if (parts.length > 0) { + result.push({ role: "user", content: parts }); + } + } else { + const textBlocks = msg.content.filter((b) => b.type === "text"); + if (textBlocks.length > 0) { + result.push({ + role: "user", + content: textBlocks.map((b) => b.text).join("\n") + }); + } + } + for (const tr of toolResults) { + const content = typeof tr.content === "string" ? tr.content : Array.isArray(tr.content) ? tr.content.map((b) => b.text ?? JSON.stringify(b)).join("\n") : JSON.stringify(tr.content); + result.push({ + role: "tool", + content, + tool_call_id: tr.tool_use_id + }); + } + } + continue; + } + if (msg.role === "assistant") { + if (typeof msg.content === "string") { + result.push({ role: "assistant", content: msg.content }); + continue; + } + if (Array.isArray(msg.content)) { + const textBlocks = msg.content.filter((b) => b.type === "text"); + const toolUseBlocks = msg.content.filter((b) => b.type === "tool_use"); + const content = textBlocks.length > 0 ? textBlocks.map((b) => b.text).join("\n") : null; + const openaiMsg = { role: "assistant", content }; + if (toolUseBlocks.length > 0) { + openaiMsg.tool_calls = toolUseBlocks.map((b) => ({ + id: b.id, + type: "function", + function: { + name: b.name, + arguments: typeof b.input === "string" ? b.input : JSON.stringify(b.input) + } + })); + } + result.push(openaiMsg); + } + continue; + } + } + return result; +} +function openAIToAnthropic(messages) { + const result = []; + for (const msg of messages) { + if (msg.role === "system") { + result.push({ role: "user", content: msg.content }); + continue; + } + if (msg.role === "user") { + if (typeof msg.content === "string") { + result.push({ role: "user", content: msg.content }); + } else if (Array.isArray(msg.content)) { + result.push({ + role: "user", + content: msg.content.map((p) => { + if (p.type === "text") return { type: "text", text: p.text }; + if (p.type === "image_url") return openAIImageBlock(p.image_url.url); + return { type: "text", text: "" }; + }) + }); + } + continue; + } + if (msg.role === "assistant") { + const blocks = []; + if (msg.content) blocks.push({ type: "text", text: msg.content }); + if (msg.tool_calls) { + for (const tc of msg.tool_calls) { + blocks.push({ + type: "tool_use", + id: tc.id, + name: tc.function.name, + input: JSON.parse(tc.function.arguments) + }); + } + } + result.push({ + role: "assistant", + content: blocks.length === 1 && blocks[0].type === "text" ? blocks[0].text : blocks + }); + continue; + } + if (msg.role === "tool") { + result.push({ + role: "user", + content: [ + { type: "tool_result", tool_use_id: msg.tool_call_id, content: msg.content } + ] + }); + continue; + } + } + return result; +} +function vercelToOpenAI(messages) { + const result = []; + for (const msg of messages) { + if (msg.role === "system") { + result.push({ role: "system", content: typeof msg.content === "string" ? msg.content : String(msg.content) }); + continue; + } + if (msg.role === "user") { + if (typeof msg.content === "string") { + result.push({ role: "user", content: msg.content }); + continue; + } + const parts = Array.isArray(msg.content) ? msg.content : []; + const textParts = parts.filter((p) => p.type === "text"); + const imageParts = parts.filter((p) => p.type === "image"); + if (imageParts.length === 0 && textParts.length > 0) { + result.push({ role: "user", content: textParts.map((p) => p.text).join("") }); + } else { + const openaiParts = parts.filter((p) => p.type === "text" || p.type === "image").map((p) => { + if (p.type === "text") return { type: "text", text: p.text }; + if (p.type === "image") { + const url = p.image instanceof URL ? p.image.toString() : String(p.image); + return { type: "image_url", image_url: { url } }; + } + return { type: "text", text: "" }; + }); + result.push({ role: "user", content: openaiParts }); + } + continue; + } + if (msg.role === "assistant") { + if (typeof msg.content === "string") { + result.push({ role: "assistant", content: msg.content }); + continue; + } + const parts = Array.isArray(msg.content) ? msg.content : []; + const textParts = parts.filter((p) => p.type === "text"); + const toolCallParts = parts.filter((p) => p.type === "tool-call"); + const content = textParts.length > 0 ? textParts.map((p) => p.text).join("") : null; + const openaiMsg = { role: "assistant", content }; + if (toolCallParts.length > 0) { + openaiMsg.tool_calls = toolCallParts.map((p) => ({ + id: p.toolCallId, + type: "function", + // AI SDK v6 uses `input`, earlier versions used `args` + function: { name: p.toolName, arguments: JSON.stringify(p.input ?? p.args) } + })); + } + result.push(openaiMsg); + continue; + } + if (msg.role === "tool") { + const parts = Array.isArray(msg.content) ? msg.content : []; + for (const part of parts) { + if (part.type === "tool-result") { + let contentStr; + if (part.output !== void 0) { + const val = part.output?.value ?? part.output; + contentStr = typeof val === "string" ? val : JSON.stringify(val); + } else if (part.result !== void 0) { + contentStr = typeof part.result === "string" ? part.result : JSON.stringify(part.result); + } else { + contentStr = ""; + } + result.push({ + role: "tool", + content: contentStr, + tool_call_id: part.toolCallId + }); + } + } + continue; + } + } + return result; +} +function openAIToVercel(messages) { + const result = []; + for (const msg of messages) { + if (msg.role === "system") { + result.push({ role: "system", content: msg.content }); + continue; + } + if (msg.role === "user") { + if (typeof msg.content === "string") { + result.push({ role: "user", content: [{ type: "text", text: msg.content }] }); + } else if (Array.isArray(msg.content)) { + const parts = msg.content.map((p) => { + if (p.type === "text") return { type: "text", text: p.text }; + if (p.type === "image_url") return { type: "image", image: new URL(p.image_url.url) }; + return { type: "text", text: "" }; + }); + result.push({ role: "user", content: parts }); + } + continue; + } + if (msg.role === "assistant") { + const parts = []; + if (msg.content) parts.push({ type: "text", text: msg.content }); + if (msg.tool_calls) { + for (const tc of msg.tool_calls) { + let input; + try { + input = JSON.parse(tc.function.arguments); + } catch { + input = tc.function.arguments ?? {}; + } + parts.push({ + type: "tool-call", + toolCallId: tc.id, + toolName: tc.function.name, + input + // AI SDK v6 uses `input`, not `args` + }); + } + } + result.push({ role: "assistant", content: parts }); + continue; + } + if (msg.role === "tool") { + let parsed; + try { + parsed = JSON.parse(msg.content); + } catch { + parsed = msg.content; + } + const output = typeof parsed === "string" ? { type: "text", value: parsed } : { type: "json", value: parsed }; + result.push({ + role: "tool", + content: [{ + type: "tool-result", + toolCallId: msg.tool_call_id, + toolName: "unknown", + output + }] + }); + continue; + } + } + return result; +} +function geminiToOpenAI(messages) { + const result = []; + for (const msg of messages) { + const role = msg.role === "model" ? "assistant" : "user"; + const parts = msg.parts ?? []; + if (role === "user") { + const funcResponses = parts.filter((p) => p.functionResponse); + const textParts = parts.filter((p) => p.text !== void 0); + if (textParts.length > 0) { + result.push({ role: "user", content: textParts.map((p) => p.text).join("\n") }); + } + for (const fr of funcResponses) { + result.push({ + role: "tool", + content: JSON.stringify(fr.functionResponse.response), + tool_call_id: `gemini_${fr.functionResponse.name}` + }); + } + continue; + } + if (role === "assistant") { + const textParts = parts.filter((p) => p.text !== void 0); + const funcCalls = parts.filter((p) => p.functionCall); + const content = textParts.length > 0 ? textParts.map((p) => p.text).join("\n") : null; + const openaiMsg = { role: "assistant", content }; + if (funcCalls.length > 0) { + openaiMsg.tool_calls = funcCalls.map((p) => ({ + id: `gemini_${p.functionCall.name}`, + type: "function", + function: { + name: p.functionCall.name, + arguments: JSON.stringify(p.functionCall.args) + } + })); + } + result.push(openaiMsg); + continue; + } + } + return result; +} +function openAIToGemini(messages) { + const result = []; + for (const msg of messages) { + if (msg.role === "system") { + result.push({ role: "user", parts: [{ text: msg.content }] }); + continue; + } + if (msg.role === "user") { + const text = typeof msg.content === "string" ? msg.content : (msg.content ?? []).filter((p) => p.type === "text").map((p) => p.text).join("\n"); + result.push({ role: "user", parts: [{ text }] }); + continue; + } + if (msg.role === "assistant") { + const parts = []; + if (msg.content) parts.push({ text: msg.content }); + if (msg.tool_calls) { + for (const tc of msg.tool_calls) { + parts.push({ + functionCall: { name: tc.function.name, args: JSON.parse(tc.function.arguments) } + }); + } + } + result.push({ role: "model", parts }); + continue; + } + if (msg.role === "tool") { + let response; + try { + response = JSON.parse(msg.content); + } catch { + response = { result: msg.content }; + } + result.push({ + role: "user", + parts: [{ functionResponse: { name: msg.tool_call_id?.replace("gemini_", "") ?? "unknown", response } }] + }); + continue; + } + } + return result; +} +function toOpenAI(messages) { + const format = detectFormat(messages); + switch (format) { + case "openai": + return messages; + case "anthropic": + return anthropicToOpenAI(messages); + case "vercel": + return vercelToOpenAI(messages); + case "gemini": + return geminiToOpenAI(messages); + } +} +function fromOpenAI(messages, targetFormat) { + switch (targetFormat) { + case "openai": + return messages; + case "anthropic": + return openAIToAnthropic(messages); + case "vercel": + return openAIToVercel(messages); + case "gemini": + return openAIToGemini(messages); + } +} + +// src/hooks.ts +var CompressionHooks = class { + /** + * Called before compression. Modify messages before they're sent to the proxy. + */ + preCompress(messages, _ctx) { + return messages; + } + /** + * Compute per-message compression biases. + * Return a map of message index -> bias (>1 = preserve more, <1 = compress more). + */ + computeBiases(_messages, _ctx) { + return {}; + } + /** + * Called after compression. Observe-only — cannot modify the result. + */ + postCompress(_event) { + } +}; +function extractUserQuery(messages) { + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg.role === "user") { + if (typeof msg.content === "string") return msg.content; + if (Array.isArray(msg.content)) { + const textPart = msg.content.find( + (p) => p.type === "text" || p.text + ); + if (textPart) return textPart.text ?? textPart.content ?? ""; + } + } + } + return ""; +} +function countTurns(messages) { + return messages.filter((m) => m.role === "user").length; +} +function extractToolCalls(messages) { + const names = []; + for (const msg of messages) { + if (msg.tool_calls) { + for (const tc of msg.tool_calls) { + names.push(tc.function?.name ?? tc.name ?? "unknown"); + } + } + if (Array.isArray(msg.content)) { + for (const part of msg.content) { + if (part.type === "tool_use") names.push(part.name ?? "unknown"); + if (part.type === "tool-call") names.push(part.toolName ?? "unknown"); + } + } + } + return names; +} + +// src/compress.ts +async function compress(messages, options = {}) { + const { + client: providedClient, + model, + tokenBudget, + hooks, + config, + ...clientOptions + } = options; + const ctx = { + model: model ?? "gpt-4o", + userQuery: extractUserQuery(messages), + turnNumber: countTurns(messages), + toolCalls: extractToolCalls(messages), + provider: "" + }; + let processedMessages = messages; + if (hooks) { + processedMessages = await hooks.preCompress(messages, ctx); + } + const inputFormat = detectFormat(processedMessages); + const openaiMessages = toOpenAI(processedMessages); + if (hooks) { + await hooks.computeBiases(openaiMessages, ctx); + } + const client = providedClient ?? new HeadroomClient(clientOptions); + const result = await client.compress(openaiMessages, { model, tokenBudget, config }); + const outputMessages = fromOpenAI(result.messages, inputFormat); + const finalResult = { + ...result, + messages: outputMessages + }; + if (hooks) { + const event = { + tokensBefore: result.tokensBefore, + tokensAfter: result.tokensAfter, + tokensSaved: result.tokensSaved, + compressionRatio: result.compressionRatio, + transformsApplied: result.transformsApplied, + ccrHashes: result.ccrHashes, + model: ctx.model, + userQuery: ctx.userQuery, + provider: ctx.provider + }; + await hooks.postCompress(event); + } + return finalResult; +} + +export { CacheError, CompressionHooks, ConfigurationError, HeadroomAuthError, HeadroomClient, HeadroomCompressError, HeadroomConnectionError, HeadroomError, ProviderError, StorageError, TokenizationError, TransformError, ValidationError, __require, camelToSnake, collectStream, compress, countTurns, deepCamelCase, deepSnakeCase, detectFormat, extractToolCalls, extractUserQuery, fromOpenAI, mapProxyError, openAIToVercel, parseSSE, snakeToCamel, toOpenAI, vercelToOpenAI }; +//# sourceMappingURL=chunk-24W6JPRV.js.map +//# sourceMappingURL=chunk-24W6JPRV.js.map \ No newline at end of file diff --git a/node_modules/headroom-ai/dist/chunk-24W6JPRV.js.map b/node_modules/headroom-ai/dist/chunk-24W6JPRV.js.map new file mode 100644 index 00000000..346f71cf --- /dev/null +++ b/node_modules/headroom-ai/dist/chunk-24W6JPRV.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../src/errors.ts","../src/utils/case.ts","../src/utils/stream.ts","../src/client.ts","../src/utils/format.ts","../src/hooks.ts","../src/compress.ts"],"names":[],"mappings":"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","file":"chunk-24W6JPRV.js","sourcesContent":["/**\n * Error hierarchy matching Python headroom.exceptions.\n */\n\nexport class HeadroomError extends Error {\n details?: Record;\n\n constructor(message: string, details?: Record) {\n super(message);\n this.name = \"HeadroomError\";\n this.details = details;\n }\n}\n\nexport class HeadroomConnectionError extends HeadroomError {\n constructor(message: string, details?: Record) {\n super(message, details);\n this.name = \"HeadroomConnectionError\";\n }\n}\n\nexport class HeadroomAuthError extends HeadroomError {\n constructor(message: string, details?: Record) {\n super(message, details);\n this.name = \"HeadroomAuthError\";\n }\n}\n\nexport class HeadroomCompressError extends HeadroomError {\n statusCode: number;\n errorType: string;\n\n constructor(statusCode: number, errorType: string, message: string, details?: Record) {\n super(message, details);\n this.name = \"HeadroomCompressError\";\n this.statusCode = statusCode;\n this.errorType = errorType;\n }\n}\n\nexport class ConfigurationError extends HeadroomError {\n constructor(message: string, details?: Record) {\n super(message, details);\n this.name = \"ConfigurationError\";\n }\n}\n\nexport class ProviderError extends HeadroomError {\n constructor(message: string, details?: Record) {\n super(message, details);\n this.name = \"ProviderError\";\n }\n}\n\nexport class StorageError extends HeadroomError {\n constructor(message: string, details?: Record) {\n super(message, details);\n this.name = \"StorageError\";\n }\n}\n\nexport class TokenizationError extends HeadroomError {\n constructor(message: string, details?: Record) {\n super(message, details);\n this.name = \"TokenizationError\";\n }\n}\n\nexport class CacheError extends HeadroomError {\n constructor(message: string, details?: Record) {\n super(message, details);\n this.name = \"CacheError\";\n }\n}\n\nexport class ValidationError extends HeadroomError {\n constructor(message: string, details?: Record) {\n super(message, details);\n this.name = \"ValidationError\";\n }\n}\n\nexport class TransformError extends HeadroomError {\n constructor(message: string, details?: Record) {\n super(message, details);\n this.name = \"TransformError\";\n }\n}\n\n// --- Proxy error mapping ---\n\nconst ERROR_TYPE_MAP: Record) => HeadroomError> = {\n configuration_error: ConfigurationError,\n provider_error: ProviderError,\n storage_error: StorageError,\n tokenization_error: TokenizationError,\n cache_error: CacheError,\n validation_error: ValidationError,\n transform_error: TransformError,\n};\n\n/**\n * Map a proxy error response to the correct HeadroomError subclass.\n */\nexport function mapProxyError(\n status: number,\n type: string,\n message: string,\n): HeadroomError {\n if (status === 401) return new HeadroomAuthError(message);\n const ErrorClass = ERROR_TYPE_MAP[type];\n if (ErrorClass) return new ErrorClass(message, { statusCode: status, errorType: type });\n return new HeadroomCompressError(status, type, message);\n}\n","/**\n * Case conversion utilities for proxy communication.\n * Proxy uses snake_case (Python), SDK uses camelCase (TypeScript).\n */\n\nexport function snakeToCamel(str: string): string {\n return str.replace(/_([a-z0-9])/g, (_, c) => c.toUpperCase());\n}\n\nexport function camelToSnake(str: string): string {\n return str.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);\n}\n\nexport function deepCamelCase(obj: any): T {\n if (obj === null || obj === undefined) return obj as T;\n if (Array.isArray(obj)) return obj.map(deepCamelCase) as T;\n if (typeof obj === \"object\" && !(obj instanceof Date)) {\n return Object.fromEntries(\n Object.entries(obj).map(([k, v]) => [snakeToCamel(k), deepCamelCase(v)]),\n ) as T;\n }\n return obj as T;\n}\n\nexport function deepSnakeCase(obj: any): any {\n if (obj === null || obj === undefined) return obj;\n if (Array.isArray(obj)) return obj.map(deepSnakeCase);\n if (typeof obj === \"object\" && !(obj instanceof Date)) {\n return Object.fromEntries(\n Object.entries(obj).map(([k, v]) => [camelToSnake(k), deepSnakeCase(v)]),\n );\n }\n return obj;\n}\n","/**\n * SSE (Server-Sent Events) stream parser for proxy streaming responses.\n */\n\n/**\n * Parse an SSE response body into an async generator of parsed JSON events.\n */\nexport async function* parseSSE(\n response: Response,\n): AsyncGenerator {\n const reader = response.body!.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n\n try {\n while (true) {\n const { done, value } = await reader.read();\n // On the final read, flush any buffered multi-byte remainder and process\n // every remaining line — including a last `data:` line that arrived\n // without a trailing newline, which would otherwise be held back in\n // `buffer` and dropped when the loop exits.\n buffer += done ? decoder.decode() : decoder.decode(value, { stream: true });\n const lines = buffer.split(\"\\n\");\n buffer = done ? \"\" : lines.pop()!;\n\n for (const line of lines) {\n if (line.startsWith(\"data: \")) {\n const data = line.slice(6).trim();\n if (data === \"[DONE]\") return;\n try {\n yield JSON.parse(data) as T;\n } catch {\n // skip non-JSON data lines (e.g. Anthropic event metadata)\n }\n }\n }\n\n if (done) break;\n }\n } finally {\n reader.releaseLock();\n }\n}\n\n/**\n * Collect all chunks from an async iterable into an array.\n */\nexport async function collectStream(stream: AsyncIterable): Promise {\n const chunks: T[] = [];\n for await (const chunk of stream) {\n chunks.push(chunk);\n }\n return chunks;\n}\n","/**\n * HeadroomClient — HTTP client for the Headroom compression proxy.\n *\n * Supports:\n * - compress() — direct compression via /v1/compress\n * - chat.completions.create() — OpenAI-style passthrough via /v1/chat/completions\n * - messages.create() — Anthropic-style passthrough via /v1/messages\n * - Metrics, health, CCR retrieve, telemetry, feedback, TOIN\n */\n\nimport type {\n OpenAIMessage,\n CompressResult,\n HeadroomClientOptions,\n HeadroomClientInterface,\n ProxyCompressResponse,\n ProxyErrorResponse,\n} from \"./types.js\";\nimport { mapProxyError, HeadroomConnectionError, HeadroomAuthError, HeadroomCompressError } from \"./errors.js\";\nimport { deepCamelCase, deepSnakeCase } from \"./utils/case.js\";\nimport { parseSSE } from \"./utils/stream.js\";\nimport type { CompressRequestConfig, HeadroomMode } from \"./types/config.js\";\nimport type {\n SimulationResult,\n RequestMetrics,\n MetricsSummary,\n SessionStats,\n ValidationResult,\n HealthStatus,\n ProxyStats,\n MemoryUsage,\n RetrieveResult,\n RetrieveSearchResult,\n CCRStats,\n TelemetryStats,\n ToolHints,\n TOINStats,\n TOINPattern,\n MetricsQuery,\n SummaryQuery,\n StatsHistoryQuery,\n} from \"./types/models.js\";\n\nconst DEFAULT_BASE_URL = \"http://localhost:8787\";\nconst DEFAULT_TIMEOUT = 30_000;\nconst DEFAULT_RETRIES = 1;\n\nfunction getEnv(key: string): string | undefined {\n if (typeof process !== \"undefined\" && process.env) {\n return process.env[key];\n }\n return undefined;\n}\n\nfunction makeFallbackResult(messages: OpenAIMessage[]): CompressResult {\n return {\n messages,\n tokensBefore: 0,\n tokensAfter: 0,\n tokensSaved: 0,\n compressionRatio: 1.0,\n transformsApplied: [],\n ccrHashes: [],\n compressed: false,\n };\n}\n\n// --- Headroom request params ---\n\nexport interface HeadroomParams {\n headroomMode?: HeadroomMode;\n headroomCachePrefixTokens?: number;\n headroomOutputBufferTokens?: number;\n headroomKeepTurns?: number;\n headroomToolProfiles?: Record>;\n}\n\n// --- Sub-clients ---\n\nclass ChatCompletions {\n constructor(private client: HeadroomClient) {}\n\n /**\n * Create a chat completion with automatic compression.\n * Routes through proxy's POST /v1/chat/completions.\n */\n async create(\n params: { model: string; messages: OpenAIMessage[]; stream?: boolean; [key: string]: any } & HeadroomParams,\n ): Promise {\n const { headroomMode, headroomCachePrefixTokens, headroomOutputBufferTokens, headroomKeepTurns, headroomToolProfiles, ...apiParams } = params;\n\n const headers: Record = {};\n if (headroomMode) headers[\"x-headroom-mode\"] = headroomMode;\n\n const providerKey = this.client.providerApiKey ?? getEnv(\"OPENAI_API_KEY\");\n if (providerKey) headers[\"Authorization\"] = `Bearer ${providerKey}`;\n\n const response = await this.client.rawFetch(\"/v1/chat/completions\", {\n method: \"POST\",\n headers,\n body: apiParams,\n stream: params.stream,\n });\n\n if (params.stream) {\n return parseSSE(response);\n }\n\n return response.json();\n }\n\n /**\n * Simulate compression without calling the LLM.\n */\n async simulate(\n params: { model: string; messages: OpenAIMessage[] } & HeadroomParams,\n ): Promise {\n const body = {\n messages: params.messages,\n model: params.model,\n config: { default_mode: \"simulate\", generate_diff_artifact: true },\n };\n const result = await this.client.compressRaw(body);\n return deepCamelCase(result);\n }\n}\n\nclass Messages {\n constructor(private client: HeadroomClient) {}\n\n /**\n * Create a message with automatic compression.\n * Routes through proxy's POST /v1/messages (Anthropic).\n */\n async create(\n params: { model: string; messages: any[]; max_tokens?: number; system?: string | any[]; stream?: boolean; [key: string]: any } & HeadroomParams,\n ): Promise {\n const { headroomMode, headroomCachePrefixTokens, headroomOutputBufferTokens, headroomKeepTurns, headroomToolProfiles, ...apiParams } = params;\n\n const headers: Record = {\n \"anthropic-version\": \"2023-06-01\",\n };\n if (headroomMode) headers[\"x-headroom-mode\"] = headroomMode;\n\n const providerKey = this.client.providerApiKey ?? getEnv(\"ANTHROPIC_API_KEY\");\n if (providerKey) headers[\"x-api-key\"] = providerKey;\n\n if (!apiParams.max_tokens) apiParams.max_tokens = 1024;\n\n const response = await this.client.rawFetch(\"/v1/messages\", {\n method: \"POST\",\n headers,\n body: apiParams,\n stream: params.stream,\n });\n\n if (params.stream) {\n return parseSSE(response);\n }\n\n return response.json();\n }\n\n /**\n * Stream a message with automatic compression.\n */\n stream(\n params: { model: string; messages: any[]; max_tokens?: number; system?: string | any[]; [key: string]: any } & HeadroomParams,\n ): Promise> {\n return this.create({ ...params, stream: true }) as Promise>;\n }\n\n /**\n * Simulate compression without calling the LLM.\n */\n async simulate(\n params: { model: string; messages: any[] } & HeadroomParams,\n ): Promise {\n const body = {\n messages: params.messages,\n model: params.model,\n config: { default_mode: \"simulate\", generate_diff_artifact: true },\n };\n const result = await this.client.compressRaw(body);\n return deepCamelCase(result);\n }\n}\n\n// --- Main client ---\n\nexport interface ExtendedClientOptions extends HeadroomClientOptions {\n providerApiKey?: string;\n defaultMode?: HeadroomMode;\n config?: CompressRequestConfig;\n}\n\nexport class HeadroomClient implements HeadroomClientInterface {\n private baseUrl: string;\n private apiKey: string | undefined;\n private timeout: number;\n private fallback: boolean;\n private retries: number;\n private config: CompressRequestConfig | undefined;\n private stack: string | undefined;\n\n /** @internal */ providerApiKey: string | undefined;\n\n /** OpenAI-style chat completions API. */\n readonly chat: { completions: ChatCompletions };\n /** Anthropic-style messages API. */\n readonly messages: Messages;\n\n constructor(options: ExtendedClientOptions = {}) {\n this.baseUrl = (\n options.baseUrl ??\n getEnv(\"HEADROOM_BASE_URL\") ??\n DEFAULT_BASE_URL\n ).replace(/\\/+$/, \"\");\n this.apiKey = options.apiKey ?? getEnv(\"HEADROOM_API_KEY\");\n this.timeout = options.timeout ?? DEFAULT_TIMEOUT;\n this.fallback = options.fallback ?? true;\n this.retries = options.retries ?? DEFAULT_RETRIES;\n this.providerApiKey = options.providerApiKey;\n this.config = options.config;\n this.stack = options.stack;\n\n this.chat = { completions: new ChatCompletions(this) };\n this.messages = new Messages(this);\n }\n\n // ============================================================\n // Core: compress\n // ============================================================\n\n async compress(\n messages: OpenAIMessage[],\n options: { model?: string; tokenBudget?: number; config?: CompressRequestConfig } = {},\n ): Promise {\n const model = options.model ?? \"gpt-4o\";\n\n let lastError: unknown;\n const maxAttempts = 1 + this.retries;\n\n for (let attempt = 0; attempt < maxAttempts; attempt++) {\n try {\n return await this._doCompress(messages, model, options.tokenBudget, options.config);\n } catch (error) {\n lastError = error;\n if (error instanceof HeadroomAuthError) throw error;\n if (\n error instanceof HeadroomCompressError &&\n error.statusCode < 500\n ) {\n throw error;\n }\n }\n }\n\n if (this.fallback) {\n return makeFallbackResult(messages);\n }\n if (lastError instanceof HeadroomConnectionError) throw lastError;\n if (lastError instanceof HeadroomCompressError) throw lastError;\n throw new HeadroomConnectionError(\n `Failed after ${maxAttempts} attempts: ${lastError}`,\n );\n }\n\n /**\n * Raw compress call — sends body directly to /v1/compress.\n * Used by simulate() and other advanced features.\n * @internal\n */\n async compressRaw(body: Record): Promise {\n const response = await this._fetch(\"/v1/compress\", {\n method: \"POST\",\n body: JSON.stringify(body),\n });\n return response.json();\n }\n\n // ============================================================\n // Health & Stats\n // ============================================================\n\n /** Check if the proxy is running and healthy. */\n async health(): Promise {\n const resp = await this._fetch(\"/health\", { method: \"GET\" });\n return deepCamelCase(await resp.json());\n }\n\n /** Get comprehensive proxy statistics. */\n async proxyStats(): Promise {\n const resp = await this._fetch(\"/stats\", { method: \"GET\" });\n return deepCamelCase(await resp.json());\n }\n\n /** Get Prometheus-format metrics. */\n async prometheusMetrics(): Promise {\n const resp = await this._fetch(\"/metrics\", { method: \"GET\" });\n return resp.text();\n }\n\n /** Get historical stats. */\n async statsHistory(query?: StatsHistoryQuery): Promise {\n const params = new URLSearchParams();\n if (query?.format) params.set(\"format\", query.format);\n if (query?.series) params.set(\"series\", query.series);\n const qs = params.toString();\n const resp = await this._fetch(`/stats-history${qs ? `?${qs}` : \"\"}`, { method: \"GET\" });\n return resp.json();\n }\n\n /** Get proxy memory usage. */\n async memoryUsage(): Promise {\n const resp = await this._fetch(\"/debug/memory\", { method: \"GET\" });\n return deepCamelCase(await resp.json());\n }\n\n /** Clear the response cache. */\n async clearCache(): Promise<{ status: string }> {\n const resp = await this._fetch(\"/cache/clear\", { method: \"POST\" });\n return resp.json() as Promise<{ status: string }>;\n }\n\n // ============================================================\n // Metrics & Observability\n // ============================================================\n\n /** Get request metrics from the proxy. */\n async getMetrics(query?: MetricsQuery): Promise {\n const resp = await this._fetch(\"/stats\", { method: \"GET\" });\n const stats = (await resp.json()) as any;\n let metrics: any[] = stats.recent_requests ?? [];\n\n if (query?.model) {\n metrics = metrics.filter((m: any) => m.model === query.model);\n }\n if (query?.mode) {\n metrics = metrics.filter((m: any) => m.mode === query.mode);\n }\n if (query?.limit) {\n metrics = metrics.slice(0, query.limit);\n }\n\n return metrics.map((m: any) => deepCamelCase(m));\n }\n\n /** Get aggregated metrics summary. */\n async getSummary(query?: SummaryQuery): Promise {\n const resp = await this._fetch(\"/stats\", { method: \"GET\" });\n const stats = (await resp.json()) as any;\n return deepCamelCase({\n total_requests: stats.requests?.total ?? 0,\n total_tokens_before: stats.tokens?.total_before_compression ?? 0,\n total_tokens_after: (stats.tokens?.total_before_compression ?? 0) - (stats.tokens?.saved ?? 0),\n total_tokens_saved: stats.tokens?.saved ?? 0,\n average_compression_ratio: stats.tokens?.savings_percent ? stats.tokens.savings_percent / 100 : 0,\n models: stats.requests?.by_model ?? {},\n modes: {},\n error_count: stats.requests?.failed ?? 0,\n });\n }\n\n /** Get in-memory session stats. */\n async getStats(): Promise {\n const resp = await this._fetch(\"/stats\", { method: \"GET\" });\n const stats = (await resp.json()) as any;\n return deepCamelCase({\n total_requests: stats.requests?.total ?? 0,\n total_tokens_before: stats.tokens?.total_before_compression ?? 0,\n total_tokens_after: (stats.tokens?.total_before_compression ?? 0) - (stats.tokens?.saved ?? 0),\n total_tokens_saved: stats.tokens?.saved ?? 0,\n average_compression_ratio: stats.tokens?.savings_percent ? stats.tokens.savings_percent / 100 : 0,\n cache_hits: stats.requests?.cached ?? 0,\n by_mode: {},\n });\n }\n\n /** Validate proxy configuration. */\n async validateSetup(): Promise {\n const resp = await this._fetch(\"/health\", { method: \"GET\" });\n const health = (await resp.json()) as any;\n return {\n valid: health.status === \"healthy\",\n provider: \"\",\n errors: health.status !== \"healthy\" ? [\"Proxy unhealthy\"] : [],\n warnings: [],\n config: health.config ?? {},\n };\n }\n\n // ============================================================\n // CCR Retrieve\n // ============================================================\n\n /** Retrieve original content from the CCR compression store. */\n async retrieve(\n hash: string,\n options?: { query?: string },\n ): Promise {\n const body: Record = { hash };\n if (options?.query) body.query = options.query;\n const resp = await this._fetch(\"/v1/retrieve\", {\n method: \"POST\",\n body: JSON.stringify(body),\n });\n return deepCamelCase(await resp.json());\n }\n\n /** Get CCR store statistics. */\n async getCCRStats(): Promise {\n const resp = await this._fetch(\"/v1/retrieve/stats\", { method: \"GET\" });\n return deepCamelCase(await resp.json());\n }\n\n /** Handle an LLM tool call for headroom_retrieve. */\n async handleToolCall(request: {\n toolCall: any;\n provider?: \"anthropic\" | \"openai\";\n }): Promise {\n const resp = await this._fetch(\"/v1/retrieve/tool_call\", {\n method: \"POST\",\n body: JSON.stringify(deepSnakeCase(request)),\n });\n return deepCamelCase(await resp.json());\n }\n\n // ============================================================\n // Telemetry & Feedback\n // ============================================================\n\n readonly telemetry = {\n getStats: async (): Promise => {\n const resp = await this._fetch(\"/v1/telemetry\", { method: \"GET\" });\n return deepCamelCase(await resp.json());\n },\n export: async (): Promise => {\n const resp = await this._fetch(\"/v1/telemetry/export\", { method: \"GET\" });\n return resp.json();\n },\n import: async (data: any): Promise<{ status: string }> => {\n const resp = await this._fetch(\"/v1/telemetry/import\", {\n method: \"POST\",\n body: JSON.stringify(data),\n });\n return resp.json() as Promise<{ status: string }>;\n },\n getTools: async (): Promise => {\n const resp = await this._fetch(\"/v1/telemetry/tools\", { method: \"GET\" });\n return deepCamelCase(await resp.json());\n },\n getTool: async (signatureHash: string): Promise => {\n const resp = await this._fetch(`/v1/telemetry/tools/${signatureHash}`, { method: \"GET\" });\n return deepCamelCase(await resp.json());\n },\n };\n\n readonly feedback = {\n getStats: async (): Promise => {\n const resp = await this._fetch(\"/v1/feedback\", { method: \"GET\" });\n return deepCamelCase(await resp.json());\n },\n getHints: async (toolName: string): Promise => {\n const resp = await this._fetch(`/v1/feedback/${encodeURIComponent(toolName)}`, { method: \"GET\" });\n return deepCamelCase(await resp.json());\n },\n };\n\n readonly toin = {\n getStats: async (): Promise => {\n const resp = await this._fetch(\"/v1/toin/stats\", { method: \"GET\" });\n return deepCamelCase(await resp.json());\n },\n getPatterns: async (limit?: number): Promise => {\n const qs = limit ? `?limit=${limit}` : \"\";\n const resp = await this._fetch(`/v1/toin/patterns${qs}`, { method: \"GET\" });\n return deepCamelCase(await resp.json());\n },\n getPattern: async (hashPrefix: string): Promise => {\n const resp = await this._fetch(`/v1/toin/pattern/${encodeURIComponent(hashPrefix)}`, { method: \"GET\" });\n return deepCamelCase(await resp.json());\n },\n };\n\n // ============================================================\n // Lifecycle\n // ============================================================\n\n /** Close the client (no-op for HTTP client, included for API parity). */\n close(): void {\n // HTTP client is stateless — nothing to close\n }\n\n // ============================================================\n // Internal HTTP helpers\n // ============================================================\n\n /**\n * Raw fetch with proxy base URL, auth, and timeout.\n * A string body is sent as-is; anything else is JSON-serialized.\n * @internal\n */\n async rawFetch(\n path: string,\n options: { method: string; headers?: Record; body?: any; stream?: boolean },\n ): Promise {\n const url = `${this.baseUrl}${path}`;\n const headers: Record = {\n \"Content-Type\": \"application/json\",\n ...options.headers,\n };\n if (this.apiKey) {\n headers[\"X-Headroom-Proxy-Token\"] = this.apiKey;\n // Don't override provider auth headers\n if (!headers[\"Authorization\"] && !headers[\"x-api-key\"]) {\n headers[\"Authorization\"] = `Bearer ${this.apiKey}`;\n }\n }\n if (this.stack && !headers[\"X-Headroom-Stack\"]) {\n headers[\"X-Headroom-Stack\"] = this.stack;\n }\n\n let response: Response;\n try {\n response = await fetch(url, {\n method: options.method,\n headers,\n body:\n typeof options.body === \"string\"\n ? options.body\n : options.body\n ? JSON.stringify(options.body)\n : undefined,\n signal: AbortSignal.timeout(this.timeout),\n });\n } catch (error) {\n throw new HeadroomConnectionError(\n `Failed to connect to Headroom at ${this.baseUrl}: ${error}`,\n );\n }\n\n if (!response.ok) {\n let errorBody: ProxyErrorResponse | undefined;\n try {\n errorBody = (await response.json()) as ProxyErrorResponse;\n } catch {\n // ignore\n }\n throw mapProxyError(\n response.status,\n errorBody?.error?.type ?? \"unknown\",\n errorBody?.error?.message ?? `HTTP ${response.status}`,\n );\n }\n\n return response;\n }\n\n /** Proxy-only endpoints, whose bodies are already serialized. @internal */\n private _fetch(\n path: string,\n init: { method: string; body?: string },\n ): Promise {\n return this.rawFetch(path, init);\n }\n\n private async _doCompress(\n messages: OpenAIMessage[],\n model: string,\n tokenBudget?: number,\n config?: CompressRequestConfig,\n ): Promise {\n const body: Record = { messages, model };\n if (tokenBudget) {\n body.token_budget = tokenBudget;\n }\n const mergedConfig = { ...this.config, ...config };\n if (Object.keys(mergedConfig).length > 0) {\n body.config = deepSnakeCase(mergedConfig);\n }\n\n const response = await this._fetch(\"/v1/compress\", {\n method: \"POST\",\n body: JSON.stringify(body),\n });\n\n const data = (await response.json()) as ProxyCompressResponse;\n\n return {\n messages: data.messages,\n tokensBefore: data.tokens_before,\n tokensAfter: data.tokens_after,\n tokensSaved: data.tokens_saved,\n compressionRatio: data.compression_ratio,\n transformsApplied: data.transforms_applied,\n ccrHashes: data.ccr_hashes,\n compressed: true,\n };\n }\n}\n","/**\n * Universal message format detection and conversion.\n *\n * Supports 4 formats:\n * - OpenAI: { role, content, tool_calls?, tool_call_id? }\n * - Anthropic: { role, content (string | ContentBlock[]) } with tool_use/tool_result blocks\n * - Vercel AI SDK: { role, content (string | Part[]) } with tool-call/tool-result parts\n * - Google Gemini: { role, parts[] } with functionCall/functionResponse\n *\n * Detection is structural (unique keys/values per format), not heuristic.\n * Conversion always targets OpenAI format (the proxy's lingua franca).\n */\n\nimport type {\n OpenAIMessage,\n AssistantMessage,\n ToolCall,\n} from \"../types.js\";\n\n/* eslint-disable @typescript-eslint/no-explicit-any */\n\nexport type MessageFormat = \"openai\" | \"anthropic\" | \"vercel\" | \"gemini\";\n\n/**\n * Detect which format an array of messages is in.\n *\n * Uses structural markers unique to each format:\n * - Gemini: messages have `parts` field (not `content`)\n * - Vercel: content parts use hyphenated types: `tool-call`, `tool-result`\n * - Anthropic: content blocks use underscored types: `tool_use`, `tool_result`\n * - OpenAI: assistant messages have `tool_calls` field; tool messages have `tool_call_id`\n */\nexport function detectFormat(messages: any[]): MessageFormat {\n for (const msg of messages) {\n // Gemini: uses `parts` instead of `content`\n if (\"parts\" in msg && !(\"content\" in msg)) return \"gemini\";\n\n // Gemini: role \"model\" instead of \"assistant\"\n if (msg.role === \"model\") return \"gemini\";\n\n // OpenAI: tool_calls on assistant messages\n if (msg.tool_calls && msg.role === \"assistant\") return \"openai\";\n\n // OpenAI: tool role with tool_call_id\n if (msg.role === \"tool\" && \"tool_call_id\" in msg && typeof msg.content === \"string\") return \"openai\";\n\n // Check content blocks/parts for format-specific markers\n if (Array.isArray(msg.content)) {\n for (const part of msg.content) {\n // Vercel: hyphenated types\n if (part.type === \"tool-call\" || part.type === \"tool-result\") return \"vercel\";\n // Anthropic: underscored types\n if (part.type === \"tool_use\" || part.type === \"tool_result\") return \"anthropic\";\n // Anthropic: image with source.type\n if (part.type === \"image\" && part.source?.type) return \"anthropic\";\n }\n }\n }\n\n // Default: OpenAI (simple {role, content: string} is valid OpenAI)\n return \"openai\";\n}\n\n// ============================================================\n// OpenAI ↔ OpenAI (passthrough)\n// ============================================================\n\n// No conversion needed — this is the canonical format.\n\n// ============================================================\n// Anthropic → OpenAI\n// ============================================================\n\n// Anthropic image block `source` -> OpenAI image_url url string, or null if unconvertible.\nfunction anthropicImageUrl(source: any): string | null {\n if (!source || typeof source !== \"object\") return null;\n if (source.type === \"base64\" && source.media_type && source.data) {\n return `data:${source.media_type};base64,${source.data}`;\n }\n if (source.type === \"url\" && typeof source.url === \"string\") return source.url;\n return null;\n}\n\n// OpenAI image_url url -> Anthropic image block. data: URIs become a base64 source; other urls a url source.\nfunction openAIImageBlock(url: string): any {\n const m = /^data:([^;,]+);base64,(.*)$/s.exec(url);\n if (m) return { type: \"image\", source: { type: \"base64\", media_type: m[1], data: m[2] } };\n return { type: \"image\", source: { type: \"url\", url } };\n}\n\nexport function anthropicToOpenAI(messages: any[]): OpenAIMessage[] {\n const result: OpenAIMessage[] = [];\n\n for (const msg of messages) {\n if (msg.role === \"user\") {\n if (typeof msg.content === \"string\") {\n result.push({ role: \"user\", content: msg.content });\n continue;\n }\n if (Array.isArray(msg.content)) {\n const toolResults = msg.content.filter((b: any) => b.type === \"tool_result\");\n const hasImages = msg.content.some((b: any) => b.type === \"image\");\n\n if (hasImages) {\n const parts = msg.content\n .filter((b: any) => b.type === \"text\" || b.type === \"image\")\n .map((b: any) => {\n if (b.type === \"text\") return { type: \"text\" as const, text: b.text };\n const url = anthropicImageUrl(b.source);\n return url ? { type: \"image_url\" as const, image_url: { url } } : null;\n })\n .filter((p: any): p is NonNullable => p !== null);\n\n if (parts.length > 0) {\n result.push({ role: \"user\", content: parts });\n }\n } else {\n const textBlocks = msg.content.filter((b: any) => b.type === \"text\");\n if (textBlocks.length > 0) {\n result.push({\n role: \"user\",\n content: textBlocks.map((b: any) => b.text).join(\"\\n\"),\n });\n }\n }\n for (const tr of toolResults) {\n const content = typeof tr.content === \"string\"\n ? tr.content\n : Array.isArray(tr.content)\n ? tr.content.map((b: any) => b.text ?? JSON.stringify(b)).join(\"\\n\")\n : JSON.stringify(tr.content);\n result.push({\n role: \"tool\",\n content,\n tool_call_id: tr.tool_use_id,\n });\n }\n }\n continue;\n }\n\n if (msg.role === \"assistant\") {\n if (typeof msg.content === \"string\") {\n result.push({ role: \"assistant\", content: msg.content });\n continue;\n }\n if (Array.isArray(msg.content)) {\n const textBlocks = msg.content.filter((b: any) => b.type === \"text\");\n const toolUseBlocks = msg.content.filter((b: any) => b.type === \"tool_use\");\n\n const content = textBlocks.length > 0\n ? textBlocks.map((b: any) => b.text).join(\"\\n\")\n : null;\n\n const openaiMsg: AssistantMessage = { role: \"assistant\", content };\n if (toolUseBlocks.length > 0) {\n openaiMsg.tool_calls = toolUseBlocks.map((b: any): ToolCall => ({\n id: b.id,\n type: \"function\",\n function: {\n name: b.name,\n arguments: typeof b.input === \"string\" ? b.input : JSON.stringify(b.input),\n },\n }));\n }\n result.push(openaiMsg);\n }\n continue;\n }\n }\n\n return result;\n}\n\nexport function openAIToAnthropic(messages: OpenAIMessage[]): any[] {\n const result: any[] = [];\n\n for (const msg of messages) {\n if (msg.role === \"system\") {\n // Anthropic system is top-level, but if it appears in messages, convert to user\n result.push({ role: \"user\", content: msg.content });\n continue;\n }\n\n if (msg.role === \"user\") {\n if (typeof msg.content === \"string\") {\n result.push({ role: \"user\", content: msg.content });\n } else if (Array.isArray(msg.content)) {\n result.push({\n role: \"user\",\n content: msg.content.map((p) => {\n if (p.type === \"text\") return { type: \"text\", text: p.text };\n if (p.type === \"image_url\") return openAIImageBlock(p.image_url.url);\n return { type: \"text\", text: \"\" };\n }),\n });\n }\n continue;\n }\n\n if (msg.role === \"assistant\") {\n const blocks: any[] = [];\n if (msg.content) blocks.push({ type: \"text\", text: msg.content });\n if (msg.tool_calls) {\n for (const tc of msg.tool_calls) {\n blocks.push({\n type: \"tool_use\",\n id: tc.id,\n name: tc.function.name,\n input: JSON.parse(tc.function.arguments),\n });\n }\n }\n result.push({\n role: \"assistant\",\n content: blocks.length === 1 && blocks[0].type === \"text\" ? blocks[0].text : blocks,\n });\n continue;\n }\n\n if (msg.role === \"tool\") {\n result.push({\n role: \"user\",\n content: [\n { type: \"tool_result\", tool_use_id: msg.tool_call_id, content: msg.content },\n ],\n });\n continue;\n }\n }\n\n return result;\n}\n\n// ============================================================\n// Vercel AI SDK → OpenAI\n// ============================================================\n\nexport function vercelToOpenAI(messages: any[]): OpenAIMessage[] {\n const result: OpenAIMessage[] = [];\n\n for (const msg of messages) {\n if (msg.role === \"system\") {\n result.push({ role: \"system\", content: typeof msg.content === \"string\" ? msg.content : String(msg.content) });\n continue;\n }\n\n if (msg.role === \"user\") {\n if (typeof msg.content === \"string\") {\n result.push({ role: \"user\", content: msg.content });\n continue;\n }\n const parts = Array.isArray(msg.content) ? msg.content : [];\n const textParts = parts.filter((p: any) => p.type === \"text\");\n const imageParts = parts.filter((p: any) => p.type === \"image\");\n\n if (imageParts.length === 0 && textParts.length > 0) {\n result.push({ role: \"user\", content: textParts.map((p: any) => p.text).join(\"\") });\n } else {\n const openaiParts = parts\n .filter((p: any) => p.type === \"text\" || p.type === \"image\")\n .map((p: any) => {\n if (p.type === \"text\") return { type: \"text\" as const, text: p.text };\n if (p.type === \"image\") {\n const url = p.image instanceof URL ? p.image.toString() : String(p.image);\n return { type: \"image_url\" as const, image_url: { url } };\n }\n return { type: \"text\" as const, text: \"\" };\n });\n result.push({ role: \"user\", content: openaiParts });\n }\n continue;\n }\n\n if (msg.role === \"assistant\") {\n if (typeof msg.content === \"string\") {\n result.push({ role: \"assistant\", content: msg.content });\n continue;\n }\n const parts = Array.isArray(msg.content) ? msg.content : [];\n const textParts = parts.filter((p: any) => p.type === \"text\");\n const toolCallParts = parts.filter((p: any) => p.type === \"tool-call\");\n\n const content = textParts.length > 0 ? textParts.map((p: any) => p.text).join(\"\") : null;\n const openaiMsg: AssistantMessage = { role: \"assistant\", content };\n\n if (toolCallParts.length > 0) {\n openaiMsg.tool_calls = toolCallParts.map((p: any): ToolCall => ({\n id: p.toolCallId,\n type: \"function\",\n // AI SDK v6 uses `input`, earlier versions used `args`\n function: { name: p.toolName, arguments: JSON.stringify(p.input ?? p.args) },\n }));\n }\n result.push(openaiMsg);\n continue;\n }\n\n if (msg.role === \"tool\") {\n const parts = Array.isArray(msg.content) ? msg.content : [];\n for (const part of parts) {\n if (part.type === \"tool-result\") {\n // AI SDK v6 uses `output: { type, value }`, earlier versions used `result`\n let contentStr: string;\n if (part.output !== undefined) {\n // v6 format: output is { type: 'json', value } or { type: 'text', value }\n const val = part.output?.value ?? part.output;\n contentStr = typeof val === \"string\" ? val : JSON.stringify(val);\n } else if (part.result !== undefined) {\n // Legacy format: result field directly\n contentStr = typeof part.result === \"string\" ? part.result : JSON.stringify(part.result);\n } else {\n contentStr = \"\";\n }\n result.push({\n role: \"tool\",\n content: contentStr,\n tool_call_id: part.toolCallId,\n });\n }\n }\n continue;\n }\n }\n\n return result;\n}\n\nexport function openAIToVercel(messages: OpenAIMessage[]): any[] {\n const result: any[] = [];\n\n for (const msg of messages) {\n if (msg.role === \"system\") {\n result.push({ role: \"system\", content: msg.content });\n continue;\n }\n\n if (msg.role === \"user\") {\n if (typeof msg.content === \"string\") {\n result.push({ role: \"user\", content: [{ type: \"text\", text: msg.content }] });\n } else if (Array.isArray(msg.content)) {\n const parts = msg.content.map((p) => {\n if (p.type === \"text\") return { type: \"text\", text: p.text };\n if (p.type === \"image_url\") return { type: \"image\", image: new URL(p.image_url.url) };\n return { type: \"text\", text: \"\" };\n });\n result.push({ role: \"user\", content: parts });\n }\n continue;\n }\n\n if (msg.role === \"assistant\") {\n const parts: any[] = [];\n if (msg.content) parts.push({ type: \"text\", text: msg.content });\n if (msg.tool_calls) {\n for (const tc of msg.tool_calls) {\n let input: any;\n try { input = JSON.parse(tc.function.arguments); } catch { input = tc.function.arguments ?? {}; }\n parts.push({\n type: \"tool-call\",\n toolCallId: tc.id,\n toolName: tc.function.name,\n input, // AI SDK v6 uses `input`, not `args`\n });\n }\n }\n result.push({ role: \"assistant\", content: parts });\n continue;\n }\n\n if (msg.role === \"tool\") {\n let parsed: any;\n try { parsed = JSON.parse(msg.content); } catch { parsed = msg.content; }\n // AI SDK v6 requires output: { type: 'json' | 'text', value }\n const output = typeof parsed === \"string\"\n ? { type: \"text\" as const, value: parsed }\n : { type: \"json\" as const, value: parsed };\n result.push({\n role: \"tool\",\n content: [{\n type: \"tool-result\",\n toolCallId: msg.tool_call_id,\n toolName: \"unknown\",\n output,\n }],\n });\n continue;\n }\n }\n\n return result;\n}\n\n// ============================================================\n// Google Gemini → OpenAI\n// ============================================================\n\nexport function geminiToOpenAI(messages: any[]): OpenAIMessage[] {\n const result: OpenAIMessage[] = [];\n\n for (const msg of messages) {\n const role = msg.role === \"model\" ? \"assistant\" : \"user\";\n const parts: any[] = msg.parts ?? [];\n\n if (role === \"user\") {\n // Check for functionResponse parts\n const funcResponses = parts.filter((p: any) => p.functionResponse);\n const textParts = parts.filter((p: any) => p.text !== undefined);\n\n if (textParts.length > 0) {\n result.push({ role: \"user\", content: textParts.map((p: any) => p.text).join(\"\\n\") });\n }\n for (const fr of funcResponses) {\n result.push({\n role: \"tool\",\n content: JSON.stringify(fr.functionResponse.response),\n tool_call_id: `gemini_${fr.functionResponse.name}`,\n });\n }\n continue;\n }\n\n if (role === \"assistant\") {\n const textParts = parts.filter((p: any) => p.text !== undefined);\n const funcCalls = parts.filter((p: any) => p.functionCall);\n\n const content = textParts.length > 0 ? textParts.map((p: any) => p.text).join(\"\\n\") : null;\n const openaiMsg: AssistantMessage = { role: \"assistant\", content };\n\n if (funcCalls.length > 0) {\n openaiMsg.tool_calls = funcCalls.map((p: any): ToolCall => ({\n id: `gemini_${p.functionCall.name}`,\n type: \"function\",\n function: {\n name: p.functionCall.name,\n arguments: JSON.stringify(p.functionCall.args),\n },\n }));\n }\n result.push(openaiMsg);\n continue;\n }\n }\n\n return result;\n}\n\nexport function openAIToGemini(messages: OpenAIMessage[]): any[] {\n const result: any[] = [];\n\n for (const msg of messages) {\n if (msg.role === \"system\") {\n // Gemini system is top-level; if in messages, convert to user\n result.push({ role: \"user\", parts: [{ text: msg.content }] });\n continue;\n }\n\n if (msg.role === \"user\") {\n const text = typeof msg.content === \"string\"\n ? msg.content\n : (msg.content ?? []).filter((p) => p.type === \"text\").map((p) => (p as any).text).join(\"\\n\");\n result.push({ role: \"user\", parts: [{ text }] });\n continue;\n }\n\n if (msg.role === \"assistant\") {\n const parts: any[] = [];\n if (msg.content) parts.push({ text: msg.content });\n if (msg.tool_calls) {\n for (const tc of msg.tool_calls) {\n parts.push({\n functionCall: { name: tc.function.name, args: JSON.parse(tc.function.arguments) },\n });\n }\n }\n result.push({ role: \"model\", parts });\n continue;\n }\n\n if (msg.role === \"tool\") {\n let response: any;\n try { response = JSON.parse(msg.content); } catch { response = { result: msg.content }; }\n result.push({\n role: \"user\",\n parts: [{ functionResponse: { name: msg.tool_call_id?.replace(\"gemini_\", \"\") ?? \"unknown\", response } }],\n });\n continue;\n }\n }\n\n return result;\n}\n\n// ============================================================\n// Universal: any format → OpenAI, and OpenAI → original format\n// ============================================================\n\nexport function toOpenAI(messages: any[]): OpenAIMessage[] {\n const format = detectFormat(messages);\n switch (format) {\n case \"openai\": return messages as OpenAIMessage[];\n case \"anthropic\": return anthropicToOpenAI(messages);\n case \"vercel\": return vercelToOpenAI(messages);\n case \"gemini\": return geminiToOpenAI(messages);\n }\n}\n\nexport function fromOpenAI(messages: OpenAIMessage[], targetFormat: MessageFormat): any[] {\n switch (targetFormat) {\n case \"openai\": return messages;\n case \"anthropic\": return openAIToAnthropic(messages);\n case \"vercel\": return openAIToVercel(messages);\n case \"gemini\": return openAIToGemini(messages);\n }\n}\n","/**\n * Compression hooks matching Python headroom.hooks.\n * Allows customizing compression behavior with pre/post hooks.\n */\n\nexport interface CompressContext {\n model: string;\n userQuery: string;\n turnNumber: number;\n toolCalls: string[];\n provider: string;\n}\n\nexport interface CompressEvent {\n tokensBefore: number;\n tokensAfter: number;\n tokensSaved: number;\n compressionRatio: number;\n transformsApplied: string[];\n ccrHashes: string[];\n model: string;\n userQuery: string;\n provider: string;\n}\n\n/**\n * Base class for compression hooks. Override methods to customize behavior.\n *\n * @example\n * ```typescript\n * class LoggingHooks extends CompressionHooks {\n * postCompress(event: CompressEvent) {\n * console.log(`Saved ${event.tokensSaved} tokens (${event.compressionRatio})`);\n * }\n * }\n * ```\n */\nexport class CompressionHooks {\n /**\n * Called before compression. Modify messages before they're sent to the proxy.\n */\n preCompress(\n messages: any[],\n _ctx: CompressContext,\n ): any[] | Promise {\n return messages;\n }\n\n /**\n * Compute per-message compression biases.\n * Return a map of message index -> bias (>1 = preserve more, <1 = compress more).\n */\n computeBiases(\n _messages: any[],\n _ctx: CompressContext,\n ): Record | Promise> {\n return {};\n }\n\n /**\n * Called after compression. Observe-only — cannot modify the result.\n */\n postCompress(_event: CompressEvent): void | Promise {\n // no-op default\n }\n}\n\n/**\n * Extract the last user message text from a messages array (any format).\n */\nexport function extractUserQuery(messages: any[]): string {\n for (let i = messages.length - 1; i >= 0; i--) {\n const msg = messages[i];\n if (msg.role === \"user\") {\n if (typeof msg.content === \"string\") return msg.content;\n if (Array.isArray(msg.content)) {\n const textPart = msg.content.find(\n (p: any) => p.type === \"text\" || p.text,\n );\n if (textPart) return textPart.text ?? textPart.content ?? \"\";\n }\n }\n }\n return \"\";\n}\n\n/**\n * Count conversation turns (user+assistant pairs).\n */\nexport function countTurns(messages: any[]): number {\n return messages.filter((m) => m.role === \"user\").length;\n}\n\n/**\n * Extract tool call names from messages.\n */\nexport function extractToolCalls(messages: any[]): string[] {\n const names: string[] = [];\n for (const msg of messages) {\n if (msg.tool_calls) {\n for (const tc of msg.tool_calls) {\n names.push(tc.function?.name ?? tc.name ?? \"unknown\");\n }\n }\n if (Array.isArray(msg.content)) {\n for (const part of msg.content) {\n if (part.type === \"tool_use\") names.push(part.name ?? \"unknown\");\n if (part.type === \"tool-call\") names.push(part.toolName ?? \"unknown\");\n }\n }\n }\n return names;\n}\n","/**\n * Universal compress function with hooks support.\n * Accepts messages in any format: OpenAI, Anthropic, Vercel AI SDK, or Google Gemini.\n */\n\nimport { HeadroomClient } from \"./client.js\";\nimport type { CompressResult, CompressOptions } from \"./types.js\";\nimport { detectFormat, toOpenAI, fromOpenAI } from \"./utils/format.js\";\nimport type { CompressionHooks, CompressContext, CompressEvent } from \"./hooks.js\";\nimport { extractUserQuery, countTurns, extractToolCalls } from \"./hooks.js\";\n\n/**\n * Compress an array of messages using the Headroom proxy.\n *\n * Accepts messages in any format: OpenAI, Anthropic, Vercel AI SDK, or Google Gemini.\n * Detects the format automatically, compresses via the proxy, and returns\n * compressed messages in the same format as the input.\n */\nexport async function compress(\n messages: any[],\n options: CompressOptions = {},\n): Promise {\n const {\n client: providedClient,\n model,\n tokenBudget,\n hooks,\n config,\n ...clientOptions\n } = options;\n\n // Build hook context\n const ctx: CompressContext = {\n model: model ?? \"gpt-4o\",\n userQuery: extractUserQuery(messages),\n turnNumber: countTurns(messages),\n toolCalls: extractToolCalls(messages),\n provider: \"\",\n };\n\n // 1. Pre-compress hook\n let processedMessages = messages;\n if (hooks) {\n processedMessages = await hooks.preCompress(messages, ctx);\n }\n\n // 2. Detect input format\n const inputFormat = detectFormat(processedMessages);\n\n // 3. Convert to OpenAI format (the proxy's lingua franca)\n const openaiMessages = toOpenAI(processedMessages);\n\n // 4. Compute biases\n let biases: Record = {};\n if (hooks) {\n biases = await hooks.computeBiases(openaiMessages, ctx);\n }\n\n // 5. Compress via proxy\n const client = providedClient ?? new HeadroomClient(clientOptions);\n const result = await client.compress(openaiMessages, { model, tokenBudget, config });\n\n // 6. Convert compressed messages back to original format\n const outputMessages = fromOpenAI(result.messages, inputFormat);\n\n const finalResult: CompressResult = {\n ...result,\n messages: outputMessages,\n };\n\n // 7. Post-compress hook\n if (hooks) {\n const event: CompressEvent = {\n tokensBefore: result.tokensBefore,\n tokensAfter: result.tokensAfter,\n tokensSaved: result.tokensSaved,\n compressionRatio: result.compressionRatio,\n transformsApplied: result.transformsApplied,\n ccrHashes: result.ccrHashes,\n model: ctx.model,\n userQuery: ctx.userQuery,\n provider: ctx.provider,\n };\n await hooks.postCompress(event);\n }\n\n return finalResult;\n}\n"]} \ No newline at end of file diff --git a/node_modules/headroom-ai/dist/chunk-26N6BVLT.cjs b/node_modules/headroom-ai/dist/chunk-26N6BVLT.cjs new file mode 100644 index 00000000..f7b224e8 --- /dev/null +++ b/node_modules/headroom-ai/dist/chunk-26N6BVLT.cjs @@ -0,0 +1,1136 @@ +'use strict'; + +var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, { + get: (a, b) => (typeof require !== "undefined" ? require : a)[b] +}) : x)(function(x) { + if (typeof require !== "undefined") return require.apply(this, arguments); + throw Error('Dynamic require of "' + x + '" is not supported'); +}); + +// src/errors.ts +var HeadroomError = class extends Error { + details; + constructor(message, details) { + super(message); + this.name = "HeadroomError"; + this.details = details; + } +}; +var HeadroomConnectionError = class extends HeadroomError { + constructor(message, details) { + super(message, details); + this.name = "HeadroomConnectionError"; + } +}; +var HeadroomAuthError = class extends HeadroomError { + constructor(message, details) { + super(message, details); + this.name = "HeadroomAuthError"; + } +}; +var HeadroomCompressError = class extends HeadroomError { + statusCode; + errorType; + constructor(statusCode, errorType, message, details) { + super(message, details); + this.name = "HeadroomCompressError"; + this.statusCode = statusCode; + this.errorType = errorType; + } +}; +var ConfigurationError = class extends HeadroomError { + constructor(message, details) { + super(message, details); + this.name = "ConfigurationError"; + } +}; +var ProviderError = class extends HeadroomError { + constructor(message, details) { + super(message, details); + this.name = "ProviderError"; + } +}; +var StorageError = class extends HeadroomError { + constructor(message, details) { + super(message, details); + this.name = "StorageError"; + } +}; +var TokenizationError = class extends HeadroomError { + constructor(message, details) { + super(message, details); + this.name = "TokenizationError"; + } +}; +var CacheError = class extends HeadroomError { + constructor(message, details) { + super(message, details); + this.name = "CacheError"; + } +}; +var ValidationError = class extends HeadroomError { + constructor(message, details) { + super(message, details); + this.name = "ValidationError"; + } +}; +var TransformError = class extends HeadroomError { + constructor(message, details) { + super(message, details); + this.name = "TransformError"; + } +}; +var ERROR_TYPE_MAP = { + configuration_error: ConfigurationError, + provider_error: ProviderError, + storage_error: StorageError, + tokenization_error: TokenizationError, + cache_error: CacheError, + validation_error: ValidationError, + transform_error: TransformError +}; +function mapProxyError(status, type, message) { + if (status === 401) return new HeadroomAuthError(message); + const ErrorClass = ERROR_TYPE_MAP[type]; + if (ErrorClass) return new ErrorClass(message, { statusCode: status, errorType: type }); + return new HeadroomCompressError(status, type, message); +} + +// src/utils/case.ts +function snakeToCamel(str) { + return str.replace(/_([a-z0-9])/g, (_, c) => c.toUpperCase()); +} +function camelToSnake(str) { + return str.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`); +} +function deepCamelCase(obj) { + if (obj === null || obj === void 0) return obj; + if (Array.isArray(obj)) return obj.map(deepCamelCase); + if (typeof obj === "object" && !(obj instanceof Date)) { + return Object.fromEntries( + Object.entries(obj).map(([k, v]) => [snakeToCamel(k), deepCamelCase(v)]) + ); + } + return obj; +} +function deepSnakeCase(obj) { + if (obj === null || obj === void 0) return obj; + if (Array.isArray(obj)) return obj.map(deepSnakeCase); + if (typeof obj === "object" && !(obj instanceof Date)) { + return Object.fromEntries( + Object.entries(obj).map(([k, v]) => [camelToSnake(k), deepSnakeCase(v)]) + ); + } + return obj; +} + +// src/utils/stream.ts +async function* parseSSE(response) { + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + try { + while (true) { + const { done, value } = await reader.read(); + buffer += done ? decoder.decode() : decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = done ? "" : lines.pop(); + for (const line of lines) { + if (line.startsWith("data: ")) { + const data = line.slice(6).trim(); + if (data === "[DONE]") return; + try { + yield JSON.parse(data); + } catch { + } + } + } + if (done) break; + } + } finally { + reader.releaseLock(); + } +} +async function collectStream(stream) { + const chunks = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + return chunks; +} + +// src/client.ts +var DEFAULT_BASE_URL = "http://localhost:8787"; +var DEFAULT_TIMEOUT = 3e4; +var DEFAULT_RETRIES = 1; +function getEnv(key) { + if (typeof process !== "undefined" && process.env) { + return process.env[key]; + } + return void 0; +} +function makeFallbackResult(messages) { + return { + messages, + tokensBefore: 0, + tokensAfter: 0, + tokensSaved: 0, + compressionRatio: 1, + transformsApplied: [], + ccrHashes: [], + compressed: false + }; +} +var ChatCompletions = class { + constructor(client) { + this.client = client; + } + client; + /** + * Create a chat completion with automatic compression. + * Routes through proxy's POST /v1/chat/completions. + */ + async create(params) { + const { headroomMode, headroomCachePrefixTokens, headroomOutputBufferTokens, headroomKeepTurns, headroomToolProfiles, ...apiParams } = params; + const headers = {}; + if (headroomMode) headers["x-headroom-mode"] = headroomMode; + const providerKey = this.client.providerApiKey ?? getEnv("OPENAI_API_KEY"); + if (providerKey) headers["Authorization"] = `Bearer ${providerKey}`; + const response = await this.client.rawFetch("/v1/chat/completions", { + method: "POST", + headers, + body: apiParams, + stream: params.stream + }); + if (params.stream) { + return parseSSE(response); + } + return response.json(); + } + /** + * Simulate compression without calling the LLM. + */ + async simulate(params) { + const body = { + messages: params.messages, + model: params.model, + config: { default_mode: "simulate", generate_diff_artifact: true } + }; + const result = await this.client.compressRaw(body); + return deepCamelCase(result); + } +}; +var Messages = class { + constructor(client) { + this.client = client; + } + client; + /** + * Create a message with automatic compression. + * Routes through proxy's POST /v1/messages (Anthropic). + */ + async create(params) { + const { headroomMode, headroomCachePrefixTokens, headroomOutputBufferTokens, headroomKeepTurns, headroomToolProfiles, ...apiParams } = params; + const headers = { + "anthropic-version": "2023-06-01" + }; + if (headroomMode) headers["x-headroom-mode"] = headroomMode; + const providerKey = this.client.providerApiKey ?? getEnv("ANTHROPIC_API_KEY"); + if (providerKey) headers["x-api-key"] = providerKey; + if (!apiParams.max_tokens) apiParams.max_tokens = 1024; + const response = await this.client.rawFetch("/v1/messages", { + method: "POST", + headers, + body: apiParams, + stream: params.stream + }); + if (params.stream) { + return parseSSE(response); + } + return response.json(); + } + /** + * Stream a message with automatic compression. + */ + stream(params) { + return this.create({ ...params, stream: true }); + } + /** + * Simulate compression without calling the LLM. + */ + async simulate(params) { + const body = { + messages: params.messages, + model: params.model, + config: { default_mode: "simulate", generate_diff_artifact: true } + }; + const result = await this.client.compressRaw(body); + return deepCamelCase(result); + } +}; +var HeadroomClient = class { + baseUrl; + apiKey; + timeout; + fallback; + retries; + config; + stack; + /** @internal */ + providerApiKey; + /** OpenAI-style chat completions API. */ + chat; + /** Anthropic-style messages API. */ + messages; + constructor(options = {}) { + this.baseUrl = (options.baseUrl ?? getEnv("HEADROOM_BASE_URL") ?? DEFAULT_BASE_URL).replace(/\/+$/, ""); + this.apiKey = options.apiKey ?? getEnv("HEADROOM_API_KEY"); + this.timeout = options.timeout ?? DEFAULT_TIMEOUT; + this.fallback = options.fallback ?? true; + this.retries = options.retries ?? DEFAULT_RETRIES; + this.providerApiKey = options.providerApiKey; + this.config = options.config; + this.stack = options.stack; + this.chat = { completions: new ChatCompletions(this) }; + this.messages = new Messages(this); + } + // ============================================================ + // Core: compress + // ============================================================ + async compress(messages, options = {}) { + const model = options.model ?? "gpt-4o"; + let lastError; + const maxAttempts = 1 + this.retries; + for (let attempt = 0; attempt < maxAttempts; attempt++) { + try { + return await this._doCompress(messages, model, options.tokenBudget, options.config); + } catch (error) { + lastError = error; + if (error instanceof HeadroomAuthError) throw error; + if (error instanceof HeadroomCompressError && error.statusCode < 500) { + throw error; + } + } + } + if (this.fallback) { + return makeFallbackResult(messages); + } + if (lastError instanceof HeadroomConnectionError) throw lastError; + if (lastError instanceof HeadroomCompressError) throw lastError; + throw new HeadroomConnectionError( + `Failed after ${maxAttempts} attempts: ${lastError}` + ); + } + /** + * Raw compress call — sends body directly to /v1/compress. + * Used by simulate() and other advanced features. + * @internal + */ + async compressRaw(body) { + const response = await this._fetch("/v1/compress", { + method: "POST", + body: JSON.stringify(body) + }); + return response.json(); + } + // ============================================================ + // Health & Stats + // ============================================================ + /** Check if the proxy is running and healthy. */ + async health() { + const resp = await this._fetch("/health", { method: "GET" }); + return deepCamelCase(await resp.json()); + } + /** Get comprehensive proxy statistics. */ + async proxyStats() { + const resp = await this._fetch("/stats", { method: "GET" }); + return deepCamelCase(await resp.json()); + } + /** Get Prometheus-format metrics. */ + async prometheusMetrics() { + const resp = await this._fetch("/metrics", { method: "GET" }); + return resp.text(); + } + /** Get historical stats. */ + async statsHistory(query) { + const params = new URLSearchParams(); + if (query?.format) params.set("format", query.format); + if (query?.series) params.set("series", query.series); + const qs = params.toString(); + const resp = await this._fetch(`/stats-history${qs ? `?${qs}` : ""}`, { method: "GET" }); + return resp.json(); + } + /** Get proxy memory usage. */ + async memoryUsage() { + const resp = await this._fetch("/debug/memory", { method: "GET" }); + return deepCamelCase(await resp.json()); + } + /** Clear the response cache. */ + async clearCache() { + const resp = await this._fetch("/cache/clear", { method: "POST" }); + return resp.json(); + } + // ============================================================ + // Metrics & Observability + // ============================================================ + /** Get request metrics from the proxy. */ + async getMetrics(query) { + const resp = await this._fetch("/stats", { method: "GET" }); + const stats = await resp.json(); + let metrics = stats.recent_requests ?? []; + if (query?.model) { + metrics = metrics.filter((m) => m.model === query.model); + } + if (query?.mode) { + metrics = metrics.filter((m) => m.mode === query.mode); + } + if (query?.limit) { + metrics = metrics.slice(0, query.limit); + } + return metrics.map((m) => deepCamelCase(m)); + } + /** Get aggregated metrics summary. */ + async getSummary(query) { + const resp = await this._fetch("/stats", { method: "GET" }); + const stats = await resp.json(); + return deepCamelCase({ + total_requests: stats.requests?.total ?? 0, + total_tokens_before: stats.tokens?.total_before_compression ?? 0, + total_tokens_after: (stats.tokens?.total_before_compression ?? 0) - (stats.tokens?.saved ?? 0), + total_tokens_saved: stats.tokens?.saved ?? 0, + average_compression_ratio: stats.tokens?.savings_percent ? stats.tokens.savings_percent / 100 : 0, + models: stats.requests?.by_model ?? {}, + modes: {}, + error_count: stats.requests?.failed ?? 0 + }); + } + /** Get in-memory session stats. */ + async getStats() { + const resp = await this._fetch("/stats", { method: "GET" }); + const stats = await resp.json(); + return deepCamelCase({ + total_requests: stats.requests?.total ?? 0, + total_tokens_before: stats.tokens?.total_before_compression ?? 0, + total_tokens_after: (stats.tokens?.total_before_compression ?? 0) - (stats.tokens?.saved ?? 0), + total_tokens_saved: stats.tokens?.saved ?? 0, + average_compression_ratio: stats.tokens?.savings_percent ? stats.tokens.savings_percent / 100 : 0, + cache_hits: stats.requests?.cached ?? 0, + by_mode: {} + }); + } + /** Validate proxy configuration. */ + async validateSetup() { + const resp = await this._fetch("/health", { method: "GET" }); + const health = await resp.json(); + return { + valid: health.status === "healthy", + provider: "", + errors: health.status !== "healthy" ? ["Proxy unhealthy"] : [], + warnings: [], + config: health.config ?? {} + }; + } + // ============================================================ + // CCR Retrieve + // ============================================================ + /** Retrieve original content from the CCR compression store. */ + async retrieve(hash, options) { + const body = { hash }; + if (options?.query) body.query = options.query; + const resp = await this._fetch("/v1/retrieve", { + method: "POST", + body: JSON.stringify(body) + }); + return deepCamelCase(await resp.json()); + } + /** Get CCR store statistics. */ + async getCCRStats() { + const resp = await this._fetch("/v1/retrieve/stats", { method: "GET" }); + return deepCamelCase(await resp.json()); + } + /** Handle an LLM tool call for headroom_retrieve. */ + async handleToolCall(request) { + const resp = await this._fetch("/v1/retrieve/tool_call", { + method: "POST", + body: JSON.stringify(deepSnakeCase(request)) + }); + return deepCamelCase(await resp.json()); + } + // ============================================================ + // Telemetry & Feedback + // ============================================================ + telemetry = { + getStats: async () => { + const resp = await this._fetch("/v1/telemetry", { method: "GET" }); + return deepCamelCase(await resp.json()); + }, + export: async () => { + const resp = await this._fetch("/v1/telemetry/export", { method: "GET" }); + return resp.json(); + }, + import: async (data) => { + const resp = await this._fetch("/v1/telemetry/import", { + method: "POST", + body: JSON.stringify(data) + }); + return resp.json(); + }, + getTools: async () => { + const resp = await this._fetch("/v1/telemetry/tools", { method: "GET" }); + return deepCamelCase(await resp.json()); + }, + getTool: async (signatureHash) => { + const resp = await this._fetch(`/v1/telemetry/tools/${signatureHash}`, { method: "GET" }); + return deepCamelCase(await resp.json()); + } + }; + feedback = { + getStats: async () => { + const resp = await this._fetch("/v1/feedback", { method: "GET" }); + return deepCamelCase(await resp.json()); + }, + getHints: async (toolName) => { + const resp = await this._fetch(`/v1/feedback/${encodeURIComponent(toolName)}`, { method: "GET" }); + return deepCamelCase(await resp.json()); + } + }; + toin = { + getStats: async () => { + const resp = await this._fetch("/v1/toin/stats", { method: "GET" }); + return deepCamelCase(await resp.json()); + }, + getPatterns: async (limit) => { + const qs = limit ? `?limit=${limit}` : ""; + const resp = await this._fetch(`/v1/toin/patterns${qs}`, { method: "GET" }); + return deepCamelCase(await resp.json()); + }, + getPattern: async (hashPrefix) => { + const resp = await this._fetch(`/v1/toin/pattern/${encodeURIComponent(hashPrefix)}`, { method: "GET" }); + return deepCamelCase(await resp.json()); + } + }; + // ============================================================ + // Lifecycle + // ============================================================ + /** Close the client (no-op for HTTP client, included for API parity). */ + close() { + } + // ============================================================ + // Internal HTTP helpers + // ============================================================ + /** + * Raw fetch with proxy base URL, auth, and timeout. + * A string body is sent as-is; anything else is JSON-serialized. + * @internal + */ + async rawFetch(path, options) { + const url = `${this.baseUrl}${path}`; + const headers = { + "Content-Type": "application/json", + ...options.headers + }; + if (this.apiKey) { + headers["X-Headroom-Proxy-Token"] = this.apiKey; + if (!headers["Authorization"] && !headers["x-api-key"]) { + headers["Authorization"] = `Bearer ${this.apiKey}`; + } + } + if (this.stack && !headers["X-Headroom-Stack"]) { + headers["X-Headroom-Stack"] = this.stack; + } + let response; + try { + response = await fetch(url, { + method: options.method, + headers, + body: typeof options.body === "string" ? options.body : options.body ? JSON.stringify(options.body) : void 0, + signal: AbortSignal.timeout(this.timeout) + }); + } catch (error) { + throw new HeadroomConnectionError( + `Failed to connect to Headroom at ${this.baseUrl}: ${error}` + ); + } + if (!response.ok) { + let errorBody; + try { + errorBody = await response.json(); + } catch { + } + throw mapProxyError( + response.status, + errorBody?.error?.type ?? "unknown", + errorBody?.error?.message ?? `HTTP ${response.status}` + ); + } + return response; + } + /** Proxy-only endpoints, whose bodies are already serialized. @internal */ + _fetch(path, init) { + return this.rawFetch(path, init); + } + async _doCompress(messages, model, tokenBudget, config) { + const body = { messages, model }; + if (tokenBudget) { + body.token_budget = tokenBudget; + } + const mergedConfig = { ...this.config, ...config }; + if (Object.keys(mergedConfig).length > 0) { + body.config = deepSnakeCase(mergedConfig); + } + const response = await this._fetch("/v1/compress", { + method: "POST", + body: JSON.stringify(body) + }); + const data = await response.json(); + return { + messages: data.messages, + tokensBefore: data.tokens_before, + tokensAfter: data.tokens_after, + tokensSaved: data.tokens_saved, + compressionRatio: data.compression_ratio, + transformsApplied: data.transforms_applied, + ccrHashes: data.ccr_hashes, + compressed: true + }; + } +}; + +// src/utils/format.ts +function detectFormat(messages) { + for (const msg of messages) { + if ("parts" in msg && !("content" in msg)) return "gemini"; + if (msg.role === "model") return "gemini"; + if (msg.tool_calls && msg.role === "assistant") return "openai"; + if (msg.role === "tool" && "tool_call_id" in msg && typeof msg.content === "string") return "openai"; + if (Array.isArray(msg.content)) { + for (const part of msg.content) { + if (part.type === "tool-call" || part.type === "tool-result") return "vercel"; + if (part.type === "tool_use" || part.type === "tool_result") return "anthropic"; + if (part.type === "image" && part.source?.type) return "anthropic"; + } + } + } + return "openai"; +} +function anthropicImageUrl(source) { + if (!source || typeof source !== "object") return null; + if (source.type === "base64" && source.media_type && source.data) { + return `data:${source.media_type};base64,${source.data}`; + } + if (source.type === "url" && typeof source.url === "string") return source.url; + return null; +} +function openAIImageBlock(url) { + const m = /^data:([^;,]+);base64,(.*)$/s.exec(url); + if (m) return { type: "image", source: { type: "base64", media_type: m[1], data: m[2] } }; + return { type: "image", source: { type: "url", url } }; +} +function anthropicToOpenAI(messages) { + const result = []; + for (const msg of messages) { + if (msg.role === "user") { + if (typeof msg.content === "string") { + result.push({ role: "user", content: msg.content }); + continue; + } + if (Array.isArray(msg.content)) { + const toolResults = msg.content.filter((b) => b.type === "tool_result"); + const hasImages = msg.content.some((b) => b.type === "image"); + if (hasImages) { + const parts = msg.content.filter((b) => b.type === "text" || b.type === "image").map((b) => { + if (b.type === "text") return { type: "text", text: b.text }; + const url = anthropicImageUrl(b.source); + return url ? { type: "image_url", image_url: { url } } : null; + }).filter((p) => p !== null); + if (parts.length > 0) { + result.push({ role: "user", content: parts }); + } + } else { + const textBlocks = msg.content.filter((b) => b.type === "text"); + if (textBlocks.length > 0) { + result.push({ + role: "user", + content: textBlocks.map((b) => b.text).join("\n") + }); + } + } + for (const tr of toolResults) { + const content = typeof tr.content === "string" ? tr.content : Array.isArray(tr.content) ? tr.content.map((b) => b.text ?? JSON.stringify(b)).join("\n") : JSON.stringify(tr.content); + result.push({ + role: "tool", + content, + tool_call_id: tr.tool_use_id + }); + } + } + continue; + } + if (msg.role === "assistant") { + if (typeof msg.content === "string") { + result.push({ role: "assistant", content: msg.content }); + continue; + } + if (Array.isArray(msg.content)) { + const textBlocks = msg.content.filter((b) => b.type === "text"); + const toolUseBlocks = msg.content.filter((b) => b.type === "tool_use"); + const content = textBlocks.length > 0 ? textBlocks.map((b) => b.text).join("\n") : null; + const openaiMsg = { role: "assistant", content }; + if (toolUseBlocks.length > 0) { + openaiMsg.tool_calls = toolUseBlocks.map((b) => ({ + id: b.id, + type: "function", + function: { + name: b.name, + arguments: typeof b.input === "string" ? b.input : JSON.stringify(b.input) + } + })); + } + result.push(openaiMsg); + } + continue; + } + } + return result; +} +function openAIToAnthropic(messages) { + const result = []; + for (const msg of messages) { + if (msg.role === "system") { + result.push({ role: "user", content: msg.content }); + continue; + } + if (msg.role === "user") { + if (typeof msg.content === "string") { + result.push({ role: "user", content: msg.content }); + } else if (Array.isArray(msg.content)) { + result.push({ + role: "user", + content: msg.content.map((p) => { + if (p.type === "text") return { type: "text", text: p.text }; + if (p.type === "image_url") return openAIImageBlock(p.image_url.url); + return { type: "text", text: "" }; + }) + }); + } + continue; + } + if (msg.role === "assistant") { + const blocks = []; + if (msg.content) blocks.push({ type: "text", text: msg.content }); + if (msg.tool_calls) { + for (const tc of msg.tool_calls) { + blocks.push({ + type: "tool_use", + id: tc.id, + name: tc.function.name, + input: JSON.parse(tc.function.arguments) + }); + } + } + result.push({ + role: "assistant", + content: blocks.length === 1 && blocks[0].type === "text" ? blocks[0].text : blocks + }); + continue; + } + if (msg.role === "tool") { + result.push({ + role: "user", + content: [ + { type: "tool_result", tool_use_id: msg.tool_call_id, content: msg.content } + ] + }); + continue; + } + } + return result; +} +function vercelToOpenAI(messages) { + const result = []; + for (const msg of messages) { + if (msg.role === "system") { + result.push({ role: "system", content: typeof msg.content === "string" ? msg.content : String(msg.content) }); + continue; + } + if (msg.role === "user") { + if (typeof msg.content === "string") { + result.push({ role: "user", content: msg.content }); + continue; + } + const parts = Array.isArray(msg.content) ? msg.content : []; + const textParts = parts.filter((p) => p.type === "text"); + const imageParts = parts.filter((p) => p.type === "image"); + if (imageParts.length === 0 && textParts.length > 0) { + result.push({ role: "user", content: textParts.map((p) => p.text).join("") }); + } else { + const openaiParts = parts.filter((p) => p.type === "text" || p.type === "image").map((p) => { + if (p.type === "text") return { type: "text", text: p.text }; + if (p.type === "image") { + const url = p.image instanceof URL ? p.image.toString() : String(p.image); + return { type: "image_url", image_url: { url } }; + } + return { type: "text", text: "" }; + }); + result.push({ role: "user", content: openaiParts }); + } + continue; + } + if (msg.role === "assistant") { + if (typeof msg.content === "string") { + result.push({ role: "assistant", content: msg.content }); + continue; + } + const parts = Array.isArray(msg.content) ? msg.content : []; + const textParts = parts.filter((p) => p.type === "text"); + const toolCallParts = parts.filter((p) => p.type === "tool-call"); + const content = textParts.length > 0 ? textParts.map((p) => p.text).join("") : null; + const openaiMsg = { role: "assistant", content }; + if (toolCallParts.length > 0) { + openaiMsg.tool_calls = toolCallParts.map((p) => ({ + id: p.toolCallId, + type: "function", + // AI SDK v6 uses `input`, earlier versions used `args` + function: { name: p.toolName, arguments: JSON.stringify(p.input ?? p.args) } + })); + } + result.push(openaiMsg); + continue; + } + if (msg.role === "tool") { + const parts = Array.isArray(msg.content) ? msg.content : []; + for (const part of parts) { + if (part.type === "tool-result") { + let contentStr; + if (part.output !== void 0) { + const val = part.output?.value ?? part.output; + contentStr = typeof val === "string" ? val : JSON.stringify(val); + } else if (part.result !== void 0) { + contentStr = typeof part.result === "string" ? part.result : JSON.stringify(part.result); + } else { + contentStr = ""; + } + result.push({ + role: "tool", + content: contentStr, + tool_call_id: part.toolCallId + }); + } + } + continue; + } + } + return result; +} +function openAIToVercel(messages) { + const result = []; + for (const msg of messages) { + if (msg.role === "system") { + result.push({ role: "system", content: msg.content }); + continue; + } + if (msg.role === "user") { + if (typeof msg.content === "string") { + result.push({ role: "user", content: [{ type: "text", text: msg.content }] }); + } else if (Array.isArray(msg.content)) { + const parts = msg.content.map((p) => { + if (p.type === "text") return { type: "text", text: p.text }; + if (p.type === "image_url") return { type: "image", image: new URL(p.image_url.url) }; + return { type: "text", text: "" }; + }); + result.push({ role: "user", content: parts }); + } + continue; + } + if (msg.role === "assistant") { + const parts = []; + if (msg.content) parts.push({ type: "text", text: msg.content }); + if (msg.tool_calls) { + for (const tc of msg.tool_calls) { + let input; + try { + input = JSON.parse(tc.function.arguments); + } catch { + input = tc.function.arguments ?? {}; + } + parts.push({ + type: "tool-call", + toolCallId: tc.id, + toolName: tc.function.name, + input + // AI SDK v6 uses `input`, not `args` + }); + } + } + result.push({ role: "assistant", content: parts }); + continue; + } + if (msg.role === "tool") { + let parsed; + try { + parsed = JSON.parse(msg.content); + } catch { + parsed = msg.content; + } + const output = typeof parsed === "string" ? { type: "text", value: parsed } : { type: "json", value: parsed }; + result.push({ + role: "tool", + content: [{ + type: "tool-result", + toolCallId: msg.tool_call_id, + toolName: "unknown", + output + }] + }); + continue; + } + } + return result; +} +function geminiToOpenAI(messages) { + const result = []; + for (const msg of messages) { + const role = msg.role === "model" ? "assistant" : "user"; + const parts = msg.parts ?? []; + if (role === "user") { + const funcResponses = parts.filter((p) => p.functionResponse); + const textParts = parts.filter((p) => p.text !== void 0); + if (textParts.length > 0) { + result.push({ role: "user", content: textParts.map((p) => p.text).join("\n") }); + } + for (const fr of funcResponses) { + result.push({ + role: "tool", + content: JSON.stringify(fr.functionResponse.response), + tool_call_id: `gemini_${fr.functionResponse.name}` + }); + } + continue; + } + if (role === "assistant") { + const textParts = parts.filter((p) => p.text !== void 0); + const funcCalls = parts.filter((p) => p.functionCall); + const content = textParts.length > 0 ? textParts.map((p) => p.text).join("\n") : null; + const openaiMsg = { role: "assistant", content }; + if (funcCalls.length > 0) { + openaiMsg.tool_calls = funcCalls.map((p) => ({ + id: `gemini_${p.functionCall.name}`, + type: "function", + function: { + name: p.functionCall.name, + arguments: JSON.stringify(p.functionCall.args) + } + })); + } + result.push(openaiMsg); + continue; + } + } + return result; +} +function openAIToGemini(messages) { + const result = []; + for (const msg of messages) { + if (msg.role === "system") { + result.push({ role: "user", parts: [{ text: msg.content }] }); + continue; + } + if (msg.role === "user") { + const text = typeof msg.content === "string" ? msg.content : (msg.content ?? []).filter((p) => p.type === "text").map((p) => p.text).join("\n"); + result.push({ role: "user", parts: [{ text }] }); + continue; + } + if (msg.role === "assistant") { + const parts = []; + if (msg.content) parts.push({ text: msg.content }); + if (msg.tool_calls) { + for (const tc of msg.tool_calls) { + parts.push({ + functionCall: { name: tc.function.name, args: JSON.parse(tc.function.arguments) } + }); + } + } + result.push({ role: "model", parts }); + continue; + } + if (msg.role === "tool") { + let response; + try { + response = JSON.parse(msg.content); + } catch { + response = { result: msg.content }; + } + result.push({ + role: "user", + parts: [{ functionResponse: { name: msg.tool_call_id?.replace("gemini_", "") ?? "unknown", response } }] + }); + continue; + } + } + return result; +} +function toOpenAI(messages) { + const format = detectFormat(messages); + switch (format) { + case "openai": + return messages; + case "anthropic": + return anthropicToOpenAI(messages); + case "vercel": + return vercelToOpenAI(messages); + case "gemini": + return geminiToOpenAI(messages); + } +} +function fromOpenAI(messages, targetFormat) { + switch (targetFormat) { + case "openai": + return messages; + case "anthropic": + return openAIToAnthropic(messages); + case "vercel": + return openAIToVercel(messages); + case "gemini": + return openAIToGemini(messages); + } +} + +// src/hooks.ts +var CompressionHooks = class { + /** + * Called before compression. Modify messages before they're sent to the proxy. + */ + preCompress(messages, _ctx) { + return messages; + } + /** + * Compute per-message compression biases. + * Return a map of message index -> bias (>1 = preserve more, <1 = compress more). + */ + computeBiases(_messages, _ctx) { + return {}; + } + /** + * Called after compression. Observe-only — cannot modify the result. + */ + postCompress(_event) { + } +}; +function extractUserQuery(messages) { + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg.role === "user") { + if (typeof msg.content === "string") return msg.content; + if (Array.isArray(msg.content)) { + const textPart = msg.content.find( + (p) => p.type === "text" || p.text + ); + if (textPart) return textPart.text ?? textPart.content ?? ""; + } + } + } + return ""; +} +function countTurns(messages) { + return messages.filter((m) => m.role === "user").length; +} +function extractToolCalls(messages) { + const names = []; + for (const msg of messages) { + if (msg.tool_calls) { + for (const tc of msg.tool_calls) { + names.push(tc.function?.name ?? tc.name ?? "unknown"); + } + } + if (Array.isArray(msg.content)) { + for (const part of msg.content) { + if (part.type === "tool_use") names.push(part.name ?? "unknown"); + if (part.type === "tool-call") names.push(part.toolName ?? "unknown"); + } + } + } + return names; +} + +// src/compress.ts +async function compress(messages, options = {}) { + const { + client: providedClient, + model, + tokenBudget, + hooks, + config, + ...clientOptions + } = options; + const ctx = { + model: model ?? "gpt-4o", + userQuery: extractUserQuery(messages), + turnNumber: countTurns(messages), + toolCalls: extractToolCalls(messages), + provider: "" + }; + let processedMessages = messages; + if (hooks) { + processedMessages = await hooks.preCompress(messages, ctx); + } + const inputFormat = detectFormat(processedMessages); + const openaiMessages = toOpenAI(processedMessages); + if (hooks) { + await hooks.computeBiases(openaiMessages, ctx); + } + const client = providedClient ?? new HeadroomClient(clientOptions); + const result = await client.compress(openaiMessages, { model, tokenBudget, config }); + const outputMessages = fromOpenAI(result.messages, inputFormat); + const finalResult = { + ...result, + messages: outputMessages + }; + if (hooks) { + const event = { + tokensBefore: result.tokensBefore, + tokensAfter: result.tokensAfter, + tokensSaved: result.tokensSaved, + compressionRatio: result.compressionRatio, + transformsApplied: result.transformsApplied, + ccrHashes: result.ccrHashes, + model: ctx.model, + userQuery: ctx.userQuery, + provider: ctx.provider + }; + await hooks.postCompress(event); + } + return finalResult; +} + +exports.CacheError = CacheError; +exports.CompressionHooks = CompressionHooks; +exports.ConfigurationError = ConfigurationError; +exports.HeadroomAuthError = HeadroomAuthError; +exports.HeadroomClient = HeadroomClient; +exports.HeadroomCompressError = HeadroomCompressError; +exports.HeadroomConnectionError = HeadroomConnectionError; +exports.HeadroomError = HeadroomError; +exports.ProviderError = ProviderError; +exports.StorageError = StorageError; +exports.TokenizationError = TokenizationError; +exports.TransformError = TransformError; +exports.ValidationError = ValidationError; +exports.__require = __require; +exports.camelToSnake = camelToSnake; +exports.collectStream = collectStream; +exports.compress = compress; +exports.countTurns = countTurns; +exports.deepCamelCase = deepCamelCase; +exports.deepSnakeCase = deepSnakeCase; +exports.detectFormat = detectFormat; +exports.extractToolCalls = extractToolCalls; +exports.extractUserQuery = extractUserQuery; +exports.fromOpenAI = fromOpenAI; +exports.mapProxyError = mapProxyError; +exports.openAIToVercel = openAIToVercel; +exports.parseSSE = parseSSE; +exports.snakeToCamel = snakeToCamel; +exports.toOpenAI = toOpenAI; +exports.vercelToOpenAI = vercelToOpenAI; +//# sourceMappingURL=chunk-26N6BVLT.cjs.map +//# sourceMappingURL=chunk-26N6BVLT.cjs.map \ No newline at end of file diff --git a/node_modules/headroom-ai/dist/chunk-26N6BVLT.cjs.map b/node_modules/headroom-ai/dist/chunk-26N6BVLT.cjs.map new file mode 100644 index 00000000..9e83bd96 --- /dev/null +++ b/node_modules/headroom-ai/dist/chunk-26N6BVLT.cjs.map @@ -0,0 +1 @@ +{"version":3,"sources":["../src/errors.ts","../src/utils/case.ts","../src/utils/stream.ts","../src/client.ts","../src/utils/format.ts","../src/hooks.ts","../src/compress.ts"],"names":[],"mappings":"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","file":"chunk-26N6BVLT.cjs","sourcesContent":["/**\n * Error hierarchy matching Python headroom.exceptions.\n */\n\nexport class HeadroomError extends Error {\n details?: Record;\n\n constructor(message: string, details?: Record) {\n super(message);\n this.name = \"HeadroomError\";\n this.details = details;\n }\n}\n\nexport class HeadroomConnectionError extends HeadroomError {\n constructor(message: string, details?: Record) {\n super(message, details);\n this.name = \"HeadroomConnectionError\";\n }\n}\n\nexport class HeadroomAuthError extends HeadroomError {\n constructor(message: string, details?: Record) {\n super(message, details);\n this.name = \"HeadroomAuthError\";\n }\n}\n\nexport class HeadroomCompressError extends HeadroomError {\n statusCode: number;\n errorType: string;\n\n constructor(statusCode: number, errorType: string, message: string, details?: Record) {\n super(message, details);\n this.name = \"HeadroomCompressError\";\n this.statusCode = statusCode;\n this.errorType = errorType;\n }\n}\n\nexport class ConfigurationError extends HeadroomError {\n constructor(message: string, details?: Record) {\n super(message, details);\n this.name = \"ConfigurationError\";\n }\n}\n\nexport class ProviderError extends HeadroomError {\n constructor(message: string, details?: Record) {\n super(message, details);\n this.name = \"ProviderError\";\n }\n}\n\nexport class StorageError extends HeadroomError {\n constructor(message: string, details?: Record) {\n super(message, details);\n this.name = \"StorageError\";\n }\n}\n\nexport class TokenizationError extends HeadroomError {\n constructor(message: string, details?: Record) {\n super(message, details);\n this.name = \"TokenizationError\";\n }\n}\n\nexport class CacheError extends HeadroomError {\n constructor(message: string, details?: Record) {\n super(message, details);\n this.name = \"CacheError\";\n }\n}\n\nexport class ValidationError extends HeadroomError {\n constructor(message: string, details?: Record) {\n super(message, details);\n this.name = \"ValidationError\";\n }\n}\n\nexport class TransformError extends HeadroomError {\n constructor(message: string, details?: Record) {\n super(message, details);\n this.name = \"TransformError\";\n }\n}\n\n// --- Proxy error mapping ---\n\nconst ERROR_TYPE_MAP: Record) => HeadroomError> = {\n configuration_error: ConfigurationError,\n provider_error: ProviderError,\n storage_error: StorageError,\n tokenization_error: TokenizationError,\n cache_error: CacheError,\n validation_error: ValidationError,\n transform_error: TransformError,\n};\n\n/**\n * Map a proxy error response to the correct HeadroomError subclass.\n */\nexport function mapProxyError(\n status: number,\n type: string,\n message: string,\n): HeadroomError {\n if (status === 401) return new HeadroomAuthError(message);\n const ErrorClass = ERROR_TYPE_MAP[type];\n if (ErrorClass) return new ErrorClass(message, { statusCode: status, errorType: type });\n return new HeadroomCompressError(status, type, message);\n}\n","/**\n * Case conversion utilities for proxy communication.\n * Proxy uses snake_case (Python), SDK uses camelCase (TypeScript).\n */\n\nexport function snakeToCamel(str: string): string {\n return str.replace(/_([a-z0-9])/g, (_, c) => c.toUpperCase());\n}\n\nexport function camelToSnake(str: string): string {\n return str.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);\n}\n\nexport function deepCamelCase(obj: any): T {\n if (obj === null || obj === undefined) return obj as T;\n if (Array.isArray(obj)) return obj.map(deepCamelCase) as T;\n if (typeof obj === \"object\" && !(obj instanceof Date)) {\n return Object.fromEntries(\n Object.entries(obj).map(([k, v]) => [snakeToCamel(k), deepCamelCase(v)]),\n ) as T;\n }\n return obj as T;\n}\n\nexport function deepSnakeCase(obj: any): any {\n if (obj === null || obj === undefined) return obj;\n if (Array.isArray(obj)) return obj.map(deepSnakeCase);\n if (typeof obj === \"object\" && !(obj instanceof Date)) {\n return Object.fromEntries(\n Object.entries(obj).map(([k, v]) => [camelToSnake(k), deepSnakeCase(v)]),\n );\n }\n return obj;\n}\n","/**\n * SSE (Server-Sent Events) stream parser for proxy streaming responses.\n */\n\n/**\n * Parse an SSE response body into an async generator of parsed JSON events.\n */\nexport async function* parseSSE(\n response: Response,\n): AsyncGenerator {\n const reader = response.body!.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n\n try {\n while (true) {\n const { done, value } = await reader.read();\n // On the final read, flush any buffered multi-byte remainder and process\n // every remaining line — including a last `data:` line that arrived\n // without a trailing newline, which would otherwise be held back in\n // `buffer` and dropped when the loop exits.\n buffer += done ? decoder.decode() : decoder.decode(value, { stream: true });\n const lines = buffer.split(\"\\n\");\n buffer = done ? \"\" : lines.pop()!;\n\n for (const line of lines) {\n if (line.startsWith(\"data: \")) {\n const data = line.slice(6).trim();\n if (data === \"[DONE]\") return;\n try {\n yield JSON.parse(data) as T;\n } catch {\n // skip non-JSON data lines (e.g. Anthropic event metadata)\n }\n }\n }\n\n if (done) break;\n }\n } finally {\n reader.releaseLock();\n }\n}\n\n/**\n * Collect all chunks from an async iterable into an array.\n */\nexport async function collectStream(stream: AsyncIterable): Promise {\n const chunks: T[] = [];\n for await (const chunk of stream) {\n chunks.push(chunk);\n }\n return chunks;\n}\n","/**\n * HeadroomClient — HTTP client for the Headroom compression proxy.\n *\n * Supports:\n * - compress() — direct compression via /v1/compress\n * - chat.completions.create() — OpenAI-style passthrough via /v1/chat/completions\n * - messages.create() — Anthropic-style passthrough via /v1/messages\n * - Metrics, health, CCR retrieve, telemetry, feedback, TOIN\n */\n\nimport type {\n OpenAIMessage,\n CompressResult,\n HeadroomClientOptions,\n HeadroomClientInterface,\n ProxyCompressResponse,\n ProxyErrorResponse,\n} from \"./types.js\";\nimport { mapProxyError, HeadroomConnectionError, HeadroomAuthError, HeadroomCompressError } from \"./errors.js\";\nimport { deepCamelCase, deepSnakeCase } from \"./utils/case.js\";\nimport { parseSSE } from \"./utils/stream.js\";\nimport type { CompressRequestConfig, HeadroomMode } from \"./types/config.js\";\nimport type {\n SimulationResult,\n RequestMetrics,\n MetricsSummary,\n SessionStats,\n ValidationResult,\n HealthStatus,\n ProxyStats,\n MemoryUsage,\n RetrieveResult,\n RetrieveSearchResult,\n CCRStats,\n TelemetryStats,\n ToolHints,\n TOINStats,\n TOINPattern,\n MetricsQuery,\n SummaryQuery,\n StatsHistoryQuery,\n} from \"./types/models.js\";\n\nconst DEFAULT_BASE_URL = \"http://localhost:8787\";\nconst DEFAULT_TIMEOUT = 30_000;\nconst DEFAULT_RETRIES = 1;\n\nfunction getEnv(key: string): string | undefined {\n if (typeof process !== \"undefined\" && process.env) {\n return process.env[key];\n }\n return undefined;\n}\n\nfunction makeFallbackResult(messages: OpenAIMessage[]): CompressResult {\n return {\n messages,\n tokensBefore: 0,\n tokensAfter: 0,\n tokensSaved: 0,\n compressionRatio: 1.0,\n transformsApplied: [],\n ccrHashes: [],\n compressed: false,\n };\n}\n\n// --- Headroom request params ---\n\nexport interface HeadroomParams {\n headroomMode?: HeadroomMode;\n headroomCachePrefixTokens?: number;\n headroomOutputBufferTokens?: number;\n headroomKeepTurns?: number;\n headroomToolProfiles?: Record>;\n}\n\n// --- Sub-clients ---\n\nclass ChatCompletions {\n constructor(private client: HeadroomClient) {}\n\n /**\n * Create a chat completion with automatic compression.\n * Routes through proxy's POST /v1/chat/completions.\n */\n async create(\n params: { model: string; messages: OpenAIMessage[]; stream?: boolean; [key: string]: any } & HeadroomParams,\n ): Promise {\n const { headroomMode, headroomCachePrefixTokens, headroomOutputBufferTokens, headroomKeepTurns, headroomToolProfiles, ...apiParams } = params;\n\n const headers: Record = {};\n if (headroomMode) headers[\"x-headroom-mode\"] = headroomMode;\n\n const providerKey = this.client.providerApiKey ?? getEnv(\"OPENAI_API_KEY\");\n if (providerKey) headers[\"Authorization\"] = `Bearer ${providerKey}`;\n\n const response = await this.client.rawFetch(\"/v1/chat/completions\", {\n method: \"POST\",\n headers,\n body: apiParams,\n stream: params.stream,\n });\n\n if (params.stream) {\n return parseSSE(response);\n }\n\n return response.json();\n }\n\n /**\n * Simulate compression without calling the LLM.\n */\n async simulate(\n params: { model: string; messages: OpenAIMessage[] } & HeadroomParams,\n ): Promise {\n const body = {\n messages: params.messages,\n model: params.model,\n config: { default_mode: \"simulate\", generate_diff_artifact: true },\n };\n const result = await this.client.compressRaw(body);\n return deepCamelCase(result);\n }\n}\n\nclass Messages {\n constructor(private client: HeadroomClient) {}\n\n /**\n * Create a message with automatic compression.\n * Routes through proxy's POST /v1/messages (Anthropic).\n */\n async create(\n params: { model: string; messages: any[]; max_tokens?: number; system?: string | any[]; stream?: boolean; [key: string]: any } & HeadroomParams,\n ): Promise {\n const { headroomMode, headroomCachePrefixTokens, headroomOutputBufferTokens, headroomKeepTurns, headroomToolProfiles, ...apiParams } = params;\n\n const headers: Record = {\n \"anthropic-version\": \"2023-06-01\",\n };\n if (headroomMode) headers[\"x-headroom-mode\"] = headroomMode;\n\n const providerKey = this.client.providerApiKey ?? getEnv(\"ANTHROPIC_API_KEY\");\n if (providerKey) headers[\"x-api-key\"] = providerKey;\n\n if (!apiParams.max_tokens) apiParams.max_tokens = 1024;\n\n const response = await this.client.rawFetch(\"/v1/messages\", {\n method: \"POST\",\n headers,\n body: apiParams,\n stream: params.stream,\n });\n\n if (params.stream) {\n return parseSSE(response);\n }\n\n return response.json();\n }\n\n /**\n * Stream a message with automatic compression.\n */\n stream(\n params: { model: string; messages: any[]; max_tokens?: number; system?: string | any[]; [key: string]: any } & HeadroomParams,\n ): Promise> {\n return this.create({ ...params, stream: true }) as Promise>;\n }\n\n /**\n * Simulate compression without calling the LLM.\n */\n async simulate(\n params: { model: string; messages: any[] } & HeadroomParams,\n ): Promise {\n const body = {\n messages: params.messages,\n model: params.model,\n config: { default_mode: \"simulate\", generate_diff_artifact: true },\n };\n const result = await this.client.compressRaw(body);\n return deepCamelCase(result);\n }\n}\n\n// --- Main client ---\n\nexport interface ExtendedClientOptions extends HeadroomClientOptions {\n providerApiKey?: string;\n defaultMode?: HeadroomMode;\n config?: CompressRequestConfig;\n}\n\nexport class HeadroomClient implements HeadroomClientInterface {\n private baseUrl: string;\n private apiKey: string | undefined;\n private timeout: number;\n private fallback: boolean;\n private retries: number;\n private config: CompressRequestConfig | undefined;\n private stack: string | undefined;\n\n /** @internal */ providerApiKey: string | undefined;\n\n /** OpenAI-style chat completions API. */\n readonly chat: { completions: ChatCompletions };\n /** Anthropic-style messages API. */\n readonly messages: Messages;\n\n constructor(options: ExtendedClientOptions = {}) {\n this.baseUrl = (\n options.baseUrl ??\n getEnv(\"HEADROOM_BASE_URL\") ??\n DEFAULT_BASE_URL\n ).replace(/\\/+$/, \"\");\n this.apiKey = options.apiKey ?? getEnv(\"HEADROOM_API_KEY\");\n this.timeout = options.timeout ?? DEFAULT_TIMEOUT;\n this.fallback = options.fallback ?? true;\n this.retries = options.retries ?? DEFAULT_RETRIES;\n this.providerApiKey = options.providerApiKey;\n this.config = options.config;\n this.stack = options.stack;\n\n this.chat = { completions: new ChatCompletions(this) };\n this.messages = new Messages(this);\n }\n\n // ============================================================\n // Core: compress\n // ============================================================\n\n async compress(\n messages: OpenAIMessage[],\n options: { model?: string; tokenBudget?: number; config?: CompressRequestConfig } = {},\n ): Promise {\n const model = options.model ?? \"gpt-4o\";\n\n let lastError: unknown;\n const maxAttempts = 1 + this.retries;\n\n for (let attempt = 0; attempt < maxAttempts; attempt++) {\n try {\n return await this._doCompress(messages, model, options.tokenBudget, options.config);\n } catch (error) {\n lastError = error;\n if (error instanceof HeadroomAuthError) throw error;\n if (\n error instanceof HeadroomCompressError &&\n error.statusCode < 500\n ) {\n throw error;\n }\n }\n }\n\n if (this.fallback) {\n return makeFallbackResult(messages);\n }\n if (lastError instanceof HeadroomConnectionError) throw lastError;\n if (lastError instanceof HeadroomCompressError) throw lastError;\n throw new HeadroomConnectionError(\n `Failed after ${maxAttempts} attempts: ${lastError}`,\n );\n }\n\n /**\n * Raw compress call — sends body directly to /v1/compress.\n * Used by simulate() and other advanced features.\n * @internal\n */\n async compressRaw(body: Record): Promise {\n const response = await this._fetch(\"/v1/compress\", {\n method: \"POST\",\n body: JSON.stringify(body),\n });\n return response.json();\n }\n\n // ============================================================\n // Health & Stats\n // ============================================================\n\n /** Check if the proxy is running and healthy. */\n async health(): Promise {\n const resp = await this._fetch(\"/health\", { method: \"GET\" });\n return deepCamelCase(await resp.json());\n }\n\n /** Get comprehensive proxy statistics. */\n async proxyStats(): Promise {\n const resp = await this._fetch(\"/stats\", { method: \"GET\" });\n return deepCamelCase(await resp.json());\n }\n\n /** Get Prometheus-format metrics. */\n async prometheusMetrics(): Promise {\n const resp = await this._fetch(\"/metrics\", { method: \"GET\" });\n return resp.text();\n }\n\n /** Get historical stats. */\n async statsHistory(query?: StatsHistoryQuery): Promise {\n const params = new URLSearchParams();\n if (query?.format) params.set(\"format\", query.format);\n if (query?.series) params.set(\"series\", query.series);\n const qs = params.toString();\n const resp = await this._fetch(`/stats-history${qs ? `?${qs}` : \"\"}`, { method: \"GET\" });\n return resp.json();\n }\n\n /** Get proxy memory usage. */\n async memoryUsage(): Promise {\n const resp = await this._fetch(\"/debug/memory\", { method: \"GET\" });\n return deepCamelCase(await resp.json());\n }\n\n /** Clear the response cache. */\n async clearCache(): Promise<{ status: string }> {\n const resp = await this._fetch(\"/cache/clear\", { method: \"POST\" });\n return resp.json() as Promise<{ status: string }>;\n }\n\n // ============================================================\n // Metrics & Observability\n // ============================================================\n\n /** Get request metrics from the proxy. */\n async getMetrics(query?: MetricsQuery): Promise {\n const resp = await this._fetch(\"/stats\", { method: \"GET\" });\n const stats = (await resp.json()) as any;\n let metrics: any[] = stats.recent_requests ?? [];\n\n if (query?.model) {\n metrics = metrics.filter((m: any) => m.model === query.model);\n }\n if (query?.mode) {\n metrics = metrics.filter((m: any) => m.mode === query.mode);\n }\n if (query?.limit) {\n metrics = metrics.slice(0, query.limit);\n }\n\n return metrics.map((m: any) => deepCamelCase(m));\n }\n\n /** Get aggregated metrics summary. */\n async getSummary(query?: SummaryQuery): Promise {\n const resp = await this._fetch(\"/stats\", { method: \"GET\" });\n const stats = (await resp.json()) as any;\n return deepCamelCase({\n total_requests: stats.requests?.total ?? 0,\n total_tokens_before: stats.tokens?.total_before_compression ?? 0,\n total_tokens_after: (stats.tokens?.total_before_compression ?? 0) - (stats.tokens?.saved ?? 0),\n total_tokens_saved: stats.tokens?.saved ?? 0,\n average_compression_ratio: stats.tokens?.savings_percent ? stats.tokens.savings_percent / 100 : 0,\n models: stats.requests?.by_model ?? {},\n modes: {},\n error_count: stats.requests?.failed ?? 0,\n });\n }\n\n /** Get in-memory session stats. */\n async getStats(): Promise {\n const resp = await this._fetch(\"/stats\", { method: \"GET\" });\n const stats = (await resp.json()) as any;\n return deepCamelCase({\n total_requests: stats.requests?.total ?? 0,\n total_tokens_before: stats.tokens?.total_before_compression ?? 0,\n total_tokens_after: (stats.tokens?.total_before_compression ?? 0) - (stats.tokens?.saved ?? 0),\n total_tokens_saved: stats.tokens?.saved ?? 0,\n average_compression_ratio: stats.tokens?.savings_percent ? stats.tokens.savings_percent / 100 : 0,\n cache_hits: stats.requests?.cached ?? 0,\n by_mode: {},\n });\n }\n\n /** Validate proxy configuration. */\n async validateSetup(): Promise {\n const resp = await this._fetch(\"/health\", { method: \"GET\" });\n const health = (await resp.json()) as any;\n return {\n valid: health.status === \"healthy\",\n provider: \"\",\n errors: health.status !== \"healthy\" ? [\"Proxy unhealthy\"] : [],\n warnings: [],\n config: health.config ?? {},\n };\n }\n\n // ============================================================\n // CCR Retrieve\n // ============================================================\n\n /** Retrieve original content from the CCR compression store. */\n async retrieve(\n hash: string,\n options?: { query?: string },\n ): Promise {\n const body: Record = { hash };\n if (options?.query) body.query = options.query;\n const resp = await this._fetch(\"/v1/retrieve\", {\n method: \"POST\",\n body: JSON.stringify(body),\n });\n return deepCamelCase(await resp.json());\n }\n\n /** Get CCR store statistics. */\n async getCCRStats(): Promise {\n const resp = await this._fetch(\"/v1/retrieve/stats\", { method: \"GET\" });\n return deepCamelCase(await resp.json());\n }\n\n /** Handle an LLM tool call for headroom_retrieve. */\n async handleToolCall(request: {\n toolCall: any;\n provider?: \"anthropic\" | \"openai\";\n }): Promise {\n const resp = await this._fetch(\"/v1/retrieve/tool_call\", {\n method: \"POST\",\n body: JSON.stringify(deepSnakeCase(request)),\n });\n return deepCamelCase(await resp.json());\n }\n\n // ============================================================\n // Telemetry & Feedback\n // ============================================================\n\n readonly telemetry = {\n getStats: async (): Promise => {\n const resp = await this._fetch(\"/v1/telemetry\", { method: \"GET\" });\n return deepCamelCase(await resp.json());\n },\n export: async (): Promise => {\n const resp = await this._fetch(\"/v1/telemetry/export\", { method: \"GET\" });\n return resp.json();\n },\n import: async (data: any): Promise<{ status: string }> => {\n const resp = await this._fetch(\"/v1/telemetry/import\", {\n method: \"POST\",\n body: JSON.stringify(data),\n });\n return resp.json() as Promise<{ status: string }>;\n },\n getTools: async (): Promise => {\n const resp = await this._fetch(\"/v1/telemetry/tools\", { method: \"GET\" });\n return deepCamelCase(await resp.json());\n },\n getTool: async (signatureHash: string): Promise => {\n const resp = await this._fetch(`/v1/telemetry/tools/${signatureHash}`, { method: \"GET\" });\n return deepCamelCase(await resp.json());\n },\n };\n\n readonly feedback = {\n getStats: async (): Promise => {\n const resp = await this._fetch(\"/v1/feedback\", { method: \"GET\" });\n return deepCamelCase(await resp.json());\n },\n getHints: async (toolName: string): Promise => {\n const resp = await this._fetch(`/v1/feedback/${encodeURIComponent(toolName)}`, { method: \"GET\" });\n return deepCamelCase(await resp.json());\n },\n };\n\n readonly toin = {\n getStats: async (): Promise => {\n const resp = await this._fetch(\"/v1/toin/stats\", { method: \"GET\" });\n return deepCamelCase(await resp.json());\n },\n getPatterns: async (limit?: number): Promise => {\n const qs = limit ? `?limit=${limit}` : \"\";\n const resp = await this._fetch(`/v1/toin/patterns${qs}`, { method: \"GET\" });\n return deepCamelCase(await resp.json());\n },\n getPattern: async (hashPrefix: string): Promise => {\n const resp = await this._fetch(`/v1/toin/pattern/${encodeURIComponent(hashPrefix)}`, { method: \"GET\" });\n return deepCamelCase(await resp.json());\n },\n };\n\n // ============================================================\n // Lifecycle\n // ============================================================\n\n /** Close the client (no-op for HTTP client, included for API parity). */\n close(): void {\n // HTTP client is stateless — nothing to close\n }\n\n // ============================================================\n // Internal HTTP helpers\n // ============================================================\n\n /**\n * Raw fetch with proxy base URL, auth, and timeout.\n * A string body is sent as-is; anything else is JSON-serialized.\n * @internal\n */\n async rawFetch(\n path: string,\n options: { method: string; headers?: Record; body?: any; stream?: boolean },\n ): Promise {\n const url = `${this.baseUrl}${path}`;\n const headers: Record = {\n \"Content-Type\": \"application/json\",\n ...options.headers,\n };\n if (this.apiKey) {\n headers[\"X-Headroom-Proxy-Token\"] = this.apiKey;\n // Don't override provider auth headers\n if (!headers[\"Authorization\"] && !headers[\"x-api-key\"]) {\n headers[\"Authorization\"] = `Bearer ${this.apiKey}`;\n }\n }\n if (this.stack && !headers[\"X-Headroom-Stack\"]) {\n headers[\"X-Headroom-Stack\"] = this.stack;\n }\n\n let response: Response;\n try {\n response = await fetch(url, {\n method: options.method,\n headers,\n body:\n typeof options.body === \"string\"\n ? options.body\n : options.body\n ? JSON.stringify(options.body)\n : undefined,\n signal: AbortSignal.timeout(this.timeout),\n });\n } catch (error) {\n throw new HeadroomConnectionError(\n `Failed to connect to Headroom at ${this.baseUrl}: ${error}`,\n );\n }\n\n if (!response.ok) {\n let errorBody: ProxyErrorResponse | undefined;\n try {\n errorBody = (await response.json()) as ProxyErrorResponse;\n } catch {\n // ignore\n }\n throw mapProxyError(\n response.status,\n errorBody?.error?.type ?? \"unknown\",\n errorBody?.error?.message ?? `HTTP ${response.status}`,\n );\n }\n\n return response;\n }\n\n /** Proxy-only endpoints, whose bodies are already serialized. @internal */\n private _fetch(\n path: string,\n init: { method: string; body?: string },\n ): Promise {\n return this.rawFetch(path, init);\n }\n\n private async _doCompress(\n messages: OpenAIMessage[],\n model: string,\n tokenBudget?: number,\n config?: CompressRequestConfig,\n ): Promise {\n const body: Record = { messages, model };\n if (tokenBudget) {\n body.token_budget = tokenBudget;\n }\n const mergedConfig = { ...this.config, ...config };\n if (Object.keys(mergedConfig).length > 0) {\n body.config = deepSnakeCase(mergedConfig);\n }\n\n const response = await this._fetch(\"/v1/compress\", {\n method: \"POST\",\n body: JSON.stringify(body),\n });\n\n const data = (await response.json()) as ProxyCompressResponse;\n\n return {\n messages: data.messages,\n tokensBefore: data.tokens_before,\n tokensAfter: data.tokens_after,\n tokensSaved: data.tokens_saved,\n compressionRatio: data.compression_ratio,\n transformsApplied: data.transforms_applied,\n ccrHashes: data.ccr_hashes,\n compressed: true,\n };\n }\n}\n","/**\n * Universal message format detection and conversion.\n *\n * Supports 4 formats:\n * - OpenAI: { role, content, tool_calls?, tool_call_id? }\n * - Anthropic: { role, content (string | ContentBlock[]) } with tool_use/tool_result blocks\n * - Vercel AI SDK: { role, content (string | Part[]) } with tool-call/tool-result parts\n * - Google Gemini: { role, parts[] } with functionCall/functionResponse\n *\n * Detection is structural (unique keys/values per format), not heuristic.\n * Conversion always targets OpenAI format (the proxy's lingua franca).\n */\n\nimport type {\n OpenAIMessage,\n AssistantMessage,\n ToolCall,\n} from \"../types.js\";\n\n/* eslint-disable @typescript-eslint/no-explicit-any */\n\nexport type MessageFormat = \"openai\" | \"anthropic\" | \"vercel\" | \"gemini\";\n\n/**\n * Detect which format an array of messages is in.\n *\n * Uses structural markers unique to each format:\n * - Gemini: messages have `parts` field (not `content`)\n * - Vercel: content parts use hyphenated types: `tool-call`, `tool-result`\n * - Anthropic: content blocks use underscored types: `tool_use`, `tool_result`\n * - OpenAI: assistant messages have `tool_calls` field; tool messages have `tool_call_id`\n */\nexport function detectFormat(messages: any[]): MessageFormat {\n for (const msg of messages) {\n // Gemini: uses `parts` instead of `content`\n if (\"parts\" in msg && !(\"content\" in msg)) return \"gemini\";\n\n // Gemini: role \"model\" instead of \"assistant\"\n if (msg.role === \"model\") return \"gemini\";\n\n // OpenAI: tool_calls on assistant messages\n if (msg.tool_calls && msg.role === \"assistant\") return \"openai\";\n\n // OpenAI: tool role with tool_call_id\n if (msg.role === \"tool\" && \"tool_call_id\" in msg && typeof msg.content === \"string\") return \"openai\";\n\n // Check content blocks/parts for format-specific markers\n if (Array.isArray(msg.content)) {\n for (const part of msg.content) {\n // Vercel: hyphenated types\n if (part.type === \"tool-call\" || part.type === \"tool-result\") return \"vercel\";\n // Anthropic: underscored types\n if (part.type === \"tool_use\" || part.type === \"tool_result\") return \"anthropic\";\n // Anthropic: image with source.type\n if (part.type === \"image\" && part.source?.type) return \"anthropic\";\n }\n }\n }\n\n // Default: OpenAI (simple {role, content: string} is valid OpenAI)\n return \"openai\";\n}\n\n// ============================================================\n// OpenAI ↔ OpenAI (passthrough)\n// ============================================================\n\n// No conversion needed — this is the canonical format.\n\n// ============================================================\n// Anthropic → OpenAI\n// ============================================================\n\n// Anthropic image block `source` -> OpenAI image_url url string, or null if unconvertible.\nfunction anthropicImageUrl(source: any): string | null {\n if (!source || typeof source !== \"object\") return null;\n if (source.type === \"base64\" && source.media_type && source.data) {\n return `data:${source.media_type};base64,${source.data}`;\n }\n if (source.type === \"url\" && typeof source.url === \"string\") return source.url;\n return null;\n}\n\n// OpenAI image_url url -> Anthropic image block. data: URIs become a base64 source; other urls a url source.\nfunction openAIImageBlock(url: string): any {\n const m = /^data:([^;,]+);base64,(.*)$/s.exec(url);\n if (m) return { type: \"image\", source: { type: \"base64\", media_type: m[1], data: m[2] } };\n return { type: \"image\", source: { type: \"url\", url } };\n}\n\nexport function anthropicToOpenAI(messages: any[]): OpenAIMessage[] {\n const result: OpenAIMessage[] = [];\n\n for (const msg of messages) {\n if (msg.role === \"user\") {\n if (typeof msg.content === \"string\") {\n result.push({ role: \"user\", content: msg.content });\n continue;\n }\n if (Array.isArray(msg.content)) {\n const toolResults = msg.content.filter((b: any) => b.type === \"tool_result\");\n const hasImages = msg.content.some((b: any) => b.type === \"image\");\n\n if (hasImages) {\n const parts = msg.content\n .filter((b: any) => b.type === \"text\" || b.type === \"image\")\n .map((b: any) => {\n if (b.type === \"text\") return { type: \"text\" as const, text: b.text };\n const url = anthropicImageUrl(b.source);\n return url ? { type: \"image_url\" as const, image_url: { url } } : null;\n })\n .filter((p: any): p is NonNullable => p !== null);\n\n if (parts.length > 0) {\n result.push({ role: \"user\", content: parts });\n }\n } else {\n const textBlocks = msg.content.filter((b: any) => b.type === \"text\");\n if (textBlocks.length > 0) {\n result.push({\n role: \"user\",\n content: textBlocks.map((b: any) => b.text).join(\"\\n\"),\n });\n }\n }\n for (const tr of toolResults) {\n const content = typeof tr.content === \"string\"\n ? tr.content\n : Array.isArray(tr.content)\n ? tr.content.map((b: any) => b.text ?? JSON.stringify(b)).join(\"\\n\")\n : JSON.stringify(tr.content);\n result.push({\n role: \"tool\",\n content,\n tool_call_id: tr.tool_use_id,\n });\n }\n }\n continue;\n }\n\n if (msg.role === \"assistant\") {\n if (typeof msg.content === \"string\") {\n result.push({ role: \"assistant\", content: msg.content });\n continue;\n }\n if (Array.isArray(msg.content)) {\n const textBlocks = msg.content.filter((b: any) => b.type === \"text\");\n const toolUseBlocks = msg.content.filter((b: any) => b.type === \"tool_use\");\n\n const content = textBlocks.length > 0\n ? textBlocks.map((b: any) => b.text).join(\"\\n\")\n : null;\n\n const openaiMsg: AssistantMessage = { role: \"assistant\", content };\n if (toolUseBlocks.length > 0) {\n openaiMsg.tool_calls = toolUseBlocks.map((b: any): ToolCall => ({\n id: b.id,\n type: \"function\",\n function: {\n name: b.name,\n arguments: typeof b.input === \"string\" ? b.input : JSON.stringify(b.input),\n },\n }));\n }\n result.push(openaiMsg);\n }\n continue;\n }\n }\n\n return result;\n}\n\nexport function openAIToAnthropic(messages: OpenAIMessage[]): any[] {\n const result: any[] = [];\n\n for (const msg of messages) {\n if (msg.role === \"system\") {\n // Anthropic system is top-level, but if it appears in messages, convert to user\n result.push({ role: \"user\", content: msg.content });\n continue;\n }\n\n if (msg.role === \"user\") {\n if (typeof msg.content === \"string\") {\n result.push({ role: \"user\", content: msg.content });\n } else if (Array.isArray(msg.content)) {\n result.push({\n role: \"user\",\n content: msg.content.map((p) => {\n if (p.type === \"text\") return { type: \"text\", text: p.text };\n if (p.type === \"image_url\") return openAIImageBlock(p.image_url.url);\n return { type: \"text\", text: \"\" };\n }),\n });\n }\n continue;\n }\n\n if (msg.role === \"assistant\") {\n const blocks: any[] = [];\n if (msg.content) blocks.push({ type: \"text\", text: msg.content });\n if (msg.tool_calls) {\n for (const tc of msg.tool_calls) {\n blocks.push({\n type: \"tool_use\",\n id: tc.id,\n name: tc.function.name,\n input: JSON.parse(tc.function.arguments),\n });\n }\n }\n result.push({\n role: \"assistant\",\n content: blocks.length === 1 && blocks[0].type === \"text\" ? blocks[0].text : blocks,\n });\n continue;\n }\n\n if (msg.role === \"tool\") {\n result.push({\n role: \"user\",\n content: [\n { type: \"tool_result\", tool_use_id: msg.tool_call_id, content: msg.content },\n ],\n });\n continue;\n }\n }\n\n return result;\n}\n\n// ============================================================\n// Vercel AI SDK → OpenAI\n// ============================================================\n\nexport function vercelToOpenAI(messages: any[]): OpenAIMessage[] {\n const result: OpenAIMessage[] = [];\n\n for (const msg of messages) {\n if (msg.role === \"system\") {\n result.push({ role: \"system\", content: typeof msg.content === \"string\" ? msg.content : String(msg.content) });\n continue;\n }\n\n if (msg.role === \"user\") {\n if (typeof msg.content === \"string\") {\n result.push({ role: \"user\", content: msg.content });\n continue;\n }\n const parts = Array.isArray(msg.content) ? msg.content : [];\n const textParts = parts.filter((p: any) => p.type === \"text\");\n const imageParts = parts.filter((p: any) => p.type === \"image\");\n\n if (imageParts.length === 0 && textParts.length > 0) {\n result.push({ role: \"user\", content: textParts.map((p: any) => p.text).join(\"\") });\n } else {\n const openaiParts = parts\n .filter((p: any) => p.type === \"text\" || p.type === \"image\")\n .map((p: any) => {\n if (p.type === \"text\") return { type: \"text\" as const, text: p.text };\n if (p.type === \"image\") {\n const url = p.image instanceof URL ? p.image.toString() : String(p.image);\n return { type: \"image_url\" as const, image_url: { url } };\n }\n return { type: \"text\" as const, text: \"\" };\n });\n result.push({ role: \"user\", content: openaiParts });\n }\n continue;\n }\n\n if (msg.role === \"assistant\") {\n if (typeof msg.content === \"string\") {\n result.push({ role: \"assistant\", content: msg.content });\n continue;\n }\n const parts = Array.isArray(msg.content) ? msg.content : [];\n const textParts = parts.filter((p: any) => p.type === \"text\");\n const toolCallParts = parts.filter((p: any) => p.type === \"tool-call\");\n\n const content = textParts.length > 0 ? textParts.map((p: any) => p.text).join(\"\") : null;\n const openaiMsg: AssistantMessage = { role: \"assistant\", content };\n\n if (toolCallParts.length > 0) {\n openaiMsg.tool_calls = toolCallParts.map((p: any): ToolCall => ({\n id: p.toolCallId,\n type: \"function\",\n // AI SDK v6 uses `input`, earlier versions used `args`\n function: { name: p.toolName, arguments: JSON.stringify(p.input ?? p.args) },\n }));\n }\n result.push(openaiMsg);\n continue;\n }\n\n if (msg.role === \"tool\") {\n const parts = Array.isArray(msg.content) ? msg.content : [];\n for (const part of parts) {\n if (part.type === \"tool-result\") {\n // AI SDK v6 uses `output: { type, value }`, earlier versions used `result`\n let contentStr: string;\n if (part.output !== undefined) {\n // v6 format: output is { type: 'json', value } or { type: 'text', value }\n const val = part.output?.value ?? part.output;\n contentStr = typeof val === \"string\" ? val : JSON.stringify(val);\n } else if (part.result !== undefined) {\n // Legacy format: result field directly\n contentStr = typeof part.result === \"string\" ? part.result : JSON.stringify(part.result);\n } else {\n contentStr = \"\";\n }\n result.push({\n role: \"tool\",\n content: contentStr,\n tool_call_id: part.toolCallId,\n });\n }\n }\n continue;\n }\n }\n\n return result;\n}\n\nexport function openAIToVercel(messages: OpenAIMessage[]): any[] {\n const result: any[] = [];\n\n for (const msg of messages) {\n if (msg.role === \"system\") {\n result.push({ role: \"system\", content: msg.content });\n continue;\n }\n\n if (msg.role === \"user\") {\n if (typeof msg.content === \"string\") {\n result.push({ role: \"user\", content: [{ type: \"text\", text: msg.content }] });\n } else if (Array.isArray(msg.content)) {\n const parts = msg.content.map((p) => {\n if (p.type === \"text\") return { type: \"text\", text: p.text };\n if (p.type === \"image_url\") return { type: \"image\", image: new URL(p.image_url.url) };\n return { type: \"text\", text: \"\" };\n });\n result.push({ role: \"user\", content: parts });\n }\n continue;\n }\n\n if (msg.role === \"assistant\") {\n const parts: any[] = [];\n if (msg.content) parts.push({ type: \"text\", text: msg.content });\n if (msg.tool_calls) {\n for (const tc of msg.tool_calls) {\n let input: any;\n try { input = JSON.parse(tc.function.arguments); } catch { input = tc.function.arguments ?? {}; }\n parts.push({\n type: \"tool-call\",\n toolCallId: tc.id,\n toolName: tc.function.name,\n input, // AI SDK v6 uses `input`, not `args`\n });\n }\n }\n result.push({ role: \"assistant\", content: parts });\n continue;\n }\n\n if (msg.role === \"tool\") {\n let parsed: any;\n try { parsed = JSON.parse(msg.content); } catch { parsed = msg.content; }\n // AI SDK v6 requires output: { type: 'json' | 'text', value }\n const output = typeof parsed === \"string\"\n ? { type: \"text\" as const, value: parsed }\n : { type: \"json\" as const, value: parsed };\n result.push({\n role: \"tool\",\n content: [{\n type: \"tool-result\",\n toolCallId: msg.tool_call_id,\n toolName: \"unknown\",\n output,\n }],\n });\n continue;\n }\n }\n\n return result;\n}\n\n// ============================================================\n// Google Gemini → OpenAI\n// ============================================================\n\nexport function geminiToOpenAI(messages: any[]): OpenAIMessage[] {\n const result: OpenAIMessage[] = [];\n\n for (const msg of messages) {\n const role = msg.role === \"model\" ? \"assistant\" : \"user\";\n const parts: any[] = msg.parts ?? [];\n\n if (role === \"user\") {\n // Check for functionResponse parts\n const funcResponses = parts.filter((p: any) => p.functionResponse);\n const textParts = parts.filter((p: any) => p.text !== undefined);\n\n if (textParts.length > 0) {\n result.push({ role: \"user\", content: textParts.map((p: any) => p.text).join(\"\\n\") });\n }\n for (const fr of funcResponses) {\n result.push({\n role: \"tool\",\n content: JSON.stringify(fr.functionResponse.response),\n tool_call_id: `gemini_${fr.functionResponse.name}`,\n });\n }\n continue;\n }\n\n if (role === \"assistant\") {\n const textParts = parts.filter((p: any) => p.text !== undefined);\n const funcCalls = parts.filter((p: any) => p.functionCall);\n\n const content = textParts.length > 0 ? textParts.map((p: any) => p.text).join(\"\\n\") : null;\n const openaiMsg: AssistantMessage = { role: \"assistant\", content };\n\n if (funcCalls.length > 0) {\n openaiMsg.tool_calls = funcCalls.map((p: any): ToolCall => ({\n id: `gemini_${p.functionCall.name}`,\n type: \"function\",\n function: {\n name: p.functionCall.name,\n arguments: JSON.stringify(p.functionCall.args),\n },\n }));\n }\n result.push(openaiMsg);\n continue;\n }\n }\n\n return result;\n}\n\nexport function openAIToGemini(messages: OpenAIMessage[]): any[] {\n const result: any[] = [];\n\n for (const msg of messages) {\n if (msg.role === \"system\") {\n // Gemini system is top-level; if in messages, convert to user\n result.push({ role: \"user\", parts: [{ text: msg.content }] });\n continue;\n }\n\n if (msg.role === \"user\") {\n const text = typeof msg.content === \"string\"\n ? msg.content\n : (msg.content ?? []).filter((p) => p.type === \"text\").map((p) => (p as any).text).join(\"\\n\");\n result.push({ role: \"user\", parts: [{ text }] });\n continue;\n }\n\n if (msg.role === \"assistant\") {\n const parts: any[] = [];\n if (msg.content) parts.push({ text: msg.content });\n if (msg.tool_calls) {\n for (const tc of msg.tool_calls) {\n parts.push({\n functionCall: { name: tc.function.name, args: JSON.parse(tc.function.arguments) },\n });\n }\n }\n result.push({ role: \"model\", parts });\n continue;\n }\n\n if (msg.role === \"tool\") {\n let response: any;\n try { response = JSON.parse(msg.content); } catch { response = { result: msg.content }; }\n result.push({\n role: \"user\",\n parts: [{ functionResponse: { name: msg.tool_call_id?.replace(\"gemini_\", \"\") ?? \"unknown\", response } }],\n });\n continue;\n }\n }\n\n return result;\n}\n\n// ============================================================\n// Universal: any format → OpenAI, and OpenAI → original format\n// ============================================================\n\nexport function toOpenAI(messages: any[]): OpenAIMessage[] {\n const format = detectFormat(messages);\n switch (format) {\n case \"openai\": return messages as OpenAIMessage[];\n case \"anthropic\": return anthropicToOpenAI(messages);\n case \"vercel\": return vercelToOpenAI(messages);\n case \"gemini\": return geminiToOpenAI(messages);\n }\n}\n\nexport function fromOpenAI(messages: OpenAIMessage[], targetFormat: MessageFormat): any[] {\n switch (targetFormat) {\n case \"openai\": return messages;\n case \"anthropic\": return openAIToAnthropic(messages);\n case \"vercel\": return openAIToVercel(messages);\n case \"gemini\": return openAIToGemini(messages);\n }\n}\n","/**\n * Compression hooks matching Python headroom.hooks.\n * Allows customizing compression behavior with pre/post hooks.\n */\n\nexport interface CompressContext {\n model: string;\n userQuery: string;\n turnNumber: number;\n toolCalls: string[];\n provider: string;\n}\n\nexport interface CompressEvent {\n tokensBefore: number;\n tokensAfter: number;\n tokensSaved: number;\n compressionRatio: number;\n transformsApplied: string[];\n ccrHashes: string[];\n model: string;\n userQuery: string;\n provider: string;\n}\n\n/**\n * Base class for compression hooks. Override methods to customize behavior.\n *\n * @example\n * ```typescript\n * class LoggingHooks extends CompressionHooks {\n * postCompress(event: CompressEvent) {\n * console.log(`Saved ${event.tokensSaved} tokens (${event.compressionRatio})`);\n * }\n * }\n * ```\n */\nexport class CompressionHooks {\n /**\n * Called before compression. Modify messages before they're sent to the proxy.\n */\n preCompress(\n messages: any[],\n _ctx: CompressContext,\n ): any[] | Promise {\n return messages;\n }\n\n /**\n * Compute per-message compression biases.\n * Return a map of message index -> bias (>1 = preserve more, <1 = compress more).\n */\n computeBiases(\n _messages: any[],\n _ctx: CompressContext,\n ): Record | Promise> {\n return {};\n }\n\n /**\n * Called after compression. Observe-only — cannot modify the result.\n */\n postCompress(_event: CompressEvent): void | Promise {\n // no-op default\n }\n}\n\n/**\n * Extract the last user message text from a messages array (any format).\n */\nexport function extractUserQuery(messages: any[]): string {\n for (let i = messages.length - 1; i >= 0; i--) {\n const msg = messages[i];\n if (msg.role === \"user\") {\n if (typeof msg.content === \"string\") return msg.content;\n if (Array.isArray(msg.content)) {\n const textPart = msg.content.find(\n (p: any) => p.type === \"text\" || p.text,\n );\n if (textPart) return textPart.text ?? textPart.content ?? \"\";\n }\n }\n }\n return \"\";\n}\n\n/**\n * Count conversation turns (user+assistant pairs).\n */\nexport function countTurns(messages: any[]): number {\n return messages.filter((m) => m.role === \"user\").length;\n}\n\n/**\n * Extract tool call names from messages.\n */\nexport function extractToolCalls(messages: any[]): string[] {\n const names: string[] = [];\n for (const msg of messages) {\n if (msg.tool_calls) {\n for (const tc of msg.tool_calls) {\n names.push(tc.function?.name ?? tc.name ?? \"unknown\");\n }\n }\n if (Array.isArray(msg.content)) {\n for (const part of msg.content) {\n if (part.type === \"tool_use\") names.push(part.name ?? \"unknown\");\n if (part.type === \"tool-call\") names.push(part.toolName ?? \"unknown\");\n }\n }\n }\n return names;\n}\n","/**\n * Universal compress function with hooks support.\n * Accepts messages in any format: OpenAI, Anthropic, Vercel AI SDK, or Google Gemini.\n */\n\nimport { HeadroomClient } from \"./client.js\";\nimport type { CompressResult, CompressOptions } from \"./types.js\";\nimport { detectFormat, toOpenAI, fromOpenAI } from \"./utils/format.js\";\nimport type { CompressionHooks, CompressContext, CompressEvent } from \"./hooks.js\";\nimport { extractUserQuery, countTurns, extractToolCalls } from \"./hooks.js\";\n\n/**\n * Compress an array of messages using the Headroom proxy.\n *\n * Accepts messages in any format: OpenAI, Anthropic, Vercel AI SDK, or Google Gemini.\n * Detects the format automatically, compresses via the proxy, and returns\n * compressed messages in the same format as the input.\n */\nexport async function compress(\n messages: any[],\n options: CompressOptions = {},\n): Promise {\n const {\n client: providedClient,\n model,\n tokenBudget,\n hooks,\n config,\n ...clientOptions\n } = options;\n\n // Build hook context\n const ctx: CompressContext = {\n model: model ?? \"gpt-4o\",\n userQuery: extractUserQuery(messages),\n turnNumber: countTurns(messages),\n toolCalls: extractToolCalls(messages),\n provider: \"\",\n };\n\n // 1. Pre-compress hook\n let processedMessages = messages;\n if (hooks) {\n processedMessages = await hooks.preCompress(messages, ctx);\n }\n\n // 2. Detect input format\n const inputFormat = detectFormat(processedMessages);\n\n // 3. Convert to OpenAI format (the proxy's lingua franca)\n const openaiMessages = toOpenAI(processedMessages);\n\n // 4. Compute biases\n let biases: Record = {};\n if (hooks) {\n biases = await hooks.computeBiases(openaiMessages, ctx);\n }\n\n // 5. Compress via proxy\n const client = providedClient ?? new HeadroomClient(clientOptions);\n const result = await client.compress(openaiMessages, { model, tokenBudget, config });\n\n // 6. Convert compressed messages back to original format\n const outputMessages = fromOpenAI(result.messages, inputFormat);\n\n const finalResult: CompressResult = {\n ...result,\n messages: outputMessages,\n };\n\n // 7. Post-compress hook\n if (hooks) {\n const event: CompressEvent = {\n tokensBefore: result.tokensBefore,\n tokensAfter: result.tokensAfter,\n tokensSaved: result.tokensSaved,\n compressionRatio: result.compressionRatio,\n transformsApplied: result.transformsApplied,\n ccrHashes: result.ccrHashes,\n model: ctx.model,\n userQuery: ctx.userQuery,\n provider: ctx.provider,\n };\n await hooks.postCompress(event);\n }\n\n return finalResult;\n}\n"]} \ No newline at end of file diff --git a/node_modules/headroom-ai/dist/index.cjs b/node_modules/headroom-ai/dist/index.cjs new file mode 100644 index 00000000..86a1d1d8 --- /dev/null +++ b/node_modules/headroom-ai/dist/index.cjs @@ -0,0 +1,458 @@ +'use strict'; + +var chunk26N6BVLT_cjs = require('./chunk-26N6BVLT.cjs'); + +// src/simulate.ts +async function simulate(messages, options = {}) { + const { client: providedClient, model, config, ...clientOptions } = options; + const openaiMessages = chunk26N6BVLT_cjs.toOpenAI(messages); + const client = providedClient ?? new chunk26N6BVLT_cjs.HeadroomClient(clientOptions); + const body = { + messages: openaiMessages, + model: model ?? "gpt-4o", + config: { + default_mode: "simulate", + generate_diff_artifact: true, + ...config ? chunk26N6BVLT_cjs.deepSnakeCase(config) : {} + } + }; + const result = await client.compressRaw(body); + return chunk26N6BVLT_cjs.deepCamelCase(result); +} + +// src/shared-context.ts +var SharedContext = class { + entries = /* @__PURE__ */ new Map(); + client; + model; + ttl; + maxEntries; + constructor(options = {}) { + const { model, ttl, maxEntries, ...clientOptions } = options; + this.client = new chunk26N6BVLT_cjs.HeadroomClient(clientOptions); + this.model = model ?? "claude-sonnet-4-5-20250929"; + this.ttl = ttl ?? 3600; + this.maxEntries = maxEntries ?? 100; + } + /** + * Store content with compression. + */ + async put(key, content, options) { + this.evictExpired(); + this.evictIfFull(); + const messages = [{ role: "user", content }]; + let result; + try { + result = await this.client.compress(messages, { model: this.model }); + } catch { + result = { + messages, + tokensBefore: content.length / 4, + // rough estimate + tokensAfter: content.length / 4, + tokensSaved: 0, + compressionRatio: 1, + transformsApplied: [], + ccrHashes: [], + compressed: false + }; + } + const compressed = result.compressed ? typeof result.messages[0]?.content === "string" ? result.messages[0].content : JSON.stringify(result.messages[0]?.content ?? content) : content; + const entry = { + key, + original: content, + compressed, + originalTokens: result.tokensBefore, + compressedTokens: result.tokensAfter, + agent: options?.agent ?? null, + timestamp: Date.now() / 1e3, + transforms: result.transformsApplied, + savingsPercent: result.tokensBefore > 0 ? (result.tokensBefore - result.tokensAfter) / result.tokensBefore * 100 : 0 + }; + this.entries.set(key, entry); + return entry; + } + /** + * Get content by key. Returns compressed by default, full original if full=true. + */ + get(key, options) { + const entry = this.entries.get(key); + if (!entry) return null; + if (Date.now() / 1e3 - entry.timestamp > this.ttl) { + this.entries.delete(key); + return null; + } + return options?.full ? entry.original : entry.compressed; + } + /** + * Get full entry metadata. + */ + getEntry(key) { + const entry = this.entries.get(key); + if (!entry) return null; + if (Date.now() / 1e3 - entry.timestamp > this.ttl) { + this.entries.delete(key); + return null; + } + return entry; + } + /** + * List all stored keys (excluding expired). + */ + keys() { + this.evictExpired(); + return Array.from(this.entries.keys()); + } + /** + * Get aggregated statistics. + */ + stats() { + this.evictExpired(); + let totalOriginal = 0; + let totalCompressed = 0; + for (const entry of this.entries.values()) { + totalOriginal += entry.originalTokens; + totalCompressed += entry.compressedTokens; + } + const saved = totalOriginal - totalCompressed; + return { + entries: this.entries.size, + totalOriginalTokens: totalOriginal, + totalCompressedTokens: totalCompressed, + totalTokensSaved: saved, + savingsPercent: totalOriginal > 0 ? saved / totalOriginal * 100 : 0 + }; + } + /** + * Clear all entries. + */ + clear() { + this.entries.clear(); + } + evictExpired() { + const now = Date.now() / 1e3; + for (const [key, entry] of this.entries) { + if (now - entry.timestamp > this.ttl) { + this.entries.delete(key); + } + } + } + evictIfFull() { + while (this.entries.size >= this.maxEntries) { + const oldest = this.entries.keys().next().value; + if (oldest !== void 0) this.entries.delete(oldest); + } + } +}; + +// src/paths.ts +var HEADROOM_CONFIG_DIR_ENV = "HEADROOM_CONFIG_DIR"; +var HEADROOM_WORKSPACE_DIR_ENV = "HEADROOM_WORKSPACE_DIR"; +var HEADROOM_SAVINGS_PATH_ENV = "HEADROOM_SAVINGS_PATH"; +var HEADROOM_TOIN_PATH_ENV = "HEADROOM_TOIN_PATH"; +var HEADROOM_SUBSCRIPTION_STATE_PATH_ENV = "HEADROOM_SUBSCRIPTION_STATE_PATH"; +function isNode() { + return typeof process !== "undefined" && typeof process.versions !== "undefined" && typeof process.versions.node === "string"; +} +function getEnv(name) { + if (typeof process === "undefined" || !process.env) { + return ""; + } + const value = process.env[name]; + return typeof value === "string" ? value.trim() : ""; +} +function homeDir() { + if (!isNode()) { + return ""; + } + try { + const os = chunk26N6BVLT_cjs.__require("os"); + if (os.homedir) { + const home = os.homedir(); + if (home) return home; + } + } catch { + } + return getEnv("HOME") || getEnv("USERPROFILE") || ""; +} +function joinPath(...parts) { + if (!isNode()) { + return parts.filter((p) => p !== "").join("/"); + } + try { + const path = chunk26N6BVLT_cjs.__require("path"); + if (path.join) return path.join(...parts.filter((p) => p !== "")); + } catch { + } + const sep = process.platform === "win32" ? "\\" : "/"; + return parts.filter((p) => p !== "").join(sep); +} +function expandTilde(p) { + if (!p.startsWith("~")) return p; + const home = homeDir(); + if (!home) return p; + if (p === "~") return home; + if (p.startsWith("~/") || p.startsWith("~\\")) { + return joinPath(home, p.slice(2)); + } + return p; +} +function resolve(explicit, envVar, derived) { + if (!isNode()) { + return ""; + } + if (explicit !== void 0 && explicit !== "") { + return expandTilde(explicit); + } + const envValue = getEnv(envVar); + if (envValue) { + return expandTilde(envValue); + } + return derived; +} +function workspaceDir() { + if (!isNode()) return ""; + const envValue = getEnv(HEADROOM_WORKSPACE_DIR_ENV); + if (envValue) return expandTilde(envValue); + const home = homeDir(); + if (!home) return ""; + return joinPath(home, ".headroom"); +} +function configDir() { + if (!isNode()) return ""; + const envValue = getEnv(HEADROOM_CONFIG_DIR_ENV); + if (envValue) return expandTilde(envValue); + const workspaceEnv = getEnv(HEADROOM_WORKSPACE_DIR_ENV); + if (workspaceEnv) { + return joinPath(expandTilde(workspaceEnv), "config"); + } + const home = homeDir(); + if (!home) return ""; + return joinPath(home, ".headroom", "config"); +} +function savingsPath(explicit) { + return resolve( + explicit, + HEADROOM_SAVINGS_PATH_ENV, + joinPath(workspaceDir(), "proxy_savings.json") + ); +} +function toinPath(explicit) { + return resolve( + explicit, + HEADROOM_TOIN_PATH_ENV, + joinPath(workspaceDir(), "toin.json") + ); +} +function subscriptionStatePath(explicit) { + return resolve( + explicit, + HEADROOM_SUBSCRIPTION_STATE_PATH_ENV, + joinPath(workspaceDir(), "subscription_state.json") + ); +} +function memoryDbPath() { + if (!isNode()) return ""; + return joinPath(workspaceDir(), "memory.db"); +} +function nativeMemoryDir() { + if (!isNode()) return ""; + return joinPath(workspaceDir(), "memories"); +} +function licenseCachePath() { + if (!isNode()) return ""; + return joinPath(workspaceDir(), "license_cache.json"); +} +function sessionStatsPath() { + if (!isNode()) return ""; + return joinPath(workspaceDir(), "session_stats.jsonl"); +} +function syncStatePath() { + if (!isNode()) return ""; + return joinPath(workspaceDir(), "sync_state.json"); +} +function bridgeStatePath() { + if (!isNode()) return ""; + return joinPath(workspaceDir(), "bridge_state.json"); +} +function logDir() { + if (!isNode()) return ""; + return joinPath(workspaceDir(), "logs"); +} +function proxyLogPath() { + if (!isNode()) return ""; + return joinPath(logDir(), "proxy.log"); +} +function debug400Dir() { + if (!isNode()) return ""; + return joinPath(logDir(), "debug_400"); +} +function binDir() { + if (!isNode()) return ""; + return joinPath(workspaceDir(), "bin"); +} +function deployRoot() { + if (!isNode()) return ""; + return joinPath(workspaceDir(), "deploy"); +} +function beaconLockPath(port) { + if (!isNode()) return ""; + return joinPath(workspaceDir(), `.beacon_lock_${Math.trunc(port)}`); +} +function modelsConfigPath() { + if (!isNode()) return ""; + return joinPath(configDir(), "models.json"); +} +function assertPluginName(name) { + if (!name || name.includes("/") || name.includes("\\")) { + throw new Error(`invalid plugin name: ${JSON.stringify(name)}`); + } +} +function pluginConfigDir(pluginName) { + assertPluginName(pluginName); + if (!isNode()) return ""; + return joinPath(configDir(), "plugins", pluginName); +} +function pluginWorkspaceDir(pluginName) { + assertPluginName(pluginName); + if (!isNode()) return ""; + return joinPath(workspaceDir(), "plugins", pluginName); +} + +Object.defineProperty(exports, "CacheError", { + enumerable: true, + get: function () { return chunk26N6BVLT_cjs.CacheError; } +}); +Object.defineProperty(exports, "CompressionHooks", { + enumerable: true, + get: function () { return chunk26N6BVLT_cjs.CompressionHooks; } +}); +Object.defineProperty(exports, "ConfigurationError", { + enumerable: true, + get: function () { return chunk26N6BVLT_cjs.ConfigurationError; } +}); +Object.defineProperty(exports, "HeadroomAuthError", { + enumerable: true, + get: function () { return chunk26N6BVLT_cjs.HeadroomAuthError; } +}); +Object.defineProperty(exports, "HeadroomClient", { + enumerable: true, + get: function () { return chunk26N6BVLT_cjs.HeadroomClient; } +}); +Object.defineProperty(exports, "HeadroomCompressError", { + enumerable: true, + get: function () { return chunk26N6BVLT_cjs.HeadroomCompressError; } +}); +Object.defineProperty(exports, "HeadroomConnectionError", { + enumerable: true, + get: function () { return chunk26N6BVLT_cjs.HeadroomConnectionError; } +}); +Object.defineProperty(exports, "HeadroomError", { + enumerable: true, + get: function () { return chunk26N6BVLT_cjs.HeadroomError; } +}); +Object.defineProperty(exports, "ProviderError", { + enumerable: true, + get: function () { return chunk26N6BVLT_cjs.ProviderError; } +}); +Object.defineProperty(exports, "StorageError", { + enumerable: true, + get: function () { return chunk26N6BVLT_cjs.StorageError; } +}); +Object.defineProperty(exports, "TokenizationError", { + enumerable: true, + get: function () { return chunk26N6BVLT_cjs.TokenizationError; } +}); +Object.defineProperty(exports, "TransformError", { + enumerable: true, + get: function () { return chunk26N6BVLT_cjs.TransformError; } +}); +Object.defineProperty(exports, "ValidationError", { + enumerable: true, + get: function () { return chunk26N6BVLT_cjs.ValidationError; } +}); +Object.defineProperty(exports, "camelToSnake", { + enumerable: true, + get: function () { return chunk26N6BVLT_cjs.camelToSnake; } +}); +Object.defineProperty(exports, "collectStream", { + enumerable: true, + get: function () { return chunk26N6BVLT_cjs.collectStream; } +}); +Object.defineProperty(exports, "compress", { + enumerable: true, + get: function () { return chunk26N6BVLT_cjs.compress; } +}); +Object.defineProperty(exports, "countTurns", { + enumerable: true, + get: function () { return chunk26N6BVLT_cjs.countTurns; } +}); +Object.defineProperty(exports, "deepCamelCase", { + enumerable: true, + get: function () { return chunk26N6BVLT_cjs.deepCamelCase; } +}); +Object.defineProperty(exports, "deepSnakeCase", { + enumerable: true, + get: function () { return chunk26N6BVLT_cjs.deepSnakeCase; } +}); +Object.defineProperty(exports, "detectFormat", { + enumerable: true, + get: function () { return chunk26N6BVLT_cjs.detectFormat; } +}); +Object.defineProperty(exports, "extractToolCalls", { + enumerable: true, + get: function () { return chunk26N6BVLT_cjs.extractToolCalls; } +}); +Object.defineProperty(exports, "extractUserQuery", { + enumerable: true, + get: function () { return chunk26N6BVLT_cjs.extractUserQuery; } +}); +Object.defineProperty(exports, "fromOpenAI", { + enumerable: true, + get: function () { return chunk26N6BVLT_cjs.fromOpenAI; } +}); +Object.defineProperty(exports, "mapProxyError", { + enumerable: true, + get: function () { return chunk26N6BVLT_cjs.mapProxyError; } +}); +Object.defineProperty(exports, "parseSSE", { + enumerable: true, + get: function () { return chunk26N6BVLT_cjs.parseSSE; } +}); +Object.defineProperty(exports, "snakeToCamel", { + enumerable: true, + get: function () { return chunk26N6BVLT_cjs.snakeToCamel; } +}); +Object.defineProperty(exports, "toOpenAI", { + enumerable: true, + get: function () { return chunk26N6BVLT_cjs.toOpenAI; } +}); +exports.HEADROOM_CONFIG_DIR_ENV = HEADROOM_CONFIG_DIR_ENV; +exports.HEADROOM_SAVINGS_PATH_ENV = HEADROOM_SAVINGS_PATH_ENV; +exports.HEADROOM_SUBSCRIPTION_STATE_PATH_ENV = HEADROOM_SUBSCRIPTION_STATE_PATH_ENV; +exports.HEADROOM_TOIN_PATH_ENV = HEADROOM_TOIN_PATH_ENV; +exports.HEADROOM_WORKSPACE_DIR_ENV = HEADROOM_WORKSPACE_DIR_ENV; +exports.SharedContext = SharedContext; +exports.beaconLockPath = beaconLockPath; +exports.binDir = binDir; +exports.bridgeStatePath = bridgeStatePath; +exports.configDir = configDir; +exports.debug400Dir = debug400Dir; +exports.deployRoot = deployRoot; +exports.licenseCachePath = licenseCachePath; +exports.logDir = logDir; +exports.memoryDbPath = memoryDbPath; +exports.modelsConfigPath = modelsConfigPath; +exports.nativeMemoryDir = nativeMemoryDir; +exports.pluginConfigDir = pluginConfigDir; +exports.pluginWorkspaceDir = pluginWorkspaceDir; +exports.proxyLogPath = proxyLogPath; +exports.savingsPath = savingsPath; +exports.sessionStatsPath = sessionStatsPath; +exports.simulate = simulate; +exports.subscriptionStatePath = subscriptionStatePath; +exports.syncStatePath = syncStatePath; +exports.toinPath = toinPath; +exports.workspaceDir = workspaceDir; +//# sourceMappingURL=index.cjs.map +//# sourceMappingURL=index.cjs.map \ No newline at end of file diff --git a/node_modules/headroom-ai/dist/index.cjs.map b/node_modules/headroom-ai/dist/index.cjs.map new file mode 100644 index 00000000..6c2825e2 --- /dev/null +++ b/node_modules/headroom-ai/dist/index.cjs.map @@ -0,0 +1 @@ +{"version":3,"sources":["../src/simulate.ts","../src/shared-context.ts","../src/paths.ts"],"names":["toOpenAI","HeadroomClient","deepSnakeCase","deepCamelCase","__require"],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * Simulation API — dry-run compression to see what would happen.\n * Matches Python client.simulate() behavior.\n */\n\nimport { HeadroomClient } from \"./client.js\";\nimport type { HeadroomClientOptions } from \"./types.js\";\nimport type { SimulationResult } from \"./types/models.js\";\nimport type { HeadroomConfig } from \"./types/config.js\";\nimport { deepCamelCase, deepSnakeCase } from \"./utils/case.js\";\nimport { detectFormat, toOpenAI } from \"./utils/format.js\";\n\nexport interface SimulateOptions extends HeadroomClientOptions {\n model?: string;\n config?: HeadroomConfig;\n client?: HeadroomClient;\n}\n\n/**\n * Simulate compression without calling the LLM.\n * Shows what compression would do: token savings, transforms, waste signals.\n *\n * @example\n * ```typescript\n * const sim = await simulate(messages, { model: 'gpt-4o' });\n * console.log(`Would save ${sim.tokensSaved} tokens (${sim.estimatedSavings})`);\n * console.log('Transforms:', sim.transforms);\n * ```\n */\nexport async function simulate(\n messages: any[],\n options: SimulateOptions = {},\n): Promise {\n const { client: providedClient, model, config, ...clientOptions } = options;\n\n const openaiMessages = toOpenAI(messages);\n const client = providedClient ?? new HeadroomClient(clientOptions);\n\n const body: Record = {\n messages: openaiMessages,\n model: model ?? \"gpt-4o\",\n config: {\n default_mode: \"simulate\",\n generate_diff_artifact: true,\n ...(config ? deepSnakeCase(config) : {}),\n },\n };\n\n // Use the client's internal fetch to hit /v1/compress with simulation config\n const result = await client.compressRaw(body);\n return deepCamelCase(result);\n}\n","/**\n * SharedContext — compressed inter-agent context sharing.\n * Matches Python headroom.shared_context.SharedContext.\n */\n\nimport { HeadroomClient } from \"./client.js\";\nimport type { HeadroomClientOptions, CompressResult } from \"./types.js\";\n\nexport interface ContextEntry {\n key: string;\n original: string;\n compressed: string;\n originalTokens: number;\n compressedTokens: number;\n agent: string | null;\n timestamp: number;\n transforms: string[];\n savingsPercent: number;\n}\n\nexport interface SharedContextStats {\n entries: number;\n totalOriginalTokens: number;\n totalCompressedTokens: number;\n totalTokensSaved: number;\n savingsPercent: number;\n}\n\nexport interface SharedContextOptions extends HeadroomClientOptions {\n model?: string;\n ttl?: number;\n maxEntries?: number;\n}\n\nexport class SharedContext {\n private entries = new Map();\n private client: HeadroomClient;\n private model: string;\n private ttl: number;\n private maxEntries: number;\n\n constructor(options: SharedContextOptions = {}) {\n const { model, ttl, maxEntries, ...clientOptions } = options;\n this.client = new HeadroomClient(clientOptions);\n this.model = model ?? \"claude-sonnet-4-5-20250929\";\n this.ttl = ttl ?? 3600;\n this.maxEntries = maxEntries ?? 100;\n }\n\n /**\n * Store content with compression.\n */\n async put(\n key: string,\n content: string,\n options?: { agent?: string },\n ): Promise {\n this.evictExpired();\n this.evictIfFull();\n\n const messages = [{ role: \"user\" as const, content }];\n let result: CompressResult;\n try {\n result = await this.client.compress(messages, { model: this.model });\n } catch {\n // If proxy unavailable, store uncompressed\n result = {\n messages,\n tokensBefore: content.length / 4, // rough estimate\n tokensAfter: content.length / 4,\n tokensSaved: 0,\n compressionRatio: 1.0,\n transformsApplied: [],\n ccrHashes: [],\n compressed: false,\n };\n }\n\n const compressed = result.compressed\n ? typeof result.messages[0]?.content === \"string\"\n ? result.messages[0].content\n : JSON.stringify(result.messages[0]?.content ?? content)\n : content;\n\n const entry: ContextEntry = {\n key,\n original: content,\n compressed,\n originalTokens: result.tokensBefore,\n compressedTokens: result.tokensAfter,\n agent: options?.agent ?? null,\n timestamp: Date.now() / 1000,\n transforms: result.transformsApplied,\n savingsPercent:\n result.tokensBefore > 0\n ? ((result.tokensBefore - result.tokensAfter) / result.tokensBefore) *\n 100\n : 0,\n };\n\n this.entries.set(key, entry);\n return entry;\n }\n\n /**\n * Get content by key. Returns compressed by default, full original if full=true.\n */\n get(key: string, options?: { full?: boolean }): string | null {\n const entry = this.entries.get(key);\n if (!entry) return null;\n\n if (Date.now() / 1000 - entry.timestamp > this.ttl) {\n this.entries.delete(key);\n return null;\n }\n\n return options?.full ? entry.original : entry.compressed;\n }\n\n /**\n * Get full entry metadata.\n */\n getEntry(key: string): ContextEntry | null {\n const entry = this.entries.get(key);\n if (!entry) return null;\n\n if (Date.now() / 1000 - entry.timestamp > this.ttl) {\n this.entries.delete(key);\n return null;\n }\n\n return entry;\n }\n\n /**\n * List all stored keys (excluding expired).\n */\n keys(): string[] {\n this.evictExpired();\n return Array.from(this.entries.keys());\n }\n\n /**\n * Get aggregated statistics.\n */\n stats(): SharedContextStats {\n this.evictExpired();\n let totalOriginal = 0;\n let totalCompressed = 0;\n\n for (const entry of this.entries.values()) {\n totalOriginal += entry.originalTokens;\n totalCompressed += entry.compressedTokens;\n }\n\n const saved = totalOriginal - totalCompressed;\n return {\n entries: this.entries.size,\n totalOriginalTokens: totalOriginal,\n totalCompressedTokens: totalCompressed,\n totalTokensSaved: saved,\n savingsPercent: totalOriginal > 0 ? (saved / totalOriginal) * 100 : 0,\n };\n }\n\n /**\n * Clear all entries.\n */\n clear(): void {\n this.entries.clear();\n }\n\n private evictExpired(): void {\n const now = Date.now() / 1000;\n for (const [key, entry] of this.entries) {\n if (now - entry.timestamp > this.ttl) {\n this.entries.delete(key);\n }\n }\n }\n\n private evictIfFull(): void {\n while (this.entries.size >= this.maxEntries) {\n const oldest = this.entries.keys().next().value;\n if (oldest !== undefined) this.entries.delete(oldest);\n }\n }\n}\n","/**\n * Canonical filesystem contract for Headroom — parity shell for the npm SDK.\n *\n * The TypeScript SDK is an HTTP client today and does not touch the\n * filesystem directly. This module mirrors `headroom/paths.py` so that\n * future local features (e.g. cache/log co-location with the Python\n * proxy) land on the same contract.\n *\n * Two canonical roots:\n * - HEADROOM_CONFIG_DIR — read-mostly configuration\n * (default: ~/.headroom/config)\n * - HEADROOM_WORKSPACE_DIR — read-write state\n * (default: ~/.headroom)\n *\n * Precedence for every per-resource helper is:\n * explicit argument > per-resource env var > derived from canonical\n * root > default.\n *\n * Browser behavior: when `process` is not available (typeof process ===\n * \"undefined\"), all helpers return the empty string. Consumers running\n * in a browser should not call these helpers; they exist here so the\n * shape of the API matches Python's `headroom.paths` module.\n */\n\n// ---------------------------------------------------------------------------\n// Env var names\n// ---------------------------------------------------------------------------\n\nexport const HEADROOM_CONFIG_DIR_ENV = \"HEADROOM_CONFIG_DIR\";\nexport const HEADROOM_WORKSPACE_DIR_ENV = \"HEADROOM_WORKSPACE_DIR\";\n\nexport const HEADROOM_SAVINGS_PATH_ENV = \"HEADROOM_SAVINGS_PATH\";\nexport const HEADROOM_TOIN_PATH_ENV = \"HEADROOM_TOIN_PATH\";\nexport const HEADROOM_SUBSCRIPTION_STATE_PATH_ENV =\n \"HEADROOM_SUBSCRIPTION_STATE_PATH\";\n\n// ---------------------------------------------------------------------------\n// Node / browser guard (mirrors the pattern in client.ts::getEnv)\n// ---------------------------------------------------------------------------\n\nfunction isNode(): boolean {\n return (\n typeof process !== \"undefined\" &&\n typeof process.versions !== \"undefined\" &&\n typeof process.versions.node === \"string\"\n );\n}\n\nfunction getEnv(name: string): string {\n if (typeof process === \"undefined\" || !process.env) {\n return \"\";\n }\n const value = process.env[name];\n return typeof value === \"string\" ? value.trim() : \"\";\n}\n\nfunction homeDir(): string {\n if (!isNode()) {\n return \"\";\n }\n // Prefer the userInfo API, fall back to HOME / USERPROFILE env vars.\n try {\n // eslint-disable-next-line @typescript-eslint/no-var-requires\n const os = require(\"os\") as { homedir?: () => string };\n if (os.homedir) {\n const home = os.homedir();\n if (home) return home;\n }\n } catch {\n // ignore — fall back to env\n }\n return getEnv(\"HOME\") || getEnv(\"USERPROFILE\") || \"\";\n}\n\nfunction joinPath(...parts: string[]): string {\n if (!isNode()) {\n // Browser fallback — keep it simple, use forward slashes.\n return parts.filter((p) => p !== \"\").join(\"/\");\n }\n try {\n // eslint-disable-next-line @typescript-eslint/no-var-requires\n const path = require(\"path\") as {\n join?: (...p: string[]) => string;\n };\n if (path.join) return path.join(...parts.filter((p) => p !== \"\"));\n } catch {\n // ignore — fall back\n }\n const sep = process.platform === \"win32\" ? \"\\\\\" : \"/\";\n return parts.filter((p) => p !== \"\").join(sep);\n}\n\nfunction expandTilde(p: string): string {\n if (!p.startsWith(\"~\")) return p;\n const home = homeDir();\n if (!home) return p;\n if (p === \"~\") return home;\n if (p.startsWith(\"~/\") || p.startsWith(\"~\\\\\")) {\n return joinPath(home, p.slice(2));\n }\n return p;\n}\n\nfunction resolve(\n explicit: string | undefined,\n envVar: string,\n derived: string,\n): string {\n if (!isNode()) {\n // Browser fallback: no filesystem, return empty string.\n return \"\";\n }\n if (explicit !== undefined && explicit !== \"\") {\n return expandTilde(explicit);\n }\n const envValue = getEnv(envVar);\n if (envValue) {\n return expandTilde(envValue);\n }\n return derived;\n}\n\n// ---------------------------------------------------------------------------\n// Canonical roots\n// ---------------------------------------------------------------------------\n\nexport function workspaceDir(): string {\n if (!isNode()) return \"\";\n const envValue = getEnv(HEADROOM_WORKSPACE_DIR_ENV);\n if (envValue) return expandTilde(envValue);\n const home = homeDir();\n if (!home) return \"\";\n return joinPath(home, \".headroom\");\n}\n\nexport function configDir(): string {\n if (!isNode()) return \"\";\n const envValue = getEnv(HEADROOM_CONFIG_DIR_ENV);\n if (envValue) return expandTilde(envValue);\n const workspaceEnv = getEnv(HEADROOM_WORKSPACE_DIR_ENV);\n if (workspaceEnv) {\n return joinPath(expandTilde(workspaceEnv), \"config\");\n }\n const home = homeDir();\n if (!home) return \"\";\n return joinPath(home, \".headroom\", \"config\");\n}\n\n// ---------------------------------------------------------------------------\n// Per-resource helpers -- workspace bucket\n// ---------------------------------------------------------------------------\n\nexport function savingsPath(explicit?: string): string {\n return resolve(\n explicit,\n HEADROOM_SAVINGS_PATH_ENV,\n joinPath(workspaceDir(), \"proxy_savings.json\"),\n );\n}\n\nexport function toinPath(explicit?: string): string {\n return resolve(\n explicit,\n HEADROOM_TOIN_PATH_ENV,\n joinPath(workspaceDir(), \"toin.json\"),\n );\n}\n\nexport function subscriptionStatePath(explicit?: string): string {\n return resolve(\n explicit,\n HEADROOM_SUBSCRIPTION_STATE_PATH_ENV,\n joinPath(workspaceDir(), \"subscription_state.json\"),\n );\n}\n\nexport function memoryDbPath(): string {\n if (!isNode()) return \"\";\n return joinPath(workspaceDir(), \"memory.db\");\n}\n\nexport function nativeMemoryDir(): string {\n if (!isNode()) return \"\";\n return joinPath(workspaceDir(), \"memories\");\n}\n\nexport function licenseCachePath(): string {\n if (!isNode()) return \"\";\n return joinPath(workspaceDir(), \"license_cache.json\");\n}\n\nexport function sessionStatsPath(): string {\n if (!isNode()) return \"\";\n return joinPath(workspaceDir(), \"session_stats.jsonl\");\n}\n\nexport function syncStatePath(): string {\n if (!isNode()) return \"\";\n return joinPath(workspaceDir(), \"sync_state.json\");\n}\n\nexport function bridgeStatePath(): string {\n if (!isNode()) return \"\";\n return joinPath(workspaceDir(), \"bridge_state.json\");\n}\n\nexport function logDir(): string {\n if (!isNode()) return \"\";\n return joinPath(workspaceDir(), \"logs\");\n}\n\nexport function proxyLogPath(): string {\n if (!isNode()) return \"\";\n return joinPath(logDir(), \"proxy.log\");\n}\n\nexport function debug400Dir(): string {\n if (!isNode()) return \"\";\n return joinPath(logDir(), \"debug_400\");\n}\n\nexport function binDir(): string {\n if (!isNode()) return \"\";\n return joinPath(workspaceDir(), \"bin\");\n}\n\nexport function deployRoot(): string {\n if (!isNode()) return \"\";\n return joinPath(workspaceDir(), \"deploy\");\n}\n\nexport function beaconLockPath(port: number): string {\n if (!isNode()) return \"\";\n return joinPath(workspaceDir(), `.beacon_lock_${Math.trunc(port)}`);\n}\n\n// ---------------------------------------------------------------------------\n// Per-resource helpers -- config bucket\n// ---------------------------------------------------------------------------\n\nexport function modelsConfigPath(): string {\n if (!isNode()) return \"\";\n return joinPath(configDir(), \"models.json\");\n}\n\n// ---------------------------------------------------------------------------\n// Plugin-author entry points\n// ---------------------------------------------------------------------------\n\nfunction assertPluginName(name: string): void {\n if (!name || name.includes(\"/\") || name.includes(\"\\\\\")) {\n throw new Error(`invalid plugin name: ${JSON.stringify(name)}`);\n }\n}\n\nexport function pluginConfigDir(pluginName: string): string {\n assertPluginName(pluginName);\n if (!isNode()) return \"\";\n return joinPath(configDir(), \"plugins\", pluginName);\n}\n\nexport function pluginWorkspaceDir(pluginName: string): string {\n assertPluginName(pluginName);\n if (!isNode()) return \"\";\n return joinPath(workspaceDir(), \"plugins\", pluginName);\n}\n"]} \ No newline at end of file diff --git a/node_modules/headroom-ai/dist/index.d.cts b/node_modules/headroom-ai/dist/index.d.cts new file mode 100644 index 00000000..9f65381f --- /dev/null +++ b/node_modules/headroom-ai/dist/index.d.cts @@ -0,0 +1,708 @@ +import { C as CompressOptions, a as CompressResult, B as BlockKind, H as HeadroomClientOptions, b as HeadroomMode, c as CompressRequestConfig, d as HeadroomClientInterface, O as OpenAIMessage, e as HeadroomConfig } from './types-Cjdl75F4.cjs'; +export { A as AnchorConfig, f as AssistantMessage, g as CCRConfig, h as CacheAlignerConfig, i as CacheOptimizerConfig, j as CompressContext, k as CompressEvent, l as CompressMode, m as CompressionHooks, n as CompressionProfile, o as ContentPart, p as ContentType, I as ImageContentPart, q as IntelligentContextConfig, P as PrefixFreezeConfig, R as ReadLifecycleConfig, r as RelevanceScorerConfig, s as RelevanceTier, t as RollingWindowConfig, S as ScoringWeights, u as SmartCrusherConfig, v as SystemMessage, T as TextContentPart, w as ToolCall, x as ToolCrusherConfig, y as ToolMessage, U as UserMessage, z as countTurns, D as extractToolCalls, E as extractUserQuery } from './types-Cjdl75F4.cjs'; + +/** + * Error hierarchy matching Python headroom.exceptions. + */ +declare class HeadroomError extends Error { + details?: Record; + constructor(message: string, details?: Record); +} +declare class HeadroomConnectionError extends HeadroomError { + constructor(message: string, details?: Record); +} +declare class HeadroomAuthError extends HeadroomError { + constructor(message: string, details?: Record); +} +declare class HeadroomCompressError extends HeadroomError { + statusCode: number; + errorType: string; + constructor(statusCode: number, errorType: string, message: string, details?: Record); +} +declare class ConfigurationError extends HeadroomError { + constructor(message: string, details?: Record); +} +declare class ProviderError extends HeadroomError { + constructor(message: string, details?: Record); +} +declare class StorageError extends HeadroomError { + constructor(message: string, details?: Record); +} +declare class TokenizationError extends HeadroomError { + constructor(message: string, details?: Record); +} +declare class CacheError extends HeadroomError { + constructor(message: string, details?: Record); +} +declare class ValidationError extends HeadroomError { + constructor(message: string, details?: Record); +} +declare class TransformError extends HeadroomError { + constructor(message: string, details?: Record); +} +/** + * Map a proxy error response to the correct HeadroomError subclass. + */ +declare function mapProxyError(status: number, type: string, message: string): HeadroomError; + +/** + * Universal compress function with hooks support. + * Accepts messages in any format: OpenAI, Anthropic, Vercel AI SDK, or Google Gemini. + */ + +/** + * Compress an array of messages using the Headroom proxy. + * + * Accepts messages in any format: OpenAI, Anthropic, Vercel AI SDK, or Google Gemini. + * Detects the format automatically, compresses via the proxy, and returns + * compressed messages in the same format as the input. + */ +declare function compress(messages: any[], options?: CompressOptions): Promise; + +/** + * Data models matching Python headroom.config result/metrics dataclasses. + */ + +interface WasteSignals { + jsonBloatTokens: number; + htmlNoiseTokens: number; + base64Tokens: number; + whitespaceTokens: number; + dynamicDateTokens: number; + repetitionTokens: number; + total: number; +} +interface CachePrefixMetrics { + stablePrefixBytes: number; + stablePrefixTokensEst: number; + stablePrefixHash: string; + prefixChanged: boolean; + previousHash?: string | null; +} +interface TransformDiff { + transformName: string; + tokensBefore: number; + tokensAfter: number; + tokensSaved: number; + itemsRemoved: number; + itemsKept: number; + details: string; + durationMs: number; +} +interface DiffArtifact { + requestId: string; + originalTokens: number; + optimizedTokens: number; + totalTokensSaved: number; + transforms: TransformDiff[]; +} +interface SimulationResult { + tokensBefore: number; + tokensAfter: number; + tokensSaved: number; + transforms: string[]; + estimatedSavings: string; + messagesOptimized: any[]; + blockBreakdown: Record; + wasteSignals: Record; + stablePrefixHash: string; + cacheAlignmentScore: number; +} +interface RequestMetrics { + requestId: string; + timestamp: string; + model: string; + stream: boolean; + mode: string; + inputTokensOriginal?: number | null; + inputTokensOptimized?: number | null; + outputTokens?: number | null; + tokensSaved?: number | null; + savingsPercent?: number | null; + optimizationLatencyMs?: number | null; + totalLatencyMs?: number | null; + hasExactTokens?: boolean; + tokenAccountingStatus?: "complete" | "partial" | "missing"; + tokensInputBefore: number; + tokensInputAfter: number; + tokensOutput?: number | null; + blockBreakdown: Record; + wasteSignals: Record; + stablePrefixHash: string; + cacheAlignmentScore: number; + cachedTokens?: number | null; + cacheOptimizerUsed?: string | null; + cacheOptimizerStrategy?: string | null; + cacheableTokens: number; + breakpointsInserted: number; + estimatedCacheHit: boolean; + estimatedSavingsPercent: number; + semanticCacheHit: boolean; + transformsApplied: string[]; + toolUnitsDropped: number; + turnsDropped: number; + messagesHash: string; + error?: string | null; +} +interface Block { + kind: BlockKind; + text: string; + tokensEst: number; + contentHash: string; + sourceIndex: number; + flags: Record; +} +interface SessionStats { + totalRequests: number; + totalTokensBefore: number; + totalTokensAfter: number; + totalTokensSaved: number; + averageCompressionRatio: number; + cacheHits: number; + byMode: Record; +} +interface ValidationResult { + valid: boolean; + provider: string; + errors: string[]; + warnings: string[]; + config: Record; +} +interface MetricsSummary { + totalRequests: number; + totalTokensBefore: number; + totalTokensAfter: number; + totalTokensSaved: number; + averageCompressionRatio: number; + models: Record; + modes: Record; + errorCount: number; +} +interface HealthStatus { + status: "healthy" | "unhealthy"; + version: string; + config: { + optimize: boolean; + cache: boolean; + rateLimit: boolean; + }; +} +interface ProxyStats { + requests: { + total: number; + cached: number; + rateLimited: number; + failed: number; + byProvider: Record; + byModel: Record; + }; + tokens: { + input: number; + output: number; + saved: number; + cliTokensAvoided: number; + totalBeforeCompression: number; + savingsPercent: number; + }; + latency: { + averageMs: number; + minMs: number; + maxMs: number; + }; + overhead: { + averageMs: number; + minMs: number; + maxMs: number; + }; + pipelineTiming: Record; + wasteSignals: Record; + compression: { + ccrEntries: number; + ccrMaxEntries: number; + originalTokensCached: number; + compressedTokensCached: number; + ccrRetrievals: number; + }; + cost: Record; + feedbackLoop: { + toolsTracked: number; + totalCompressions: number; + totalRetrievals: number; + globalRetrievalRate: number; + }; +} +interface MemoryUsage { + processMemory: { + rss: number; + vms: number; + percent: number; + }; + components: Record; + totalTrackedMb: number; + targetBudgetMb: number; +} +interface RetrieveResult { + hash: string; + originalContent: string; + originalTokens: number; + originalItemCount: number; + compressedItemCount: number; + toolName: string; + retrievalCount: number; +} +interface RetrieveSearchResult { + hash: string; + query: string; + results: any[]; + count: number; +} +interface CCRStats { + store: { + entries: number; + maxEntries: number; + originalTokensCached: number; + compressedTokensCached: number; + retrievals: number; + }; + recentRetrievals: Array<{ + hash: string; + query: string | null; + itemsRetrieved: number; + totalItems: number; + toolName: string; + retrievalType: string; + }>; +} +interface TelemetryStats { + enabled: boolean; + totalCompressions: number; + totalRetrievals: number; + globalRetrievalRate: number; + toolSignaturesTracked: number; + avgCompressionRatio: number; + avgTokenReduction: number; +} +interface ToolHints { + toolName: string; + hints: { + maxItems: number; + minItems: number; + suggestedItems: any[]; + aggressiveness: number; + skipCompression: boolean; + preserveFields: string[]; + reason: string; + }; + pattern: { + totalCompressions: number; + totalRetrievals: number; + retrievalRate: number; + fullRetrievalRate: number; + searchRate: number; + commonQueries: string[]; + queriedFields: string[]; + }; +} +interface TOINStats { + enabled: boolean; + patternsTracked: number; + totalCompressions: number; + totalRetrievals: number; + globalRetrievalRate: number; + patternsWithRecommendations: number; +} +interface TOINPattern { + hash: string; + compressions: number; + retrievals: number; + retrievalRate: string; + confidence: number; + skipRecommended: boolean; + optimalMaxItems: number; +} +interface MetricsQuery { + startTime?: Date; + endTime?: Date; + model?: string; + mode?: string; + limit?: number; +} +interface SummaryQuery { + startTime?: Date; + endTime?: Date; +} +interface StatsHistoryQuery { + format?: "json" | "csv"; + series?: "history" | "hourly" | "daily" | "weekly" | "monthly"; +} + +/** + * HeadroomClient — HTTP client for the Headroom compression proxy. + * + * Supports: + * - compress() — direct compression via /v1/compress + * - chat.completions.create() — OpenAI-style passthrough via /v1/chat/completions + * - messages.create() — Anthropic-style passthrough via /v1/messages + * - Metrics, health, CCR retrieve, telemetry, feedback, TOIN + */ + +interface HeadroomParams { + headroomMode?: HeadroomMode; + headroomCachePrefixTokens?: number; + headroomOutputBufferTokens?: number; + headroomKeepTurns?: number; + headroomToolProfiles?: Record>; +} +declare class ChatCompletions { + private client; + constructor(client: HeadroomClient); + /** + * Create a chat completion with automatic compression. + * Routes through proxy's POST /v1/chat/completions. + */ + create(params: { + model: string; + messages: OpenAIMessage[]; + stream?: boolean; + [key: string]: any; + } & HeadroomParams): Promise; + /** + * Simulate compression without calling the LLM. + */ + simulate(params: { + model: string; + messages: OpenAIMessage[]; + } & HeadroomParams): Promise; +} +declare class Messages { + private client; + constructor(client: HeadroomClient); + /** + * Create a message with automatic compression. + * Routes through proxy's POST /v1/messages (Anthropic). + */ + create(params: { + model: string; + messages: any[]; + max_tokens?: number; + system?: string | any[]; + stream?: boolean; + [key: string]: any; + } & HeadroomParams): Promise; + /** + * Stream a message with automatic compression. + */ + stream(params: { + model: string; + messages: any[]; + max_tokens?: number; + system?: string | any[]; + [key: string]: any; + } & HeadroomParams): Promise>; + /** + * Simulate compression without calling the LLM. + */ + simulate(params: { + model: string; + messages: any[]; + } & HeadroomParams): Promise; +} +interface ExtendedClientOptions extends HeadroomClientOptions { + providerApiKey?: string; + defaultMode?: HeadroomMode; + config?: CompressRequestConfig; +} +declare class HeadroomClient implements HeadroomClientInterface { + private baseUrl; + private apiKey; + private timeout; + private fallback; + private retries; + private config; + private stack; + /** @internal */ providerApiKey: string | undefined; + /** OpenAI-style chat completions API. */ + readonly chat: { + completions: ChatCompletions; + }; + /** Anthropic-style messages API. */ + readonly messages: Messages; + constructor(options?: ExtendedClientOptions); + compress(messages: OpenAIMessage[], options?: { + model?: string; + tokenBudget?: number; + config?: CompressRequestConfig; + }): Promise; + /** + * Raw compress call — sends body directly to /v1/compress. + * Used by simulate() and other advanced features. + * @internal + */ + compressRaw(body: Record): Promise; + /** Check if the proxy is running and healthy. */ + health(): Promise; + /** Get comprehensive proxy statistics. */ + proxyStats(): Promise; + /** Get Prometheus-format metrics. */ + prometheusMetrics(): Promise; + /** Get historical stats. */ + statsHistory(query?: StatsHistoryQuery): Promise; + /** Get proxy memory usage. */ + memoryUsage(): Promise; + /** Clear the response cache. */ + clearCache(): Promise<{ + status: string; + }>; + /** Get request metrics from the proxy. */ + getMetrics(query?: MetricsQuery): Promise; + /** Get aggregated metrics summary. */ + getSummary(query?: SummaryQuery): Promise; + /** Get in-memory session stats. */ + getStats(): Promise; + /** Validate proxy configuration. */ + validateSetup(): Promise; + /** Retrieve original content from the CCR compression store. */ + retrieve(hash: string, options?: { + query?: string; + }): Promise; + /** Get CCR store statistics. */ + getCCRStats(): Promise; + /** Handle an LLM tool call for headroom_retrieve. */ + handleToolCall(request: { + toolCall: any; + provider?: "anthropic" | "openai"; + }): Promise; + readonly telemetry: { + getStats: () => Promise; + export: () => Promise; + import: (data: any) => Promise<{ + status: string; + }>; + getTools: () => Promise; + getTool: (signatureHash: string) => Promise; + }; + readonly feedback: { + getStats: () => Promise; + getHints: (toolName: string) => Promise; + }; + readonly toin: { + getStats: () => Promise; + getPatterns: (limit?: number) => Promise; + getPattern: (hashPrefix: string) => Promise; + }; + /** Close the client (no-op for HTTP client, included for API parity). */ + close(): void; + /** + * Raw fetch with proxy base URL, auth, and timeout. + * A string body is sent as-is; anything else is JSON-serialized. + * @internal + */ + rawFetch(path: string, options: { + method: string; + headers?: Record; + body?: any; + stream?: boolean; + }): Promise; + /** Proxy-only endpoints, whose bodies are already serialized. @internal */ + private _fetch; + private _doCompress; +} + +/** + * Simulation API — dry-run compression to see what would happen. + * Matches Python client.simulate() behavior. + */ + +interface SimulateOptions extends HeadroomClientOptions { + model?: string; + config?: HeadroomConfig; + client?: HeadroomClient; +} +/** + * Simulate compression without calling the LLM. + * Shows what compression would do: token savings, transforms, waste signals. + * + * @example + * ```typescript + * const sim = await simulate(messages, { model: 'gpt-4o' }); + * console.log(`Would save ${sim.tokensSaved} tokens (${sim.estimatedSavings})`); + * console.log('Transforms:', sim.transforms); + * ``` + */ +declare function simulate(messages: any[], options?: SimulateOptions): Promise; + +/** + * Universal message format detection and conversion. + * + * Supports 4 formats: + * - OpenAI: { role, content, tool_calls?, tool_call_id? } + * - Anthropic: { role, content (string | ContentBlock[]) } with tool_use/tool_result blocks + * - Vercel AI SDK: { role, content (string | Part[]) } with tool-call/tool-result parts + * - Google Gemini: { role, parts[] } with functionCall/functionResponse + * + * Detection is structural (unique keys/values per format), not heuristic. + * Conversion always targets OpenAI format (the proxy's lingua franca). + */ + +type MessageFormat = "openai" | "anthropic" | "vercel" | "gemini"; +/** + * Detect which format an array of messages is in. + * + * Uses structural markers unique to each format: + * - Gemini: messages have `parts` field (not `content`) + * - Vercel: content parts use hyphenated types: `tool-call`, `tool-result` + * - Anthropic: content blocks use underscored types: `tool_use`, `tool_result` + * - OpenAI: assistant messages have `tool_calls` field; tool messages have `tool_call_id` + */ +declare function detectFormat(messages: any[]): MessageFormat; +declare function toOpenAI(messages: any[]): OpenAIMessage[]; +declare function fromOpenAI(messages: OpenAIMessage[], targetFormat: MessageFormat): any[]; + +/** + * Case conversion utilities for proxy communication. + * Proxy uses snake_case (Python), SDK uses camelCase (TypeScript). + */ +declare function snakeToCamel(str: string): string; +declare function camelToSnake(str: string): string; +declare function deepCamelCase(obj: any): T; +declare function deepSnakeCase(obj: any): any; + +/** + * SSE (Server-Sent Events) stream parser for proxy streaming responses. + */ +/** + * Parse an SSE response body into an async generator of parsed JSON events. + */ +declare function parseSSE(response: Response): AsyncGenerator; +/** + * Collect all chunks from an async iterable into an array. + */ +declare function collectStream(stream: AsyncIterable): Promise; + +/** + * SharedContext — compressed inter-agent context sharing. + * Matches Python headroom.shared_context.SharedContext. + */ + +interface ContextEntry { + key: string; + original: string; + compressed: string; + originalTokens: number; + compressedTokens: number; + agent: string | null; + timestamp: number; + transforms: string[]; + savingsPercent: number; +} +interface SharedContextStats { + entries: number; + totalOriginalTokens: number; + totalCompressedTokens: number; + totalTokensSaved: number; + savingsPercent: number; +} +interface SharedContextOptions extends HeadroomClientOptions { + model?: string; + ttl?: number; + maxEntries?: number; +} +declare class SharedContext { + private entries; + private client; + private model; + private ttl; + private maxEntries; + constructor(options?: SharedContextOptions); + /** + * Store content with compression. + */ + put(key: string, content: string, options?: { + agent?: string; + }): Promise; + /** + * Get content by key. Returns compressed by default, full original if full=true. + */ + get(key: string, options?: { + full?: boolean; + }): string | null; + /** + * Get full entry metadata. + */ + getEntry(key: string): ContextEntry | null; + /** + * List all stored keys (excluding expired). + */ + keys(): string[]; + /** + * Get aggregated statistics. + */ + stats(): SharedContextStats; + /** + * Clear all entries. + */ + clear(): void; + private evictExpired; + private evictIfFull; +} + +/** + * Canonical filesystem contract for Headroom — parity shell for the npm SDK. + * + * The TypeScript SDK is an HTTP client today and does not touch the + * filesystem directly. This module mirrors `headroom/paths.py` so that + * future local features (e.g. cache/log co-location with the Python + * proxy) land on the same contract. + * + * Two canonical roots: + * - HEADROOM_CONFIG_DIR — read-mostly configuration + * (default: ~/.headroom/config) + * - HEADROOM_WORKSPACE_DIR — read-write state + * (default: ~/.headroom) + * + * Precedence for every per-resource helper is: + * explicit argument > per-resource env var > derived from canonical + * root > default. + * + * Browser behavior: when `process` is not available (typeof process === + * "undefined"), all helpers return the empty string. Consumers running + * in a browser should not call these helpers; they exist here so the + * shape of the API matches Python's `headroom.paths` module. + */ +declare const HEADROOM_CONFIG_DIR_ENV = "HEADROOM_CONFIG_DIR"; +declare const HEADROOM_WORKSPACE_DIR_ENV = "HEADROOM_WORKSPACE_DIR"; +declare const HEADROOM_SAVINGS_PATH_ENV = "HEADROOM_SAVINGS_PATH"; +declare const HEADROOM_TOIN_PATH_ENV = "HEADROOM_TOIN_PATH"; +declare const HEADROOM_SUBSCRIPTION_STATE_PATH_ENV = "HEADROOM_SUBSCRIPTION_STATE_PATH"; +declare function workspaceDir(): string; +declare function configDir(): string; +declare function savingsPath(explicit?: string): string; +declare function toinPath(explicit?: string): string; +declare function subscriptionStatePath(explicit?: string): string; +declare function memoryDbPath(): string; +declare function nativeMemoryDir(): string; +declare function licenseCachePath(): string; +declare function sessionStatsPath(): string; +declare function syncStatePath(): string; +declare function bridgeStatePath(): string; +declare function logDir(): string; +declare function proxyLogPath(): string; +declare function debug400Dir(): string; +declare function binDir(): string; +declare function deployRoot(): string; +declare function beaconLockPath(port: number): string; +declare function modelsConfigPath(): string; +declare function pluginConfigDir(pluginName: string): string; +declare function pluginWorkspaceDir(pluginName: string): string; + +export { type Block, BlockKind, type CCRStats, CacheError, type CachePrefixMetrics, CompressOptions, CompressRequestConfig, CompressResult, ConfigurationError, type ContextEntry, type DiffArtifact, type ExtendedClientOptions, HEADROOM_CONFIG_DIR_ENV, HEADROOM_SAVINGS_PATH_ENV, HEADROOM_SUBSCRIPTION_STATE_PATH_ENV, HEADROOM_TOIN_PATH_ENV, HEADROOM_WORKSPACE_DIR_ENV, HeadroomAuthError, HeadroomClient, HeadroomClientInterface, HeadroomClientOptions, HeadroomCompressError, HeadroomConfig, HeadroomConnectionError, HeadroomError, HeadroomMode, type HeadroomParams, type HealthStatus, type MemoryUsage, type MessageFormat, type MetricsQuery, type MetricsSummary, OpenAIMessage, ProviderError, type ProxyStats, type RequestMetrics, type RetrieveResult, type RetrieveSearchResult, type SessionStats, SharedContext, type SharedContextOptions, type SharedContextStats, type SimulateOptions, type SimulationResult, type StatsHistoryQuery, StorageError, type SummaryQuery, type TOINPattern, type TOINStats, type TelemetryStats, TokenizationError, type ToolHints, type TransformDiff, TransformError, ValidationError, type ValidationResult, type WasteSignals, beaconLockPath, binDir, bridgeStatePath, camelToSnake, collectStream, compress, configDir, debug400Dir, deepCamelCase, deepSnakeCase, deployRoot, detectFormat, fromOpenAI, licenseCachePath, logDir, mapProxyError, memoryDbPath, modelsConfigPath, nativeMemoryDir, parseSSE, pluginConfigDir, pluginWorkspaceDir, proxyLogPath, savingsPath, sessionStatsPath, simulate, snakeToCamel, subscriptionStatePath, syncStatePath, toOpenAI, toinPath, workspaceDir }; diff --git a/node_modules/headroom-ai/dist/index.d.ts b/node_modules/headroom-ai/dist/index.d.ts new file mode 100644 index 00000000..47f78e8b --- /dev/null +++ b/node_modules/headroom-ai/dist/index.d.ts @@ -0,0 +1,708 @@ +import { C as CompressOptions, a as CompressResult, B as BlockKind, H as HeadroomClientOptions, b as HeadroomMode, c as CompressRequestConfig, d as HeadroomClientInterface, O as OpenAIMessage, e as HeadroomConfig } from './types-Cjdl75F4.js'; +export { A as AnchorConfig, f as AssistantMessage, g as CCRConfig, h as CacheAlignerConfig, i as CacheOptimizerConfig, j as CompressContext, k as CompressEvent, l as CompressMode, m as CompressionHooks, n as CompressionProfile, o as ContentPart, p as ContentType, I as ImageContentPart, q as IntelligentContextConfig, P as PrefixFreezeConfig, R as ReadLifecycleConfig, r as RelevanceScorerConfig, s as RelevanceTier, t as RollingWindowConfig, S as ScoringWeights, u as SmartCrusherConfig, v as SystemMessage, T as TextContentPart, w as ToolCall, x as ToolCrusherConfig, y as ToolMessage, U as UserMessage, z as countTurns, D as extractToolCalls, E as extractUserQuery } from './types-Cjdl75F4.js'; + +/** + * Error hierarchy matching Python headroom.exceptions. + */ +declare class HeadroomError extends Error { + details?: Record; + constructor(message: string, details?: Record); +} +declare class HeadroomConnectionError extends HeadroomError { + constructor(message: string, details?: Record); +} +declare class HeadroomAuthError extends HeadroomError { + constructor(message: string, details?: Record); +} +declare class HeadroomCompressError extends HeadroomError { + statusCode: number; + errorType: string; + constructor(statusCode: number, errorType: string, message: string, details?: Record); +} +declare class ConfigurationError extends HeadroomError { + constructor(message: string, details?: Record); +} +declare class ProviderError extends HeadroomError { + constructor(message: string, details?: Record); +} +declare class StorageError extends HeadroomError { + constructor(message: string, details?: Record); +} +declare class TokenizationError extends HeadroomError { + constructor(message: string, details?: Record); +} +declare class CacheError extends HeadroomError { + constructor(message: string, details?: Record); +} +declare class ValidationError extends HeadroomError { + constructor(message: string, details?: Record); +} +declare class TransformError extends HeadroomError { + constructor(message: string, details?: Record); +} +/** + * Map a proxy error response to the correct HeadroomError subclass. + */ +declare function mapProxyError(status: number, type: string, message: string): HeadroomError; + +/** + * Universal compress function with hooks support. + * Accepts messages in any format: OpenAI, Anthropic, Vercel AI SDK, or Google Gemini. + */ + +/** + * Compress an array of messages using the Headroom proxy. + * + * Accepts messages in any format: OpenAI, Anthropic, Vercel AI SDK, or Google Gemini. + * Detects the format automatically, compresses via the proxy, and returns + * compressed messages in the same format as the input. + */ +declare function compress(messages: any[], options?: CompressOptions): Promise; + +/** + * Data models matching Python headroom.config result/metrics dataclasses. + */ + +interface WasteSignals { + jsonBloatTokens: number; + htmlNoiseTokens: number; + base64Tokens: number; + whitespaceTokens: number; + dynamicDateTokens: number; + repetitionTokens: number; + total: number; +} +interface CachePrefixMetrics { + stablePrefixBytes: number; + stablePrefixTokensEst: number; + stablePrefixHash: string; + prefixChanged: boolean; + previousHash?: string | null; +} +interface TransformDiff { + transformName: string; + tokensBefore: number; + tokensAfter: number; + tokensSaved: number; + itemsRemoved: number; + itemsKept: number; + details: string; + durationMs: number; +} +interface DiffArtifact { + requestId: string; + originalTokens: number; + optimizedTokens: number; + totalTokensSaved: number; + transforms: TransformDiff[]; +} +interface SimulationResult { + tokensBefore: number; + tokensAfter: number; + tokensSaved: number; + transforms: string[]; + estimatedSavings: string; + messagesOptimized: any[]; + blockBreakdown: Record; + wasteSignals: Record; + stablePrefixHash: string; + cacheAlignmentScore: number; +} +interface RequestMetrics { + requestId: string; + timestamp: string; + model: string; + stream: boolean; + mode: string; + inputTokensOriginal?: number | null; + inputTokensOptimized?: number | null; + outputTokens?: number | null; + tokensSaved?: number | null; + savingsPercent?: number | null; + optimizationLatencyMs?: number | null; + totalLatencyMs?: number | null; + hasExactTokens?: boolean; + tokenAccountingStatus?: "complete" | "partial" | "missing"; + tokensInputBefore: number; + tokensInputAfter: number; + tokensOutput?: number | null; + blockBreakdown: Record; + wasteSignals: Record; + stablePrefixHash: string; + cacheAlignmentScore: number; + cachedTokens?: number | null; + cacheOptimizerUsed?: string | null; + cacheOptimizerStrategy?: string | null; + cacheableTokens: number; + breakpointsInserted: number; + estimatedCacheHit: boolean; + estimatedSavingsPercent: number; + semanticCacheHit: boolean; + transformsApplied: string[]; + toolUnitsDropped: number; + turnsDropped: number; + messagesHash: string; + error?: string | null; +} +interface Block { + kind: BlockKind; + text: string; + tokensEst: number; + contentHash: string; + sourceIndex: number; + flags: Record; +} +interface SessionStats { + totalRequests: number; + totalTokensBefore: number; + totalTokensAfter: number; + totalTokensSaved: number; + averageCompressionRatio: number; + cacheHits: number; + byMode: Record; +} +interface ValidationResult { + valid: boolean; + provider: string; + errors: string[]; + warnings: string[]; + config: Record; +} +interface MetricsSummary { + totalRequests: number; + totalTokensBefore: number; + totalTokensAfter: number; + totalTokensSaved: number; + averageCompressionRatio: number; + models: Record; + modes: Record; + errorCount: number; +} +interface HealthStatus { + status: "healthy" | "unhealthy"; + version: string; + config: { + optimize: boolean; + cache: boolean; + rateLimit: boolean; + }; +} +interface ProxyStats { + requests: { + total: number; + cached: number; + rateLimited: number; + failed: number; + byProvider: Record; + byModel: Record; + }; + tokens: { + input: number; + output: number; + saved: number; + cliTokensAvoided: number; + totalBeforeCompression: number; + savingsPercent: number; + }; + latency: { + averageMs: number; + minMs: number; + maxMs: number; + }; + overhead: { + averageMs: number; + minMs: number; + maxMs: number; + }; + pipelineTiming: Record; + wasteSignals: Record; + compression: { + ccrEntries: number; + ccrMaxEntries: number; + originalTokensCached: number; + compressedTokensCached: number; + ccrRetrievals: number; + }; + cost: Record; + feedbackLoop: { + toolsTracked: number; + totalCompressions: number; + totalRetrievals: number; + globalRetrievalRate: number; + }; +} +interface MemoryUsage { + processMemory: { + rss: number; + vms: number; + percent: number; + }; + components: Record; + totalTrackedMb: number; + targetBudgetMb: number; +} +interface RetrieveResult { + hash: string; + originalContent: string; + originalTokens: number; + originalItemCount: number; + compressedItemCount: number; + toolName: string; + retrievalCount: number; +} +interface RetrieveSearchResult { + hash: string; + query: string; + results: any[]; + count: number; +} +interface CCRStats { + store: { + entries: number; + maxEntries: number; + originalTokensCached: number; + compressedTokensCached: number; + retrievals: number; + }; + recentRetrievals: Array<{ + hash: string; + query: string | null; + itemsRetrieved: number; + totalItems: number; + toolName: string; + retrievalType: string; + }>; +} +interface TelemetryStats { + enabled: boolean; + totalCompressions: number; + totalRetrievals: number; + globalRetrievalRate: number; + toolSignaturesTracked: number; + avgCompressionRatio: number; + avgTokenReduction: number; +} +interface ToolHints { + toolName: string; + hints: { + maxItems: number; + minItems: number; + suggestedItems: any[]; + aggressiveness: number; + skipCompression: boolean; + preserveFields: string[]; + reason: string; + }; + pattern: { + totalCompressions: number; + totalRetrievals: number; + retrievalRate: number; + fullRetrievalRate: number; + searchRate: number; + commonQueries: string[]; + queriedFields: string[]; + }; +} +interface TOINStats { + enabled: boolean; + patternsTracked: number; + totalCompressions: number; + totalRetrievals: number; + globalRetrievalRate: number; + patternsWithRecommendations: number; +} +interface TOINPattern { + hash: string; + compressions: number; + retrievals: number; + retrievalRate: string; + confidence: number; + skipRecommended: boolean; + optimalMaxItems: number; +} +interface MetricsQuery { + startTime?: Date; + endTime?: Date; + model?: string; + mode?: string; + limit?: number; +} +interface SummaryQuery { + startTime?: Date; + endTime?: Date; +} +interface StatsHistoryQuery { + format?: "json" | "csv"; + series?: "history" | "hourly" | "daily" | "weekly" | "monthly"; +} + +/** + * HeadroomClient — HTTP client for the Headroom compression proxy. + * + * Supports: + * - compress() — direct compression via /v1/compress + * - chat.completions.create() — OpenAI-style passthrough via /v1/chat/completions + * - messages.create() — Anthropic-style passthrough via /v1/messages + * - Metrics, health, CCR retrieve, telemetry, feedback, TOIN + */ + +interface HeadroomParams { + headroomMode?: HeadroomMode; + headroomCachePrefixTokens?: number; + headroomOutputBufferTokens?: number; + headroomKeepTurns?: number; + headroomToolProfiles?: Record>; +} +declare class ChatCompletions { + private client; + constructor(client: HeadroomClient); + /** + * Create a chat completion with automatic compression. + * Routes through proxy's POST /v1/chat/completions. + */ + create(params: { + model: string; + messages: OpenAIMessage[]; + stream?: boolean; + [key: string]: any; + } & HeadroomParams): Promise; + /** + * Simulate compression without calling the LLM. + */ + simulate(params: { + model: string; + messages: OpenAIMessage[]; + } & HeadroomParams): Promise; +} +declare class Messages { + private client; + constructor(client: HeadroomClient); + /** + * Create a message with automatic compression. + * Routes through proxy's POST /v1/messages (Anthropic). + */ + create(params: { + model: string; + messages: any[]; + max_tokens?: number; + system?: string | any[]; + stream?: boolean; + [key: string]: any; + } & HeadroomParams): Promise; + /** + * Stream a message with automatic compression. + */ + stream(params: { + model: string; + messages: any[]; + max_tokens?: number; + system?: string | any[]; + [key: string]: any; + } & HeadroomParams): Promise>; + /** + * Simulate compression without calling the LLM. + */ + simulate(params: { + model: string; + messages: any[]; + } & HeadroomParams): Promise; +} +interface ExtendedClientOptions extends HeadroomClientOptions { + providerApiKey?: string; + defaultMode?: HeadroomMode; + config?: CompressRequestConfig; +} +declare class HeadroomClient implements HeadroomClientInterface { + private baseUrl; + private apiKey; + private timeout; + private fallback; + private retries; + private config; + private stack; + /** @internal */ providerApiKey: string | undefined; + /** OpenAI-style chat completions API. */ + readonly chat: { + completions: ChatCompletions; + }; + /** Anthropic-style messages API. */ + readonly messages: Messages; + constructor(options?: ExtendedClientOptions); + compress(messages: OpenAIMessage[], options?: { + model?: string; + tokenBudget?: number; + config?: CompressRequestConfig; + }): Promise; + /** + * Raw compress call — sends body directly to /v1/compress. + * Used by simulate() and other advanced features. + * @internal + */ + compressRaw(body: Record): Promise; + /** Check if the proxy is running and healthy. */ + health(): Promise; + /** Get comprehensive proxy statistics. */ + proxyStats(): Promise; + /** Get Prometheus-format metrics. */ + prometheusMetrics(): Promise; + /** Get historical stats. */ + statsHistory(query?: StatsHistoryQuery): Promise; + /** Get proxy memory usage. */ + memoryUsage(): Promise; + /** Clear the response cache. */ + clearCache(): Promise<{ + status: string; + }>; + /** Get request metrics from the proxy. */ + getMetrics(query?: MetricsQuery): Promise; + /** Get aggregated metrics summary. */ + getSummary(query?: SummaryQuery): Promise; + /** Get in-memory session stats. */ + getStats(): Promise; + /** Validate proxy configuration. */ + validateSetup(): Promise; + /** Retrieve original content from the CCR compression store. */ + retrieve(hash: string, options?: { + query?: string; + }): Promise; + /** Get CCR store statistics. */ + getCCRStats(): Promise; + /** Handle an LLM tool call for headroom_retrieve. */ + handleToolCall(request: { + toolCall: any; + provider?: "anthropic" | "openai"; + }): Promise; + readonly telemetry: { + getStats: () => Promise; + export: () => Promise; + import: (data: any) => Promise<{ + status: string; + }>; + getTools: () => Promise; + getTool: (signatureHash: string) => Promise; + }; + readonly feedback: { + getStats: () => Promise; + getHints: (toolName: string) => Promise; + }; + readonly toin: { + getStats: () => Promise; + getPatterns: (limit?: number) => Promise; + getPattern: (hashPrefix: string) => Promise; + }; + /** Close the client (no-op for HTTP client, included for API parity). */ + close(): void; + /** + * Raw fetch with proxy base URL, auth, and timeout. + * A string body is sent as-is; anything else is JSON-serialized. + * @internal + */ + rawFetch(path: string, options: { + method: string; + headers?: Record; + body?: any; + stream?: boolean; + }): Promise; + /** Proxy-only endpoints, whose bodies are already serialized. @internal */ + private _fetch; + private _doCompress; +} + +/** + * Simulation API — dry-run compression to see what would happen. + * Matches Python client.simulate() behavior. + */ + +interface SimulateOptions extends HeadroomClientOptions { + model?: string; + config?: HeadroomConfig; + client?: HeadroomClient; +} +/** + * Simulate compression without calling the LLM. + * Shows what compression would do: token savings, transforms, waste signals. + * + * @example + * ```typescript + * const sim = await simulate(messages, { model: 'gpt-4o' }); + * console.log(`Would save ${sim.tokensSaved} tokens (${sim.estimatedSavings})`); + * console.log('Transforms:', sim.transforms); + * ``` + */ +declare function simulate(messages: any[], options?: SimulateOptions): Promise; + +/** + * Universal message format detection and conversion. + * + * Supports 4 formats: + * - OpenAI: { role, content, tool_calls?, tool_call_id? } + * - Anthropic: { role, content (string | ContentBlock[]) } with tool_use/tool_result blocks + * - Vercel AI SDK: { role, content (string | Part[]) } with tool-call/tool-result parts + * - Google Gemini: { role, parts[] } with functionCall/functionResponse + * + * Detection is structural (unique keys/values per format), not heuristic. + * Conversion always targets OpenAI format (the proxy's lingua franca). + */ + +type MessageFormat = "openai" | "anthropic" | "vercel" | "gemini"; +/** + * Detect which format an array of messages is in. + * + * Uses structural markers unique to each format: + * - Gemini: messages have `parts` field (not `content`) + * - Vercel: content parts use hyphenated types: `tool-call`, `tool-result` + * - Anthropic: content blocks use underscored types: `tool_use`, `tool_result` + * - OpenAI: assistant messages have `tool_calls` field; tool messages have `tool_call_id` + */ +declare function detectFormat(messages: any[]): MessageFormat; +declare function toOpenAI(messages: any[]): OpenAIMessage[]; +declare function fromOpenAI(messages: OpenAIMessage[], targetFormat: MessageFormat): any[]; + +/** + * Case conversion utilities for proxy communication. + * Proxy uses snake_case (Python), SDK uses camelCase (TypeScript). + */ +declare function snakeToCamel(str: string): string; +declare function camelToSnake(str: string): string; +declare function deepCamelCase(obj: any): T; +declare function deepSnakeCase(obj: any): any; + +/** + * SSE (Server-Sent Events) stream parser for proxy streaming responses. + */ +/** + * Parse an SSE response body into an async generator of parsed JSON events. + */ +declare function parseSSE(response: Response): AsyncGenerator; +/** + * Collect all chunks from an async iterable into an array. + */ +declare function collectStream(stream: AsyncIterable): Promise; + +/** + * SharedContext — compressed inter-agent context sharing. + * Matches Python headroom.shared_context.SharedContext. + */ + +interface ContextEntry { + key: string; + original: string; + compressed: string; + originalTokens: number; + compressedTokens: number; + agent: string | null; + timestamp: number; + transforms: string[]; + savingsPercent: number; +} +interface SharedContextStats { + entries: number; + totalOriginalTokens: number; + totalCompressedTokens: number; + totalTokensSaved: number; + savingsPercent: number; +} +interface SharedContextOptions extends HeadroomClientOptions { + model?: string; + ttl?: number; + maxEntries?: number; +} +declare class SharedContext { + private entries; + private client; + private model; + private ttl; + private maxEntries; + constructor(options?: SharedContextOptions); + /** + * Store content with compression. + */ + put(key: string, content: string, options?: { + agent?: string; + }): Promise; + /** + * Get content by key. Returns compressed by default, full original if full=true. + */ + get(key: string, options?: { + full?: boolean; + }): string | null; + /** + * Get full entry metadata. + */ + getEntry(key: string): ContextEntry | null; + /** + * List all stored keys (excluding expired). + */ + keys(): string[]; + /** + * Get aggregated statistics. + */ + stats(): SharedContextStats; + /** + * Clear all entries. + */ + clear(): void; + private evictExpired; + private evictIfFull; +} + +/** + * Canonical filesystem contract for Headroom — parity shell for the npm SDK. + * + * The TypeScript SDK is an HTTP client today and does not touch the + * filesystem directly. This module mirrors `headroom/paths.py` so that + * future local features (e.g. cache/log co-location with the Python + * proxy) land on the same contract. + * + * Two canonical roots: + * - HEADROOM_CONFIG_DIR — read-mostly configuration + * (default: ~/.headroom/config) + * - HEADROOM_WORKSPACE_DIR — read-write state + * (default: ~/.headroom) + * + * Precedence for every per-resource helper is: + * explicit argument > per-resource env var > derived from canonical + * root > default. + * + * Browser behavior: when `process` is not available (typeof process === + * "undefined"), all helpers return the empty string. Consumers running + * in a browser should not call these helpers; they exist here so the + * shape of the API matches Python's `headroom.paths` module. + */ +declare const HEADROOM_CONFIG_DIR_ENV = "HEADROOM_CONFIG_DIR"; +declare const HEADROOM_WORKSPACE_DIR_ENV = "HEADROOM_WORKSPACE_DIR"; +declare const HEADROOM_SAVINGS_PATH_ENV = "HEADROOM_SAVINGS_PATH"; +declare const HEADROOM_TOIN_PATH_ENV = "HEADROOM_TOIN_PATH"; +declare const HEADROOM_SUBSCRIPTION_STATE_PATH_ENV = "HEADROOM_SUBSCRIPTION_STATE_PATH"; +declare function workspaceDir(): string; +declare function configDir(): string; +declare function savingsPath(explicit?: string): string; +declare function toinPath(explicit?: string): string; +declare function subscriptionStatePath(explicit?: string): string; +declare function memoryDbPath(): string; +declare function nativeMemoryDir(): string; +declare function licenseCachePath(): string; +declare function sessionStatsPath(): string; +declare function syncStatePath(): string; +declare function bridgeStatePath(): string; +declare function logDir(): string; +declare function proxyLogPath(): string; +declare function debug400Dir(): string; +declare function binDir(): string; +declare function deployRoot(): string; +declare function beaconLockPath(port: number): string; +declare function modelsConfigPath(): string; +declare function pluginConfigDir(pluginName: string): string; +declare function pluginWorkspaceDir(pluginName: string): string; + +export { type Block, BlockKind, type CCRStats, CacheError, type CachePrefixMetrics, CompressOptions, CompressRequestConfig, CompressResult, ConfigurationError, type ContextEntry, type DiffArtifact, type ExtendedClientOptions, HEADROOM_CONFIG_DIR_ENV, HEADROOM_SAVINGS_PATH_ENV, HEADROOM_SUBSCRIPTION_STATE_PATH_ENV, HEADROOM_TOIN_PATH_ENV, HEADROOM_WORKSPACE_DIR_ENV, HeadroomAuthError, HeadroomClient, HeadroomClientInterface, HeadroomClientOptions, HeadroomCompressError, HeadroomConfig, HeadroomConnectionError, HeadroomError, HeadroomMode, type HeadroomParams, type HealthStatus, type MemoryUsage, type MessageFormat, type MetricsQuery, type MetricsSummary, OpenAIMessage, ProviderError, type ProxyStats, type RequestMetrics, type RetrieveResult, type RetrieveSearchResult, type SessionStats, SharedContext, type SharedContextOptions, type SharedContextStats, type SimulateOptions, type SimulationResult, type StatsHistoryQuery, StorageError, type SummaryQuery, type TOINPattern, type TOINStats, type TelemetryStats, TokenizationError, type ToolHints, type TransformDiff, TransformError, ValidationError, type ValidationResult, type WasteSignals, beaconLockPath, binDir, bridgeStatePath, camelToSnake, collectStream, compress, configDir, debug400Dir, deepCamelCase, deepSnakeCase, deployRoot, detectFormat, fromOpenAI, licenseCachePath, logDir, mapProxyError, memoryDbPath, modelsConfigPath, nativeMemoryDir, parseSSE, pluginConfigDir, pluginWorkspaceDir, proxyLogPath, savingsPath, sessionStatsPath, simulate, snakeToCamel, subscriptionStatePath, syncStatePath, toOpenAI, toinPath, workspaceDir }; diff --git a/node_modules/headroom-ai/dist/index.js b/node_modules/headroom-ai/dist/index.js new file mode 100644 index 00000000..8dd8df5a --- /dev/null +++ b/node_modules/headroom-ai/dist/index.js @@ -0,0 +1,323 @@ +import { toOpenAI, HeadroomClient, deepSnakeCase, deepCamelCase, __require } from './chunk-24W6JPRV.js'; +export { CacheError, CompressionHooks, ConfigurationError, HeadroomAuthError, HeadroomClient, HeadroomCompressError, HeadroomConnectionError, HeadroomError, ProviderError, StorageError, TokenizationError, TransformError, ValidationError, camelToSnake, collectStream, compress, countTurns, deepCamelCase, deepSnakeCase, detectFormat, extractToolCalls, extractUserQuery, fromOpenAI, mapProxyError, parseSSE, snakeToCamel, toOpenAI } from './chunk-24W6JPRV.js'; + +// src/simulate.ts +async function simulate(messages, options = {}) { + const { client: providedClient, model, config, ...clientOptions } = options; + const openaiMessages = toOpenAI(messages); + const client = providedClient ?? new HeadroomClient(clientOptions); + const body = { + messages: openaiMessages, + model: model ?? "gpt-4o", + config: { + default_mode: "simulate", + generate_diff_artifact: true, + ...config ? deepSnakeCase(config) : {} + } + }; + const result = await client.compressRaw(body); + return deepCamelCase(result); +} + +// src/shared-context.ts +var SharedContext = class { + entries = /* @__PURE__ */ new Map(); + client; + model; + ttl; + maxEntries; + constructor(options = {}) { + const { model, ttl, maxEntries, ...clientOptions } = options; + this.client = new HeadroomClient(clientOptions); + this.model = model ?? "claude-sonnet-4-5-20250929"; + this.ttl = ttl ?? 3600; + this.maxEntries = maxEntries ?? 100; + } + /** + * Store content with compression. + */ + async put(key, content, options) { + this.evictExpired(); + this.evictIfFull(); + const messages = [{ role: "user", content }]; + let result; + try { + result = await this.client.compress(messages, { model: this.model }); + } catch { + result = { + messages, + tokensBefore: content.length / 4, + // rough estimate + tokensAfter: content.length / 4, + tokensSaved: 0, + compressionRatio: 1, + transformsApplied: [], + ccrHashes: [], + compressed: false + }; + } + const compressed = result.compressed ? typeof result.messages[0]?.content === "string" ? result.messages[0].content : JSON.stringify(result.messages[0]?.content ?? content) : content; + const entry = { + key, + original: content, + compressed, + originalTokens: result.tokensBefore, + compressedTokens: result.tokensAfter, + agent: options?.agent ?? null, + timestamp: Date.now() / 1e3, + transforms: result.transformsApplied, + savingsPercent: result.tokensBefore > 0 ? (result.tokensBefore - result.tokensAfter) / result.tokensBefore * 100 : 0 + }; + this.entries.set(key, entry); + return entry; + } + /** + * Get content by key. Returns compressed by default, full original if full=true. + */ + get(key, options) { + const entry = this.entries.get(key); + if (!entry) return null; + if (Date.now() / 1e3 - entry.timestamp > this.ttl) { + this.entries.delete(key); + return null; + } + return options?.full ? entry.original : entry.compressed; + } + /** + * Get full entry metadata. + */ + getEntry(key) { + const entry = this.entries.get(key); + if (!entry) return null; + if (Date.now() / 1e3 - entry.timestamp > this.ttl) { + this.entries.delete(key); + return null; + } + return entry; + } + /** + * List all stored keys (excluding expired). + */ + keys() { + this.evictExpired(); + return Array.from(this.entries.keys()); + } + /** + * Get aggregated statistics. + */ + stats() { + this.evictExpired(); + let totalOriginal = 0; + let totalCompressed = 0; + for (const entry of this.entries.values()) { + totalOriginal += entry.originalTokens; + totalCompressed += entry.compressedTokens; + } + const saved = totalOriginal - totalCompressed; + return { + entries: this.entries.size, + totalOriginalTokens: totalOriginal, + totalCompressedTokens: totalCompressed, + totalTokensSaved: saved, + savingsPercent: totalOriginal > 0 ? saved / totalOriginal * 100 : 0 + }; + } + /** + * Clear all entries. + */ + clear() { + this.entries.clear(); + } + evictExpired() { + const now = Date.now() / 1e3; + for (const [key, entry] of this.entries) { + if (now - entry.timestamp > this.ttl) { + this.entries.delete(key); + } + } + } + evictIfFull() { + while (this.entries.size >= this.maxEntries) { + const oldest = this.entries.keys().next().value; + if (oldest !== void 0) this.entries.delete(oldest); + } + } +}; + +// src/paths.ts +var HEADROOM_CONFIG_DIR_ENV = "HEADROOM_CONFIG_DIR"; +var HEADROOM_WORKSPACE_DIR_ENV = "HEADROOM_WORKSPACE_DIR"; +var HEADROOM_SAVINGS_PATH_ENV = "HEADROOM_SAVINGS_PATH"; +var HEADROOM_TOIN_PATH_ENV = "HEADROOM_TOIN_PATH"; +var HEADROOM_SUBSCRIPTION_STATE_PATH_ENV = "HEADROOM_SUBSCRIPTION_STATE_PATH"; +function isNode() { + return typeof process !== "undefined" && typeof process.versions !== "undefined" && typeof process.versions.node === "string"; +} +function getEnv(name) { + if (typeof process === "undefined" || !process.env) { + return ""; + } + const value = process.env[name]; + return typeof value === "string" ? value.trim() : ""; +} +function homeDir() { + if (!isNode()) { + return ""; + } + try { + const os = __require("os"); + if (os.homedir) { + const home = os.homedir(); + if (home) return home; + } + } catch { + } + return getEnv("HOME") || getEnv("USERPROFILE") || ""; +} +function joinPath(...parts) { + if (!isNode()) { + return parts.filter((p) => p !== "").join("/"); + } + try { + const path = __require("path"); + if (path.join) return path.join(...parts.filter((p) => p !== "")); + } catch { + } + const sep = process.platform === "win32" ? "\\" : "/"; + return parts.filter((p) => p !== "").join(sep); +} +function expandTilde(p) { + if (!p.startsWith("~")) return p; + const home = homeDir(); + if (!home) return p; + if (p === "~") return home; + if (p.startsWith("~/") || p.startsWith("~\\")) { + return joinPath(home, p.slice(2)); + } + return p; +} +function resolve(explicit, envVar, derived) { + if (!isNode()) { + return ""; + } + if (explicit !== void 0 && explicit !== "") { + return expandTilde(explicit); + } + const envValue = getEnv(envVar); + if (envValue) { + return expandTilde(envValue); + } + return derived; +} +function workspaceDir() { + if (!isNode()) return ""; + const envValue = getEnv(HEADROOM_WORKSPACE_DIR_ENV); + if (envValue) return expandTilde(envValue); + const home = homeDir(); + if (!home) return ""; + return joinPath(home, ".headroom"); +} +function configDir() { + if (!isNode()) return ""; + const envValue = getEnv(HEADROOM_CONFIG_DIR_ENV); + if (envValue) return expandTilde(envValue); + const workspaceEnv = getEnv(HEADROOM_WORKSPACE_DIR_ENV); + if (workspaceEnv) { + return joinPath(expandTilde(workspaceEnv), "config"); + } + const home = homeDir(); + if (!home) return ""; + return joinPath(home, ".headroom", "config"); +} +function savingsPath(explicit) { + return resolve( + explicit, + HEADROOM_SAVINGS_PATH_ENV, + joinPath(workspaceDir(), "proxy_savings.json") + ); +} +function toinPath(explicit) { + return resolve( + explicit, + HEADROOM_TOIN_PATH_ENV, + joinPath(workspaceDir(), "toin.json") + ); +} +function subscriptionStatePath(explicit) { + return resolve( + explicit, + HEADROOM_SUBSCRIPTION_STATE_PATH_ENV, + joinPath(workspaceDir(), "subscription_state.json") + ); +} +function memoryDbPath() { + if (!isNode()) return ""; + return joinPath(workspaceDir(), "memory.db"); +} +function nativeMemoryDir() { + if (!isNode()) return ""; + return joinPath(workspaceDir(), "memories"); +} +function licenseCachePath() { + if (!isNode()) return ""; + return joinPath(workspaceDir(), "license_cache.json"); +} +function sessionStatsPath() { + if (!isNode()) return ""; + return joinPath(workspaceDir(), "session_stats.jsonl"); +} +function syncStatePath() { + if (!isNode()) return ""; + return joinPath(workspaceDir(), "sync_state.json"); +} +function bridgeStatePath() { + if (!isNode()) return ""; + return joinPath(workspaceDir(), "bridge_state.json"); +} +function logDir() { + if (!isNode()) return ""; + return joinPath(workspaceDir(), "logs"); +} +function proxyLogPath() { + if (!isNode()) return ""; + return joinPath(logDir(), "proxy.log"); +} +function debug400Dir() { + if (!isNode()) return ""; + return joinPath(logDir(), "debug_400"); +} +function binDir() { + if (!isNode()) return ""; + return joinPath(workspaceDir(), "bin"); +} +function deployRoot() { + if (!isNode()) return ""; + return joinPath(workspaceDir(), "deploy"); +} +function beaconLockPath(port) { + if (!isNode()) return ""; + return joinPath(workspaceDir(), `.beacon_lock_${Math.trunc(port)}`); +} +function modelsConfigPath() { + if (!isNode()) return ""; + return joinPath(configDir(), "models.json"); +} +function assertPluginName(name) { + if (!name || name.includes("/") || name.includes("\\")) { + throw new Error(`invalid plugin name: ${JSON.stringify(name)}`); + } +} +function pluginConfigDir(pluginName) { + assertPluginName(pluginName); + if (!isNode()) return ""; + return joinPath(configDir(), "plugins", pluginName); +} +function pluginWorkspaceDir(pluginName) { + assertPluginName(pluginName); + if (!isNode()) return ""; + return joinPath(workspaceDir(), "plugins", pluginName); +} + +export { HEADROOM_CONFIG_DIR_ENV, HEADROOM_SAVINGS_PATH_ENV, HEADROOM_SUBSCRIPTION_STATE_PATH_ENV, HEADROOM_TOIN_PATH_ENV, HEADROOM_WORKSPACE_DIR_ENV, SharedContext, beaconLockPath, binDir, bridgeStatePath, configDir, debug400Dir, deployRoot, licenseCachePath, logDir, memoryDbPath, modelsConfigPath, nativeMemoryDir, pluginConfigDir, pluginWorkspaceDir, proxyLogPath, savingsPath, sessionStatsPath, simulate, subscriptionStatePath, syncStatePath, toinPath, workspaceDir }; +//# sourceMappingURL=index.js.map +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/node_modules/headroom-ai/dist/index.js.map b/node_modules/headroom-ai/dist/index.js.map new file mode 100644 index 00000000..337e6826 --- /dev/null +++ b/node_modules/headroom-ai/dist/index.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../src/simulate.ts","../src/shared-context.ts","../src/paths.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * Simulation API — dry-run compression to see what would happen.\n * Matches Python client.simulate() behavior.\n */\n\nimport { HeadroomClient } from \"./client.js\";\nimport type { HeadroomClientOptions } from \"./types.js\";\nimport type { SimulationResult } from \"./types/models.js\";\nimport type { HeadroomConfig } from \"./types/config.js\";\nimport { deepCamelCase, deepSnakeCase } from \"./utils/case.js\";\nimport { detectFormat, toOpenAI } from \"./utils/format.js\";\n\nexport interface SimulateOptions extends HeadroomClientOptions {\n model?: string;\n config?: HeadroomConfig;\n client?: HeadroomClient;\n}\n\n/**\n * Simulate compression without calling the LLM.\n * Shows what compression would do: token savings, transforms, waste signals.\n *\n * @example\n * ```typescript\n * const sim = await simulate(messages, { model: 'gpt-4o' });\n * console.log(`Would save ${sim.tokensSaved} tokens (${sim.estimatedSavings})`);\n * console.log('Transforms:', sim.transforms);\n * ```\n */\nexport async function simulate(\n messages: any[],\n options: SimulateOptions = {},\n): Promise {\n const { client: providedClient, model, config, ...clientOptions } = options;\n\n const openaiMessages = toOpenAI(messages);\n const client = providedClient ?? new HeadroomClient(clientOptions);\n\n const body: Record = {\n messages: openaiMessages,\n model: model ?? \"gpt-4o\",\n config: {\n default_mode: \"simulate\",\n generate_diff_artifact: true,\n ...(config ? deepSnakeCase(config) : {}),\n },\n };\n\n // Use the client's internal fetch to hit /v1/compress with simulation config\n const result = await client.compressRaw(body);\n return deepCamelCase(result);\n}\n","/**\n * SharedContext — compressed inter-agent context sharing.\n * Matches Python headroom.shared_context.SharedContext.\n */\n\nimport { HeadroomClient } from \"./client.js\";\nimport type { HeadroomClientOptions, CompressResult } from \"./types.js\";\n\nexport interface ContextEntry {\n key: string;\n original: string;\n compressed: string;\n originalTokens: number;\n compressedTokens: number;\n agent: string | null;\n timestamp: number;\n transforms: string[];\n savingsPercent: number;\n}\n\nexport interface SharedContextStats {\n entries: number;\n totalOriginalTokens: number;\n totalCompressedTokens: number;\n totalTokensSaved: number;\n savingsPercent: number;\n}\n\nexport interface SharedContextOptions extends HeadroomClientOptions {\n model?: string;\n ttl?: number;\n maxEntries?: number;\n}\n\nexport class SharedContext {\n private entries = new Map();\n private client: HeadroomClient;\n private model: string;\n private ttl: number;\n private maxEntries: number;\n\n constructor(options: SharedContextOptions = {}) {\n const { model, ttl, maxEntries, ...clientOptions } = options;\n this.client = new HeadroomClient(clientOptions);\n this.model = model ?? \"claude-sonnet-4-5-20250929\";\n this.ttl = ttl ?? 3600;\n this.maxEntries = maxEntries ?? 100;\n }\n\n /**\n * Store content with compression.\n */\n async put(\n key: string,\n content: string,\n options?: { agent?: string },\n ): Promise {\n this.evictExpired();\n this.evictIfFull();\n\n const messages = [{ role: \"user\" as const, content }];\n let result: CompressResult;\n try {\n result = await this.client.compress(messages, { model: this.model });\n } catch {\n // If proxy unavailable, store uncompressed\n result = {\n messages,\n tokensBefore: content.length / 4, // rough estimate\n tokensAfter: content.length / 4,\n tokensSaved: 0,\n compressionRatio: 1.0,\n transformsApplied: [],\n ccrHashes: [],\n compressed: false,\n };\n }\n\n const compressed = result.compressed\n ? typeof result.messages[0]?.content === \"string\"\n ? result.messages[0].content\n : JSON.stringify(result.messages[0]?.content ?? content)\n : content;\n\n const entry: ContextEntry = {\n key,\n original: content,\n compressed,\n originalTokens: result.tokensBefore,\n compressedTokens: result.tokensAfter,\n agent: options?.agent ?? null,\n timestamp: Date.now() / 1000,\n transforms: result.transformsApplied,\n savingsPercent:\n result.tokensBefore > 0\n ? ((result.tokensBefore - result.tokensAfter) / result.tokensBefore) *\n 100\n : 0,\n };\n\n this.entries.set(key, entry);\n return entry;\n }\n\n /**\n * Get content by key. Returns compressed by default, full original if full=true.\n */\n get(key: string, options?: { full?: boolean }): string | null {\n const entry = this.entries.get(key);\n if (!entry) return null;\n\n if (Date.now() / 1000 - entry.timestamp > this.ttl) {\n this.entries.delete(key);\n return null;\n }\n\n return options?.full ? entry.original : entry.compressed;\n }\n\n /**\n * Get full entry metadata.\n */\n getEntry(key: string): ContextEntry | null {\n const entry = this.entries.get(key);\n if (!entry) return null;\n\n if (Date.now() / 1000 - entry.timestamp > this.ttl) {\n this.entries.delete(key);\n return null;\n }\n\n return entry;\n }\n\n /**\n * List all stored keys (excluding expired).\n */\n keys(): string[] {\n this.evictExpired();\n return Array.from(this.entries.keys());\n }\n\n /**\n * Get aggregated statistics.\n */\n stats(): SharedContextStats {\n this.evictExpired();\n let totalOriginal = 0;\n let totalCompressed = 0;\n\n for (const entry of this.entries.values()) {\n totalOriginal += entry.originalTokens;\n totalCompressed += entry.compressedTokens;\n }\n\n const saved = totalOriginal - totalCompressed;\n return {\n entries: this.entries.size,\n totalOriginalTokens: totalOriginal,\n totalCompressedTokens: totalCompressed,\n totalTokensSaved: saved,\n savingsPercent: totalOriginal > 0 ? (saved / totalOriginal) * 100 : 0,\n };\n }\n\n /**\n * Clear all entries.\n */\n clear(): void {\n this.entries.clear();\n }\n\n private evictExpired(): void {\n const now = Date.now() / 1000;\n for (const [key, entry] of this.entries) {\n if (now - entry.timestamp > this.ttl) {\n this.entries.delete(key);\n }\n }\n }\n\n private evictIfFull(): void {\n while (this.entries.size >= this.maxEntries) {\n const oldest = this.entries.keys().next().value;\n if (oldest !== undefined) this.entries.delete(oldest);\n }\n }\n}\n","/**\n * Canonical filesystem contract for Headroom — parity shell for the npm SDK.\n *\n * The TypeScript SDK is an HTTP client today and does not touch the\n * filesystem directly. This module mirrors `headroom/paths.py` so that\n * future local features (e.g. cache/log co-location with the Python\n * proxy) land on the same contract.\n *\n * Two canonical roots:\n * - HEADROOM_CONFIG_DIR — read-mostly configuration\n * (default: ~/.headroom/config)\n * - HEADROOM_WORKSPACE_DIR — read-write state\n * (default: ~/.headroom)\n *\n * Precedence for every per-resource helper is:\n * explicit argument > per-resource env var > derived from canonical\n * root > default.\n *\n * Browser behavior: when `process` is not available (typeof process ===\n * \"undefined\"), all helpers return the empty string. Consumers running\n * in a browser should not call these helpers; they exist here so the\n * shape of the API matches Python's `headroom.paths` module.\n */\n\n// ---------------------------------------------------------------------------\n// Env var names\n// ---------------------------------------------------------------------------\n\nexport const HEADROOM_CONFIG_DIR_ENV = \"HEADROOM_CONFIG_DIR\";\nexport const HEADROOM_WORKSPACE_DIR_ENV = \"HEADROOM_WORKSPACE_DIR\";\n\nexport const HEADROOM_SAVINGS_PATH_ENV = \"HEADROOM_SAVINGS_PATH\";\nexport const HEADROOM_TOIN_PATH_ENV = \"HEADROOM_TOIN_PATH\";\nexport const HEADROOM_SUBSCRIPTION_STATE_PATH_ENV =\n \"HEADROOM_SUBSCRIPTION_STATE_PATH\";\n\n// ---------------------------------------------------------------------------\n// Node / browser guard (mirrors the pattern in client.ts::getEnv)\n// ---------------------------------------------------------------------------\n\nfunction isNode(): boolean {\n return (\n typeof process !== \"undefined\" &&\n typeof process.versions !== \"undefined\" &&\n typeof process.versions.node === \"string\"\n );\n}\n\nfunction getEnv(name: string): string {\n if (typeof process === \"undefined\" || !process.env) {\n return \"\";\n }\n const value = process.env[name];\n return typeof value === \"string\" ? value.trim() : \"\";\n}\n\nfunction homeDir(): string {\n if (!isNode()) {\n return \"\";\n }\n // Prefer the userInfo API, fall back to HOME / USERPROFILE env vars.\n try {\n // eslint-disable-next-line @typescript-eslint/no-var-requires\n const os = require(\"os\") as { homedir?: () => string };\n if (os.homedir) {\n const home = os.homedir();\n if (home) return home;\n }\n } catch {\n // ignore — fall back to env\n }\n return getEnv(\"HOME\") || getEnv(\"USERPROFILE\") || \"\";\n}\n\nfunction joinPath(...parts: string[]): string {\n if (!isNode()) {\n // Browser fallback — keep it simple, use forward slashes.\n return parts.filter((p) => p !== \"\").join(\"/\");\n }\n try {\n // eslint-disable-next-line @typescript-eslint/no-var-requires\n const path = require(\"path\") as {\n join?: (...p: string[]) => string;\n };\n if (path.join) return path.join(...parts.filter((p) => p !== \"\"));\n } catch {\n // ignore — fall back\n }\n const sep = process.platform === \"win32\" ? \"\\\\\" : \"/\";\n return parts.filter((p) => p !== \"\").join(sep);\n}\n\nfunction expandTilde(p: string): string {\n if (!p.startsWith(\"~\")) return p;\n const home = homeDir();\n if (!home) return p;\n if (p === \"~\") return home;\n if (p.startsWith(\"~/\") || p.startsWith(\"~\\\\\")) {\n return joinPath(home, p.slice(2));\n }\n return p;\n}\n\nfunction resolve(\n explicit: string | undefined,\n envVar: string,\n derived: string,\n): string {\n if (!isNode()) {\n // Browser fallback: no filesystem, return empty string.\n return \"\";\n }\n if (explicit !== undefined && explicit !== \"\") {\n return expandTilde(explicit);\n }\n const envValue = getEnv(envVar);\n if (envValue) {\n return expandTilde(envValue);\n }\n return derived;\n}\n\n// ---------------------------------------------------------------------------\n// Canonical roots\n// ---------------------------------------------------------------------------\n\nexport function workspaceDir(): string {\n if (!isNode()) return \"\";\n const envValue = getEnv(HEADROOM_WORKSPACE_DIR_ENV);\n if (envValue) return expandTilde(envValue);\n const home = homeDir();\n if (!home) return \"\";\n return joinPath(home, \".headroom\");\n}\n\nexport function configDir(): string {\n if (!isNode()) return \"\";\n const envValue = getEnv(HEADROOM_CONFIG_DIR_ENV);\n if (envValue) return expandTilde(envValue);\n const workspaceEnv = getEnv(HEADROOM_WORKSPACE_DIR_ENV);\n if (workspaceEnv) {\n return joinPath(expandTilde(workspaceEnv), \"config\");\n }\n const home = homeDir();\n if (!home) return \"\";\n return joinPath(home, \".headroom\", \"config\");\n}\n\n// ---------------------------------------------------------------------------\n// Per-resource helpers -- workspace bucket\n// ---------------------------------------------------------------------------\n\nexport function savingsPath(explicit?: string): string {\n return resolve(\n explicit,\n HEADROOM_SAVINGS_PATH_ENV,\n joinPath(workspaceDir(), \"proxy_savings.json\"),\n );\n}\n\nexport function toinPath(explicit?: string): string {\n return resolve(\n explicit,\n HEADROOM_TOIN_PATH_ENV,\n joinPath(workspaceDir(), \"toin.json\"),\n );\n}\n\nexport function subscriptionStatePath(explicit?: string): string {\n return resolve(\n explicit,\n HEADROOM_SUBSCRIPTION_STATE_PATH_ENV,\n joinPath(workspaceDir(), \"subscription_state.json\"),\n );\n}\n\nexport function memoryDbPath(): string {\n if (!isNode()) return \"\";\n return joinPath(workspaceDir(), \"memory.db\");\n}\n\nexport function nativeMemoryDir(): string {\n if (!isNode()) return \"\";\n return joinPath(workspaceDir(), \"memories\");\n}\n\nexport function licenseCachePath(): string {\n if (!isNode()) return \"\";\n return joinPath(workspaceDir(), \"license_cache.json\");\n}\n\nexport function sessionStatsPath(): string {\n if (!isNode()) return \"\";\n return joinPath(workspaceDir(), \"session_stats.jsonl\");\n}\n\nexport function syncStatePath(): string {\n if (!isNode()) return \"\";\n return joinPath(workspaceDir(), \"sync_state.json\");\n}\n\nexport function bridgeStatePath(): string {\n if (!isNode()) return \"\";\n return joinPath(workspaceDir(), \"bridge_state.json\");\n}\n\nexport function logDir(): string {\n if (!isNode()) return \"\";\n return joinPath(workspaceDir(), \"logs\");\n}\n\nexport function proxyLogPath(): string {\n if (!isNode()) return \"\";\n return joinPath(logDir(), \"proxy.log\");\n}\n\nexport function debug400Dir(): string {\n if (!isNode()) return \"\";\n return joinPath(logDir(), \"debug_400\");\n}\n\nexport function binDir(): string {\n if (!isNode()) return \"\";\n return joinPath(workspaceDir(), \"bin\");\n}\n\nexport function deployRoot(): string {\n if (!isNode()) return \"\";\n return joinPath(workspaceDir(), \"deploy\");\n}\n\nexport function beaconLockPath(port: number): string {\n if (!isNode()) return \"\";\n return joinPath(workspaceDir(), `.beacon_lock_${Math.trunc(port)}`);\n}\n\n// ---------------------------------------------------------------------------\n// Per-resource helpers -- config bucket\n// ---------------------------------------------------------------------------\n\nexport function modelsConfigPath(): string {\n if (!isNode()) return \"\";\n return joinPath(configDir(), \"models.json\");\n}\n\n// ---------------------------------------------------------------------------\n// Plugin-author entry points\n// ---------------------------------------------------------------------------\n\nfunction assertPluginName(name: string): void {\n if (!name || name.includes(\"/\") || name.includes(\"\\\\\")) {\n throw new Error(`invalid plugin name: ${JSON.stringify(name)}`);\n }\n}\n\nexport function pluginConfigDir(pluginName: string): string {\n assertPluginName(pluginName);\n if (!isNode()) return \"\";\n return joinPath(configDir(), \"plugins\", pluginName);\n}\n\nexport function pluginWorkspaceDir(pluginName: string): string {\n assertPluginName(pluginName);\n if (!isNode()) return \"\";\n return joinPath(workspaceDir(), \"plugins\", pluginName);\n}\n"]} \ No newline at end of file diff --git a/node_modules/headroom-ai/dist/types-Cjdl75F4.d.cts b/node_modules/headroom-ai/dist/types-Cjdl75F4.d.cts new file mode 100644 index 00000000..bdc6780d --- /dev/null +++ b/node_modules/headroom-ai/dist/types-Cjdl75F4.d.cts @@ -0,0 +1,327 @@ +/** + * Compression hooks matching Python headroom.hooks. + * Allows customizing compression behavior with pre/post hooks. + */ +interface CompressContext { + model: string; + userQuery: string; + turnNumber: number; + toolCalls: string[]; + provider: string; +} +interface CompressEvent { + tokensBefore: number; + tokensAfter: number; + tokensSaved: number; + compressionRatio: number; + transformsApplied: string[]; + ccrHashes: string[]; + model: string; + userQuery: string; + provider: string; +} +/** + * Base class for compression hooks. Override methods to customize behavior. + * + * @example + * ```typescript + * class LoggingHooks extends CompressionHooks { + * postCompress(event: CompressEvent) { + * console.log(`Saved ${event.tokensSaved} tokens (${event.compressionRatio})`); + * } + * } + * ``` + */ +declare class CompressionHooks { + /** + * Called before compression. Modify messages before they're sent to the proxy. + */ + preCompress(messages: any[], _ctx: CompressContext): any[] | Promise; + /** + * Compute per-message compression biases. + * Return a map of message index -> bias (>1 = preserve more, <1 = compress more). + */ + computeBiases(_messages: any[], _ctx: CompressContext): Record | Promise>; + /** + * Called after compression. Observe-only — cannot modify the result. + */ + postCompress(_event: CompressEvent): void | Promise; +} +/** + * Extract the last user message text from a messages array (any format). + */ +declare function extractUserQuery(messages: any[]): string; +/** + * Count conversation turns (user+assistant pairs). + */ +declare function countTurns(messages: any[]): number; +/** + * Extract tool call names from messages. + */ +declare function extractToolCalls(messages: any[]): string[]; + +/** + * Configuration types matching Python headroom.config dataclasses. + * All fields are optional — the proxy uses defaults for omitted values. + */ +type HeadroomMode = "audit" | "optimize" | "simulate"; +/** Pipeline mode accepted by POST /v1/compress. Unset = default marker-free + * pipeline. Any other value is rejected (400) by the proxy. */ +type CompressMode = "ccr" | "lossy_inline" | "lossless_then_lossy"; +/** Config accepted by the POST /v1/compress endpoint. camelCase keys are + * snake_cased on the wire (e.g. targetRatio -> target_ratio). Every field is + * optional; only these keys are honored by the endpoint. */ +interface CompressRequestConfig { + /** Compression pipeline mode (unset = default marker-free pipeline). */ + mode?: CompressMode; + /** Target keep-ratio (0–1) for lossy compression. -> target_ratio */ + targetRatio?: number; + /** Also compress user-role messages (default false). -> compress_user_messages */ + compressUserMessages?: boolean; + /** Number of most-recent messages to protect from compression. -> protect_recent */ + protectRecent?: number; + /** Protect analysis/reasoning context from compression. -> protect_analysis_context */ + protectAnalysisContext?: boolean; + /** Freeze the first N messages (prefix) from compression. -> frozen_message_count */ + frozenMessageCount?: number; + /** Session id for session-aware compression. -> session_id */ + sessionId?: string; +} +type RelevanceTier = "bm25" | "embedding" | "hybrid"; +type ContentType = "json" | "code" | "logs" | "text" | "html" | "diff" | "search" | "unknown"; +type BlockKind = "system" | "user" | "assistant" | "tool_call" | "tool_result" | "rag" | "unknown"; +interface ToolCrusherConfig { + enabled?: boolean; + minTokensToCrush?: number; + maxArrayItems?: number; + maxStringLength?: number; + maxDepth?: number; + preserveKeys?: string[]; + toolProfiles?: Record>; +} +interface CacheAlignerConfig { + enabled?: boolean; + useDynamicDetector?: boolean; + detectionTiers?: ("regex" | "ner" | "semantic")[]; + extraDynamicLabels?: string[]; + entropyThreshold?: number; + datePatterns?: string[]; + normalizeWhitespace?: boolean; + collapseBlankLines?: boolean; + dynamicTailSeparator?: string; +} +interface RollingWindowConfig { + enabled?: boolean; + keepSystem?: boolean; + keepLastTurns?: number; + outputBufferTokens?: number; +} +interface ScoringWeights { + recency?: number; + semanticSimilarity?: number; + toinImportance?: number; + errorIndicator?: number; + forwardReference?: number; + tokenDensity?: number; +} +interface IntelligentContextConfig { + enabled?: boolean; + keepSystem?: boolean; + keepLastTurns?: number; + outputBufferTokens?: number; + useImportanceScoring?: boolean; + scoringWeights?: ScoringWeights; + recencyDecayRate?: number; + toinIntegration?: boolean; + toinConfidenceThreshold?: number; + compressThreshold?: number; + summarizationEnabled?: boolean; + summarizationModel?: string | null; + summaryMaxTokens?: number; + summarizeThreshold?: number; +} +interface RelevanceScorerConfig { + tier?: RelevanceTier; + bm25K1?: number; + bm25B?: number; + embeddingModel?: string; + hybridAlpha?: number; + adaptiveAlpha?: boolean; + relevanceThreshold?: number; +} +interface AnchorConfig { + anchorBudgetPct?: number; + minAnchorSlots?: number; + maxAnchorSlots?: number; + defaultFrontWeight?: number; + defaultBackWeight?: number; + defaultMiddleWeight?: number; + useInformationDensity?: boolean; + dedupIdenticalItems?: boolean; +} +interface SmartCrusherConfig { + enabled?: boolean; + minItemsToAnalyze?: number; + minTokensToCrush?: number; + varianceThreshold?: number; + uniquenessThreshold?: number; + similarityThreshold?: number; + maxItemsAfterCrush?: number; + preserveChangePoints?: boolean; + useFeedbackHints?: boolean; + toinConfidenceThreshold?: number; + relevance?: RelevanceScorerConfig; + anchor?: AnchorConfig; + dedupIdenticalItems?: boolean; + firstFraction?: number; + lastFraction?: number; +} +interface CacheOptimizerConfig { + enabled?: boolean; + autoDetectProvider?: boolean; + minCacheableTokens?: number; + enableSemanticCache?: boolean; + semanticCacheSimilarity?: number; + semanticCacheMaxEntries?: number; + semanticCacheTtlSeconds?: number; +} +interface CCRConfig { + enabled?: boolean; + storeMaxEntries?: number; + storeTtlSeconds?: number; + injectRetrievalMarker?: boolean; + feedbackEnabled?: boolean; + minItemsToCache?: number; + injectTool?: boolean; + injectSystemInstructions?: boolean; + markerTemplate?: string; +} +interface PrefixFreezeConfig { + enabled?: boolean; + minCachedTokens?: number; + sessionTtlSeconds?: number; + forceCompressThreshold?: number; +} +interface ReadLifecycleConfig { + enabled?: boolean; + compressStale?: boolean; + compressSuperseded?: boolean; + minSizeBytes?: number; +} +interface CompressionProfile { + bias?: number; + minK?: number; + maxK?: number | null; +} +interface HeadroomConfig { + storeUrl?: string; + defaultMode?: HeadroomMode; + modelContextLimits?: Record; + toolCrusher?: ToolCrusherConfig; + smartCrusher?: SmartCrusherConfig; + cacheAligner?: CacheAlignerConfig; + rollingWindow?: RollingWindowConfig; + cacheOptimizer?: CacheOptimizerConfig; + ccr?: CCRConfig; + prefixFreeze?: PrefixFreezeConfig; + contentRouterEnabled?: boolean; + intelligentContext?: IntelligentContextConfig; + generateDiffArtifact?: boolean; +} + +/** + * Core types for the Headroom TypeScript SDK. + * Error classes moved to errors.ts — re-exported here for backwards compatibility. + */ + +interface TextContentPart { + type: "text"; + text: string; +} +interface ImageContentPart { + type: "image_url"; + image_url: { + url: string; + detail?: "auto" | "low" | "high"; + }; +} +type ContentPart = TextContentPart | ImageContentPart; +interface ToolCall { + id: string; + type: "function"; + function: { + name: string; + arguments: string; + }; +} +interface SystemMessage { + role: "system"; + content: string; +} +interface UserMessage { + role: "user"; + content: string | ContentPart[]; +} +interface AssistantMessage { + role: "assistant"; + content: string | null; + tool_calls?: ToolCall[]; +} +interface ToolMessage { + role: "tool"; + content: string; + tool_call_id: string; +} +type OpenAIMessage = SystemMessage | UserMessage | AssistantMessage | ToolMessage; +interface CompressOptions { + model?: string; + baseUrl?: string; + apiKey?: string; + timeout?: number; + fallback?: boolean; + retries?: number; + client?: HeadroomClientInterface; + /** Token budget — compress to fit within this limit. Used for compaction. */ + tokenBudget?: number; + /** Compression hooks for pre/post processing. */ + hooks?: CompressionHooks; + /** Integration slug sent as X-Headroom-Stack (e.g. "adapter_ts_openai"). */ + stack?: string; + /** Per-call /v1/compress config, merged over the client-level config + * (per-call keys override at the top level). */ + config?: CompressRequestConfig; +} +interface CompressResult { + /** + * Compressed messages in the same format as input. Typed `any[]` because the + * universal `compress()` returns messages in the *input* format — it calls + * `fromOpenAI(messages, inputFormat)`, so for Anthropic/Vercel/Gemini input + * these are not OpenAI-shaped. See the note on `HeadroomClient.compress`. + */ + messages: any[]; + tokensBefore: number; + tokensAfter: number; + tokensSaved: number; + compressionRatio: number; + transformsApplied: string[]; + ccrHashes: string[]; + compressed: boolean; +} +interface HeadroomClientOptions { + baseUrl?: string; + apiKey?: string; + timeout?: number; + fallback?: boolean; + retries?: number; + /** Integration slug sent as X-Headroom-Stack on every request. */ + stack?: string; +} +interface HeadroomClientInterface { + compress(messages: OpenAIMessage[], options?: { + model?: string; + tokenBudget?: number; + config?: CompressRequestConfig; + }): Promise; +} + +export { type AnchorConfig as A, type BlockKind as B, type CompressOptions as C, extractToolCalls as D, extractUserQuery as E, type HeadroomClientOptions as H, type ImageContentPart as I, type OpenAIMessage as O, type PrefixFreezeConfig as P, type ReadLifecycleConfig as R, type ScoringWeights as S, type TextContentPart as T, type UserMessage as U, type CompressResult as a, type HeadroomMode as b, type CompressRequestConfig as c, type HeadroomClientInterface as d, type HeadroomConfig as e, type AssistantMessage as f, type CCRConfig as g, type CacheAlignerConfig as h, type CacheOptimizerConfig as i, type CompressContext as j, type CompressEvent as k, type CompressMode as l, CompressionHooks as m, type CompressionProfile as n, type ContentPart as o, type ContentType as p, type IntelligentContextConfig as q, type RelevanceScorerConfig as r, type RelevanceTier as s, type RollingWindowConfig as t, type SmartCrusherConfig as u, type SystemMessage as v, type ToolCall as w, type ToolCrusherConfig as x, type ToolMessage as y, countTurns as z }; diff --git a/node_modules/headroom-ai/dist/types-Cjdl75F4.d.ts b/node_modules/headroom-ai/dist/types-Cjdl75F4.d.ts new file mode 100644 index 00000000..bdc6780d --- /dev/null +++ b/node_modules/headroom-ai/dist/types-Cjdl75F4.d.ts @@ -0,0 +1,327 @@ +/** + * Compression hooks matching Python headroom.hooks. + * Allows customizing compression behavior with pre/post hooks. + */ +interface CompressContext { + model: string; + userQuery: string; + turnNumber: number; + toolCalls: string[]; + provider: string; +} +interface CompressEvent { + tokensBefore: number; + tokensAfter: number; + tokensSaved: number; + compressionRatio: number; + transformsApplied: string[]; + ccrHashes: string[]; + model: string; + userQuery: string; + provider: string; +} +/** + * Base class for compression hooks. Override methods to customize behavior. + * + * @example + * ```typescript + * class LoggingHooks extends CompressionHooks { + * postCompress(event: CompressEvent) { + * console.log(`Saved ${event.tokensSaved} tokens (${event.compressionRatio})`); + * } + * } + * ``` + */ +declare class CompressionHooks { + /** + * Called before compression. Modify messages before they're sent to the proxy. + */ + preCompress(messages: any[], _ctx: CompressContext): any[] | Promise; + /** + * Compute per-message compression biases. + * Return a map of message index -> bias (>1 = preserve more, <1 = compress more). + */ + computeBiases(_messages: any[], _ctx: CompressContext): Record | Promise>; + /** + * Called after compression. Observe-only — cannot modify the result. + */ + postCompress(_event: CompressEvent): void | Promise; +} +/** + * Extract the last user message text from a messages array (any format). + */ +declare function extractUserQuery(messages: any[]): string; +/** + * Count conversation turns (user+assistant pairs). + */ +declare function countTurns(messages: any[]): number; +/** + * Extract tool call names from messages. + */ +declare function extractToolCalls(messages: any[]): string[]; + +/** + * Configuration types matching Python headroom.config dataclasses. + * All fields are optional — the proxy uses defaults for omitted values. + */ +type HeadroomMode = "audit" | "optimize" | "simulate"; +/** Pipeline mode accepted by POST /v1/compress. Unset = default marker-free + * pipeline. Any other value is rejected (400) by the proxy. */ +type CompressMode = "ccr" | "lossy_inline" | "lossless_then_lossy"; +/** Config accepted by the POST /v1/compress endpoint. camelCase keys are + * snake_cased on the wire (e.g. targetRatio -> target_ratio). Every field is + * optional; only these keys are honored by the endpoint. */ +interface CompressRequestConfig { + /** Compression pipeline mode (unset = default marker-free pipeline). */ + mode?: CompressMode; + /** Target keep-ratio (0–1) for lossy compression. -> target_ratio */ + targetRatio?: number; + /** Also compress user-role messages (default false). -> compress_user_messages */ + compressUserMessages?: boolean; + /** Number of most-recent messages to protect from compression. -> protect_recent */ + protectRecent?: number; + /** Protect analysis/reasoning context from compression. -> protect_analysis_context */ + protectAnalysisContext?: boolean; + /** Freeze the first N messages (prefix) from compression. -> frozen_message_count */ + frozenMessageCount?: number; + /** Session id for session-aware compression. -> session_id */ + sessionId?: string; +} +type RelevanceTier = "bm25" | "embedding" | "hybrid"; +type ContentType = "json" | "code" | "logs" | "text" | "html" | "diff" | "search" | "unknown"; +type BlockKind = "system" | "user" | "assistant" | "tool_call" | "tool_result" | "rag" | "unknown"; +interface ToolCrusherConfig { + enabled?: boolean; + minTokensToCrush?: number; + maxArrayItems?: number; + maxStringLength?: number; + maxDepth?: number; + preserveKeys?: string[]; + toolProfiles?: Record>; +} +interface CacheAlignerConfig { + enabled?: boolean; + useDynamicDetector?: boolean; + detectionTiers?: ("regex" | "ner" | "semantic")[]; + extraDynamicLabels?: string[]; + entropyThreshold?: number; + datePatterns?: string[]; + normalizeWhitespace?: boolean; + collapseBlankLines?: boolean; + dynamicTailSeparator?: string; +} +interface RollingWindowConfig { + enabled?: boolean; + keepSystem?: boolean; + keepLastTurns?: number; + outputBufferTokens?: number; +} +interface ScoringWeights { + recency?: number; + semanticSimilarity?: number; + toinImportance?: number; + errorIndicator?: number; + forwardReference?: number; + tokenDensity?: number; +} +interface IntelligentContextConfig { + enabled?: boolean; + keepSystem?: boolean; + keepLastTurns?: number; + outputBufferTokens?: number; + useImportanceScoring?: boolean; + scoringWeights?: ScoringWeights; + recencyDecayRate?: number; + toinIntegration?: boolean; + toinConfidenceThreshold?: number; + compressThreshold?: number; + summarizationEnabled?: boolean; + summarizationModel?: string | null; + summaryMaxTokens?: number; + summarizeThreshold?: number; +} +interface RelevanceScorerConfig { + tier?: RelevanceTier; + bm25K1?: number; + bm25B?: number; + embeddingModel?: string; + hybridAlpha?: number; + adaptiveAlpha?: boolean; + relevanceThreshold?: number; +} +interface AnchorConfig { + anchorBudgetPct?: number; + minAnchorSlots?: number; + maxAnchorSlots?: number; + defaultFrontWeight?: number; + defaultBackWeight?: number; + defaultMiddleWeight?: number; + useInformationDensity?: boolean; + dedupIdenticalItems?: boolean; +} +interface SmartCrusherConfig { + enabled?: boolean; + minItemsToAnalyze?: number; + minTokensToCrush?: number; + varianceThreshold?: number; + uniquenessThreshold?: number; + similarityThreshold?: number; + maxItemsAfterCrush?: number; + preserveChangePoints?: boolean; + useFeedbackHints?: boolean; + toinConfidenceThreshold?: number; + relevance?: RelevanceScorerConfig; + anchor?: AnchorConfig; + dedupIdenticalItems?: boolean; + firstFraction?: number; + lastFraction?: number; +} +interface CacheOptimizerConfig { + enabled?: boolean; + autoDetectProvider?: boolean; + minCacheableTokens?: number; + enableSemanticCache?: boolean; + semanticCacheSimilarity?: number; + semanticCacheMaxEntries?: number; + semanticCacheTtlSeconds?: number; +} +interface CCRConfig { + enabled?: boolean; + storeMaxEntries?: number; + storeTtlSeconds?: number; + injectRetrievalMarker?: boolean; + feedbackEnabled?: boolean; + minItemsToCache?: number; + injectTool?: boolean; + injectSystemInstructions?: boolean; + markerTemplate?: string; +} +interface PrefixFreezeConfig { + enabled?: boolean; + minCachedTokens?: number; + sessionTtlSeconds?: number; + forceCompressThreshold?: number; +} +interface ReadLifecycleConfig { + enabled?: boolean; + compressStale?: boolean; + compressSuperseded?: boolean; + minSizeBytes?: number; +} +interface CompressionProfile { + bias?: number; + minK?: number; + maxK?: number | null; +} +interface HeadroomConfig { + storeUrl?: string; + defaultMode?: HeadroomMode; + modelContextLimits?: Record; + toolCrusher?: ToolCrusherConfig; + smartCrusher?: SmartCrusherConfig; + cacheAligner?: CacheAlignerConfig; + rollingWindow?: RollingWindowConfig; + cacheOptimizer?: CacheOptimizerConfig; + ccr?: CCRConfig; + prefixFreeze?: PrefixFreezeConfig; + contentRouterEnabled?: boolean; + intelligentContext?: IntelligentContextConfig; + generateDiffArtifact?: boolean; +} + +/** + * Core types for the Headroom TypeScript SDK. + * Error classes moved to errors.ts — re-exported here for backwards compatibility. + */ + +interface TextContentPart { + type: "text"; + text: string; +} +interface ImageContentPart { + type: "image_url"; + image_url: { + url: string; + detail?: "auto" | "low" | "high"; + }; +} +type ContentPart = TextContentPart | ImageContentPart; +interface ToolCall { + id: string; + type: "function"; + function: { + name: string; + arguments: string; + }; +} +interface SystemMessage { + role: "system"; + content: string; +} +interface UserMessage { + role: "user"; + content: string | ContentPart[]; +} +interface AssistantMessage { + role: "assistant"; + content: string | null; + tool_calls?: ToolCall[]; +} +interface ToolMessage { + role: "tool"; + content: string; + tool_call_id: string; +} +type OpenAIMessage = SystemMessage | UserMessage | AssistantMessage | ToolMessage; +interface CompressOptions { + model?: string; + baseUrl?: string; + apiKey?: string; + timeout?: number; + fallback?: boolean; + retries?: number; + client?: HeadroomClientInterface; + /** Token budget — compress to fit within this limit. Used for compaction. */ + tokenBudget?: number; + /** Compression hooks for pre/post processing. */ + hooks?: CompressionHooks; + /** Integration slug sent as X-Headroom-Stack (e.g. "adapter_ts_openai"). */ + stack?: string; + /** Per-call /v1/compress config, merged over the client-level config + * (per-call keys override at the top level). */ + config?: CompressRequestConfig; +} +interface CompressResult { + /** + * Compressed messages in the same format as input. Typed `any[]` because the + * universal `compress()` returns messages in the *input* format — it calls + * `fromOpenAI(messages, inputFormat)`, so for Anthropic/Vercel/Gemini input + * these are not OpenAI-shaped. See the note on `HeadroomClient.compress`. + */ + messages: any[]; + tokensBefore: number; + tokensAfter: number; + tokensSaved: number; + compressionRatio: number; + transformsApplied: string[]; + ccrHashes: string[]; + compressed: boolean; +} +interface HeadroomClientOptions { + baseUrl?: string; + apiKey?: string; + timeout?: number; + fallback?: boolean; + retries?: number; + /** Integration slug sent as X-Headroom-Stack on every request. */ + stack?: string; +} +interface HeadroomClientInterface { + compress(messages: OpenAIMessage[], options?: { + model?: string; + tokenBudget?: number; + config?: CompressRequestConfig; + }): Promise; +} + +export { type AnchorConfig as A, type BlockKind as B, type CompressOptions as C, extractToolCalls as D, extractUserQuery as E, type HeadroomClientOptions as H, type ImageContentPart as I, type OpenAIMessage as O, type PrefixFreezeConfig as P, type ReadLifecycleConfig as R, type ScoringWeights as S, type TextContentPart as T, type UserMessage as U, type CompressResult as a, type HeadroomMode as b, type CompressRequestConfig as c, type HeadroomClientInterface as d, type HeadroomConfig as e, type AssistantMessage as f, type CCRConfig as g, type CacheAlignerConfig as h, type CacheOptimizerConfig as i, type CompressContext as j, type CompressEvent as k, type CompressMode as l, CompressionHooks as m, type CompressionProfile as n, type ContentPart as o, type ContentType as p, type IntelligentContextConfig as q, type RelevanceScorerConfig as r, type RelevanceTier as s, type RollingWindowConfig as t, type SmartCrusherConfig as u, type SystemMessage as v, type ToolCall as w, type ToolCrusherConfig as x, type ToolMessage as y, countTurns as z }; diff --git a/node_modules/headroom-ai/package.json b/node_modules/headroom-ai/package.json new file mode 100644 index 00000000..155cf325 --- /dev/null +++ b/node_modules/headroom-ai/package.json @@ -0,0 +1,112 @@ +{ + "name": "headroom-ai", + "version": "0.39.1", + "description": "Compress LLM context. Save tokens. Fit more into every request.", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./vercel-ai": { + "import": { + "types": "./dist/adapters/vercel-ai.d.ts", + "default": "./dist/adapters/vercel-ai.js" + }, + "require": { + "types": "./dist/adapters/vercel-ai.d.cts", + "default": "./dist/adapters/vercel-ai.cjs" + } + }, + "./openai": { + "import": { + "types": "./dist/adapters/openai.d.ts", + "default": "./dist/adapters/openai.js" + }, + "require": { + "types": "./dist/adapters/openai.d.cts", + "default": "./dist/adapters/openai.cjs" + } + }, + "./anthropic": { + "import": { + "types": "./dist/adapters/anthropic.d.ts", + "default": "./dist/adapters/anthropic.js" + }, + "require": { + "types": "./dist/adapters/anthropic.d.cts", + "default": "./dist/adapters/anthropic.cjs" + } + }, + "./gemini": { + "import": { + "types": "./dist/adapters/gemini.d.ts", + "default": "./dist/adapters/gemini.js" + }, + "require": { + "types": "./dist/adapters/gemini.d.cts", + "default": "./dist/adapters/gemini.cjs" + } + } + }, + "engines": { + "node": ">=18.0.0" + }, + "scripts": { + "build": "tsup", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "@ai-sdk/provider": ">=1.0.0", + "@anthropic-ai/sdk": ">=0.30.0", + "ai": ">=6.0.0", + "openai": ">=4.0.0" + }, + "peerDependenciesMeta": { + "ai": { + "optional": true + }, + "@ai-sdk/provider": { + "optional": true + }, + "openai": { + "optional": true + }, + "@anthropic-ai/sdk": { + "optional": true + } + }, + "devDependencies": { + "@ai-sdk/anthropic": "^4.0.53", + "@ai-sdk/openai": "^4.0.60", + "@ai-sdk/provider": "^4.0.14", + "@anthropic-ai/sdk": "^0.127.0", + "@types/node": "^26.5.0", + "ai": "^7.0.59", + "dotenv": "^18.0.1", + "openai": "^7.10.0", + "tsup": "^8.0.0", + "typescript": "^6.0.3", + "vitest": "^5.0.0" + }, + "overrides": { + "esbuild": "^0.28.1" + }, + "license": "Apache-2.0" +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..c7f29f49 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,41 @@ +{ + "name": "SinalACS", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "headroom-ai": "^0.39.1" + } + }, + "node_modules/headroom-ai": { + "version": "0.39.1", + "resolved": "https://registry.npmjs.org/headroom-ai/-/headroom-ai-0.39.1.tgz", + "integrity": "sha512-jMItSdHA444lCXxibw/XlyhPt9vp09LuLgLNVG6/1Ay0lOZEUhrwlezsMMhr98RMgIe9AejhIQef91cvUgiWgA==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@ai-sdk/provider": ">=1.0.0", + "@anthropic-ai/sdk": ">=0.30.0", + "ai": ">=6.0.0", + "openai": ">=4.0.0" + }, + "peerDependenciesMeta": { + "@ai-sdk/provider": { + "optional": true + }, + "@anthropic-ai/sdk": { + "optional": true + }, + "ai": { + "optional": true + }, + "openai": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..eeddae2b --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "headroom-ai": "^0.39.1" + } +} From 32ddad5fd6211b736a995391ba4ede9cf78c02fd Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Mon, 28 Sep 2026 18:21:57 -0400 Subject: [PATCH 013/498] docs: atualizar CLAUDE.md e AGENTS.md ao estado atual do projeto MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Estado do protótipo: registrar o que já foi entregue (RF01, RF07, RBAC/RNF06, TLS no RPC/RNF04, TLS + ACL no broker, colunas cifradas/RNF03) e o que falta (MFA e refresh token adiados, mTLS no broker, backend do admin, deploy de produção), no lugar de "não há autenticação institucional real" - AGENTS.md: CI passa de seis para os oito jobs reais (FINDING-1 da avaliação de CI), com nota sobre a ausência de branch protection - Endpoints principais atualizados (login real em vez de developmentLogin) - Infra local: HTTPS em :443, MQTT só sobre TLS em :8883, seed em 4 etapas - Offline: SQLite/SQLCipher e SyncFsm descritos como já em uso - Aviso de que spec/validation_report.md está desatualizado - CLAUDE.md: corrigir referência quebrada à nota de tokens de contraste WCAG (agora em apps/CLAUDE.md) Signed-off-by: Herbert Rocha --- .claude/.headroom_wrap_marker.json | 1 + .claude/.headroom_wrap_owners.json | 26 ++++++++++++++++++++++++ AGENTS.md | 32 ++++++++++++++++++++---------- CLAUDE.md | 13 +++++++++--- 4 files changed, 58 insertions(+), 14 deletions(-) create mode 100644 .claude/.headroom_wrap_marker.json create mode 100644 .claude/.headroom_wrap_owners.json diff --git a/.claude/.headroom_wrap_marker.json b/.claude/.headroom_wrap_marker.json new file mode 100644 index 00000000..0811ea36 --- /dev/null +++ b/.claude/.headroom_wrap_marker.json @@ -0,0 +1 @@ +{"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 new file mode 100644 index 00000000..bef679b3 --- /dev/null +++ b/.claude/.headroom_wrap_owners.json @@ -0,0 +1,26 @@ +{ + "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/AGENTS.md b/AGENTS.md index 4ffc33c0..a2524f2a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,10 +9,13 @@ O repositório já contém um protótipo funcional com: - apps Flutter para paciente e ACS; - PostgreSQL como persistência central; - broker MQTT para entrega de alertas em tempo real; -- fluxo de sincronização offline para visitas do ACS. +- fluxo de sincronização offline para visitas do ACS; +- app administrativo (`apps/admin`) somente leitura, ainda sobre dados mock. + +Já entregue: login institucional do ACS (RF07, matrícula + senha com Argon2id), login passwordless do paciente (RF01, CPF + nascimento + OTP), RBAC por microárea (RNF06), TLS no RPC via Traefik (RNF04) e TLS no broker MQTT com senha e ACL, colunas clínicas cifradas (RNF03), painel "Meus Dados" (LGPD) no app do paciente. Os dois fluxos centrais continuam sendo: -- paciente: autenticação simples, alerta de urgência, triagem estruturada e acompanhamento de status; +- paciente: autenticação passwordless (OTP), alerta de urgência, triagem estruturada acompanhamento de status e "Meus Dados"; - ACS: priorização dinâmica, territorialização por microárea, registro offline e acompanhamento do território. ## Leitura obrigatória antes de implementar @@ -27,9 +30,12 @@ Antes de mexer em produto, arquitetura ou comportamento, consulte primeiro: - [spec/ux_accessibility_assessment.md](spec/ux_accessibility_assessment.md) — auditoria WCAG 2.2 AA (contraste, alvo de toque, semântica) dos apps ACS, paciente e admin; - [spec/ux_ui_test_plan.md](spec/ux_ui_test_plan.md) — plano de testes de UX/UI derivado de `spec/ui_design.md`; - [CLAUDE.md](CLAUDE.md) — guia técnico e comandos para IA; é a referência mais atualizada do repositório; -- [PROGRESS.md](PROGRESS.md) — status dos milestones e histórico de migração; +- [PROGRESS.md](PROGRESS.md) — status dos milestones, histórico de migração e itens em aberto com dono; +- [spec/security_assessment.md](spec/security_assessment.md) — avaliação de segurança; +- [spec/validation_report.md](spec/validation_report.md) — relatório de validação; **está desatualizado nos dois sentidos** (lista como abertos itens já fechados e traz contagens de teste muito abaixo das reais) — confira no código antes de confiar; +- [backend/CLAUDE.md](backend/CLAUDE.md) e [apps/CLAUDE.md](apps/CLAUDE.md) — detalhes técnicos de backend e apps; - [backend/](backend) — workspace Dart com `sinalacs_server` e `sinalacs_client`; -- [apps/acs](apps/acs) e [apps/patient](apps/patient) — aplicativos Flutter reais; +- [apps/acs](apps/acs), [apps/patient](apps/patient) e [apps/admin](apps/admin) — aplicativos Flutter reais; - [spec/ui_acs](spec/ui_acs) e [spec/ui_paciente](spec/ui_paciente) — protótipos visuais e fluxos do produto. Quando houver conflito entre convenções gerais e documentação do projeto, a documentação do projeto vence. @@ -49,10 +55,11 @@ Não violar estes pontos sob qualquer hipótese: ### Backend - O backend está em um workspace Dart em [backend/](backend), com os pacotes `sinalacs_server` e `sinalacs_client`. - A stack atual é Serverpod, não um servidor hand-rolled em `dart:io`. -- O servidor usa PostgreSQL e MQTT; a autenticação de desenvolvimento é opcional e não substitui autenticação institucional real. +- O servidor usa PostgreSQL e MQTT. A autenticação real é `auth.loginInstitutional` (ACS) e `auth.requestOtp`/`auth.verifyOtp` (paciente); `auth.developmentLogin` só existe com `ENABLE_DEV_LOGIN` e não deve ser usado como caminho de produção. +- Colunas clínicas sensíveis (`patients.chronicConditions`, `triage_sessions.answers`, `visits.notes`) são cifradas com AES-256-GCM (pares `*Encrypted`/`*KeyVersion`, chave em `HEALTH_DATA_ENCRYPTION_KEY`). - A camada de domínio fica em `backend/sinalacs_server/lib/src/application/` e a infraestrutura em `.../infrastructure/`. - Os modelos e endpoints são definidos com Serverpod; não se deve editar manualmente arquivos gerados em `lib/src/generated/` ou migrações sem regenerar via `serverpod generate` e `serverpod create-migration`. -- O fluxo principal inclui: `auth.developmentLogin`, `triage.evaluate`, `alerts.createRedAlert`, `alerts.acknowledge` e `visits.sync`. +- Endpoints principais: `auth` (`loginInstitutional`, `requestOtp`, `verifyOtp`, `developmentLogin`), `onboarding` (`generateEnrollmentToken`, `completeEnrollment`), `triage.evaluate`, `alerts` (`createRedAlert`, `acknowledge`, `statusFor`), `patients` (`listMicroArea`, `myData`, `myChronicConditions`, `updateChronicConditions`), `visits` (`sync`, `pull`) e `health.check`. - MQTT conecta em background após boot, com reconexão exponencial e sem bloquear a API. ### Apps Flutter @@ -64,7 +71,8 @@ Não violar estes pontos sob qualquer hipótese: - [apps/admin](apps/admin) (`sinalacs_admin`) é o backoffice administrativo — deixou de ser um esqueleto de pubspec e hoje é um app navegável real, com 4 telas somente leitura (Indicadores, Microáreas, Alertas, Auditoria) atrás de `AdminHomeShell`. Ainda não consome `sinalacs_client`: usa a interface `AdminDataSource`, hoje implementada só por `MockAdminDataSource`, seguindo o mesmo padrão de DI de `PatientBackend`/`AcsBackend`. O login é local e não chama `auth.developmentLogin` (o backend só aceita `role: 'patient'`/`role: 'acs'` hoje). Toda tela que exibe dado sensível registra o próprio acesso via `recordAccess()` antes de renderizar, por exigência de auditoria do PRD §4.2.2. É o único dos três apps com suporte a Flutter Web, e desde a adição da plataforma Android também roda em celular e tablet (`flutter run -d emulator-5554`). O layout segue desktop-first: os pontos de quebra ficam em `lib/app/admin_layout.dart` e o layout compacto é complemento, nunca substituição — os dez testes de widget originais continuam passando sem edição, o que é o que prova isso. ### Infraestrutura local -- O ambiente de desenvolvimento usa Docker Compose com PostgreSQL, Mosquitto, backend e Traefik. +- O ambiente de desenvolvimento usa Docker Compose com PostgreSQL, Mosquitto, backend e Traefik. O backend é exposto só em HTTPS (`https://localhost/`, TLS terminado no Traefik) e o broker só em MQTT sobre TLS (`localhost:8883`). +- O seed de desenvolvimento roda em quatro etapas (`database-seed`, `health-data-seed`, `acs-credential-seed`, `cpf-hash-seed`); as três últimas existem porque SQL puro não produz valores cifrados, hashes Argon2id nem o HMAC do CPF. - Há geração local de segredos via [scripts/dev/bootstrap_env.sh](scripts/dev/bootstrap_env.sh); o projeto não possui `.env` versionado. - O servidor e o broker exigem valores configurados por ambiente; não reaproveitar credenciais do ambiente local em produção. @@ -96,10 +104,10 @@ Não violar estes pontos sob qualquer hipótese: - Manter dark mode, alta legibilidade e baixo ruído visual. - Usar cores apenas para sinal clínico; não decorar interfaces com vermelho/amarelo/verde sem relação com risco. - Manter foco em mobile-first e acessibilidade. -- Ao reaproveitar uma cor clínica de preenchimento (`red`/`accent`/`danger`) como cor de texto/ícone, usar a variante `*OnSurface` (`acs_theme.dart`/`patient_theme.dart`/`admin_theme.dart`) e medir contraste contra a superfície real (`Card`/`surfaceRaised`), não contra o fundo do Scaffold — ver `spec/ux_accessibility_assessment.md` e os testes em `test/contrast_tokens_test.dart` de cada app. +- Ao reaproveitar uma cor clínica de preenchimento (`red`/`accent`/`danger`/`yellow`/`green`) como cor de texto/ícone, usar a variante `*OnSurface` (`acs_theme.dart`/`patient_theme.dart`/`admin_theme.dart`) e medir contraste contra a superfície real (`Card`/`surfaceRaised`), não contra o fundo do Scaffold — ver `spec/ux_accessibility_assessment.md` e os testes em `test/contrast_tokens_test.dart` de cada app. ### 6) Quando o trabalho for de backend ou dados -- Considerar uso de SQLite/SQLCipher e filas locais para operação offline. +- A operação offline do ACS usa SQLite/SQLCipher e fila local (`offline_visit_queue.dart`); no backend, a sincronização passa pela `SyncFsm`. - Planejar retry, reconciliação de conflitos e rastreabilidade de eventos. - Manter a lógica de sincronização e persistência consistentes com o fluxo de visitas do ACS. @@ -135,13 +143,15 @@ cd apps/admin && flutter test integration_test -d emulator-5554 # hermético: Importante: - `flutter test` é hermético e não substitui validações com stack local real; - os testes de integração e validação de conexão vivem fora do `flutter test` e utilizam a stack Docker/VM; -- o CI do projeto valida seis jobs separados: `serverpod-backend`, `backend-docker-build`, `patient-app`, `acs-app`, `admin-app` e `admin-android-build` (único que executa Gradle, compilando o APK do admin). +- 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)) tem oito jobs: `serverpod-backend`, `backend-docker-build`, `patient-app`, `acs-app`, `admin-app`, `coverage-report`, `android-e2e` (único que sobe emulador Android contra a stack; hoje o mais instável) e `admin-android-build` (compila o APK do admin); +- nem `main` nem `develop` têm branch protection: CI vermelho não bloqueia merge — ver [docs/ci-audit/2026-09-28-avaliacao-ci-develop.md](docs/ci-audit/2026-09-28-avaliacao-ci-develop.md). ## Observações finais Este repositório já não é apenas um conjunto de especificações. Ele contém um protótipo funcional validado localmente em stack Docker, com backend, apps reais e infraestrutura mínima operável para desenvolvimento. -O estado atual não é produção: não há autenticação institucional real, não há mTLS no broker, e não existe deploy de produção concluído. Mesmo assim, qualquer mudança deve preservar a direção arquitetural do projeto e os invariantes de negócio definidos no PRD. +O estado atual não é produção: o login institucional guarda credenciais localmente (sem integração com provedor de identidade real), MFA e refresh token foram adiados por decisão registrada em `PROGRESS.md`, não há mTLS no broker (sem certificado de cliente), o backoffice admin não tem backend, e não existe deploy de produção concluído (`backend/DEPLOY.md` é só um caminho de demo free-tier). Mesmo assim, qualquer mudança deve preservar a direção arquitetural do projeto e os invariantes de negócio definidos no PRD. A documentação do produto, a arquitetura e os comandos de execução já estão no repositório; a implementação deve seguir o que está ali registrado e não inventar novos padrões sem alinhamento técnico e funcional. diff --git a/CLAUDE.md b/CLAUDE.md index b5927e1f..b2242451 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,12 +4,18 @@ 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 (no real institutional auth, no mTLS on the broker, no production deploy). +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). Two core flows: -- **Paciente (patient)**: simple auth, urgency alert, structured triage, request status tracking. +- **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. + +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`. + +`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. + ## Required reading before implementing features Read these before making product/architecture decisions — when project docs conflict with generic conventions, the project docs win: @@ -98,7 +104,8 @@ Product/architecture source of truth (PRD, UX flows, LGPD design, stack decision - Prefer simple, predictable solutions aligned with the stack already chosen (Flutter + Dart backend + Postgres + MQTT) over introducing new frameworks/services not in `spec/stack.md`. - Keep triage/prioritization logic deterministic and consistent with the Manchester Protocol model referenced in the PRD — do not make risk classification probabilistic or user-overridable. - When touching sync behavior (backend `SyncFsm` or the ACS `offline_visit_queue.dart`), preserve retry/queue/conflict semantics — offline-first correctness is the primary architectural risk called out in `AGENTS.md`. -- When reusing a clinical fill color (`red`/`accent`/`danger`/`yellow`/`green`) as text or icon color in the Flutter apps, use the `*OnSurface` token and measure contrast against the surface it actually renders on (commonly `Card`/`surfaceRaised`), not the Scaffold background — see the WCAG contrast tokens note above and `spec/ux_accessibility_assessment.md`. +- 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) (8 jobs: `serverpod-backend`, `backend-docker-build`, `patient-app`, `acs-app`, `admin-app`, `coverage-report`, `android-e2e`, `admin-android-build`). `android-e2e` is the only one that boots a real emulator against the stack and is the least stable. Neither `main` nor `develop` has branch protection, so red CI does not block merges — see `docs/ci-audit/2026-09-28-avaliacao-ci-develop.md`. - Never commit real patient data, credentials, or the dev Docker Compose secrets into anything beyond local development. ## graphify From 9d84f800ecd339a496299d4abedeae731ec0b3c3 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Mon, 28 Sep 2026 18:49:31 -0400 Subject: [PATCH 014/498] =?UTF-8?q?fix(ci):=20CI=20roda=20em=20develop,=20?= =?UTF-8?q?cancela=20runs=20obsoletos=20de=20PR=20e=20guarda=20o=20pr?= =?UTF-8?q?=C3=B3prio=20workflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FINDING-2 e FINDING-7 da avaliação de 2026-09-28. O job workflow-lint roda actionlint e scripts/qa/ci_invariants.sh, que falha quando a lista de jobs diverge da documentada (o FINDING-1 não volta). Inclui o plano de execução. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 28 +- AGENTS.md | 2 +- CLAUDE.md | 2 +- .../2026-09-28-ci-correcoes-avaliacao.md | 857 ++++++++++++++++++ scripts/qa/ci_invariants.sh | 105 +++ 5 files changed, 991 insertions(+), 3 deletions(-) create mode 100644 docs/superpowers/plans/2026-09-28-ci-correcoes-avaliacao.md create mode 100755 scripts/qa/ci_invariants.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 308f9d46..0bd27d73 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,10 +2,36 @@ name: CI on: push: - branches: [main, master] + # develop entra aqui porque, sem PR develop → main aberta, commit nenhum + # 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] 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. +concurrency: + group: ci-${{ github.event_name }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.sha }} + cancel-in-progress: true jobs: + # Guarda do próprio workflow: actionlint e as invariantes que a avaliação de + # 2026-09-28 mostrou derivarem sem ninguém ver (jobs × documentação, + # gatilhos, concorrência, versões de ações, imagem do runner). + workflow-lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: actionlint + run: docker run --rm -v "$PWD:/repo" --workdir /repo rhysd/actionlint:1.7.12 -color + - name: Invariantes do CI + run: | + python3 -c 'import yaml' 2>/dev/null || { sudo apt-get update && sudo apt-get install -y python3-yaml; } + ./scripts/qa/ci_invariants.sh serverpod-backend: runs-on: ubuntu-latest defaults: diff --git a/AGENTS.md b/AGENTS.md index a2524f2a..16f46be1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -144,7 +144,7 @@ Importante: - `flutter test` é hermético e não substitui validações com stack local real; - os testes de integração e validação de conexão vivem fora do `flutter test` e utilizam a stack Docker/VM; - 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)) tem oito jobs: `serverpod-backend`, `backend-docker-build`, `patient-app`, `acs-app`, `admin-app`, `coverage-report`, `android-e2e` (único que sobe emulador Android contra a stack; hoje o mais instável) e `admin-android-build` (compila o APK do admin); +- 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` (actionlint + `scripts/qa/ci_invariants.sh`, que falha quando esta lista diverge do workflow), `serverpod-backend`, `backend-docker-build`, `patient-app`, `acs-app`, `admin-app`, `coverage-report`, `android-e2e` (único que sobe emulador Android contra a stack; hoje o mais instável) e `admin-android-build` (compila o APK do admin); - nem `main` nem `develop` têm branch protection: CI vermelho não bloqueia merge — ver [docs/ci-audit/2026-09-28-avaliacao-ci-develop.md](docs/ci-audit/2026-09-28-avaliacao-ci-develop.md). ## Observações finais diff --git a/CLAUDE.md b/CLAUDE.md index b2242451..ef3aadad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -105,7 +105,7 @@ 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) (8 jobs: `serverpod-backend`, `backend-docker-build`, `patient-app`, `acs-app`, `admin-app`, `coverage-report`, `android-e2e`, `admin-android-build`). `android-e2e` is the only one that boots a real emulator against the stack and is the least stable. Neither `main` nor `develop` has branch protection, so red CI does not block merges — see `docs/ci-audit/2026-09-28-avaliacao-ci-develop.md`. +- 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` (actionlint + `scripts/qa/ci_invariants.sh`, which fails when this job list drifts from the workflow), `serverpod-backend`, `backend-docker-build`, `patient-app`, `acs-app`, `admin-app`, `coverage-report`, `android-e2e`, `admin-android-build`. `android-e2e` is the only one that boots a real emulator against the stack and is the least stable. Neither `main` nor `develop` has branch protection, so red CI does not block merges — see `docs/ci-audit/2026-09-28-avaliacao-ci-develop.md`. - Never commit real patient data, credentials, or the dev Docker Compose secrets into anything beyond local development. ## graphify diff --git a/docs/superpowers/plans/2026-09-28-ci-correcoes-avaliacao.md b/docs/superpowers/plans/2026-09-28-ci-correcoes-avaliacao.md new file mode 100644 index 00000000..7e361a07 --- /dev/null +++ b/docs/superpowers/plans/2026-09-28-ci-correcoes-avaliacao.md @@ -0,0 +1,857 @@ +# Correções do CI apontadas na avaliação de 2026-09-28 — 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 achados abertos da avaliação de CI: obter o dado que falta sobre o `android-e2e` (FINDING-4), fazer o CI rodar em `develop` e cancelar runs obsoletos (FINDING-2/7), atualizar ações e fixar a imagem do runner antes de 2026-10-19 (FINDING-6), e transformar o CI em gate com branch protection (FINDING-5/3). Tudo isso com uma guarda que impede a mesma deriva de voltar. + +**Architecture:** Todas as mudanças de código ficam em um único arquivo, `.github/workflows/ci.yml`, e em um script novo, `scripts/qa/ci_invariants.sh`. O script lê o workflow com PyYAML e falha, com o número do FINDING, quando uma invariante é quebrada. Ele roda num job novo, `workflow-lint`, junto com o actionlint. Cada task acrescenta ao script a sua asserção antes de mudar o workflow (primeiro vermelho, depois verde). O trabalho vai numa única branch e numa única PR para `develop`, com um push por task. O primeiro run dessa PR é o dado do FINDING-4. A branch protection é configuração do GitHub, aplicada por `gh api` depois do merge e alimentada pela lista de checks que o próprio script publica. + +**Tech Stack:** GitHub Actions, `gh` CLI, actionlint 1.7.12 (imagem Docker `rhysd/actionlint:1.7.12`), bash + python3/PyYAML. + +**Spec:** [docs/ci-audit/2026-09-28-avaliacao-ci-develop.md](../../ci-audit/2026-09-28-avaliacao-ci-develop.md). A tabela da Seção 5 é o backlog que este plano executa. + +## Estado dos achados no início do plano (medido em 2026-09-28) + +| Achado | Estado | Onde é tratado | +|---|---|---| +| FINDING-1 (docs com 6 jobs) | **Fechado** em `32ddad5` (CLAUDE.md/AGENTS.md listam os 8 jobs) | Task 1 impede a deriva de voltar | +| FINDING-2 (push em develop não dispara CI) | Aberto: `on.push.branches: [main, master]` | Task 1 | +| FINDING-7 (sem `concurrency`) | Aberto | Task 1 | +| FINDING-4 (`74d89c0` nunca rodou no CI) | Aberto: nenhum run desde `36179475405`; `origin/develop` em `32ddad5` | Task 2 | +| FINDING-6 (ações depreciadas + `ubuntu-latest` → 26 em 2026-10-19) | Aberto: `checkout@v4`, `setup-java@v4`, `cache@v4`, `upload-artifact@v4`; 9 × `ubuntu-latest` | Tasks 3, 4 e 5 | +| FINDING-5 (sem branch protection) | Aberto: `GET .../branches/{develop,main}/protection` → 404 | Task 6 | +| FINDING-3 (CI vermelho como norma) | Prática de time; depende do FINDING-5 | Task 6 (CONTRIBUTING.md) | + +Versões mais novas publicadas, conferidas com `gh api repos//releases/latest` em 2026-09-28 (todas com `runs.using: node24`): `actions/checkout` v7.0.1, `actions/setup-java` v6.0.1, `actions/cache` v6.1.0, `actions/upload-artifact` v7.0.1. `subosito/flutter-action@v2` (composite), `dart-lang/setup-dart@v1` e `reactivecircus/android-emulator-runner@v2` já estão em node24 e não mudam. + +## Global Constraints + +- Comentários de workflow e de script, mensagens de commit e documentação em **português**, no tom dos comentários que já existem em `ci.yml` (explicar o *porquê* medido, não o óbvio). +- Branch de trabalho: `ci/correcoes-avaliacao-2026-09-28`, criada a partir de `develop`. Uma PR para `develop`. Merge com `gh pr merge --merge`, como nas PRs anteriores. +- Não alterar o corpo de nenhum job existente além do pedido em cada task (em especial `android-e2e`: o objetivo da Task 2 é medir **o que `74d89c0` deixou**, não uma versão nova). +- actionlint fixado em `rhysd/actionlint:1.7.12`. Deve sair limpo em toda task. +- Imagem do runner: `ubuntu-24.04` em todos os jobs (Task 4). Versões mínimas das ações: `checkout@v7`, `setup-java@v6`, `cache@v6`, `upload-artifact@v7` (Task 3). +- Checks obrigatórios: todos os jobs **exceto `android-e2e`**, amarrados ao app GitHub Actions (`app_id` 15368, conferido na Task 6). +- `enforce_admins: false`: o dono do repositório continua podendo fazer push direto em `develop`, e demais colaboradores passam a precisar de PR. +- Nunca esperar CI com `sleep` em loop: usar `gh run watch --exit-status` (bloqueia até o fim do run). +- Mensagens de commit terminam com `Co-Authored-By: Claude Opus 5.5 `. +- Nada de segredo em log, script ou doc. O único segredo do workflow continua sendo `secrets.GOOGLE_MAPS_API_KEY`. + +## Review Focus + +1. **Push em `main`/`develop` cancelado por outro push.** Com `cancel-in-progress: true`, um grupo de concorrência por branch faria dois merges seguidos apagarem o resultado do primeiro, recriando o FINDING-4. Esperado: todo commit de `main`/`develop` guarda o próprio run. Coberto por `check_concorrencia` (Task 1), que exige `github.sha` e `github.event_name` no `group`. +2. **Check obrigatório que nunca reporta.** Se um job for renomeado, ganhar `name:` diferente do id ou `if:`, ou se o workflow ganhar `paths-ignore`, a proteção fica esperando um check que não vem e a PR trava sem erro. Esperado: a quebra aparece no `workflow-lint` da própria PR. Coberto por `check_sem_filtro_de_paths` (Task 1) e `check_checks_obrigatorios` (Task 6). +3. **`android-e2e` obrigatório por engano.** Ele travaria toda PR num job sabidamente instável. Esperado: fica fora da lista. Coberto por `check_checks_obrigatorios` (Task 6). +4. **Um job novo com `ubuntu-latest` ou ação antiga.** A deriva de imagem ou de Node voltaria em silêncio. Esperado: `workflow-lint` vermelho. Coberto por `check_runner` (Task 4) e `check_versoes_de_acoes` (Task 3). +5. **Snapshot de AVD de uma imagem reaproveitado em outra.** No ensaio do Ubuntu 26.04, o cache `avd-36-x86_64-pixel_7` criado no 24.04 pode quebrar o emulador e se passar por incompatibilidade da imagem nova. Esperado: o ensaio usa uma chave própria. Coberto pelo Step 2 da Task 5. + +--- + +### Task 1: Guarda do workflow, gatilho em `develop` e concorrência (FINDING-2, FINDING-7, e FINDING-1 sem regressão) + +**Files:** +- Create: `scripts/qa/ci_invariants.sh` +- Modify: `.github/workflows/ci.yml:1-8` (gatilhos, `concurrency`, job `workflow-lint` novo antes de `serverpod-backend`) +- Modify: `CLAUDE.md:108`, `AGENTS.md:147` (8 → 9 jobs, gatilhos) + +**Interfaces:** +- Produces: `./scripts/qa/ci_invariants.sh` (sai 0/1; imprime `FALHA: ...` no stderr). Dentro do python: lista `falhas`, dicionários `wf`/`gatilhos`/`jobs`, conjunto `JOBS_DOCUMENTADOS`, lista `CHECKS` (cada check é uma função sem argumento que acrescenta a `falhas`). Os argumentos extras do script chegam em `sys.argv[2:]`. As Tasks 3, 4 e 6 acrescentam funções a `CHECKS`. +- Produces: job `workflow-lint` no `ci.yml`. + +- [ ] **Step 1: Criar a branch** + +```bash +git switch develop && git pull --ff-only +git switch -c ci/correcoes-avaliacao-2026-09-28 +``` + +- [ ] **Step 2: Escrever a guarda (o teste que falha)** + +Criar `scripts/qa/ci_invariants.sh`: + +```bash +#!/usr/bin/env bash +# +# Invariantes do próprio workflow de CI (.github/workflows/ci.yml). +# +# Por que existe: a avaliação de 2026-09-28 +# (docs/ci-audit/2026-09-28-avaliacao-ci-develop.md) achou derivas que ninguém +# viu acontecer — a documentação contava seis jobs com oito no arquivo, push em +# develop não disparava nada, não havia concorrência, as ações ficaram em +# versões depreciadas e a imagem do runner mudaria sozinha. Cada uma vira aqui +# uma asserção que falha com o número do achado. +# +# ./scripts/qa/ci_invariants.sh +# +# Não precisa de rede nem da stack; só python3 com PyYAML. Roda no job +# workflow-lint do próprio CI. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +exec python3 - "$repo_root/.github/workflows/ci.yml" "$@" <<'PY' +import sys + +import yaml + +caminho = sys.argv[1] +with open(caminho, encoding='utf-8') as f: + wf = yaml.safe_load(f) + +# PyYAML segue o YAML 1.1, em que a chave `on` é lida como o booleano True. +gatilhos = wf.get('on', wf.get(True)) or {} +jobs = wf.get('jobs') or {} +falhas = [] + +# Os jobs que CLAUDE.md e AGENTS.md enumeram. Mudou aqui, muda lá no mesmo +# commit — foi exatamente essa enumeração que envelheceu (FINDING-1). +JOBS_DOCUMENTADOS = { + 'workflow-lint', + 'serverpod-backend', + 'backend-docker-build', + 'patient-app', + 'acs-app', + 'admin-app', + 'coverage-report', + 'android-e2e', + 'admin-android-build', +} + + +def check_jobs(): + atuais = set(jobs) + if atuais != JOBS_DOCUMENTADOS: + falhas.append( + 'FINDING-1: jobs do workflow divergem da lista documentada — ' + f'a mais: {sorted(atuais - JOBS_DOCUMENTADOS)}, ' + f'faltando: {sorted(JOBS_DOCUMENTADOS - atuais)}. ' + 'Atualize CLAUDE.md, AGENTS.md e JOBS_DOCUMENTADOS juntos.' + ) + + +def check_gatilhos(): + push = (gatilhos.get('push') or {}).get('branches') or [] + if 'develop' not in push: + falhas.append(f'FINDING-2: push não dispara o CI em develop (branches: {push})') + if 'workflow_dispatch' not in gatilhos: + falhas.append('FINDING-2: sem workflow_dispatch, não há como disparar o CI à mão') + if 'pull_request' not in gatilhos: + falhas.append('pull_request deixou de disparar o CI') + + +def check_sem_filtro_de_paths(): + # Um check obrigatório que não roda nunca reporta, e a PR fica esperando + # para sempre. Filtro de paths faria isso com toda PR só de documentação. + for evento in ('push', 'pull_request'): + cfg = gatilhos.get(evento) or {} + for chave in ('paths', 'paths-ignore'): + if chave in cfg: + falhas.append(f'{evento}.{chave} faria checks obrigatórios nunca reportarem') + + +def check_concorrencia(): + c = wf.get('concurrency') + if not isinstance(c, dict): + falhas.append('FINDING-7: sem bloco concurrency no nível do workflow') + return + grupo = str(c.get('group', '')) + # Sem o SHA, dois pushes seguidos em develop cairiam no mesmo grupo e o + # segundo apagaria o resultado do primeiro — o FINDING-4 de novo. + for termo in ('github.event_name', 'github.event.pull_request.number', 'github.sha'): + if termo not in grupo: + falhas.append(f'FINDING-7: concurrency.group sem {termo}: {grupo}') + if c.get('cancel-in-progress') is not True: + falhas.append('FINDING-7: concurrency sem cancel-in-progress: true') + + +CHECKS = [check_jobs, check_gatilhos, check_sem_filtro_de_paths, check_concorrencia] + +for check in CHECKS: + check() + +if falhas: + for falha in falhas: + print(f'FALHA: {falha}', file=sys.stderr) + sys.exit(1) +print(f'ok: {len(CHECKS)} grupos de invariantes do CI') +PY +``` + +```bash +chmod +x scripts/qa/ci_invariants.sh +``` + +- [ ] **Step 3: Rodar e ver falhar** + +Run: `./scripts/qa/ci_invariants.sh; echo rc=$?` +Expected: `rc=1` e exatamente estas quatro linhas (a ordem importa: prova que cada check enxerga o defeito que nomeia): +``` +FALHA: FINDING-1: jobs do workflow divergem da lista documentada — a mais: [], faltando: ['workflow-lint']. Atualize CLAUDE.md, AGENTS.md e JOBS_DOCUMENTADOS juntos. +FALHA: FINDING-2: push não dispara o CI em develop (branches: ['main', 'master']) +FALHA: FINDING-2: sem workflow_dispatch, não há como disparar o CI à mão +FALHA: FINDING-7: sem bloco concurrency no nível do workflow +``` +Se aparecer `ModuleNotFoundError: No module named 'yaml'`, instale `python3-pyyaml` (Fedora) ou `python3-yaml` (Ubuntu) e rode de novo. + +- [ ] **Step 4: Gatilhos e concorrência no `ci.yml`** + +Substituir as linhas 1-8 (`name: CI` até `jobs:`) por: + +```yaml +name: CI + +on: + push: + # develop entra aqui porque, sem PR develop → main aberta, commit nenhum + # 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] + 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. +concurrency: + group: ci-${{ github.event_name }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.sha }} + cancel-in-progress: true + +jobs: + # Guarda do próprio workflow: actionlint e as invariantes que a avaliação de + # 2026-09-28 mostrou derivarem sem ninguém ver (jobs × documentação, + # gatilhos, concorrência, versões de ações, imagem do runner). + workflow-lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: actionlint + run: docker run --rm -v "$PWD:/repo" --workdir /repo rhysd/actionlint:1.7.12 -color + - name: Invariantes do CI + run: | + python3 -c 'import yaml' 2>/dev/null || { sudo apt-get update && sudo apt-get install -y python3-yaml; } + ./scripts/qa/ci_invariants.sh +``` + +(`ubuntu-latest` e `checkout@v4` aqui são intencionais: as Tasks 3 e 4 trocam todos de uma vez, e a asserção delas precisa ver este job também.) + +- [ ] **Step 5: Rodar a guarda e o actionlint** + +Run: `./scripts/qa/ci_invariants.sh && docker run --rm -v "$PWD:/repo" --workdir /repo rhysd/actionlint:1.7.12 -color; echo rc=$?` +Expected: `ok: 4 grupos de invariantes do CI` e `rc=0`, sem nenhuma saída do actionlint. + +- [ ] **Step 6: Documentação dos 9 jobs e dos gatilhos** + +Em `CLAUDE.md:108`, trocar o trecho +`CI lives in [.github/workflows/ci.yml](.github/workflows/ci.yml) (8 jobs: \`serverpod-backend\`,` +por +`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\` (actionlint + \`scripts/qa/ci_invariants.sh\`, which fails when this job list drifts from the workflow), \`serverpod-backend\`,` +e manter o resto da linha. + +Em `AGENTS.md:147`, trocar +`- o CI ([.github/workflows/ci.yml](.github/workflows/ci.yml)) tem oito jobs: \`serverpod-backend\`,` +por +`- 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\` (actionlint + \`scripts/qa/ci_invariants.sh\`, que falha quando esta lista diverge do workflow), \`serverpod-backend\`,` +e manter o resto da linha. + +Run: `grep -c "workflow-lint" CLAUDE.md AGENTS.md && ./scripts/qa/check_documentation_links.sh; echo rc=$?` +Expected: `CLAUDE.md:1`, `AGENTS.md:1`, `rc=0`. + +- [ ] **Step 7: Commit** + +```bash +git add scripts/qa/ci_invariants.sh .github/workflows/ci.yml CLAUDE.md AGENTS.md +git commit -m "fix(ci): CI roda em develop, cancela runs obsoletos de PR e guarda o próprio workflow + +FINDING-2 e FINDING-7 da avaliação de 2026-09-28. O job workflow-lint roda +actionlint e scripts/qa/ci_invariants.sh, que falha quando a lista de jobs +diverge da documentada (o FINDING-1 não volta). + +Co-Authored-By: Claude Opus 5.5 " +``` + +- [ ] **Step 8: Push e PR (este push produz o run da Task 2)** + +```bash +git push -u origin ci/correcoes-avaliacao-2026-09-28 +gh pr create --base develop --title "fix(ci): correções da avaliação de CI de 2026-09-28" \ + --body "Executa docs/superpowers/plans/2026-09-28-ci-correcoes-avaliacao.md. O primeiro run desta PR é o dado que faltava para o FINDING-4 (primeira execução de CI sobre 74d89c0). + +🤖 Generated with [Claude Code](https://claude.com/claude-code)" +``` + +--- + +### Task 2: Obter o dado do FINDING-4 e decidir + +**Files:** +- Modify: `docs/ci-audit/2026-09-28-avaliacao-ci-develop.md` (nova Seção 6 no final) + +**Interfaces:** +- Consumes: a PR aberta na Task 1, Step 8. +- Produces: Seção `## 6. Acompanhamento das correções` na avaliação. As Tasks 3 a 6 acrescentam linhas à tabela dela. + +**Não faça push de nenhuma outra task antes de este run terminar.** O `cancel-in-progress` da própria Task 1 cancelaria o run, e o dado se perderia de novo. + +- [ ] **Step 1: Identificar o run e confirmar que ele cobre `74d89c0`** + +```bash +run=$(gh run list --branch ci/correcoes-avaliacao-2026-09-28 --event pull_request --limit 1 --json databaseId --jq '.[0].databaseId'); echo "$run" +sha=$(gh run view "$run" --json headSha --jq .headSha); echo "$sha" +git merge-base --is-ancestor 74d89c0 "$sha" && echo "cobre 74d89c0" +``` +Expected: um id numérico, o SHA do commit da Task 1 e `cobre 74d89c0`. + +- [ ] **Step 2: Esperar o fim (até ~60 min; rodar em background)** + +Run: `gh run watch "$run" --exit-status; echo rc=$?` +Expected: termina sozinho. `rc` pode ser 1 se o `android-e2e` falhar, e isso é dado, não erro do plano. + +- [ ] **Step 3: Coletar o resultado por job** + +```bash +gh run view "$run" --json jobs --jq '.jobs[] | "\(.name): \(.conclusion) (\(.startedAt) -> \(.completedAt))"' +``` + +- [ ] **Step 4: Aplicar a regra de decisão** + +- **Algum job que não seja `android-e2e` vermelho** (inclusive `workflow-lint`): **pare o plano**. É regressão ou defeito da Task 1. Investigue com `superpowers:systematic-debugging` antes de seguir. +- **`android-e2e` verde:** FINDING-4 fechado. A correção de `74d89c0` (KVM, cache, smoke) funcionou. +- **`android-e2e` vermelho ou `cancelled`:** capture a causa e abra uma issue. A depuração **fica fora deste plano**, em sessão própria. O restante segue, porque o `android-e2e` não será check obrigatório. + +```bash +job=$(gh run view "$run" --json jobs --jq '.jobs[] | select(.name=="android-e2e") | .databaseId') +gh run view --log-failed --job="$job" | grep -E "tests passed|failed to install|Unable to start|Timed out|error" | tail -20 +gh issue create --label bug --title "android-e2e falha no primeiro run de CI sobre 74d89c0" \ + --body "Run $run (job $job), primeiro run de CI a cobrir 74d89c0 (FINDING-4 de docs/ci-audit/2026-09-28-avaliacao-ci-develop.md). Trecho do log: + +\`\`\` + +\`\`\` + +Próximo passo: sessão com superpowers:systematic-debugging, ponto de entrada scripts/qa/run_android_e2e.sh." +``` + +- [ ] **Step 5: Registrar na avaliação** + +Acrescentar ao final de `docs/ci-audit/2026-09-28-avaliacao-ci-develop.md`, com os valores medidos nos Steps 1 a 4: + +```markdown +## 6. Acompanhamento das correções + +Plano: `docs/superpowers/plans/2026-09-28-ci-correcoes-avaliacao.md`. + +| Achado | Estado | Evidência | +|---|---|---| +| 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-4 | | run sobre (ancestral de `74d89c0` confirmado); `android-e2e`: , | +``` + +- [ ] **Step 6: Commit (sem push ainda: ele vai junto com a Task 3)** + +```bash +git add docs/ci-audit/2026-09-28-avaliacao-ci-develop.md +git commit -m "docs(ci-audit): primeiro run de CI sobre 74d89c0 (FINDING-4) + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 3: Atualizar as ações para node24 (FINDING-6, parte 1) + +**Files:** +- Modify: `scripts/qa/ci_invariants.sh` (novo check) +- Modify: `.github/workflows/ci.yml` (todas as ocorrências das quatro ações) +- Modify: `docs/ci-audit/2026-09-28-avaliacao-ci-develop.md` (linha na tabela da Seção 6) + +**Interfaces:** +- Consumes: `jobs`, `falhas`, `CHECKS` de `ci_invariants.sh` (Task 1). +- Produces: `check_versoes_de_acoes` e o dicionário `MAJOR_MINIMO`. + +- [ ] **Step 1: Ler as notas de quebra antes de trocar** + +```bash +for r in actions/checkout@v5.0.0 actions/checkout@v6.0.0 actions/checkout@v7.0.0 actions/setup-java@v5.0.0 actions/setup-java@v6.0.0 actions/cache@v5.0.0 actions/cache@v6.0.0 actions/upload-artifact@v5.0.0 actions/upload-artifact@v6.0.0 actions/upload-artifact@v7.0.0; do + echo "=== $r"; gh release view "${r#*@}" -R "${r%@*}" --json body --jq .body | grep -iE "breaking|remov|require|minimum|node" | head -8 +done +``` +Esperado, conforme a leitura feita ao escrever este plano: +- em todas, o mínimo é o runner 2.327.1 (os runners hospedados já atendem); +- `checkout@v7` bloqueia o checkout de PR de fork em `pull_request_target`/`workflow_run`, gatilhos que este workflow não usa; +- `checkout@v6` persiste as credenciais num arquivo separado; +- `cache@v6` virou ESM, sem mudança de inputs; +- `upload-artifact@v7` adiciona `archive: false`, que é opcional; +- `setup-java@v6` não muda `distribution`/`java-version`. + +Se a saída mostrar quebra que toque um input usado no `ci.yml` (`with:` de cada passo), pare e ajuste o passo junto. + +- [ ] **Step 2: Acrescentar o check (teste que falha)** + +Em `scripts/qa/ci_invariants.sh`, logo acima de `CHECKS = [`, inserir: + +```python +# Major mínimo de cada ação da GitHub (FINDING-6): abaixo disso ela roda em +# Node 20, depreciado, ou — setup-java v4 — não recebe mais atualização. +MAJOR_MINIMO = { + 'actions/checkout': 7, + 'actions/setup-java': 6, + 'actions/cache': 6, + 'actions/upload-artifact': 7, +} + + +def check_versoes_de_acoes(): + for nome_job, job in jobs.items(): + for passo in job.get('steps') or []: + uses = passo.get('uses') or '' + acao, _, versao = uses.partition('@') + minimo = MAJOR_MINIMO.get(acao) + if minimo is None: + continue + if not versao.startswith('v') or not versao[1:].split('.')[0].isdigit(): + # Fixar por SHA é legítimo, mas então esta guarda precisa + # aprender a ler o comentário de versão — não passar calada. + falhas.append(f'FINDING-6: {nome_job} usa {uses}; este check só entende tags vN') + continue + if int(versao[1:].split('.')[0]) < minimo: + falhas.append(f'FINDING-6: {nome_job} usa {uses}; mínimo v{minimo}') +``` + +e trocar a linha `CHECKS = [...]` por: + +```python +CHECKS = [check_jobs, check_gatilhos, check_sem_filtro_de_paths, check_concorrencia, + check_versoes_de_acoes] +``` + +Run: `./scripts/qa/ci_invariants.sh 2>&1 | grep -c "FINDING-6"; echo` +Expected: `16`, uma falha por uso antigo: 9 `checkout@v4` (8 originais + o do `workflow-lint`), 2 `setup-java@v4`, 3 `cache@v4`, 2 `upload-artifact@v4`. Confira com `grep -cE "actions/(checkout|setup-java|cache|upload-artifact)@v4" .github/workflows/ci.yml`, que também deve dar `16`. A guarda precisa ver todas, não só a primeira. + +- [ ] **Step 3: Trocar as versões** + +```bash +sed -i -e 's#actions/checkout@v4#actions/checkout@v7#g' \ + -e 's#actions/setup-java@v4#actions/setup-java@v6#g' \ + -e 's#actions/cache@v4#actions/cache@v6#g' \ + -e 's#actions/upload-artifact@v4#actions/upload-artifact@v7#g' .github/workflows/ci.yml +``` + +- [ ] **Step 4: Guarda e actionlint verdes** + +Run: `./scripts/qa/ci_invariants.sh && docker run --rm -v "$PWD:/repo" --workdir /repo rhysd/actionlint:1.7.12 -color; echo rc=$?` +Expected: `ok: 5 grupos de invariantes do CI`, `rc=0`. + +- [ ] **Step 5: Commit, push e esperar o run** + +```bash +git add scripts/qa/ci_invariants.sh .github/workflows/ci.yml +git commit -m "fix(ci): ações em node24 — checkout@v7, setup-java@v6, cache@v6, upload-artifact@v7 (FINDING-6) + +Co-Authored-By: Claude Opus 5.5 " +git push +run=$(gh run list --branch ci/correcoes-avaliacao-2026-09-28 --event pull_request --limit 1 --json databaseId --jq '.[0].databaseId') +gh run watch "$run" --exit-status; echo rc=$? +``` +Expected: todos os jobs, exceto possivelmente `android-e2e`, com `success`. O primeiro run depois da troca de `cache@v4` → `v6` pode perder o cache e ficar mais lento, o que é esperado. + +- [ ] **Step 6: Confirmar que os avisos sumiram** + +Run: `gh run view "$run" | grep -cE "Node.js 20 is deprecated|setup-java v4 is deprecated"` +Expected: `0`. Se o `android-e2e` foi cancelado antes de subir artifact, os avisos dele ainda contam como vistos, porque as annotations são emitidas no início do job. + +- [ ] **Step 7: Registrar e commitar** + +Acrescentar à tabela da Seção 6 da avaliação: + +```markdown +| FINDING-6 (ações) | Fechado | run : 0 avisos de Node 20/setup-java v4; guarda `check_versoes_de_acoes` | +``` + +```bash +git add docs/ci-audit/2026-09-28-avaliacao-ci-develop.md +git commit -m "docs(ci-audit): FINDING-6 — ações atualizadas, avisos zerados + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 4: Fixar a imagem do runner em `ubuntu-24.04` (FINDING-6, parte 2) + +**Files:** +- Modify: `scripts/qa/ci_invariants.sh` (novo check) +- Modify: `.github/workflows/ci.yml` (todos os `runs-on`, mais o comentário acima de `jobs:`) +- Modify: `docs/ci-audit/2026-09-28-avaliacao-ci-develop.md` + +**Interfaces:** +- Consumes: `jobs`, `falhas`, `CHECKS` (Task 1). +- Produces: constante `RUNNER = 'ubuntu-24.04'` e `check_runner`. A Task 5 muda `RUNNER` só na branch de ensaio. + +- [ ] **Step 1: Acrescentar o check (teste que falha)** + +Acima de `CHECKS = [`, inserir: + +```python +# Imagem fixada (FINDING-6): `ubuntu-latest` migra de versão sozinho, no dia +# que a GitHub escolher (Ubuntu 26 a partir de 2026-10-19). Trocar de imagem +# tem de ser um PR, não uma surpresa. +RUNNER = 'ubuntu-24.04' + + +def check_runner(): + for nome_job, job in jobs.items(): + if job.get('runs-on') != RUNNER: + falhas.append(f"FINDING-6: {nome_job} roda em {job.get('runs-on')!r}; esperado {RUNNER!r}") +``` + +e a lista: + +```python +CHECKS = [check_jobs, check_gatilhos, check_sem_filtro_de_paths, check_concorrencia, + check_versoes_de_acoes, check_runner] +``` + +Run: `./scripts/qa/ci_invariants.sh 2>&1 | grep -c "roda em 'ubuntu-latest'"` +Expected: `9` (um por job). + +- [ ] **Step 2: Fixar a imagem** + +```bash +sed -i 's/runs-on: ubuntu-latest/runs-on: ubuntu-24.04/g' .github/workflows/ci.yml +``` + +Logo acima da linha `jobs:`, inserir: + +```yaml +# Todos os jobs fixam ubuntu-24.04 em vez de ubuntu-latest: o label migrou +# para Ubuntu 26 em 2026-10-19 sem aviso no dia, e o android-e2e (KVM, udev) +# e os builds Android (NDK/CMake em cache) são os mais sensíveis à troca. A +# migração é feita de propósito, depois de um ensaio — ver a Seção 6 de +# docs/ci-audit/2026-09-28-avaliacao-ci-develop.md. +``` + +- [ ] **Step 3: Guarda e actionlint verdes** + +Run: `./scripts/qa/ci_invariants.sh && docker run --rm -v "$PWD:/repo" --workdir /repo rhysd/actionlint:1.7.12 -color; echo rc=$?` +Expected: `ok: 6 grupos de invariantes do CI`, `rc=0`. + +- [ ] **Step 4: Commit, push, esperar e conferir** + +```bash +git add scripts/qa/ci_invariants.sh .github/workflows/ci.yml +git commit -m "fix(ci): fixar ubuntu-24.04 em todos os jobs antes da migração do ubuntu-latest (FINDING-6) + +Co-Authored-By: Claude Opus 5.5 " +git push +run=$(gh run list --branch ci/correcoes-avaliacao-2026-09-28 --event pull_request --limit 1 --json databaseId --jq '.[0].databaseId') +gh run watch "$run" --exit-status; echo rc=$? +gh run view "$run" | grep -c "ubuntu-latest label will migrate" +``` +Expected: jobs verdes (exceto possivelmente `android-e2e`), e `0` avisos de migração. + +- [ ] **Step 5: Registrar e commitar** + +```markdown +| FINDING-6 (runner) | Fechado para o prazo de 2026-10-19 | run : todos os jobs em `ubuntu-24.04`, 0 avisos de migração; guarda `check_runner`. Ensaio do 26.04: ver a linha seguinte | +``` + +```bash +git add docs/ci-audit/2026-09-28-avaliacao-ci-develop.md +git commit -m "docs(ci-audit): runner fixado em ubuntu-24.04 + +Co-Authored-By: Claude Opus 5.5 " +git push +``` + +--- + +### Task 5: Ensaio em `ubuntu-26.04` (dado para a migração futura, sem mesclar) + +Fixar o 24.04 tira a pressa do prazo, mas não diz se o 26.04 funciona. O ensaio gera esse dado numa PR descartável. + +**Files:** +- Branch descartável `ci/ensaio-ubuntu-26.04`, criada a partir da branch da Task 4. **Nada dela é mesclado.** +- Modify (na branch principal): `docs/ci-audit/2026-09-28-avaliacao-ci-develop.md` + +**Interfaces:** +- Consumes: `RUNNER` (Task 4). + +- [ ] **Step 1: Criar a branch de ensaio** + +```bash +git switch -c ci/ensaio-ubuntu-26.04 +sed -i 's/runs-on: ubuntu-24.04/runs-on: ubuntu-26.04/g' .github/workflows/ci.yml +sed -i "s/^RUNNER = 'ubuntu-24.04'/RUNNER = 'ubuntu-26.04'/" scripts/qa/ci_invariants.sh +``` + +- [ ] **Step 2: Chave de AVD própria (Review Focus 5)** + +```bash +sed -i 's/key: avd-36-x86_64-pixel_7$/key: avd-36-x86_64-pixel_7-ubuntu-26.04/' .github/workflows/ci.yml +grep -n "key: avd-" .github/workflows/ci.yml +``` +Expected: uma linha terminando em `-ubuntu-26.04`. + +- [ ] **Step 3: Guarda, commit e PR em rascunho** + +```bash +./scripts/qa/ci_invariants.sh && docker run --rm -v "$PWD:/repo" --workdir /repo rhysd/actionlint:1.7.12 -color +git commit -am "ci(ensaio): todos os jobs em ubuntu-26.04 — NÃO MESCLAR + +Co-Authored-By: Claude Opus 5.5 " +git push -u origin ci/ensaio-ubuntu-26.04 +gh pr create --draft --base develop --title "[NÃO MESCLAR] ensaio do CI em ubuntu-26.04" \ + --body "Só para medir a migração de imagem (FINDING-6). Será fechada sem merge. + +🤖 Generated with [Claude Code](https://claude.com/claude-code)" +``` + +- [ ] **Step 4: Esperar e coletar** + +```bash +run=$(gh run list --branch ci/ensaio-ubuntu-26.04 --event pull_request --limit 1 --json databaseId --jq '.[0].databaseId') +gh run watch "$run"; gh run view "$run" --json jobs --jq '.jobs[] | "\(.name): \(.conclusion)"' +``` +Se depois de 15 minutos os jobs continuarem `queued` sem runner, cancele (`gh run cancel "$run"`) e registre o label como indisponível. + +- [ ] **Step 5: Fechar o ensaio e registrar** + +```bash +gh pr close ci/ensaio-ubuntu-26.04 --delete-branch --comment "Ensaio registrado na Seção 6 de docs/ci-audit/2026-09-28-avaliacao-ci-develop.md." +git switch ci/correcoes-avaliacao-2026-09-28 && git branch -D ci/ensaio-ubuntu-26.04 +``` + +Se algum job falhou só no 26.04, abra `gh issue create --title "Migrar o CI para ubuntu-26.04" --label enhancement`. No corpo, liste os jobs que falharam, com o trecho de `gh run view --log-failed --job= | tail -30` de cada um, e o link do run. + +Acrescentar à tabela da Seção 6: + +```markdown +| Ensaio ubuntu-26.04 | , issue #N \| Label indisponível> | run | +``` + +```bash +git add docs/ci-audit/2026-09-28-avaliacao-ci-develop.md +git commit -m "docs(ci-audit): ensaio do CI em ubuntu-26.04 + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 6: CI como gate — branch protection em `main` e `develop` (FINDING-5, FINDING-3) + +**Files:** +- Modify: `scripts/qa/ci_invariants.sh` (lista de checks obrigatórios + saída `--checks-obrigatorios`) +- Modify: `CONTRIBUTING.md` (seção nova "CI e merge", depois de "Validar alterações") +- Modify: `CLAUDE.md:108`, `AGENTS.md:148` (frase sobre branch protection) +- Modify: `docs/ci-audit/2026-09-28-avaliacao-ci-develop.md` +- Configuração: `PUT repos/hbgit/SinalACS/branches/{develop,main}/protection` + +**Interfaces:** +- Consumes: `JOBS_DOCUMENTADOS`, `jobs`, `falhas`, `CHECKS` (Task 1). +- Produces: `CHECKS_OBRIGATORIOS`, `check_checks_obrigatorios` e `./scripts/qa/ci_invariants.sh --checks-obrigatorios`, que imprime um JSON `[{"context": "", "app_id": 15368}, ...]` e só é emitido se todas as invariantes passarem. + +- [ ] **Step 1: Confirmar o `app_id` do GitHub Actions (não confiar no número de cabeça)** + +```bash +sha=$(gh run list --branch ci/correcoes-avaliacao-2026-09-28 --limit 1 --json headSha --jq '.[0].headSha') +gh api "repos/hbgit/SinalACS/commits/$sha/check-runs" --jq '[.check_runs[] | {name, app: .app.id}] | unique_by(.name)' +``` +Expected: todos os checks com `app` = `15368` e `name` igual ao id do job. Se o número for outro, use-o no Step 2. + +- [ ] **Step 2: Acrescentar o check e a saída (teste que falha primeiro)** + +No topo do bloco python, trocar `import sys` por: + +```python +import json +import sys +``` + +Acima de `CHECKS = [`, inserir: + +```python +# Checks obrigatórios para merge em main e develop (FINDING-5). O +# android-e2e fica de fora enquanto não tiver histórico verde: torná-lo +# obrigatório hoje travaria toda PR num job sabidamente instável (FINDING-4). +# A proteção de branch é aplicada a partir desta lista (--checks-obrigatorios), +# então renomear um job quebra aqui antes de deixar PRs esperando por um check +# que não existe mais. +CHECKS_OBRIGATORIOS = sorted(JOBS_DOCUMENTADOS - {'android-e2e'}) +# App GitHub Actions: amarrar o check ao app impede que outra integração +# publique um status com o mesmo nome e destrave o merge. +APP_GITHUB_ACTIONS = 15368 + + +def check_checks_obrigatorios(): + if 'android-e2e' in CHECKS_OBRIGATORIOS: + falhas.append('FINDING-4: android-e2e não pode ser obrigatório enquanto for instável') + for nome in CHECKS_OBRIGATORIOS: + job = jobs.get(nome) + if job is None: + falhas.append(f'FINDING-5: check obrigatório {nome} não existe no workflow') + continue + # O nome do check é o `name:` do job, se houver; e um job com `if:` + # pode não rodar e nunca reportar. + if job.get('name', nome) != nome: + falhas.append(f"FINDING-5: {nome} tem name: {job['name']!r}; o check obrigatório não casaria") + if 'if' in job: + falhas.append(f'FINDING-5: {nome} tem if: no nível do job; pode nunca reportar') +``` + +Trocar a lista: + +```python +CHECKS = [check_jobs, check_gatilhos, check_sem_filtro_de_paths, check_concorrencia, + check_versoes_de_acoes, check_runner, check_checks_obrigatorios] +``` + +E trocar o bloco final + +```python +print(f'ok: {len(CHECKS)} grupos de invariantes do CI') +``` + +por + +```python +if '--checks-obrigatorios' in sys.argv[2:]: + print(json.dumps([{'context': c, 'app_id': APP_GITHUB_ACTIONS} for c in CHECKS_OBRIGATORIOS])) + sys.exit(0) +print(f'ok: {len(CHECKS)} grupos de invariantes do CI') +``` + +- [ ] **Step 3: Controle negativo e positivo** + +```bash +# negativo: um name: divergente tem de quebrar +sed -i '0,/^ serverpod-backend:$/s// serverpod-backend:\n name: Backend/' .github/workflows/ci.yml +./scripts/qa/ci_invariants.sh; echo rc=$? +git checkout .github/workflows/ci.yml +# positivo +./scripts/qa/ci_invariants.sh && ./scripts/qa/ci_invariants.sh --checks-obrigatorios | python3 -m json.tool +``` +Expected: +- o negativo sai `rc=1` com `FALHA: FINDING-5: serverpod-backend tem name: 'Backend'...`; +- depois do `git checkout`, `ok: 7 grupos de invariantes do CI`; +- um JSON com 8 entradas (todos os 9 jobs menos `android-e2e`), cada uma com `"app_id": 15368`. + +- [ ] **Step 4: Documentar a regra de merge (FINDING-3)** + +Em `CONTRIBUTING.md`, antes de `## Alterações no backend`, inserir: + +~~~markdown +## CI e merge + +`main` e `develop` são protegidas: uma PR só entra com todos os jobs do CI +verdes, exceto `android-e2e`. O `android-e2e` é informativo até acumular +histórico verde, mas vermelho nele continua sendo defeito a investigar, não +ruído. A lista de checks obrigatórios vem de +`./scripts/qa/ci_invariants.sh --checks-obrigatorios`. Ao criar, renomear ou +remover um job, atualize `JOBS_DOCUMENTADOS` nesse script, `CLAUDE.md` e +`AGENTS.md` no mesmo commit. O job `workflow-lint` falha se não fizer isso. + +Não mescle com CI vermelho. Para rodar o CI numa branch sem abrir PR: + +```bash +gh workflow run CI --ref +``` +~~~ + +Em `CLAUDE.md:108`, trocar +`Neither \`main\` nor \`develop\` has branch protection, so red CI does not block merges — see` +por +`\`main\` and \`develop\` are protected: every job except \`android-e2e\` must pass to merge (list from \`./scripts/qa/ci_invariants.sh --checks-obrigatorios\`; admins can still push directly) — history in` + +Em `AGENTS.md:148`, trocar +`- nem \`main\` nem \`develop\` têm branch protection: CI vermelho não bloqueia merge — ver` +por +`- \`main\` e \`develop\` são protegidas: todo job exceto \`android-e2e\` precisa passar para mesclar (lista em \`./scripts/qa/ci_invariants.sh --checks-obrigatorios\`; ver \`CONTRIBUTING.md\`) — histórico em` + +Run: `./scripts/qa/check_documentation_links.sh; grep -c "branch protection" CLAUDE.md AGENTS.md; echo rc=$?` +Expected: links ok. `CLAUDE.md:0` e `AGENTS.md:0`: a frase antiga saiu dos dois. + +- [ ] **Step 5: Linha da avaliação, commit, push e run verde** + +Acrescentar à Seção 6: + +```markdown +| FINDING-5 | Fechado | proteção em `main` e `develop` com os checks de `ci_invariants.sh --checks-obrigatorios` (todos menos `android-e2e`), `enforce_admins: false`; PR obrigatória em `main` | +| FINDING-3 | Fechado como regra | `CONTRIBUTING.md` › CI e merge; aplicado pela proteção do FINDING-5 | +``` + +```bash +git add scripts/qa/ci_invariants.sh CONTRIBUTING.md CLAUDE.md AGENTS.md docs/ci-audit/2026-09-28-avaliacao-ci-develop.md +git commit -m "fix(ci): lista de checks obrigatórios vem da guarda do workflow (FINDING-5, FINDING-3) + +Co-Authored-By: Claude Opus 5.5 " +git push +run=$(gh run list --branch ci/correcoes-avaliacao-2026-09-28 --event pull_request --limit 1 --json databaseId --jq '.[0].databaseId') +gh run watch "$run" --exit-status; gh run view "$run" --json jobs --jq '.jobs[] | "\(.name): \(.conclusion)"' +``` +Expected: os 8 jobs obrigatórios com `success`. + +- [ ] **Step 6: Mesclar e ver o push em `develop` rodar (FINDING-2 medido)** + +```bash +gh pr merge ci/correcoes-avaliacao-2026-09-28 --merge --delete-branch +git switch develop && git pull --ff-only +run=$(gh run list --branch develop --event push --limit 1 --json databaseId,headSha --jq '.[0].databaseId'); echo "$run" +gh run watch "$run" --exit-status +``` +Expected: existe um run `push` em `develop` para o merge commit (a prova de que o FINDING-2 foi fechado), com os 8 obrigatórios verdes. + +- [ ] **Step 7: Aplicar a proteção — CONFIRMAR COM O USUÁRIO ANTES** + +É configuração do repositório, fora do git. Mostre o payload ao usuário e só aplique depois de um "sim" explícito: + +```bash +checks=$(./scripts/qa/ci_invariants.sh --checks-obrigatorios) +for b in develop main; do + reviews=null + [ "$b" = main ] && reviews='{"required_approving_review_count":0}' + payload="{\"required_status_checks\":{\"strict\":false,\"checks\":$checks},\"enforce_admins\":false,\"required_pull_request_reviews\":$reviews,\"restrictions\":null}" + echo "== $b: $payload" +done +``` + +Depois da confirmação: + +```bash +for b in develop main; do + reviews=null + [ "$b" = main ] && reviews='{"required_approving_review_count":0}' + gh api -X PUT "repos/hbgit/SinalACS/branches/$b/protection" --input - < Date: Mon, 28 Sep 2026 19:05:07 -0400 Subject: [PATCH 015/498] =?UTF-8?q?fix(ci):=20android-e2e=20apaga=20pg=5Fd?= =?UTF-8?q?ata/=20antes=20do=20p=C3=B3s-passo=20do=20flutter-action=20(FIN?= =?UTF-8?q?DING-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Primeiro run de CI sobre 74d89c0 (36494654391): o E2E no emulador passou inteiro, mas o job caiu no pós-passo do flutter-action, cujo hashFiles('**/pubspec.lock') não lê pg_data/ (modo 700, dono do container). Registra o resultado na Seção 6 da avaliação. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 7 +++++++ .../2026-09-28-avaliacao-ci-develop.md | 11 +++++++++++ scripts/qa/ci_invariants.sh | 18 +++++++++++++++++- 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0bd27d73..06648af7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -287,6 +287,13 @@ jobs: # precisa viver num script de verdade — ver run_android_e2e.sh. # Roda só o smoke de cada app (e2e.sh --emulator, sem --full). script: ./scripts/qa/run_android_e2e.sh + # O volume do Postgres fica no workspace com modo 700 e dono do + # 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. + - name: Remove pg_data/ do workspace + if: always() + run: sudo rm -rf pg_data - uses: actions/upload-artifact@v4 if: always() with: 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 f35d7ed3..62b45c9a 100644 --- a/docs/ci-audit/2026-09-28-avaliacao-ci-develop.md +++ b/docs/ci-audit/2026-09-28-avaliacao-ci-develop.md @@ -260,3 +260,14 @@ Ordenado por severidade (crítica → alta → média → baixa → informativa) | FINDING-2 | Informativa | `push` só dispara CI em `main`/`master`; um push direto em `develop` sem PR aberta não roda o workflow — e hoje não há nenhuma PR `develop → main` aberta (a única, #7, foi mesclada em 2026-09-25), então essa cobertura simplesmente não existe agora. É exatamente essa ausência que abre a lacuna de verificação descrita no FINDING-4 | Nenhuma ação obrigatória por si só, mas é a causa estrutural do FINDING-4: considerar adicionar `push: branches: [..., develop]` ou um trigger `workflow_dispatch` para que a cobertura de CI não dependa de haver uma PR aberta por acaso | N/A por padrão; se adotada, PR pequeno em `.github/workflows/ci.yml` | Follow-up sugerido: um plano de implementação separado (via `superpowers:writing-plans` de novo) para os itens de severidade alta/média. A prioridade imediata **não** é depurar `android-e2e` (FINDING-4) — é gerar o dado que falta, abrindo a PR `develop → main` ou disparando o workflow manualmente, já que hoje não existe nenhuma execução de CI contra a única correção já tentada. Em paralelo, o item estrutural é FINDING-5 (branch protection), para que corrigir o CI passe a valer a pena de fato. + +## 6. Acompanhamento das correções + +Plano: `docs/superpowers/plans/2026-09-28-ci-correcoes-avaliacao.md`. + +| Achado | Estado | Evidência | +|---|---|---| +| 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-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` | diff --git a/scripts/qa/ci_invariants.sh b/scripts/qa/ci_invariants.sh index 7457e4e7..bbed7291 100755 --- a/scripts/qa/ci_invariants.sh +++ b/scripts/qa/ci_invariants.sh @@ -92,7 +92,23 @@ def check_concorrencia(): falhas.append('FINDING-7: concurrency sem cancel-in-progress: true') -CHECKS = [check_jobs, check_gatilhos, check_sem_filtro_de_paths, check_concorrencia] +def check_limpeza_do_workspace(): + # A stack do android-e2e deixa pg_data/ no workspace com modo 700 e dono + # do container: o hashFiles('**/pubspec.lock') do pós-passo do + # flutter-action não consegue varrê-lo e derruba um job cujo E2E passou + # (run 36494654391). Algum passo depois do E2E tem de apagá-lo, sempre. + passos = (jobs.get('android-e2e') or {}).get('steps') or [] + nomes = [p.get('name', '') for p in passos] + if 'E2E no emulador Android' not in nomes: + falhas.append('android-e2e sem o passo "E2E no emulador Android"') + return + depois = passos[nomes.index('E2E no emulador Android') + 1:] + if not any(p.get('if') == 'always()' and 'pg_data' in (p.get('run') or '') for p in depois): + falhas.append('android-e2e: nenhum passo if: always() apaga pg_data/ depois do E2E') + + +CHECKS = [check_jobs, check_gatilhos, check_sem_filtro_de_paths, check_concorrencia, + check_limpeza_do_workspace] for check in CHECKS: check() From acb24781af0d1388305eaa70f61137d25647c0a0 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Mon, 28 Sep 2026 19:05:28 -0400 Subject: [PATCH 016/498] =?UTF-8?q?fix(ci):=20a=C3=A7=C3=B5es=20em=20node2?= =?UTF-8?q?4=20=E2=80=94=20checkout@v7,=20setup-java@v6,=20cache@v6,=20upl?= =?UTF-8?q?oad-artifact@v7=20(FINDING-6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 32 ++++++++++++++++---------------- scripts/qa/ci_invariants.sh | 29 ++++++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 06648af7..2aa523cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: workflow-lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: actionlint run: docker run --rm -v "$PWD:/repo" --workdir /repo rhysd/actionlint:1.7.12 -color - name: Invariantes do CI @@ -71,7 +71,7 @@ jobs: --health-timeout=5s --health-retries=5 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: dart-lang/setup-dart@v1 with: sdk: 3.12.2 @@ -100,7 +100,7 @@ jobs: backend-docker-build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 # Imagem do servidor Serverpod, a mesma que o docker-compose constrói. # O contexto é a raiz do workspace, porque o build precisa do pubspec.lock # compartilhado entre sinalacs_server e sinalacs_client. @@ -113,7 +113,7 @@ jobs: run: working-directory: apps/patient steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: subosito/flutter-action@v2 with: flutter-version: '3.44.8' @@ -147,7 +147,7 @@ jobs: run: working-directory: apps/acs steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: subosito/flutter-action@v2 with: flutter-version: '3.44.8' @@ -167,7 +167,7 @@ jobs: run: working-directory: apps/admin steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: subosito/flutter-action@v2 with: flutter-version: '3.44.8' @@ -178,7 +178,7 @@ jobs: coverage-report: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: dart-lang/setup-dart@v1 with: sdk: 3.12.2 @@ -196,7 +196,7 @@ jobs: - name: Derruba banco de teste if: always() run: docker compose --profile test down - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 if: always() with: name: coverage-reports @@ -208,7 +208,7 @@ jobs: env: GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }} steps: - - uses: actions/checkout@v4 + - 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 @@ -220,7 +220,7 @@ jobs: | sudo tee /etc/udev/rules.d/99-kvm4all.rules sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm - - uses: actions/setup-java@v4 + - uses: actions/setup-java@v6 with: distribution: temurin java-version: '17' @@ -231,7 +231,7 @@ jobs: # O 1º `assembleDebug` levava 311 s sem cache, 24 deles instalando o # CMake do NDK. Chave pelos pubspec.lock e scripts Gradle dos dois apps # que rodam no emulador — mesmo padrão do job admin-android-build. - - uses: actions/cache@v4 + - uses: actions/cache@v6 with: path: | ~/.pub-cache @@ -243,7 +243,7 @@ jobs: # Snapshot do AVD já inicializado: sem ele, todo run paga o primeiro # boot a frio. Os parâmetros do emulador precisam ser os mesmos aqui e # no step que roda os testes, ou o snapshot é descartado. - - uses: actions/cache@v4 + - uses: actions/cache@v6 id: avd-cache with: path: | @@ -294,7 +294,7 @@ jobs: - name: Remove pg_data/ do workspace if: always() run: sudo rm -rf pg_data - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 if: always() with: name: android-e2e-metrics @@ -314,8 +314,8 @@ jobs: run: working-directory: apps/admin steps: - - uses: actions/checkout@v4 - - uses: actions/setup-java@v4 + - uses: actions/checkout@v7 + - uses: actions/setup-java@v6 with: distribution: temurin java-version: '17' @@ -323,7 +323,7 @@ jobs: with: flutter-version: '3.44.8' # Sem cache, cada execução rebaixa ~200MB de Gradle e AGP. - - uses: actions/cache@v4 + - uses: actions/cache@v6 with: path: | ~/.gradle/caches diff --git a/scripts/qa/ci_invariants.sh b/scripts/qa/ci_invariants.sh index bbed7291..f696e961 100755 --- a/scripts/qa/ci_invariants.sh +++ b/scripts/qa/ci_invariants.sh @@ -107,8 +107,35 @@ def check_limpeza_do_workspace(): falhas.append('android-e2e: nenhum passo if: always() apaga pg_data/ depois do E2E') +# Major mínimo de cada ação da GitHub (FINDING-6): abaixo disso ela roda em +# Node 20, depreciado, ou — setup-java v4 — não recebe mais atualização. +MAJOR_MINIMO = { + 'actions/checkout': 7, + 'actions/setup-java': 6, + 'actions/cache': 6, + 'actions/upload-artifact': 7, +} + + +def check_versoes_de_acoes(): + for nome_job, job in jobs.items(): + for passo in job.get('steps') or []: + uses = passo.get('uses') or '' + acao, _, versao = uses.partition('@') + minimo = MAJOR_MINIMO.get(acao) + if minimo is None: + continue + if not versao.startswith('v') or not versao[1:].split('.')[0].isdigit(): + # Fixar por SHA é legítimo, mas então esta guarda precisa + # aprender a ler o comentário de versão — não passar calada. + falhas.append(f'FINDING-6: {nome_job} usa {uses}; este check só entende tags vN') + continue + if int(versao[1:].split('.')[0]) < minimo: + falhas.append(f'FINDING-6: {nome_job} usa {uses}; mínimo v{minimo}') + + CHECKS = [check_jobs, check_gatilhos, check_sem_filtro_de_paths, check_concorrencia, - check_limpeza_do_workspace] + check_limpeza_do_workspace, check_versoes_de_acoes] for check in CHECKS: check() From 23597cae96b809359604cc60b154d6f54b5fd86e Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Mon, 28 Sep 2026 19:13:46 -0400 Subject: [PATCH 017/498] =?UTF-8?q?docs(ci-audit):=20FINDING-6=20=E2=80=94?= =?UTF-8?q?=20a=C3=A7=C3=B5es=20atualizadas,=20avisos=20zerados;=20primeir?= =?UTF-8?q?o=20run=209/9=20verde?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/ci-audit/2026-09-28-avaliacao-ci-develop.md | 2 ++ 1 file changed, 2 insertions(+) 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 62b45c9a..38b978b7 100644 --- a/docs/ci-audit/2026-09-28-avaliacao-ci-develop.md +++ b/docs/ci-audit/2026-09-28-avaliacao-ci-develop.md @@ -271,3 +271,5 @@ Plano: `docs/superpowers/plans/2026-09-28-ci-correcoes-avaliacao.md`. | 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-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` | From 2fdab9bcc67e4d26e168c842d89bb2fe61ab052b Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Mon, 28 Sep 2026 19:14:08 -0400 Subject: [PATCH 018/498] =?UTF-8?q?fix(ci):=20fixar=20ubuntu-24.04=20em=20?= =?UTF-8?q?todos=20os=20jobs=20antes=20da=20migra=C3=A7=C3=A3o=20do=20ubun?= =?UTF-8?q?tu-latest=20(FINDING-6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 23 ++++++++++++++--------- scripts/qa/ci_invariants.sh | 15 ++++++++++++++- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2aa523cc..25a13894 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,12 +18,17 @@ concurrency: group: ci-${{ github.event_name }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.sha }} cancel-in-progress: true +# Todos os jobs fixam ubuntu-24.04 em vez de ubuntu-latest: o label migra +# para Ubuntu 26 a partir de 2026-10-19 sem aviso no dia, e o android-e2e +# (KVM, udev) e os builds Android (NDK/CMake em cache) são os mais sensíveis à +# troca. A migração é feita de propósito, depois de um ensaio — ver a Seção 6 +# de docs/ci-audit/2026-09-28-avaliacao-ci-develop.md. jobs: # Guarda do próprio workflow: actionlint e as invariantes que a avaliação de # 2026-09-28 mostrou derivarem sem ninguém ver (jobs × documentação, # gatilhos, concorrência, versões de ações, imagem do runner). workflow-lint: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v7 - name: actionlint @@ -33,7 +38,7 @@ jobs: python3 -c 'import yaml' 2>/dev/null || { sudo apt-get update && sudo apt-get install -y python3-yaml; } ./scripts/qa/ci_invariants.sh serverpod-backend: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 defaults: run: working-directory: backend @@ -98,7 +103,7 @@ jobs: working-directory: backend/sinalacs_server backend-docker-build: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v7 # Imagem do servidor Serverpod, a mesma que o docker-compose constrói. @@ -108,7 +113,7 @@ jobs: run: docker build -f backend/sinalacs_server/Dockerfile backend patient-app: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 defaults: run: working-directory: apps/patient @@ -142,7 +147,7 @@ jobs: - run: flutter test acs-app: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 defaults: run: working-directory: apps/acs @@ -162,7 +167,7 @@ jobs: - run: flutter test admin-app: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 defaults: run: working-directory: apps/admin @@ -176,7 +181,7 @@ jobs: - run: flutter test coverage-report: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v7 - uses: dart-lang/setup-dart@v1 @@ -203,7 +208,7 @@ jobs: path: build/qa/coverage android-e2e: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 60 env: GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }} @@ -309,7 +314,7 @@ jobs: # tem a guarda que faz o build falhar sem SINALACS_MQTT_PASSWORD: cobri-lo # aqui exigiria compilar um APK que a própria guarda declara inútil. admin-android-build: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 defaults: run: working-directory: apps/admin diff --git a/scripts/qa/ci_invariants.sh b/scripts/qa/ci_invariants.sh index f696e961..94c3e0cc 100755 --- a/scripts/qa/ci_invariants.sh +++ b/scripts/qa/ci_invariants.sh @@ -134,8 +134,21 @@ def check_versoes_de_acoes(): falhas.append(f'FINDING-6: {nome_job} usa {uses}; mínimo v{minimo}') +# Imagem fixada (FINDING-6): `ubuntu-latest` migra de versão sozinho, no dia +# que a GitHub escolher (Ubuntu 26 a partir de 2026-10-19). Trocar de imagem +# tem de ser um PR, não uma surpresa. +RUNNER = 'ubuntu-24.04' + + +def check_runner(): + for nome_job, job in jobs.items(): + if job.get('runs-on') != RUNNER: + falhas.append(f"FINDING-6: {nome_job} roda em {job.get('runs-on')!r}; esperado {RUNNER!r}") + + CHECKS = [check_jobs, check_gatilhos, check_sem_filtro_de_paths, check_concorrencia, - check_limpeza_do_workspace, check_versoes_de_acoes] + check_limpeza_do_workspace, check_versoes_de_acoes, + check_runner] for check in CHECKS: check() From fa37b1a6c32ccb1797483ac91dc3f52e3c2c72b2 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Mon, 28 Sep 2026 19:21:23 -0400 Subject: [PATCH 019/498] docs(ci-audit): runner fixado em ubuntu-24.04 Co-Authored-By: Claude Opus 5.5 --- docs/ci-audit/2026-09-28-avaliacao-ci-develop.md | 1 + 1 file changed, 1 insertion(+) 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 38b978b7..4e604709 100644 --- a/docs/ci-audit/2026-09-28-avaliacao-ci-develop.md +++ b/docs/ci-audit/2026-09-28-avaliacao-ci-develop.md @@ -273,3 +273,4 @@ Plano: `docs/superpowers/plans/2026-09-28-ci-correcoes-avaliacao.md`. | 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` | +| FINDING-6 (runner) | Fechado para o prazo de 2026-10-19 | run `36496906676` sobre `2fdab9b`: 9/9 `success`, todos os jobs em `ubuntu-24.04`, 0 avisos de migração; guarda `check_runner`. Ensaio do 26.04: ver a linha seguinte | From 0af3b8e40c69beb53ea0602bf43e0d4ab887c247 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Mon, 28 Sep 2026 19:40:33 -0400 Subject: [PATCH 020/498] docs(ci-audit): ensaio ubuntu-26.04 verde (9/9) Co-Authored-By: Claude Opus 5.5 --- docs/ci-audit/2026-09-28-avaliacao-ci-develop.md | 1 + 1 file changed, 1 insertion(+) 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 4e604709..9cde8aef 100644 --- a/docs/ci-audit/2026-09-28-avaliacao-ci-develop.md +++ b/docs/ci-audit/2026-09-28-avaliacao-ci-develop.md @@ -274,3 +274,4 @@ Plano: `docs/superpowers/plans/2026-09-28-ci-correcoes-avaliacao.md`. | 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` | | FINDING-6 (runner) | Fechado para o prazo de 2026-10-19 | run `36496906676` sobre `2fdab9b`: 9/9 `success`, todos os jobs em `ubuntu-24.04`, 0 avisos de migração; guarda `check_runner`. Ensaio do 26.04: ver a linha seguinte | +| Ensaio ubuntu-26.04 | Sem bloqueio para a migração | run `36497671043` (PR #17, fechada sem merge): 9/9 `success` em `ubuntu-26.04`; `android-e2e` em 17 min, contra ~7 no 24.04, com cache de AVD frio. O actionlint 1.7.12 ainda não reconhece o rótulo `ubuntu-26.04`: a migração precisa atualizar o actionlint ou declarar o rótulo em `.github/actionlint.yaml` | From 441d75e92ffa07b59a6dfe17f5542620322eceb9 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Mon, 28 Sep 2026 19:40:34 -0400 Subject: [PATCH 021/498] =?UTF-8?q?fix(ci):=20lista=20de=20checks=20obriga?= =?UTF-8?q?t=C3=B3rios=20vem=20da=20guarda=20do=20workflow=20(FINDING-5,?= =?UTF-8?q?=20FINDING-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- CLAUDE.md | 2 +- CONTRIBUTING.md | 16 +++++++++ .../2026-09-28-avaliacao-ci-develop.md | 2 ++ scripts/qa/ci_invariants.sh | 33 ++++++++++++++++++- 5 files changed, 52 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 16f46be1..52031fa1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -145,7 +145,7 @@ 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` (actionlint + `scripts/qa/ci_invariants.sh`, que falha quando esta lista diverge do workflow), `serverpod-backend`, `backend-docker-build`, `patient-app`, `acs-app`, `admin-app`, `coverage-report`, `android-e2e` (único que sobe emulador Android contra a stack; hoje o mais instável) e `admin-android-build` (compila o APK do admin); -- nem `main` nem `develop` têm branch protection: CI vermelho não bloqueia merge — ver [docs/ci-audit/2026-09-28-avaliacao-ci-develop.md](docs/ci-audit/2026-09-28-avaliacao-ci-develop.md). +- `main` e `develop` são protegidas: todo job exceto `android-e2e` precisa passar para mesclar (lista em `./scripts/qa/ci_invariants.sh --checks-obrigatorios`; ver `CONTRIBUTING.md`) — histórico em [docs/ci-audit/2026-09-28-avaliacao-ci-develop.md](docs/ci-audit/2026-09-28-avaliacao-ci-develop.md). ## Observações finais diff --git a/CLAUDE.md b/CLAUDE.md index ef3aadad..a45e0371 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -105,7 +105,7 @@ 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` (actionlint + `scripts/qa/ci_invariants.sh`, which fails when this job list drifts from the workflow), `serverpod-backend`, `backend-docker-build`, `patient-app`, `acs-app`, `admin-app`, `coverage-report`, `android-e2e`, `admin-android-build`. `android-e2e` is the only one that boots a real emulator against the stack and is the least stable. Neither `main` nor `develop` has branch protection, so red CI does not block merges — see `docs/ci-audit/2026-09-28-avaliacao-ci-develop.md`. +- 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` (actionlint + `scripts/qa/ci_invariants.sh`, which fails when this job list drifts from the workflow), `serverpod-backend`, `backend-docker-build`, `patient-app`, `acs-app`, `admin-app`, `coverage-report`, `android-e2e`, `admin-android-build`. `android-e2e` is the only one that boots a real emulator against the stack and is the least stable. `main` and `develop` are protected: every job except `android-e2e` must pass to merge (list from `./scripts/qa/ci_invariants.sh --checks-obrigatorios`; admins can still push directly) — history in `docs/ci-audit/2026-09-28-avaliacao-ci-develop.md`. - Never commit real patient data, credentials, or the dev Docker Compose secrets into anything beyond local development. ## graphify diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0c837a5a..b787e609 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -124,6 +124,22 @@ padrão, e é gerada por máquina. Um `flutter build apk` puro **falha** — a guarda vive em `apps/acs/android/app/build.gradle.kts` — em vez de compilar em silêncio um APK que nunca recebe alerta. +## CI e merge + +`main` e `develop` são protegidas: uma PR só entra com todos os jobs do CI +verdes, exceto `android-e2e`. O `android-e2e` é informativo até acumular +histórico verde, mas vermelho nele continua sendo defeito a investigar, não +ruído. A lista de checks obrigatórios vem de +`./scripts/qa/ci_invariants.sh --checks-obrigatorios`. Ao criar, renomear ou +remover um job, atualize `JOBS_DOCUMENTADOS` nesse script, `CLAUDE.md` e +`AGENTS.md` no mesmo commit. O job `workflow-lint` falha se não fizer isso. + +Não mescle com CI vermelho. Para rodar o CI numa branch sem abrir PR: + +```bash +gh workflow run CI --ref +``` + ## Alterações no backend O backend é um workspace Serverpod. Mantenha lógica de negócio em `application/` 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 9cde8aef..792e7e6f 100644 --- a/docs/ci-audit/2026-09-28-avaliacao-ci-develop.md +++ b/docs/ci-audit/2026-09-28-avaliacao-ci-develop.md @@ -275,3 +275,5 @@ Plano: `docs/superpowers/plans/2026-09-28-ci-correcoes-avaliacao.md`. | 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` | | FINDING-6 (runner) | Fechado para o prazo de 2026-10-19 | run `36496906676` sobre `2fdab9b`: 9/9 `success`, todos os jobs em `ubuntu-24.04`, 0 avisos de migração; guarda `check_runner`. Ensaio do 26.04: ver a linha seguinte | | Ensaio ubuntu-26.04 | Sem bloqueio para a migração | run `36497671043` (PR #17, fechada sem merge): 9/9 `success` em `ubuntu-26.04`; `android-e2e` em 17 min, contra ~7 no 24.04, com cache de AVD frio. O actionlint 1.7.12 ainda não reconhece o rótulo `ubuntu-26.04`: a migração precisa atualizar o actionlint ou declarar o rótulo em `.github/actionlint.yaml` | +| FINDING-5 | Fechado | proteção em `main` e `develop` com os checks de `ci_invariants.sh --checks-obrigatorios` (todos menos `android-e2e`), `enforce_admins: false`; PR obrigatória em `main` | +| FINDING-3 | Fechado como regra | `CONTRIBUTING.md` › CI e merge; aplicado pela proteção do FINDING-5 | diff --git a/scripts/qa/ci_invariants.sh b/scripts/qa/ci_invariants.sh index 94c3e0cc..45844c94 100755 --- a/scripts/qa/ci_invariants.sh +++ b/scripts/qa/ci_invariants.sh @@ -18,6 +18,7 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" exec python3 - "$repo_root/.github/workflows/ci.yml" "$@" <<'PY' +import json import sys import yaml @@ -146,9 +147,36 @@ def check_runner(): falhas.append(f"FINDING-6: {nome_job} roda em {job.get('runs-on')!r}; esperado {RUNNER!r}") +# Checks obrigatórios para merge em main e develop (FINDING-5). O +# android-e2e fica de fora enquanto não tiver histórico verde: torná-lo +# obrigatório hoje travaria toda PR num job sabidamente instável (FINDING-4). +# A proteção de branch é aplicada a partir desta lista (--checks-obrigatorios), +# então renomear um job quebra aqui antes de deixar PRs esperando por um check +# que não existe mais. +CHECKS_OBRIGATORIOS = sorted(JOBS_DOCUMENTADOS - {'android-e2e'}) +# App GitHub Actions: amarrar o check ao app impede que outra integração +# publique um status com o mesmo nome e destrave o merge. +APP_GITHUB_ACTIONS = 15368 + +def check_checks_obrigatorios(): + if 'android-e2e' in CHECKS_OBRIGATORIOS: + falhas.append('FINDING-4: android-e2e não pode ser obrigatório enquanto for instável') + for nome in CHECKS_OBRIGATORIOS: + job = jobs.get(nome) + if job is None: + falhas.append(f'FINDING-5: check obrigatório {nome} não existe no workflow') + continue + # O nome do check é o `name:` do job, se houver; e um job com `if:` + # pode não rodar e nunca reportar. + if job.get('name', nome) != nome: + falhas.append(f"FINDING-5: {nome} tem name: {job['name']!r}; o check obrigatório não casaria") + if 'if' in job: + falhas.append(f'FINDING-5: {nome} tem if: no nível do job; pode nunca reportar') + + CHECKS = [check_jobs, check_gatilhos, check_sem_filtro_de_paths, check_concorrencia, check_limpeza_do_workspace, check_versoes_de_acoes, - check_runner] + check_runner, check_checks_obrigatorios] for check in CHECKS: check() @@ -157,5 +185,8 @@ if falhas: for falha in falhas: print(f'FALHA: {falha}', file=sys.stderr) sys.exit(1) +if '--checks-obrigatorios' in sys.argv[2:]: + print(json.dumps([{'context': c, 'app_id': APP_GITHUB_ACTIONS} for c in CHECKS_OBRIGATORIOS])) + sys.exit(0) print(f'ok: {len(CHECKS)} grupos de invariantes do CI') PY From d5502fe33fad2fb3c6f8563bee39ace301bbf29d Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Mon, 28 Sep 2026 19:55:58 -0400 Subject: [PATCH 022/498] =?UTF-8?q?fix(ci):=20chave=20do=20AVD=20leva=20a?= =?UTF-8?q?=20imagem=20do=20runner;=20documenta=20reaplica=C3=A7=C3=A3o=20?= =?UTF-8?q?da=20prote=C3=A7=C3=A3o=20(revis=C3=A3o=20final)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 2 +- CONTRIBUTING.md | 17 +++++++++++++++++ scripts/qa/ci_invariants.sh | 13 ++++++++++--- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25a13894..3787b7ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -254,7 +254,7 @@ jobs: path: | ~/.android/avd/* ~/.android/adb* - key: avd-36-x86_64-pixel_7 + key: avd-36-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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b787e609..60579c3a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -134,6 +134,23 @@ ruído. A lista de checks obrigatórios vem de remover um job, atualize `JOBS_DOCUMENTADOS` nesse script, `CLAUDE.md` e `AGENTS.md` no mesmo commit. O job `workflow-lint` falha se não fizer isso. +O `workflow-lint` não enxerga a proteção configurada no GitHub. Depois de +mesclar uma PR que muda a lista, um admin tem de reaplicar a proteção, senão +as PRs ficam esperando um check que não existe mais (job renomeado) ou um job +novo fica opcional sem ninguém perceber. Para conferir as duas listas: + +```bash +esperado=$(./scripts/qa/ci_invariants.sh --checks-obrigatorios | python3 -c 'import json,sys; print(" ".join(c["context"] for c in json.load(sys.stdin)))') +for b in develop main; do + atual=$(gh api "repos/hbgit/SinalACS/branches/$b/protection/required_status_checks" --jq '[.checks[].context] | sort | join(" ")') + [ "$atual" = "$esperado" ] && echo "$b ok" || echo "$b DIVERGE: $atual" +done +``` + +Para reaplicar, use o `PUT .../branches//protection` da Task 6 de +`docs/superpowers/plans/2026-09-28-ci-correcoes-avaliacao.md`, com +`"checks"` igual à saída de `--checks-obrigatorios`. + Não mescle com CI vermelho. Para rodar o CI numa branch sem abrir PR: ```bash diff --git a/scripts/qa/ci_invariants.sh b/scripts/qa/ci_invariants.sh index 45844c94..9e3fb821 100755 --- a/scripts/qa/ci_invariants.sh +++ b/scripts/qa/ci_invariants.sh @@ -145,14 +145,21 @@ def check_runner(): for nome_job, job in jobs.items(): if job.get('runs-on') != RUNNER: falhas.append(f"FINDING-6: {nome_job} roda em {job.get('runs-on')!r}; esperado {RUNNER!r}") + # O snapshot do AVD é específico da imagem do runner: sem a imagem na + # chave, a primeira execução numa imagem nova restauraria o AVD da antiga. + for passo in jobs.get('android-e2e', {}).get('steps', []): + chave = str((passo.get('with') or {}).get('key', '')) + if chave.startswith('avd-') and not chave.endswith(f'-{RUNNER}'): + falhas.append(f'FINDING-6: chave do cache de AVD {chave!r} não termina em -{RUNNER}') # Checks obrigatórios para merge em main e develop (FINDING-5). O # android-e2e fica de fora enquanto não tiver histórico verde: torná-lo # obrigatório hoje travaria toda PR num job sabidamente instável (FINDING-4). -# A proteção de branch é aplicada a partir desta lista (--checks-obrigatorios), -# então renomear um job quebra aqui antes de deixar PRs esperando por um check -# que não existe mais. +# A proteção de branch é aplicada a partir desta lista (--checks-obrigatorios). +# Este 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 PRs esperam por um +# check que não existe mais. CHECKS_OBRIGATORIOS = sorted(JOBS_DOCUMENTADOS - {'android-e2e'}) # App GitHub Actions: amarrar o check ao app impede que outra integração # publique um status com o mesmo nome e destrave o merge. From 032be4b5f8048776f1ea8b8e501743d5dddfdfdc Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Mon, 28 Sep 2026 20:15:14 -0400 Subject: [PATCH 023/498] docs: CLAUDE.md e AGENTS.md refletem o CI fixado, a guarda e a branch protection Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 7 +++++-- CLAUDE.md | 3 ++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 52031fa1..4b860ec3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -144,8 +144,11 @@ Importante: - `flutter test` é hermético e não substitui validações com stack local real; - os testes de integração e validação de conexão vivem fora do `flutter test` e utilizam a stack Docker/VM; - 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` (actionlint + `scripts/qa/ci_invariants.sh`, que falha quando esta lista diverge do workflow), `serverpod-backend`, `backend-docker-build`, `patient-app`, `acs-app`, `admin-app`, `coverage-report`, `android-e2e` (único que sobe emulador Android contra a stack; hoje o mais instável) e `admin-android-build` (compila o APK do admin); -- `main` e `develop` são protegidas: todo job exceto `android-e2e` precisa passar para mesclar (lista em `./scripts/qa/ci_invariants.sh --checks-obrigatorios`; ver `CONTRIBUTING.md`) — histórico em [docs/ci-audit/2026-09-28-avaliacao-ci-develop.md](docs/ci-audit/2026-09-28-avaliacao-ci-develop.md). +- 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 `-`; +- `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. ## Observações finais diff --git a/CLAUDE.md b/CLAUDE.md index a45e0371..f58b0f6c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -105,7 +105,8 @@ 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` (actionlint + `scripts/qa/ci_invariants.sh`, which fails when this job list drifts from the workflow), `serverpod-backend`, `backend-docker-build`, `patient-app`, `acs-app`, `admin-app`, `coverage-report`, `android-e2e`, `admin-android-build`. `android-e2e` is the only one that boots a real emulator against the stack and is the least stable. `main` and `develop` are protected: every job except `android-e2e` must pass to merge (list from `./scripts/qa/ci_invariants.sh --checks-obrigatorios`; admins can still push directly) — history in `docs/ci-audit/2026-09-28-avaliacao-ci-develop.md`. +- 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. - Never commit real patient data, credentials, or the dev Docker Compose secrets into anything beyond local development. ## graphify From b6cfffd9db2f1514e76b6a86f2154910b0d3d3c0 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Mon, 28 Sep 2026 20:55:26 -0400 Subject: [PATCH 024/498] =?UTF-8?q?feat(lgpd):=20schema=20de=20pedidos=20d?= =?UTF-8?q?o=20titular=20e=20linha=20de=20consentimento=20assinada=20num?= =?UTF-8?q?=20lugar=20s=C3=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- apps/patient/test/patient_app_mvp_test.dart | 3 + .../test/support/fake_patient_backend.dart | 1 + .../protocol/api/patient_data_overview.dart | 23 +- .../patient_data_subject_request_record.dart | 131 + .../src/protocol/data_subject_request.dart | 171 + .../enums/data_subject_request_status.dart | 43 + .../enums/data_subject_request_type.dart | 40 + .../exceptions/data_rights_exception.dart | 61 + .../lib/src/protocol/protocol.dart | 603 +-- .../patient_data_overview_service.dart | 26 + .../lib/src/endpoints/patients_endpoint.dart | 11 + .../generated/api/patient_data_overview.dart | 24 +- .../patient_data_subject_request_record.dart | 143 + .../src/generated/data_subject_request.dart | 646 ++++ .../enums/data_subject_request_status.dart | 43 + .../enums/data_subject_request_type.dart | 40 + .../exceptions/data_rights_exception.dart | 72 + .../lib/src/generated/protocol.dart | 751 ++-- .../database/orm_onboarding_store.dart | 23 +- .../database/signed_consent_log.dart | 40 + .../models/api/patient_data_overview.spy.yaml | 1 + ...tient_data_subject_request_record.spy.yaml | 9 + .../src/models/data_subject_request.spy.yaml | 22 + .../data_subject_request_status.spy.yaml | 8 + .../enums/data_subject_request_type.spy.yaml | 7 + .../exceptions/data_rights_exception.spy.yaml | 6 + .../20260929005339393/definition.json | 3296 +++++++++++++++++ .../20260929005339393/definition.sql | 687 ++++ .../20260929005339393/definition_project.json | 1960 ++++++++++ .../20260929005339393/migration.json | 129 + .../20260929005339393/migration.sql | 48 + .../migrations/migration_registry.txt | 1 + .../test/unit/signed_consent_log_test.dart | 45 + ...-09-28-direitos-do-titular-app-paciente.md | 2729 ++++++++++++++ spec/lgpd_data_audit.md | 8 +- 35 files changed, 11281 insertions(+), 570 deletions(-) create mode 100644 backend/sinalacs_client/lib/src/protocol/api/patient_data_subject_request_record.dart create mode 100644 backend/sinalacs_client/lib/src/protocol/data_subject_request.dart create mode 100644 backend/sinalacs_client/lib/src/protocol/enums/data_subject_request_status.dart create mode 100644 backend/sinalacs_client/lib/src/protocol/enums/data_subject_request_type.dart create mode 100644 backend/sinalacs_client/lib/src/protocol/exceptions/data_rights_exception.dart create mode 100644 backend/sinalacs_server/lib/src/generated/api/patient_data_subject_request_record.dart create mode 100644 backend/sinalacs_server/lib/src/generated/data_subject_request.dart create mode 100644 backend/sinalacs_server/lib/src/generated/enums/data_subject_request_status.dart create mode 100644 backend/sinalacs_server/lib/src/generated/enums/data_subject_request_type.dart create mode 100644 backend/sinalacs_server/lib/src/generated/exceptions/data_rights_exception.dart create mode 100644 backend/sinalacs_server/lib/src/infrastructure/database/signed_consent_log.dart create mode 100644 backend/sinalacs_server/lib/src/models/api/patient_data_subject_request_record.spy.yaml create mode 100644 backend/sinalacs_server/lib/src/models/data_subject_request.spy.yaml create mode 100644 backend/sinalacs_server/lib/src/models/enums/data_subject_request_status.spy.yaml create mode 100644 backend/sinalacs_server/lib/src/models/enums/data_subject_request_type.spy.yaml create mode 100644 backend/sinalacs_server/lib/src/models/exceptions/data_rights_exception.spy.yaml create mode 100644 backend/sinalacs_server/migrations/20260929005339393/definition.json create mode 100644 backend/sinalacs_server/migrations/20260929005339393/definition.sql create mode 100644 backend/sinalacs_server/migrations/20260929005339393/definition_project.json create mode 100644 backend/sinalacs_server/migrations/20260929005339393/migration.json create mode 100644 backend/sinalacs_server/migrations/20260929005339393/migration.sql create mode 100644 backend/sinalacs_server/test/unit/signed_consent_log_test.dart create mode 100644 docs/superpowers/plans/2026-09-28-direitos-do-titular-app-paciente.md diff --git a/apps/patient/test/patient_app_mvp_test.dart b/apps/patient/test/patient_app_mvp_test.dart index 1369e77d..28287cc5 100644 --- a/apps/patient/test/patient_app_mvp_test.dart +++ b/apps/patient/test/patient_app_mvp_test.dart @@ -11,6 +11,7 @@ import 'package:sinalacs_client/sinalacs_client.dart' AlertStatusResult, PatientConsentRecord, PatientDataOverview, + PatientDataSubjectRequestRecord, PatientRiskEvent, RiskLevel; import 'package:sinalacs_patient/app/app.dart'; @@ -834,6 +835,7 @@ void main() { PatientDataOverview overview({ List consents = const [], List riskHistory = const [], + List requests = const [], }) => PatientDataOverview( name: 'Fulano de Tal', @@ -843,6 +845,7 @@ void main() { chronicConditions: const ['hipertensão'], consents: consents, riskHistory: riskHistory, + requests: requests, ); testWidgets('mostra o cadastro e as condições crônicas devolvidas pelo servidor', (tester) async { diff --git a/apps/patient/test/support/fake_patient_backend.dart b/apps/patient/test/support/fake_patient_backend.dart index 5c1bd491..6b3b3027 100644 --- a/apps/patient/test/support/fake_patient_backend.dart +++ b/apps/patient/test/support/fake_patient_backend.dart @@ -49,6 +49,7 @@ class FakePatientBackend implements PatientBackend { chronicConditions: const [], consents: const [], riskHistory: const [], + requests: const [], ); BackendFailure? myDataFailure; int myDataCallCount = 0; 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..ac22a03a --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/api/patient_data_subject_request_record.dart @@ -0,0 +1,131 @@ +/* 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, + }); + + factory PatientDataSubjectRequestRecord({ + required _i2.DataSubjectRequestType type, + required _i3.DataSubjectRequestStatus status, + String? details, + required DateTime createdAt, + required DateTime dueAt, + }) = _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']), + ); + } + + _i2.DataSubjectRequestType type; + + _i3.DataSubjectRequestStatus status; + + String? details; + + DateTime createdAt; + + DateTime dueAt; + + /// 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, + }); + @override + Map toJson() { + return { + '__className__': 'PatientDataSubjectRequestRecord', + 'type': type.toJson(), + 'status': status.toJson(), + if (details != null) 'details': details, + 'createdAt': createdAt.toJson(), + 'dueAt': dueAt.toJson(), + }; + } + + @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, + }) : super._( + type: type, + status: status, + details: details, + createdAt: createdAt, + dueAt: dueAt, + ); + + /// 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, + }) { + 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, + ); + } +} 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..7e3ab70d --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/data_subject_request.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; +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, + }); + + 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, + }) = _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']), + ); + } + + /// 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; + + /// 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, + }); + @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(), + }; + } + + @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, + }) : super._( + id: id, + userId: userId, + requestType: requestType, + detailsEncrypted: detailsEncrypted, + detailsKeyVersion: detailsKeyVersion, + status: status, + createdAt: createdAt, + dueAt: dueAt, + ); + + /// 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, + }) { + 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, + ); + } +} 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..15e53e0b --- /dev/null +++ b/backend/sinalacs_client/lib/src/protocol/enums/data_subject_request_status.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; + +/// 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 implements _i1.SerializableModel { + open, + completed, + rejected; + + static DataSubjectRequestStatus fromJson(String name) { + switch (name) { + case 'open': + return DataSubjectRequestStatus.open; + 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/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/protocol.dart b/backend/sinalacs_client/lib/src/protocol/protocol.dart index 2fed766f..40bf5de5 100644 --- a/backend/sinalacs_client/lib/src/protocol/protocol.dart +++ b/backend/sinalacs_client/lib/src/protocol/protocol.dart @@ -25,42 +25,47 @@ 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 'api/patient_data_subject_request_record.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 'data_subject_request.dart' as _i24; +import 'enrollment_token.dart' as _i25; +import 'enums/alert_status.dart' as _i26; +import 'enums/arrival_method.dart' as _i27; +import 'enums/consent_purpose.dart' as _i28; +import 'enums/data_subject_request_status.dart' as _i29; +import 'enums/data_subject_request_type.dart' as _i30; +import 'enums/risk_level.dart' as _i31; +import 'enums/sync_status.dart' as _i32; +import 'enums/user_role.dart' as _i33; +import 'exceptions/alert_dispatch_unavailable_exception.dart' as _i34; +import 'exceptions/alert_permission_exception.dart' as _i35; +import 'exceptions/alert_validation_exception.dart' as _i36; +import 'exceptions/authentication_failed_exception.dart' as _i37; +import 'exceptions/data_rights_exception.dart' as _i38; +import 'exceptions/endpoint_disabled_exception.dart' as _i39; +import 'exceptions/enrollment_exception.dart' as _i40; +import 'exceptions/otp_request_exception.dart' as _i41; +import 'micro_area.dart' as _i42; +import 'otp_challenge.dart' as _i43; +import 'patient.dart' as _i44; +import 'triage_answer.dart' as _i45; +import 'triage_session.dart' as _i46; +import 'ubs.dart' as _i47; +import 'user.dart' as _i48; +import 'user_credential.dart' as _i49; +import 'visit.dart' as _i50; import 'package:sinalacs_client/src/protocol/api/micro_area_patient.dart' - as _i46; + as _i51; 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 _i52; +import 'package:sinalacs_client/src/protocol/api/visit_sync_entry.dart' as _i53; export 'acs.dart'; export 'alert.dart'; export 'alert_delivery_record.dart'; @@ -74,6 +79,7 @@ export 'api/enrollment_token_result.dart'; export 'api/micro_area_patient.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'; @@ -82,10 +88,13 @@ export 'api/visit_sync_entry.dart'; export 'api/visit_sync_result.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/consent_purpose.dart'; +export 'enums/data_subject_request_status.dart'; +export 'enums/data_subject_request_type.dart'; export 'enums/risk_level.dart'; export 'enums/sync_status.dart'; export 'enums/user_role.dart'; @@ -93,6 +102,7 @@ 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/otp_request_exception.dart'; @@ -180,98 +190,113 @@ class Protocol extends _i1.SerializationManager { if (t == _i14.PatientDataOverview) { return _i14.PatientDataOverview.fromJson(data) as T; } - if (t == _i15.PatientRiskEvent) { - return _i15.PatientRiskEvent.fromJson(data) as T; + if (t == _i15.PatientDataSubjectRequestRecord) { + return _i15.PatientDataSubjectRequestRecord.fromJson(data) as T; } - if (t == _i16.RedAlertResult) { - return _i16.RedAlertResult.fromJson(data) as T; + if (t == _i16.PatientRiskEvent) { + return _i16.PatientRiskEvent.fromJson(data) as T; } - if (t == _i17.ServiceHealth) { - return _i17.ServiceHealth.fromJson(data) as T; + if (t == _i17.RedAlertResult) { + return _i17.RedAlertResult.fromJson(data) as T; } - if (t == _i18.TriageResult) { - return _i18.TriageResult.fromJson(data) as T; + if (t == _i18.ServiceHealth) { + return _i18.ServiceHealth.fromJson(data) as T; } - if (t == _i19.VisitSyncEntry) { - return _i19.VisitSyncEntry.fromJson(data) as T; + if (t == _i19.TriageResult) { + return _i19.TriageResult.fromJson(data) as T; } - if (t == _i20.VisitSyncResult) { - return _i20.VisitSyncResult.fromJson(data) as T; + if (t == _i20.VisitSyncEntry) { + return _i20.VisitSyncEntry.fromJson(data) as T; } - if (t == _i21.AuditLog) { - return _i21.AuditLog.fromJson(data) as T; + if (t == _i21.VisitSyncResult) { + return _i21.VisitSyncResult.fromJson(data) as T; } - if (t == _i22.ConsentLog) { - return _i22.ConsentLog.fromJson(data) as T; + if (t == _i22.AuditLog) { + return _i22.AuditLog.fromJson(data) as T; } - if (t == _i23.EnrollmentToken) { - return _i23.EnrollmentToken.fromJson(data) as T; + if (t == _i23.ConsentLog) { + return _i23.ConsentLog.fromJson(data) as T; } - if (t == _i24.AlertStatus) { - return _i24.AlertStatus.fromJson(data) as T; + if (t == _i24.DataSubjectRequest) { + return _i24.DataSubjectRequest.fromJson(data) as T; } - if (t == _i25.ArrivalMethod) { - return _i25.ArrivalMethod.fromJson(data) as T; + if (t == _i25.EnrollmentToken) { + return _i25.EnrollmentToken.fromJson(data) as T; } - if (t == _i26.ConsentPurpose) { - return _i26.ConsentPurpose.fromJson(data) as T; + if (t == _i26.AlertStatus) { + return _i26.AlertStatus.fromJson(data) as T; } - if (t == _i27.RiskLevel) { - return _i27.RiskLevel.fromJson(data) as T; + if (t == _i27.ArrivalMethod) { + return _i27.ArrivalMethod.fromJson(data) as T; } - if (t == _i28.SyncStatus) { - return _i28.SyncStatus.fromJson(data) as T; + if (t == _i28.ConsentPurpose) { + return _i28.ConsentPurpose.fromJson(data) as T; } - if (t == _i29.UserRole) { - return _i29.UserRole.fromJson(data) as T; + if (t == _i29.DataSubjectRequestStatus) { + return _i29.DataSubjectRequestStatus.fromJson(data) as T; } - if (t == _i30.AlertDispatchUnavailableException) { - return _i30.AlertDispatchUnavailableException.fromJson(data) as T; + if (t == _i30.DataSubjectRequestType) { + return _i30.DataSubjectRequestType.fromJson(data) as T; } - if (t == _i31.AlertPermissionException) { - return _i31.AlertPermissionException.fromJson(data) as T; + if (t == _i31.RiskLevel) { + return _i31.RiskLevel.fromJson(data) as T; } - if (t == _i32.AlertValidationException) { - return _i32.AlertValidationException.fromJson(data) as T; + if (t == _i32.SyncStatus) { + return _i32.SyncStatus.fromJson(data) as T; } - if (t == _i33.AuthenticationFailedException) { - return _i33.AuthenticationFailedException.fromJson(data) as T; + if (t == _i33.UserRole) { + return _i33.UserRole.fromJson(data) as T; } - if (t == _i34.EndpointDisabledException) { - return _i34.EndpointDisabledException.fromJson(data) as T; + if (t == _i34.AlertDispatchUnavailableException) { + return _i34.AlertDispatchUnavailableException.fromJson(data) as T; } - if (t == _i35.EnrollmentException) { - return _i35.EnrollmentException.fromJson(data) as T; + if (t == _i35.AlertPermissionException) { + return _i35.AlertPermissionException.fromJson(data) as T; } - if (t == _i36.OtpRequestException) { - return _i36.OtpRequestException.fromJson(data) as T; + if (t == _i36.AlertValidationException) { + return _i36.AlertValidationException.fromJson(data) as T; } - if (t == _i37.MicroArea) { - return _i37.MicroArea.fromJson(data) as T; + if (t == _i37.AuthenticationFailedException) { + return _i37.AuthenticationFailedException.fromJson(data) as T; } - if (t == _i38.OtpChallenge) { - return _i38.OtpChallenge.fromJson(data) as T; + if (t == _i38.DataRightsException) { + return _i38.DataRightsException.fromJson(data) as T; } - if (t == _i39.Patient) { - return _i39.Patient.fromJson(data) as T; + if (t == _i39.EndpointDisabledException) { + return _i39.EndpointDisabledException.fromJson(data) as T; } - if (t == _i40.TriageAnswer) { - return _i40.TriageAnswer.fromJson(data) as T; + if (t == _i40.EnrollmentException) { + return _i40.EnrollmentException.fromJson(data) as T; } - if (t == _i41.TriageSession) { - return _i41.TriageSession.fromJson(data) as T; + if (t == _i41.OtpRequestException) { + return _i41.OtpRequestException.fromJson(data) as T; } - if (t == _i42.Ubs) { - return _i42.Ubs.fromJson(data) as T; + if (t == _i42.MicroArea) { + return _i42.MicroArea.fromJson(data) as T; } - if (t == _i43.User) { - return _i43.User.fromJson(data) as T; + if (t == _i43.OtpChallenge) { + return _i43.OtpChallenge.fromJson(data) as T; } - if (t == _i44.UserCredential) { - return _i44.UserCredential.fromJson(data) as T; + if (t == _i44.Patient) { + return _i44.Patient.fromJson(data) as T; } - if (t == _i45.Visit) { - return _i45.Visit.fromJson(data) as T; + if (t == _i45.TriageAnswer) { + return _i45.TriageAnswer.fromJson(data) as T; + } + if (t == _i46.TriageSession) { + return _i46.TriageSession.fromJson(data) as T; + } + if (t == _i47.Ubs) { + return _i47.Ubs.fromJson(data) as T; + } + if (t == _i48.User) { + return _i48.User.fromJson(data) as T; + } + if (t == _i49.UserCredential) { + return _i49.UserCredential.fromJson(data) as T; + } + if (t == _i50.Visit) { + return _i50.Visit.fromJson(data) as T; } if (t == _i1.getType<_i2.Acs?>()) { return (data != null ? _i2.Acs.fromJson(data) : null) as T; @@ -318,115 +343,139 @@ class Protocol extends _i1.SerializationManager { return (data != null ? _i14.PatientDataOverview.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<_i15.PatientDataSubjectRequestRecord?>()) { + return (data != null + ? _i15.PatientDataSubjectRequestRecord.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<_i16.RedAlertResult?>()) { - return (data != null ? _i16.RedAlertResult.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<_i17.ServiceHealth?>()) { - return (data != null ? _i17.ServiceHealth.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<_i18.TriageResult?>()) { - return (data != null ? _i18.TriageResult.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<_i19.VisitSyncEntry?>()) { - return (data != null ? _i19.VisitSyncEntry.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<_i20.VisitSyncResult?>()) { - return (data != null ? _i20.VisitSyncResult.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<_i21.AuditLog?>()) { - return (data != null ? _i21.AuditLog.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<_i22.ConsentLog?>()) { - return (data != null ? _i22.ConsentLog.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<_i24.DataSubjectRequest?>()) { + return (data != null ? _i24.DataSubjectRequest.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<_i25.EnrollmentToken?>()) { + return (data != null ? _i25.EnrollmentToken.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<_i26.AlertStatus?>()) { + return (data != null ? _i26.AlertStatus.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<_i27.ArrivalMethod?>()) { + return (data != null ? _i27.ArrivalMethod.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i28.ConsentPurpose?>()) { + return (data != null ? _i28.ConsentPurpose.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i29.DataSubjectRequestStatus?>()) { + return (data != null + ? _i29.DataSubjectRequestStatus.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<_i30.DataSubjectRequestType?>()) { + return (data != null ? _i30.DataSubjectRequestType.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<_i31.RiskLevel?>()) { + return (data != null ? _i31.RiskLevel.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<_i32.SyncStatus?>()) { + return (data != null ? _i32.SyncStatus.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<_i33.UserRole?>()) { + return (data != null ? _i33.UserRole.fromJson(data) : null) as T; } - if (t == _i1.getType<_i30.AlertDispatchUnavailableException?>()) { + if (t == _i1.getType<_i34.AlertDispatchUnavailableException?>()) { return (data != null - ? _i30.AlertDispatchUnavailableException.fromJson(data) + ? _i34.AlertDispatchUnavailableException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i31.AlertPermissionException?>()) { + if (t == _i1.getType<_i35.AlertPermissionException?>()) { return (data != null - ? _i31.AlertPermissionException.fromJson(data) + ? _i35.AlertPermissionException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i32.AlertValidationException?>()) { + if (t == _i1.getType<_i36.AlertValidationException?>()) { return (data != null - ? _i32.AlertValidationException.fromJson(data) + ? _i36.AlertValidationException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i33.AuthenticationFailedException?>()) { + if (t == _i1.getType<_i37.AuthenticationFailedException?>()) { return (data != null - ? _i33.AuthenticationFailedException.fromJson(data) + ? _i37.AuthenticationFailedException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i34.EndpointDisabledException?>()) { + if (t == _i1.getType<_i38.DataRightsException?>()) { + return (data != null ? _i38.DataRightsException.fromJson(data) : null) + as T; + } + if (t == _i1.getType<_i39.EndpointDisabledException?>()) { return (data != null - ? _i34.EndpointDisabledException.fromJson(data) + ? _i39.EndpointDisabledException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i35.EnrollmentException?>()) { - return (data != null ? _i35.EnrollmentException.fromJson(data) : null) + if (t == _i1.getType<_i40.EnrollmentException?>()) { + return (data != null ? _i40.EnrollmentException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i36.OtpRequestException?>()) { - return (data != null ? _i36.OtpRequestException.fromJson(data) : null) + if (t == _i1.getType<_i41.OtpRequestException?>()) { + return (data != null ? _i41.OtpRequestException.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<_i42.MicroArea?>()) { + return (data != null ? _i42.MicroArea.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<_i43.OtpChallenge?>()) { + return (data != null ? _i43.OtpChallenge.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<_i44.Patient?>()) { + return (data != null ? _i44.Patient.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<_i45.TriageAnswer?>()) { + return (data != null ? _i45.TriageAnswer.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<_i46.TriageSession?>()) { + return (data != null ? _i46.TriageSession.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<_i47.Ubs?>()) { + return (data != null ? _i47.Ubs.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<_i48.User?>()) { + return (data != null ? _i48.User.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<_i49.UserCredential?>()) { + return (data != null ? _i49.UserCredential.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<_i50.Visit?>()) { + return (data != null ? _i50.Visit.fromJson(data) : null) as T; } if (t == List) { return (data as List).map((e) => deserialize(e)).toList() as T; @@ -437,9 +486,15 @@ class Protocol extends _i1.SerializationManager { .toList() as T; } - if (t == List<_i15.PatientRiskEvent>) { + if (t == List<_i16.PatientRiskEvent>) { + return (data as List) + .map((e) => deserialize<_i16.PatientRiskEvent>(e)) + .toList() + as T; + } + if (t == List<_i15.PatientDataSubjectRequestRecord>) { return (data as List) - .map((e) => deserialize<_i15.PatientRiskEvent>(e)) + .map((e) => deserialize<_i15.PatientDataSubjectRequestRecord>(e)) .toList() as T; } @@ -449,24 +504,24 @@ class Protocol extends _i1.SerializationManager { ) as T; } - if (t == List<_i46.MicroAreaPatient>) { + if (t == List<_i51.MicroAreaPatient>) { return (data as List) - .map((e) => deserialize<_i46.MicroAreaPatient>(e)) + .map((e) => deserialize<_i51.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<_i52.VisitSyncResult>) { return (data as List) - .map((e) => deserialize<_i47.VisitSyncResult>(e)) + .map((e) => deserialize<_i52.VisitSyncResult>(e)) .toList() as T; } - if (t == List<_i48.VisitSyncEntry>) { + if (t == List<_i53.VisitSyncEntry>) { return (data as List) - .map((e) => deserialize<_i48.VisitSyncEntry>(e)) + .map((e) => deserialize<_i53.VisitSyncEntry>(e)) .toList() as T; } @@ -488,38 +543,43 @@ class Protocol extends _i1.SerializationManager { _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 => + _i15.PatientDataSubjectRequestRecord => 'PatientDataSubjectRequestRecord', + _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.DataSubjectRequest => 'DataSubjectRequest', + _i25.EnrollmentToken => 'EnrollmentToken', + _i26.AlertStatus => 'AlertStatus', + _i27.ArrivalMethod => 'ArrivalMethod', + _i28.ConsentPurpose => 'ConsentPurpose', + _i29.DataSubjectRequestStatus => 'DataSubjectRequestStatus', + _i30.DataSubjectRequestType => 'DataSubjectRequestType', + _i31.RiskLevel => 'RiskLevel', + _i32.SyncStatus => 'SyncStatus', + _i33.UserRole => 'UserRole', + _i34.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', + _i35.AlertPermissionException => 'AlertPermissionException', + _i36.AlertValidationException => 'AlertValidationException', + _i37.AuthenticationFailedException => 'AuthenticationFailedException', + _i38.DataRightsException => 'DataRightsException', + _i39.EndpointDisabledException => 'EndpointDisabledException', + _i40.EnrollmentException => 'EnrollmentException', + _i41.OtpRequestException => 'OtpRequestException', + _i42.MicroArea => 'MicroArea', + _i43.OtpChallenge => 'OtpChallenge', + _i44.Patient => 'Patient', + _i45.TriageAnswer => 'TriageAnswer', + _i46.TriageSession => 'TriageSession', + _i47.Ubs => 'Ubs', + _i48.User => 'User', + _i49.UserCredential => 'UserCredential', + _i50.Visit => 'Visit', _ => null, }; } @@ -560,67 +620,77 @@ class Protocol extends _i1.SerializationManager { return 'PatientConsentRecord'; case _i14.PatientDataOverview(): return 'PatientDataOverview'; - case _i15.PatientRiskEvent(): + case _i15.PatientDataSubjectRequestRecord(): + return 'PatientDataSubjectRequestRecord'; + case _i16.PatientRiskEvent(): return 'PatientRiskEvent'; - case _i16.RedAlertResult(): + case _i17.RedAlertResult(): return 'RedAlertResult'; - case _i17.ServiceHealth(): + case _i18.ServiceHealth(): return 'ServiceHealth'; - case _i18.TriageResult(): + case _i19.TriageResult(): return 'TriageResult'; - case _i19.VisitSyncEntry(): + case _i20.VisitSyncEntry(): return 'VisitSyncEntry'; - case _i20.VisitSyncResult(): + case _i21.VisitSyncResult(): return 'VisitSyncResult'; - case _i21.AuditLog(): + case _i22.AuditLog(): return 'AuditLog'; - case _i22.ConsentLog(): + case _i23.ConsentLog(): return 'ConsentLog'; - case _i23.EnrollmentToken(): + case _i24.DataSubjectRequest(): + return 'DataSubjectRequest'; + case _i25.EnrollmentToken(): return 'EnrollmentToken'; - case _i24.AlertStatus(): + case _i26.AlertStatus(): return 'AlertStatus'; - case _i25.ArrivalMethod(): + case _i27.ArrivalMethod(): return 'ArrivalMethod'; - case _i26.ConsentPurpose(): + case _i28.ConsentPurpose(): return 'ConsentPurpose'; - case _i27.RiskLevel(): + case _i29.DataSubjectRequestStatus(): + return 'DataSubjectRequestStatus'; + case _i30.DataSubjectRequestType(): + return 'DataSubjectRequestType'; + case _i31.RiskLevel(): return 'RiskLevel'; - case _i28.SyncStatus(): + case _i32.SyncStatus(): return 'SyncStatus'; - case _i29.UserRole(): + case _i33.UserRole(): return 'UserRole'; - case _i30.AlertDispatchUnavailableException(): + case _i34.AlertDispatchUnavailableException(): return 'AlertDispatchUnavailableException'; - case _i31.AlertPermissionException(): + case _i35.AlertPermissionException(): return 'AlertPermissionException'; - case _i32.AlertValidationException(): + case _i36.AlertValidationException(): return 'AlertValidationException'; - case _i33.AuthenticationFailedException(): + case _i37.AuthenticationFailedException(): return 'AuthenticationFailedException'; - case _i34.EndpointDisabledException(): + case _i38.DataRightsException(): + return 'DataRightsException'; + case _i39.EndpointDisabledException(): return 'EndpointDisabledException'; - case _i35.EnrollmentException(): + case _i40.EnrollmentException(): return 'EnrollmentException'; - case _i36.OtpRequestException(): + case _i41.OtpRequestException(): return 'OtpRequestException'; - case _i37.MicroArea(): + case _i42.MicroArea(): return 'MicroArea'; - case _i38.OtpChallenge(): + case _i43.OtpChallenge(): return 'OtpChallenge'; - case _i39.Patient(): + case _i44.Patient(): return 'Patient'; - case _i40.TriageAnswer(): + case _i45.TriageAnswer(): return 'TriageAnswer'; - case _i41.TriageSession(): + case _i46.TriageSession(): return 'TriageSession'; - case _i42.Ubs(): + case _i47.Ubs(): return 'Ubs'; - case _i43.User(): + case _i48.User(): return 'User'; - case _i44.UserCredential(): + case _i49.UserCredential(): return 'UserCredential'; - case _i45.Visit(): + case _i50.Visit(): return 'Visit'; } return null; @@ -671,98 +741,113 @@ class Protocol extends _i1.SerializationManager { if (dataClassName == 'PatientDataOverview') { return deserialize<_i14.PatientDataOverview>(data['data']); } + if (dataClassName == 'PatientDataSubjectRequestRecord') { + return deserialize<_i15.PatientDataSubjectRequestRecord>(data['data']); + } if (dataClassName == 'PatientRiskEvent') { - return deserialize<_i15.PatientRiskEvent>(data['data']); + return deserialize<_i16.PatientRiskEvent>(data['data']); } if (dataClassName == 'RedAlertResult') { - return deserialize<_i16.RedAlertResult>(data['data']); + return deserialize<_i17.RedAlertResult>(data['data']); } if (dataClassName == 'ServiceHealth') { - return deserialize<_i17.ServiceHealth>(data['data']); + return deserialize<_i18.ServiceHealth>(data['data']); } if (dataClassName == 'TriageResult') { - return deserialize<_i18.TriageResult>(data['data']); + return deserialize<_i19.TriageResult>(data['data']); } if (dataClassName == 'VisitSyncEntry') { - return deserialize<_i19.VisitSyncEntry>(data['data']); + return deserialize<_i20.VisitSyncEntry>(data['data']); } if (dataClassName == 'VisitSyncResult') { - return deserialize<_i20.VisitSyncResult>(data['data']); + return deserialize<_i21.VisitSyncResult>(data['data']); } if (dataClassName == 'AuditLog') { - return deserialize<_i21.AuditLog>(data['data']); + return deserialize<_i22.AuditLog>(data['data']); } if (dataClassName == 'ConsentLog') { - return deserialize<_i22.ConsentLog>(data['data']); + return deserialize<_i23.ConsentLog>(data['data']); + } + if (dataClassName == 'DataSubjectRequest') { + return deserialize<_i24.DataSubjectRequest>(data['data']); } if (dataClassName == 'EnrollmentToken') { - return deserialize<_i23.EnrollmentToken>(data['data']); + return deserialize<_i25.EnrollmentToken>(data['data']); } if (dataClassName == 'AlertStatus') { - return deserialize<_i24.AlertStatus>(data['data']); + return deserialize<_i26.AlertStatus>(data['data']); } if (dataClassName == 'ArrivalMethod') { - return deserialize<_i25.ArrivalMethod>(data['data']); + return deserialize<_i27.ArrivalMethod>(data['data']); } if (dataClassName == 'ConsentPurpose') { - return deserialize<_i26.ConsentPurpose>(data['data']); + return deserialize<_i28.ConsentPurpose>(data['data']); + } + if (dataClassName == 'DataSubjectRequestStatus') { + return deserialize<_i29.DataSubjectRequestStatus>(data['data']); + } + if (dataClassName == 'DataSubjectRequestType') { + return deserialize<_i30.DataSubjectRequestType>(data['data']); } if (dataClassName == 'RiskLevel') { - return deserialize<_i27.RiskLevel>(data['data']); + return deserialize<_i31.RiskLevel>(data['data']); } if (dataClassName == 'SyncStatus') { - return deserialize<_i28.SyncStatus>(data['data']); + return deserialize<_i32.SyncStatus>(data['data']); } if (dataClassName == 'UserRole') { - return deserialize<_i29.UserRole>(data['data']); + return deserialize<_i33.UserRole>(data['data']); } if (dataClassName == 'AlertDispatchUnavailableException') { - return deserialize<_i30.AlertDispatchUnavailableException>(data['data']); + return deserialize<_i34.AlertDispatchUnavailableException>(data['data']); } if (dataClassName == 'AlertPermissionException') { - return deserialize<_i31.AlertPermissionException>(data['data']); + return deserialize<_i35.AlertPermissionException>(data['data']); } if (dataClassName == 'AlertValidationException') { - return deserialize<_i32.AlertValidationException>(data['data']); + return deserialize<_i36.AlertValidationException>(data['data']); } if (dataClassName == 'AuthenticationFailedException') { - return deserialize<_i33.AuthenticationFailedException>(data['data']); + return deserialize<_i37.AuthenticationFailedException>(data['data']); + } + if (dataClassName == 'DataRightsException') { + return deserialize<_i38.DataRightsException>(data['data']); } if (dataClassName == 'EndpointDisabledException') { - return deserialize<_i34.EndpointDisabledException>(data['data']); + return deserialize<_i39.EndpointDisabledException>(data['data']); } if (dataClassName == 'EnrollmentException') { - return deserialize<_i35.EnrollmentException>(data['data']); + return deserialize<_i40.EnrollmentException>(data['data']); } if (dataClassName == 'OtpRequestException') { - return deserialize<_i36.OtpRequestException>(data['data']); + return deserialize<_i41.OtpRequestException>(data['data']); } if (dataClassName == 'MicroArea') { - return deserialize<_i37.MicroArea>(data['data']); + return deserialize<_i42.MicroArea>(data['data']); } if (dataClassName == 'OtpChallenge') { - return deserialize<_i38.OtpChallenge>(data['data']); + return deserialize<_i43.OtpChallenge>(data['data']); } if (dataClassName == 'Patient') { - return deserialize<_i39.Patient>(data['data']); + return deserialize<_i44.Patient>(data['data']); } if (dataClassName == 'TriageAnswer') { - return deserialize<_i40.TriageAnswer>(data['data']); + return deserialize<_i45.TriageAnswer>(data['data']); } if (dataClassName == 'TriageSession') { - return deserialize<_i41.TriageSession>(data['data']); + return deserialize<_i46.TriageSession>(data['data']); } if (dataClassName == 'Ubs') { - return deserialize<_i42.Ubs>(data['data']); + return deserialize<_i47.Ubs>(data['data']); } if (dataClassName == 'User') { - return deserialize<_i43.User>(data['data']); + return deserialize<_i48.User>(data['data']); } if (dataClassName == 'UserCredential') { - return deserialize<_i44.UserCredential>(data['data']); + return deserialize<_i49.UserCredential>(data['data']); } if (dataClassName == 'Visit') { - return deserialize<_i45.Visit>(data['data']); + return deserialize<_i50.Visit>(data['data']); } return super.deserializeByClassName(data); } 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..b7ef8e66 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,28 @@ 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, + }); + + 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; +} + /// Tudo que o painel "Meus Dados" mostra sobre o próprio paciente. class PatientDataSnapshot { const PatientDataSnapshot({ @@ -44,6 +66,7 @@ class PatientDataSnapshot { required this.chronicConditions, required this.consents, required this.riskHistory, + this.requests = const [], }); final String name; @@ -53,6 +76,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/endpoints/patients_endpoint.dart b/backend/sinalacs_server/lib/src/endpoints/patients_endpoint.dart index 02cdca6a..a9243212 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'; @@ -90,9 +91,19 @@ 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); } } + + static PatientDataSubjectRequestRecord _requestRecord(DataSubjectRequestSnapshot r) => + PatientDataSubjectRequestRecord( + type: r.type, + status: r.status, + details: r.details, + createdAt: r.createdAt, + dueAt: r.dueAt, + ); } 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..d38f09a3 --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/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/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, + }); + + factory PatientDataSubjectRequestRecord({ + required _i2.DataSubjectRequestType type, + required _i3.DataSubjectRequestStatus status, + String? details, + required DateTime createdAt, + required DateTime dueAt, + }) = _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']), + ); + } + + _i2.DataSubjectRequestType type; + + _i3.DataSubjectRequestStatus status; + + String? details; + + DateTime createdAt; + + DateTime dueAt; + + /// 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, + }); + @override + Map toJson() { + return { + '__className__': 'PatientDataSubjectRequestRecord', + 'type': type.toJson(), + 'status': status.toJson(), + if (details != null) 'details': details, + 'createdAt': createdAt.toJson(), + 'dueAt': dueAt.toJson(), + }; + } + + @override + Map toJsonForProtocol() { + return { + '__className__': 'PatientDataSubjectRequestRecord', + 'type': type.toJson(), + 'status': status.toJson(), + if (details != null) 'details': details, + 'createdAt': createdAt.toJson(), + 'dueAt': dueAt.toJson(), + }; + } + + @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, + }) : super._( + type: type, + status: status, + details: details, + createdAt: createdAt, + dueAt: dueAt, + ); + + /// 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, + }) { + 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, + ); + } +} 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..a2b8a37f --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/data_subject_request.dart @@ -0,0 +1,646 @@ +/* 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, + }); + + 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, + }) = _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']), + ); + } + + 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; + + @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, + }); + @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(), + }; + } + + @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(), + }; + } + + 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, + }) : super._( + id: id, + userId: userId, + requestType: requestType, + detailsEncrypted: detailsEncrypted, + detailsKeyVersion: detailsKeyVersion, + status: status, + createdAt: createdAt, + dueAt: dueAt, + ); + + /// 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, + }) { + 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, + ); + } +} + +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, + ); +} + +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, + ); + } + + 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; + + @override + List<_i1.Column> get columns => [ + id, + userId, + requestType, + detailsEncrypted, + detailsKeyVersion, + status, + createdAt, + dueAt, + ]; +} + +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/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..236f3194 --- /dev/null +++ b/backend/sinalacs_server/lib/src/generated/enums/data_subject_request_status.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; + +/// 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 implements _i1.SerializableModel { + open, + completed, + rejected; + + static DataSubjectRequestStatus fromJson(String name) { + switch (name) { + case 'open': + return DataSubjectRequestStatus.open; + 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/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/protocol.dart b/backend/sinalacs_server/lib/src/generated/protocol.dart index 182fd669..4e1d5bec 100644 --- a/backend/sinalacs_server/lib/src/generated/protocol.dart +++ b/backend/sinalacs_server/lib/src/generated/protocol.dart @@ -26,43 +26,48 @@ 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 'api/patient_data_subject_request_record.dart' as _i16; +import 'api/patient_risk_event.dart' as _i17; +import 'api/red_alert_result.dart' as _i18; +import 'api/service_health.dart' as _i19; +import 'api/triage_result.dart' as _i20; +import 'api/visit_sync_entry.dart' as _i21; +import 'api/visit_sync_result.dart' as _i22; +import 'audit_log.dart' as _i23; +import 'consent_log.dart' as _i24; +import 'data_subject_request.dart' as _i25; +import 'enrollment_token.dart' as _i26; +import 'enums/alert_status.dart' as _i27; +import 'enums/arrival_method.dart' as _i28; +import 'enums/consent_purpose.dart' as _i29; +import 'enums/data_subject_request_status.dart' as _i30; +import 'enums/data_subject_request_type.dart' as _i31; +import 'enums/risk_level.dart' as _i32; +import 'enums/sync_status.dart' as _i33; +import 'enums/user_role.dart' as _i34; +import 'exceptions/alert_dispatch_unavailable_exception.dart' as _i35; +import 'exceptions/alert_permission_exception.dart' as _i36; +import 'exceptions/alert_validation_exception.dart' as _i37; +import 'exceptions/authentication_failed_exception.dart' as _i38; +import 'exceptions/data_rights_exception.dart' as _i39; +import 'exceptions/endpoint_disabled_exception.dart' as _i40; +import 'exceptions/enrollment_exception.dart' as _i41; +import 'exceptions/otp_request_exception.dart' as _i42; +import 'micro_area.dart' as _i43; +import 'otp_challenge.dart' as _i44; +import 'patient.dart' as _i45; +import 'triage_answer.dart' as _i46; +import 'triage_session.dart' as _i47; +import 'ubs.dart' as _i48; +import 'user.dart' as _i49; +import 'user_credential.dart' as _i50; +import 'visit.dart' as _i51; import 'package:sinalacs_server/src/generated/api/micro_area_patient.dart' - as _i47; + as _i52; import 'package:sinalacs_server/src/generated/api/visit_sync_result.dart' - as _i48; + as _i53; import 'package:sinalacs_server/src/generated/api/visit_sync_entry.dart' - as _i49; + as _i54; export 'acs.dart'; export 'alert.dart'; export 'alert_delivery_record.dart'; @@ -76,6 +81,7 @@ export 'api/enrollment_token_result.dart'; export 'api/micro_area_patient.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'; @@ -84,10 +90,13 @@ export 'api/visit_sync_entry.dart'; export 'api/visit_sync_result.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/consent_purpose.dart'; +export 'enums/data_subject_request_status.dart'; +export 'enums/data_subject_request_type.dart'; export 'enums/risk_level.dart'; export 'enums/sync_status.dart'; export 'enums/user_role.dart'; @@ -95,6 +104,7 @@ 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/otp_request_exception.dart'; @@ -845,6 +855,108 @@ class Protocol extends _i1.SerializationManagerServer { ], 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', + ), + ], + 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, + ), + ], + 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', @@ -1769,98 +1881,113 @@ class Protocol extends _i1.SerializationManagerServer { if (t == _i15.PatientDataOverview) { return _i15.PatientDataOverview.fromJson(data) as T; } - if (t == _i16.PatientRiskEvent) { - return _i16.PatientRiskEvent.fromJson(data) as T; + if (t == _i16.PatientDataSubjectRequestRecord) { + return _i16.PatientDataSubjectRequestRecord.fromJson(data) as T; + } + if (t == _i17.PatientRiskEvent) { + return _i17.PatientRiskEvent.fromJson(data) as T; + } + if (t == _i18.RedAlertResult) { + return _i18.RedAlertResult.fromJson(data) as T; + } + if (t == _i19.ServiceHealth) { + return _i19.ServiceHealth.fromJson(data) as T; } - if (t == _i17.RedAlertResult) { - return _i17.RedAlertResult.fromJson(data) as T; + if (t == _i20.TriageResult) { + return _i20.TriageResult.fromJson(data) as T; } - if (t == _i18.ServiceHealth) { - return _i18.ServiceHealth.fromJson(data) as T; + if (t == _i21.VisitSyncEntry) { + return _i21.VisitSyncEntry.fromJson(data) as T; } - if (t == _i19.TriageResult) { - return _i19.TriageResult.fromJson(data) as T; + if (t == _i22.VisitSyncResult) { + return _i22.VisitSyncResult.fromJson(data) as T; } - if (t == _i20.VisitSyncEntry) { - return _i20.VisitSyncEntry.fromJson(data) as T; + if (t == _i23.AuditLog) { + return _i23.AuditLog.fromJson(data) as T; } - if (t == _i21.VisitSyncResult) { - return _i21.VisitSyncResult.fromJson(data) as T; + if (t == _i24.ConsentLog) { + return _i24.ConsentLog.fromJson(data) as T; } - if (t == _i22.AuditLog) { - return _i22.AuditLog.fromJson(data) as T; + if (t == _i25.DataSubjectRequest) { + return _i25.DataSubjectRequest.fromJson(data) as T; } - if (t == _i23.ConsentLog) { - return _i23.ConsentLog.fromJson(data) as T; + if (t == _i26.EnrollmentToken) { + return _i26.EnrollmentToken.fromJson(data) as T; } - if (t == _i24.EnrollmentToken) { - return _i24.EnrollmentToken.fromJson(data) as T; + if (t == _i27.AlertStatus) { + return _i27.AlertStatus.fromJson(data) as T; } - if (t == _i25.AlertStatus) { - return _i25.AlertStatus.fromJson(data) as T; + if (t == _i28.ArrivalMethod) { + return _i28.ArrivalMethod.fromJson(data) as T; } - if (t == _i26.ArrivalMethod) { - return _i26.ArrivalMethod.fromJson(data) as T; + if (t == _i29.ConsentPurpose) { + return _i29.ConsentPurpose.fromJson(data) as T; } - if (t == _i27.ConsentPurpose) { - return _i27.ConsentPurpose.fromJson(data) as T; + if (t == _i30.DataSubjectRequestStatus) { + return _i30.DataSubjectRequestStatus.fromJson(data) as T; } - if (t == _i28.RiskLevel) { - return _i28.RiskLevel.fromJson(data) as T; + if (t == _i31.DataSubjectRequestType) { + return _i31.DataSubjectRequestType.fromJson(data) as T; } - if (t == _i29.SyncStatus) { - return _i29.SyncStatus.fromJson(data) as T; + if (t == _i32.RiskLevel) { + return _i32.RiskLevel.fromJson(data) as T; } - if (t == _i30.UserRole) { - return _i30.UserRole.fromJson(data) as T; + if (t == _i33.SyncStatus) { + return _i33.SyncStatus.fromJson(data) as T; } - if (t == _i31.AlertDispatchUnavailableException) { - return _i31.AlertDispatchUnavailableException.fromJson(data) as T; + if (t == _i34.UserRole) { + return _i34.UserRole.fromJson(data) as T; } - if (t == _i32.AlertPermissionException) { - return _i32.AlertPermissionException.fromJson(data) as T; + if (t == _i35.AlertDispatchUnavailableException) { + return _i35.AlertDispatchUnavailableException.fromJson(data) as T; } - if (t == _i33.AlertValidationException) { - return _i33.AlertValidationException.fromJson(data) as T; + if (t == _i36.AlertPermissionException) { + return _i36.AlertPermissionException.fromJson(data) as T; } - if (t == _i34.AuthenticationFailedException) { - return _i34.AuthenticationFailedException.fromJson(data) as T; + if (t == _i37.AlertValidationException) { + return _i37.AlertValidationException.fromJson(data) as T; } - if (t == _i35.EndpointDisabledException) { - return _i35.EndpointDisabledException.fromJson(data) as T; + if (t == _i38.AuthenticationFailedException) { + return _i38.AuthenticationFailedException.fromJson(data) as T; } - if (t == _i36.EnrollmentException) { - return _i36.EnrollmentException.fromJson(data) as T; + if (t == _i39.DataRightsException) { + return _i39.DataRightsException.fromJson(data) as T; } - if (t == _i37.OtpRequestException) { - return _i37.OtpRequestException.fromJson(data) as T; + if (t == _i40.EndpointDisabledException) { + return _i40.EndpointDisabledException.fromJson(data) as T; } - if (t == _i38.MicroArea) { - return _i38.MicroArea.fromJson(data) as T; + if (t == _i41.EnrollmentException) { + return _i41.EnrollmentException.fromJson(data) as T; } - if (t == _i39.OtpChallenge) { - return _i39.OtpChallenge.fromJson(data) as T; + if (t == _i42.OtpRequestException) { + return _i42.OtpRequestException.fromJson(data) as T; } - if (t == _i40.Patient) { - return _i40.Patient.fromJson(data) as T; + if (t == _i43.MicroArea) { + return _i43.MicroArea.fromJson(data) as T; } - if (t == _i41.TriageAnswer) { - return _i41.TriageAnswer.fromJson(data) as T; + if (t == _i44.OtpChallenge) { + return _i44.OtpChallenge.fromJson(data) as T; } - if (t == _i42.TriageSession) { - return _i42.TriageSession.fromJson(data) as T; + if (t == _i45.Patient) { + return _i45.Patient.fromJson(data) as T; } - if (t == _i43.Ubs) { - return _i43.Ubs.fromJson(data) as T; + if (t == _i46.TriageAnswer) { + return _i46.TriageAnswer.fromJson(data) as T; } - if (t == _i44.User) { - return _i44.User.fromJson(data) as T; + if (t == _i47.TriageSession) { + return _i47.TriageSession.fromJson(data) as T; } - if (t == _i45.UserCredential) { - return _i45.UserCredential.fromJson(data) as T; + if (t == _i48.Ubs) { + return _i48.Ubs.fromJson(data) as T; } - if (t == _i46.Visit) { - return _i46.Visit.fromJson(data) as T; + if (t == _i49.User) { + return _i49.User.fromJson(data) as T; + } + if (t == _i50.UserCredential) { + return _i50.UserCredential.fromJson(data) as T; + } + if (t == _i51.Visit) { + return _i51.Visit.fromJson(data) as T; } if (t == _i1.getType<_i3.Acs?>()) { return (data != null ? _i3.Acs.fromJson(data) : null) as T; @@ -1907,115 +2034,139 @@ class Protocol extends _i1.SerializationManagerServer { return (data != null ? _i15.PatientDataOverview.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<_i16.PatientDataSubjectRequestRecord?>()) { + return (data != null + ? _i16.PatientDataSubjectRequestRecord.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<_i17.PatientRiskEvent?>()) { + return (data != null ? _i17.PatientRiskEvent.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<_i18.RedAlertResult?>()) { + return (data != null ? _i18.RedAlertResult.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<_i19.ServiceHealth?>()) { + return (data != null ? _i19.ServiceHealth.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<_i20.TriageResult?>()) { + return (data != null ? _i20.TriageResult.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<_i21.VisitSyncEntry?>()) { + return (data != null ? _i21.VisitSyncEntry.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<_i22.VisitSyncResult?>()) { + return (data != null ? _i22.VisitSyncResult.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<_i23.AuditLog?>()) { + return (data != null ? _i23.AuditLog.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<_i24.ConsentLog?>()) { + return (data != null ? _i24.ConsentLog.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<_i25.DataSubjectRequest?>()) { + return (data != null ? _i25.DataSubjectRequest.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<_i26.EnrollmentToken?>()) { + return (data != null ? _i26.EnrollmentToken.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<_i27.AlertStatus?>()) { + return (data != null ? _i27.AlertStatus.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<_i28.ArrivalMethod?>()) { + return (data != null ? _i28.ArrivalMethod.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<_i29.ConsentPurpose?>()) { + return (data != null ? _i29.ConsentPurpose.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<_i30.DataSubjectRequestStatus?>()) { + return (data != null + ? _i30.DataSubjectRequestStatus.fromJson(data) + : null) + as T; + } + if (t == _i1.getType<_i31.DataSubjectRequestType?>()) { + return (data != null ? _i31.DataSubjectRequestType.fromJson(data) : null) + as T; } - if (t == _i1.getType<_i31.AlertDispatchUnavailableException?>()) { + if (t == _i1.getType<_i32.RiskLevel?>()) { + return (data != null ? _i32.RiskLevel.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i33.SyncStatus?>()) { + return (data != null ? _i33.SyncStatus.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i34.UserRole?>()) { + return (data != null ? _i34.UserRole.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i35.AlertDispatchUnavailableException?>()) { return (data != null - ? _i31.AlertDispatchUnavailableException.fromJson(data) + ? _i35.AlertDispatchUnavailableException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i32.AlertPermissionException?>()) { + if (t == _i1.getType<_i36.AlertPermissionException?>()) { return (data != null - ? _i32.AlertPermissionException.fromJson(data) + ? _i36.AlertPermissionException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i33.AlertValidationException?>()) { + if (t == _i1.getType<_i37.AlertValidationException?>()) { return (data != null - ? _i33.AlertValidationException.fromJson(data) + ? _i37.AlertValidationException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i34.AuthenticationFailedException?>()) { + if (t == _i1.getType<_i38.AuthenticationFailedException?>()) { return (data != null - ? _i34.AuthenticationFailedException.fromJson(data) + ? _i38.AuthenticationFailedException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i35.EndpointDisabledException?>()) { + if (t == _i1.getType<_i39.DataRightsException?>()) { + return (data != null ? _i39.DataRightsException.fromJson(data) : null) + as T; + } + if (t == _i1.getType<_i40.EndpointDisabledException?>()) { return (data != null - ? _i35.EndpointDisabledException.fromJson(data) + ? _i40.EndpointDisabledException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i36.EnrollmentException?>()) { - return (data != null ? _i36.EnrollmentException.fromJson(data) : null) + if (t == _i1.getType<_i41.EnrollmentException?>()) { + return (data != null ? _i41.EnrollmentException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i37.OtpRequestException?>()) { - return (data != null ? _i37.OtpRequestException.fromJson(data) : null) + if (t == _i1.getType<_i42.OtpRequestException?>()) { + return (data != null ? _i42.OtpRequestException.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<_i43.MicroArea?>()) { + return (data != null ? _i43.MicroArea.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<_i44.OtpChallenge?>()) { + return (data != null ? _i44.OtpChallenge.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<_i45.Patient?>()) { + return (data != null ? _i45.Patient.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<_i46.TriageAnswer?>()) { + return (data != null ? _i46.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<_i47.TriageSession?>()) { + return (data != null ? _i47.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<_i48.Ubs?>()) { + return (data != null ? _i48.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<_i49.User?>()) { + return (data != null ? _i49.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<_i50.UserCredential?>()) { + return (data != null ? _i50.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<_i51.Visit?>()) { + return (data != null ? _i51.Visit.fromJson(data) : null) as T; } if (t == List) { return (data as List).map((e) => deserialize(e)).toList() as T; @@ -2026,9 +2177,15 @@ class Protocol extends _i1.SerializationManagerServer { .toList() as T; } - if (t == List<_i16.PatientRiskEvent>) { + if (t == List<_i17.PatientRiskEvent>) { + return (data as List) + .map((e) => deserialize<_i17.PatientRiskEvent>(e)) + .toList() + as T; + } + if (t == List<_i16.PatientDataSubjectRequestRecord>) { return (data as List) - .map((e) => deserialize<_i16.PatientRiskEvent>(e)) + .map((e) => deserialize<_i16.PatientDataSubjectRequestRecord>(e)) .toList() as T; } @@ -2038,24 +2195,24 @@ class Protocol extends _i1.SerializationManagerServer { ) as T; } - if (t == List<_i47.MicroAreaPatient>) { + if (t == List<_i52.MicroAreaPatient>) { return (data as List) - .map((e) => deserialize<_i47.MicroAreaPatient>(e)) + .map((e) => deserialize<_i52.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<_i53.VisitSyncResult>) { return (data as List) - .map((e) => deserialize<_i48.VisitSyncResult>(e)) + .map((e) => deserialize<_i53.VisitSyncResult>(e)) .toList() as T; } - if (t == List<_i49.VisitSyncEntry>) { + if (t == List<_i54.VisitSyncEntry>) { return (data as List) - .map((e) => deserialize<_i49.VisitSyncEntry>(e)) + .map((e) => deserialize<_i54.VisitSyncEntry>(e)) .toList() as T; } @@ -2080,38 +2237,43 @@ class Protocol extends _i1.SerializationManagerServer { _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 => + _i16.PatientDataSubjectRequestRecord => 'PatientDataSubjectRequestRecord', + _i17.PatientRiskEvent => 'PatientRiskEvent', + _i18.RedAlertResult => 'RedAlertResult', + _i19.ServiceHealth => 'ServiceHealth', + _i20.TriageResult => 'TriageResult', + _i21.VisitSyncEntry => 'VisitSyncEntry', + _i22.VisitSyncResult => 'VisitSyncResult', + _i23.AuditLog => 'AuditLog', + _i24.ConsentLog => 'ConsentLog', + _i25.DataSubjectRequest => 'DataSubjectRequest', + _i26.EnrollmentToken => 'EnrollmentToken', + _i27.AlertStatus => 'AlertStatus', + _i28.ArrivalMethod => 'ArrivalMethod', + _i29.ConsentPurpose => 'ConsentPurpose', + _i30.DataSubjectRequestStatus => 'DataSubjectRequestStatus', + _i31.DataSubjectRequestType => 'DataSubjectRequestType', + _i32.RiskLevel => 'RiskLevel', + _i33.SyncStatus => 'SyncStatus', + _i34.UserRole => 'UserRole', + _i35.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', + _i36.AlertPermissionException => 'AlertPermissionException', + _i37.AlertValidationException => 'AlertValidationException', + _i38.AuthenticationFailedException => 'AuthenticationFailedException', + _i39.DataRightsException => 'DataRightsException', + _i40.EndpointDisabledException => 'EndpointDisabledException', + _i41.EnrollmentException => 'EnrollmentException', + _i42.OtpRequestException => 'OtpRequestException', + _i43.MicroArea => 'MicroArea', + _i44.OtpChallenge => 'OtpChallenge', + _i45.Patient => 'Patient', + _i46.TriageAnswer => 'TriageAnswer', + _i47.TriageSession => 'TriageSession', + _i48.Ubs => 'Ubs', + _i49.User => 'User', + _i50.UserCredential => 'UserCredential', + _i51.Visit => 'Visit', _ => null, }; } @@ -2152,67 +2314,77 @@ class Protocol extends _i1.SerializationManagerServer { return 'PatientConsentRecord'; case _i15.PatientDataOverview(): return 'PatientDataOverview'; - case _i16.PatientRiskEvent(): + case _i16.PatientDataSubjectRequestRecord(): + return 'PatientDataSubjectRequestRecord'; + case _i17.PatientRiskEvent(): return 'PatientRiskEvent'; - case _i17.RedAlertResult(): + case _i18.RedAlertResult(): return 'RedAlertResult'; - case _i18.ServiceHealth(): + case _i19.ServiceHealth(): return 'ServiceHealth'; - case _i19.TriageResult(): + case _i20.TriageResult(): return 'TriageResult'; - case _i20.VisitSyncEntry(): + case _i21.VisitSyncEntry(): return 'VisitSyncEntry'; - case _i21.VisitSyncResult(): + case _i22.VisitSyncResult(): return 'VisitSyncResult'; - case _i22.AuditLog(): + case _i23.AuditLog(): return 'AuditLog'; - case _i23.ConsentLog(): + case _i24.ConsentLog(): return 'ConsentLog'; - case _i24.EnrollmentToken(): + case _i25.DataSubjectRequest(): + return 'DataSubjectRequest'; + case _i26.EnrollmentToken(): return 'EnrollmentToken'; - case _i25.AlertStatus(): + case _i27.AlertStatus(): return 'AlertStatus'; - case _i26.ArrivalMethod(): + case _i28.ArrivalMethod(): return 'ArrivalMethod'; - case _i27.ConsentPurpose(): + case _i29.ConsentPurpose(): return 'ConsentPurpose'; - case _i28.RiskLevel(): + case _i30.DataSubjectRequestStatus(): + return 'DataSubjectRequestStatus'; + case _i31.DataSubjectRequestType(): + return 'DataSubjectRequestType'; + case _i32.RiskLevel(): return 'RiskLevel'; - case _i29.SyncStatus(): + case _i33.SyncStatus(): return 'SyncStatus'; - case _i30.UserRole(): + case _i34.UserRole(): return 'UserRole'; - case _i31.AlertDispatchUnavailableException(): + case _i35.AlertDispatchUnavailableException(): return 'AlertDispatchUnavailableException'; - case _i32.AlertPermissionException(): + case _i36.AlertPermissionException(): return 'AlertPermissionException'; - case _i33.AlertValidationException(): + case _i37.AlertValidationException(): return 'AlertValidationException'; - case _i34.AuthenticationFailedException(): + case _i38.AuthenticationFailedException(): return 'AuthenticationFailedException'; - case _i35.EndpointDisabledException(): + case _i39.DataRightsException(): + return 'DataRightsException'; + case _i40.EndpointDisabledException(): return 'EndpointDisabledException'; - case _i36.EnrollmentException(): + case _i41.EnrollmentException(): return 'EnrollmentException'; - case _i37.OtpRequestException(): + case _i42.OtpRequestException(): return 'OtpRequestException'; - case _i38.MicroArea(): + case _i43.MicroArea(): return 'MicroArea'; - case _i39.OtpChallenge(): + case _i44.OtpChallenge(): return 'OtpChallenge'; - case _i40.Patient(): + case _i45.Patient(): return 'Patient'; - case _i41.TriageAnswer(): + case _i46.TriageAnswer(): return 'TriageAnswer'; - case _i42.TriageSession(): + case _i47.TriageSession(): return 'TriageSession'; - case _i43.Ubs(): + case _i48.Ubs(): return 'Ubs'; - case _i44.User(): + case _i49.User(): return 'User'; - case _i45.UserCredential(): + case _i50.UserCredential(): return 'UserCredential'; - case _i46.Visit(): + case _i51.Visit(): return 'Visit'; } className = _i2.Protocol().getClassNameForObject(data); @@ -2267,98 +2439,113 @@ class Protocol extends _i1.SerializationManagerServer { if (dataClassName == 'PatientDataOverview') { return deserialize<_i15.PatientDataOverview>(data['data']); } + if (dataClassName == 'PatientDataSubjectRequestRecord') { + return deserialize<_i16.PatientDataSubjectRequestRecord>(data['data']); + } if (dataClassName == 'PatientRiskEvent') { - return deserialize<_i16.PatientRiskEvent>(data['data']); + return deserialize<_i17.PatientRiskEvent>(data['data']); } if (dataClassName == 'RedAlertResult') { - return deserialize<_i17.RedAlertResult>(data['data']); + return deserialize<_i18.RedAlertResult>(data['data']); } if (dataClassName == 'ServiceHealth') { - return deserialize<_i18.ServiceHealth>(data['data']); + return deserialize<_i19.ServiceHealth>(data['data']); } if (dataClassName == 'TriageResult') { - return deserialize<_i19.TriageResult>(data['data']); + return deserialize<_i20.TriageResult>(data['data']); } if (dataClassName == 'VisitSyncEntry') { - return deserialize<_i20.VisitSyncEntry>(data['data']); + return deserialize<_i21.VisitSyncEntry>(data['data']); } if (dataClassName == 'VisitSyncResult') { - return deserialize<_i21.VisitSyncResult>(data['data']); + return deserialize<_i22.VisitSyncResult>(data['data']); } if (dataClassName == 'AuditLog') { - return deserialize<_i22.AuditLog>(data['data']); + return deserialize<_i23.AuditLog>(data['data']); } if (dataClassName == 'ConsentLog') { - return deserialize<_i23.ConsentLog>(data['data']); + return deserialize<_i24.ConsentLog>(data['data']); + } + if (dataClassName == 'DataSubjectRequest') { + return deserialize<_i25.DataSubjectRequest>(data['data']); } if (dataClassName == 'EnrollmentToken') { - return deserialize<_i24.EnrollmentToken>(data['data']); + return deserialize<_i26.EnrollmentToken>(data['data']); } if (dataClassName == 'AlertStatus') { - return deserialize<_i25.AlertStatus>(data['data']); + return deserialize<_i27.AlertStatus>(data['data']); } if (dataClassName == 'ArrivalMethod') { - return deserialize<_i26.ArrivalMethod>(data['data']); + return deserialize<_i28.ArrivalMethod>(data['data']); } if (dataClassName == 'ConsentPurpose') { - return deserialize<_i27.ConsentPurpose>(data['data']); + return deserialize<_i29.ConsentPurpose>(data['data']); + } + if (dataClassName == 'DataSubjectRequestStatus') { + return deserialize<_i30.DataSubjectRequestStatus>(data['data']); + } + if (dataClassName == 'DataSubjectRequestType') { + return deserialize<_i31.DataSubjectRequestType>(data['data']); } if (dataClassName == 'RiskLevel') { - return deserialize<_i28.RiskLevel>(data['data']); + return deserialize<_i32.RiskLevel>(data['data']); } if (dataClassName == 'SyncStatus') { - return deserialize<_i29.SyncStatus>(data['data']); + return deserialize<_i33.SyncStatus>(data['data']); } if (dataClassName == 'UserRole') { - return deserialize<_i30.UserRole>(data['data']); + return deserialize<_i34.UserRole>(data['data']); } if (dataClassName == 'AlertDispatchUnavailableException') { - return deserialize<_i31.AlertDispatchUnavailableException>(data['data']); + return deserialize<_i35.AlertDispatchUnavailableException>(data['data']); } if (dataClassName == 'AlertPermissionException') { - return deserialize<_i32.AlertPermissionException>(data['data']); + return deserialize<_i36.AlertPermissionException>(data['data']); } if (dataClassName == 'AlertValidationException') { - return deserialize<_i33.AlertValidationException>(data['data']); + return deserialize<_i37.AlertValidationException>(data['data']); } if (dataClassName == 'AuthenticationFailedException') { - return deserialize<_i34.AuthenticationFailedException>(data['data']); + return deserialize<_i38.AuthenticationFailedException>(data['data']); + } + if (dataClassName == 'DataRightsException') { + return deserialize<_i39.DataRightsException>(data['data']); } if (dataClassName == 'EndpointDisabledException') { - return deserialize<_i35.EndpointDisabledException>(data['data']); + return deserialize<_i40.EndpointDisabledException>(data['data']); } if (dataClassName == 'EnrollmentException') { - return deserialize<_i36.EnrollmentException>(data['data']); + return deserialize<_i41.EnrollmentException>(data['data']); } if (dataClassName == 'OtpRequestException') { - return deserialize<_i37.OtpRequestException>(data['data']); + return deserialize<_i42.OtpRequestException>(data['data']); } if (dataClassName == 'MicroArea') { - return deserialize<_i38.MicroArea>(data['data']); + return deserialize<_i43.MicroArea>(data['data']); } if (dataClassName == 'OtpChallenge') { - return deserialize<_i39.OtpChallenge>(data['data']); + return deserialize<_i44.OtpChallenge>(data['data']); } if (dataClassName == 'Patient') { - return deserialize<_i40.Patient>(data['data']); + return deserialize<_i45.Patient>(data['data']); } if (dataClassName == 'TriageAnswer') { - return deserialize<_i41.TriageAnswer>(data['data']); + return deserialize<_i46.TriageAnswer>(data['data']); } if (dataClassName == 'TriageSession') { - return deserialize<_i42.TriageSession>(data['data']); + return deserialize<_i47.TriageSession>(data['data']); } if (dataClassName == 'Ubs') { - return deserialize<_i43.Ubs>(data['data']); + return deserialize<_i48.Ubs>(data['data']); } if (dataClassName == 'User') { - return deserialize<_i44.User>(data['data']); + return deserialize<_i49.User>(data['data']); } if (dataClassName == 'UserCredential') { - return deserialize<_i45.UserCredential>(data['data']); + return deserialize<_i50.UserCredential>(data['data']); } if (dataClassName == 'Visit') { - return deserialize<_i46.Visit>(data['data']); + return deserialize<_i51.Visit>(data['data']); } if (dataClassName.startsWith('serverpod.')) { data['className'] = dataClassName.substring(10); @@ -2386,28 +2573,30 @@ class Protocol extends _i1.SerializationManagerServer { 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 _i23.AuditLog: + return _i23.AuditLog.t; + case _i24.ConsentLog: + return _i24.ConsentLog.t; + case _i25.DataSubjectRequest: + return _i25.DataSubjectRequest.t; + case _i26.EnrollmentToken: + return _i26.EnrollmentToken.t; + case _i43.MicroArea: + return _i43.MicroArea.t; + case _i44.OtpChallenge: + return _i44.OtpChallenge.t; + case _i45.Patient: + return _i45.Patient.t; + case _i47.TriageSession: + return _i47.TriageSession.t; + case _i48.Ubs: + return _i48.Ubs.t; + case _i49.User: + return _i49.User.t; + case _i50.UserCredential: + return _i50.UserCredential.t; + case _i51.Visit: + return _i51.Visit.t; } return null; } 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/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/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..8615df82 --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/api/patient_data_subject_request_record.spy.yaml @@ -0,0 +1,9 @@ +### 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 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..bd903dd2 --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/data_subject_request.spy.yaml @@ -0,0 +1,22 @@ +### 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 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..34bf27ee --- /dev/null +++ b/backend/sinalacs_server/lib/src/models/enums/data_subject_request_status.spy.yaml @@ -0,0 +1,8 @@ +### 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 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/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/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/migration_registry.txt b/backend/sinalacs_server/migrations/migration_registry.txt index 5ee746ef..adca3612 100644 --- a/backend/sinalacs_server/migrations/migration_registry.txt +++ b/backend/sinalacs_server/migrations/migration_registry.txt @@ -13,3 +13,4 @@ 20260917233529326 20260919001437559 20260919032710552 +20260929005339393 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/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/spec/lgpd_data_audit.md b/spec/lgpd_data_audit.md index bda2b76b..840210bb 100644 --- a/spec/lgpd_data_audit.md +++ b/spec/lgpd_data_audit.md @@ -93,9 +93,15 @@ A tabela abaixo consolida o mapeamento exaustivo de dados persistidos pelo backe | | `action` | `text` | Metadado Legal | Enum textual (`GRANT`, `REVOKE`, etc.) | Ação exercida sobre o consentimento pelo titular. | | | `version` | `text` | Metadado Legal | String de versão semântica | Versão dos termos aceita no momento da ação (LGPD-RT04). | | | `timestamp` | `timestamp without time zone` | Metadado Legal | Timestamp de registro | Comprovação temporal imutável da manifestação de vontade. | -| | `ipHash` | `text` | Pseudonimizado | Hash SHA-256 do IP | Se gerado para IPv4 sem salt ($2^{32}$ combinações), é reversível por força bruta imediata; requer salt rotativo. | +| | `ipHash` | `text` | Pseudonimizado | Hash SHA-256 do IP | Se gerado para IPv4 sem salt ($2^{32}$ combinações), é reversível por força bruta imediata; requer salt rotativo. Linhas gravadas pelo painel "Meus Dados" (LGPD-RF05) usam o marcador `nao-aplicavel-painel-titular`. | | | `userAgent` | `text` | Metadado Técnico / Fingerprint | String de cabeçalho User-Agent | Auxilia na caracterização do dispositivo utilizado. | | | `signature` | `text` | Metadado Legal / Prova Criptográfica | Assinatura digital/hash | Garantia de não repúdio e integridade do consentimento (LGPD-RT05). | +| **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). | | **audit_logs** | `id` | `uuid` | Pseudonimizado | UUID v4 (`gen_random_uuid()`) | Identificador do registro de auditoria (LGPD-RF11). | | | `userId` | `uuid` | Pseudonimizado | Chave estrangeira (`users.id`) | Identifica o operador que executou a ação auditada. | | | `actionType` | `text` | Metadado Técnico | Enum textual (`READ`, `WRITE`, `DELETE`, etc.) | Operação registrada. | From 16d27ee57acaaf111bf9e50c06b572d105f68e4c Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Mon, 28 Sep 2026 20:56:03 -0400 Subject: [PATCH 025/498] =?UTF-8?q?feat(lgpd):=20servi=C3=A7o=20de=20direi?= =?UTF-8?q?tos=20do=20titular=20=E2=80=94=20revoga=C3=A7=C3=A3o=20por=20fi?= =?UTF-8?q?nalidade=20e=20pedidos=20de=20exclus=C3=A3o/corre=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../patients/data_subject_rights_service.dart | 162 +++++++++++ .../data_subject_rights_service_test.dart | 271 ++++++++++++++++++ 2 files changed, 433 insertions(+) create mode 100644 backend/sinalacs_server/lib/src/application/patients/data_subject_rights_service.dart create mode 100644 backend/sinalacs_server/test/unit/data_subject_rights_service_test.dart 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..4c483f1f --- /dev/null +++ b/backend/sinalacs_server/lib/src/application/patients/data_subject_rights_service.dart @@ -0,0 +1,162 @@ +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, + ); +} 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..b2a03370 --- /dev/null +++ b/backend/sinalacs_server/test/unit/data_subject_rights_service_test.dart @@ -0,0 +1,271 @@ +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()), + ); + }); + }); +} From 62fb0aedd25653fb191c661a295dc3830b2a9620 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Mon, 28 Sep 2026 20:57:40 -0400 Subject: [PATCH 026/498] feat(lgpd): RPCs de consentimento e pedidos do titular, com store ORM cifrada Co-Authored-By: Claude Opus 5.5 --- .../lib/src/protocol/client.dart | 76 +++++- .../lib/src/endpoints/patients_endpoint.dart | 68 +++++ .../lib/src/generated/endpoints.dart | 82 +++++- .../lib/src/generated/protocol.yaml | 3 + .../orm_data_subject_rights_store.dart | 96 +++++++ .../orm_patient_data_overview_store.dart | 19 +- .../lib/src/runtime/alert_runtime.dart | 14 + .../data_subject_rights_endpoint_test.dart | 249 ++++++++++++++++++ .../test_tools/serverpod_test_tools.dart | 129 ++++++++- 9 files changed, 709 insertions(+), 27 deletions(-) create mode 100644 backend/sinalacs_server/lib/src/infrastructure/database/orm_data_subject_rights_store.dart create mode 100644 backend/sinalacs_server/test/integration/data_subject_rights_endpoint_test.dart diff --git a/backend/sinalacs_client/lib/src/protocol/client.dart b/backend/sinalacs_client/lib/src/protocol/client.dart index bba152a6..a5e084cb 100644 --- a/backend/sinalacs_client/lib/src/protocol/client.dart +++ b/backend/sinalacs_client/lib/src/protocol/client.dart @@ -27,11 +27,17 @@ import 'package:sinalacs_client/src/protocol/api/micro_area_patient.dart' as _i10; 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; -import 'package:sinalacs_client/src/protocol/api/visit_sync_result.dart' +import 'package:sinalacs_client/src/protocol/api/patient_consent_record.dart' + as _i12; +import 'package:sinalacs_client/src/protocol/enums/consent_purpose.dart' as _i13; -import 'package:sinalacs_client/src/protocol/api/visit_sync_entry.dart' as _i14; -import 'protocol.dart' as _i15; +import 'package:sinalacs_client/src/protocol/api/patient_data_subject_request_record.dart' + as _i14; +import 'package:sinalacs_client/src/protocol/api/triage_result.dart' as _i15; +import 'package:sinalacs_client/src/protocol/api/visit_sync_result.dart' + as _i16; +import 'package:sinalacs_client/src/protocol/api/visit_sync_entry.dart' as _i17; +import 'protocol.dart' as _i18; /// Ciclo do alerta vermelho. /// @@ -285,6 +291,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); @@ -333,6 +342,49 @@ class EndpointPatients extends EndpointAuthenticated { '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<_i12.PatientConsentRecord> updateConsent({ + required String accessToken, + required _i13.ConsentPurpose purpose, + required bool granted, + }) => caller.callServerEndpoint<_i12.PatientConsentRecord>( + 'patients', + 'updateConsent', + { + 'accessToken': accessToken, + 'purpose': purpose, + 'granted': granted, + }, + ); + + /// Pedido de exclusão/anonimização dos próprios dados (LGPD-RF08). + /// Idempotente enquanto houver um pedido de exclusão em aberto. + _i2.Future<_i14.PatientDataSubjectRequestRecord> requestDataDeletion({ + required String accessToken, + }) => caller.callServerEndpoint<_i14.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<_i14.PatientDataSubjectRequestRecord> requestDataCorrection({ + required String accessToken, + required String details, + }) => caller.callServerEndpoint<_i14.PatientDataSubjectRequestRecord>( + 'patients', + 'requestDataCorrection', + { + 'accessToken': accessToken, + 'details': details, + }, + ); } /// Motor de triagem determinístico, inspirado no Protocolo de Manchester. @@ -352,7 +404,7 @@ class EndpointTriage extends EndpointAuthenticated { @override String get name => 'triage'; - _i2.Future<_i12.TriageResult> evaluate({ + _i2.Future<_i15.TriageResult> evaluate({ required String accessToken, required bool chestPain, required bool difficultyBreathing, @@ -360,7 +412,7 @@ class EndpointTriage extends EndpointAuthenticated { required bool persistentVomiting, required bool bleeding, required bool severeWeakness, - }) => caller.callServerEndpoint<_i12.TriageResult>( + }) => caller.callServerEndpoint<_i15.TriageResult>( 'triage', 'evaluate', { @@ -391,10 +443,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<_i17.VisitSyncEntry> visits, + }) => caller.callServerEndpoint>( 'visits', 'sync', { @@ -406,10 +458,10 @@ class EndpointVisits extends EndpointAuthenticated { /// 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 +491,7 @@ class Client extends _i1.ServerpodClientShared { bool? disconnectStreamsOnLostInternetConnection, }) : super( host, - _i15.Protocol(), + _i18.Protocol(), securityContext: securityContext, streamingConnectionTimeout: streamingConnectionTimeout, connectionTimeout: connectionTimeout, diff --git a/backend/sinalacs_server/lib/src/endpoints/patients_endpoint.dart b/backend/sinalacs_server/lib/src/endpoints/patients_endpoint.dart index a9243212..df5afc4f 100644 --- a/backend/sinalacs_server/lib/src/endpoints/patients_endpoint.dart +++ b/backend/sinalacs_server/lib/src/endpoints/patients_endpoint.dart @@ -10,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, { @@ -98,6 +101,71 @@ class PatientsEndpoint extends AuthenticatedEndpoint { } } + /// 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); + } + } + static PatientDataSubjectRequestRecord _requestRecord(DataSubjectRequestSnapshot r) => PatientDataSubjectRequestRecord( type: r.type, diff --git a/backend/sinalacs_server/lib/src/generated/endpoints.dart b/backend/sinalacs_server/lib/src/generated/endpoints.dart index 3e947a9c..1baec80c 100644 --- a/backend/sinalacs_server/lib/src/generated/endpoints.dart +++ b/backend/sinalacs_server/lib/src/generated/endpoints.dart @@ -19,7 +19,10 @@ 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 'package:sinalacs_server/src/generated/enums/consent_purpose.dart' + as _i9; +import 'package:sinalacs_server/src/generated/api/visit_sync_entry.dart' + as _i10; class Endpoints extends _i1.EndpointDispatch { @override @@ -433,6 +436,81 @@ class Endpoints extends _i1.EndpointDispatch { 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<_i9.ConsentPurpose>(), + nullable: false, + ), + 'granted': _i1.ParameterDescription( + name: 'granted', + type: _i1.getType(), + nullable: false, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => + (endpoints['patients'] as _i6.PatientsEndpoint).updateConsent( + session, + accessToken: params['accessToken'], + purpose: params['purpose'], + granted: params['granted'], + ), + ), + '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 _i6.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 _i6.PatientsEndpoint) + .requestDataCorrection( + session, + accessToken: params['accessToken'], + details: params['details'], + ), + ), }, ); connectors['triage'] = _i1.EndpointConnector( @@ -509,7 +587,7 @@ class Endpoints extends _i1.EndpointDispatch { ), 'visits': _i1.ParameterDescription( name: 'visits', - type: _i1.getType>(), + type: _i1.getType>(), nullable: false, ), }, diff --git a/backend/sinalacs_server/lib/src/generated/protocol.yaml b/backend/sinalacs_server/lib/src/generated/protocol.yaml index c223e540..92ae3f9b 100644 --- a/backend/sinalacs_server/lib/src/generated/protocol.yaml +++ b/backend/sinalacs_server/lib/src/generated/protocol.yaml @@ -17,6 +17,9 @@ patients: - myChronicConditions: - updateChronicConditions: - myData: + - updateConsent: + - requestDataDeletion: + - requestDataCorrection: triage: - evaluate: visits: 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..99f4e441 --- /dev/null +++ b/backend/sinalacs_server/lib/src/infrastructure/database/orm_data_subject_rights_store.dart @@ -0,0 +1,96 @@ +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); + } +} 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..65cfde5d 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,15 @@ 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` 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 { @@ -52,6 +55,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) @@ -80,6 +92,7 @@ class OrmPatientDataOverviewStore implements PatientDataOverviewStore { ), ], riskHistory: riskHistory, + requests: requests, ); } } diff --git a/backend/sinalacs_server/lib/src/runtime/alert_runtime.dart b/backend/sinalacs_server/lib/src/runtime/alert_runtime.dart index 09665e18..d1e4ff30 100644 --- a/backend/sinalacs_server/lib/src/runtime/alert_runtime.dart +++ b/backend/sinalacs_server/lib/src/runtime/alert_runtime.dart @@ -10,6 +10,7 @@ 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'; @@ -24,6 +25,7 @@ import 'package:sinalacs_server/src/infrastructure/database/orm_alert_store.dart import 'package:sinalacs_server/src/infrastructure/database/orm_audit_trail.dart'; import 'package:sinalacs_server/src/infrastructure/database/orm_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_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'; @@ -199,6 +201,18 @@ 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), + ); + /// Constrói o serviço de triagem persistida para uma requisição. TriageSessionService triageSessionServiceFor(Session session) => TriageSessionService( 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..744a1c9e --- /dev/null +++ b/backend/sinalacs_server/test/integration/data_subject_rights_endpoint_test.dart @@ -0,0 +1,249 @@ +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)); + }); + }); +} 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..48f11b44 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 @@ -29,11 +29,17 @@ import 'package:sinalacs_server/src/generated/api/micro_area_patient.dart' as _i11; 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; -import 'package:sinalacs_server/src/generated/api/visit_sync_result.dart' +import 'package:sinalacs_server/src/generated/api/patient_consent_record.dart' + as _i13; +import 'package:sinalacs_server/src/generated/enums/consent_purpose.dart' as _i14; -import 'package:sinalacs_server/src/generated/api/visit_sync_entry.dart' +import 'package:sinalacs_server/src/generated/api/patient_data_subject_request_record.dart' as _i15; +import 'package:sinalacs_server/src/generated/api/triage_result.dart' as _i16; +import 'package:sinalacs_server/src/generated/api/visit_sync_result.dart' + as _i17; +import 'package:sinalacs_server/src/generated/api/visit_sync_entry.dart' + as _i18; 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'; @@ -731,6 +737,109 @@ class _PatientsEndpoint { } }); } + + _i3.Future<_i13.PatientConsentRecord> updateConsent( + _i1.TestSessionBuilder sessionBuilder, { + required String accessToken, + required _i14.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<_i13.PatientConsentRecord>); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } + + _i3.Future<_i15.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<_i15.PatientDataSubjectRequestRecord>); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } + + _i3.Future<_i15.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<_i15.PatientDataSubjectRequestRecord>); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } } class _TriageEndpoint { @@ -743,7 +852,7 @@ class _TriageEndpoint { final _i2.SerializationManager _serializationManager; - _i3.Future<_i13.TriageResult> evaluate( + _i3.Future<_i16.TriageResult> evaluate( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, required bool chestPain, @@ -780,7 +889,7 @@ class _TriageEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i13.TriageResult>); + as _i3.Future<_i16.TriageResult>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -799,10 +908,10 @@ class _VisitsEndpoint { final _i2.SerializationManager _serializationManager; - _i3.Future> sync( + _i3.Future> sync( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, - required List<_i15.VisitSyncEntry> visits, + required List<_i18.VisitSyncEntry> visits, }) async { return _i1.callAwaitableFunctionAndHandleExceptions(() async { var _localUniqueSession = @@ -826,7 +935,7 @@ class _VisitsEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future>); + as _i3.Future>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -834,7 +943,7 @@ class _VisitsEndpoint { }); } - _i3.Future> pull( + _i3.Future> pull( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, required DateTime since, @@ -861,7 +970,7 @@ class _VisitsEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future>); + as _i3.Future>); return _localReturnValue; } finally { await _localUniqueSession.close(); From f640fc295171563d1af11892e53ba8b25ef06953 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Mon, 28 Sep 2026 20:59:58 -0400 Subject: [PATCH 027/498] =?UTF-8?q?feat(paciente):=20contrato=20de=20direi?= =?UTF-8?q?tos=20do=20titular=20no=20cliente=20e=20decis=C3=A3o=20vigente?= =?UTF-8?q?=20de=20consentimento?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../lib/core/consent/consent_decisions.dart | 38 ++++++++++ .../lib/core/network/backend_client.dart | 60 ++++++++++++++++ .../test/backend_client_data_rights_test.dart | 63 ++++++++++++++++ apps/patient/test/consent_decisions_test.dart | 59 +++++++++++++++ .../test/support/fake_patient_backend.dart | 72 +++++++++++++++++++ .../patient/test/support/fake_rpc_server.dart | 17 +++++ 6 files changed, 309 insertions(+) create mode 100644 apps/patient/lib/core/consent/consent_decisions.dart create mode 100644 apps/patient/test/backend_client_data_rights_test.dart create mode 100644 apps/patient/test/consent_decisions_test.dart 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..ce675e08 --- /dev/null +++ b/apps/patient/lib/core/consent/consent_decisions.dart @@ -0,0 +1,38 @@ +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); +} diff --git a/apps/patient/lib/core/network/backend_client.dart b/apps/patient/lib/core/network/backend_client.dart index 6678bee9..aea0342c 100644 --- a/apps/patient/lib/core/network/backend_client.dart +++ b/apps/patient/lib/core/network/backend_client.dart @@ -122,6 +122,22 @@ 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, + }); + + /// 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(); } @@ -210,6 +226,20 @@ class MisconfiguredBackend implements PatientBackend { @override Future myData() async => _recusar(); + @override + Future updateConsent({ + required ConsentPurpose purpose, + required bool granted, + }) 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 @@ -473,6 +503,31 @@ 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 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 +558,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/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/support/fake_patient_backend.dart b/apps/patient/test/support/fake_patient_backend.dart index 6b3b3027..671c9aa4 100644 --- a/apps/patient/test/support/fake_patient_backend.dart +++ b/apps/patient/test/support/fake_patient_backend.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:sinalacs_client/sinalacs_client.dart'; import 'package:sinalacs_patient/core/network/auth_session.dart'; import 'package:sinalacs_patient/core/network/backend_client.dart'; @@ -54,6 +56,19 @@ class FakePatientBackend implements PatientBackend { BackendFailure? myDataFailure; int myDataCallCount = 0; + /// 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; + /// 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 @@ -282,6 +297,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: '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; + } + @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 From 9d3d62c8f1a74cd25fc9f4685ca22e1272f95da4 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Mon, 28 Sep 2026 21:02:49 -0400 Subject: [PATCH 028/498] feat(paciente): revogar e conceder consentimento em Meus dados (LGPD-RF05) Co-Authored-By: Claude Opus 5.5 --- apps/patient/lib/app/app.dart | 158 +++++++++++++++-- apps/patient/test/patient_app_mvp_test.dart | 179 +++++++++++++++++++- 2 files changed, 318 insertions(+), 19 deletions(-) diff --git a/apps/patient/lib/app/app.dart b/apps/patient/lib/app/app.dart index 1984fdbc..fe68da9d 100644 --- a/apps/patient/lib/app/app.dart +++ b/apps/patient/lib/app/app.dart @@ -6,8 +6,9 @@ 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, PatientDataOverview, RiskLevel; import 'package:sinalacs_patient/app/patient_theme.dart'; +import 'package:sinalacs_patient/core/consent/consent_decisions.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'; @@ -1506,6 +1507,7 @@ class _MyDataScreenState extends State { String? _error; String? _confirmation; bool _requestedLoad = false; + bool _busy = false; @override void didChangeDependencies() { @@ -1519,19 +1521,111 @@ 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; + 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.', + }; + /// 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. @@ -1584,6 +1678,9 @@ class _MyDataScreenState extends State { @override Widget build(BuildContext context) { final data = _data; + final decisions = data == null + ? const {} + : currentConsentDecisions(data.consents); return ListView( padding: const EdgeInsets.all(20), children: [ @@ -1613,7 +1710,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 +1726,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,7 +1736,26 @@ 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), @@ -1651,9 +1766,9 @@ class _MyDataScreenState extends State { (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), @@ -1929,6 +2044,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 +2105,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 +2132,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.'; diff --git a/apps/patient/test/patient_app_mvp_test.dart b/apps/patient/test/patient_app_mvp_test.dart index 28287cc5..d3966054 100644 --- a/apps/patient/test/patient_app_mvp_test.dart +++ b/apps/patient/test/patient_app_mvp_test.dart @@ -9,6 +9,7 @@ import 'package:sinalacs_client/sinalacs_client.dart' show AlertStatus, AlertStatusResult, + ConsentPurpose, PatientConsentRecord, PatientDataOverview, PatientDataSubjectRequestRecord, @@ -180,7 +181,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')); @@ -848,6 +857,170 @@ void main() { 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('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(); + }); + 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)); @@ -884,7 +1057,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); @@ -954,8 +1127,8 @@ void main() { 'data': DateTime.utc(2026, 1, 1).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); }); From d3100a1ea2cd6f3ed13a3941bdedb0119c0ecd57 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Mon, 28 Sep 2026 21:04:39 -0400 Subject: [PATCH 029/498] =?UTF-8?q?feat(paciente):=20pedidos=20de=20exclus?= =?UTF-8?q?=C3=A3o=20e=20corre=C3=A7=C3=A3o=20em=20Meus=20dados=20(LGPD-RF?= =?UTF-8?q?08)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- apps/patient/lib/app/app.dart | 194 +++++++++++++++++++- apps/patient/test/patient_app_mvp_test.dart | 162 ++++++++++++++++ 2 files changed, 354 insertions(+), 2 deletions(-) diff --git a/apps/patient/lib/app/app.dart b/apps/patient/lib/app/app.dart index fe68da9d..f728e3de 100644 --- a/apps/patient/lib/app/app.dart +++ b/apps/patient/lib/app/app.dart @@ -6,7 +6,15 @@ 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, ConsentPurpose, PatientDataOverview, RiskLevel; + show + AlertStatus, + AlertStatusResult, + ConsentPurpose, + DataSubjectRequestStatus, + DataSubjectRequestType, + PatientDataOverview, + PatientDataSubjectRequestRecord, + RiskLevel; import 'package:sinalacs_patient/app/patient_theme.dart'; import 'package:sinalacs_patient/core/consent/consent_decisions.dart'; import 'package:sinalacs_patient/core/consent/consent_preferences.dart'; @@ -1612,6 +1620,86 @@ class _MyDataScreenState extends State { } } + /// 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', + }; + /// 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. @@ -1652,6 +1740,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 { @@ -1681,6 +1779,10 @@ class _MyDataScreenState extends State { final decisions = data == null ? const {} : currentConsentDecisions(data.consents); + final openDeletion = data?.requests.any((r) => + r.type == DataSubjectRequestType.deletion && + r.status == DataSubjectRequestStatus.open) ?? + false; return ListView( padding: const EdgeInsets.all(20), children: [ @@ -1688,7 +1790,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) @@ -1788,6 +1891,40 @@ class _MyDataScreenState extends State { ), ), 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'), + ), + const SizedBox(height: 16), FilledButton.icon( key: const Key('export_my_data_button'), onPressed: _export, @@ -1800,6 +1937,59 @@ 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(); + + @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'), + ), + ], + ); + } +} + /// Acompanhamento da solicitação do paciente (RF05, decisão §5) — consome /// `alerts.statusFor`, escopado ao próprio paciente pelo token. /// diff --git a/apps/patient/test/patient_app_mvp_test.dart b/apps/patient/test/patient_app_mvp_test.dart index d3966054..86669225 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'; @@ -10,6 +11,8 @@ import 'package:sinalacs_client/sinalacs_client.dart' AlertStatus, AlertStatusResult, ConsentPurpose, + DataSubjectRequestStatus, + DataSubjectRequestType, PatientConsentRecord, PatientDataOverview, PatientDataSubjectRequestRecord, @@ -1021,6 +1024,158 @@ void main() { 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); + }); + + 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(); + }); + 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)); @@ -1102,6 +1257,7 @@ void main() { timestamp: DateTime.utc(2026, 1, 1), ), ], + requests: [openRequest(DataSubjectRequestType.deletion)], ); await tester.pumpWidget(SinalAcsApp(backend: backend)); await login(tester); @@ -1126,6 +1282,12 @@ 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_confirmation')), findsOneWidget); final semantics = tester.getSemantics(find.byKey(const Key('my_data_confirmation'))); From 6cc1d384394b39381a763ca268d3309af05b4f0f Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Mon, 28 Sep 2026 21:06:40 -0400 Subject: [PATCH 030/498] docs(lgpd): registra os direitos do titular no app paciente e o que ficou de fora Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 4 ++-- PROGRESS.md | 35 +++++++++++++++++++++++++++++++++++ backend/CLAUDE.md | 2 +- 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f58b0f6c..5697ae5c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,7 @@ Two core flows: A third app, `apps/admin`, is a read-only backoffice (Indicadores, Microáreas, Alertas, Auditoria) on mock data. -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`, `pull`), `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 (17 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. diff --git a/PROGRESS.md b/PROGRESS.md index 8c47ad3f..f7e6be1f 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1058,3 +1058,38 @@ 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.** `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. diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 16fee680..4af7f9ef 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -55,7 +55,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: 17 tables, 8 enums (`RiskLevel`, `AlertStatus`, `SyncStatus`, `UserRole`, `ConsentPurpose`, `ArrivalMethod`, `DataSubjectRequestType`, `DataSubjectRequestStatus`), 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. From 6207f8c9b5995194806321818b843e2ea65d5468 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Mon, 28 Sep 2026 21:11:47 -0400 Subject: [PATCH 031/498] fix(paciente): revogar lembretes vale no aparelho mesmo se o recarregamento falhar Co-Authored-By: Claude Opus 5.5 --- apps/patient/lib/app/app.dart | 11 ++++++++++ apps/patient/test/patient_app_mvp_test.dart | 24 +++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/apps/patient/lib/app/app.dart b/apps/patient/lib/app/app.dart index f728e3de..a0dbbae7 100644 --- a/apps/patient/lib/app/app.dart +++ b/apps/patient/lib/app/app.dart @@ -1554,6 +1554,10 @@ class _MyDataScreenState extends State { 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); @@ -1601,6 +1605,7 @@ class _MyDataScreenState extends State { if (!granted && !await _confirmRevocation(purpose)) return; if (!mounted) return; final backend = BackendScope.of(context); + final reminders = RemindersScope.of(context); setState(() { _busy = true; _error = null; @@ -1608,6 +1613,12 @@ class _MyDataScreenState extends State { }); try { final record = await backend.updateConsent(purpose: purpose, granted: granted); + // 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())}.'); diff --git a/apps/patient/test/patient_app_mvp_test.dart b/apps/patient/test/patient_app_mvp_test.dart index 86669225..ded7a265 100644 --- a/apps/patient/test/patient_app_mvp_test.dart +++ b/apps/patient/test/patient_app_mvp_test.dart @@ -959,6 +959,30 @@ void main() { ); }); + 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()); From 00748c474fea7a466d015e890b089d944bb692fe Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Mon, 28 Sep 2026 21:20:23 -0400 Subject: [PATCH 032/498] chore: atualiza os marcadores locais do headroom Co-Authored-By: Claude Opus 5.5 --- .claude/.headroom_wrap_marker.json | 2 +- .claude/.headroom_wrap_owners.json | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.claude/.headroom_wrap_marker.json b/.claude/.headroom_wrap_marker.json index 0811ea36..64b1e9b1 100644 --- a/.claude/.headroom_wrap_marker.json +++ b/.claude/.headroom_wrap_marker.json @@ -1 +1 @@ -{"pid": 53774, "start_src": "psutil", "start_time": 1790633543.21, "port": 8787, "key": "ANTHROPIC_BASE_URL", "previous": null} \ No newline at end of file +{"pid": 82297, "start_src": "psutil", "start_time": 1790641701.85, "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 index bef679b3..3499bda7 100644 --- a/.claude/.headroom_wrap_owners.json +++ b/.claude/.headroom_wrap_owners.json @@ -1,24 +1,24 @@ { - "ANTHROPIC_BASE_URL": { + "ENABLE_TOOL_SEARCH": { "original": null, "holders": [ { - "pid": 53774, + "pid": 82297, "start_src": "psutil", - "start_time": 1790633543.21, - "port": 8787, + "start_time": 1790641701.85, + "port": null, "inherited": false } ] }, - "ENABLE_TOOL_SEARCH": { + "ANTHROPIC_BASE_URL": { "original": null, "holders": [ { - "pid": 53774, + "pid": 82297, "start_src": "psutil", - "start_time": 1790633543.21, - "port": null, + "start_time": 1790641701.85, + "port": 8787, "inherited": false } ] From db7b9a17ccf43b20c210c3be530ef4a318f4f1a3 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Mon, 28 Sep 2026 21:21:10 -0400 Subject: [PATCH 033/498] chore: ignora os marcadores locais do headroom Co-Authored-By: Claude Opus 5.5 --- .claude/.headroom_wrap_marker.json | 1 - .claude/.headroom_wrap_owners.json | 26 -------------------------- .gitignore | 3 +++ 3 files changed, 3 insertions(+), 27 deletions(-) delete mode 100644 .claude/.headroom_wrap_marker.json delete mode 100644 .claude/.headroom_wrap_owners.json diff --git a/.claude/.headroom_wrap_marker.json b/.claude/.headroom_wrap_marker.json deleted file mode 100644 index 64b1e9b1..00000000 --- a/.claude/.headroom_wrap_marker.json +++ /dev/null @@ -1 +0,0 @@ -{"pid": 82297, "start_src": "psutil", "start_time": 1790641701.85, "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 3499bda7..00000000 --- a/.claude/.headroom_wrap_owners.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "ENABLE_TOOL_SEARCH": { - "original": null, - "holders": [ - { - "pid": 82297, - "start_src": "psutil", - "start_time": 1790641701.85, - "port": null, - "inherited": false - } - ] - }, - "ANTHROPIC_BASE_URL": { - "original": null, - "holders": [ - { - "pid": 82297, - "start_src": "psutil", - "start_time": 1790641701.85, - "port": 8787, - "inherited": false - } - ] - } -} diff --git a/.gitignore b/.gitignore index bc66ae39..5ffb09ba 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,6 @@ graphify-out/ apps/acs/assets/certs/ apps/patient/assets/certs/ .worktrees/ + +# Estado local do proxy headroom (PID e timestamps), reescrito a cada sessão +.claude/.headroom_wrap_*.json From 4aabe16ea2305fc30f973f2ad32ab020b0b81983 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 29 Sep 2026 09:02:14 -0400 Subject: [PATCH 034/498] docs: plano do QR do onboarding e dos documentos legais do app paciente Co-Authored-By: Claude Opus 5.5 --- ...09-29-qr-onboarding-e-documentos-legais.md | 2196 +++++++++++++++++ 1 file changed, 2196 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-29-qr-onboarding-e-documentos-legais.md 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 " +``` From 8197d57de1384d7ddc8e2ce0664f2f0cc5d53e7a Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 29 Sep 2026 09:03:26 -0400 Subject: [PATCH 035/498] =?UTF-8?q?feat(paciente):=20conte=C3=BAdo=20versi?= =?UTF-8?q?onado=20do=20Termo=20de=20Uso=20e=20da=20Pol=C3=ADtica=20de=20P?= =?UTF-8?q?rivacidade=20(LGPD-RF18/RF19)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../lib/core/legal/legal_documents.dart | 301 ++++++++++++++++++ apps/patient/test/legal_documents_test.dart | 98 ++++++ 2 files changed, 399 insertions(+) create mode 100644 apps/patient/lib/core/legal/legal_documents.dart create mode 100644 apps/patient/test/legal_documents_test.dart 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..b1500374 --- /dev/null +++ b/apps/patient/lib/core/legal/legal_documents.dart @@ -0,0 +1,301 @@ +/// 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, +); 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)); + }); +} From 6a58637ea5875e9518450efc158736a073251411 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 29 Sep 2026 09:04:56 -0400 Subject: [PATCH 036/498] =?UTF-8?q?feat(paciente):=20telas=20de=20Privacid?= =?UTF-8?q?ade=20e=20termos=20com=20resumo=20visual=20e=20hist=C3=B3rico?= =?UTF-8?q?=20(LGPD-RF10/RF18/RF19)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- apps/patient/lib/app/app.dart | 11 +- apps/patient/lib/app/legal_screens.dart | 136 ++++++++++++++++++++ apps/patient/test/legal_screens_test.dart | 81 ++++++++++++ apps/patient/test/patient_app_mvp_test.dart | 12 ++ 4 files changed, 239 insertions(+), 1 deletion(-) create mode 100644 apps/patient/lib/app/legal_screens.dart create mode 100644 apps/patient/test/legal_screens_test.dart diff --git a/apps/patient/lib/app/app.dart b/apps/patient/lib/app/app.dart index a0dbbae7..bf22be10 100644 --- a/apps/patient/lib/app/app.dart +++ b/apps/patient/lib/app/app.dart @@ -15,6 +15,7 @@ import 'package:sinalacs_client/sinalacs_client.dart' PatientDataOverview, PatientDataSubjectRequestRecord, RiskLevel; +import 'package:sinalacs_patient/app/legal_screens.dart'; import 'package:sinalacs_patient/app/patient_theme.dart'; import 'package:sinalacs_patient/core/consent/consent_decisions.dart'; import 'package:sinalacs_patient/core/consent/consent_preferences.dart'; @@ -471,6 +472,14 @@ class _PatientLoginScreenState extends State { 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'), + ), ], ], ), @@ -2614,5 +2623,5 @@ class _PatientHeader extends StatelessWidget implements PreferredSizeWidget { } 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); })]))); + 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())); })]))); } \ No newline at end of file diff --git a/apps/patient/lib/app/legal_screens.dart b/apps/patient/lib/app/legal_screens.dart new file mode 100644 index 00000000..48730147 --- /dev/null +++ b/apps/patient/lib/app/legal_screens.dart @@ -0,0 +1,136 @@ +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), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/apps/patient/test/legal_screens_test.dart b/apps/patient/test/legal_screens_test.dart new file mode 100644 index 00000000..3ded899e --- /dev/null +++ b/apps/patient/test/legal_screens_test.dart @@ -0,0 +1,81 @@ +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.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(); + }, + ); +} diff --git a/apps/patient/test/patient_app_mvp_test.dart b/apps/patient/test/patient_app_mvp_test.dart index ded7a265..41408ca2 100644 --- a/apps/patient/test/patient_app_mvp_test.dart +++ b/apps/patient/test/patient_app_mvp_test.dart @@ -19,6 +19,7 @@ import 'package:sinalacs_client/sinalacs_client.dart' 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/network/backend_scope.dart'; @@ -1456,4 +1457,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); + }); } From 9d38ba420d5a2c7ef3638bc8836d45d7b4cd5fa2 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 29 Sep 2026 09:06:51 -0400 Subject: [PATCH 037/498] feat(backend): aceite versionado do Termo de Uso no onboarding (LGPD-RF18) Co-Authored-By: Claude Opus 5.5 --- .../lib/src/protocol/client.dart | 2 + .../src/protocol/enums/consent_purpose.dart | 14 ++-- .../onboarding/onboarding_service.dart | 16 ++++- .../patients/data_subject_rights_service.dart | 6 ++ .../src/endpoints/onboarding_endpoint.dart | 4 +- .../lib/src/generated/endpoints.dart | 6 ++ .../src/generated/enums/consent_purpose.dart | 14 ++-- .../src/models/enums/consent_purpose.spy.yaml | 10 ++- .../integration/onboarding_endpoint_test.dart | 54 +++++++++++++-- .../test_tools/serverpod_test_tools.dart | 2 + .../data_subject_rights_service_test.dart | 9 +++ .../test/unit/onboarding_service_test.dart | 68 +++++++++++++++++-- 12 files changed, 181 insertions(+), 24 deletions(-) diff --git a/backend/sinalacs_client/lib/src/protocol/client.dart b/backend/sinalacs_client/lib/src/protocol/client.dart index a5e084cb..54a82ec2 100644 --- a/backend/sinalacs_client/lib/src/protocol/client.dart +++ b/backend/sinalacs_client/lib/src/protocol/client.dart @@ -273,6 +273,7 @@ class EndpointOnboarding extends _i1.EndpointRef { required bool healthDataConsent, required bool remindersConsent, required bool pushConsent, + required bool termsAccepted, }) => caller.callServerEndpoint<_i9.EnrollmentResult>( 'onboarding', 'completeEnrollment', @@ -281,6 +282,7 @@ class EndpointOnboarding extends _i1.EndpointRef { 'healthDataConsent': healthDataConsent, 'remindersConsent': remindersConsent, 'pushConsent': pushConsent, + 'termsAccepted': termsAccepted, }, ); } 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_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 index 4c483f1f..d435f03e 100644 --- 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 @@ -72,6 +72,12 @@ class DataSubjectRightsService { '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.', + ); + } final now = _clock().toUtc(); final action = granted ? 'granted' : 'denied'; diff --git a/backend/sinalacs_server/lib/src/endpoints/onboarding_endpoint.dart b/backend/sinalacs_server/lib/src/endpoints/onboarding_endpoint.dart index b446256b..e526d4ac 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,6 +56,7 @@ class OnboardingEndpoint extends Endpoint { ConsentPurpose.healthDataProcessing: healthDataConsent, ConsentPurpose.localReminders: remindersConsent, ConsentPurpose.segmentedPush: pushConsent, + ConsentPurpose.termsOfUse: termsAccepted, }, ); }); diff --git a/backend/sinalacs_server/lib/src/generated/endpoints.dart b/backend/sinalacs_server/lib/src/generated/endpoints.dart index 1baec80c..95af9333 100644 --- a/backend/sinalacs_server/lib/src/generated/endpoints.dart +++ b/backend/sinalacs_server/lib/src/generated/endpoints.dart @@ -335,6 +335,11 @@ class Endpoints extends _i1.EndpointDispatch { type: _i1.getType(), nullable: false, ), + 'termsAccepted': _i1.ParameterDescription( + name: 'termsAccepted', + type: _i1.getType(), + nullable: false, + ), }, call: ( @@ -347,6 +352,7 @@ class Endpoints extends _i1.EndpointDispatch { healthDataConsent: params['healthDataConsent'], remindersConsent: params['remindersConsent'], pushConsent: params['pushConsent'], + termsAccepted: params['termsAccepted'], ), ), }, 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/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/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/test_tools/serverpod_test_tools.dart b/backend/sinalacs_server/test/integration/test_tools/serverpod_test_tools.dart index 48f11b44..83847209 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 @@ -566,6 +566,7 @@ class _OnboardingEndpoint { required bool healthDataConsent, required bool remindersConsent, required bool pushConsent, + required bool termsAccepted, }) async { return _i1.callAwaitableFunctionAndHandleExceptions(() async { var _localUniqueSession = @@ -583,6 +584,7 @@ class _OnboardingEndpoint { 'healthDataConsent': healthDataConsent, 'remindersConsent': remindersConsent, 'pushConsent': pushConsent, + 'termsAccepted': termsAccepted, }), serializationManager: _serializationManager, ); 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 index b2a03370..25a84171 100644 --- a/backend/sinalacs_server/test/unit/data_subject_rights_service_test.dart +++ b/backend/sinalacs_server/test/unit/data_subject_rights_service_test.dart @@ -134,6 +134,15 @@ void main() { 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), 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); + }); }); } From 407aa6834a08b72ebce9e0caed86ea334f525d35 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 29 Sep 2026 09:08:52 -0400 Subject: [PATCH 038/498] =?UTF-8?q?feat(paciente):=20aceite=20expl=C3=ADci?= =?UTF-8?q?to=20do=20Termo=20de=20Uso=20e=20da=20Pol=C3=ADtica=20no=20cada?= =?UTF-8?q?stro=20(LGPD-RF18)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- apps/patient/lib/app/app.dart | 47 +++++++++++++++++++ .../lib/core/consent/consent_decisions.dart | 1 + .../lib/core/network/backend_client.dart | 9 +++- apps/patient/test/main_boot_ca_test.dart | 1 + apps/patient/test/onboarding_flow_test.dart | 38 +++++++++++++++ .../test/support/fake_patient_backend.dart | 2 + 6 files changed, 96 insertions(+), 2 deletions(-) diff --git a/apps/patient/lib/app/app.dart b/apps/patient/lib/app/app.dart index bf22be10..7c0920c1 100644 --- a/apps/patient/lib/app/app.dart +++ b/apps/patient/lib/app/app.dart @@ -18,6 +18,7 @@ import 'package:sinalacs_client/sinalacs_client.dart' import 'package:sinalacs_patient/app/legal_screens.dart'; import 'package:sinalacs_patient/app/patient_theme.dart'; import 'package:sinalacs_patient/core/consent/consent_decisions.dart'; +import 'package:sinalacs_patient/core/legal/legal_documents.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'; @@ -621,6 +622,10 @@ 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; String? _error; @@ -644,6 +649,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; @@ -656,6 +667,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 @@ -755,6 +767,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 @@ -1732,6 +1778,7 @@ class _MyDataScreenState extends State { 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á diff --git a/apps/patient/lib/core/consent/consent_decisions.dart b/apps/patient/lib/core/consent/consent_decisions.dart index ce675e08..718e91aa 100644 --- a/apps/patient/lib/core/consent/consent_decisions.dart +++ b/apps/patient/lib/core/consent/consent_decisions.dart @@ -28,6 +28,7 @@ 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. diff --git a/apps/patient/lib/core/network/backend_client.dart b/apps/patient/lib/core/network/backend_client.dart index aea0342c..03b78246 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 @@ -214,6 +216,7 @@ class MisconfiguredBackend implements PatientBackend { required bool healthDataConsent, required bool remindersConsent, required bool pushConsent, + required bool termsAccepted, }) async => _recusar(); @@ -458,6 +461,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( @@ -465,6 +469,7 @@ class BackendClient implements PatientBackend { healthDataConsent: healthDataConsent, remindersConsent: remindersConsent, pushConsent: pushConsent, + termsAccepted: termsAccepted, ), ); final session = AuthSession.tryParse(result.accessToken, result.tokenType); 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/onboarding_flow_test.dart b/apps/patient/test/onboarding_flow_test.dart index fa8c5e55..17adb682 100644 --- a/apps/patient/test/onboarding_flow_test.dart +++ b/apps/patient/test/onboarding_flow_test.dart @@ -1,6 +1,7 @@ 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'; @@ -59,6 +60,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 +105,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,6 +115,7 @@ void main() { 'healthDataConsent': true, 'remindersConsent': true, 'pushConsent': false, + 'termsAccepted': true, }); expect(find.text('Triagem rápida'), findsOneWidget); }); @@ -121,6 +129,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 +145,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 +160,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 +186,31 @@ 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); + }); } diff --git a/apps/patient/test/support/fake_patient_backend.dart b/apps/patient/test/support/fake_patient_backend.dart index 671c9aa4..62233bb9 100644 --- a/apps/patient/test/support/fake_patient_backend.dart +++ b/apps/patient/test/support/fake_patient_backend.dart @@ -251,12 +251,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; From ffe8352d0185e96d0f6a8827a49cd08f21c661c0 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 29 Sep 2026 09:10:08 -0400 Subject: [PATCH 039/498] feat(acs): generateInvite na camada de rede, sobre onboarding.generateEnrollmentToken (RF02) Co-Authored-By: Claude Opus 5.5 --- apps/acs/lib/core/network/backend_client.dart | 22 ++++++++++++++ apps/acs/test/backend_client_test.dart | 30 +++++++++++++++++++ apps/acs/test/support/fake_rpc_server.dart | 18 +++++++++++ apps/acs/test/support/fakes.dart | 17 +++++++++++ 4 files changed, 87 insertions(+) diff --git a/apps/acs/lib/core/network/backend_client.dart b/apps/acs/lib/core/network/backend_client.dart index a053196b..08d7cfce 100644 --- a/apps/acs/lib/core/network/backend_client.dart +++ b/apps/acs/lib/core/network/backend_client.dart @@ -81,6 +81,11 @@ abstract class AcsBackend { /// partir fora do caminho reativo. Future> listPatients(); + /// 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}); + void close(); } @@ -134,6 +139,9 @@ class MisconfiguredBackend implements AcsBackend { @override Future> listPatients() async => _recusar(); + @override + Future generateInvite({required String patientId}) 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 @@ -333,6 +341,20 @@ class BackendClient implements AcsBackend { ); } + @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 void close() => _client.close(); diff --git a/apps/acs/test/backend_client_test.dart b/apps/acs/test/backend_client_test.dart index 2ab0d7c6..845ac250 100644 --- a/apps/acs/test/backend_client_test.dart +++ b/apps/acs/test/backend_client_test.dart @@ -124,6 +124,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/support/fake_rpc_server.dart b/apps/acs/test/support/fake_rpc_server.dart index 911115c6..b831ded1 100644 --- a/apps/acs/test/support/fake_rpc_server.dart +++ b/apps/acs/test/support/fake_rpc_server.dart @@ -53,6 +53,10 @@ class FakeRpcServer { /// formato que o backend real usa (`AuthenticationFailedException`). String? rejectWith; + /// Faz `generateEnrollmentToken` recusar como o backend recusa paciente de + /// outra microárea (`AlertPermissionException`, INV-01). + bool rejectInviteWithPermission = false; + /// 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}/'; @@ -89,6 +93,14 @@ class FakeRpcServer { 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' => { 'accessToken': _token(), @@ -97,6 +109,12 @@ class FakeRpcServer { // 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..494a4f3c 100644 --- a/apps/acs/test/support/fakes.dart +++ b/apps/acs/test/support/fakes.dart @@ -140,6 +140,23 @@ 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; + + @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), + ); + } + /// Entradas que `pullVisits` devolve. Vazio por padrão. List pullEntries = const []; From 1887833833789ea491865fcd0139fb48933a2e76 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 29 Sep 2026 09:11:32 -0400 Subject: [PATCH 040/498] feat(acs): tela Convidar paciente com QR Code do convite de onboarding (RF02) Co-Authored-By: Claude Opus 5.5 --- apps/acs/lib/app/app.dart | 5 +- apps/acs/lib/app/invite_screen.dart | 213 +++++++++++++++++++++++++ apps/acs/pubspec.lock | 16 ++ apps/acs/pubspec.yaml | 1 + apps/acs/test/invite_screen_test.dart | 220 ++++++++++++++++++++++++++ 5 files changed, 454 insertions(+), 1 deletion(-) create mode 100644 apps/acs/lib/app/invite_screen.dart create mode 100644 apps/acs/test/invite_screen_test.dart diff --git a/apps/acs/lib/app/app.dart b/apps/acs/lib/app/app.dart index b6b22233..cb053156 100644 --- a/apps/acs/lib/app/app.dart +++ b/apps/acs/lib/app/app.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; 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/invite_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/backend_client.dart'; @@ -252,7 +253,7 @@ class _LoginScreenState extends State { ); } -enum AcsDestination { area, queue, map, visit, escalation, geofencing, notices } +enum AcsDestination { area, queue, map, visit, escalation, geofencing, notices, invite } /// Aviso de infraestrutura: o que quebrou e a consequência prática. /// @@ -759,6 +760,7 @@ class _AcsHomeShellState extends State with WidgetsBindingObserver }), ), AcsDestination.notices => const NoticesScreen(), + AcsDestination.invite => const InviteScreen(), }), bottomNavigationBar: NavigationBar( selectedIndex: destination.index <= 3 ? destination.index : 4, @@ -770,6 +772,7 @@ class _AcsHomeShellState extends State with WidgetsBindingObserver _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), ]))); Widget _moreItem(BuildContext sheet, IconData icon, String label, AcsDestination value) => ListTile(leading: Icon(icon), title: Text(label), onTap: () { Navigator.pop(sheet); setState(() => destination = value); }); } diff --git a/apps/acs/lib/app/invite_screen.dart b/apps/acs/lib/app/invite_screen.dart new file mode 100644 index 00000000..e6225abb --- /dev/null +++ b/apps/acs/lib/app/invite_screen.dart @@ -0,0 +1,213 @@ +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'), + ), + ], + ], + ); + } +} diff --git a/apps/acs/pubspec.lock b/apps/acs/pubspec.lock index 0f1089bb..93e6f46b 100644 --- a/apps/acs/pubspec.lock +++ b/apps/acs/pubspec.lock @@ -551,6 +551,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: diff --git a/apps/acs/pubspec.yaml b/apps/acs/pubspec.yaml index fed18778..26aa87cc 100644 --- a/apps/acs/pubspec.yaml +++ b/apps/acs/pubspec.yaml @@ -30,6 +30,7 @@ dependencies: mqtt_client: ^10.0.0 geolocator: ^12.0.0 google_maps_flutter: ^2.10.1 + qr_flutter: ^4.1.0 dev_dependencies: diff --git a/apps/acs/test/invite_screen_test.dart b/apps/acs/test/invite_screen_test.dart new file mode 100644 index 00000000..251e383d --- /dev/null +++ b/apps/acs/test/invite_screen_test.dart @@ -0,0 +1,220 @@ +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(); + }); +} From 8dea0c875d1360649206b1954497139aee9e81ce Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 29 Sep 2026 09:15:08 -0400 Subject: [PATCH 041/498] =?UTF-8?q?feat(paciente):=20leitura=20do=20QR=20C?= =?UTF-8?q?ode=20do=20convite=20pela=20c=C3=A2mera=20no=20onboarding=20(RF?= =?UTF-8?q?02)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../android/app/src/main/AndroidManifest.xml | 6 + apps/patient/lib/app/app.dart | 66 +++++++++-- apps/patient/lib/app/qr_scanner.dart | 103 ++++++++++++++++++ .../lib/core/onboarding/enrollment_qr.dart | 15 +++ apps/patient/pubspec.lock | 10 +- apps/patient/pubspec.yaml | 1 + apps/patient/test/enrollment_qr_test.dart | 26 +++++ apps/patient/test/onboarding_flow_test.dart | 69 ++++++++++++ 8 files changed, 284 insertions(+), 12 deletions(-) create mode 100644 apps/patient/lib/app/qr_scanner.dart create mode 100644 apps/patient/lib/core/onboarding/enrollment_qr.dart create mode 100644 apps/patient/test/enrollment_qr_test.dart diff --git a/apps/patient/android/app/src/main/AndroidManifest.xml b/apps/patient/android/app/src/main/AndroidManifest.xml index ebccdd39..33f24dfb 100644 --- a/apps/patient/android/app/src/main/AndroidManifest.xml +++ b/apps/patient/android/app/src/main/AndroidManifest.xml @@ -10,6 +10,12 @@ sem isso, os alarmes agendados via `flutter_local_notifications` somem em cada reboot (não sobrevivem sozinhos). --> + + + createState() => _SinalAcsAppState(); } @@ -81,6 +88,7 @@ class _SinalAcsAppState extends State { widget.reminderScheduler ?? LocalNotificationsReminderScheduler(FlutterLocalNotificationsPlugin()); late final ConsentPreferences _consentPreferences = widget.consentPreferences ?? SqfliteConsentPreferences(); + late final QrScanner _qrScanner = widget.qrScanner ?? scanQrWithCamera; // 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 @@ -96,11 +104,14 @@ class _SinalAcsAppState extends State { store: _reminderStore, scheduler: _reminderScheduler, consentPreferences: _consentPreferences, - child: MaterialApp( - title: 'SinalACS Paciente', - debugShowCheckedModeBanner: false, - theme: buildPatientTheme(), - home: const PatientLoginScreen(), + child: QrScannerScope( + scanner: _qrScanner, + child: MaterialApp( + title: 'SinalACS Paciente', + debugShowCheckedModeBanner: false, + theme: buildPatientTheme(), + home: const PatientLoginScreen(), + ), ), ), ), @@ -604,9 +615,9 @@ 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}); @@ -636,6 +647,29 @@ class _OnboardingScreenState extends State { super.dispose(); } + 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; + } + }); + } + Future _complete() async { final token = _tokenController.text.trim(); if (token.isEmpty) return; @@ -726,13 +760,23 @@ class _OnboardingScreenState extends State { 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.', + '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'), + ), + ), + const SizedBox(height: 20), TextField( key: const Key('onboarding_token_field'), controller: _tokenController, diff --git a/apps/patient/lib/app/qr_scanner.dart b/apps/patient/lib/app/qr_scanner.dart new file mode 100644 index 00000000..329579b4 --- /dev/null +++ b/apps/patient/lib/app/qr_scanner.dart @@ -0,0 +1,103 @@ +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), + ), + ), + ), + ], + ), + ); + } +} 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/pubspec.lock b/apps/patient/pubspec.lock index 38f9553e..1da8398b 100644 --- a/apps/patient/pubspec.lock +++ b/apps/patient/pubspec.lock @@ -295,6 +295,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: @@ -613,4 +621,4 @@ packages: version: "3.1.4" sdks: dart: ">=3.12.0 <4.0.0" - flutter: ">=3.24.0" + flutter: ">=3.29.0" diff --git a/apps/patient/pubspec.yaml b/apps/patient/pubspec.yaml index 4640b90a..e64c280b 100644 --- a/apps/patient/pubspec.yaml +++ b/apps/patient/pubspec.yaml @@ -30,6 +30,7 @@ dependencies: # 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 dev_dependencies: flutter_test: 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/onboarding_flow_test.dart b/apps/patient/test/onboarding_flow_test.dart index 17adb682..ad173034 100644 --- a/apps/patient/test/onboarding_flow_test.dart +++ b/apps/patient/test/onboarding_flow_test.dart @@ -38,6 +38,9 @@ Future tapKey(WidgetTester tester, String key) async { await tester.pumpAndSettle(); } +/// 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())); @@ -213,4 +216,70 @@ void main() { 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)); + }); } From 9911cdb4183604775806d5f025eceb182cf5c374 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 29 Sep 2026 09:16:15 -0400 Subject: [PATCH 042/498] docs: registra o QR do onboarding e os documentos legais do app paciente Co-Authored-By: Claude Opus 5.5 --- PROGRESS.md | 16 ++++++++++++++++ apps/CLAUDE.md | 2 ++ spec/PRD_system.md | 2 +- spec/lgpd_design.md | 6 ++++++ 4 files changed, 25 insertions(+), 1 deletion(-) diff --git a/PROGRESS.md b/PROGRESS.md index f7e6be1f..76fb60f6 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1093,3 +1093,19 @@ O que **não** foi feito, de propósito: 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. + +## 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 329, paciente 162, ACS 167. diff --git a/apps/CLAUDE.md b/apps/CLAUDE.md index 4ca7340a..5563b5f3 100644 --- a/apps/CLAUDE.md +++ b/apps/CLAUDE.md @@ -34,6 +34,8 @@ The ACS login is institutional now (RF07): `_enter` in `apps/acs/lib/app/app.dar 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 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`. + The UI depends on the interface, never on the generated `Client`, which is what keeps the widget tests hermetic; the live path is checked by `tool/live_check.dart` in each app and by `integration_test/`. Risk classification now comes **only** from `triage.evaluate`: the patient app's client-side string-matching rule is gone, and its triage form asks the six symptoms the server's engine actually takes. The ACS dashboard is fed by `AlertQueue`, which receives alerts over MQTT and orders them deterministically by risk and then by age; it rejects alerts from another micro-area and de-duplicates re-deliveries (QoS 1 is at-least-once). diff --git a/spec/PRD_system.md b/spec/PRD_system.md index 2f8022bb..c9d81709 100644 --- a/spec/PRD_system.md +++ b/spec/PRD_system.md @@ -132,7 +132,7 @@ events: | ID | Requisito | Ator | Complexidade | Risco | Dependência | |----|-----------|------|--------------|-------|-------------| | **RF01** | Autenticação Passwordless (CPF + Data Nasc + OTP) | Paciente | M | Médio | SMS Gateway | -| **RF02** | Onboarding via QR Code (ACS gera, paciente escaneia) | Paciente | M | Médio | Câmera, `qr_code_scanner` | +| **RF02** | Onboarding via QR Code (ACS gera, paciente escaneia) | Paciente | M | Médio | Câmera, `mobile_scanner` (paciente) e `qr_flutter` (ACS) | | **RF03** | Botão de Alerta de Urgência (MQTT) | Paciente | M | **Crítico** | Mosquitto, GPS | | **RF04** | Formulário de Triagem Estruturada (árvore de decisão) | Paciente | S | Médio | Nenhuma (local) | | **RF05** | Painel de Status de Solicitação | Paciente | S | Baixo | API HTTP do backend | diff --git a/spec/lgpd_design.md b/spec/lgpd_design.md index a1d2996e..34142ef2 100644 --- a/spec/lgpd_design.md +++ b/spec/lgpd_design.md @@ -165,6 +165,8 @@ como um item separado a lembrar depois. | **Artigos LGPD** | 6º, VI; 9º | | **Critério de Aceite** | ✓ Política de Privacidade com linguagem clara e acessível
✓ Resumo visual do fluxo de dados no app
✓ Notificações sobre mudanças nas políticas
✓ Canal de dúvidas sobre tratamento de dados | +> **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 canal digital de dúvidas. + ### LGPD-RF11 - Controle de Acesso e RBAC | Propriedade | Descrição | @@ -256,6 +258,8 @@ como um item separado a lembrar depois. | **Conteúdo Mínimo** | Regras de uso, responsabilidades, proibições, propriedade intelectual, limitação de responsabilidade, jurisdição, alterações no termo. | | **Critério de Aceite** | ✓ Texto em linguagem simples (acessibilidade para idosos e baixo letramento)
✓ Disponibilizado no app e no site
✓ Aceite explícito no cadastro
✓ Controle de versões disponível para consulta | +> **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. + ### LGPD-RF19 - Política de Privacidade | Propriedade | Descrição | @@ -266,6 +270,8 @@ como um item separado a lembrar depois. | **Conteúdo Mínimo** | Identificação do controlador, dados coletados (por funcionalidade), finalidades específicas, bases legais, compartilhamento, transferências, retenção, direitos, medidas de segurança, DPO/encarregado. | | **Critério de Aceite** | ✓ Linguagem acessível para leigos (recomendado: Nível de leitura 8º ano)
✓ Tópicos claros e organizados
✓ Versão resumida (sumário visual) e versão completa
✓ Atualização comunicada com no mínimo 15 dias de antecedência | +> **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. + ### LGPD-RF20 - Aviso de Consentimento (Banner/Modal) | Propriedade | Descrição | From 18a5af3512983df77ee81fd4d264c54268a57546 Mon Sep 17 00:00:00 2001 From: vinimartinsufrr Date: Tue, 29 Sep 2026 09:19:32 -0400 Subject: [PATCH 043/498] =?UTF-8?q?fix(ci):=20check=20obrigat=C3=B3rio=20p?= =?UTF-8?q?assa=20sem=20rodar=20(needs,=20continue-on-error,=20matrix)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check_checks_obrigatorios (scripts/qa/ci_invariants.sh) garantia que um check obrigatório existe e não tem `if:` no job, mas não pegava três outros jeitos de ele reportar sucesso sem ter rodado nada: - needs: apontando para um job fora de CHECKS_OBRIGATORIOS (ex.: android-e2e) — se esse job falhar, o obrigatório fica skipped, e skipped conta como aprovado na proteção de branch. - continue-on-error: true no job, que transforma falha em sucesso. - strategy.matrix, que muda o nome do check para "job (valor)" e não casa mais com o contexto exigido pela proteção — a PR espera para sempre. Também corrige a mensagem do caso se `if:` já coberto: dizia "pode nunca reportar", mas um job pulado por if: sempre reporta, só que reporta skipped/ verde — o bloqueio já estava certo, só a causa citada estava errada. scripts/qa/ci_invariants_test.sh cobre as três checagens novas com fixture positiva e negativa cada (ex.: needs: para outro job obrigatório não deve disparar, só needs: para um job fora do conjunto obrigatório deve), plugado no job workflow-lint logo depois do ci_invariants.sh. De passagem, força PYTHONIOENCODING=utf-8 na chamada do Python — sem isso as mensagens de falha saem corrompidas em locales que não são UTF-8 por padrão (achado ao rodar localmente neste Windows; sem efeito nos runners Linux do CI, que já usam UTF-8, mas deixa o script correto independente do host). CLAUDE.md e AGENTS.md atualizados no mesmo commit com as novas condições de falha e a lista de lacunas conhecidas (issues #19-#24), por exigência do próprio workflow-lint. Closes #20 Co-Authored-By: Claude Sonnet 5 --- .github/workflows/ci.yml | 1 + AGENTS.md | 4 +- CLAUDE.md | 4 +- scripts/qa/ci_invariants.sh | 52 +++++++++- scripts/qa/ci_invariants_test.sh | 163 +++++++++++++++++++++++++++++++ 5 files changed, 216 insertions(+), 8 deletions(-) create mode 100755 scripts/qa/ci_invariants_test.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3787b7ba..84995fe7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,7 @@ 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 serverpod-backend: runs-on: ubuntu-24.04 defaults: diff --git a/AGENTS.md b/AGENTS.md index 4b860ec3..9085ffd2 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/SHA, 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); lacunas conhecidas da guarda nas issues #19, #21 a #24 (#20 corrigida — ver `check_checks_obrigatorios` e `scripts/qa/ci_invariants_test.sh`). ## Observações finais diff --git a/CLAUDE.md b/CLAUDE.md index f58b0f6c..e26f1c00 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -105,8 +105,8 @@ 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-SHA `concurrency` group; `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. +- `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, #21–#24 (#20 fixed — see `check_checks_obrigatorios` and `scripts/qa/ci_invariants_test.sh`). - Never commit real patient data, credentials, or the dev Docker Compose secrets into anything beyond local development. ## graphify diff --git a/scripts/qa/ci_invariants.sh b/scripts/qa/ci_invariants.sh index 9e3fb821..35c6552c 100755 --- a/scripts/qa/ci_invariants.sh +++ b/scripts/qa/ci_invariants.sh @@ -13,11 +13,22 @@ # # Não precisa de rede nem da stack; só python3 com PyYAML. Roda no job # workflow-lint do próprio CI. +# +# CI_INVARIANTS_WORKFLOW aponta para um workflow diferente do real — só para +# scripts/qa/ci_invariants_test.sh testar as checagens contra fixtures +# sintéticas, sem tocar em .github/workflows/ci.yml. set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +caminho_workflow="${CI_INVARIANTS_WORKFLOW:-$repo_root/.github/workflows/ci.yml}" + +# Sem isto, o Python usa a codificação do locale do host para stdout/stderr — +# em runners Linux normalmente já é UTF-8, mas não é garantido (e não é, por +# padrão, no Windows), e as mensagens de falha têm acento. Sem UTF-8 forçado, +# elas saem corrompidas em vez de crashar, o que é pior: passa despercebido. +export PYTHONIOENCODING=utf-8 -exec python3 - "$repo_root/.github/workflows/ci.yml" "$@" <<'PY' +exec python3 - "$caminho_workflow" "$@" <<'PY' import json import sys @@ -165,6 +176,16 @@ CHECKS_OBRIGATORIOS = sorted(JOBS_DOCUMENTADOS - {'android-e2e'}) # publique um status com o mesmo nome e destrave o merge. APP_GITHUB_ACTIONS = 15368 +def _lista_needs(needs): + # `needs:` aceita string, lista ou (sem chave) None — normaliza pra uma + # lista só, sempre. + if needs is None: + return [] + if isinstance(needs, str): + return [needs] + return list(needs) + + def check_checks_obrigatorios(): if 'android-e2e' in CHECKS_OBRIGATORIOS: falhas.append('FINDING-4: android-e2e não pode ser obrigatório enquanto for instável') @@ -173,12 +194,35 @@ def check_checks_obrigatorios(): if job is None: falhas.append(f'FINDING-5: check obrigatório {nome} não existe no workflow') continue - # O nome do check é o `name:` do job, se houver; e um job com `if:` - # pode não rodar e nunca reportar. + # O nome do check é o `name:` do job, se houver. if job.get('name', nome) != nome: falhas.append(f"FINDING-5: {nome} tem name: {job['name']!r}; o check obrigatório não casaria") if 'if' in job: - falhas.append(f'FINDING-5: {nome} tem if: no nível do job; pode nunca reportar') + # Um job pulado pelo próprio `if:` reporta `skipped` — e a + # proteção de branch conta `skipped` como aprovado. Não é "pode + # nunca reportar": ele sempre reporta, só que reporta verde do + # jeito errado, e o merge destrava sem o job ter rodado nada + # (issue #20 corrige esta mensagem, que dizia o contrário). + falhas.append( + f'FINDING-5: {nome} tem if: no nível do job; skipped conta como aprovado e destrava o merge sem rodar' + ) + # issue #20: três outros jeitos de um check obrigatório passar verde + # sem ter, de fato, rodado. + if job.get('continue-on-error') is True: + falhas.append( + f'{nome} (obrigatório) tem continue-on-error: true; uma falha do job reporta sucesso' + ) + if 'matrix' in (job.get('strategy') or {}): + falhas.append( + f'{nome} (obrigatório) tem strategy.matrix; o nome do check vira "{nome} (valor)" ' + 'e não casa com o contexto exigido pela proteção — a PR espera para sempre' + ) + for dep in _lista_needs(job.get('needs')): + if dep not in CHECKS_OBRIGATORIOS: + falhas.append( + f'{nome} (obrigatório) tem needs: {dep}, que não é obrigatório; se {dep} falhar, ' + f'{nome} fica skipped e conta como aprovado' + ) CHECKS = [check_jobs, check_gatilhos, check_sem_filtro_de_paths, check_concorrencia, diff --git a/scripts/qa/ci_invariants_test.sh b/scripts/qa/ci_invariants_test.sh new file mode 100755 index 00000000..128ec95e --- /dev/null +++ b/scripts/qa/ci_invariants_test.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +# +# Controles positivo e negativo das três checagens que a issue #20 pediu para +# scripts/qa/ci_invariants.sh: um check obrigatório que passa verde sem ter +# rodado por causa de continue-on-error, strategy.matrix ou needs: apontando +# para um job fora de CHECKS_OBRIGATORIOS. +# +# Por que existe: sem fixture sintética, as três checagens só seriam +# exercitadas contra o .github/workflows/ci.yml real do momento — que, por +# não ter nenhum dos três defeitos, nunca provaria que a checagem DETECTA o +# que promete detectar, só que ela não acende à toa no arquivo de hoje. Cada +# caso aqui tem o par positivo (o defeito existe, a mensagem tem de aparecer) +# e negativo (uma variação vizinha SEM o defeito, a mensagem não pode +# aparecer) — o mesmo padrão de scripts/qa/tls_invariants.sh. +# +# ./scripts/qa/ci_invariants_test.sh +# +# Não precisa de rede nem da stack; só python3 com PyYAML (mesma dependência +# do ci_invariants.sh, que este script chama via CI_INVARIANTS_WORKFLOW para +# apontar cada fixture em vez do .github/workflows/ci.yml real). +set -uo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT + +falhas=0 +ok() { echo " ok — $1"; } +falha() { echo " FALHA — $1" >&2; falhas=$((falhas + 1)); } + +# Roda o ci_invariants.sh contra a fixture $1 e devolve o stderr em $saida. +# Não usa `set -e`: um exit 1 é o resultado esperado sempre que a fixture tem +# um defeito de propósito. +rodar() { + local fixture="$1" + saida="$(CI_INVARIANTS_WORKFLOW="$fixture" "$repo_root/scripts/qa/ci_invariants.sh" 2>&1 1>/dev/null)" +} + +# Espera que $trecho apareça na saída da fixture $1. +espera_falha() { + local descricao="$1" fixture="$2" trecho="$3" + rodar "$fixture" + if printf '%s' "$saida" | grep -qF -- "$trecho"; then + ok "$descricao: a checagem acusa (\"$trecho\")" + else + falha "$descricao: esperava \"$trecho\" na saída, não apareceu — saída: $saida" + fi +} + +# Espera que $trecho NÃO apareça na saída da fixture $1 — o controle +# negativo: prova que a checagem não dispara por acidente numa variação +# vizinha sem o defeito. +espera_ok() { + local descricao="$1" fixture="$2" trecho="$3" + rodar "$fixture" + if printf '%s' "$saida" | grep -qF -- "$trecho"; then + falha "$descricao: não devia acusar (\"$trecho\") e acusou — saída: $saida" + else + ok "$descricao: a checagem não dispara por acidente" + fi +} + +# Workflow mínimo que passa por TODAS as outras checagens do ci_invariants.sh +# (jobs documentados, gatilhos, concurrency, versões de ação, runner, limpeza +# do pg_data) — assim, o único jeito de uma fixture derivada daqui falhar por +# causa das checagens desta issue é ela mesma ter o defeito que existe para +# provar. $1, se dado, substitui a linha ` # EXTRA` dentro do job +# admin-app (indentação de 4 espaços já incluída no marcador). +workflow_base() { + local extra_admin_app="${1:-}" + cat < "$caminho" + printf '%s' "$caminho" +} + +echo '== Controle de base: a fixture limpa não deve acusar nada =' +base_fixture="$(escreve base '')" +rodar "$base_fixture" +if [[ -z "$saida" ]]; then + ok 'a fixture base passa por todas as outras checagens sem ruído' +else + falha "a fixture base deveria estar limpa e não está — saída: $saida" +fi + +echo '== continue-on-error: true num check obrigatório vira sucesso mesmo falhando ==' +espera_falha 'positivo' \ + "$(escreve continue_on_error_true ' continue-on-error: true')" \ + 'admin-app (obrigatório) tem continue-on-error: true' +espera_ok 'negativo (continue-on-error: false, valor explícito)' \ + "$(escreve continue_on_error_false ' continue-on-error: false')" \ + 'continue-on-error: true' + +echo '== strategy.matrix num check obrigatório muda o nome do check ==' +espera_falha 'positivo' \ + "$(escreve matrix ' strategy: + matrix: + flutter: ["3.44.8"]')" \ + 'admin-app (obrigatório) tem strategy.matrix' +espera_ok 'negativo (strategy sem matrix, só fail-fast)' \ + "$(escreve strategy_sem_matrix ' strategy: + fail-fast: true')" \ + 'strategy.matrix' + +echo '== needs: apontando para fora de CHECKS_OBRIGATORIOS esconde o job que falhou ==' +espera_falha 'positivo (needs: android-e2e, não obrigatório)' \ + "$(escreve needs_fora ' needs: android-e2e')" \ + 'admin-app (obrigatório) tem needs: android-e2e' +espera_ok 'negativo (needs: acs-app, também obrigatório)' \ + "$(escreve needs_dentro ' needs: acs-app')" \ + 'admin-app (obrigatório) tem needs:' + +echo +if [[ "$falhas" -eq 0 ]]; then + echo 'OK — as checagens de check obrigatório "verde sem rodar" (issue #20) conferem.' +else + echo "FALHOU — $falhas asserção(ões) não conferem." >&2 +fi +[[ "$falhas" -eq 0 ]] From 8775274691516189f050ff061cbc99a3e7e39ae6 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 29 Sep 2026 09:24:20 -0400 Subject: [PATCH 044/498] =?UTF-8?q?fix:=20achados=20da=20revis=C3=A3o=20fi?= =?UTF-8?q?nal=20do=20QR=20do=20onboarding=20e=20dos=20documentos=20legais?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ACS: convite que chega depois da troca de paciente é descartado, em vez de aparecer sob o nome da pessoa errada (rede lenta). - Paciente: leitura da câmera depois de a pessoa voltar não fecha mais o onboarding por baixo (QrDetectionGate). - Paciente: números do resumo legal sobre accentDark (contraste 4.5:1). Co-Authored-By: Claude Opus 5.5 --- PROGRESS.md | 2 +- apps/acs/lib/app/invite_screen.dart | 7 ++- apps/acs/test/invite_screen_test.dart | 42 +++++++++++++++++ apps/acs/test/support/fakes.dart | 6 +++ apps/patient/lib/app/legal_screens.dart | 4 +- apps/patient/lib/app/qr_scanner.dart | 51 ++++++++++++++++----- apps/patient/test/contrast_tokens_test.dart | 2 + apps/patient/test/legal_screens_test.dart | 22 +++++++++ apps/patient/test/qr_scanner_test.dart | 27 +++++++++++ 9 files changed, 147 insertions(+), 16 deletions(-) create mode 100644 apps/patient/test/qr_scanner_test.dart diff --git a/PROGRESS.md b/PROGRESS.md index 76fb60f6..ff935669 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1108,4 +1108,4 @@ Plano: `docs/superpowers/plans/2026-09-29-qr-onboarding-e-documentos-legais.md`, - 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 329, paciente 162, ACS 167. +- Contagens de teste depois desta entrega: backend 329, paciente 167, ACS 168. diff --git a/apps/acs/lib/app/invite_screen.dart b/apps/acs/lib/app/invite_screen.dart index e6225abb..658d3d29 100644 --- a/apps/acs/lib/app/invite_screen.dart +++ b/apps/acs/lib/app/invite_screen.dart @@ -80,15 +80,18 @@ class _InviteScreenState extends State { ).generateInvite(patientId: patient.patientId); if (!mounted) return; setState(() { - _invite = invite; _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; }); } on BackendFailure catch (failure) { if (!mounted) return; setState(() { + _generating = false; + if (_selected?.patientId != patient.patientId) return; _invite = null; _error = failure.message; - _generating = false; }); } } diff --git a/apps/acs/test/invite_screen_test.dart b/apps/acs/test/invite_screen_test.dart index 251e383d..6c6f7eec 100644 --- a/apps/acs/test/invite_screen_test.dart +++ b/apps/acs/test/invite_screen_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:qr_flutter/qr_flutter.dart'; @@ -135,6 +137,46 @@ void main() { 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; diff --git a/apps/acs/test/support/fakes.dart b/apps/acs/test/support/fakes.dart index 494a4f3c..e3cd14bc 100644 --- a/apps/acs/test/support/fakes.dart +++ b/apps/acs/test/support/fakes.dart @@ -1,3 +1,4 @@ +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/services/alert_feed.dart'; @@ -146,9 +147,14 @@ class FakeAcsBackend implements AcsBackend { /// 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; + @override Future generateInvite({required String patientId}) async { inviteCalls.add(patientId); + await inviteGate?.future; final failure = inviteFailure; if (failure != null) throw failure; return EnrollmentTokenResult( diff --git a/apps/patient/lib/app/legal_screens.dart b/apps/patient/lib/app/legal_screens.dart index 48730147..983d6a3d 100644 --- a/apps/patient/lib/app/legal_screens.dart +++ b/apps/patient/lib/app/legal_screens.dart @@ -82,7 +82,9 @@ class LegalDocumentScreen extends StatelessWidget { Card( child: ListTile( leading: CircleAvatar( - backgroundColor: PatientColors.accent, + // `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}'), ), diff --git a/apps/patient/lib/app/qr_scanner.dart b/apps/patient/lib/app/qr_scanner.dart index 329579b4..d2816da0 100644 --- a/apps/patient/lib/app/qr_scanner.dart +++ b/apps/patient/lib/app/qr_scanner.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:mobile_scanner/mobile_scanner.dart'; @@ -33,6 +35,33 @@ Future scanQrWithCamera(BuildContext context) => 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(); @@ -43,9 +72,7 @@ class _CameraScanPage extends StatefulWidget { 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; + final _gate = QrDetectionGate(); @override void dispose() { @@ -54,15 +81,15 @@ class _CameraScanPageState extends State<_CameraScanPage> { } 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; - } - } + // 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 diff --git a/apps/patient/test/contrast_tokens_test.dart b/apps/patient/test/contrast_tokens_test.dart index a3f4d226..da28e0e4 100644 --- a/apps/patient/test/contrast_tokens_test.dart +++ b/apps/patient/test/contrast_tokens_test.dart @@ -22,6 +22,8 @@ void main() { ('dangerOnSurface sobre card', PatientColors.dangerOnSurface, PatientColors.surfaceRaised, normalText), ('dangerOnSurface sobre scaffold', PatientColors.dangerOnSurface, PatientColors.background, normalText), ('accentOnSurface sobre card', PatientColors.accentOnSurface, PatientColors.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), // Preenchimento de botão: continua correto sem token novo. ('branco sobre botão de pânico (danger fill)', Colors.white, PatientColors.danger, largeTextOrUi), ]; diff --git a/apps/patient/test/legal_screens_test.dart b/apps/patient/test/legal_screens_test.dart index 3ded899e..f161f9dd 100644 --- a/apps/patient/test/legal_screens_test.dart +++ b/apps/patient/test/legal_screens_test.dart @@ -5,6 +5,7 @@ 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 { @@ -78,4 +79,25 @@ void main() { 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/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); + }); +} From df7fdd36a71ddc904c78a29713690e13ffe9c3c5 Mon Sep 17 00:00:00 2001 From: vinimartinsufrr Date: Tue, 29 Sep 2026 09:38:39 -0400 Subject: [PATCH 045/498] =?UTF-8?q?fix(ci):=20ci=5Finvariants.sh=20n=C3=A3?= =?UTF-8?q?o=20quebra=20com=20on:=20string/lista=20nem=20engole=20flag=20e?= =?UTF-8?q?rrada?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dois defeitos de robustez em scripts/qa/ci_invariants.sh: - gatilhos = wf.get('on', wf.get(True)) or {} assumia dict. `on: push` (string) ou `on: [push, pull_request]` (lista) — as duas formas curtas que o GitHub Actions aceita — viravam str/list, e o primeiro .get() de check_gatilhos quebrava com AttributeError: traceback do Python em vez de FALHA: ..., a causa real longe do sintoma. _normaliza_gatilhos() agora converte string/lista em dict (sem sub-configuração mesmo, então os checks existentes acusam o que já acusariam: push sem branches, sem workflow_dispatch etc.) e registra FALHA explícita pra qualquer outro tipo, em vez de deixar o AttributeError estourar. - sys.argv[2:] só procurava --checks-obrigatorios; qualquer flag desconhecida (ex.: o typo --checks-obrigatorio, sem o S) era ignorada em silêncio e o script saía com "ok"/exit 0. Quem chama com --checks-obrigatorios normalmente alimenta a saída direto num PUT da API de proteção de branch — "ok" sem o JSON esperado quebra ali, longe da causa. Os argumentos agora são validados ANTES de qualquer trabalho: flag desconhecida sai 2 com mensagem de uso. De passagem, adiciona CI_INVARIANTS_WORKFLOW (aponta pra um workflow sintético em vez do real, usado só para testar as duas correções acima com fixtures) e PYTHONIOENCODING=utf-8 (sem isso, mensagens de falha com acento saem corrompidas em locale não-UTF-8 — sem efeito nos runners Linux do CI, mas necessário pra validar localmente neste Windows). Mesmos dois ajustes já feitos na PR #26 (issue #20), que ainda não foi mesclada — como as duas PRs mexem nas mesmas linhas por motivos independentes, é esperado precisarem de um rebase uma sobre a outra antes do merge da segunda. Validado manualmente com fixtures sintéticas (sem mudar .github/workflows/ci.yml): `on: push`, `on: [push, pull_request]` e `on: 42` não quebram mais (FALHA específica ou nenhuma, exit 1 quando há falha, nunca traceback); `--checks-obrigatorios --foo` e `--checks-obrigatorio` (typo) saem 2 com mensagem de uso. Script continua ok contra o ci.yml real (dict) e --checks-obrigatorios continua devolvendo os 8 checks certos. CLAUDE.md e AGENTS.md atualizados: #22 sai da lista de lacunas conhecidas da guarda. Closes #22 Co-Authored-By: Claude Sonnet 5 --- AGENTS.md | 2 +- CLAUDE.md | 2 +- scripts/qa/ci_invariants.sh | 53 ++++++++++++++++++++++++++++++++++--- 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4b860ec3..4b9ce5ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -148,7 +148,7 @@ Importante: - 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 `-`; - `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); lacunas conhecidas da guarda nas issues #19 a #21 e #23 a #24 (#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). ## Observações finais diff --git a/CLAUDE.md b/CLAUDE.md index f58b0f6c..0fb608a3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -106,7 +106,7 @@ Product/architecture source of truth (PRD, UX flows, LGPD design, stack decision - 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. +- `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–#21, #23–#24 (#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). - Never commit real patient data, credentials, or the dev Docker Compose secrets into anything beyond local development. ## graphify diff --git a/scripts/qa/ci_invariants.sh b/scripts/qa/ci_invariants.sh index 9e3fb821..2ff58e53 100755 --- a/scripts/qa/ci_invariants.sh +++ b/scripts/qa/ci_invariants.sh @@ -13,25 +13,70 @@ # # Não precisa de rede nem da stack; só python3 com PyYAML. Roda no job # workflow-lint do próprio CI. +# +# CI_INVARIANTS_WORKFLOW aponta para um workflow diferente do real — só para +# testar as checagens contra fixtures sintéticas, sem tocar em +# .github/workflows/ci.yml. set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +caminho_workflow="${CI_INVARIANTS_WORKFLOW:-$repo_root/.github/workflows/ci.yml}" + +# Sem isto, o Python usa a codificação do locale do host para stdout/stderr — +# em runners Linux normalmente já é UTF-8, mas não é garantido (e não é, por +# padrão, no Windows), e as mensagens de falha têm acento. Sem UTF-8 forçado, +# elas saem corrompidas em vez de crashar, o que é pior: passa despercebido. +export PYTHONIOENCODING=utf-8 -exec python3 - "$repo_root/.github/workflows/ci.yml" "$@" <<'PY' +exec python3 - "$caminho_workflow" "$@" <<'PY' import json import sys import yaml +# Valida os argumentos ANTES de fazer qualquer trabalho — uma flag digitada +# errada (ex.: --checks-obrigatorio, sem o S) não pode sair calada com "ok"/ +# exit 0: quem chama este script com --checks-obrigatorios normalmente +# alimenta a saída direto num PUT da API de proteção de branch, e "ok" sem o +# JSON esperado quebra ali, longe da causa real (issue #22). +FLAGS_CONHECIDAS = {'--checks-obrigatorios'} +flags_desconhecidas = [a for a in sys.argv[2:] if a not in FLAGS_CONHECIDAS] +if flags_desconhecidas: + print(f'uso: ci_invariants.sh [{" | ".join(sorted(FLAGS_CONHECIDAS))}]', file=sys.stderr) + print(f'flag(s) desconhecida(s): {flags_desconhecidas}', file=sys.stderr) + sys.exit(2) + caminho = sys.argv[1] with open(caminho, encoding='utf-8') as f: wf = yaml.safe_load(f) -# PyYAML segue o YAML 1.1, em que a chave `on` é lida como o booleano True. -gatilhos = wf.get('on', wf.get(True)) or {} -jobs = wf.get('jobs') or {} falhas = [] + +def _normaliza_gatilhos(bruto): + # PyYAML segue o YAML 1.1, em que a chave `on` é lida como o booleano + # True. E `on:` aceita três formas no GitHub Actions: dict (a única que + # os checks abaixo sabem ler), string solta (`on: push`) e lista + # (`on: [push, pull_request]`) — as duas últimas não carregam + # sub-configuração nenhuma (não dá para expressar `branches:` nelas), e + # sem normalizar viravam `str`/`list` aqui e quebravam com AttributeError + # no primeiro `.get()` de check_gatilhos, longe da causa (issue #22). + valor = bruto.get('on', bruto.get(True)) + if valor is None: + return {} + if isinstance(valor, dict): + return valor + if isinstance(valor, str): + return {valor: None} + if isinstance(valor, list): + return {evento: None for evento in valor} + falhas.append(f'on: tem tipo inesperado ({type(valor).__name__}): {valor!r}') + return {} + + +gatilhos = _normaliza_gatilhos(wf) +jobs = wf.get('jobs') or {} + # Os jobs que CLAUDE.md e AGENTS.md enumeram. Mudou aqui, muda lá no mesmo # commit — foi exatamente essa enumeração que envelheceu (FINDING-1). JOBS_DOCUMENTADOS = { From d98bab041684fc38ae2cca9138d85143c445e751 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 29 Sep 2026 09:43:03 -0400 Subject: [PATCH 046/498] docs: plano do aceite do Termo de Uso no login por OTP Co-Authored-By: Claude Sonnet 5.5 --- ...2026-09-29-aceite-do-termo-no-login-otp.md | 710 ++++++++++++++++++ 1 file changed, 710 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-29-aceite-do-termo-no-login-otp.md 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..ca90619d --- /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 " +``` From 871f6947fbc18cc313490ded0e90ec6277e10419 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 29 Sep 2026 09:56:53 -0400 Subject: [PATCH 047/498] feat(backend): patients.acceptTermsOfUse para o aceite do termo fora do onboarding (LGPD-RF18) Co-Authored-By: Claude Sonnet 5.5 --- .../lib/src/protocol/client.dart | 11 +++++ .../patients/data_subject_rights_service.dart | 19 +++++++- .../lib/src/endpoints/patients_endpoint.dart | 24 ++++++++++ .../lib/src/generated/endpoints.dart | 19 ++++++++ .../lib/src/generated/protocol.yaml | 1 + .../data_subject_rights_endpoint_test.dart | 45 +++++++++++++++++++ .../test_tools/serverpod_test_tools.dart | 31 +++++++++++++ .../data_subject_rights_service_test.dart | 22 +++++++++ 8 files changed, 171 insertions(+), 1 deletion(-) diff --git a/backend/sinalacs_client/lib/src/protocol/client.dart b/backend/sinalacs_client/lib/src/protocol/client.dart index 54a82ec2..08ff6309 100644 --- a/backend/sinalacs_client/lib/src/protocol/client.dart +++ b/backend/sinalacs_client/lib/src/protocol/client.dart @@ -363,6 +363,17 @@ class EndpointPatients extends EndpointAuthenticated { }, ); + /// 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`. + _i2.Future<_i12.PatientConsentRecord> acceptTermsOfUse({ + required String accessToken, + }) => caller.callServerEndpoint<_i12.PatientConsentRecord>( + 'patients', + 'acceptTermsOfUse', + {'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<_i14.PatientDataSubjectRequestRecord> requestDataDeletion({ 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 index d435f03e..ec1b707c 100644 --- 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 @@ -79,8 +79,25 @@ class DataSubjectRightsService { ); } + return _record(user, purpose: purpose, action: granted ? 'granted' : 'denied'); + } + + /// 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(); - final action = granted ? 'granted' : 'denied'; await _store.recordConsent(ConsentLogEntry( userId: user.id, purpose: purpose, diff --git a/backend/sinalacs_server/lib/src/endpoints/patients_endpoint.dart b/backend/sinalacs_server/lib/src/endpoints/patients_endpoint.dart index df5afc4f..e323e58f 100644 --- a/backend/sinalacs_server/lib/src/endpoints/patients_endpoint.dart +++ b/backend/sinalacs_server/lib/src/endpoints/patients_endpoint.dart @@ -128,6 +128,30 @@ class PatientsEndpoint extends AuthenticatedEndpoint { } } + /// 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); + } + } + /// Pedido de exclusão/anonimização dos próprios dados (LGPD-RF08). /// Idempotente enquanto houver um pedido de exclusão em aberto. Future requestDataDeletion( diff --git a/backend/sinalacs_server/lib/src/generated/endpoints.dart b/backend/sinalacs_server/lib/src/generated/endpoints.dart index 95af9333..615b900b 100644 --- a/backend/sinalacs_server/lib/src/generated/endpoints.dart +++ b/backend/sinalacs_server/lib/src/generated/endpoints.dart @@ -473,6 +473,25 @@ class Endpoints extends _i1.EndpointDispatch { 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 _i6.PatientsEndpoint) + .acceptTermsOfUse( + session, + accessToken: params['accessToken'], + ), + ), 'requestDataDeletion': _i1.MethodConnector( name: 'requestDataDeletion', params: { diff --git a/backend/sinalacs_server/lib/src/generated/protocol.yaml b/backend/sinalacs_server/lib/src/generated/protocol.yaml index 92ae3f9b..bd17d4d0 100644 --- a/backend/sinalacs_server/lib/src/generated/protocol.yaml +++ b/backend/sinalacs_server/lib/src/generated/protocol.yaml @@ -18,6 +18,7 @@ patients: - updateChronicConditions: - myData: - updateConsent: + - acceptTermsOfUse: - requestDataDeletion: - requestDataCorrection: triage: 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 index 744a1c9e..f2abe5cf 100644 --- a/backend/sinalacs_server/test/integration/data_subject_rights_endpoint_test.dart +++ b/backend/sinalacs_server/test/integration/data_subject_rights_endpoint_test.dart @@ -1,5 +1,6 @@ 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 consentPolicyVersion; 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'; @@ -141,6 +142,50 @@ void main() { 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), 0); + }); + test('updateConsent recusa a finalidade obrigatória sem gravar nada', () async { final session = sessionBuilder.build(); await _seed(session); 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 83847209..42883128 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 @@ -777,6 +777,37 @@ class _PatientsEndpoint { }); } + _i3.Future<_i13.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<_i13.PatientConsentRecord>); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } + _i3.Future<_i15.PatientDataSubjectRequestRecord> requestDataDeletion( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, 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 index 25a84171..7a18a52a 100644 --- a/backend/sinalacs_server/test/unit/data_subject_rights_service_test.dart +++ b/backend/sinalacs_server/test/unit/data_subject_rights_service_test.dart @@ -93,6 +93,28 @@ void main() { service = DataSubjectRightsService(store: store, audit: audit, clock: () => _now); }); + 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); + }); + }); + 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( From 1d78e986a4d74286b32dcd8db1f806f0fa87f1f6 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 29 Sep 2026 10:00:36 -0400 Subject: [PATCH 048/498] feat(paciente): convite ao aceite do Termo de Uso depois do login por OTP (LGPD-RF18) Co-Authored-By: Claude Sonnet 5.5 --- apps/patient/lib/app/app.dart | 30 ++++- apps/patient/lib/app/legal_screens.dart | 102 +++++++++++++++ .../lib/core/legal/terms_acceptance.dart | 21 +++ .../lib/core/network/backend_client.dart | 14 ++ apps/patient/test/patient_app_mvp_test.dart | 11 +- .../test/support/fake_patient_backend.dart | 29 ++++- apps/patient/test/terms_acceptance_test.dart | 47 +++++++ apps/patient/test/terms_gate_flow_test.dart | 121 ++++++++++++++++++ 8 files changed, 368 insertions(+), 7 deletions(-) create mode 100644 apps/patient/lib/core/legal/terms_acceptance.dart create mode 100644 apps/patient/test/terms_acceptance_test.dart create mode 100644 apps/patient/test/terms_gate_flow_test.dart diff --git a/apps/patient/lib/app/app.dart b/apps/patient/lib/app/app.dart index ce599ce2..b1c628a5 100644 --- a/apps/patient/lib/app/app.dart +++ b/apps/patient/lib/app/app.dart @@ -20,6 +20,7 @@ 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_acceptance.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'; @@ -363,6 +364,18 @@ 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 { + final overview = await BackendScope.of(context).myData(); + return needsTermsAcceptance(overview.consents); + } on BackendFailure { + return false; + } + } + /// Verifica o código e só navega em caso de sucesso. /// /// Antes a tela navegava incondicionalmente, ignorando o que era digitado — @@ -385,12 +398,19 @@ class _PatientLoginScreenState extends State { try { await BackendScope.of(context).verifyOtp(cpf: cpf, code: _codigo.text.trim()); if (!mounted) return; + final needsTerms = await _needsTerms(); + if (!mounted) return; + final home = MaterialPageRoute( + builder: (_) => const PatientHomeShell(initialDestination: PatientDestination.triage), + ); 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; diff --git a/apps/patient/lib/app/legal_screens.dart b/apps/patient/lib/app/legal_screens.dart index 983d6a3d..4ef88876 100644 --- a/apps/patient/lib/app/legal_screens.dart +++ b/apps/patient/lib/app/legal_screens.dart @@ -1,6 +1,8 @@ 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 → @@ -136,3 +138,103 @@ class LegalDocumentScreen extends StatelessWidget { ); } } + +/// 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'), + ), + ], + ), + ), + ); + } +} diff --git a/apps/patient/lib/core/legal/terms_acceptance.dart b/apps/patient/lib/core/legal/terms_acceptance.dart new file mode 100644 index 00000000..d5368ea8 --- /dev/null +++ b/apps/patient/lib/core/legal/terms_acceptance.dart @@ -0,0 +1,21 @@ +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; +} diff --git a/apps/patient/lib/core/network/backend_client.dart b/apps/patient/lib/core/network/backend_client.dart index 03b78246..775b7dc8 100644 --- a/apps/patient/lib/core/network/backend_client.dart +++ b/apps/patient/lib/core/network/backend_client.dart @@ -132,6 +132,11 @@ abstract class PatientBackend { 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`. + Future acceptTermsOfUse(); + /// Pede a exclusão dos próprios dados (LGPD-RF08). Pedir de novo com um /// pedido aberto devolve o mesmo. Future requestDataDeletion(); @@ -236,6 +241,9 @@ class MisconfiguredBackend implements PatientBackend { }) async => _recusar(); + @override + Future acceptTermsOfUse() async => _recusar(); + @override Future requestDataDeletion() async => _recusar(); @@ -519,6 +527,12 @@ class BackendClient implements PatientBackend { ); } + @override + Future acceptTermsOfUse() async { + final token = await _requireToken(); + return _guard(() => _client.patients.acceptTermsOfUse(accessToken: token)); + } + @override Future requestDataDeletion() async { final token = await _requireToken(); diff --git a/apps/patient/test/patient_app_mvp_test.dart b/apps/patient/test/patient_app_mvp_test.dart index 41408ca2..fd9afc06 100644 --- a/apps/patient/test/patient_app_mvp_test.dart +++ b/apps/patient/test/patient_app_mvp_test.dart @@ -194,6 +194,14 @@ Future openMyData(WidgetTester tester) async { tester.view.physicalSize = const Size(800, 2400); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); + // Os testes de "Meus dados" montam o próprio `myDataResult`, em geral sem + // aceite do termo: o login cai na tela de aceite. O assunto aqui é o painel, + // então o paciente toca "Agora não", como faria de verdade. + final later = find.byKey(const Key('terms_gate_later_button')); + if (later.evaluate().isNotEmpty) { + await tester.tap(later); + await tester.pumpAndSettle(); + } await tester.tap(find.text('Mais')); await tester.pumpAndSettle(); await tester.tap(find.text('Meus dados')); @@ -1207,7 +1215,8 @@ void main() { await login(tester); await openMyData(tester); - expect(backend.myDataCallCount, 1); + // Uma leitura do login (checa o aceite do termo) e uma do painel. + expect(backend.myDataCallCount, 2); expect(find.text('Fulano de Tal'), findsOneWidget); expect(find.textContaining('10/03/1975'), findsOneWidget); expect(find.textContaining('Ciclana, (11) 90000-0000'), findsOneWidget); diff --git a/apps/patient/test/support/fake_patient_backend.dart b/apps/patient/test/support/fake_patient_backend.dart index 62233bb9..f9353a1d 100644 --- a/apps/patient/test/support/fake_patient_backend.dart +++ b/apps/patient/test/support/fake_patient_backend.dart @@ -49,7 +49,14 @@ class FakePatientBackend implements PatientBackend { emergencyContact: 'Contato de teste', isChronic: false, chronicConditions: const [], - consents: const [], + consents: [ + PatientConsentRecord( + purpose: 'termsOfUse', + action: 'granted', + version: '2026.1', + timestamp: DateTime.utc(2026, 9, 1), + ), + ], riskHistory: const [], requests: const [], ); @@ -61,6 +68,26 @@ class FakePatientBackend implements PatientBackend { <({ConsentPurpose purpose, bool granted})>[]; BackendFailure? updateConsentFailure; + /// 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; + } + int requestDataDeletionCount = 0; final List correctionRequests = []; BackendFailure? dataRequestFailure; diff --git a/apps/patient/test/terms_acceptance_test.dart b/apps/patient/test/terms_acceptance_test.dart new file mode 100644 index 00000000..de63d416 --- /dev/null +++ b/apps/patient/test/terms_acceptance_test.dart @@ -0,0 +1,47 @@ +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, + ); + }); +} 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..2ee00b43 --- /dev/null +++ b/apps/patient/test/terms_gate_flow_test.dart @@ -0,0 +1,121 @@ +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() { + 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); + }); +} From a1ce53ae6b991ca336ec86a7a7055e2e6e704d00 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 29 Sep 2026 10:01:26 -0400 Subject: [PATCH 049/498] =?UTF-8?q?docs:=20sess=C3=A3o=20do=20onboarding?= =?UTF-8?q?=20j=C3=A1=20=C3=A9=20de=201h;=20registra=20o=20aceite=20do=20t?= =?UTF-8?q?ermo=20no=20login=20OTP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- PROGRESS.md | 14 ++++++++++++++ apps/CLAUDE.md | 2 +- backend/CLAUDE.md | 2 +- .../lib/src/endpoints/auth_endpoint.dart | 8 ++++---- .../lib/src/endpoints/onboarding_endpoint.dart | 5 +++-- spec/lgpd_design.md | 2 +- 6 files changed, 24 insertions(+), 9 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index ff935669..4ba72d56 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -988,6 +988,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 @@ -1109,3 +1111,15 @@ Plano: `docs/superpowers/plans/2026-09-29-qr-onboarding-e-documentos-legais.md`, - 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 lê `myData()`; 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 `myData()` 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 e revisão jurídica do texto seguem pendentes. +- `acceptTermsOfUse` grava uma linha nova a cada chamada, sem checar se já havia aceite da versão vigente; o app só chama quando `needsTermsAcceptance`. +- Contagens de teste depois desta entrega: backend 333, paciente 179, ACS 168. diff --git a/apps/CLAUDE.md b/apps/CLAUDE.md index 5563b5f3..64dbd428 100644 --- a/apps/CLAUDE.md +++ b/apps/CLAUDE.md @@ -32,7 +32,7 @@ The RPC trusts a development CA that is **separate** from the broker's: `scripts 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). -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 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 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. 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`. diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 4af7f9ef..6565cf81 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -65,7 +65,7 @@ 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 +**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 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), `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 ú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 diff --git a/backend/sinalacs_server/lib/src/endpoints/auth_endpoint.dart b/backend/sinalacs_server/lib/src/endpoints/auth_endpoint.dart index dc4cd2d0..b127969f 100644 --- a/backend/sinalacs_server/lib/src/endpoints/auth_endpoint.dart +++ b/backend/sinalacs_server/lib/src/endpoints/auth_endpoint.dart @@ -152,10 +152,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/onboarding_endpoint.dart b/backend/sinalacs_server/lib/src/endpoints/onboarding_endpoint.dart index e526d4ac..1e2efbe2 100644 --- a/backend/sinalacs_server/lib/src/endpoints/onboarding_endpoint.dart +++ b/backend/sinalacs_server/lib/src/endpoints/onboarding_endpoint.dart @@ -63,8 +63,9 @@ class OnboardingEndpoint extends Endpoint { // 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/spec/lgpd_design.md b/spec/lgpd_design.md index 34142ef2..1e967b2c 100644 --- a/spec/lgpd_design.md +++ b/spec/lgpd_design.md @@ -258,7 +258,7 @@ como um item separado a lembrar depois. | **Conteúdo Mínimo** | Regras de uso, responsabilidades, proibições, propriedade intelectual, limitação de responsabilidade, jurisdição, alterações no termo. | | **Critério de Aceite** | ✓ Texto em linguagem simples (acessibilidade para idosos e baixo letramento)
✓ Disponibilizado no app e no site
✓ Aceite explícito no cadastro
✓ Controle de versões disponível para consulta | -> **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. +> **Estado (2026-09-29):** implementado no app paciente — ver PROGRESS.md "QR Code do onboarding e documentos legais". Pacientes que entram por OTP sem onboarding são convidados a aceitar no primeiro login (`patients.acceptTermsOfUse`), e de novo quando a versão mudar; o convite não é obrigatório para acessar o alerta de emergência. Pendentes: revisão jurídica do texto e aviso com 15 dias de antecedência. ### LGPD-RF19 - Política de Privacidade From faaf1266ef2bf829163f1348eb512435c00714e1 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 29 Sep 2026 10:08:23 -0400 Subject: [PATCH 050/498] =?UTF-8?q?fix(paciente):=20checagem=20do=20aceite?= =?UTF-8?q?=20com=20teto=20de=203=20s,=20texto=20sem=20tom=20de=20obrigato?= =?UTF-8?q?riedade=20e=20sa=C3=ADda=20=C3=BAnica?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Achados da revisão final do aceite do termo no login OTP. Co-Authored-By: Claude Sonnet 5.5 --- PROGRESS.md | 2 +- apps/patient/.flutter-plugins-dependencies | 2 +- apps/patient/lib/app/app.dart | 19 ++++++---- apps/patient/lib/app/legal_screens.dart | 19 +++++++--- .../test/support/fake_patient_backend.dart | 5 +++ apps/patient/test/terms_gate_flow_test.dart | 36 +++++++++++++++++++ 6 files changed, 70 insertions(+), 13 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 4ba72d56..0a06659d 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1122,4 +1122,4 @@ Plano: `docs/superpowers/plans/2026-09-29-aceite-do-termo-no-login-otp.md`, bran - O aceite **não é portão duro**: "Agora não" e uma falha de `myData()` 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 e revisão jurídica do texto seguem pendentes. - `acceptTermsOfUse` grava uma linha nova a cada chamada, sem checar se já havia aceite da versão vigente; o app só chama quando `needsTermsAcceptance`. -- Contagens de teste depois desta entrega: backend 333, paciente 179, ACS 168. +- Contagens de teste depois desta entrega: backend 333, paciente 182, ACS 168. diff --git a/apps/patient/.flutter-plugins-dependencies b/apps/patient/.flutter-plugins-dependencies index fd6a93f8..9999b2db 100644 --- a/apps/patient/.flutter-plugins-dependencies +++ b/apps/patient/.flutter-plugins-dependencies @@ -1 +1 @@ -{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"connectivity_plus","path":"/home/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 +{"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":"mobile_scanner","path":"/home/rock/.pub-cache/hosted/pub.dev/mobile_scanner-7.4.2/","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_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":"mobile_scanner","path":"/home/rock/.pub-cache/hosted/pub.dev/mobile_scanner-7.4.2/","native_build":true,"dependencies":[],"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_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":"mobile_scanner","path":"/home/rock/.pub-cache/hosted/pub.dev/mobile_scanner-7.4.2/","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},{"name":"mobile_scanner","path":"/home/rock/.pub-cache/hosted/pub.dev/mobile_scanner-7.4.2/","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":"mobile_scanner","dependencies":[]},{"name":"sqflite","dependencies":["sqflite_android","sqflite_darwin"]},{"name":"sqflite_android","dependencies":[]},{"name":"sqflite_darwin","dependencies":[]},{"name":"sqflite_sqlcipher","dependencies":[]}],"date_created":"2026-09-29 09:11:43.681258","version":"3.44.8","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file diff --git a/apps/patient/lib/app/app.dart b/apps/patient/lib/app/app.dart index b1c628a5..e5a78ca7 100644 --- a/apps/patient/lib/app/app.dart +++ b/apps/patient/lib/app/app.dart @@ -240,6 +240,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(); @@ -369,10 +372,14 @@ class _PatientLoginScreenState extends State { /// o alerta de emergência não espera por um aceite. Future _needsTerms() async { try { - final overview = await BackendScope.of(context).myData(); + // 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 overview = await BackendScope.of(context).myData().timeout(_termsCheckTimeout); return needsTermsAcceptance(overview.consents); } on BackendFailure { return false; + } on TimeoutException { + return false; } } @@ -400,17 +407,17 @@ class _PatientLoginScreenState extends State { if (!mounted) return; final needsTerms = await _needsTerms(); if (!mounted) return; - final home = MaterialPageRoute( - builder: (_) => const PatientHomeShell(initialDestination: PatientDestination.triage), - ); + MaterialPageRoute home() => MaterialPageRoute( + builder: (_) => const PatientHomeShell(initialDestination: PatientDestination.triage), + ); Navigator.of(context).pushReplacement( needsTerms ? MaterialPageRoute( builder: (routeContext) => TermsAcceptanceScreen( - onContinue: () => Navigator.of(routeContext).pushReplacement(home), + onContinue: () => Navigator.of(routeContext).pushReplacement(home()), ), ) - : home, + : home(), ); } on BackendFailure catch (failure) { if (!mounted) return; diff --git a/apps/patient/lib/app/legal_screens.dart b/apps/patient/lib/app/legal_screens.dart index 4ef88876..e8bd603f 100644 --- a/apps/patient/lib/app/legal_screens.dart +++ b/apps/patient/lib/app/legal_screens.dart @@ -159,8 +159,17 @@ class TermsAcceptanceScreen extends StatefulWidget { 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; @@ -169,7 +178,7 @@ class _TermsAcceptanceScreenState extends State { try { await BackendScope.of(context).acceptTermsOfUse(); if (!mounted) return; - widget.onContinue(); + _continue(); } on BackendFailure catch (failure) { if (!mounted) return; setState(() { @@ -188,9 +197,9 @@ class _TermsAcceptanceScreenState extends State { 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.', + '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( @@ -228,7 +237,7 @@ class _TermsAcceptanceScreenState extends State { const SizedBox(height: 8), TextButton( key: const Key('terms_gate_later_button'), - onPressed: _busy ? null : widget.onContinue, + onPressed: _busy ? null : _continue, style: TextButton.styleFrom(minimumSize: const Size(48, 52)), child: const Text('Agora não'), ), diff --git a/apps/patient/test/support/fake_patient_backend.dart b/apps/patient/test/support/fake_patient_backend.dart index f9353a1d..fa7c31ad 100644 --- a/apps/patient/test/support/fake_patient_backend.dart +++ b/apps/patient/test/support/fake_patient_backend.dart @@ -61,6 +61,10 @@ class FakePatientBackend implements PatientBackend { requests: const [], ); BackendFailure? myDataFailure; + + /// Quando definido, [myData] só responde depois que ele completa — simula + /// um backend lento ou pendurado. + Completer? myDataGate; int myDataCallCount = 0; /// Chamadas a [updateConsent], na ordem. @@ -321,6 +325,7 @@ class FakePatientBackend implements PatientBackend { @override Future myData() async { myDataCallCount++; + await myDataGate?.future; final failure = myDataFailure; if (failure != null) throw failure; return myDataResult; diff --git a/apps/patient/test/terms_gate_flow_test.dart b/apps/patient/test/terms_gate_flow_test.dart index 2ee00b43..5c795ae8 100644 --- a/apps/patient/test/terms_gate_flow_test.dart +++ b/apps/patient/test/terms_gate_flow_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:sinalacs_patient/app/app.dart'; @@ -118,4 +120,38 @@ void main() { 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()..myDataGate = 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); + }); } From 0d66a71921eb941d2324f5b83c26e97149f9a88c Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 29 Sep 2026 10:21:31 -0400 Subject: [PATCH 051/498] =?UTF-8?q?docs:=20plano=20de=20fechamento=20das?= =?UTF-8?q?=20pend=C3=AAncias=20do=20app=20paciente?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- ...29-fechamento-de-pendencias-do-paciente.md | 921 ++++++++++++++++++ 1 file changed, 921 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-29-fechamento-de-pendencias-do-paciente.md 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 " +``` From 356f18bac32a200b101cc3444b7b7c2e4d90d7a4 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 29 Sep 2026 10:39:50 -0400 Subject: [PATCH 052/498] =?UTF-8?q?feat(backend):=20status=20do=20aceite?= =?UTF-8?q?=20sem=20ler=20o=20painel,=20aceite=20idempotente,=20exclus?= =?UTF-8?q?=C3=A3o=20serializada=20(LGPD-RF08/RF18)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../lib/src/protocol/client.dart | 12 ++ .../patients/data_subject_rights_service.dart | 54 +++++- .../lib/src/endpoints/patients_endpoint.dart | 20 ++ .../lib/src/generated/endpoints.dart | 19 ++ .../lib/src/generated/protocol.yaml | 1 + .../orm_data_subject_rights_store.dart | 79 ++++++-- .../data_subject_rights_endpoint_test.dart | 171 ++++++++++++++++++ .../test_tools/serverpod_test_tools.dart | 31 ++++ .../data_subject_rights_service_test.dart | 124 ++++++++++++- 9 files changed, 478 insertions(+), 33 deletions(-) diff --git a/backend/sinalacs_client/lib/src/protocol/client.dart b/backend/sinalacs_client/lib/src/protocol/client.dart index 08ff6309..6a2bb148 100644 --- a/backend/sinalacs_client/lib/src/protocol/client.dart +++ b/backend/sinalacs_client/lib/src/protocol/client.dart @@ -374,6 +374,18 @@ class EndpointPatients extends EndpointAuthenticated { {'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}, + ); + /// Pedido de exclusão/anonimização dos próprios dados (LGPD-RF08). /// Idempotente enquanto houver um pedido de exclusão em aberto. _i2.Future<_i14.PatientDataSubjectRequestRecord> requestDataDeletion({ 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 index ec1b707c..21961dff 100644 --- 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 @@ -13,13 +13,19 @@ import 'package:sinalacs_server/src/generated/protocol.dart'; abstract interface class DataSubjectRightsStore { /// Grava uma linha nova, assinada, em `consent_logs` — nunca edita uma /// anterior (append-only, LGPD-RF04). - Future recordConsent(ConsentLogEntry entry); + Future recordConsent(ConsentLogEntry entry); - /// O pedido em aberto mais recente daquele tipo, ou `null`. - Future findOpenRequest( - String userId, - DataSubjectRequestType type, - ); + /// 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, @@ -89,16 +95,32 @@ class DataSubjectRightsService { /// que o termo não vire uma chave liga/desliga no painel. Future acceptTermsOfUse(AuthenticatedUser user) async { _requirePatient(user); + final latest = await _store.latestConsent(user.id, ConsentPurpose.termsOfUse); + // 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 (_isCurrentAcceptance(latest)) return latest!; return _record(user, purpose: ConsentPurpose.termsOfUse, action: 'granted'); } + 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)); + } + Future _record( AuthenticatedUser user, { required ConsentPurpose purpose, required String action, }) async { final now = _clock().toUtc(); - await _store.recordConsent(ConsentLogEntry( + final id = await _store.recordConsent(ConsentLogEntry( userId: user.id, purpose: purpose, action: action, @@ -109,6 +131,7 @@ class DataSubjectRightsService { userId: user.id, actionType: 'write', resourceType: 'consent_log', + resourceId: id, result: 'granted', )); @@ -125,9 +148,20 @@ class DataSubjectRightsService { /// 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); + 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: diff --git a/backend/sinalacs_server/lib/src/endpoints/patients_endpoint.dart b/backend/sinalacs_server/lib/src/endpoints/patients_endpoint.dart index e323e58f..5a79f4f0 100644 --- a/backend/sinalacs_server/lib/src/endpoints/patients_endpoint.dart +++ b/backend/sinalacs_server/lib/src/endpoints/patients_endpoint.dart @@ -152,6 +152,26 @@ class PatientsEndpoint extends AuthenticatedEndpoint { } } + /// 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); + } + } + /// Pedido de exclusão/anonimização dos próprios dados (LGPD-RF08). /// Idempotente enquanto houver um pedido de exclusão em aberto. Future requestDataDeletion( diff --git a/backend/sinalacs_server/lib/src/generated/endpoints.dart b/backend/sinalacs_server/lib/src/generated/endpoints.dart index 615b900b..1c0b16b8 100644 --- a/backend/sinalacs_server/lib/src/generated/endpoints.dart +++ b/backend/sinalacs_server/lib/src/generated/endpoints.dart @@ -492,6 +492,25 @@ class Endpoints extends _i1.EndpointDispatch { 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 _i6.PatientsEndpoint) + .hasAcceptedCurrentTerms( + session, + accessToken: params['accessToken'], + ), + ), 'requestDataDeletion': _i1.MethodConnector( name: 'requestDataDeletion', params: { diff --git a/backend/sinalacs_server/lib/src/generated/protocol.yaml b/backend/sinalacs_server/lib/src/generated/protocol.yaml index bd17d4d0..f24e70ef 100644 --- a/backend/sinalacs_server/lib/src/generated/protocol.yaml +++ b/backend/sinalacs_server/lib/src/generated/protocol.yaml @@ -19,6 +19,7 @@ patients: - myData: - updateConsent: - acceptTermsOfUse: + - hasAcceptedCurrentTerms: - requestDataDeletion: - requestDataCorrection: triage: 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 index 99f4e441..393282ba 100644 --- 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 @@ -4,7 +4,7 @@ import 'package:sinalacs_server/src/application/onboarding/onboarding_service.da 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; + 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'; @@ -44,28 +44,79 @@ class OrmDataSubjectRightsStore implements DataSubjectRightsStore { final HealthDataCipher _cipher; @override - Future recordConsent(ConsentLogEntry entry) async { - await ConsentLog.db.insertRow( + Future recordConsent(ConsentLogEntry entry) async { + final row = await ConsentLog.db.insertRow( _session(), signedConsentLog(entry, signature: _signature, origin: 'painel-titular'), ); + return row.id!.uuid; } @override - Future findOpenRequest( - String userId, - DataSubjectRequestType type, - ) async { - final row = await DataSubjectRequest.db.findFirstRow( + Future latestConsent(String userId, ConsentPurpose purpose) async { + final row = await ConsentLog.db.findFirstRow( _session(), - where: (t) => - t.userId.equals(UuidValue.fromString(userId)) & - t.requestType.equals(type) & - t.status.equals(DataSubjectRequestStatus.open), - orderBy: (t) => t.createdAt, + where: (t) => t.userId.equals(UuidValue.fromString(userId)) & t.purpose.equals(purpose.name), + orderBy: (t) => t.timestamp, orderDescending: true, ); - return row == null ? null : dataSubjectRequestSnapshotOf(row, _cipher); + 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: 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 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 index f2abe5cf..cd2f768e 100644 --- a/backend/sinalacs_server/test/integration/data_subject_rights_endpoint_test.dart +++ b/backend/sinalacs_server/test/integration/data_subject_rights_endpoint_test.dart @@ -2,7 +2,10 @@ 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 consentPolicyVersion; import 'package:sinalacs_server/src/config/app_config.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/database/orm_data_subject_rights_store.dart'; import 'package:sinalacs_server/src/runtime/alert_runtime.dart'; import 'package:test/test.dart'; @@ -97,6 +100,81 @@ Future _seed(Session session) async { ); } +// 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 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())); @@ -186,6 +264,39 @@ void main() { expect(await ConsentLog.db.count(session), 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('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); + }); + test('updateConsent recusa a finalidade obrigatória sem gravar nada', () async { final session = sessionBuilder.build(); await _seed(session); @@ -291,4 +402,64 @@ void main() { 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); + } + }); + }, + rollbackDatabase: RollbackDatabase.disabled, + ); } 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 42883128..0fe5c513 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 @@ -808,6 +808,37 @@ class _PatientsEndpoint { }); } + _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<_i15.PatientDataSubjectRequestRecord> requestDataDeletion( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, 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 index 7a18a52a..cd153877 100644 --- a/backend/sinalacs_server/test/unit/data_subject_rights_service_test.dart +++ b/backend/sinalacs_server/test/unit/data_subject_rights_service_test.dart @@ -31,21 +31,49 @@ class FakeDataSubjectRightsStore implements DataSubjectRightsStore { var _nextId = 1; @override - Future recordConsent(ConsentLogEntry entry) async => consents.add(entry); + 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 findOpenRequest( - String userId, - DataSubjectRequestType type, - ) async { + 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 == type && + r.snapshot.type == DataSubjectRequestType.deletion && r.snapshot.status == DataSubjectRequestStatus.open) { - return r.snapshot; + return (request: r.snapshot, created: false); } } - return null; + final created = await createRequest( + userId: userId, + type: DataSubjectRequestType.deletion, + details: null, + createdAt: createdAt, + dueAt: dueAt, + ); + return (request: created, created: true); } @override @@ -113,9 +141,84 @@ void main() { 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('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, @@ -214,7 +317,10 @@ void main() { expect(second.id, first.id); expect(store.requests, hasLength(1)); - expect(audit.events, 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 { From 7d9bc60021baab996c78c5e1be0484a3b2813865 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 29 Sep 2026 10:44:32 -0400 Subject: [PATCH 053/498] =?UTF-8?q?fix(paciente):=20login=20consulta=20s?= =?UTF-8?q?=C3=B3=20o=20status=20do=20aceite;=20toque=20duplo=20na=20c?= =?UTF-8?q?=C3=A2mera,=20aviso=20do=20QR=20e=20texto=20de=20corre=C3=A7?= =?UTF-8?q?=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- apps/patient/lib/app/app.dart | 53 ++++++++++++++----- .../lib/core/legal/terms_acceptance.dart | 21 -------- .../lib/core/network/backend_client.dart | 14 +++++ apps/patient/test/onboarding_flow_test.dart | 41 ++++++++++++++ apps/patient/test/patient_app_mvp_test.dart | 45 ++++++++++++---- .../test/support/fake_patient_backend.dart | 38 +++++++------ apps/patient/test/terms_acceptance_test.dart | 47 ---------------- apps/patient/test/terms_gate_flow_test.dart | 40 +++++++++++--- 8 files changed, 185 insertions(+), 114 deletions(-) delete mode 100644 apps/patient/lib/core/legal/terms_acceptance.dart delete mode 100644 apps/patient/test/terms_acceptance_test.dart diff --git a/apps/patient/lib/app/app.dart b/apps/patient/lib/app/app.dart index e5a78ca7..09730d5d 100644 --- a/apps/patient/lib/app/app.dart +++ b/apps/patient/lib/app/app.dart @@ -20,7 +20,6 @@ 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_acceptance.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'; @@ -374,8 +373,9 @@ class _PatientLoginScreenState extends State { 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 overview = await BackendScope.of(context).myData().timeout(_termsCheckTimeout); - return needsTermsAcceptance(overview.consents); + final accepted = + await BackendScope.of(context).hasAcceptedCurrentTerms().timeout(_termsCheckTimeout); + return !accepted; } on BackendFailure { return false; } on TimeoutException { @@ -666,6 +666,7 @@ class _OnboardingScreenState extends State { bool _termsAccepted = false; bool _busy = false; + bool _scanning = false; String? _error; @override @@ -675,6 +676,16 @@ class _OnboardingScreenState extends State { } 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 { @@ -797,7 +808,7 @@ class _OnboardingScreenState extends State { width: double.infinity, child: OutlinedButton.icon( key: const Key('scan_qr_button'), - onPressed: _busy ? null : _scan, + 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'), @@ -807,7 +818,7 @@ class _OnboardingScreenState extends State { 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), @@ -1640,6 +1651,9 @@ 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; @@ -1760,7 +1774,8 @@ class _MyDataScreenState extends State { /// 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( + /// `true` quando o servidor aceitou o pedido. + Future _submitRequest( Future Function(PatientBackend backend) call, String done, ) async { @@ -1772,12 +1787,14 @@ class _MyDataScreenState extends State { }); try { final record = await call(backend); - if (!mounted) return; + if (!mounted) return true; setState(() => _confirmation = '$done. Resposta até ${_formatDate(record.dueAt.toLocal())}.'); await _load(); + return true; } on BackendFailure catch (failure) { - if (!mounted) return; + if (!mounted) return false; setState(() => _error = failure.message); + return false; } finally { if (mounted) setState(() => _busy = false); } @@ -1817,13 +1834,21 @@ class _MyDataScreenState extends State { if (_busy) return; final details = await showDialog( context: context, - builder: (_) => const _CorrectionRequestDialog(), + builder: (_) => _CorrectionRequestDialog(initialText: _pendingCorrection), ); - if (details == null || !mounted) return; - await _submitRequest( + 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) { @@ -2080,14 +2105,16 @@ class _MyDataScreenState extends State { /// 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(); + const _CorrectionRequestDialog({this.initialText}); + + final String? initialText; @override State<_CorrectionRequestDialog> createState() => _CorrectionRequestDialogState(); } class _CorrectionRequestDialogState extends State<_CorrectionRequestDialog> { - final _controller = TextEditingController(); + late final _controller = TextEditingController(text: widget.initialText); @override void dispose() { diff --git a/apps/patient/lib/core/legal/terms_acceptance.dart b/apps/patient/lib/core/legal/terms_acceptance.dart deleted file mode 100644 index d5368ea8..00000000 --- a/apps/patient/lib/core/legal/terms_acceptance.dart +++ /dev/null @@ -1,21 +0,0 @@ -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; -} diff --git a/apps/patient/lib/core/network/backend_client.dart b/apps/patient/lib/core/network/backend_client.dart index 775b7dc8..039eb87e 100644 --- a/apps/patient/lib/core/network/backend_client.dart +++ b/apps/patient/lib/core/network/backend_client.dart @@ -137,6 +137,11 @@ abstract class PatientBackend { /// linha `termsOfUse` `granted` em `consent_logs`. Future acceptTermsOfUse(); + /// 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(); + /// Pede a exclusão dos próprios dados (LGPD-RF08). Pedir de novo com um /// pedido aberto devolve o mesmo. Future requestDataDeletion(); @@ -244,6 +249,9 @@ class MisconfiguredBackend implements PatientBackend { @override Future acceptTermsOfUse() async => _recusar(); + @override + Future hasAcceptedCurrentTerms() async => _recusar(); + @override Future requestDataDeletion() async => _recusar(); @@ -533,6 +541,12 @@ class BackendClient implements PatientBackend { return _guard(() => _client.patients.acceptTermsOfUse(accessToken: token)); } + @override + Future hasAcceptedCurrentTerms() async { + final token = await _requireToken(); + return _guard(() => _client.patients.hasAcceptedCurrentTerms(accessToken: token)); + } + @override Future requestDataDeletion() async { final token = await _requireToken(); diff --git a/apps/patient/test/onboarding_flow_test.dart b/apps/patient/test/onboarding_flow_test.dart index ad173034..c4084b37 100644 --- a/apps/patient/test/onboarding_flow_test.dart +++ b/apps/patient/test/onboarding_flow_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:sinalacs_patient/app/app.dart'; @@ -282,4 +284,43 @@ void main() { 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 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 fd9afc06..4dcd4d3b 100644 --- a/apps/patient/test/patient_app_mvp_test.dart +++ b/apps/patient/test/patient_app_mvp_test.dart @@ -194,14 +194,6 @@ Future openMyData(WidgetTester tester) async { tester.view.physicalSize = const Size(800, 2400); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); - // Os testes de "Meus dados" montam o próprio `myDataResult`, em geral sem - // aceite do termo: o login cai na tela de aceite. O assunto aqui é o painel, - // então o paciente toca "Agora não", como faria de verdade. - final later = find.byKey(const Key('terms_gate_later_button')); - if (later.evaluate().isNotEmpty) { - await tester.tap(later); - await tester.pumpAndSettle(); - } await tester.tap(find.text('Mais')); await tester.pumpAndSettle(); await tester.tap(find.text('Meus dados')); @@ -1198,6 +1190,40 @@ void main() { 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('os botões de pedido não criam nó de botão inerte', (tester) async { final handle = tester.ensureSemantics(); final backend = FakePatientBackend()..myDataResult = overview(); @@ -1215,8 +1241,7 @@ void main() { await login(tester); await openMyData(tester); - // Uma leitura do login (checa o aceite do termo) e uma do painel. - expect(backend.myDataCallCount, 2); + expect(backend.myDataCallCount, 1); expect(find.text('Fulano de Tal'), findsOneWidget); expect(find.textContaining('10/03/1975'), findsOneWidget); expect(find.textContaining('Ciclana, (11) 90000-0000'), findsOneWidget); diff --git a/apps/patient/test/support/fake_patient_backend.dart b/apps/patient/test/support/fake_patient_backend.dart index fa7c31ad..52bfc727 100644 --- a/apps/patient/test/support/fake_patient_backend.dart +++ b/apps/patient/test/support/fake_patient_backend.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:sinalacs_client/sinalacs_client.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,22 +50,11 @@ class FakePatientBackend implements PatientBackend { emergencyContact: 'Contato de teste', isChronic: false, chronicConditions: const [], - consents: [ - PatientConsentRecord( - purpose: 'termsOfUse', - action: 'granted', - version: '2026.1', - timestamp: DateTime.utc(2026, 9, 1), - ), - ], + consents: const [], riskHistory: const [], requests: const [], ); BackendFailure? myDataFailure; - - /// Quando definido, [myData] só responde depois que ele completa — simula - /// um backend lento ou pendurado. - Completer? myDataGate; int myDataCallCount = 0; /// Chamadas a [updateConsent], na ordem. @@ -72,6 +62,24 @@ class FakePatientBackend implements PatientBackend { <({ConsentPurpose purpose, bool granted})>[]; BackendFailure? updateConsentFailure; + /// 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; + } + /// Chamadas a [acceptTermsOfUse]. int acceptTermsCalls = 0; BackendFailure? acceptTermsFailure; @@ -85,9 +93,10 @@ class FakePatientBackend implements PatientBackend { final record = PatientConsentRecord( purpose: 'termsOfUse', action: 'granted', - version: '2026.1', + version: legalDocumentsVersion, timestamp: DateTime.now().toUtc(), ); + termsAccepted = true; myDataResult = myDataResult.copyWith(consents: [...myDataResult.consents, record]); return record; } @@ -325,7 +334,6 @@ class FakePatientBackend implements PatientBackend { @override Future myData() async { myDataCallCount++; - await myDataGate?.future; final failure = myDataFailure; if (failure != null) throw failure; return myDataResult; @@ -344,7 +352,7 @@ class FakePatientBackend implements PatientBackend { final record = PatientConsentRecord( purpose: purpose.name, action: granted ? 'granted' : 'denied', - version: '2026.1', + version: legalDocumentsVersion, timestamp: DateTime.now().toUtc(), ); myDataResult = myDataResult.copyWith(consents: [...myDataResult.consents, record]); diff --git a/apps/patient/test/terms_acceptance_test.dart b/apps/patient/test/terms_acceptance_test.dart deleted file mode 100644 index de63d416..00000000 --- a/apps/patient/test/terms_acceptance_test.dart +++ /dev/null @@ -1,47 +0,0 @@ -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, - ); - }); -} diff --git a/apps/patient/test/terms_gate_flow_test.dart b/apps/patient/test/terms_gate_flow_test.dart index 5c795ae8..2974ba85 100644 --- a/apps/patient/test/terms_gate_flow_test.dart +++ b/apps/patient/test/terms_gate_flow_test.dart @@ -34,11 +34,7 @@ Future tapKey(WidgetTester tester, String key) async { } /// 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; -} +FakePatientBackend semAceite() => FakePatientBackend()..termsAccepted = false; void main() { testWidgets('sem aceite registrado, o login leva à tela de aceite', (tester) async { @@ -82,12 +78,13 @@ void main() { await login(tester); expect(find.byKey(const Key('terms_gate_accept_button')), findsNothing); - expect(backend.myDataCallCount, 1); + 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()..myDataFailure = const BackendFailure('sem rede'); + final backend = semAceite()..termsStatusFailure = const BackendFailure('sem rede'); await tester.pumpWidget(SinalAcsApp(backend: backend)); await login(tester); @@ -124,7 +121,7 @@ void main() { 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()..myDataGate = Completer(); + final backend = semAceite()..termsStatusGate = Completer(); await tester.pumpWidget(SinalAcsApp(backend: backend)); await login(tester); await tester.pump(const Duration(seconds: 4)); @@ -154,4 +151,31 @@ void main() { 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); + }); } From e459147bff49158cbe90092d34579361584c0d03 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 29 Sep 2026 10:45:39 -0400 Subject: [PATCH 054/498] fix(acs): convite expirado some da tela e pede um novo Co-Authored-By: Claude Sonnet 5.5 --- apps/acs/lib/app/invite_screen.dart | 57 +++++++++++++++++++++++++-- apps/acs/test/invite_screen_test.dart | 45 +++++++++++++++++++++ apps/acs/test/support/fakes.dart | 5 ++- 3 files changed, 103 insertions(+), 4 deletions(-) diff --git a/apps/acs/lib/app/invite_screen.dart b/apps/acs/lib/app/invite_screen.dart index 658d3d29..4079136b 100644 --- a/apps/acs/lib/app/invite_screen.dart +++ b/apps/acs/lib/app/invite_screen.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:qr_flutter/qr_flutter.dart'; import 'package:sinalacs_acs/app/acs_theme.dart'; @@ -27,6 +29,8 @@ class _InviteScreenState extends State { String? _error; bool _loading = true; bool _generating = false; + Timer? _expiryTimer; + bool _expired = false; @override void initState() { @@ -36,6 +40,33 @@ class _InviteScreenState extends State { WidgetsBinding.instance.addPostFrameCallback((_) => _load()); } + @override + void dispose() { + _expiryTimer?.cancel(); + super.dispose(); + } + + 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); + }); + } + Future _load() async { setState(() { _loading = true; @@ -61,7 +92,10 @@ class _InviteScreenState extends State { 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; + if (_selected?.patientId != patient.patientId) { + _invite = null; + _clearExpiry(); + } _selected = patient; _error = null; }); @@ -83,7 +117,10 @@ class _InviteScreenState extends State { _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; + if (_selected?.patientId == patient.patientId) { + _invite = invite; + _watchExpiry(invite.expiresAt); + } }); } on BackendFailure catch (failure) { if (!mounted) return; @@ -91,6 +128,7 @@ class _InviteScreenState extends State { _generating = false; if (_selected?.patientId != patient.patientId) return; _invite = null; + _clearExpiry(); _error = failure.message; }); } @@ -174,7 +212,20 @@ class _InviteScreenState extends State { ), ), ), - if (invite != null && _selected != null) ...[ + 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( diff --git a/apps/acs/test/invite_screen_test.dart b/apps/acs/test/invite_screen_test.dart index 6c6f7eec..240127f5 100644 --- a/apps/acs/test/invite_screen_test.dart +++ b/apps/acs/test/invite_screen_test.dart @@ -259,4 +259,49 @@ void main() { 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'); + }); } diff --git a/apps/acs/test/support/fakes.dart b/apps/acs/test/support/fakes.dart index e3cd14bc..dfa4b0ba 100644 --- a/apps/acs/test/support/fakes.dart +++ b/apps/acs/test/support/fakes.dart @@ -151,6 +151,9 @@ class FakeAcsBackend implements AcsBackend { /// 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); @@ -159,7 +162,7 @@ class FakeAcsBackend implements AcsBackend { if (failure != null) throw failure; return EnrollmentTokenResult( token: 'convite-sintetico-${inviteCalls.length}', - expiresAt: DateTime.utc(2026, 9, 29, 10, 15), + expiresAt: DateTime.now().toUtc().add(inviteLifetime), ); } From a89f313304fadbd456259cf02f9ccc1707c55f5c Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 29 Sep 2026 10:46:15 -0400 Subject: [PATCH 055/498] =?UTF-8?q?docs:=20registra=20o=20fechamento=20das?= =?UTF-8?q?=20pend=C3=AAncias=20do=20app=20paciente?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- PROGRESS.md | 32 ++++++++++++++++++++++++++------ apps/CLAUDE.md | 2 +- backend/CLAUDE.md | 2 +- 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 0a06659d..a6f84fe6 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1089,10 +1089,11 @@ O que **não** foi feito, de propósito: `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. +- **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` é registrado mas não tem efeito**: não há projeto Firebase (RF14). A descrição na tela diz isso. @@ -1106,8 +1107,8 @@ Plano: `docs/superpowers/plans/2026-09-29-qr-onboarding-e-documentos-legais.md`, **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. +- 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. @@ -1123,3 +1124,22 @@ Plano: `docs/superpowers/plans/2026-09-29-aceite-do-termo-no-login-otp.md`, bran - Aviso de mudança com 15 dias de antecedência e revisão jurídica do texto seguem pendentes. - `acceptTermsOfUse` grava uma linha nova a cada chamada, sem checar se já havia aceite da versão vigente; o app só chama quando `needsTermsAcceptance`. - 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, push (RF14) e o aviso de 15 dias seguem pendentes. +- Contagens de teste depois desta entrega: backend 346, paciente 182, ACS 170. diff --git a/apps/CLAUDE.md b/apps/CLAUDE.md index 64dbd428..2b9fa2b1 100644 --- a/apps/CLAUDE.md +++ b/apps/CLAUDE.md @@ -32,7 +32,7 @@ The RPC trusts a development CA that is **separate** from the broker's: `scripts 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). -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 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. +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 `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. 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`. diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 6565cf81..0a1cfe17 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -65,7 +65,7 @@ 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 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), `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 +**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 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); a criação do pedido de exclusão (`patients.requestDataDeletion`) é serializada por `pg_advisory_xact_lock` por titular, `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 ú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 From ff407cd2a549829ba3ff4127595c4b611c28c1c1 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 29 Sep 2026 10:53:54 -0400 Subject: [PATCH 056/498] =?UTF-8?q?fix(acs):=20validade=20do=20convite=20m?= =?UTF-8?q?edida=20do=20recebimento,=20imune=20ao=20rel=C3=B3gio=20do=20ap?= =?UTF-8?q?arelho?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Achado da revisão final: com o relógio do aparelho 15 min ou mais à frente, o convite chegava já vencido e nenhum QR aparecia. Co-Authored-By: Claude Sonnet 5.5 --- PROGRESS.md | 2 +- apps/acs/lib/app/invite_screen.dart | 23 ++++++++++----- apps/acs/test/invite_screen_test.dart | 42 +++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 8 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index a6f84fe6..55146a3b 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1142,4 +1142,4 @@ Plano: `docs/superpowers/plans/2026-09-29-fechamento-de-pendencias-do-paciente.m - `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, push (RF14) e o aviso de 15 dias seguem pendentes. -- Contagens de teste depois desta entrega: backend 346, paciente 182, ACS 170. +- Contagens de teste depois desta entrega: backend 346, paciente 182, ACS 172. diff --git a/apps/acs/lib/app/invite_screen.dart b/apps/acs/lib/app/invite_screen.dart index 4079136b..232c1929 100644 --- a/apps/acs/lib/app/invite_screen.dart +++ b/apps/acs/lib/app/invite_screen.dart @@ -22,6 +22,9 @@ class InviteScreen extends StatefulWidget { State createState() => _InviteScreenState(); } +/// Validade de um convite, igual à do servidor. +const _inviteLifetime = Duration(minutes: 15); + class _InviteScreenState extends State { List? _patients; MicroAreaPatient? _selected; @@ -52,15 +55,21 @@ class _InviteScreenState extends State { _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. + /// 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(); - final remaining = expiresAt.difference(DateTime.now()); - if (remaining <= Duration.zero) { - _expired = true; - return; + var remaining = expiresAt.difference(DateTime.now()); + if (remaining <= Duration.zero || remaining > _inviteLifetime) { + remaining = _inviteLifetime; } _expiryTimer = Timer(remaining, () { if (mounted) setState(() => _expired = true); diff --git a/apps/acs/test/invite_screen_test.dart b/apps/acs/test/invite_screen_test.dart index 240127f5..55a8339e 100644 --- a/apps/acs/test/invite_screen_test.dart +++ b/apps/acs/test/invite_screen_test.dart @@ -304,4 +304,46 @@ void main() { 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); + }); } From cd18f95ee2c36af23005e9e7cc0d23288fa939bd Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 29 Sep 2026 15:28:05 -0400 Subject: [PATCH 057/498] =?UTF-8?q?fix(backend):=20aceite=20do=20termo=20a?= =?UTF-8?q?t=C3=B4mico=20e=20advisory=20locks=20de=20duas=20chaves=20(LGPD?= =?UTF-8?q?-RF18)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../lib/core/network/backend_client.dart | 3 +- .../patients/data_subject_rights_service.dart | 35 +++++++++++-- .../lib/src/endpoints/patients_endpoint.dart | 3 +- .../orm_data_subject_rights_store.dart | 43 ++++++++++++++-- .../infrastructure/database/subject_lock.dart | 23 +++++++++ .../data_subject_rights_endpoint_test.dart | 49 ++++++++++++++++++- .../data_subject_rights_service_test.dart | 22 +++++++++ 7 files changed, 166 insertions(+), 12 deletions(-) create mode 100644 backend/sinalacs_server/lib/src/infrastructure/database/subject_lock.dart diff --git a/apps/patient/lib/core/network/backend_client.dart b/apps/patient/lib/core/network/backend_client.dart index 039eb87e..cc25ba66 100644 --- a/apps/patient/lib/core/network/backend_client.dart +++ b/apps/patient/lib/core/network/backend_client.dart @@ -134,7 +134,8 @@ abstract class PatientBackend { /// 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`. + /// linha `termsOfUse` `granted` em `consent_logs` só se a versão vigente ainda + /// não foi aceita; repetir devolve a existente. Future acceptTermsOfUse(); /// Se o paciente já aceitou o Termo de Uso e a Política de Privacidade da 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 index 21961dff..91c9b884 100644 --- 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 @@ -15,6 +15,14 @@ abstract interface class DataSubjectRightsStore { /// anterior (append-only, LGPD-RF04). Future recordConsent(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); @@ -95,11 +103,24 @@ class DataSubjectRightsService { /// que o termo não vire uma chave liga/desliga no painel. Future acceptTermsOfUse(AuthenticatedUser user) async { _requirePatient(user); - final latest = await _store.latestConsent(user.id, ConsentPurpose.termsOfUse); + 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 (_isCurrentAcceptance(latest)) return latest!; - return _record(user, purpose: ConsentPurpose.termsOfUse, action: 'granted'); + if (result.existing != null) return result.existing!; + await _auditConsent(user, result.id!); + return ConsentRecordSnapshot( + purpose: ConsentPurpose.termsOfUse.name, + action: 'granted', + version: consentPolicyVersion, + timestamp: now, + ); } bool _isCurrentAcceptance(ConsentRecordSnapshot? latest) => @@ -143,6 +164,14 @@ class DataSubjectRightsService { ); } + 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. diff --git a/backend/sinalacs_server/lib/src/endpoints/patients_endpoint.dart b/backend/sinalacs_server/lib/src/endpoints/patients_endpoint.dart index 5a79f4f0..5a03c220 100644 --- a/backend/sinalacs_server/lib/src/endpoints/patients_endpoint.dart +++ b/backend/sinalacs_server/lib/src/endpoints/patients_endpoint.dart @@ -130,7 +130,8 @@ class PatientsEndpoint extends AuthenticatedEndpoint { /// 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`. + /// 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, 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 index 393282ba..92e1c2da 100644 --- 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 @@ -9,6 +9,7 @@ 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'; +import 'package:sinalacs_server/src/infrastructure/database/subject_lock.dart'; /// Um pedido lido do banco, com `details` decifrado. Compartilhado com /// `OrmPatientDataOverviewStore`, que lista os mesmos pedidos em "Meus Dados". @@ -52,6 +53,42 @@ class OrmDataSubjectRightsStore implements DataSubjectRightsStore { 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}'); + 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); + }); + } + @override Future latestConsent(String userId, ConsentPurpose purpose) async { final row = await ConsentLog.db.findFirstRow( @@ -84,11 +121,7 @@ class OrmDataSubjectRightsStore implements DataSubjectRightsStore { 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, - ); + await lockPerSubject(session, transaction, namespace: lockNamespaceDeletion, key: userId); final open = await DataSubjectRequest.db.findFirstRow( session, where: (t) => 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..8466efa4 --- /dev/null +++ b/backend/sinalacs_server/lib/src/infrastructure/database/subject_lock.dart @@ -0,0 +1,23 @@ +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; +const int lockNamespacePushToken = 3; + +/// 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/test/integration/data_subject_rights_endpoint_test.dart b/backend/sinalacs_server/test/integration/data_subject_rights_endpoint_test.dart index cd2f768e..e0eced2a 100644 --- a/backend/sinalacs_server/test/integration/data_subject_rights_endpoint_test.dart +++ b/backend/sinalacs_server/test/integration/data_subject_rights_endpoint_test.dart @@ -1,9 +1,9 @@ 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 consentPolicyVersion; +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 DataSubjectRequestSnapshot; + 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'; @@ -153,6 +153,10 @@ Future _seedRace(Session session) async { /// 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)), @@ -459,6 +463,47 @@ void main() { 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/unit/data_subject_rights_service_test.dart b/backend/sinalacs_server/test/unit/data_subject_rights_service_test.dart index cd153877..7bdafe10 100644 --- a/backend/sinalacs_server/test/unit/data_subject_rights_service_test.dart +++ b/backend/sinalacs_server/test/unit/data_subject_rights_service_test.dart @@ -29,6 +29,19 @@ 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); + } @override Future recordConsent(ConsentLogEntry entry) async { @@ -136,6 +149,15 @@ void main() { 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); From 0c825c247614d999c28e31515ef7d61c2227dbd7 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 29 Sep 2026 15:32:05 -0400 Subject: [PATCH 058/498] feat(backend): registro de token de push condicionado ao consentimento (RF14) Co-Authored-By: Claude Sonnet 5.5 --- .../lib/src/protocol/client.dart | 32 +- .../lib/src/protocol/protocol.dart | 128 +- .../lib/src/protocol/push_token.dart | 160 + .../patients/data_subject_rights_service.dart | 12 +- .../patients/push_token_service.dart | 71 + .../lib/src/endpoints/devices_endpoint.dart | 26 + .../lib/src/generated/endpoints.dart | 102 +- .../lib/src/generated/protocol.dart | 255 +- .../lib/src/generated/protocol.yaml | 2 + .../lib/src/generated/push_token.dart | 615 +++ .../database/orm_push_token_store.dart | 81 + .../lib/src/models/push_token.spy.yaml | 22 + .../lib/src/runtime/alert_runtime.dart | 7 + .../20260929192823862/definition.json | 3416 +++++++++++++++++ .../20260929192823862/definition.sql | 714 ++++ .../20260929192823862/definition_project.json | 2080 ++++++++++ .../20260929192823862/migration.json | 131 + .../20260929192823862/migration.sql | 48 + .../migrations/migration_registry.txt | 1 + .../integration/push_token_endpoint_test.dart | 202 + .../test_tools/serverpod_test_tools.dart | 54 + .../data_subject_rights_service_test.dart | 42 + .../test/unit/push_token_service_test.dart | 102 + 23 files changed, 8148 insertions(+), 155 deletions(-) create mode 100644 backend/sinalacs_client/lib/src/protocol/push_token.dart create mode 100644 backend/sinalacs_server/lib/src/application/patients/push_token_service.dart create mode 100644 backend/sinalacs_server/lib/src/endpoints/devices_endpoint.dart create mode 100644 backend/sinalacs_server/lib/src/generated/push_token.dart create mode 100644 backend/sinalacs_server/lib/src/infrastructure/database/orm_push_token_store.dart create mode 100644 backend/sinalacs_server/lib/src/models/push_token.spy.yaml create mode 100644 backend/sinalacs_server/migrations/20260929192823862/definition.json create mode 100644 backend/sinalacs_server/migrations/20260929192823862/definition.sql create mode 100644 backend/sinalacs_server/migrations/20260929192823862/definition_project.json create mode 100644 backend/sinalacs_server/migrations/20260929192823862/migration.json create mode 100644 backend/sinalacs_server/migrations/20260929192823862/migration.sql create mode 100644 backend/sinalacs_server/test/integration/push_token_endpoint_test.dart create mode 100644 backend/sinalacs_server/test/unit/push_token_service_test.dart diff --git a/backend/sinalacs_client/lib/src/protocol/client.dart b/backend/sinalacs_client/lib/src/protocol/client.dart index 6a2bb148..34837aee 100644 --- a/backend/sinalacs_client/lib/src/protocol/client.dart +++ b/backend/sinalacs_client/lib/src/protocol/client.dart @@ -213,6 +213,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, @@ -365,7 +390,8 @@ class EndpointPatients extends EndpointAuthenticated { /// 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`. + /// 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<_i12.PatientConsentRecord> acceptTermsOfUse({ required String accessToken, }) => caller.callServerEndpoint<_i12.PatientConsentRecord>( @@ -527,6 +553,7 @@ class Client extends _i1.ServerpodClientShared { ) { alerts = EndpointAlerts(this); auth = EndpointAuth(this); + devices = EndpointDevices(this); health = EndpointHealth(this); onboarding = EndpointOnboarding(this); patients = EndpointPatients(this); @@ -538,6 +565,8 @@ class Client extends _i1.ServerpodClientShared { late final EndpointAuth auth; + late final EndpointDevices devices; + late final EndpointHealth health; late final EndpointOnboarding onboarding; @@ -552,6 +581,7 @@ class Client extends _i1.ServerpodClientShared { Map get endpointRefLookup => { 'alerts': alerts, 'auth': auth, + 'devices': devices, 'health': health, 'onboarding': onboarding, 'patients': patients, diff --git a/backend/sinalacs_client/lib/src/protocol/protocol.dart b/backend/sinalacs_client/lib/src/protocol/protocol.dart index 40bf5de5..cbe9a77d 100644 --- a/backend/sinalacs_client/lib/src/protocol/protocol.dart +++ b/backend/sinalacs_client/lib/src/protocol/protocol.dart @@ -55,17 +55,18 @@ import 'exceptions/otp_request_exception.dart' as _i41; import 'micro_area.dart' as _i42; import 'otp_challenge.dart' as _i43; import 'patient.dart' as _i44; -import 'triage_answer.dart' as _i45; -import 'triage_session.dart' as _i46; -import 'ubs.dart' as _i47; -import 'user.dart' as _i48; -import 'user_credential.dart' as _i49; -import 'visit.dart' as _i50; +import 'push_token.dart' as _i45; +import 'triage_answer.dart' as _i46; +import 'triage_session.dart' as _i47; +import 'ubs.dart' as _i48; +import 'user.dart' as _i49; +import 'user_credential.dart' as _i50; +import 'visit.dart' as _i51; import 'package:sinalacs_client/src/protocol/api/micro_area_patient.dart' - as _i51; -import 'package:sinalacs_client/src/protocol/api/visit_sync_result.dart' as _i52; -import 'package:sinalacs_client/src/protocol/api/visit_sync_entry.dart' as _i53; +import 'package:sinalacs_client/src/protocol/api/visit_sync_result.dart' + as _i53; +import 'package:sinalacs_client/src/protocol/api/visit_sync_entry.dart' as _i54; export 'acs.dart'; export 'alert.dart'; export 'alert_delivery_record.dart'; @@ -109,6 +110,7 @@ export 'exceptions/otp_request_exception.dart'; export 'micro_area.dart'; export 'otp_challenge.dart'; export 'patient.dart'; +export 'push_token.dart'; export 'triage_answer.dart'; export 'triage_session.dart'; export 'ubs.dart'; @@ -280,23 +282,26 @@ class Protocol extends _i1.SerializationManager { if (t == _i44.Patient) { return _i44.Patient.fromJson(data) as T; } - if (t == _i45.TriageAnswer) { - return _i45.TriageAnswer.fromJson(data) as T; + if (t == _i45.PushToken) { + return _i45.PushToken.fromJson(data) as T; + } + if (t == _i46.TriageAnswer) { + return _i46.TriageAnswer.fromJson(data) as T; } - if (t == _i46.TriageSession) { - return _i46.TriageSession.fromJson(data) as T; + if (t == _i47.TriageSession) { + return _i47.TriageSession.fromJson(data) as T; } - if (t == _i47.Ubs) { - return _i47.Ubs.fromJson(data) as T; + if (t == _i48.Ubs) { + return _i48.Ubs.fromJson(data) as T; } - if (t == _i48.User) { - return _i48.User.fromJson(data) as T; + if (t == _i49.User) { + return _i49.User.fromJson(data) as T; } - if (t == _i49.UserCredential) { - return _i49.UserCredential.fromJson(data) as T; + if (t == _i50.UserCredential) { + return _i50.UserCredential.fromJson(data) as T; } - if (t == _i50.Visit) { - return _i50.Visit.fromJson(data) as T; + if (t == _i51.Visit) { + return _i51.Visit.fromJson(data) as T; } if (t == _i1.getType<_i2.Acs?>()) { return (data != null ? _i2.Acs.fromJson(data) : null) as T; @@ -459,23 +464,26 @@ class Protocol extends _i1.SerializationManager { if (t == _i1.getType<_i44.Patient?>()) { return (data != null ? _i44.Patient.fromJson(data) : null) as T; } - if (t == _i1.getType<_i45.TriageAnswer?>()) { - return (data != null ? _i45.TriageAnswer.fromJson(data) : null) as T; + if (t == _i1.getType<_i45.PushToken?>()) { + return (data != null ? _i45.PushToken.fromJson(data) : null) as T; } - if (t == _i1.getType<_i46.TriageSession?>()) { - return (data != null ? _i46.TriageSession.fromJson(data) : null) as T; + if (t == _i1.getType<_i46.TriageAnswer?>()) { + return (data != null ? _i46.TriageAnswer.fromJson(data) : null) as T; } - if (t == _i1.getType<_i47.Ubs?>()) { - return (data != null ? _i47.Ubs.fromJson(data) : null) as T; + if (t == _i1.getType<_i47.TriageSession?>()) { + return (data != null ? _i47.TriageSession.fromJson(data) : null) as T; } - if (t == _i1.getType<_i48.User?>()) { - return (data != null ? _i48.User.fromJson(data) : null) as T; + if (t == _i1.getType<_i48.Ubs?>()) { + return (data != null ? _i48.Ubs.fromJson(data) : null) as T; } - if (t == _i1.getType<_i49.UserCredential?>()) { - return (data != null ? _i49.UserCredential.fromJson(data) : null) as T; + if (t == _i1.getType<_i49.User?>()) { + return (data != null ? _i49.User.fromJson(data) : null) as T; } - if (t == _i1.getType<_i50.Visit?>()) { - return (data != null ? _i50.Visit.fromJson(data) : null) as T; + if (t == _i1.getType<_i50.UserCredential?>()) { + return (data != null ? _i50.UserCredential.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i51.Visit?>()) { + return (data != null ? _i51.Visit.fromJson(data) : null) as T; } if (t == List) { return (data as List).map((e) => deserialize(e)).toList() as T; @@ -504,24 +512,24 @@ class Protocol extends _i1.SerializationManager { ) as T; } - if (t == List<_i51.MicroAreaPatient>) { + if (t == List<_i52.MicroAreaPatient>) { return (data as List) - .map((e) => deserialize<_i51.MicroAreaPatient>(e)) + .map((e) => deserialize<_i52.MicroAreaPatient>(e)) .toList() as T; } if (t == List) { return (data as List).map((e) => deserialize(e)).toList() as T; } - if (t == List<_i52.VisitSyncResult>) { + if (t == List<_i53.VisitSyncResult>) { return (data as List) - .map((e) => deserialize<_i52.VisitSyncResult>(e)) + .map((e) => deserialize<_i53.VisitSyncResult>(e)) .toList() as T; } - if (t == List<_i53.VisitSyncEntry>) { + if (t == List<_i54.VisitSyncEntry>) { return (data as List) - .map((e) => deserialize<_i53.VisitSyncEntry>(e)) + .map((e) => deserialize<_i54.VisitSyncEntry>(e)) .toList() as T; } @@ -574,12 +582,13 @@ class Protocol extends _i1.SerializationManager { _i42.MicroArea => 'MicroArea', _i43.OtpChallenge => 'OtpChallenge', _i44.Patient => 'Patient', - _i45.TriageAnswer => 'TriageAnswer', - _i46.TriageSession => 'TriageSession', - _i47.Ubs => 'Ubs', - _i48.User => 'User', - _i49.UserCredential => 'UserCredential', - _i50.Visit => 'Visit', + _i45.PushToken => 'PushToken', + _i46.TriageAnswer => 'TriageAnswer', + _i47.TriageSession => 'TriageSession', + _i48.Ubs => 'Ubs', + _i49.User => 'User', + _i50.UserCredential => 'UserCredential', + _i51.Visit => 'Visit', _ => null, }; } @@ -680,17 +689,19 @@ class Protocol extends _i1.SerializationManager { return 'OtpChallenge'; case _i44.Patient(): return 'Patient'; - case _i45.TriageAnswer(): + case _i45.PushToken(): + return 'PushToken'; + case _i46.TriageAnswer(): return 'TriageAnswer'; - case _i46.TriageSession(): + case _i47.TriageSession(): return 'TriageSession'; - case _i47.Ubs(): + case _i48.Ubs(): return 'Ubs'; - case _i48.User(): + case _i49.User(): return 'User'; - case _i49.UserCredential(): + case _i50.UserCredential(): return 'UserCredential'; - case _i50.Visit(): + case _i51.Visit(): return 'Visit'; } return null; @@ -831,23 +842,26 @@ class Protocol extends _i1.SerializationManager { if (dataClassName == 'Patient') { return deserialize<_i44.Patient>(data['data']); } + if (dataClassName == 'PushToken') { + return deserialize<_i45.PushToken>(data['data']); + } if (dataClassName == 'TriageAnswer') { - return deserialize<_i45.TriageAnswer>(data['data']); + return deserialize<_i46.TriageAnswer>(data['data']); } if (dataClassName == 'TriageSession') { - return deserialize<_i46.TriageSession>(data['data']); + return deserialize<_i47.TriageSession>(data['data']); } if (dataClassName == 'Ubs') { - return deserialize<_i47.Ubs>(data['data']); + return deserialize<_i48.Ubs>(data['data']); } if (dataClassName == 'User') { - return deserialize<_i48.User>(data['data']); + return deserialize<_i49.User>(data['data']); } if (dataClassName == 'UserCredential') { - return deserialize<_i49.UserCredential>(data['data']); + return deserialize<_i50.UserCredential>(data['data']); } if (dataClassName == 'Visit') { - return deserialize<_i50.Visit>(data['data']); + return deserialize<_i51.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_server/lib/src/application/patients/data_subject_rights_service.dart b/backend/sinalacs_server/lib/src/application/patients/data_subject_rights_service.dart index 91c9b884..f16f2e61 100644 --- 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 @@ -5,6 +5,7 @@ import 'package:sinalacs_server/src/application/onboarding/onboarding_service.da 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/push_token_service.dart' show PushTokenStore; import 'package:sinalacs_server/src/generated/protocol.dart'; /// Persistência das operações do titular sobre os próprios dados. Interface @@ -61,13 +62,16 @@ class DataSubjectRightsService { DataSubjectRightsService({ required DataSubjectRightsStore store, required AuditTrail audit, + PushTokenStore? pushTokens, DateTime Function()? clock, }) : _store = store, _audit = audit, + _pushTokens = pushTokens, _clock = clock ?? DateTime.now; final DataSubjectRightsStore _store; final AuditTrail _audit; + final PushTokenStore? _pushTokens; final DateTime Function() _clock; /// Concede ou revoga uma finalidade opcional. `healthDataProcessing` é @@ -93,7 +97,13 @@ class DataSubjectRightsService { ); } - return _record(user, purpose: purpose, action: granted ? 'granted' : 'denied'); + final record = await _record(user, purpose: purpose, action: granted ? 'granted' : 'denied'); + // Sem consentimento, sem token: o aparelho deixa de estar ligado ao titular + // no mesmo instante da revogação (RF14). + if (purpose == ConsentPurpose.segmentedPush && !granted) { + await _pushTokens?.deleteAllFor(user.id); + } + return record; } /// Aceite explícito do Termo de Uso e da Política de Privacidade por quem 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..8036ac66 --- /dev/null +++ b/backend/sinalacs_server/lib/src/application/patients/push_token_service.dart @@ -0,0 +1,71 @@ +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 { + /// `true` quando a linha mais recente de `segmentedPush` do titular é `granted`. + Future hasGrantedConsent(String userId); + + /// Grava o token; se ele já existe, muda o dono e renova `updatedAt`. + Future upsert({ + required String userId, + required String? microAreaId, + required String token, + required String platform, + required DateTime now, + }); + + /// Apaga todos os tokens do titular e devolve quantos eram. + Future deleteAllFor(String userId); +} + +/// 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, 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(), + ); + } +} 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/generated/endpoints.dart b/backend/sinalacs_server/lib/src/generated/endpoints.dart index 1c0b16b8..b8fe8999 100644 --- a/backend/sinalacs_server/lib/src/generated/endpoints.dart +++ b/backend/sinalacs_server/lib/src/generated/endpoints.dart @@ -14,15 +14,16 @@ 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 '../endpoints/devices_endpoint.dart' as _i4; +import '../endpoints/health_endpoint.dart' as _i5; +import '../endpoints/onboarding_endpoint.dart' as _i6; +import '../endpoints/patients_endpoint.dart' as _i7; +import '../endpoints/triage_endpoint.dart' as _i8; +import '../endpoints/visits_endpoint.dart' as _i9; import 'package:sinalacs_server/src/generated/enums/consent_purpose.dart' - as _i9; -import 'package:sinalacs_server/src/generated/api/visit_sync_entry.dart' as _i10; +import 'package:sinalacs_server/src/generated/api/visit_sync_entry.dart' + as _i11; class Endpoints extends _i1.EndpointDispatch { @override @@ -40,31 +41,37 @@ class Endpoints extends _i1.EndpointDispatch { 'auth', null, ), - 'health': _i4.HealthEndpoint() + 'devices': _i4.DevicesEndpoint() + ..initialize( + server, + 'devices', + null, + ), + 'health': _i5.HealthEndpoint() ..initialize( server, 'health', null, ), - 'onboarding': _i5.OnboardingEndpoint() + 'onboarding': _i6.OnboardingEndpoint() ..initialize( server, 'onboarding', null, ), - 'patients': _i6.PatientsEndpoint() + 'patients': _i7.PatientsEndpoint() ..initialize( server, 'patients', null, ), - 'triage': _i7.TriageEndpoint() + 'triage': _i8.TriageEndpoint() ..initialize( server, 'triage', null, ), - 'visits': _i8.VisitsEndpoint() + 'visits': _i9.VisitsEndpoint() ..initialize( server, 'visits', @@ -267,6 +274,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 _i4.DevicesEndpoint) + .registerPushToken( + session, + accessToken: params['accessToken'], + token: params['token'], + platform: params['platform'], + ), + ), + }, + ); connectors['health'] = _i1.EndpointConnector( name: 'health', endpoint: endpoints['health']!, @@ -279,7 +323,7 @@ class Endpoints extends _i1.EndpointDispatch { _i1.Session session, Map params, ) async => - (endpoints['health'] as _i4.HealthEndpoint).check(session), + (endpoints['health'] as _i5.HealthEndpoint).check(session), ), }, ); @@ -305,7 +349,7 @@ class Endpoints extends _i1.EndpointDispatch { ( _i1.Session session, Map params, - ) async => (endpoints['onboarding'] as _i5.OnboardingEndpoint) + ) async => (endpoints['onboarding'] as _i6.OnboardingEndpoint) .generateEnrollmentToken( session, accessToken: params['accessToken'], @@ -345,7 +389,7 @@ class Endpoints extends _i1.EndpointDispatch { ( _i1.Session session, Map params, - ) async => (endpoints['onboarding'] as _i5.OnboardingEndpoint) + ) async => (endpoints['onboarding'] as _i6.OnboardingEndpoint) .completeEnrollment( session, token: params['token'], @@ -375,7 +419,7 @@ class Endpoints extends _i1.EndpointDispatch { _i1.Session session, Map params, ) async => - (endpoints['patients'] as _i6.PatientsEndpoint).listMicroArea( + (endpoints['patients'] as _i7.PatientsEndpoint).listMicroArea( session, accessToken: params['accessToken'], ), @@ -393,7 +437,7 @@ class Endpoints extends _i1.EndpointDispatch { ( _i1.Session session, Map params, - ) async => (endpoints['patients'] as _i6.PatientsEndpoint) + ) async => (endpoints['patients'] as _i7.PatientsEndpoint) .myChronicConditions( session, accessToken: params['accessToken'], @@ -417,7 +461,7 @@ class Endpoints extends _i1.EndpointDispatch { ( _i1.Session session, Map params, - ) async => (endpoints['patients'] as _i6.PatientsEndpoint) + ) async => (endpoints['patients'] as _i7.PatientsEndpoint) .updateChronicConditions( session, accessToken: params['accessToken'], @@ -437,7 +481,7 @@ class Endpoints extends _i1.EndpointDispatch { ( _i1.Session session, Map params, - ) async => (endpoints['patients'] as _i6.PatientsEndpoint).myData( + ) async => (endpoints['patients'] as _i7.PatientsEndpoint).myData( session, accessToken: params['accessToken'], ), @@ -452,7 +496,7 @@ class Endpoints extends _i1.EndpointDispatch { ), 'purpose': _i1.ParameterDescription( name: 'purpose', - type: _i1.getType<_i9.ConsentPurpose>(), + type: _i1.getType<_i10.ConsentPurpose>(), nullable: false, ), 'granted': _i1.ParameterDescription( @@ -466,7 +510,7 @@ class Endpoints extends _i1.EndpointDispatch { _i1.Session session, Map params, ) async => - (endpoints['patients'] as _i6.PatientsEndpoint).updateConsent( + (endpoints['patients'] as _i7.PatientsEndpoint).updateConsent( session, accessToken: params['accessToken'], purpose: params['purpose'], @@ -486,7 +530,7 @@ class Endpoints extends _i1.EndpointDispatch { ( _i1.Session session, Map params, - ) async => (endpoints['patients'] as _i6.PatientsEndpoint) + ) async => (endpoints['patients'] as _i7.PatientsEndpoint) .acceptTermsOfUse( session, accessToken: params['accessToken'], @@ -505,7 +549,7 @@ class Endpoints extends _i1.EndpointDispatch { ( _i1.Session session, Map params, - ) async => (endpoints['patients'] as _i6.PatientsEndpoint) + ) async => (endpoints['patients'] as _i7.PatientsEndpoint) .hasAcceptedCurrentTerms( session, accessToken: params['accessToken'], @@ -524,7 +568,7 @@ class Endpoints extends _i1.EndpointDispatch { ( _i1.Session session, Map params, - ) async => (endpoints['patients'] as _i6.PatientsEndpoint) + ) async => (endpoints['patients'] as _i7.PatientsEndpoint) .requestDataDeletion( session, accessToken: params['accessToken'], @@ -548,7 +592,7 @@ class Endpoints extends _i1.EndpointDispatch { ( _i1.Session session, Map params, - ) async => (endpoints['patients'] as _i6.PatientsEndpoint) + ) async => (endpoints['patients'] as _i7.PatientsEndpoint) .requestDataCorrection( session, accessToken: params['accessToken'], @@ -604,7 +648,7 @@ class Endpoints extends _i1.EndpointDispatch { ( _i1.Session session, Map params, - ) async => (endpoints['triage'] as _i7.TriageEndpoint).evaluate( + ) async => (endpoints['triage'] as _i8.TriageEndpoint).evaluate( session, accessToken: params['accessToken'], chestPain: params['chestPain'], @@ -631,7 +675,7 @@ class Endpoints extends _i1.EndpointDispatch { ), 'visits': _i1.ParameterDescription( name: 'visits', - type: _i1.getType>(), + type: _i1.getType>(), nullable: false, ), }, @@ -639,7 +683,7 @@ class Endpoints extends _i1.EndpointDispatch { ( _i1.Session session, Map params, - ) async => (endpoints['visits'] as _i8.VisitsEndpoint).sync( + ) async => (endpoints['visits'] as _i9.VisitsEndpoint).sync( session, accessToken: params['accessToken'], visits: params['visits'], @@ -663,7 +707,7 @@ class Endpoints extends _i1.EndpointDispatch { ( _i1.Session session, Map params, - ) async => (endpoints['visits'] as _i8.VisitsEndpoint).pull( + ) async => (endpoints['visits'] as _i9.VisitsEndpoint).pull( session, accessToken: params['accessToken'], since: params['since'], diff --git a/backend/sinalacs_server/lib/src/generated/protocol.dart b/backend/sinalacs_server/lib/src/generated/protocol.dart index 4e1d5bec..d4b59e33 100644 --- a/backend/sinalacs_server/lib/src/generated/protocol.dart +++ b/backend/sinalacs_server/lib/src/generated/protocol.dart @@ -56,18 +56,19 @@ import 'exceptions/otp_request_exception.dart' as _i42; import 'micro_area.dart' as _i43; import 'otp_challenge.dart' as _i44; import 'patient.dart' as _i45; -import 'triage_answer.dart' as _i46; -import 'triage_session.dart' as _i47; -import 'ubs.dart' as _i48; -import 'user.dart' as _i49; -import 'user_credential.dart' as _i50; -import 'visit.dart' as _i51; +import 'push_token.dart' as _i46; +import 'triage_answer.dart' as _i47; +import 'triage_session.dart' as _i48; +import 'ubs.dart' as _i49; +import 'user.dart' as _i50; +import 'user_credential.dart' as _i51; +import 'visit.dart' as _i52; import 'package:sinalacs_server/src/generated/api/micro_area_patient.dart' - as _i52; -import 'package:sinalacs_server/src/generated/api/visit_sync_result.dart' as _i53; -import 'package:sinalacs_server/src/generated/api/visit_sync_entry.dart' +import 'package:sinalacs_server/src/generated/api/visit_sync_result.dart' as _i54; +import 'package:sinalacs_server/src/generated/api/visit_sync_entry.dart' + as _i55; export 'acs.dart'; export 'alert.dart'; export 'alert_delivery_record.dart'; @@ -111,6 +112,7 @@ export 'exceptions/otp_request_exception.dart'; export 'micro_area.dart'; export 'otp_challenge.dart'; export 'patient.dart'; +export 'push_token.dart'; export 'triage_answer.dart'; export 'triage_session.dart'; export 'ubs.dart'; @@ -1294,6 +1296,111 @@ class Protocol extends _i1.SerializationManagerServer { ], 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: 'triage_sessions', dartName: 'TriageSession', @@ -1971,23 +2078,26 @@ class Protocol extends _i1.SerializationManagerServer { if (t == _i45.Patient) { return _i45.Patient.fromJson(data) as T; } - if (t == _i46.TriageAnswer) { - return _i46.TriageAnswer.fromJson(data) as T; + if (t == _i46.PushToken) { + return _i46.PushToken.fromJson(data) as T; } - if (t == _i47.TriageSession) { - return _i47.TriageSession.fromJson(data) as T; + if (t == _i47.TriageAnswer) { + return _i47.TriageAnswer.fromJson(data) as T; } - if (t == _i48.Ubs) { - return _i48.Ubs.fromJson(data) as T; + if (t == _i48.TriageSession) { + return _i48.TriageSession.fromJson(data) as T; } - if (t == _i49.User) { - return _i49.User.fromJson(data) as T; + if (t == _i49.Ubs) { + return _i49.Ubs.fromJson(data) as T; } - if (t == _i50.UserCredential) { - return _i50.UserCredential.fromJson(data) as T; + if (t == _i50.User) { + return _i50.User.fromJson(data) as T; } - if (t == _i51.Visit) { - return _i51.Visit.fromJson(data) as T; + if (t == _i51.UserCredential) { + return _i51.UserCredential.fromJson(data) as T; + } + if (t == _i52.Visit) { + return _i52.Visit.fromJson(data) as T; } if (t == _i1.getType<_i3.Acs?>()) { return (data != null ? _i3.Acs.fromJson(data) : null) as T; @@ -2150,23 +2260,26 @@ class Protocol extends _i1.SerializationManagerServer { if (t == _i1.getType<_i45.Patient?>()) { return (data != null ? _i45.Patient.fromJson(data) : null) as T; } - if (t == _i1.getType<_i46.TriageAnswer?>()) { - return (data != null ? _i46.TriageAnswer.fromJson(data) : null) as T; + if (t == _i1.getType<_i46.PushToken?>()) { + return (data != null ? _i46.PushToken.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i47.TriageAnswer?>()) { + return (data != null ? _i47.TriageAnswer.fromJson(data) : null) as T; } - if (t == _i1.getType<_i47.TriageSession?>()) { - return (data != null ? _i47.TriageSession.fromJson(data) : null) as T; + if (t == _i1.getType<_i48.TriageSession?>()) { + return (data != null ? _i48.TriageSession.fromJson(data) : null) as T; } - if (t == _i1.getType<_i48.Ubs?>()) { - return (data != null ? _i48.Ubs.fromJson(data) : null) as T; + if (t == _i1.getType<_i49.Ubs?>()) { + return (data != null ? _i49.Ubs.fromJson(data) : null) as T; } - if (t == _i1.getType<_i49.User?>()) { - return (data != null ? _i49.User.fromJson(data) : null) as T; + if (t == _i1.getType<_i50.User?>()) { + return (data != null ? _i50.User.fromJson(data) : null) as T; } - if (t == _i1.getType<_i50.UserCredential?>()) { - return (data != null ? _i50.UserCredential.fromJson(data) : null) as T; + if (t == _i1.getType<_i51.UserCredential?>()) { + return (data != null ? _i51.UserCredential.fromJson(data) : null) as T; } - if (t == _i1.getType<_i51.Visit?>()) { - return (data != null ? _i51.Visit.fromJson(data) : null) as T; + if (t == _i1.getType<_i52.Visit?>()) { + return (data != null ? _i52.Visit.fromJson(data) : null) as T; } if (t == List) { return (data as List).map((e) => deserialize(e)).toList() as T; @@ -2195,24 +2308,24 @@ class Protocol extends _i1.SerializationManagerServer { ) as T; } - if (t == List<_i52.MicroAreaPatient>) { + if (t == List<_i53.MicroAreaPatient>) { return (data as List) - .map((e) => deserialize<_i52.MicroAreaPatient>(e)) + .map((e) => deserialize<_i53.MicroAreaPatient>(e)) .toList() as T; } if (t == List) { return (data as List).map((e) => deserialize(e)).toList() as T; } - if (t == List<_i53.VisitSyncResult>) { + if (t == List<_i54.VisitSyncResult>) { return (data as List) - .map((e) => deserialize<_i53.VisitSyncResult>(e)) + .map((e) => deserialize<_i54.VisitSyncResult>(e)) .toList() as T; } - if (t == List<_i54.VisitSyncEntry>) { + if (t == List<_i55.VisitSyncEntry>) { return (data as List) - .map((e) => deserialize<_i54.VisitSyncEntry>(e)) + .map((e) => deserialize<_i55.VisitSyncEntry>(e)) .toList() as T; } @@ -2268,12 +2381,13 @@ class Protocol extends _i1.SerializationManagerServer { _i43.MicroArea => 'MicroArea', _i44.OtpChallenge => 'OtpChallenge', _i45.Patient => 'Patient', - _i46.TriageAnswer => 'TriageAnswer', - _i47.TriageSession => 'TriageSession', - _i48.Ubs => 'Ubs', - _i49.User => 'User', - _i50.UserCredential => 'UserCredential', - _i51.Visit => 'Visit', + _i46.PushToken => 'PushToken', + _i47.TriageAnswer => 'TriageAnswer', + _i48.TriageSession => 'TriageSession', + _i49.Ubs => 'Ubs', + _i50.User => 'User', + _i51.UserCredential => 'UserCredential', + _i52.Visit => 'Visit', _ => null, }; } @@ -2374,17 +2488,19 @@ class Protocol extends _i1.SerializationManagerServer { return 'OtpChallenge'; case _i45.Patient(): return 'Patient'; - case _i46.TriageAnswer(): + case _i46.PushToken(): + return 'PushToken'; + case _i47.TriageAnswer(): return 'TriageAnswer'; - case _i47.TriageSession(): + case _i48.TriageSession(): return 'TriageSession'; - case _i48.Ubs(): + case _i49.Ubs(): return 'Ubs'; - case _i49.User(): + case _i50.User(): return 'User'; - case _i50.UserCredential(): + case _i51.UserCredential(): return 'UserCredential'; - case _i51.Visit(): + case _i52.Visit(): return 'Visit'; } className = _i2.Protocol().getClassNameForObject(data); @@ -2529,23 +2645,26 @@ class Protocol extends _i1.SerializationManagerServer { if (dataClassName == 'Patient') { return deserialize<_i45.Patient>(data['data']); } + if (dataClassName == 'PushToken') { + return deserialize<_i46.PushToken>(data['data']); + } if (dataClassName == 'TriageAnswer') { - return deserialize<_i46.TriageAnswer>(data['data']); + return deserialize<_i47.TriageAnswer>(data['data']); } if (dataClassName == 'TriageSession') { - return deserialize<_i47.TriageSession>(data['data']); + return deserialize<_i48.TriageSession>(data['data']); } if (dataClassName == 'Ubs') { - return deserialize<_i48.Ubs>(data['data']); + return deserialize<_i49.Ubs>(data['data']); } if (dataClassName == 'User') { - return deserialize<_i49.User>(data['data']); + return deserialize<_i50.User>(data['data']); } if (dataClassName == 'UserCredential') { - return deserialize<_i50.UserCredential>(data['data']); + return deserialize<_i51.UserCredential>(data['data']); } if (dataClassName == 'Visit') { - return deserialize<_i51.Visit>(data['data']); + return deserialize<_i52.Visit>(data['data']); } if (dataClassName.startsWith('serverpod.')) { data['className'] = dataClassName.substring(10); @@ -2587,16 +2706,18 @@ class Protocol extends _i1.SerializationManagerServer { return _i44.OtpChallenge.t; case _i45.Patient: return _i45.Patient.t; - case _i47.TriageSession: - return _i47.TriageSession.t; - case _i48.Ubs: - return _i48.Ubs.t; - case _i49.User: - return _i49.User.t; - case _i50.UserCredential: - return _i50.UserCredential.t; - case _i51.Visit: - return _i51.Visit.t; + case _i46.PushToken: + return _i46.PushToken.t; + case _i48.TriageSession: + return _i48.TriageSession.t; + case _i49.Ubs: + return _i49.Ubs.t; + case _i50.User: + return _i50.User.t; + case _i51.UserCredential: + return _i51.UserCredential.t; + case _i52.Visit: + return _i52.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 f24e70ef..20d7d2cb 100644 --- a/backend/sinalacs_server/lib/src/generated/protocol.yaml +++ b/backend/sinalacs_server/lib/src/generated/protocol.yaml @@ -7,6 +7,8 @@ auth: - loginInstitutional: - requestOtp: - verifyOtp: +devices: + - registerPushToken: health: - check: onboarding: 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/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..021d69d5 --- /dev/null +++ b/backend/sinalacs_server/lib/src/infrastructure/database/orm_push_token_store.dart @@ -0,0 +1,81 @@ +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; + + @override + Future hasGrantedConsent(String userId) async { + final row = await ConsentLog.db.findFirstRow( + _session(), + where: (t) => + t.userId.equals(UuidValue.fromString(userId)) & + t.purpose.equals(ConsentPurpose.segmentedPush.name), + orderBy: (t) => t.timestamp, + orderDescending: true, + ); + return row != null && row.action == 'granted'; + } + + /// Serializa por token com advisory lock: o índice único de `token` recusaria + /// o segundo inserto de uma corrida, e aqui o segundo deve virar atualização. + @override + Future upsert({ + 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); + await session.db.transaction((transaction) async { + await lockPerSubject(session, transaction, namespace: lockNamespacePushToken, key: token); + final existing = await PushToken.db.findFirstRow( + session, + where: (t) => t.token.equals(token), + transaction: transaction, + ); + if (existing != null) { + await PushToken.db.updateRow( + session, + existing.copyWith( + userId: userUuid, + microAreaId: areaUuid, + platform: platform, + updatedAt: now, + ), + transaction: transaction, + ); + return; + } + await PushToken.db.insertRow( + session, + PushToken( + userId: userUuid, + microAreaId: areaUuid, + token: token, + platform: platform, + createdAt: now, + updatedAt: now, + ), + transaction: transaction, + ); + }); + } + + @override + Future deleteAllFor(String userId) async { + final removed = await PushToken.db.deleteWhere( + _session(), + where: (t) => t.userId.equals(UuidValue.fromString(userId)), + ); + return removed.length; + } +} 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/runtime/alert_runtime.dart b/backend/sinalacs_server/lib/src/runtime/alert_runtime.dart index d1e4ff30..436aca9b 100644 --- a/backend/sinalacs_server/lib/src/runtime/alert_runtime.dart +++ b/backend/sinalacs_server/lib/src/runtime/alert_runtime.dart @@ -26,6 +26,8 @@ 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_push_token_store.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'; @@ -211,8 +213,13 @@ class AlertRuntime { cipher: healthDataCipher, ), audit: auditTrailFor(session), + pushTokens: OrmPushTokenStore(session: () => session), ); + /// Registro do aparelho para avisos segmentados (RF14). + PushTokenService pushTokenServiceFor(Session session) => + PushTokenService(store: OrmPushTokenStore(session: () => session)); + /// Constrói o serviço de triagem persistida para uma requisição. TriageSessionService triageSessionServiceFor(Session session) => TriageSessionService( 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/migration_registry.txt b/backend/sinalacs_server/migrations/migration_registry.txt index adca3612..64b206fe 100644 --- a/backend/sinalacs_server/migrations/migration_registry.txt +++ b/backend/sinalacs_server/migrations/migration_registry.txt @@ -14,3 +14,4 @@ 20260919001437559 20260919032710552 20260929005339393 +20260929192823862 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..0887a734 --- /dev/null +++ b/backend/sinalacs_server/test/integration/push_token_endpoint_test.dart @@ -0,0 +1,202 @@ +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, 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'; +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('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()), + ); + }); + }); +} 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 0fe5c513..eb0a1fff 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 @@ -158,6 +158,8 @@ class TestEndpoints { late final _AuthEndpoint auth; + late final _DevicesEndpoint devices; + late final _HealthEndpoint health; late final _OnboardingEndpoint onboarding; @@ -184,6 +186,10 @@ class _InternalTestEndpoints extends TestEndpoints endpoints, serializationManager, ); + devices = _DevicesEndpoint( + endpoints, + serializationManager, + ); health = _HealthEndpoint( endpoints, serializationManager, @@ -474,6 +480,54 @@ class _AuthEndpoint { } } +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, 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 index 7bdafe10..54a6227f 100644 --- a/backend/sinalacs_server/test/unit/data_subject_rights_service_test.dart +++ b/backend/sinalacs_server/test/unit/data_subject_rights_service_test.dart @@ -3,6 +3,7 @@ import 'package:sinalacs_server/src/application/auth/development_auth_service.da 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/push_token_service.dart'; import 'package:sinalacs_server/src/generated/protocol.dart'; import 'package:test/test.dart'; @@ -110,6 +111,28 @@ class FakeDataSubjectRightsStore implements DataSubjectRightsStore { } } +class FakePushTokenStore implements PushTokenStore { + var deleteCalls = []; + + @override + Future hasGrantedConsent(String userId) async => true; + + @override + Future upsert({ + required String userId, + required String? microAreaId, + required String token, + required String platform, + required DateTime now, + }) async {} + + @override + Future deleteAllFor(String userId) async { + deleteCalls.add(userId); + return 0; + } +} + class FakeAuditTrail extends AuditTrail { FakeAuditTrail({this.failOnRecord = false}); @@ -134,6 +157,25 @@ void main() { service = DataSubjectRightsService(store: store, audit: audit, clock: () => _now); }); + group('revogar segmentedPush apaga os tokens de push (RF14)', () { + test('só a revogação de segmentedPush chama deleteAllFor', () async { + final pushTokens = FakePushTokenStore(); + final svc = DataSubjectRightsService( + store: store, + audit: audit, + pushTokens: pushTokens, + clock: () => _now, + ); + + await svc.updateConsent(_patient, purpose: ConsentPurpose.segmentedPush, granted: true); + await svc.updateConsent(_patient, purpose: ConsentPurpose.localReminders, granted: false); + expect(pushTokens.deleteCalls, isEmpty); + + await svc.updateConsent(_patient, purpose: ConsentPurpose.segmentedPush, granted: false); + expect(pushTokens.deleteCalls, [_patientId]); + }); + }); + group('acceptTermsOfUse (LGPD-RF18)', () { test('grava "granted" para termsOfUse com a versão vigente e audita', () async { final record = await service.acceptTermsOfUse(_patient); 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..cb12fed1 --- /dev/null +++ b/backend/sinalacs_server/test/unit/push_token_service_test.dart @@ -0,0 +1,102 @@ +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; + var deleted = 0; + final rows = {}; + + @override + Future hasGrantedConsent(String userId) async => consent; + + @override + Future upsert({ + required String userId, + required String? microAreaId, + required String token, + required String platform, + required DateTime now, + }) async { + rows[token] = (userId: userId, platform: platform); + } + + @override + Future deleteAllFor(String userId) async { + deleted++; + rows.removeWhere((_, r) => r.userId == userId); + return 1; + } +} + +void main() { + late _FakePushTokenStore store; + late PushTokenService service; + + setUp(() { + store = _FakePushTokenStore(); + service = PushTokenService(store: store, 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()), + ); + }); +} From abf1f710554cefe6b2403b052eb86877a6610708 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 29 Sep 2026 15:33:21 -0400 Subject: [PATCH 059/498] =?UTF-8?q?fix(paciente):=20tocar=20fora=20do=20di?= =?UTF-8?q?=C3=A1logo=20de=20corre=C3=A7=C3=A3o=20n=C3=A3o=20apaga=20o=20r?= =?UTF-8?q?ascunho?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- apps/patient/lib/app/app.dart | 3 ++ apps/patient/test/onboarding_flow_test.dart | 20 ++++++++++++ apps/patient/test/patient_app_mvp_test.dart | 35 +++++++++++++++++++++ 3 files changed, 58 insertions(+) diff --git a/apps/patient/lib/app/app.dart b/apps/patient/lib/app/app.dart index 09730d5d..a495f4c2 100644 --- a/apps/patient/lib/app/app.dart +++ b/apps/patient/lib/app/app.dart @@ -1832,8 +1832,11 @@ class _MyDataScreenState extends State { 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) { diff --git a/apps/patient/test/onboarding_flow_test.dart b/apps/patient/test/onboarding_flow_test.dart index c4084b37..c3755823 100644 --- a/apps/patient/test/onboarding_flow_test.dart +++ b/apps/patient/test/onboarding_flow_test.dart @@ -309,6 +309,26 @@ void main() { 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(), diff --git a/apps/patient/test/patient_app_mvp_test.dart b/apps/patient/test/patient_app_mvp_test.dart index 4dcd4d3b..d28297b1 100644 --- a/apps/patient/test/patient_app_mvp_test.dart +++ b/apps/patient/test/patient_app_mvp_test.dart @@ -1224,6 +1224,41 @@ void main() { ); }); + 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(); From 61e2fe5f9039944f919f6ec3a3a56be1a4e605ca Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 29 Sep 2026 15:35:37 -0400 Subject: [PATCH 060/498] =?UTF-8?q?feat(paciente):=20registra=20o=20token?= =?UTF-8?q?=20de=20push=20do=20aparelho=20quando=20h=C3=A1=20consentimento?= =?UTF-8?q?=20(RF14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- apps/patient/lib/app/app.dart | 30 ++++++-- .../lib/core/network/backend_client.dart | 17 +++++ .../lib/core/push/push_token_source.dart | 60 +++++++++++++++ apps/patient/test/patient_app_mvp_test.dart | 34 +++++++++ apps/patient/test/push_registration_test.dart | 74 +++++++++++++++++++ .../test/support/fake_patient_backend.dart | 11 +++ 6 files changed, 219 insertions(+), 7 deletions(-) create mode 100644 apps/patient/lib/core/push/push_token_source.dart create mode 100644 apps/patient/test/push_registration_test.dart diff --git a/apps/patient/lib/app/app.dart b/apps/patient/lib/app/app.dart index a495f4c2..cff919f7 100644 --- a/apps/patient/lib/app/app.dart +++ b/apps/patient/lib/app/app.dart @@ -23,6 +23,7 @@ import 'package:sinalacs_patient/core/legal/legal_documents.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_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'; @@ -41,6 +42,7 @@ class SinalAcsApp extends StatefulWidget { this.reminderScheduler, this.consentPreferences, this.qrScanner, + this.pushTokens, }); /// Backend do app. **Obrigatório, e construído em `main.dart`.** @@ -76,6 +78,11 @@ class SinalAcsApp extends StatefulWidget { /// 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; + @override State createState() => _SinalAcsAppState(); } @@ -89,6 +96,7 @@ class _SinalAcsAppState extends State { late final ConsentPreferences _consentPreferences = widget.consentPreferences ?? SqfliteConsentPreferences(); late final QrScanner _qrScanner = widget.qrScanner ?? scanQrWithCamera; + late final PushTokenSource _pushTokens = widget.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 @@ -104,13 +112,16 @@ class _SinalAcsAppState extends State { store: _reminderStore, scheduler: _reminderScheduler, consentPreferences: _consentPreferences, - child: QrScannerScope( - scanner: _qrScanner, - child: MaterialApp( - title: 'SinalACS Paciente', - debugShowCheckedModeBanner: false, - theme: buildPatientTheme(), - home: const PatientLoginScreen(), + child: PushTokenScope( + source: _pushTokens, + child: QrScannerScope( + scanner: _qrScanner, + child: MaterialApp( + title: 'SinalACS Paciente', + debugShowCheckedModeBanner: false, + theme: buildPatientTheme(), + home: const PatientLoginScreen(), + ), ), ), ), @@ -405,6 +416,7 @@ 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.of(context))); final needsTerms = await _needsTerms(); if (!mounted) return; MaterialPageRoute home() => MaterialPageRoute( @@ -757,6 +769,7 @@ class _OnboardingScreenState extends State { // Intencionalmente silencioso — ver comentário acima. } if (!mounted) return; + unawaited(registerPushDevice(BackendScope.of(context), PushTokenScope.of(context))); // 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. @@ -1753,6 +1766,9 @@ class _MyDataScreenState extends State { }); try { final record = await backend.updateConsent(purpose: purpose, granted: granted); + if (purpose == ConsentPurpose.segmentedPush && granted && mounted) { + unawaited(registerPushDevice(backend, PushTokenScope.of(context))); + } // 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. diff --git a/apps/patient/lib/core/network/backend_client.dart b/apps/patient/lib/core/network/backend_client.dart index cc25ba66..637b0d64 100644 --- a/apps/patient/lib/core/network/backend_client.dart +++ b/apps/patient/lib/core/network/backend_client.dart @@ -138,6 +138,10 @@ abstract class PatientBackend { /// 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}); + /// 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. @@ -253,6 +257,10 @@ class MisconfiguredBackend implements PatientBackend { @override Future hasAcceptedCurrentTerms() async => _recusar(); + @override + Future registerPushToken({required String token, required String platform}) async => + _recusar(); + @override Future requestDataDeletion() async => _recusar(); @@ -542,6 +550,15 @@ class BackendClient implements PatientBackend { 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 hasAcceptedCurrentTerms() async { final token = await _requireToken(); 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..af321400 --- /dev/null +++ b/apps/patient/lib/core/push/push_token_source.dart @@ -0,0 +1,60 @@ +import 'package:flutter/widgets.dart'; +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 (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; +} + +/// 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. +Future registerPushDevice(PatientBackend backend, PushTokenSource source) async { + try { + 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; + } + + @override + bool updateShouldNotify(PushTokenScope oldWidget) => source != oldWidget.source; +} diff --git a/apps/patient/test/patient_app_mvp_test.dart b/apps/patient/test/patient_app_mvp_test.dart index d28297b1..f34e4ee2 100644 --- a/apps/patient/test/patient_app_mvp_test.dart +++ b/apps/patient/test/patient_app_mvp_test.dart @@ -22,6 +22,7 @@ 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/reminders/reminder.dart'; @@ -33,6 +34,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; @@ -1038,6 +1048,30 @@ void main() { 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 + + await tapSwitch(tester, ConsentPurpose.segmentedPush); + expect(backend.updateConsentCalls.last.granted, isTrue); + expect(backend.pushRegistrations, [('tok-9', 'ios')]); + + 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()); diff --git a/apps/patient/test/push_registration_test.dart b/apps/patient/test/push_registration_test.dart new file mode 100644 index 00000000..fbbe0b03 --- /dev/null +++ b/apps/patient/test/push_registration_test.dart @@ -0,0 +1,74 @@ +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(); + 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() + ..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); + }); +} + +class _ThrowingSource implements PushTokenSource { + @override + Future currentDevice() async => throw StateError('sem provedor'); +} diff --git a/apps/patient/test/support/fake_patient_backend.dart b/apps/patient/test/support/fake_patient_backend.dart index 52bfc727..b26b6075 100644 --- a/apps/patient/test/support/fake_patient_backend.dart +++ b/apps/patient/test/support/fake_patient_backend.dart @@ -62,6 +62,17 @@ class FakePatientBackend implements PatientBackend { <({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 que o servidor responderia a [hasAcceptedCurrentTerms]. bool termsAccepted = true; int termsStatusCalls = 0; From bace4e3cb0bab367d4317310b3cfd2d9ffc9fea7 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 29 Sep 2026 15:38:20 -0400 Subject: [PATCH 061/498] docs: registra os menores fechados e o registro de push do paciente Co-Authored-By: Claude Sonnet 5.5 --- CLAUDE.md | 2 +- PROGRESS.md | 24 ++++++++++++++++--- apps/CLAUDE.md | 2 +- backend/CLAUDE.md | 2 +- .../integration/push_token_endpoint_test.dart | 5 ---- spec/PRD_system.md | 2 +- spec/lgpd_data_audit.md | 6 +++++ 7 files changed, 31 insertions(+), 12 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5697ae5c..2c4d8ae7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 (17 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 (18 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. diff --git a/PROGRESS.md b/PROGRESS.md index 55146a3b..95f405a3 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1117,12 +1117,12 @@ Plano: `docs/superpowers/plans/2026-09-29-qr-onboarding-e-documentos-legais.md`, 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 lê `myData()`; 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. +**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 `myData()` 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. +- 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 e revisão jurídica do texto seguem pendentes. -- `acceptTermsOfUse` grava uma linha nova a cada chamada, sem checar se já havia aceite da versão vigente; o app só chama quando `needsTermsAcceptance`. +- ~~`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) @@ -1143,3 +1143,21 @@ Plano: `docs/superpowers/plans/2026-09-29-fechamento-de-pendencias-do-paciente.m - 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, push (RF14) e o aviso de 15 dias seguem pendentes. - 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. + +**Ficou de fora, de propósito:** +- Envio segmentado (`notices.sendSegmented`), tela de avisos do ACS e o SDK `firebase_messaging`: bloqueio externo do §3.2 (sem projeto Firebase). +- 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 e revisão jurídica do texto 2026.1. +- 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. diff --git a/apps/CLAUDE.md b/apps/CLAUDE.md index 2b9fa2b1..5c0199b9 100644 --- a/apps/CLAUDE.md +++ b/apps/CLAUDE.md @@ -32,7 +32,7 @@ The RPC trusts a development CA that is **separate** from the broker's: `scripts 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). -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 `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. +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 default `NoPushTokenSource` returns `null` until a Firebase project exists (RF14 §3.2), and a refusal or failure never blocks the home or the emergency alert. 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`. diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 0a1cfe17..2d5991f8 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -65,7 +65,7 @@ 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 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); a criação do pedido de exclusão (`patients.requestDataDeletion`) é serializada por `pg_advisory_xact_lock` por titular, `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 +**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 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); 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), `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), `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 ú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 diff --git a/backend/sinalacs_server/test/integration/push_token_endpoint_test.dart b/backend/sinalacs_server/test/integration/push_token_endpoint_test.dart index 0887a734..1b6791ee 100644 --- a/backend/sinalacs_server/test/integration/push_token_endpoint_test.dart +++ b/backend/sinalacs_server/test/integration/push_token_endpoint_test.dart @@ -1,11 +1,6 @@ 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'; diff --git a/spec/PRD_system.md b/spec/PRD_system.md index c9d81709..c47821d8 100644 --- a/spec/PRD_system.md +++ b/spec/PRD_system.md @@ -170,7 +170,7 @@ documento de decisão, não implementação: | RF10 (mapa) | Geocélula arredondada, não posição exata (§1) | Não | | RF02 + LGPD-RF02 (onboarding/consentimento) | Token de convite de uso único + consentimento por finalidade (§2) | Não | | RF06 (lembretes) | Local ao dispositivo, sem endpoint (§3.1) | Não | -| RF14 (avisos push) | Contrato FCM definido (§3.2) | **Sim** — sem projeto Firebase provisionado | +| RF14 (avisos push) | Contrato FCM definido (§3.2); lado do paciente pronto (`devices.registerPushToken`, `push_tokens`, `PushTokenSource`); envio e SDK pendentes | **Sim** — sem projeto Firebase provisionado | | RF12 (geofencing) | Geofence atrelado a visita ativa, sem rastreamento contínuo (§4) | Parcial — submissão à loja pendente | | RF15 (sync central→dispositivo) | Pull incremental por cursor (§5) | Não | | RNF03 (criptografia Postgres) | AES-256-GCM em nível de aplicação (§6) | Não | diff --git a/spec/lgpd_data_audit.md b/spec/lgpd_data_audit.md index 840210bb..3f6b9779 100644 --- a/spec/lgpd_data_audit.md +++ b/spec/lgpd_data_audit.md @@ -102,6 +102,12 @@ A tabela abaixo consolida o mapeamento exaustivo de dados persistidos pelo backe | | `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). | +| **push_tokens** | `id` | `uuid` | Pseudonimizado | UUID v4 (`gen_random_uuid()`) | Identificador da linha do token de push (RF14). | +| | `userId` | `uuid` | Pseudonimizado | Chave estrangeira (`users.id`) | Liga o aparelho ao titular; apagado quando o consentimento `segmentedPush` é revogado. | +| | `microAreaId` | `uuid` | Pseudonimizado | Copiado do token de acesso | Segmentação dos avisos por microárea. | +| | `token` | `text` | Identificador de aparelho | Emitido pelo FCM/APNs | Identifica um aparelho, não uma pessoa; junto de `userId` liga os dois. Índice único: o mesmo token nunca tem dois donos. | +| | `platform` | `text` | Metadado Técnico | `android` \| `ios` | Escolhe o provedor do envio. | +| | `createdAt` / `updatedAt` | `timestamp without time zone` | Metadado Técnico | Relógio do servidor | Primeiro registro e última confirmação do token. | | **audit_logs** | `id` | `uuid` | Pseudonimizado | UUID v4 (`gen_random_uuid()`) | Identificador do registro de auditoria (LGPD-RF11). | | | `userId` | `uuid` | Pseudonimizado | Chave estrangeira (`users.id`) | Identifica o operador que executou a ação auditada. | | | `actionType` | `text` | Metadado Técnico | Enum textual (`READ`, `WRITE`, `DELETE`, etc.) | Operação registrada. | From 8b183deca2cf7dfaad16b75927a528df894661d3 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 29 Sep 2026 15:38:27 -0400 Subject: [PATCH 062/498] docs: plano da rodada de menores adiados e push do paciente Co-Authored-By: Claude Sonnet 5.5 --- ...9-29-menores-adiados-e-push-do-paciente.md | 712 ++++++++++++++++++ 1 file changed, 712 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-29-menores-adiados-e-push-do-paciente.md 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. From df8d8a83fbb92fb7944f95aa1d9fbaf8f7e8d7eb Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 29 Sep 2026 15:52:12 -0400 Subject: [PATCH 063/498] =?UTF-8?q?fix(backend):=20registro=20de=20token?= =?UTF-8?q?=20e=20revoga=C3=A7=C3=A3o=20sob=20o=20mesmo=20lock=20por=20tit?= =?UTF-8?q?ular=20(RF14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O consentimento era lido fora da transação do registro: registrar × revogar em paralelo deixava token de titular que já revogou. Aparelho apresentado por quem não consentiu perde o vínculo do titular anterior. Co-Authored-By: Claude Sonnet 5.5 --- PROGRESS.md | 5 + .../patients/push_token_service.dart | 28 +-- .../database/orm_push_token_store.dart | 59 +++--- .../infrastructure/database/subject_lock.dart | 2 + .../integration/push_token_endpoint_test.dart | 169 ++++++++++++++++-- .../data_subject_rights_service_test.dart | 8 +- .../test/unit/push_token_service_test.dart | 7 +- 7 files changed, 219 insertions(+), 59 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 95f405a3..553d92b9 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1154,7 +1154,12 @@ Plano: `docs/superpowers/plans/2026-09-29-menores-adiados-e-push-do-paciente.md` - 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: se a segunda falhar, o consentimento já está revogado e o token fica até a próxima revogação. +- `devices.registerPushToken` não grava linha de auditoria (a revogação já deixa `consent_log`); a troca de dono do token não deixa rastro. +- Sem teto de tokens por titular; sem teste do caso "fonte de token que nunca completa"; `PushTokenScope.of` sem `maybeOf`. - Envio segmentado (`notices.sendSegmented`), tela de avisos do ACS e o SDK `firebase_messaging`: bloqueio externo do §3.2 (sem projeto Firebase). - 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 e revisão jurídica do texto 2026.1. 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 index 8036ac66..0a699c8f 100644 --- a/backend/sinalacs_server/lib/src/application/patients/push_token_service.dart +++ b/backend/sinalacs_server/lib/src/application/patients/push_token_service.dart @@ -5,11 +5,14 @@ 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 { - /// `true` quando a linha mais recente de `segmentedPush` do titular é `granted`. - Future hasGrantedConsent(String userId); - - /// Grava o token; se ele já existe, muda o dono e renova `updatedAt`. - Future upsert({ + /// 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 + /// (`deleteAllFor`) espera o mesmo lock. Se o token já existe, muda o dono e + /// renova `updatedAt`. Devolve `false` sem consentimento — 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. + Future registerIfConsented({ required String userId, required String? microAreaId, required String token, @@ -17,7 +20,8 @@ abstract interface class PushTokenStore { required DateTime now, }); - /// Apaga todos os tokens do titular e devolve quantos eram. + /// Apaga todos os tokens do titular (sob o mesmo lock por titular do + /// registro) e devolve quantos eram. Future deleteAllFor(String userId); } @@ -55,17 +59,17 @@ class PushTokenService { !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( + final registered = await _store.registerIfConsented( userId: user.id, microAreaId: user.microAreaId, token: trimmed, platform: platform, now: _clock().toUtc(), ); + if (!registered) { + throw DataRightsException( + message: 'Ative "Avisos da equipe de saúde" em Meus Dados para receber avisos.', + ); + } } } 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 index 021d69d5..6f241dd9 100644 --- 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 @@ -9,23 +9,13 @@ class OrmPushTokenStore implements PushTokenStore { 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 hasGrantedConsent(String userId) async { - final row = await ConsentLog.db.findFirstRow( - _session(), - where: (t) => - t.userId.equals(UuidValue.fromString(userId)) & - t.purpose.equals(ConsentPurpose.segmentedPush.name), - orderBy: (t) => t.timestamp, - orderDescending: true, - ); - return row != null && row.action == 'granted'; - } - - /// Serializa por token com advisory lock: o índice único de `token` recusaria - /// o segundo inserto de uma corrida, e aqui o segundo deve virar atualização. - @override - Future upsert({ + Future registerIfConsented({ required String userId, required String? microAreaId, required String token, @@ -35,13 +25,28 @@ class OrmPushTokenStore implements PushTokenStore { final session = _session(); final userUuid = UuidValue.fromString(userId); final areaUuid = microAreaId == null ? null : UuidValue.fromString(microAreaId); - await session.db.transaction((transaction) async { - await lockPerSubject(session, transaction, namespace: lockNamespacePushToken, key: token); + 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), + orderBy: (t) => t.timestamp, + 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 false; + } if (existing != null) { await PushToken.db.updateRow( session, @@ -53,7 +58,7 @@ class OrmPushTokenStore implements PushTokenStore { ), transaction: transaction, ); - return; + return true; } await PushToken.db.insertRow( session, @@ -67,15 +72,21 @@ class OrmPushTokenStore implements PushTokenStore { ), transaction: transaction, ); + return true; }); } @override Future deleteAllFor(String userId) async { - final removed = await PushToken.db.deleteWhere( - _session(), - where: (t) => t.userId.equals(UuidValue.fromString(userId)), - ); - return removed.length; + final session = _session(); + return session.db.transaction((transaction) async { + await lockPerSubject(session, transaction, namespace: lockNamespacePushToken, key: userId); + final removed = await PushToken.db.deleteWhere( + session, + where: (t) => t.userId.equals(UuidValue.fromString(userId)), + transaction: transaction, + ); + return removed.length; + }); } } diff --git a/backend/sinalacs_server/lib/src/infrastructure/database/subject_lock.dart b/backend/sinalacs_server/lib/src/infrastructure/database/subject_lock.dart index 8466efa4..49763a1d 100644 --- a/backend/sinalacs_server/lib/src/infrastructure/database/subject_lock.dart +++ b/backend/sinalacs_server/lib/src/infrastructure/database/subject_lock.dart @@ -5,7 +5,9 @@ import 'package:serverpod/serverpod.dart'; /// (`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; /// Serializa, dentro de [transaction], quem disputa a mesma [key] no mesmo /// [namespace]. Solta sozinho no fim da transação. diff --git a/backend/sinalacs_server/test/integration/push_token_endpoint_test.dart b/backend/sinalacs_server/test/integration/push_token_endpoint_test.dart index 1b6791ee..62c316a8 100644 --- a/backend/sinalacs_server/test/integration/push_token_endpoint_test.dart +++ b/backend/sinalacs_server/test/integration/push_token_endpoint_test.dart @@ -1,6 +1,10 @@ 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/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'; @@ -13,6 +17,12 @@ 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( @@ -30,11 +40,18 @@ AppConfig _config() => AppConfig( enableDevLogin: true, ); -Future _seed(Session session) async { +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), + id: UuidValue.fromString(ubsId), name: 'UBS Desenvolvimento', address: 'Endereço local', city: 'São Paulo', @@ -44,31 +61,31 @@ Future _seed(Session session) async { await MicroArea.db.insertRow( session, MicroArea( - id: UuidValue.fromString(_microAreaId), + id: UuidValue.fromString(microAreaId), name: 'Microárea 12', - ubsId: UuidValue.fromString(_ubsId), + ubsId: UuidValue.fromString(ubsId), geoJsonBoundary: '{}', ), ); final now = DateTime.now().toUtc(); await User.db.insert(session, [ User( - id: UuidValue.fromString(_patientId), - cpfHash: 'development-patient', + 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), + microAreaId: UuidValue.fromString(microAreaId), createdAt: now, updatedAt: now, ), User( - id: UuidValue.fromString(_acsId), - cpfHash: 'development-acs', + 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), + microAreaId: UuidValue.fromString(microAreaId), createdAt: now, updatedAt: now, ), @@ -76,16 +93,16 @@ Future _seed(Session session) async { await Acs.db.insertRow( session, Acs( - id: UuidValue.fromString(_acsId), - enrollmentId: 'ACS-001', - ubsId: UuidValue.fromString(_ubsId), + id: UuidValue.fromString(acsId), + enrollmentId: enrollmentId, + ubsId: UuidValue.fromString(ubsId), active: true, ), ); await Patient.db.insertRow( session, await encryptedPatient( - id: _patientId, + id: patientId, emergencyContact: 'Contato de desenvolvimento', isChronic: false, chronicConditions: const [], @@ -194,4 +211,128 @@ void main() { ); }); }); + + // 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); + await PushToken.db.deleteWhere(session, where: (t) => t.userId.equals(id)); + await ConsentLog.db.deleteWhere(session, where: (t) => t.userId.equals(id)); + await Patient.db.deleteWhere(session, where: (t) => t.id.equals(id)); + await Acs.db.deleteWhere(session, where: (t) => t.id.equals(UuidValue.fromString(_raceAcsId))); + 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(); + await _seed(session, + ubsId: _raceUbsId, microAreaId: _raceMicroAreaId, patientId: _racePatientId, acsId: _raceAcsId, enrollmentId: 'ACS-CORRIDA-PUSH'); + try { + 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(), + ), + () async { + await consent('denied'); + await tokens.deleteAllFor(_racePatientId); + }(), + ]); + 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(); + await _seed(session, + ubsId: _raceUbsId, microAreaId: _raceMicroAreaId, patientId: _racePatientId, acsId: _raceAcsId, enrollmentId: 'ACS-CORRIDA-PUSH'); + try { + 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(), + ), + isTrue, + ); + + // 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, isFalse); + 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); + } + }); + }, + rollbackDatabase: RollbackDatabase.disabled, + ); } 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 index 54a6227f..d9830229 100644 --- a/backend/sinalacs_server/test/unit/data_subject_rights_service_test.dart +++ b/backend/sinalacs_server/test/unit/data_subject_rights_service_test.dart @@ -115,16 +115,14 @@ class FakePushTokenStore implements PushTokenStore { var deleteCalls = []; @override - Future hasGrantedConsent(String userId) async => true; - - @override - Future upsert({ + Future registerIfConsented({ required String userId, required String? microAreaId, required String token, required String platform, required DateTime now, - }) async {} + }) async => + true; @override Future deleteAllFor(String userId) async { diff --git a/backend/sinalacs_server/test/unit/push_token_service_test.dart b/backend/sinalacs_server/test/unit/push_token_service_test.dart index cb12fed1..6fa05e80 100644 --- a/backend/sinalacs_server/test/unit/push_token_service_test.dart +++ b/backend/sinalacs_server/test/unit/push_token_service_test.dart @@ -32,17 +32,16 @@ class _FakePushTokenStore implements PushTokenStore { final rows = {}; @override - Future hasGrantedConsent(String userId) async => consent; - - @override - Future upsert({ + Future registerIfConsented({ required String userId, required String? microAreaId, required String token, required String platform, required DateTime now, }) async { + if (!consent) return false; rows[token] = (userId: userId, platform: platform); + return true; } @override From 501d8cedf797e267eebbc75663738838089cb7eb Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 29 Sep 2026 16:41:54 -0400 Subject: [PATCH 064/498] =?UTF-8?q?docs:=20RF14=20passa=20a=20usar=20Gorus?= =?UTF-8?q?h;=20Riverpod=20s=C3=B3=20no=20push=20do=20paciente?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- PROGRESS.md | 16 +- .../lib/core/push/push_token_source.dart | 9 +- ...s-do-push-e-aviso-de-mudanca-dos-termos.md | 646 ++++++++++++++++++ ...26-09-29-rf14-gorush-avisos-segmentados.md | 615 +++++++++++++++++ ...26-09-16-decisoes-produto-pos-validacao.md | 84 ++- spec/PRD_system.md | 4 +- spec/lgpd_design.md | 4 +- spec/stack.md | 18 +- spec/validation_report.md | 2 +- 9 files changed, 1352 insertions(+), 46 deletions(-) create mode 100644 docs/superpowers/plans/2026-09-29-menores-do-push-e-aviso-de-mudanca-dos-termos.md create mode 100644 docs/superpowers/plans/2026-09-29-rf14-gorush-avisos-segmentados.md diff --git a/PROGRESS.md b/PROGRESS.md index 553d92b9..45795914 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1094,8 +1094,8 @@ O que **não** foi feito, de propósito: `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` é registrado mas não tem efeito**: não há projeto Firebase - (RF14). A descrição na tela diz isso. +- **`segmentedPush` é registrado mas não tem efeito**: não há Gorush hospedado nem credenciais + FCM/APNs (RF14). A descrição na tela diz isso. ## QR Code do onboarding e documentos legais (2026-09-29) @@ -1160,9 +1160,19 @@ Plano: `docs/superpowers/plans/2026-09-29-menores-adiados-e-push-do-paciente.md` - Revogar grava o `denied` e apaga os tokens em duas operações: se a segunda falhar, o consentimento já está revogado e o token fica até a próxima revogação. - `devices.registerPushToken` não grava linha de auditoria (a revogação já deixa `consent_log`); a troca de dono do token não deixa rastro. - Sem teto de tokens por titular; sem teste do caso "fonte de token que nunca completa"; `PushTokenScope.of` sem `maybeOf`. -- Envio segmentado (`notices.sendSegmented`), tela de avisos do ACS e o SDK `firebase_messaging`: bloqueio externo do §3.2 (sem projeto Firebase). +- Envio segmentado (`notices.sendSegmented`), tela de avisos do ACS e a captura do token nativo: dependem de hospedar o Gorush e provisionar credenciais FCM/APNs (§3.2, revisado em 2026-09-29 — Gorush no lugar de integrar o Firebase). - 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 e revisão jurídica do texto 2026.1. - 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. diff --git a/apps/patient/lib/core/push/push_token_source.dart b/apps/patient/lib/core/push/push_token_source.dart index af321400..67d6cc81 100644 --- a/apps/patient/lib/core/push/push_token_source.dart +++ b/apps/patient/lib/core/push/push_token_source.dart @@ -11,10 +11,11 @@ class PushDevice { 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. +/// De onde o app tira o token de push. A implementação real (token nativo do +/// FCM no Android e do APNs no iOS, entregue depois ao Gorush pelo backend — +/// decisão §3.2 do documento de decisões de produto) entra quando o Gorush e as +/// credenciais existirem; 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(); 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..2017ff10 --- /dev/null +++ b/docs/superpowers/plans/2026-09-29-menores-do-push-e-aviso-de-mudanca-dos-termos.md @@ -0,0 +1,646 @@ +# 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`. + +**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 44 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 (`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 o `FakeAuditTrail` de `data_subject_rights_service_test.dart`, copiado para o arquivo ou movido para `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`, grupo de corrida (ids `9200`, mesmo `cleanup`), três testes novos: + +```dart +test('revogação atômica: falha ao apagar tokens não deixa denied gravado', () async { + // seed + consentimento granted + token registrado (via store) + // chama recordConsentRevokingPush com um entry inválido de propósito + // (userId inexistente => violação de FK do consent_logs) 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, prove a atomicidade com uma falha real do banco: `entry.userId` que não existe em `users` faz o `insertRow` violar a FK; o `deleteWhere` do mesmo bloco não pode ter efeito (`count` do token do titular real continua 1, e nenhuma linha `denied` existe para ele). Como o titular do `entry` é inexistente, mande o `entry` com o `userId` de um segundo usuário semeado e faça a falha vir de `purpose` fora do enum? Não: use a FK. O que importa é que a transação inteira reverta. + +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); + final row = await ConsentLog.db.insertRow( + session, + signedConsentLog(entry, signature: _signature, origin: 'painel-titular'), + transaction: transaction, + ); + await PushToken.db.deleteWhere( + session, + where: (t) => t.userId.equals(userUuid), + transaction: transaction, + ); + return row.id!.uuid; + }); + } +``` + +`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 44 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; 44 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 44 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-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/specs/2026-09-16-decisoes-produto-pos-validacao.md b/docs/superpowers/specs/2026-09-16-decisoes-produto-pos-validacao.md index 2160d270..8b21876a 100644 --- a/docs/superpowers/specs/2026-09-16-decisoes-produto-pos-validacao.md +++ b/docs/superpowers/specs/2026-09-16-decisoes-produto-pos-validacao.md @@ -222,38 +222,64 @@ específico já decidido em §2. ### 3.2 RF14 — Avisos segmentados à comunidade (push) -**Estado atual:** `NoticesScreen` descarta a entrada. Não há FCM/APNs, não há -tabela de token de dispositivo, não há endpoint de envio segmentado por -microárea/UBS. - -**Decisão aprovada (contrato, não implementação):** -- Provedor: **Firebase Cloud Messaging (FCM)**, por ser o caminho padrão para - Android (plataforma primária hoje — os três apps só têm build Android) e - compatível com iOS via APNs através do mesmo SDK, evitando manter dois - backends de push. -- Contrato de backend: um endpoint de registro de token - (`devices.registerPushToken`, tabela nova `push_tokens` associando - `userId`/`microAreaId`/token/plataforma) e um endpoint de envio segmentado - (`notices.sendSegmented`, restrito a ACS/admin da UBS/microárea alvo, - auditado como os demais endpoints sensíveis). +**Estado atual:** o lado do paciente registra o token do aparelho +(`devices.registerPushToken`, tabela `push_tokens`, só com o consentimento +`segmentedPush` vigente). Não há envio segmentado, nem tela de avisos no ACS, +nem servidor de push. `NoticesScreen` continua descartando a entrada. + +**Decisão aprovada (revisada em 2026-09-29: Gorush no lugar de um SDK/console +Firebase como ponto de integração):** +- Envio por **Gorush** (servidor de push open-source em Go, auto-hospedado no + mesmo Docker Compose/host do backend), que mantém as conexões com o FCM + (Android) e o APNs (iOS). O backend não fala com o provedor: entrega ao + Gorush a lista de tokens e o conteúdo, e o Gorush faz o envio. Isso evita + manter dois clientes de push no backend e mantém a lógica de segmentação + onde já estão os dados. +- **Segmentação no PostgreSQL:** a consulta de destinatários é um `JOIN` entre + `push_tokens` e `users`/`patients` filtrando por microárea (e, quando houver, + por outro atributo do titular), sempre restrita a titulares com o + consentimento `segmentedPush` vigente. Nada de segmentação no provedor. +- **Contrato de backend:** `devices.registerPushToken` (implementado) e + `notices.sendSegmented` (restrito a ACS/admin da UBS/microárea alvo, auditado + como os demais endpoints sensíveis, e que consulta o consentimento antes de + montar a lista). A tabela segue como `push_tokens` (uma linha por token, + `userId`, `microAreaId`, `platform`, `createdAt`, `updatedAt`); o nome + `user_push_tokens` cogitado na revisão não foi adotado porque a tabela já + existe com este nome. +- **App do paciente:** a captura do token nativo (FCM no Android, APNs no iOS) + fica atrás da interface `PushTokenSource` (`core/push/push_token_source.dart`), + que o app já injeta por `PushTokenScope`. O app adota `flutter_riverpod` + **somente** para a captura e o registro do token (`pushTokenSourceProvider`); + o restante da injeção segue por `InheritedWidget` (`BackendScope`, + `QrScannerScope`, `PushTokenScope`). Migrar o resto para Riverpod fica fora do + RF14 (decisão do produto em 2026-09-29). O + token também é guardado no SQLite local só se um consumidor local vier a + precisar dele; hoje o servidor é a única fonte. - Conteúdo do aviso não deve carregar dado de saúde identificável — só o necessário para abrir o app na tela correta (mesma lógica de minimização já aplicada ao alerta). -**Bloqueio externo — este item está bloqueado, não só pendente de -implementação:** -- Não existe projeto Firebase configurado no repositório (sem - `google-services.json`, sem `GoogleService-Info.plist`, sem chave de - servidor FCM em `.env.example`). -- Criar esse projeto é uma decisão de produto/infra sobre qual conta - organizacional o hospeda, quem tem acesso administrativo e qual orçamento - cobre o uso em produção — decisão que este repositório não pode tomar - sozinho. - -**Plano derivado (após o bloqueio externo ser resolvido):** provisionar o -projeto Firebase, declarar as credenciais como segredo (padrão -`bootstrap_env.sh`), implementar os dois endpoints acima e a tela real de -avisos nos apps ACS (emissor) e paciente (receptor). +**O que a mudança destrava e o que não destrava:** +- Sai o bloqueio "provisionar e administrar um projeto Firebase como + plataforma do produto": o envio passa a ser infraestrutura própria (Gorush). +- **Continua exigindo credenciais de provedor.** O Gorush é um relé: para + Android ele precisa de uma credencial FCM (conta de serviço) e para iOS de + uma chave APNs. Ter essa credencial de FCM implica uma conta/projeto no + Google, embora sem a dependência do SDK de análise e sem o backend falar com + ele. Quem hospeda essa conta e quem a administra segue sendo decisão de + produto/infra que este repositório não toma sozinho. +- **Ponto em aberto:** obter o token FCM no Android normalmente exige o SDK + `firebase_messaging` (ou um canal nativo equivalente) e o `google-services.json`; + o iOS pode usar um pacote leve de APNs. A escolha do pacote de cada + plataforma fica para a implementação de `PushTokenSource`, com a exigência + de que o registro continue silencioso e nunca bloqueie o login, a home ou o + alerta de urgência. + +**Plano derivado:** subir o Gorush no `docker-compose.yml` (perfil próprio, +credenciais FCM/APNs como segredos no padrão `bootstrap_env.sh`, porta não +publicada fora da rede do Compose), implementar `notices.sendSegmented` com a +consulta segmentada e o consentimento, a tela de avisos do ACS (emissor) e a +implementação real de `PushTokenSource` no paciente (receptor). --- @@ -461,7 +487,7 @@ como decisão futura, não uma dependência que bloqueia esta decisão. | Mapa/localização | RF10, L-05 | Aprovado (geocélula) | Não | | Onboarding/consentimento | RF02, LGPD-RF02 | Aprovado | Não | | Lembretes locais | RF06 | Aprovado | Não | -| Avisos push | RF14 | Contrato aprovado | **Sim** — projeto FCM inexistente | +| Avisos push | RF14 | Contrato aprovado (Gorush, revisado 2026-09-29) | **Parcial** — falta hospedar o Gorush e provisionar credenciais FCM/APNs | | Geofencing | RF12 | Aprovado (atrelado a visita ativa) | Parcial — submissão à loja pendente de revisão | | Sync central→dispositivo | RF15 (leitura), RF05, INV-05 | Aprovado (pull incremental) | Não | | Criptografia Postgres | RNF03, INV-04 | Aprovado (app-level AES-256-GCM) | Não (KMS de produção é melhoria futura) | diff --git a/spec/PRD_system.md b/spec/PRD_system.md index c47821d8..e361ae41 100644 --- a/spec/PRD_system.md +++ b/spec/PRD_system.md @@ -144,7 +144,7 @@ events: | **RF11** | Registro Rápido de Visitas (Offline-first) | ACS | M | **Crítico** | `sqflite`, `connectivity_plus` | | **RF12** | Geofencing (Check-in Passivo) | ACS | M | Médio | GPS em segundo plano | | **RF13** | Escalonamento para SAMU/UBS (`url_launcher`) | ACS | S | Baixo | `url_launcher` | -| **RF14** | Avisos Segmentados à Comunidade (Push) | ACS | M | Médio | FCM/APNs | +| **RF14** | Avisos Segmentados à Comunidade (Push) | ACS | M | Médio | Gorush (relé para FCM/APNs) | | **RF15** | Sincronização Bidirecional (Local ↔ Central) | Sistema | **L** | **Crítico** | Driver `postgres` no backend (sem ORM); ainda não conectado ao cliente | | **RF16** | Motor de Triagem Determinístico (Manchester) | Sistema | **L** | **Crítico** | Nenhuma (local) | | **RF17** | Logs de Auditoria e Conformidade (LGPD) | Sistema | M | Alto | PostgreSQL | @@ -170,7 +170,7 @@ documento de decisão, não implementação: | RF10 (mapa) | Geocélula arredondada, não posição exata (§1) | Não | | RF02 + LGPD-RF02 (onboarding/consentimento) | Token de convite de uso único + consentimento por finalidade (§2) | Não | | RF06 (lembretes) | Local ao dispositivo, sem endpoint (§3.1) | Não | -| RF14 (avisos push) | Contrato FCM definido (§3.2); lado do paciente pronto (`devices.registerPushToken`, `push_tokens`, `PushTokenSource`); envio e SDK pendentes | **Sim** — sem projeto Firebase provisionado | +| RF14 (avisos push) | Contrato com Gorush definido (§3.2, revisado 2026-09-29); lado do paciente pronto (`devices.registerPushToken`, `push_tokens`, `PushTokenSource`); envio, Gorush e captura do token nativo pendentes | **Parcial** — falta hospedar o Gorush e provisionar credenciais FCM/APNs | | RF12 (geofencing) | Geofence atrelado a visita ativa, sem rastreamento contínuo (§4) | Parcial — submissão à loja pendente | | RF15 (sync central→dispositivo) | Pull incremental por cursor (§5) | Não | | RNF03 (criptografia Postgres) | AES-256-GCM em nível de aplicação (§6) | Não | diff --git a/spec/lgpd_design.md b/spec/lgpd_design.md index 1e967b2c..5ee4ccb8 100644 --- a/spec/lgpd_design.md +++ b/spec/lgpd_design.md @@ -69,8 +69,8 @@ locais quando o consentimento espelhado no aparelho **Aviso — `ConsentPurpose.segmentedPush` continua sem leitor.** RF14 (avisos segmentados por push) não tem nenhum código de envio no repositório ainda — -está bloqueado externamente na provisão de um projeto Firebase (§3.2 do -mesmo documento de decisões), não apenas pendente de implementação. Não há +depende de hospedar o Gorush e provisionar credenciais FCM/APNs (§3.2 do +mesmo documento de decisões), não apenas de implementação. Não há o que "respeitar" hoje porque nada envia. Quando `notices.sendSegmented` for implementado, ele **deve** consultar o consentimento de `segmentedPush` antes de enviar, com o mesmo padrão de recusa por omissão adotado aqui para diff --git a/spec/stack.md b/spec/stack.md index 68908e43..17331b53 100644 --- a/spec/stack.md +++ b/spec/stack.md @@ -44,8 +44,8 @@ onboarding e consentimento (§2, RF02) e a metade central→dispositivo da sincronização (§5, RF15 — pull incremental; a leitura fica do lado do ACS, não há geração de mudança do lado do paciente ainda). Lembretes locais (§3.1, RF06) também têm implementação no app do paciente. Ainda **não** -implementados: push segmentado (§3.2, RF14 — bloqueado externamente, sem -projeto Firebase) e geofencing (§4, RF12 — só o contrato de dados +implementados: push segmentado (§3.2, RF14 — Gorush; falta hospedá-lo e +provisionar as credenciais FCM/APNs) e geofencing (§4, RF12 — só o contrato de dados `arrivalMethod` foi desenhado, sem o serviço de geofence em primeiro plano): * **Criptografia de colunas no PostgreSQL (RNF03/INV-04, §6 — implementada):** @@ -58,9 +58,17 @@ projeto Firebase) e geofencing (§4, RF12 — só o contrato de dados `scripts/dev/bootstrap_env.sh`, opcional em `development` (cai num valor público conhecido) e obrigatória fora dele —, sem introduzir um KMS externo nesta fase. -* **Push segmentado (RF14):** Firebase Cloud Messaging é o provedor - escolhido, mas a decisão está **bloqueada externamente** — não existe - projeto Firebase provisionado neste repositório. +* **Push segmentado (RF14):** o envio é feito por **Gorush**, servidor de + push open-source em Go, auto-hospedado no Docker Compose ao lado do backend + (revisão de 2026-09-29, no lugar de integrar o Firebase). O app captura o + token nativo (FCM no Android, APNs no iOS) atrás de `PushTokenSource` e o + registra em `push_tokens` (PostgreSQL); a segmentação é uma consulta SQL + restrita a quem consentiu (`segmentedPush`); o backend entrega a lista de + tokens ao Gorush, que fala com o FCM/APNs. O app do paciente adota + `flutter_riverpod` **somente** na captura e no registro do token + (`pushTokenProvider`); o resto da injeção segue por `InheritedWidget`. O Gorush ainda precisa de credencial FCM (Android) e chave + APNs (iOS), então a pendência passa de "projeto Firebase" para "hospedar o + Gorush e provisionar essas credenciais" — decisão de infra, não de código. * **Geofencing (RF12):** rejeitado rastreamento contínuo em segundo plano do ACS; adotado geofence único atrelado a uma visita ativa, com serviço em primeiro plano e notificação persistente, para evitar a política mais diff --git a/spec/validation_report.md b/spec/validation_report.md index 682a5d85..ab2c789a 100644 --- a/spec/validation_report.md +++ b/spec/validation_report.md @@ -87,7 +87,7 @@ cliente · **`parcial`** = existe, mas alimentado por dado fabricado · | RF11 | Registro rápido de visitas offline-first | **backend** | `visits.sync` com dedupe por `localId`, versionamento, conflito e território. Fila SQLCipher no dispositivo. | | RF12 | Geofencing (check-in passivo) | **ausente** | `RouteService` calcula chegada localmente, mas não há GPS em segundo plano. | | RF13 | Escalonamento para SAMU/UBS | **ausente** | Ambos os botões são snackbars — ver L-07. | -| RF14 | Avisos segmentados (push) | **ausente** | `NoticesScreen` descarta a entrada. Sem FCM/APNs. | +| RF14 | Avisos segmentados (push) | **ausente** | `NoticesScreen` descarta a entrada. Sem envio; contrato revisado para Gorush (ver `docs/superpowers/specs/2026-09-16-decisoes-produto-pos-validacao.md` §3.2). | | RF15 | Sincronização bidirecional | **backend** | Dispositivo → central e central → dispositivo funcionam e são testados dos dois lados: ACS (`visits.pull`) e paciente (`alerts.statusFor`, RF05). Ambos rodam automaticamente ao abrir a tela, em ciclo periódico enquanto o app está em primeiro plano (ACS, em qualquer aba) ou enquanto a tela de Status está aberta (paciente), e por botão manual. Do lado ACS, o pull continua sendo só referência somente leitura (contagem exibida na tela), sem gravar as visitas puxadas na fila offline local — persistir esse resultado como registro local segue como trabalho futuro (ver nota em `VisitPullService`), fora do escopo deste plano. | | RF16 | Motor de triagem determinístico | **backend** | `TriageEngine`, determinismo verificado em teste de integração. INV-02 preservado. | | RF17 | Logs de auditoria e conformidade | **backend** | `audit_logs` encadeado por HMAC; gravou `granted` e `denied_territory` nesta validação; cadeia verificada íntegra. | From 4c6649fea68bb8990ec27361b2cb53df9ae2084f Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 29 Sep 2026 16:43:00 -0400 Subject: [PATCH 065/498] feat(infra): Gorush opcional no Compose e GORUSH_URL no backend (RF14) Co-Authored-By: Claude Sonnet 5.5 --- .env.example | 9 +++++++ .gitignore | 1 + .../lib/src/config/app_config.dart | 22 ++++++++++++++++ .../test/unit/app_config_test.dart | 25 ++++++++++++++++++ docker-compose.yml | 19 ++++++++++++++ infra/docker/gorush/README.md | 26 +++++++++++++++++++ infra/docker/gorush/config.yml | 20 ++++++++++++++ 7 files changed, 122 insertions(+) create mode 100644 infra/docker/gorush/README.md create mode 100644 infra/docker/gorush/config.yml diff --git a/.env.example b/.env.example index e82cadb2..362e16fd 100644 --- a/.env.example +++ b/.env.example @@ -216,3 +216,12 @@ 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= diff --git a/.gitignore b/.gitignore index 5ffb09ba..5601e59e 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,4 @@ apps/patient/assets/certs/ # Estado local do proxy headroom (PID e timestamps), reescrito a cada sessão .claude/.headroom_wrap_*.json +infra/docker/gorush/credentials/ diff --git a/backend/sinalacs_server/lib/src/config/app_config.dart b/backend/sinalacs_server/lib/src/config/app_config.dart index 4e13fb4a..2d14da82 100644 --- a/backend/sinalacs_server/lib/src/config/app_config.dart +++ b/backend/sinalacs_server/lib/src/config/app_config.dart @@ -19,10 +19,19 @@ class AppConfig { required this.mqttCaCertificatePath, required this.appEnv, required this.enableDevLogin, + this.gorushUrl, }); 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 @@ -190,9 +199,22 @@ class AppConfig { mqttCaCertificatePath: environment['MQTT_CA_CERT_PATH'], appEnv: appEnv, enableDevLogin: environment['ENABLE_DEV_LOGIN'] == 'true', + gorushUrl: _resolveGorushUrl(environment['GORUSH_URL']), ); } + 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/test/unit/app_config_test.dart b/backend/sinalacs_server/test/unit/app_config_test.dart index eb5babfb..432e4e69 100644 --- a/backend/sinalacs_server/test/unit/app_config_test.dart +++ b/backend/sinalacs_server/test/unit/app_config_test.dart @@ -383,4 +383,29 @@ 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)); + }); + }); } diff --git a/docker-compose.yml b/docker-compose.yml index a29c4388..d860c285 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -80,6 +80,22 @@ 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:latest + profiles: [push] + restart: unless-stopped + command: ["-c", "/config.yml"] + 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 @@ -124,6 +140,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 diff --git a/infra/docker/gorush/README.md b/infra/docker/gorush/README.md new file mode 100644 index 00000000..43c736b2 --- /dev/null +++ b/infra/docker/gorush/README.md @@ -0,0 +1,26 @@ +# Gorush (RF14) + +Relé de push open-source que mantém as conexões com o FCM (Android) e o APNs +(iOS). O backend entrega a lista de tokens e o Gorush faz o envio; a segmentação +é feita no PostgreSQL, nunca aqui. + +Só sobe com o perfil `push`: `docker compose --profile push up`. + +## O que a organização precisa fornecer + +O Gorush é um relé, **não substitui** as credenciais dos provedores. Coloque em +`GORUSH_CREDENTIALS_DIR` (fora do repositório): + +- `fcm-service-account.json` — conta de serviço do FCM (Android); +- `apns-key.p8` — chave de autenticação do APNs (iOS), com `GORUSH_IOS_KEY_ID` e + `GORUSH_IOS_TEAM_ID` no `.env`. + +Nada disso é versionado nem gerado por `bootstrap_env.sh`. + +## Ligar o backend + +No `.env`: `GORUSH_URL=http://gorush:8088`. Vazio desliga o envio: o backend sobe +normalmente e `notices.sendSegmented` recusa com uma mensagem clara. + +O `config.yml` deste diretório usa a imagem `appleboy/gorush`; fixe a tag testada +no `docker-compose.yml` e confira os nomes das chaves no README dela. diff --git a/infra/docker/gorush/config.yml b/infra/docker/gorush/config.yml new file mode 100644 index 00000000..82c53c89 --- /dev/null +++ b/infra/docker/gorush/config.yml @@ -0,0 +1,20 @@ +# Configuração do Gorush (RF14). Confira os nomes das chaves contra o README da +# imagem que estiver fixada no docker-compose.yml antes de subir. +core: + port: "8088" + sync: true + feedback_timeout: 10 +android: + enabled: true + credential: "" + key_path: /credentials/fcm-service-account.json +ios: + enabled: true + key_path: /credentials/apns-key.p8 + key_id: "" + team_id: "" + production: false +log: + format: json + access_log: "-" + error_log: "-" From 1cd68713c6f81b684be7c1cf0cfe36778d7a1954 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 29 Sep 2026 16:43:56 -0400 Subject: [PATCH 066/498] =?UTF-8?q?feat(backend):=20cliente=20do=20Gorush?= =?UTF-8?q?=20com=20tempo=20limite=20e=20poda=20de=20tokens=20inv=C3=A1lid?= =?UTF-8?q?os=20(RF14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../infrastructure/push/gorush_client.dart | 133 +++++++++++++++ .../test/unit/gorush_client_test.dart | 152 ++++++++++++++++++ 2 files changed, 285 insertions(+) create mode 100644 backend/sinalacs_server/lib/src/infrastructure/push/gorush_client.dart create mode 100644 backend/sinalacs_server/test/unit/gorush_client_test.dart 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..f3920535 --- /dev/null +++ b/backend/sinalacs_server/lib/src/infrastructure/push/gorush_client.dart @@ -0,0 +1,133 @@ +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); + + final String message; + + @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, HttpClient? httpClient}) + : _baseUrl = baseUrl, + _timeout = timeout, + _http = httpClient ?? HttpClient(); + + final String _baseUrl; + final Duration _timeout; + final HttpClient _http; + + /// Códigos de plataforma do Gorush. + static const _ios = 1; + static const _android = 2; + + static const _invalidTokenErrors = [ + 'notregistered', + 'unregistered', + 'invalidregistration', + 'baddevicetoken', + 'devicetokennotfortopic', + 'mismatchsenderid', + ]; + + @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).timeout(_timeout); + } on PushGatewayException { + rethrow; + } on TimeoutException { + throw const PushGatewayException('O Gorush não respondeu a tempo.'); + } 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 { + throw const PushGatewayException('O Gorush respondeu algo ilegível.'); + } + } + + Future _post(String body, int total) async { + final request = await _http.postUrl(Uri.parse('$_baseUrl/api/push')); + 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 json = raw.isEmpty ? {} : jsonDecode(raw) as Map; + final logs = (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']; + if (token is String && _invalidTokenErrors.any(error.contains)) invalid.add(token); + } + final counts = json['counts']; + return PushSendReport(accepted: counts is int ? counts : (total - failed).clamp(0, total), invalidTokens: invalid); + } +} 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..e19d0641 --- /dev/null +++ b/backend/sinalacs_server/test/unit/gorush_client_test.dart @@ -0,0 +1,152 @@ +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) { + _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(jsonEncode(response)); + await request.response.close(); + }); + } + + static Future start({ + int status = 200, + Map response = const {}, + bool hang = false, + }) async => + FakeGorush._(await HttpServer.bind(InternetAddress.loopbackIPv4, 0), status, response, hang); + + final HttpServer _server; + final int status; + final Map response; + final bool hang; + 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'); + +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('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()), + ); + }); + + test('servidor que não responde estoura o tempo limite como PushGatewayException', () 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()), + ); + }); + + 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('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); + }); +} From f8b2c6306ad42c15b234be89c270ac3ede03986d Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 29 Sep 2026 17:01:21 -0400 Subject: [PATCH 067/498] =?UTF-8?q?feat(backend):=20notices.sendSegmented?= =?UTF-8?q?=20com=20segmenta=C3=A7=C3=A3o=20SQL=20e=20consentimento=20vige?= =?UTF-8?q?nte=20(RF14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restringe contagens de testes de integração que liam tabelas inteiras: os grupos de corrida commitam linhas e tornavam a suíte intermitente. Co-Authored-By: Claude Sonnet 5.5 --- .../src/protocol/api/notice_send_result.dart | 85 ++ .../lib/src/protocol/client.dart | 108 ++- .../exceptions/notice_delivery_exception.dart | 65 ++ .../lib/src/protocol/protocol.dart | 683 ++++++++-------- .../application/notices/notice_service.dart | 126 +++ .../lib/src/endpoints/notices_endpoint.dart | 30 + .../src/generated/api/notice_send_result.dart | 95 +++ .../lib/src/generated/endpoints.dart | 102 ++- .../exceptions/notice_delivery_exception.dart | 76 ++ .../lib/src/generated/protocol.dart | 735 +++++++++--------- .../lib/src/generated/protocol.yaml | 2 + .../database/orm_notice_recipient_store.dart | 60 ++ .../models/api/notice_send_result.spy.yaml | 7 + .../notice_delivery_exception.spy.yaml | 6 + .../lib/src/runtime/alert_runtime.dart | 25 + .../data_subject_rights_endpoint_test.dart | 6 +- .../health_data_encryption_test.dart | 13 +- .../integration/notices_endpoint_test.dart | 308 ++++++++ .../integration/push_token_endpoint_test.dart | 50 +- .../test_tools/serverpod_test_tools.dart | 126 ++- .../test/unit/notice_service_test.dart | 173 +++++ 21 files changed, 2095 insertions(+), 786 deletions(-) create mode 100644 backend/sinalacs_client/lib/src/protocol/api/notice_send_result.dart create mode 100644 backend/sinalacs_client/lib/src/protocol/exceptions/notice_delivery_exception.dart create mode 100644 backend/sinalacs_server/lib/src/application/notices/notice_service.dart create mode 100644 backend/sinalacs_server/lib/src/endpoints/notices_endpoint.dart create mode 100644 backend/sinalacs_server/lib/src/generated/api/notice_send_result.dart create mode 100644 backend/sinalacs_server/lib/src/generated/exceptions/notice_delivery_exception.dart create mode 100644 backend/sinalacs_server/lib/src/infrastructure/database/orm_notice_recipient_store.dart create mode 100644 backend/sinalacs_server/lib/src/models/api/notice_send_result.spy.yaml create mode 100644 backend/sinalacs_server/lib/src/models/exceptions/notice_delivery_exception.spy.yaml create mode 100644 backend/sinalacs_server/test/integration/notices_endpoint_test.dart create mode 100644 backend/sinalacs_server/test/unit/notice_service_test.dart 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/client.dart b/backend/sinalacs_client/lib/src/protocol/client.dart index 34837aee..5ab24a5b 100644 --- a/backend/sinalacs_client/lib/src/protocol/client.dart +++ b/backend/sinalacs_client/lib/src/protocol/client.dart @@ -20,24 +20,27 @@ import 'package:sinalacs_client/src/protocol/api/alert_status_result.dart' 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; -import 'package:sinalacs_client/src/protocol/api/enrollment_token_result.dart' +import 'package:sinalacs_client/src/protocol/api/notice_send_result.dart' as _i8; -import 'package:sinalacs_client/src/protocol/api/enrollment_result.dart' as _i9; -import 'package:sinalacs_client/src/protocol/api/micro_area_patient.dart' +import 'package:sinalacs_client/src/protocol/api/enrollment_token_result.dart' + as _i9; +import 'package:sinalacs_client/src/protocol/api/enrollment_result.dart' as _i10; -import 'package:sinalacs_client/src/protocol/api/patient_data_overview.dart' +import 'package:sinalacs_client/src/protocol/api/micro_area_patient.dart' as _i11; -import 'package:sinalacs_client/src/protocol/api/patient_consent_record.dart' +import 'package:sinalacs_client/src/protocol/api/patient_data_overview.dart' as _i12; -import 'package:sinalacs_client/src/protocol/enums/consent_purpose.dart' +import 'package:sinalacs_client/src/protocol/api/patient_consent_record.dart' as _i13; -import 'package:sinalacs_client/src/protocol/api/patient_data_subject_request_record.dart' +import 'package:sinalacs_client/src/protocol/enums/consent_purpose.dart' as _i14; -import 'package:sinalacs_client/src/protocol/api/triage_result.dart' as _i15; +import 'package:sinalacs_client/src/protocol/api/patient_data_subject_request_record.dart' + as _i15; +import 'package:sinalacs_client/src/protocol/api/triage_result.dart' as _i16; import 'package:sinalacs_client/src/protocol/api/visit_sync_result.dart' - as _i16; -import 'package:sinalacs_client/src/protocol/api/visit_sync_entry.dart' as _i17; -import 'protocol.dart' as _i18; + as _i17; +import 'package:sinalacs_client/src/protocol/api/visit_sync_entry.dart' as _i18; +import 'protocol.dart' as _i19; /// Ciclo do alerta vermelho. /// @@ -262,6 +265,35 @@ class EndpointHealth extends _i1.EndpointRef { ); } +/// 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<_i8.NoticeSendResult> sendSegmented({ + required String accessToken, + required String title, + required String message, + required String audience, + }) => caller.callServerEndpoint<_i8.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. @@ -279,10 +311,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<_i9.EnrollmentTokenResult> generateEnrollmentToken({ required String accessToken, required String patientId, - }) => caller.callServerEndpoint<_i8.EnrollmentTokenResult>( + }) => caller.callServerEndpoint<_i9.EnrollmentTokenResult>( 'onboarding', 'generateEnrollmentToken', { @@ -293,13 +325,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<_i10.EnrollmentResult> completeEnrollment({ required String token, required bool healthDataConsent, required bool remindersConsent, required bool pushConsent, required bool termsAccepted, - }) => caller.callServerEndpoint<_i9.EnrollmentResult>( + }) => caller.callServerEndpoint<_i10.EnrollmentResult>( 'onboarding', 'completeEnrollment', { @@ -328,9 +360,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}, @@ -363,8 +395,8 @@ 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<_i12.PatientDataOverview> myData({required String accessToken}) => + caller.callServerEndpoint<_i12.PatientDataOverview>( 'patients', 'myData', {'accessToken': accessToken}, @@ -374,11 +406,11 @@ class EndpointPatients extends EndpointAuthenticated { /// 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<_i12.PatientConsentRecord> updateConsent({ + _i2.Future<_i13.PatientConsentRecord> updateConsent({ required String accessToken, - required _i13.ConsentPurpose purpose, + required _i14.ConsentPurpose purpose, required bool granted, - }) => caller.callServerEndpoint<_i12.PatientConsentRecord>( + }) => caller.callServerEndpoint<_i13.PatientConsentRecord>( 'patients', 'updateConsent', { @@ -392,9 +424,9 @@ class EndpointPatients extends EndpointAuthenticated { /// 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<_i12.PatientConsentRecord> acceptTermsOfUse({ + _i2.Future<_i13.PatientConsentRecord> acceptTermsOfUse({ required String accessToken, - }) => caller.callServerEndpoint<_i12.PatientConsentRecord>( + }) => caller.callServerEndpoint<_i13.PatientConsentRecord>( 'patients', 'acceptTermsOfUse', {'accessToken': accessToken}, @@ -414,9 +446,9 @@ class EndpointPatients extends EndpointAuthenticated { /// Pedido de exclusão/anonimização dos próprios dados (LGPD-RF08). /// Idempotente enquanto houver um pedido de exclusão em aberto. - _i2.Future<_i14.PatientDataSubjectRequestRecord> requestDataDeletion({ + _i2.Future<_i15.PatientDataSubjectRequestRecord> requestDataDeletion({ required String accessToken, - }) => caller.callServerEndpoint<_i14.PatientDataSubjectRequestRecord>( + }) => caller.callServerEndpoint<_i15.PatientDataSubjectRequestRecord>( 'patients', 'requestDataDeletion', {'accessToken': accessToken}, @@ -425,10 +457,10 @@ class EndpointPatients extends EndpointAuthenticated { /// 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<_i14.PatientDataSubjectRequestRecord> requestDataCorrection({ + _i2.Future<_i15.PatientDataSubjectRequestRecord> requestDataCorrection({ required String accessToken, required String details, - }) => caller.callServerEndpoint<_i14.PatientDataSubjectRequestRecord>( + }) => caller.callServerEndpoint<_i15.PatientDataSubjectRequestRecord>( 'patients', 'requestDataCorrection', { @@ -455,7 +487,7 @@ class EndpointTriage extends EndpointAuthenticated { @override String get name => 'triage'; - _i2.Future<_i15.TriageResult> evaluate({ + _i2.Future<_i16.TriageResult> evaluate({ required String accessToken, required bool chestPain, required bool difficultyBreathing, @@ -463,7 +495,7 @@ class EndpointTriage extends EndpointAuthenticated { required bool persistentVomiting, required bool bleeding, required bool severeWeakness, - }) => caller.callServerEndpoint<_i15.TriageResult>( + }) => caller.callServerEndpoint<_i16.TriageResult>( 'triage', 'evaluate', { @@ -494,10 +526,10 @@ class EndpointVisits extends EndpointAuthenticated { @override String get name => 'visits'; - _i2.Future> sync({ + _i2.Future> sync({ required String accessToken, - required List<_i17.VisitSyncEntry> visits, - }) => caller.callServerEndpoint>( + required List<_i18.VisitSyncEntry> visits, + }) => caller.callServerEndpoint>( 'visits', 'sync', { @@ -509,10 +541,10 @@ class EndpointVisits extends EndpointAuthenticated { /// 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', { @@ -542,7 +574,7 @@ class Client extends _i1.ServerpodClientShared { bool? disconnectStreamsOnLostInternetConnection, }) : super( host, - _i18.Protocol(), + _i19.Protocol(), securityContext: securityContext, streamingConnectionTimeout: streamingConnectionTimeout, connectionTimeout: connectionTimeout, @@ -555,6 +587,7 @@ class Client extends _i1.ServerpodClientShared { auth = EndpointAuth(this); devices = EndpointDevices(this); health = EndpointHealth(this); + notices = EndpointNotices(this); onboarding = EndpointOnboarding(this); patients = EndpointPatients(this); triage = EndpointTriage(this); @@ -569,6 +602,8 @@ class Client extends _i1.ServerpodClientShared { late final EndpointHealth health; + late final EndpointNotices notices; + late final EndpointOnboarding onboarding; late final EndpointPatients patients; @@ -583,6 +618,7 @@ class Client extends _i1.ServerpodClientShared { 'auth': auth, 'devices': devices, 'health': health, + 'notices': notices, 'onboarding': onboarding, 'patients': patients, 'triage': triage, 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/protocol.dart b/backend/sinalacs_client/lib/src/protocol/protocol.dart index cbe9a77d..5c51b7ed 100644 --- a/backend/sinalacs_client/lib/src/protocol/protocol.dart +++ b/backend/sinalacs_client/lib/src/protocol/protocol.dart @@ -23,50 +23,52 @@ 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_data_subject_request_record.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 'data_subject_request.dart' as _i24; -import 'enrollment_token.dart' as _i25; -import 'enums/alert_status.dart' as _i26; -import 'enums/arrival_method.dart' as _i27; -import 'enums/consent_purpose.dart' as _i28; -import 'enums/data_subject_request_status.dart' as _i29; -import 'enums/data_subject_request_type.dart' as _i30; -import 'enums/risk_level.dart' as _i31; -import 'enums/sync_status.dart' as _i32; -import 'enums/user_role.dart' as _i33; -import 'exceptions/alert_dispatch_unavailable_exception.dart' as _i34; -import 'exceptions/alert_permission_exception.dart' as _i35; -import 'exceptions/alert_validation_exception.dart' as _i36; -import 'exceptions/authentication_failed_exception.dart' as _i37; -import 'exceptions/data_rights_exception.dart' as _i38; -import 'exceptions/endpoint_disabled_exception.dart' as _i39; -import 'exceptions/enrollment_exception.dart' as _i40; -import 'exceptions/otp_request_exception.dart' as _i41; -import 'micro_area.dart' as _i42; -import 'otp_challenge.dart' as _i43; -import 'patient.dart' as _i44; -import 'push_token.dart' as _i45; -import 'triage_answer.dart' as _i46; -import 'triage_session.dart' as _i47; -import 'ubs.dart' as _i48; -import 'user.dart' as _i49; -import 'user_credential.dart' as _i50; -import 'visit.dart' as _i51; +import 'api/notice_send_result.dart' as _i13; +import 'api/patient_consent_record.dart' as _i14; +import 'api/patient_data_overview.dart' as _i15; +import 'api/patient_data_subject_request_record.dart' as _i16; +import 'api/patient_risk_event.dart' as _i17; +import 'api/red_alert_result.dart' as _i18; +import 'api/service_health.dart' as _i19; +import 'api/triage_result.dart' as _i20; +import 'api/visit_sync_entry.dart' as _i21; +import 'api/visit_sync_result.dart' as _i22; +import 'audit_log.dart' as _i23; +import 'consent_log.dart' as _i24; +import 'data_subject_request.dart' as _i25; +import 'enrollment_token.dart' as _i26; +import 'enums/alert_status.dart' as _i27; +import 'enums/arrival_method.dart' as _i28; +import 'enums/consent_purpose.dart' as _i29; +import 'enums/data_subject_request_status.dart' as _i30; +import 'enums/data_subject_request_type.dart' as _i31; +import 'enums/risk_level.dart' as _i32; +import 'enums/sync_status.dart' as _i33; +import 'enums/user_role.dart' as _i34; +import 'exceptions/alert_dispatch_unavailable_exception.dart' as _i35; +import 'exceptions/alert_permission_exception.dart' as _i36; +import 'exceptions/alert_validation_exception.dart' as _i37; +import 'exceptions/authentication_failed_exception.dart' as _i38; +import 'exceptions/data_rights_exception.dart' as _i39; +import 'exceptions/endpoint_disabled_exception.dart' as _i40; +import 'exceptions/enrollment_exception.dart' as _i41; +import 'exceptions/notice_delivery_exception.dart' as _i42; +import 'exceptions/otp_request_exception.dart' as _i43; +import 'micro_area.dart' as _i44; +import 'otp_challenge.dart' as _i45; +import 'patient.dart' as _i46; +import 'push_token.dart' as _i47; +import 'triage_answer.dart' as _i48; +import 'triage_session.dart' as _i49; +import 'ubs.dart' as _i50; +import 'user.dart' as _i51; +import 'user_credential.dart' as _i52; +import 'visit.dart' as _i53; import 'package:sinalacs_client/src/protocol/api/micro_area_patient.dart' - as _i52; + as _i54; import 'package:sinalacs_client/src/protocol/api/visit_sync_result.dart' - as _i53; -import 'package:sinalacs_client/src/protocol/api/visit_sync_entry.dart' as _i54; + as _i55; +import 'package:sinalacs_client/src/protocol/api/visit_sync_entry.dart' as _i56; export 'acs.dart'; export 'alert.dart'; export 'alert_delivery_record.dart'; @@ -78,6 +80,7 @@ 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_send_result.dart'; export 'api/patient_consent_record.dart'; export 'api/patient_data_overview.dart'; export 'api/patient_data_subject_request_record.dart'; @@ -106,6 +109,7 @@ 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/notice_delivery_exception.dart'; export 'exceptions/otp_request_exception.dart'; export 'micro_area.dart'; export 'otp_challenge.dart'; @@ -186,122 +190,128 @@ class Protocol extends _i1.SerializationManager { if (t == _i12.MicroAreaPatient) { return _i12.MicroAreaPatient.fromJson(data) as T; } - if (t == _i13.PatientConsentRecord) { - return _i13.PatientConsentRecord.fromJson(data) as T; + if (t == _i13.NoticeSendResult) { + return _i13.NoticeSendResult.fromJson(data) as T; } - if (t == _i14.PatientDataOverview) { - return _i14.PatientDataOverview.fromJson(data) as T; + if (t == _i14.PatientConsentRecord) { + return _i14.PatientConsentRecord.fromJson(data) as T; } - if (t == _i15.PatientDataSubjectRequestRecord) { - return _i15.PatientDataSubjectRequestRecord.fromJson(data) as T; + if (t == _i15.PatientDataOverview) { + return _i15.PatientDataOverview.fromJson(data) as T; } - if (t == _i16.PatientRiskEvent) { - return _i16.PatientRiskEvent.fromJson(data) as T; + if (t == _i16.PatientDataSubjectRequestRecord) { + return _i16.PatientDataSubjectRequestRecord.fromJson(data) as T; } - if (t == _i17.RedAlertResult) { - return _i17.RedAlertResult.fromJson(data) as T; + if (t == _i17.PatientRiskEvent) { + return _i17.PatientRiskEvent.fromJson(data) as T; } - if (t == _i18.ServiceHealth) { - return _i18.ServiceHealth.fromJson(data) as T; + if (t == _i18.RedAlertResult) { + return _i18.RedAlertResult.fromJson(data) as T; } - if (t == _i19.TriageResult) { - return _i19.TriageResult.fromJson(data) as T; + if (t == _i19.ServiceHealth) { + return _i19.ServiceHealth.fromJson(data) as T; } - if (t == _i20.VisitSyncEntry) { - return _i20.VisitSyncEntry.fromJson(data) as T; + if (t == _i20.TriageResult) { + return _i20.TriageResult.fromJson(data) as T; } - if (t == _i21.VisitSyncResult) { - return _i21.VisitSyncResult.fromJson(data) as T; + if (t == _i21.VisitSyncEntry) { + return _i21.VisitSyncEntry.fromJson(data) as T; } - if (t == _i22.AuditLog) { - return _i22.AuditLog.fromJson(data) as T; + if (t == _i22.VisitSyncResult) { + return _i22.VisitSyncResult.fromJson(data) as T; } - if (t == _i23.ConsentLog) { - return _i23.ConsentLog.fromJson(data) as T; + if (t == _i23.AuditLog) { + return _i23.AuditLog.fromJson(data) as T; } - if (t == _i24.DataSubjectRequest) { - return _i24.DataSubjectRequest.fromJson(data) as T; + if (t == _i24.ConsentLog) { + return _i24.ConsentLog.fromJson(data) as T; } - if (t == _i25.EnrollmentToken) { - return _i25.EnrollmentToken.fromJson(data) as T; + if (t == _i25.DataSubjectRequest) { + return _i25.DataSubjectRequest.fromJson(data) as T; } - if (t == _i26.AlertStatus) { - return _i26.AlertStatus.fromJson(data) as T; + if (t == _i26.EnrollmentToken) { + return _i26.EnrollmentToken.fromJson(data) as T; } - if (t == _i27.ArrivalMethod) { - return _i27.ArrivalMethod.fromJson(data) as T; + if (t == _i27.AlertStatus) { + return _i27.AlertStatus.fromJson(data) as T; } - if (t == _i28.ConsentPurpose) { - return _i28.ConsentPurpose.fromJson(data) as T; + if (t == _i28.ArrivalMethod) { + return _i28.ArrivalMethod.fromJson(data) as T; } - if (t == _i29.DataSubjectRequestStatus) { - return _i29.DataSubjectRequestStatus.fromJson(data) as T; + if (t == _i29.ConsentPurpose) { + return _i29.ConsentPurpose.fromJson(data) as T; } - if (t == _i30.DataSubjectRequestType) { - return _i30.DataSubjectRequestType.fromJson(data) as T; + if (t == _i30.DataSubjectRequestStatus) { + return _i30.DataSubjectRequestStatus.fromJson(data) as T; } - if (t == _i31.RiskLevel) { - return _i31.RiskLevel.fromJson(data) as T; + if (t == _i31.DataSubjectRequestType) { + return _i31.DataSubjectRequestType.fromJson(data) as T; } - if (t == _i32.SyncStatus) { - return _i32.SyncStatus.fromJson(data) as T; + if (t == _i32.RiskLevel) { + return _i32.RiskLevel.fromJson(data) as T; } - if (t == _i33.UserRole) { - return _i33.UserRole.fromJson(data) as T; + if (t == _i33.SyncStatus) { + return _i33.SyncStatus.fromJson(data) as T; } - if (t == _i34.AlertDispatchUnavailableException) { - return _i34.AlertDispatchUnavailableException.fromJson(data) as T; + if (t == _i34.UserRole) { + return _i34.UserRole.fromJson(data) as T; } - if (t == _i35.AlertPermissionException) { - return _i35.AlertPermissionException.fromJson(data) as T; + if (t == _i35.AlertDispatchUnavailableException) { + return _i35.AlertDispatchUnavailableException.fromJson(data) as T; } - if (t == _i36.AlertValidationException) { - return _i36.AlertValidationException.fromJson(data) as T; + if (t == _i36.AlertPermissionException) { + return _i36.AlertPermissionException.fromJson(data) as T; } - if (t == _i37.AuthenticationFailedException) { - return _i37.AuthenticationFailedException.fromJson(data) as T; + if (t == _i37.AlertValidationException) { + return _i37.AlertValidationException.fromJson(data) as T; } - if (t == _i38.DataRightsException) { - return _i38.DataRightsException.fromJson(data) as T; + if (t == _i38.AuthenticationFailedException) { + return _i38.AuthenticationFailedException.fromJson(data) as T; } - if (t == _i39.EndpointDisabledException) { - return _i39.EndpointDisabledException.fromJson(data) as T; + if (t == _i39.DataRightsException) { + return _i39.DataRightsException.fromJson(data) as T; } - if (t == _i40.EnrollmentException) { - return _i40.EnrollmentException.fromJson(data) as T; + if (t == _i40.EndpointDisabledException) { + return _i40.EndpointDisabledException.fromJson(data) as T; } - if (t == _i41.OtpRequestException) { - return _i41.OtpRequestException.fromJson(data) as T; + if (t == _i41.EnrollmentException) { + return _i41.EnrollmentException.fromJson(data) as T; } - if (t == _i42.MicroArea) { - return _i42.MicroArea.fromJson(data) as T; + if (t == _i42.NoticeDeliveryException) { + return _i42.NoticeDeliveryException.fromJson(data) as T; } - if (t == _i43.OtpChallenge) { - return _i43.OtpChallenge.fromJson(data) as T; + if (t == _i43.OtpRequestException) { + return _i43.OtpRequestException.fromJson(data) as T; } - if (t == _i44.Patient) { - return _i44.Patient.fromJson(data) as T; + if (t == _i44.MicroArea) { + return _i44.MicroArea.fromJson(data) as T; } - if (t == _i45.PushToken) { - return _i45.PushToken.fromJson(data) as T; + if (t == _i45.OtpChallenge) { + return _i45.OtpChallenge.fromJson(data) as T; } - if (t == _i46.TriageAnswer) { - return _i46.TriageAnswer.fromJson(data) as T; + if (t == _i46.Patient) { + return _i46.Patient.fromJson(data) as T; } - if (t == _i47.TriageSession) { - return _i47.TriageSession.fromJson(data) as T; + if (t == _i47.PushToken) { + return _i47.PushToken.fromJson(data) as T; } - if (t == _i48.Ubs) { - return _i48.Ubs.fromJson(data) as T; + if (t == _i48.TriageAnswer) { + return _i48.TriageAnswer.fromJson(data) as T; } - if (t == _i49.User) { - return _i49.User.fromJson(data) as T; + if (t == _i49.TriageSession) { + return _i49.TriageSession.fromJson(data) as T; } - if (t == _i50.UserCredential) { - return _i50.UserCredential.fromJson(data) as T; + if (t == _i50.Ubs) { + return _i50.Ubs.fromJson(data) as T; } - if (t == _i51.Visit) { - return _i51.Visit.fromJson(data) as T; + if (t == _i51.User) { + return _i51.User.fromJson(data) as T; + } + if (t == _i52.UserCredential) { + return _i52.UserCredential.fromJson(data) as T; + } + if (t == _i53.Visit) { + return _i53.Visit.fromJson(data) as T; } if (t == _i1.getType<_i2.Acs?>()) { return (data != null ? _i2.Acs.fromJson(data) : null) as T; @@ -340,169 +350,176 @@ class Protocol extends _i1.SerializationManager { if (t == _i1.getType<_i12.MicroAreaPatient?>()) { return (data != null ? _i12.MicroAreaPatient.fromJson(data) : null) as T; } - if (t == _i1.getType<_i13.PatientConsentRecord?>()) { - return (data != null ? _i13.PatientConsentRecord.fromJson(data) : null) + if (t == _i1.getType<_i13.NoticeSendResult?>()) { + return (data != null ? _i13.NoticeSendResult.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i14.PatientConsentRecord?>()) { + return (data != null ? _i14.PatientConsentRecord.fromJson(data) : null) as T; } - if (t == _i1.getType<_i14.PatientDataOverview?>()) { - return (data != null ? _i14.PatientDataOverview.fromJson(data) : null) + if (t == _i1.getType<_i15.PatientDataOverview?>()) { + return (data != null ? _i15.PatientDataOverview.fromJson(data) : null) as T; } - if (t == _i1.getType<_i15.PatientDataSubjectRequestRecord?>()) { + if (t == _i1.getType<_i16.PatientDataSubjectRequestRecord?>()) { return (data != null - ? _i15.PatientDataSubjectRequestRecord.fromJson(data) + ? _i16.PatientDataSubjectRequestRecord.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<_i17.PatientRiskEvent?>()) { + return (data != null ? _i17.PatientRiskEvent.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<_i18.RedAlertResult?>()) { + return (data != null ? _i18.RedAlertResult.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<_i19.ServiceHealth?>()) { + return (data != null ? _i19.ServiceHealth.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<_i20.TriageResult?>()) { + return (data != null ? _i20.TriageResult.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<_i21.VisitSyncEntry?>()) { + return (data != null ? _i21.VisitSyncEntry.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<_i22.VisitSyncResult?>()) { + return (data != null ? _i22.VisitSyncResult.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<_i23.AuditLog?>()) { + return (data != null ? _i23.AuditLog.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<_i24.ConsentLog?>()) { + return (data != null ? _i24.ConsentLog.fromJson(data) : null) as T; } - if (t == _i1.getType<_i24.DataSubjectRequest?>()) { - return (data != null ? _i24.DataSubjectRequest.fromJson(data) : null) + if (t == _i1.getType<_i25.DataSubjectRequest?>()) { + return (data != null ? _i25.DataSubjectRequest.fromJson(data) : null) as T; } - if (t == _i1.getType<_i25.EnrollmentToken?>()) { - return (data != null ? _i25.EnrollmentToken.fromJson(data) : null) as T; + if (t == _i1.getType<_i26.EnrollmentToken?>()) { + return (data != null ? _i26.EnrollmentToken.fromJson(data) : null) as T; } - if (t == _i1.getType<_i26.AlertStatus?>()) { - return (data != null ? _i26.AlertStatus.fromJson(data) : null) as T; + if (t == _i1.getType<_i27.AlertStatus?>()) { + return (data != null ? _i27.AlertStatus.fromJson(data) : null) as T; } - if (t == _i1.getType<_i27.ArrivalMethod?>()) { - return (data != null ? _i27.ArrivalMethod.fromJson(data) : null) as T; + if (t == _i1.getType<_i28.ArrivalMethod?>()) { + return (data != null ? _i28.ArrivalMethod.fromJson(data) : null) as T; } - if (t == _i1.getType<_i28.ConsentPurpose?>()) { - return (data != null ? _i28.ConsentPurpose.fromJson(data) : null) as T; + if (t == _i1.getType<_i29.ConsentPurpose?>()) { + return (data != null ? _i29.ConsentPurpose.fromJson(data) : null) as T; } - if (t == _i1.getType<_i29.DataSubjectRequestStatus?>()) { + if (t == _i1.getType<_i30.DataSubjectRequestStatus?>()) { return (data != null - ? _i29.DataSubjectRequestStatus.fromJson(data) + ? _i30.DataSubjectRequestStatus.fromJson(data) : null) as T; } - if (t == _i1.getType<_i30.DataSubjectRequestType?>()) { - return (data != null ? _i30.DataSubjectRequestType.fromJson(data) : null) + if (t == _i1.getType<_i31.DataSubjectRequestType?>()) { + return (data != null ? _i31.DataSubjectRequestType.fromJson(data) : null) as T; } - if (t == _i1.getType<_i31.RiskLevel?>()) { - return (data != null ? _i31.RiskLevel.fromJson(data) : null) as T; + if (t == _i1.getType<_i32.RiskLevel?>()) { + return (data != null ? _i32.RiskLevel.fromJson(data) : null) as T; } - if (t == _i1.getType<_i32.SyncStatus?>()) { - return (data != null ? _i32.SyncStatus.fromJson(data) : null) as T; + if (t == _i1.getType<_i33.SyncStatus?>()) { + return (data != null ? _i33.SyncStatus.fromJson(data) : null) as T; } - if (t == _i1.getType<_i33.UserRole?>()) { - return (data != null ? _i33.UserRole.fromJson(data) : null) as T; + if (t == _i1.getType<_i34.UserRole?>()) { + return (data != null ? _i34.UserRole.fromJson(data) : null) as T; } - if (t == _i1.getType<_i34.AlertDispatchUnavailableException?>()) { + if (t == _i1.getType<_i35.AlertDispatchUnavailableException?>()) { return (data != null - ? _i34.AlertDispatchUnavailableException.fromJson(data) + ? _i35.AlertDispatchUnavailableException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i35.AlertPermissionException?>()) { + if (t == _i1.getType<_i36.AlertPermissionException?>()) { return (data != null - ? _i35.AlertPermissionException.fromJson(data) + ? _i36.AlertPermissionException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i36.AlertValidationException?>()) { + if (t == _i1.getType<_i37.AlertValidationException?>()) { return (data != null - ? _i36.AlertValidationException.fromJson(data) + ? _i37.AlertValidationException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i37.AuthenticationFailedException?>()) { + if (t == _i1.getType<_i38.AuthenticationFailedException?>()) { return (data != null - ? _i37.AuthenticationFailedException.fromJson(data) + ? _i38.AuthenticationFailedException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i38.DataRightsException?>()) { - return (data != null ? _i38.DataRightsException.fromJson(data) : null) + if (t == _i1.getType<_i39.DataRightsException?>()) { + return (data != null ? _i39.DataRightsException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i39.EndpointDisabledException?>()) { + if (t == _i1.getType<_i40.EndpointDisabledException?>()) { return (data != null - ? _i39.EndpointDisabledException.fromJson(data) + ? _i40.EndpointDisabledException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i40.EnrollmentException?>()) { - return (data != null ? _i40.EnrollmentException.fromJson(data) : null) + if (t == _i1.getType<_i41.EnrollmentException?>()) { + return (data != null ? _i41.EnrollmentException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i41.OtpRequestException?>()) { - return (data != null ? _i41.OtpRequestException.fromJson(data) : null) + if (t == _i1.getType<_i42.NoticeDeliveryException?>()) { + return (data != null ? _i42.NoticeDeliveryException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i42.MicroArea?>()) { - return (data != null ? _i42.MicroArea.fromJson(data) : null) as T; + if (t == _i1.getType<_i43.OtpRequestException?>()) { + return (data != null ? _i43.OtpRequestException.fromJson(data) : null) + as T; } - if (t == _i1.getType<_i43.OtpChallenge?>()) { - return (data != null ? _i43.OtpChallenge.fromJson(data) : null) as T; + if (t == _i1.getType<_i44.MicroArea?>()) { + return (data != null ? _i44.MicroArea.fromJson(data) : null) as T; } - if (t == _i1.getType<_i44.Patient?>()) { - return (data != null ? _i44.Patient.fromJson(data) : null) as T; + if (t == _i1.getType<_i45.OtpChallenge?>()) { + return (data != null ? _i45.OtpChallenge.fromJson(data) : null) as T; } - if (t == _i1.getType<_i45.PushToken?>()) { - return (data != null ? _i45.PushToken.fromJson(data) : null) as T; + if (t == _i1.getType<_i46.Patient?>()) { + return (data != null ? _i46.Patient.fromJson(data) : null) as T; } - if (t == _i1.getType<_i46.TriageAnswer?>()) { - return (data != null ? _i46.TriageAnswer.fromJson(data) : null) as T; + if (t == _i1.getType<_i47.PushToken?>()) { + return (data != null ? _i47.PushToken.fromJson(data) : null) as T; } - if (t == _i1.getType<_i47.TriageSession?>()) { - return (data != null ? _i47.TriageSession.fromJson(data) : null) as T; + if (t == _i1.getType<_i48.TriageAnswer?>()) { + return (data != null ? _i48.TriageAnswer.fromJson(data) : null) as T; } - if (t == _i1.getType<_i48.Ubs?>()) { - return (data != null ? _i48.Ubs.fromJson(data) : null) as T; + if (t == _i1.getType<_i49.TriageSession?>()) { + return (data != null ? _i49.TriageSession.fromJson(data) : null) as T; } - if (t == _i1.getType<_i49.User?>()) { - return (data != null ? _i49.User.fromJson(data) : null) as T; + if (t == _i1.getType<_i50.Ubs?>()) { + return (data != null ? _i50.Ubs.fromJson(data) : null) as T; } - if (t == _i1.getType<_i50.UserCredential?>()) { - return (data != null ? _i50.UserCredential.fromJson(data) : null) as T; + if (t == _i1.getType<_i51.User?>()) { + return (data != null ? _i51.User.fromJson(data) : null) as T; } - if (t == _i1.getType<_i51.Visit?>()) { - return (data != null ? _i51.Visit.fromJson(data) : null) as T; + if (t == _i1.getType<_i52.UserCredential?>()) { + return (data != null ? _i52.UserCredential.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i53.Visit?>()) { + return (data != null ? _i53.Visit.fromJson(data) : null) as T; } if (t == List) { return (data as List).map((e) => deserialize(e)).toList() as T; } - if (t == List<_i13.PatientConsentRecord>) { + if (t == List<_i14.PatientConsentRecord>) { return (data as List) - .map((e) => deserialize<_i13.PatientConsentRecord>(e)) + .map((e) => deserialize<_i14.PatientConsentRecord>(e)) .toList() as T; } - if (t == List<_i16.PatientRiskEvent>) { + if (t == List<_i17.PatientRiskEvent>) { return (data as List) - .map((e) => deserialize<_i16.PatientRiskEvent>(e)) + .map((e) => deserialize<_i17.PatientRiskEvent>(e)) .toList() as T; } - if (t == List<_i15.PatientDataSubjectRequestRecord>) { + if (t == List<_i16.PatientDataSubjectRequestRecord>) { return (data as List) - .map((e) => deserialize<_i15.PatientDataSubjectRequestRecord>(e)) + .map((e) => deserialize<_i16.PatientDataSubjectRequestRecord>(e)) .toList() as T; } @@ -512,24 +529,24 @@ class Protocol extends _i1.SerializationManager { ) as T; } - if (t == List<_i52.MicroAreaPatient>) { + if (t == List<_i54.MicroAreaPatient>) { return (data as List) - .map((e) => deserialize<_i52.MicroAreaPatient>(e)) + .map((e) => deserialize<_i54.MicroAreaPatient>(e)) .toList() as T; } if (t == List) { return (data as List).map((e) => deserialize(e)).toList() as T; } - if (t == List<_i53.VisitSyncResult>) { + if (t == List<_i55.VisitSyncResult>) { return (data as List) - .map((e) => deserialize<_i53.VisitSyncResult>(e)) + .map((e) => deserialize<_i55.VisitSyncResult>(e)) .toList() as T; } - if (t == List<_i54.VisitSyncEntry>) { + if (t == List<_i56.VisitSyncEntry>) { return (data as List) - .map((e) => deserialize<_i54.VisitSyncEntry>(e)) + .map((e) => deserialize<_i56.VisitSyncEntry>(e)) .toList() as T; } @@ -549,46 +566,48 @@ class Protocol extends _i1.SerializationManager { _i10.EnrollmentResult => 'EnrollmentResult', _i11.EnrollmentTokenResult => 'EnrollmentTokenResult', _i12.MicroAreaPatient => 'MicroAreaPatient', - _i13.PatientConsentRecord => 'PatientConsentRecord', - _i14.PatientDataOverview => 'PatientDataOverview', - _i15.PatientDataSubjectRequestRecord => 'PatientDataSubjectRequestRecord', - _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.DataSubjectRequest => 'DataSubjectRequest', - _i25.EnrollmentToken => 'EnrollmentToken', - _i26.AlertStatus => 'AlertStatus', - _i27.ArrivalMethod => 'ArrivalMethod', - _i28.ConsentPurpose => 'ConsentPurpose', - _i29.DataSubjectRequestStatus => 'DataSubjectRequestStatus', - _i30.DataSubjectRequestType => 'DataSubjectRequestType', - _i31.RiskLevel => 'RiskLevel', - _i32.SyncStatus => 'SyncStatus', - _i33.UserRole => 'UserRole', - _i34.AlertDispatchUnavailableException => + _i13.NoticeSendResult => 'NoticeSendResult', + _i14.PatientConsentRecord => 'PatientConsentRecord', + _i15.PatientDataOverview => 'PatientDataOverview', + _i16.PatientDataSubjectRequestRecord => 'PatientDataSubjectRequestRecord', + _i17.PatientRiskEvent => 'PatientRiskEvent', + _i18.RedAlertResult => 'RedAlertResult', + _i19.ServiceHealth => 'ServiceHealth', + _i20.TriageResult => 'TriageResult', + _i21.VisitSyncEntry => 'VisitSyncEntry', + _i22.VisitSyncResult => 'VisitSyncResult', + _i23.AuditLog => 'AuditLog', + _i24.ConsentLog => 'ConsentLog', + _i25.DataSubjectRequest => 'DataSubjectRequest', + _i26.EnrollmentToken => 'EnrollmentToken', + _i27.AlertStatus => 'AlertStatus', + _i28.ArrivalMethod => 'ArrivalMethod', + _i29.ConsentPurpose => 'ConsentPurpose', + _i30.DataSubjectRequestStatus => 'DataSubjectRequestStatus', + _i31.DataSubjectRequestType => 'DataSubjectRequestType', + _i32.RiskLevel => 'RiskLevel', + _i33.SyncStatus => 'SyncStatus', + _i34.UserRole => 'UserRole', + _i35.AlertDispatchUnavailableException => 'AlertDispatchUnavailableException', - _i35.AlertPermissionException => 'AlertPermissionException', - _i36.AlertValidationException => 'AlertValidationException', - _i37.AuthenticationFailedException => 'AuthenticationFailedException', - _i38.DataRightsException => 'DataRightsException', - _i39.EndpointDisabledException => 'EndpointDisabledException', - _i40.EnrollmentException => 'EnrollmentException', - _i41.OtpRequestException => 'OtpRequestException', - _i42.MicroArea => 'MicroArea', - _i43.OtpChallenge => 'OtpChallenge', - _i44.Patient => 'Patient', - _i45.PushToken => 'PushToken', - _i46.TriageAnswer => 'TriageAnswer', - _i47.TriageSession => 'TriageSession', - _i48.Ubs => 'Ubs', - _i49.User => 'User', - _i50.UserCredential => 'UserCredential', - _i51.Visit => 'Visit', + _i36.AlertPermissionException => 'AlertPermissionException', + _i37.AlertValidationException => 'AlertValidationException', + _i38.AuthenticationFailedException => 'AuthenticationFailedException', + _i39.DataRightsException => 'DataRightsException', + _i40.EndpointDisabledException => 'EndpointDisabledException', + _i41.EnrollmentException => 'EnrollmentException', + _i42.NoticeDeliveryException => 'NoticeDeliveryException', + _i43.OtpRequestException => 'OtpRequestException', + _i44.MicroArea => 'MicroArea', + _i45.OtpChallenge => 'OtpChallenge', + _i46.Patient => 'Patient', + _i47.PushToken => 'PushToken', + _i48.TriageAnswer => 'TriageAnswer', + _i49.TriageSession => 'TriageSession', + _i50.Ubs => 'Ubs', + _i51.User => 'User', + _i52.UserCredential => 'UserCredential', + _i53.Visit => 'Visit', _ => null, }; } @@ -625,83 +644,87 @@ class Protocol extends _i1.SerializationManager { return 'EnrollmentTokenResult'; case _i12.MicroAreaPatient(): return 'MicroAreaPatient'; - case _i13.PatientConsentRecord(): + case _i13.NoticeSendResult(): + return 'NoticeSendResult'; + case _i14.PatientConsentRecord(): return 'PatientConsentRecord'; - case _i14.PatientDataOverview(): + case _i15.PatientDataOverview(): return 'PatientDataOverview'; - case _i15.PatientDataSubjectRequestRecord(): + case _i16.PatientDataSubjectRequestRecord(): return 'PatientDataSubjectRequestRecord'; - case _i16.PatientRiskEvent(): + case _i17.PatientRiskEvent(): return 'PatientRiskEvent'; - case _i17.RedAlertResult(): + case _i18.RedAlertResult(): return 'RedAlertResult'; - case _i18.ServiceHealth(): + case _i19.ServiceHealth(): return 'ServiceHealth'; - case _i19.TriageResult(): + case _i20.TriageResult(): return 'TriageResult'; - case _i20.VisitSyncEntry(): + case _i21.VisitSyncEntry(): return 'VisitSyncEntry'; - case _i21.VisitSyncResult(): + case _i22.VisitSyncResult(): return 'VisitSyncResult'; - case _i22.AuditLog(): + case _i23.AuditLog(): return 'AuditLog'; - case _i23.ConsentLog(): + case _i24.ConsentLog(): return 'ConsentLog'; - case _i24.DataSubjectRequest(): + case _i25.DataSubjectRequest(): return 'DataSubjectRequest'; - case _i25.EnrollmentToken(): + case _i26.EnrollmentToken(): return 'EnrollmentToken'; - case _i26.AlertStatus(): + case _i27.AlertStatus(): return 'AlertStatus'; - case _i27.ArrivalMethod(): + case _i28.ArrivalMethod(): return 'ArrivalMethod'; - case _i28.ConsentPurpose(): + case _i29.ConsentPurpose(): return 'ConsentPurpose'; - case _i29.DataSubjectRequestStatus(): + case _i30.DataSubjectRequestStatus(): return 'DataSubjectRequestStatus'; - case _i30.DataSubjectRequestType(): + case _i31.DataSubjectRequestType(): return 'DataSubjectRequestType'; - case _i31.RiskLevel(): + case _i32.RiskLevel(): return 'RiskLevel'; - case _i32.SyncStatus(): + case _i33.SyncStatus(): return 'SyncStatus'; - case _i33.UserRole(): + case _i34.UserRole(): return 'UserRole'; - case _i34.AlertDispatchUnavailableException(): + case _i35.AlertDispatchUnavailableException(): return 'AlertDispatchUnavailableException'; - case _i35.AlertPermissionException(): + case _i36.AlertPermissionException(): return 'AlertPermissionException'; - case _i36.AlertValidationException(): + case _i37.AlertValidationException(): return 'AlertValidationException'; - case _i37.AuthenticationFailedException(): + case _i38.AuthenticationFailedException(): return 'AuthenticationFailedException'; - case _i38.DataRightsException(): + case _i39.DataRightsException(): return 'DataRightsException'; - case _i39.EndpointDisabledException(): + case _i40.EndpointDisabledException(): return 'EndpointDisabledException'; - case _i40.EnrollmentException(): + case _i41.EnrollmentException(): return 'EnrollmentException'; - case _i41.OtpRequestException(): + case _i42.NoticeDeliveryException(): + return 'NoticeDeliveryException'; + case _i43.OtpRequestException(): return 'OtpRequestException'; - case _i42.MicroArea(): + case _i44.MicroArea(): return 'MicroArea'; - case _i43.OtpChallenge(): + case _i45.OtpChallenge(): return 'OtpChallenge'; - case _i44.Patient(): + case _i46.Patient(): return 'Patient'; - case _i45.PushToken(): + case _i47.PushToken(): return 'PushToken'; - case _i46.TriageAnswer(): + case _i48.TriageAnswer(): return 'TriageAnswer'; - case _i47.TriageSession(): + case _i49.TriageSession(): return 'TriageSession'; - case _i48.Ubs(): + case _i50.Ubs(): return 'Ubs'; - case _i49.User(): + case _i51.User(): return 'User'; - case _i50.UserCredential(): + case _i52.UserCredential(): return 'UserCredential'; - case _i51.Visit(): + case _i53.Visit(): return 'Visit'; } return null; @@ -746,122 +769,128 @@ class Protocol extends _i1.SerializationManager { if (dataClassName == 'MicroAreaPatient') { return deserialize<_i12.MicroAreaPatient>(data['data']); } + if (dataClassName == 'NoticeSendResult') { + return deserialize<_i13.NoticeSendResult>(data['data']); + } if (dataClassName == 'PatientConsentRecord') { - return deserialize<_i13.PatientConsentRecord>(data['data']); + return deserialize<_i14.PatientConsentRecord>(data['data']); } if (dataClassName == 'PatientDataOverview') { - return deserialize<_i14.PatientDataOverview>(data['data']); + return deserialize<_i15.PatientDataOverview>(data['data']); } if (dataClassName == 'PatientDataSubjectRequestRecord') { - return deserialize<_i15.PatientDataSubjectRequestRecord>(data['data']); + return deserialize<_i16.PatientDataSubjectRequestRecord>(data['data']); } if (dataClassName == 'PatientRiskEvent') { - return deserialize<_i16.PatientRiskEvent>(data['data']); + return deserialize<_i17.PatientRiskEvent>(data['data']); } if (dataClassName == 'RedAlertResult') { - return deserialize<_i17.RedAlertResult>(data['data']); + return deserialize<_i18.RedAlertResult>(data['data']); } if (dataClassName == 'ServiceHealth') { - return deserialize<_i18.ServiceHealth>(data['data']); + return deserialize<_i19.ServiceHealth>(data['data']); } if (dataClassName == 'TriageResult') { - return deserialize<_i19.TriageResult>(data['data']); + return deserialize<_i20.TriageResult>(data['data']); } if (dataClassName == 'VisitSyncEntry') { - return deserialize<_i20.VisitSyncEntry>(data['data']); + return deserialize<_i21.VisitSyncEntry>(data['data']); } if (dataClassName == 'VisitSyncResult') { - return deserialize<_i21.VisitSyncResult>(data['data']); + return deserialize<_i22.VisitSyncResult>(data['data']); } if (dataClassName == 'AuditLog') { - return deserialize<_i22.AuditLog>(data['data']); + return deserialize<_i23.AuditLog>(data['data']); } if (dataClassName == 'ConsentLog') { - return deserialize<_i23.ConsentLog>(data['data']); + return deserialize<_i24.ConsentLog>(data['data']); } if (dataClassName == 'DataSubjectRequest') { - return deserialize<_i24.DataSubjectRequest>(data['data']); + return deserialize<_i25.DataSubjectRequest>(data['data']); } if (dataClassName == 'EnrollmentToken') { - return deserialize<_i25.EnrollmentToken>(data['data']); + return deserialize<_i26.EnrollmentToken>(data['data']); } if (dataClassName == 'AlertStatus') { - return deserialize<_i26.AlertStatus>(data['data']); + return deserialize<_i27.AlertStatus>(data['data']); } if (dataClassName == 'ArrivalMethod') { - return deserialize<_i27.ArrivalMethod>(data['data']); + return deserialize<_i28.ArrivalMethod>(data['data']); } if (dataClassName == 'ConsentPurpose') { - return deserialize<_i28.ConsentPurpose>(data['data']); + return deserialize<_i29.ConsentPurpose>(data['data']); } if (dataClassName == 'DataSubjectRequestStatus') { - return deserialize<_i29.DataSubjectRequestStatus>(data['data']); + return deserialize<_i30.DataSubjectRequestStatus>(data['data']); } if (dataClassName == 'DataSubjectRequestType') { - return deserialize<_i30.DataSubjectRequestType>(data['data']); + return deserialize<_i31.DataSubjectRequestType>(data['data']); } if (dataClassName == 'RiskLevel') { - return deserialize<_i31.RiskLevel>(data['data']); + return deserialize<_i32.RiskLevel>(data['data']); } if (dataClassName == 'SyncStatus') { - return deserialize<_i32.SyncStatus>(data['data']); + return deserialize<_i33.SyncStatus>(data['data']); } if (dataClassName == 'UserRole') { - return deserialize<_i33.UserRole>(data['data']); + return deserialize<_i34.UserRole>(data['data']); } if (dataClassName == 'AlertDispatchUnavailableException') { - return deserialize<_i34.AlertDispatchUnavailableException>(data['data']); + return deserialize<_i35.AlertDispatchUnavailableException>(data['data']); } if (dataClassName == 'AlertPermissionException') { - return deserialize<_i35.AlertPermissionException>(data['data']); + return deserialize<_i36.AlertPermissionException>(data['data']); } if (dataClassName == 'AlertValidationException') { - return deserialize<_i36.AlertValidationException>(data['data']); + return deserialize<_i37.AlertValidationException>(data['data']); } if (dataClassName == 'AuthenticationFailedException') { - return deserialize<_i37.AuthenticationFailedException>(data['data']); + return deserialize<_i38.AuthenticationFailedException>(data['data']); } if (dataClassName == 'DataRightsException') { - return deserialize<_i38.DataRightsException>(data['data']); + return deserialize<_i39.DataRightsException>(data['data']); } if (dataClassName == 'EndpointDisabledException') { - return deserialize<_i39.EndpointDisabledException>(data['data']); + return deserialize<_i40.EndpointDisabledException>(data['data']); } if (dataClassName == 'EnrollmentException') { - return deserialize<_i40.EnrollmentException>(data['data']); + return deserialize<_i41.EnrollmentException>(data['data']); + } + if (dataClassName == 'NoticeDeliveryException') { + return deserialize<_i42.NoticeDeliveryException>(data['data']); } if (dataClassName == 'OtpRequestException') { - return deserialize<_i41.OtpRequestException>(data['data']); + return deserialize<_i43.OtpRequestException>(data['data']); } if (dataClassName == 'MicroArea') { - return deserialize<_i42.MicroArea>(data['data']); + return deserialize<_i44.MicroArea>(data['data']); } if (dataClassName == 'OtpChallenge') { - return deserialize<_i43.OtpChallenge>(data['data']); + return deserialize<_i45.OtpChallenge>(data['data']); } if (dataClassName == 'Patient') { - return deserialize<_i44.Patient>(data['data']); + return deserialize<_i46.Patient>(data['data']); } if (dataClassName == 'PushToken') { - return deserialize<_i45.PushToken>(data['data']); + return deserialize<_i47.PushToken>(data['data']); } if (dataClassName == 'TriageAnswer') { - return deserialize<_i46.TriageAnswer>(data['data']); + return deserialize<_i48.TriageAnswer>(data['data']); } if (dataClassName == 'TriageSession') { - return deserialize<_i47.TriageSession>(data['data']); + return deserialize<_i49.TriageSession>(data['data']); } if (dataClassName == 'Ubs') { - return deserialize<_i48.Ubs>(data['data']); + return deserialize<_i50.Ubs>(data['data']); } if (dataClassName == 'User') { - return deserialize<_i49.User>(data['data']); + return deserialize<_i51.User>(data['data']); } if (dataClassName == 'UserCredential') { - return deserialize<_i50.UserCredential>(data['data']); + return deserialize<_i52.UserCredential>(data['data']); } if (dataClassName == 'Visit') { - return deserialize<_i51.Visit>(data['data']); + return deserialize<_i53.Visit>(data['data']); } return super.deserializeByClassName(data); } 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..3ad1443e --- /dev/null +++ b/backend/sinalacs_server/lib/src/application/notices/notice_service.dart @@ -0,0 +1,126 @@ +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'}; + +/// 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.'); + } + final sender = _sender; + if (sender == null) { + throw NoticeDeliveryException( + message: 'O envio de avisos não está configurado neste ambiente.', + ); + } + + final microAreaId = user.microAreaId!; + final targets = await _store.consentedTargets( + microAreaId: microAreaId, + chronicOnly: audience == 'chronic', + ); + if (targets.isEmpty) { + await _record(user, microAreaId, 'no_recipients'); + return const NoticeSendSnapshot(recipients: 0, accepted: 0); + } + + final PushSendReport report; + try { + report = await sender.send( + PushMessage(title: cleanTitle, body: cleanMessage, data: const {'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); + } + await _record(user, microAreaId, 'granted'); + 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(AuthenticatedUser user, String microAreaId, String result) => + _audit.recordSafely(AuditEvent( + userId: user.id, + actionType: 'write', + resourceType: 'community_notice', + resourceId: microAreaId, + result: result, + )); +} 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/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/endpoints.dart b/backend/sinalacs_server/lib/src/generated/endpoints.dart index b8fe8999..8216febc 100644 --- a/backend/sinalacs_server/lib/src/generated/endpoints.dart +++ b/backend/sinalacs_server/lib/src/generated/endpoints.dart @@ -16,14 +16,15 @@ import '../endpoints/alerts_endpoint.dart' as _i2; import '../endpoints/auth_endpoint.dart' as _i3; import '../endpoints/devices_endpoint.dart' as _i4; import '../endpoints/health_endpoint.dart' as _i5; -import '../endpoints/onboarding_endpoint.dart' as _i6; -import '../endpoints/patients_endpoint.dart' as _i7; -import '../endpoints/triage_endpoint.dart' as _i8; -import '../endpoints/visits_endpoint.dart' as _i9; +import '../endpoints/notices_endpoint.dart' as _i6; +import '../endpoints/onboarding_endpoint.dart' as _i7; +import '../endpoints/patients_endpoint.dart' as _i8; +import '../endpoints/triage_endpoint.dart' as _i9; +import '../endpoints/visits_endpoint.dart' as _i10; import 'package:sinalacs_server/src/generated/enums/consent_purpose.dart' - as _i10; -import 'package:sinalacs_server/src/generated/api/visit_sync_entry.dart' as _i11; +import 'package:sinalacs_server/src/generated/api/visit_sync_entry.dart' + as _i12; class Endpoints extends _i1.EndpointDispatch { @override @@ -53,25 +54,31 @@ class Endpoints extends _i1.EndpointDispatch { 'health', null, ), - 'onboarding': _i6.OnboardingEndpoint() + 'notices': _i6.NoticesEndpoint() + ..initialize( + server, + 'notices', + null, + ), + 'onboarding': _i7.OnboardingEndpoint() ..initialize( server, 'onboarding', null, ), - 'patients': _i7.PatientsEndpoint() + 'patients': _i8.PatientsEndpoint() ..initialize( server, 'patients', null, ), - 'triage': _i8.TriageEndpoint() + 'triage': _i9.TriageEndpoint() ..initialize( server, 'triage', null, ), - 'visits': _i9.VisitsEndpoint() + 'visits': _i10.VisitsEndpoint() ..initialize( server, 'visits', @@ -327,6 +334,49 @@ class Endpoints extends _i1.EndpointDispatch { ), }, ); + 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 _i6.NoticesEndpoint).sendSegmented( + session, + accessToken: params['accessToken'], + title: params['title'], + message: params['message'], + audience: params['audience'], + ), + ), + }, + ); connectors['onboarding'] = _i1.EndpointConnector( name: 'onboarding', endpoint: endpoints['onboarding']!, @@ -349,7 +399,7 @@ class Endpoints extends _i1.EndpointDispatch { ( _i1.Session session, Map params, - ) async => (endpoints['onboarding'] as _i6.OnboardingEndpoint) + ) async => (endpoints['onboarding'] as _i7.OnboardingEndpoint) .generateEnrollmentToken( session, accessToken: params['accessToken'], @@ -389,7 +439,7 @@ class Endpoints extends _i1.EndpointDispatch { ( _i1.Session session, Map params, - ) async => (endpoints['onboarding'] as _i6.OnboardingEndpoint) + ) async => (endpoints['onboarding'] as _i7.OnboardingEndpoint) .completeEnrollment( session, token: params['token'], @@ -419,7 +469,7 @@ class Endpoints extends _i1.EndpointDispatch { _i1.Session session, Map params, ) async => - (endpoints['patients'] as _i7.PatientsEndpoint).listMicroArea( + (endpoints['patients'] as _i8.PatientsEndpoint).listMicroArea( session, accessToken: params['accessToken'], ), @@ -437,7 +487,7 @@ class Endpoints extends _i1.EndpointDispatch { ( _i1.Session session, Map params, - ) async => (endpoints['patients'] as _i7.PatientsEndpoint) + ) async => (endpoints['patients'] as _i8.PatientsEndpoint) .myChronicConditions( session, accessToken: params['accessToken'], @@ -461,7 +511,7 @@ class Endpoints extends _i1.EndpointDispatch { ( _i1.Session session, Map params, - ) async => (endpoints['patients'] as _i7.PatientsEndpoint) + ) async => (endpoints['patients'] as _i8.PatientsEndpoint) .updateChronicConditions( session, accessToken: params['accessToken'], @@ -481,7 +531,7 @@ class Endpoints extends _i1.EndpointDispatch { ( _i1.Session session, Map params, - ) async => (endpoints['patients'] as _i7.PatientsEndpoint).myData( + ) async => (endpoints['patients'] as _i8.PatientsEndpoint).myData( session, accessToken: params['accessToken'], ), @@ -496,7 +546,7 @@ class Endpoints extends _i1.EndpointDispatch { ), 'purpose': _i1.ParameterDescription( name: 'purpose', - type: _i1.getType<_i10.ConsentPurpose>(), + type: _i1.getType<_i11.ConsentPurpose>(), nullable: false, ), 'granted': _i1.ParameterDescription( @@ -510,7 +560,7 @@ class Endpoints extends _i1.EndpointDispatch { _i1.Session session, Map params, ) async => - (endpoints['patients'] as _i7.PatientsEndpoint).updateConsent( + (endpoints['patients'] as _i8.PatientsEndpoint).updateConsent( session, accessToken: params['accessToken'], purpose: params['purpose'], @@ -530,7 +580,7 @@ class Endpoints extends _i1.EndpointDispatch { ( _i1.Session session, Map params, - ) async => (endpoints['patients'] as _i7.PatientsEndpoint) + ) async => (endpoints['patients'] as _i8.PatientsEndpoint) .acceptTermsOfUse( session, accessToken: params['accessToken'], @@ -549,7 +599,7 @@ class Endpoints extends _i1.EndpointDispatch { ( _i1.Session session, Map params, - ) async => (endpoints['patients'] as _i7.PatientsEndpoint) + ) async => (endpoints['patients'] as _i8.PatientsEndpoint) .hasAcceptedCurrentTerms( session, accessToken: params['accessToken'], @@ -568,7 +618,7 @@ class Endpoints extends _i1.EndpointDispatch { ( _i1.Session session, Map params, - ) async => (endpoints['patients'] as _i7.PatientsEndpoint) + ) async => (endpoints['patients'] as _i8.PatientsEndpoint) .requestDataDeletion( session, accessToken: params['accessToken'], @@ -592,7 +642,7 @@ class Endpoints extends _i1.EndpointDispatch { ( _i1.Session session, Map params, - ) async => (endpoints['patients'] as _i7.PatientsEndpoint) + ) async => (endpoints['patients'] as _i8.PatientsEndpoint) .requestDataCorrection( session, accessToken: params['accessToken'], @@ -648,7 +698,7 @@ class Endpoints extends _i1.EndpointDispatch { ( _i1.Session session, Map params, - ) async => (endpoints['triage'] as _i8.TriageEndpoint).evaluate( + ) async => (endpoints['triage'] as _i9.TriageEndpoint).evaluate( session, accessToken: params['accessToken'], chestPain: params['chestPain'], @@ -675,7 +725,7 @@ class Endpoints extends _i1.EndpointDispatch { ), 'visits': _i1.ParameterDescription( name: 'visits', - type: _i1.getType>(), + type: _i1.getType>(), nullable: false, ), }, @@ -683,7 +733,7 @@ class Endpoints extends _i1.EndpointDispatch { ( _i1.Session session, Map params, - ) async => (endpoints['visits'] as _i9.VisitsEndpoint).sync( + ) async => (endpoints['visits'] as _i10.VisitsEndpoint).sync( session, accessToken: params['accessToken'], visits: params['visits'], @@ -707,7 +757,7 @@ class Endpoints extends _i1.EndpointDispatch { ( _i1.Session session, Map params, - ) async => (endpoints['visits'] as _i9.VisitsEndpoint).pull( + ) async => (endpoints['visits'] as _i10.VisitsEndpoint).pull( session, accessToken: params['accessToken'], since: params['since'], 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/protocol.dart b/backend/sinalacs_server/lib/src/generated/protocol.dart index d4b59e33..6fbdd928 100644 --- a/backend/sinalacs_server/lib/src/generated/protocol.dart +++ b/backend/sinalacs_server/lib/src/generated/protocol.dart @@ -24,51 +24,53 @@ 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_data_subject_request_record.dart' as _i16; -import 'api/patient_risk_event.dart' as _i17; -import 'api/red_alert_result.dart' as _i18; -import 'api/service_health.dart' as _i19; -import 'api/triage_result.dart' as _i20; -import 'api/visit_sync_entry.dart' as _i21; -import 'api/visit_sync_result.dart' as _i22; -import 'audit_log.dart' as _i23; -import 'consent_log.dart' as _i24; -import 'data_subject_request.dart' as _i25; -import 'enrollment_token.dart' as _i26; -import 'enums/alert_status.dart' as _i27; -import 'enums/arrival_method.dart' as _i28; -import 'enums/consent_purpose.dart' as _i29; -import 'enums/data_subject_request_status.dart' as _i30; -import 'enums/data_subject_request_type.dart' as _i31; -import 'enums/risk_level.dart' as _i32; -import 'enums/sync_status.dart' as _i33; -import 'enums/user_role.dart' as _i34; -import 'exceptions/alert_dispatch_unavailable_exception.dart' as _i35; -import 'exceptions/alert_permission_exception.dart' as _i36; -import 'exceptions/alert_validation_exception.dart' as _i37; -import 'exceptions/authentication_failed_exception.dart' as _i38; -import 'exceptions/data_rights_exception.dart' as _i39; -import 'exceptions/endpoint_disabled_exception.dart' as _i40; -import 'exceptions/enrollment_exception.dart' as _i41; -import 'exceptions/otp_request_exception.dart' as _i42; -import 'micro_area.dart' as _i43; -import 'otp_challenge.dart' as _i44; -import 'patient.dart' as _i45; -import 'push_token.dart' as _i46; -import 'triage_answer.dart' as _i47; -import 'triage_session.dart' as _i48; -import 'ubs.dart' as _i49; -import 'user.dart' as _i50; -import 'user_credential.dart' as _i51; -import 'visit.dart' as _i52; +import 'api/notice_send_result.dart' as _i14; +import 'api/patient_consent_record.dart' as _i15; +import 'api/patient_data_overview.dart' as _i16; +import 'api/patient_data_subject_request_record.dart' as _i17; +import 'api/patient_risk_event.dart' as _i18; +import 'api/red_alert_result.dart' as _i19; +import 'api/service_health.dart' as _i20; +import 'api/triage_result.dart' as _i21; +import 'api/visit_sync_entry.dart' as _i22; +import 'api/visit_sync_result.dart' as _i23; +import 'audit_log.dart' as _i24; +import 'consent_log.dart' as _i25; +import 'data_subject_request.dart' as _i26; +import 'enrollment_token.dart' as _i27; +import 'enums/alert_status.dart' as _i28; +import 'enums/arrival_method.dart' as _i29; +import 'enums/consent_purpose.dart' as _i30; +import 'enums/data_subject_request_status.dart' as _i31; +import 'enums/data_subject_request_type.dart' as _i32; +import 'enums/risk_level.dart' as _i33; +import 'enums/sync_status.dart' as _i34; +import 'enums/user_role.dart' as _i35; +import 'exceptions/alert_dispatch_unavailable_exception.dart' as _i36; +import 'exceptions/alert_permission_exception.dart' as _i37; +import 'exceptions/alert_validation_exception.dart' as _i38; +import 'exceptions/authentication_failed_exception.dart' as _i39; +import 'exceptions/data_rights_exception.dart' as _i40; +import 'exceptions/endpoint_disabled_exception.dart' as _i41; +import 'exceptions/enrollment_exception.dart' as _i42; +import 'exceptions/notice_delivery_exception.dart' as _i43; +import 'exceptions/otp_request_exception.dart' as _i44; +import 'micro_area.dart' as _i45; +import 'otp_challenge.dart' as _i46; +import 'patient.dart' as _i47; +import 'push_token.dart' as _i48; +import 'triage_answer.dart' as _i49; +import 'triage_session.dart' as _i50; +import 'ubs.dart' as _i51; +import 'user.dart' as _i52; +import 'user_credential.dart' as _i53; +import 'visit.dart' as _i54; import 'package:sinalacs_server/src/generated/api/micro_area_patient.dart' - as _i53; + as _i55; import 'package:sinalacs_server/src/generated/api/visit_sync_result.dart' - as _i54; + as _i56; import 'package:sinalacs_server/src/generated/api/visit_sync_entry.dart' - as _i55; + as _i57; export 'acs.dart'; export 'alert.dart'; export 'alert_delivery_record.dart'; @@ -80,6 +82,7 @@ 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_send_result.dart'; export 'api/patient_consent_record.dart'; export 'api/patient_data_overview.dart'; export 'api/patient_data_subject_request_record.dart'; @@ -108,6 +111,7 @@ 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/notice_delivery_exception.dart'; export 'exceptions/otp_request_exception.dart'; export 'micro_area.dart'; export 'otp_challenge.dart'; @@ -1982,122 +1986,128 @@ class Protocol extends _i1.SerializationManagerServer { if (t == _i13.MicroAreaPatient) { return _i13.MicroAreaPatient.fromJson(data) as T; } - if (t == _i14.PatientConsentRecord) { - return _i14.PatientConsentRecord.fromJson(data) as T; + if (t == _i14.NoticeSendResult) { + return _i14.NoticeSendResult.fromJson(data) as T; + } + if (t == _i15.PatientConsentRecord) { + return _i15.PatientConsentRecord.fromJson(data) as T; } - if (t == _i15.PatientDataOverview) { - return _i15.PatientDataOverview.fromJson(data) as T; + if (t == _i16.PatientDataOverview) { + return _i16.PatientDataOverview.fromJson(data) as T; } - if (t == _i16.PatientDataSubjectRequestRecord) { - return _i16.PatientDataSubjectRequestRecord.fromJson(data) as T; + if (t == _i17.PatientDataSubjectRequestRecord) { + return _i17.PatientDataSubjectRequestRecord.fromJson(data) as T; } - if (t == _i17.PatientRiskEvent) { - return _i17.PatientRiskEvent.fromJson(data) as T; + if (t == _i18.PatientRiskEvent) { + return _i18.PatientRiskEvent.fromJson(data) as T; } - if (t == _i18.RedAlertResult) { - return _i18.RedAlertResult.fromJson(data) as T; + if (t == _i19.RedAlertResult) { + return _i19.RedAlertResult.fromJson(data) as T; } - if (t == _i19.ServiceHealth) { - return _i19.ServiceHealth.fromJson(data) as T; + if (t == _i20.ServiceHealth) { + return _i20.ServiceHealth.fromJson(data) as T; } - if (t == _i20.TriageResult) { - return _i20.TriageResult.fromJson(data) as T; + if (t == _i21.TriageResult) { + return _i21.TriageResult.fromJson(data) as T; } - if (t == _i21.VisitSyncEntry) { - return _i21.VisitSyncEntry.fromJson(data) as T; + if (t == _i22.VisitSyncEntry) { + return _i22.VisitSyncEntry.fromJson(data) as T; } - if (t == _i22.VisitSyncResult) { - return _i22.VisitSyncResult.fromJson(data) as T; + if (t == _i23.VisitSyncResult) { + return _i23.VisitSyncResult.fromJson(data) as T; } - if (t == _i23.AuditLog) { - return _i23.AuditLog.fromJson(data) as T; + if (t == _i24.AuditLog) { + return _i24.AuditLog.fromJson(data) as T; } - if (t == _i24.ConsentLog) { - return _i24.ConsentLog.fromJson(data) as T; + if (t == _i25.ConsentLog) { + return _i25.ConsentLog.fromJson(data) as T; } - if (t == _i25.DataSubjectRequest) { - return _i25.DataSubjectRequest.fromJson(data) as T; + if (t == _i26.DataSubjectRequest) { + return _i26.DataSubjectRequest.fromJson(data) as T; } - if (t == _i26.EnrollmentToken) { - return _i26.EnrollmentToken.fromJson(data) as T; + if (t == _i27.EnrollmentToken) { + return _i27.EnrollmentToken.fromJson(data) as T; } - if (t == _i27.AlertStatus) { - return _i27.AlertStatus.fromJson(data) as T; + if (t == _i28.AlertStatus) { + return _i28.AlertStatus.fromJson(data) as T; } - if (t == _i28.ArrivalMethod) { - return _i28.ArrivalMethod.fromJson(data) as T; + if (t == _i29.ArrivalMethod) { + return _i29.ArrivalMethod.fromJson(data) as T; } - if (t == _i29.ConsentPurpose) { - return _i29.ConsentPurpose.fromJson(data) as T; + if (t == _i30.ConsentPurpose) { + return _i30.ConsentPurpose.fromJson(data) as T; } - if (t == _i30.DataSubjectRequestStatus) { - return _i30.DataSubjectRequestStatus.fromJson(data) as T; + if (t == _i31.DataSubjectRequestStatus) { + return _i31.DataSubjectRequestStatus.fromJson(data) as T; } - if (t == _i31.DataSubjectRequestType) { - return _i31.DataSubjectRequestType.fromJson(data) as T; + if (t == _i32.DataSubjectRequestType) { + return _i32.DataSubjectRequestType.fromJson(data) as T; } - if (t == _i32.RiskLevel) { - return _i32.RiskLevel.fromJson(data) as T; + if (t == _i33.RiskLevel) { + return _i33.RiskLevel.fromJson(data) as T; } - if (t == _i33.SyncStatus) { - return _i33.SyncStatus.fromJson(data) as T; + if (t == _i34.SyncStatus) { + return _i34.SyncStatus.fromJson(data) as T; } - if (t == _i34.UserRole) { - return _i34.UserRole.fromJson(data) as T; + if (t == _i35.UserRole) { + return _i35.UserRole.fromJson(data) as T; } - if (t == _i35.AlertDispatchUnavailableException) { - return _i35.AlertDispatchUnavailableException.fromJson(data) as T; + if (t == _i36.AlertDispatchUnavailableException) { + return _i36.AlertDispatchUnavailableException.fromJson(data) as T; } - if (t == _i36.AlertPermissionException) { - return _i36.AlertPermissionException.fromJson(data) as T; + if (t == _i37.AlertPermissionException) { + return _i37.AlertPermissionException.fromJson(data) as T; } - if (t == _i37.AlertValidationException) { - return _i37.AlertValidationException.fromJson(data) as T; + if (t == _i38.AlertValidationException) { + return _i38.AlertValidationException.fromJson(data) as T; } - if (t == _i38.AuthenticationFailedException) { - return _i38.AuthenticationFailedException.fromJson(data) as T; + if (t == _i39.AuthenticationFailedException) { + return _i39.AuthenticationFailedException.fromJson(data) as T; } - if (t == _i39.DataRightsException) { - return _i39.DataRightsException.fromJson(data) as T; + if (t == _i40.DataRightsException) { + return _i40.DataRightsException.fromJson(data) as T; } - if (t == _i40.EndpointDisabledException) { - return _i40.EndpointDisabledException.fromJson(data) as T; + if (t == _i41.EndpointDisabledException) { + return _i41.EndpointDisabledException.fromJson(data) as T; } - if (t == _i41.EnrollmentException) { - return _i41.EnrollmentException.fromJson(data) as T; + if (t == _i42.EnrollmentException) { + return _i42.EnrollmentException.fromJson(data) as T; } - if (t == _i42.OtpRequestException) { - return _i42.OtpRequestException.fromJson(data) as T; + if (t == _i43.NoticeDeliveryException) { + return _i43.NoticeDeliveryException.fromJson(data) as T; } - if (t == _i43.MicroArea) { - return _i43.MicroArea.fromJson(data) as T; + if (t == _i44.OtpRequestException) { + return _i44.OtpRequestException.fromJson(data) as T; } - if (t == _i44.OtpChallenge) { - return _i44.OtpChallenge.fromJson(data) as T; + if (t == _i45.MicroArea) { + return _i45.MicroArea.fromJson(data) as T; } - if (t == _i45.Patient) { - return _i45.Patient.fromJson(data) as T; + if (t == _i46.OtpChallenge) { + return _i46.OtpChallenge.fromJson(data) as T; } - if (t == _i46.PushToken) { - return _i46.PushToken.fromJson(data) as T; + if (t == _i47.Patient) { + return _i47.Patient.fromJson(data) as T; } - if (t == _i47.TriageAnswer) { - return _i47.TriageAnswer.fromJson(data) as T; + if (t == _i48.PushToken) { + return _i48.PushToken.fromJson(data) as T; } - if (t == _i48.TriageSession) { - return _i48.TriageSession.fromJson(data) as T; + if (t == _i49.TriageAnswer) { + return _i49.TriageAnswer.fromJson(data) as T; } - if (t == _i49.Ubs) { - return _i49.Ubs.fromJson(data) as T; + if (t == _i50.TriageSession) { + return _i50.TriageSession.fromJson(data) as T; } - if (t == _i50.User) { - return _i50.User.fromJson(data) as T; + if (t == _i51.Ubs) { + return _i51.Ubs.fromJson(data) as T; } - if (t == _i51.UserCredential) { - return _i51.UserCredential.fromJson(data) as T; + if (t == _i52.User) { + return _i52.User.fromJson(data) as T; } - if (t == _i52.Visit) { - return _i52.Visit.fromJson(data) as T; + if (t == _i53.UserCredential) { + return _i53.UserCredential.fromJson(data) as T; + } + if (t == _i54.Visit) { + return _i54.Visit.fromJson(data) as T; } if (t == _i1.getType<_i3.Acs?>()) { return (data != null ? _i3.Acs.fromJson(data) : null) as T; @@ -2136,169 +2146,176 @@ class Protocol extends _i1.SerializationManagerServer { if (t == _i1.getType<_i13.MicroAreaPatient?>()) { return (data != null ? _i13.MicroAreaPatient.fromJson(data) : null) as T; } - if (t == _i1.getType<_i14.PatientConsentRecord?>()) { - return (data != null ? _i14.PatientConsentRecord.fromJson(data) : null) + if (t == _i1.getType<_i14.NoticeSendResult?>()) { + return (data != null ? _i14.NoticeSendResult.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i15.PatientConsentRecord?>()) { + return (data != null ? _i15.PatientConsentRecord.fromJson(data) : null) as T; } - if (t == _i1.getType<_i15.PatientDataOverview?>()) { - return (data != null ? _i15.PatientDataOverview.fromJson(data) : null) + if (t == _i1.getType<_i16.PatientDataOverview?>()) { + return (data != null ? _i16.PatientDataOverview.fromJson(data) : null) as T; } - if (t == _i1.getType<_i16.PatientDataSubjectRequestRecord?>()) { + if (t == _i1.getType<_i17.PatientDataSubjectRequestRecord?>()) { return (data != null - ? _i16.PatientDataSubjectRequestRecord.fromJson(data) + ? _i17.PatientDataSubjectRequestRecord.fromJson(data) : null) as T; } - if (t == _i1.getType<_i17.PatientRiskEvent?>()) { - return (data != null ? _i17.PatientRiskEvent.fromJson(data) : null) as T; + if (t == _i1.getType<_i18.PatientRiskEvent?>()) { + return (data != null ? _i18.PatientRiskEvent.fromJson(data) : null) as T; } - if (t == _i1.getType<_i18.RedAlertResult?>()) { - return (data != null ? _i18.RedAlertResult.fromJson(data) : null) as T; + if (t == _i1.getType<_i19.RedAlertResult?>()) { + return (data != null ? _i19.RedAlertResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i19.ServiceHealth?>()) { - return (data != null ? _i19.ServiceHealth.fromJson(data) : null) as T; + if (t == _i1.getType<_i20.ServiceHealth?>()) { + return (data != null ? _i20.ServiceHealth.fromJson(data) : null) as T; } - if (t == _i1.getType<_i20.TriageResult?>()) { - return (data != null ? _i20.TriageResult.fromJson(data) : null) as T; + if (t == _i1.getType<_i21.TriageResult?>()) { + return (data != null ? _i21.TriageResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i21.VisitSyncEntry?>()) { - return (data != null ? _i21.VisitSyncEntry.fromJson(data) : null) as T; + if (t == _i1.getType<_i22.VisitSyncEntry?>()) { + return (data != null ? _i22.VisitSyncEntry.fromJson(data) : null) as T; } - if (t == _i1.getType<_i22.VisitSyncResult?>()) { - return (data != null ? _i22.VisitSyncResult.fromJson(data) : null) as T; + if (t == _i1.getType<_i23.VisitSyncResult?>()) { + return (data != null ? _i23.VisitSyncResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i23.AuditLog?>()) { - return (data != null ? _i23.AuditLog.fromJson(data) : null) as T; + if (t == _i1.getType<_i24.AuditLog?>()) { + return (data != null ? _i24.AuditLog.fromJson(data) : null) as T; } - if (t == _i1.getType<_i24.ConsentLog?>()) { - return (data != null ? _i24.ConsentLog.fromJson(data) : null) as T; + if (t == _i1.getType<_i25.ConsentLog?>()) { + return (data != null ? _i25.ConsentLog.fromJson(data) : null) as T; } - if (t == _i1.getType<_i25.DataSubjectRequest?>()) { - return (data != null ? _i25.DataSubjectRequest.fromJson(data) : null) + if (t == _i1.getType<_i26.DataSubjectRequest?>()) { + return (data != null ? _i26.DataSubjectRequest.fromJson(data) : null) as T; } - if (t == _i1.getType<_i26.EnrollmentToken?>()) { - return (data != null ? _i26.EnrollmentToken.fromJson(data) : null) as T; + if (t == _i1.getType<_i27.EnrollmentToken?>()) { + return (data != null ? _i27.EnrollmentToken.fromJson(data) : null) as T; } - if (t == _i1.getType<_i27.AlertStatus?>()) { - return (data != null ? _i27.AlertStatus.fromJson(data) : null) as T; + if (t == _i1.getType<_i28.AlertStatus?>()) { + return (data != null ? _i28.AlertStatus.fromJson(data) : null) as T; } - if (t == _i1.getType<_i28.ArrivalMethod?>()) { - return (data != null ? _i28.ArrivalMethod.fromJson(data) : null) as T; + if (t == _i1.getType<_i29.ArrivalMethod?>()) { + return (data != null ? _i29.ArrivalMethod.fromJson(data) : null) as T; } - if (t == _i1.getType<_i29.ConsentPurpose?>()) { - return (data != null ? _i29.ConsentPurpose.fromJson(data) : null) as T; + if (t == _i1.getType<_i30.ConsentPurpose?>()) { + return (data != null ? _i30.ConsentPurpose.fromJson(data) : null) as T; } - if (t == _i1.getType<_i30.DataSubjectRequestStatus?>()) { + if (t == _i1.getType<_i31.DataSubjectRequestStatus?>()) { return (data != null - ? _i30.DataSubjectRequestStatus.fromJson(data) + ? _i31.DataSubjectRequestStatus.fromJson(data) : null) as T; } - if (t == _i1.getType<_i31.DataSubjectRequestType?>()) { - return (data != null ? _i31.DataSubjectRequestType.fromJson(data) : null) + if (t == _i1.getType<_i32.DataSubjectRequestType?>()) { + return (data != null ? _i32.DataSubjectRequestType.fromJson(data) : null) as T; } - if (t == _i1.getType<_i32.RiskLevel?>()) { - return (data != null ? _i32.RiskLevel.fromJson(data) : null) as T; + if (t == _i1.getType<_i33.RiskLevel?>()) { + return (data != null ? _i33.RiskLevel.fromJson(data) : null) as T; } - if (t == _i1.getType<_i33.SyncStatus?>()) { - return (data != null ? _i33.SyncStatus.fromJson(data) : null) as T; + if (t == _i1.getType<_i34.SyncStatus?>()) { + return (data != null ? _i34.SyncStatus.fromJson(data) : null) as T; } - if (t == _i1.getType<_i34.UserRole?>()) { - return (data != null ? _i34.UserRole.fromJson(data) : null) as T; + if (t == _i1.getType<_i35.UserRole?>()) { + return (data != null ? _i35.UserRole.fromJson(data) : null) as T; } - if (t == _i1.getType<_i35.AlertDispatchUnavailableException?>()) { + if (t == _i1.getType<_i36.AlertDispatchUnavailableException?>()) { return (data != null - ? _i35.AlertDispatchUnavailableException.fromJson(data) + ? _i36.AlertDispatchUnavailableException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i36.AlertPermissionException?>()) { + if (t == _i1.getType<_i37.AlertPermissionException?>()) { return (data != null - ? _i36.AlertPermissionException.fromJson(data) + ? _i37.AlertPermissionException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i37.AlertValidationException?>()) { + if (t == _i1.getType<_i38.AlertValidationException?>()) { return (data != null - ? _i37.AlertValidationException.fromJson(data) + ? _i38.AlertValidationException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i38.AuthenticationFailedException?>()) { + if (t == _i1.getType<_i39.AuthenticationFailedException?>()) { return (data != null - ? _i38.AuthenticationFailedException.fromJson(data) + ? _i39.AuthenticationFailedException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i39.DataRightsException?>()) { - return (data != null ? _i39.DataRightsException.fromJson(data) : null) + if (t == _i1.getType<_i40.DataRightsException?>()) { + return (data != null ? _i40.DataRightsException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i40.EndpointDisabledException?>()) { + if (t == _i1.getType<_i41.EndpointDisabledException?>()) { return (data != null - ? _i40.EndpointDisabledException.fromJson(data) + ? _i41.EndpointDisabledException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i41.EnrollmentException?>()) { - return (data != null ? _i41.EnrollmentException.fromJson(data) : null) + if (t == _i1.getType<_i42.EnrollmentException?>()) { + return (data != null ? _i42.EnrollmentException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i42.OtpRequestException?>()) { - return (data != null ? _i42.OtpRequestException.fromJson(data) : null) + if (t == _i1.getType<_i43.NoticeDeliveryException?>()) { + return (data != null ? _i43.NoticeDeliveryException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i43.MicroArea?>()) { - return (data != null ? _i43.MicroArea.fromJson(data) : null) as T; + if (t == _i1.getType<_i44.OtpRequestException?>()) { + return (data != null ? _i44.OtpRequestException.fromJson(data) : null) + as T; + } + if (t == _i1.getType<_i45.MicroArea?>()) { + return (data != null ? _i45.MicroArea.fromJson(data) : null) as T; } - if (t == _i1.getType<_i44.OtpChallenge?>()) { - return (data != null ? _i44.OtpChallenge.fromJson(data) : null) as T; + if (t == _i1.getType<_i46.OtpChallenge?>()) { + return (data != null ? _i46.OtpChallenge.fromJson(data) : null) as T; } - if (t == _i1.getType<_i45.Patient?>()) { - return (data != null ? _i45.Patient.fromJson(data) : null) as T; + if (t == _i1.getType<_i47.Patient?>()) { + return (data != null ? _i47.Patient.fromJson(data) : null) as T; } - if (t == _i1.getType<_i46.PushToken?>()) { - return (data != null ? _i46.PushToken.fromJson(data) : null) as T; + if (t == _i1.getType<_i48.PushToken?>()) { + return (data != null ? _i48.PushToken.fromJson(data) : null) as T; } - if (t == _i1.getType<_i47.TriageAnswer?>()) { - return (data != null ? _i47.TriageAnswer.fromJson(data) : null) as T; + if (t == _i1.getType<_i49.TriageAnswer?>()) { + return (data != null ? _i49.TriageAnswer.fromJson(data) : null) as T; } - if (t == _i1.getType<_i48.TriageSession?>()) { - return (data != null ? _i48.TriageSession.fromJson(data) : null) as T; + if (t == _i1.getType<_i50.TriageSession?>()) { + return (data != null ? _i50.TriageSession.fromJson(data) : null) as T; } - if (t == _i1.getType<_i49.Ubs?>()) { - return (data != null ? _i49.Ubs.fromJson(data) : null) as T; + if (t == _i1.getType<_i51.Ubs?>()) { + return (data != null ? _i51.Ubs.fromJson(data) : null) as T; } - if (t == _i1.getType<_i50.User?>()) { - return (data != null ? _i50.User.fromJson(data) : null) as T; + if (t == _i1.getType<_i52.User?>()) { + return (data != null ? _i52.User.fromJson(data) : null) as T; } - if (t == _i1.getType<_i51.UserCredential?>()) { - return (data != null ? _i51.UserCredential.fromJson(data) : null) as T; + if (t == _i1.getType<_i53.UserCredential?>()) { + return (data != null ? _i53.UserCredential.fromJson(data) : null) as T; } - if (t == _i1.getType<_i52.Visit?>()) { - return (data != null ? _i52.Visit.fromJson(data) : null) as T; + if (t == _i1.getType<_i54.Visit?>()) { + return (data != null ? _i54.Visit.fromJson(data) : null) as T; } if (t == List) { return (data as List).map((e) => deserialize(e)).toList() as T; } - if (t == List<_i14.PatientConsentRecord>) { + if (t == List<_i15.PatientConsentRecord>) { return (data as List) - .map((e) => deserialize<_i14.PatientConsentRecord>(e)) + .map((e) => deserialize<_i15.PatientConsentRecord>(e)) .toList() as T; } - if (t == List<_i17.PatientRiskEvent>) { + if (t == List<_i18.PatientRiskEvent>) { return (data as List) - .map((e) => deserialize<_i17.PatientRiskEvent>(e)) + .map((e) => deserialize<_i18.PatientRiskEvent>(e)) .toList() as T; } - if (t == List<_i16.PatientDataSubjectRequestRecord>) { + if (t == List<_i17.PatientDataSubjectRequestRecord>) { return (data as List) - .map((e) => deserialize<_i16.PatientDataSubjectRequestRecord>(e)) + .map((e) => deserialize<_i17.PatientDataSubjectRequestRecord>(e)) .toList() as T; } @@ -2308,24 +2325,24 @@ class Protocol extends _i1.SerializationManagerServer { ) as T; } - if (t == List<_i53.MicroAreaPatient>) { + if (t == List<_i55.MicroAreaPatient>) { return (data as List) - .map((e) => deserialize<_i53.MicroAreaPatient>(e)) + .map((e) => deserialize<_i55.MicroAreaPatient>(e)) .toList() as T; } if (t == List) { return (data as List).map((e) => deserialize(e)).toList() as T; } - if (t == List<_i54.VisitSyncResult>) { + if (t == List<_i56.VisitSyncResult>) { return (data as List) - .map((e) => deserialize<_i54.VisitSyncResult>(e)) + .map((e) => deserialize<_i56.VisitSyncResult>(e)) .toList() as T; } - if (t == List<_i55.VisitSyncEntry>) { + if (t == List<_i57.VisitSyncEntry>) { return (data as List) - .map((e) => deserialize<_i55.VisitSyncEntry>(e)) + .map((e) => deserialize<_i57.VisitSyncEntry>(e)) .toList() as T; } @@ -2348,46 +2365,48 @@ class Protocol extends _i1.SerializationManagerServer { _i11.EnrollmentResult => 'EnrollmentResult', _i12.EnrollmentTokenResult => 'EnrollmentTokenResult', _i13.MicroAreaPatient => 'MicroAreaPatient', - _i14.PatientConsentRecord => 'PatientConsentRecord', - _i15.PatientDataOverview => 'PatientDataOverview', - _i16.PatientDataSubjectRequestRecord => 'PatientDataSubjectRequestRecord', - _i17.PatientRiskEvent => 'PatientRiskEvent', - _i18.RedAlertResult => 'RedAlertResult', - _i19.ServiceHealth => 'ServiceHealth', - _i20.TriageResult => 'TriageResult', - _i21.VisitSyncEntry => 'VisitSyncEntry', - _i22.VisitSyncResult => 'VisitSyncResult', - _i23.AuditLog => 'AuditLog', - _i24.ConsentLog => 'ConsentLog', - _i25.DataSubjectRequest => 'DataSubjectRequest', - _i26.EnrollmentToken => 'EnrollmentToken', - _i27.AlertStatus => 'AlertStatus', - _i28.ArrivalMethod => 'ArrivalMethod', - _i29.ConsentPurpose => 'ConsentPurpose', - _i30.DataSubjectRequestStatus => 'DataSubjectRequestStatus', - _i31.DataSubjectRequestType => 'DataSubjectRequestType', - _i32.RiskLevel => 'RiskLevel', - _i33.SyncStatus => 'SyncStatus', - _i34.UserRole => 'UserRole', - _i35.AlertDispatchUnavailableException => + _i14.NoticeSendResult => 'NoticeSendResult', + _i15.PatientConsentRecord => 'PatientConsentRecord', + _i16.PatientDataOverview => 'PatientDataOverview', + _i17.PatientDataSubjectRequestRecord => 'PatientDataSubjectRequestRecord', + _i18.PatientRiskEvent => 'PatientRiskEvent', + _i19.RedAlertResult => 'RedAlertResult', + _i20.ServiceHealth => 'ServiceHealth', + _i21.TriageResult => 'TriageResult', + _i22.VisitSyncEntry => 'VisitSyncEntry', + _i23.VisitSyncResult => 'VisitSyncResult', + _i24.AuditLog => 'AuditLog', + _i25.ConsentLog => 'ConsentLog', + _i26.DataSubjectRequest => 'DataSubjectRequest', + _i27.EnrollmentToken => 'EnrollmentToken', + _i28.AlertStatus => 'AlertStatus', + _i29.ArrivalMethod => 'ArrivalMethod', + _i30.ConsentPurpose => 'ConsentPurpose', + _i31.DataSubjectRequestStatus => 'DataSubjectRequestStatus', + _i32.DataSubjectRequestType => 'DataSubjectRequestType', + _i33.RiskLevel => 'RiskLevel', + _i34.SyncStatus => 'SyncStatus', + _i35.UserRole => 'UserRole', + _i36.AlertDispatchUnavailableException => 'AlertDispatchUnavailableException', - _i36.AlertPermissionException => 'AlertPermissionException', - _i37.AlertValidationException => 'AlertValidationException', - _i38.AuthenticationFailedException => 'AuthenticationFailedException', - _i39.DataRightsException => 'DataRightsException', - _i40.EndpointDisabledException => 'EndpointDisabledException', - _i41.EnrollmentException => 'EnrollmentException', - _i42.OtpRequestException => 'OtpRequestException', - _i43.MicroArea => 'MicroArea', - _i44.OtpChallenge => 'OtpChallenge', - _i45.Patient => 'Patient', - _i46.PushToken => 'PushToken', - _i47.TriageAnswer => 'TriageAnswer', - _i48.TriageSession => 'TriageSession', - _i49.Ubs => 'Ubs', - _i50.User => 'User', - _i51.UserCredential => 'UserCredential', - _i52.Visit => 'Visit', + _i37.AlertPermissionException => 'AlertPermissionException', + _i38.AlertValidationException => 'AlertValidationException', + _i39.AuthenticationFailedException => 'AuthenticationFailedException', + _i40.DataRightsException => 'DataRightsException', + _i41.EndpointDisabledException => 'EndpointDisabledException', + _i42.EnrollmentException => 'EnrollmentException', + _i43.NoticeDeliveryException => 'NoticeDeliveryException', + _i44.OtpRequestException => 'OtpRequestException', + _i45.MicroArea => 'MicroArea', + _i46.OtpChallenge => 'OtpChallenge', + _i47.Patient => 'Patient', + _i48.PushToken => 'PushToken', + _i49.TriageAnswer => 'TriageAnswer', + _i50.TriageSession => 'TriageSession', + _i51.Ubs => 'Ubs', + _i52.User => 'User', + _i53.UserCredential => 'UserCredential', + _i54.Visit => 'Visit', _ => null, }; } @@ -2424,83 +2443,87 @@ class Protocol extends _i1.SerializationManagerServer { return 'EnrollmentTokenResult'; case _i13.MicroAreaPatient(): return 'MicroAreaPatient'; - case _i14.PatientConsentRecord(): + case _i14.NoticeSendResult(): + return 'NoticeSendResult'; + case _i15.PatientConsentRecord(): return 'PatientConsentRecord'; - case _i15.PatientDataOverview(): + case _i16.PatientDataOverview(): return 'PatientDataOverview'; - case _i16.PatientDataSubjectRequestRecord(): + case _i17.PatientDataSubjectRequestRecord(): return 'PatientDataSubjectRequestRecord'; - case _i17.PatientRiskEvent(): + case _i18.PatientRiskEvent(): return 'PatientRiskEvent'; - case _i18.RedAlertResult(): + case _i19.RedAlertResult(): return 'RedAlertResult'; - case _i19.ServiceHealth(): + case _i20.ServiceHealth(): return 'ServiceHealth'; - case _i20.TriageResult(): + case _i21.TriageResult(): return 'TriageResult'; - case _i21.VisitSyncEntry(): + case _i22.VisitSyncEntry(): return 'VisitSyncEntry'; - case _i22.VisitSyncResult(): + case _i23.VisitSyncResult(): return 'VisitSyncResult'; - case _i23.AuditLog(): + case _i24.AuditLog(): return 'AuditLog'; - case _i24.ConsentLog(): + case _i25.ConsentLog(): return 'ConsentLog'; - case _i25.DataSubjectRequest(): + case _i26.DataSubjectRequest(): return 'DataSubjectRequest'; - case _i26.EnrollmentToken(): + case _i27.EnrollmentToken(): return 'EnrollmentToken'; - case _i27.AlertStatus(): + case _i28.AlertStatus(): return 'AlertStatus'; - case _i28.ArrivalMethod(): + case _i29.ArrivalMethod(): return 'ArrivalMethod'; - case _i29.ConsentPurpose(): + case _i30.ConsentPurpose(): return 'ConsentPurpose'; - case _i30.DataSubjectRequestStatus(): + case _i31.DataSubjectRequestStatus(): return 'DataSubjectRequestStatus'; - case _i31.DataSubjectRequestType(): + case _i32.DataSubjectRequestType(): return 'DataSubjectRequestType'; - case _i32.RiskLevel(): + case _i33.RiskLevel(): return 'RiskLevel'; - case _i33.SyncStatus(): + case _i34.SyncStatus(): return 'SyncStatus'; - case _i34.UserRole(): + case _i35.UserRole(): return 'UserRole'; - case _i35.AlertDispatchUnavailableException(): + case _i36.AlertDispatchUnavailableException(): return 'AlertDispatchUnavailableException'; - case _i36.AlertPermissionException(): + case _i37.AlertPermissionException(): return 'AlertPermissionException'; - case _i37.AlertValidationException(): + case _i38.AlertValidationException(): return 'AlertValidationException'; - case _i38.AuthenticationFailedException(): + case _i39.AuthenticationFailedException(): return 'AuthenticationFailedException'; - case _i39.DataRightsException(): + case _i40.DataRightsException(): return 'DataRightsException'; - case _i40.EndpointDisabledException(): + case _i41.EndpointDisabledException(): return 'EndpointDisabledException'; - case _i41.EnrollmentException(): + case _i42.EnrollmentException(): return 'EnrollmentException'; - case _i42.OtpRequestException(): + case _i43.NoticeDeliveryException(): + return 'NoticeDeliveryException'; + case _i44.OtpRequestException(): return 'OtpRequestException'; - case _i43.MicroArea(): + case _i45.MicroArea(): return 'MicroArea'; - case _i44.OtpChallenge(): + case _i46.OtpChallenge(): return 'OtpChallenge'; - case _i45.Patient(): + case _i47.Patient(): return 'Patient'; - case _i46.PushToken(): + case _i48.PushToken(): return 'PushToken'; - case _i47.TriageAnswer(): + case _i49.TriageAnswer(): return 'TriageAnswer'; - case _i48.TriageSession(): + case _i50.TriageSession(): return 'TriageSession'; - case _i49.Ubs(): + case _i51.Ubs(): return 'Ubs'; - case _i50.User(): + case _i52.User(): return 'User'; - case _i51.UserCredential(): + case _i53.UserCredential(): return 'UserCredential'; - case _i52.Visit(): + case _i54.Visit(): return 'Visit'; } className = _i2.Protocol().getClassNameForObject(data); @@ -2549,122 +2572,128 @@ class Protocol extends _i1.SerializationManagerServer { if (dataClassName == 'MicroAreaPatient') { return deserialize<_i13.MicroAreaPatient>(data['data']); } + if (dataClassName == 'NoticeSendResult') { + return deserialize<_i14.NoticeSendResult>(data['data']); + } if (dataClassName == 'PatientConsentRecord') { - return deserialize<_i14.PatientConsentRecord>(data['data']); + return deserialize<_i15.PatientConsentRecord>(data['data']); } if (dataClassName == 'PatientDataOverview') { - return deserialize<_i15.PatientDataOverview>(data['data']); + return deserialize<_i16.PatientDataOverview>(data['data']); } if (dataClassName == 'PatientDataSubjectRequestRecord') { - return deserialize<_i16.PatientDataSubjectRequestRecord>(data['data']); + return deserialize<_i17.PatientDataSubjectRequestRecord>(data['data']); } if (dataClassName == 'PatientRiskEvent') { - return deserialize<_i17.PatientRiskEvent>(data['data']); + return deserialize<_i18.PatientRiskEvent>(data['data']); } if (dataClassName == 'RedAlertResult') { - return deserialize<_i18.RedAlertResult>(data['data']); + return deserialize<_i19.RedAlertResult>(data['data']); } if (dataClassName == 'ServiceHealth') { - return deserialize<_i19.ServiceHealth>(data['data']); + return deserialize<_i20.ServiceHealth>(data['data']); } if (dataClassName == 'TriageResult') { - return deserialize<_i20.TriageResult>(data['data']); + return deserialize<_i21.TriageResult>(data['data']); } if (dataClassName == 'VisitSyncEntry') { - return deserialize<_i21.VisitSyncEntry>(data['data']); + return deserialize<_i22.VisitSyncEntry>(data['data']); } if (dataClassName == 'VisitSyncResult') { - return deserialize<_i22.VisitSyncResult>(data['data']); + return deserialize<_i23.VisitSyncResult>(data['data']); } if (dataClassName == 'AuditLog') { - return deserialize<_i23.AuditLog>(data['data']); + return deserialize<_i24.AuditLog>(data['data']); } if (dataClassName == 'ConsentLog') { - return deserialize<_i24.ConsentLog>(data['data']); + return deserialize<_i25.ConsentLog>(data['data']); } if (dataClassName == 'DataSubjectRequest') { - return deserialize<_i25.DataSubjectRequest>(data['data']); + return deserialize<_i26.DataSubjectRequest>(data['data']); } if (dataClassName == 'EnrollmentToken') { - return deserialize<_i26.EnrollmentToken>(data['data']); + return deserialize<_i27.EnrollmentToken>(data['data']); } if (dataClassName == 'AlertStatus') { - return deserialize<_i27.AlertStatus>(data['data']); + return deserialize<_i28.AlertStatus>(data['data']); } if (dataClassName == 'ArrivalMethod') { - return deserialize<_i28.ArrivalMethod>(data['data']); + return deserialize<_i29.ArrivalMethod>(data['data']); } if (dataClassName == 'ConsentPurpose') { - return deserialize<_i29.ConsentPurpose>(data['data']); + return deserialize<_i30.ConsentPurpose>(data['data']); } if (dataClassName == 'DataSubjectRequestStatus') { - return deserialize<_i30.DataSubjectRequestStatus>(data['data']); + return deserialize<_i31.DataSubjectRequestStatus>(data['data']); } if (dataClassName == 'DataSubjectRequestType') { - return deserialize<_i31.DataSubjectRequestType>(data['data']); + return deserialize<_i32.DataSubjectRequestType>(data['data']); } if (dataClassName == 'RiskLevel') { - return deserialize<_i32.RiskLevel>(data['data']); + return deserialize<_i33.RiskLevel>(data['data']); } if (dataClassName == 'SyncStatus') { - return deserialize<_i33.SyncStatus>(data['data']); + return deserialize<_i34.SyncStatus>(data['data']); } if (dataClassName == 'UserRole') { - return deserialize<_i34.UserRole>(data['data']); + return deserialize<_i35.UserRole>(data['data']); } if (dataClassName == 'AlertDispatchUnavailableException') { - return deserialize<_i35.AlertDispatchUnavailableException>(data['data']); + return deserialize<_i36.AlertDispatchUnavailableException>(data['data']); } if (dataClassName == 'AlertPermissionException') { - return deserialize<_i36.AlertPermissionException>(data['data']); + return deserialize<_i37.AlertPermissionException>(data['data']); } if (dataClassName == 'AlertValidationException') { - return deserialize<_i37.AlertValidationException>(data['data']); + return deserialize<_i38.AlertValidationException>(data['data']); } if (dataClassName == 'AuthenticationFailedException') { - return deserialize<_i38.AuthenticationFailedException>(data['data']); + return deserialize<_i39.AuthenticationFailedException>(data['data']); } if (dataClassName == 'DataRightsException') { - return deserialize<_i39.DataRightsException>(data['data']); + return deserialize<_i40.DataRightsException>(data['data']); } if (dataClassName == 'EndpointDisabledException') { - return deserialize<_i40.EndpointDisabledException>(data['data']); + return deserialize<_i41.EndpointDisabledException>(data['data']); } if (dataClassName == 'EnrollmentException') { - return deserialize<_i41.EnrollmentException>(data['data']); + return deserialize<_i42.EnrollmentException>(data['data']); + } + if (dataClassName == 'NoticeDeliveryException') { + return deserialize<_i43.NoticeDeliveryException>(data['data']); } if (dataClassName == 'OtpRequestException') { - return deserialize<_i42.OtpRequestException>(data['data']); + return deserialize<_i44.OtpRequestException>(data['data']); } if (dataClassName == 'MicroArea') { - return deserialize<_i43.MicroArea>(data['data']); + return deserialize<_i45.MicroArea>(data['data']); } if (dataClassName == 'OtpChallenge') { - return deserialize<_i44.OtpChallenge>(data['data']); + return deserialize<_i46.OtpChallenge>(data['data']); } if (dataClassName == 'Patient') { - return deserialize<_i45.Patient>(data['data']); + return deserialize<_i47.Patient>(data['data']); } if (dataClassName == 'PushToken') { - return deserialize<_i46.PushToken>(data['data']); + return deserialize<_i48.PushToken>(data['data']); } if (dataClassName == 'TriageAnswer') { - return deserialize<_i47.TriageAnswer>(data['data']); + return deserialize<_i49.TriageAnswer>(data['data']); } if (dataClassName == 'TriageSession') { - return deserialize<_i48.TriageSession>(data['data']); + return deserialize<_i50.TriageSession>(data['data']); } if (dataClassName == 'Ubs') { - return deserialize<_i49.Ubs>(data['data']); + return deserialize<_i51.Ubs>(data['data']); } if (dataClassName == 'User') { - return deserialize<_i50.User>(data['data']); + return deserialize<_i52.User>(data['data']); } if (dataClassName == 'UserCredential') { - return deserialize<_i51.UserCredential>(data['data']); + return deserialize<_i53.UserCredential>(data['data']); } if (dataClassName == 'Visit') { - return deserialize<_i52.Visit>(data['data']); + return deserialize<_i54.Visit>(data['data']); } if (dataClassName.startsWith('serverpod.')) { data['className'] = dataClassName.substring(10); @@ -2692,32 +2721,32 @@ class Protocol extends _i1.SerializationManagerServer { return _i6.AlertIdempotencyKey.t; case _i7.AlertOutboxEntry: return _i7.AlertOutboxEntry.t; - case _i23.AuditLog: - return _i23.AuditLog.t; - case _i24.ConsentLog: - return _i24.ConsentLog.t; - case _i25.DataSubjectRequest: - return _i25.DataSubjectRequest.t; - case _i26.EnrollmentToken: - return _i26.EnrollmentToken.t; - case _i43.MicroArea: - return _i43.MicroArea.t; - case _i44.OtpChallenge: - return _i44.OtpChallenge.t; - case _i45.Patient: - return _i45.Patient.t; - case _i46.PushToken: - return _i46.PushToken.t; - case _i48.TriageSession: - return _i48.TriageSession.t; - case _i49.Ubs: - return _i49.Ubs.t; - case _i50.User: - return _i50.User.t; - case _i51.UserCredential: - return _i51.UserCredential.t; - case _i52.Visit: - return _i52.Visit.t; + case _i24.AuditLog: + return _i24.AuditLog.t; + case _i25.ConsentLog: + return _i25.ConsentLog.t; + case _i26.DataSubjectRequest: + return _i26.DataSubjectRequest.t; + case _i27.EnrollmentToken: + return _i27.EnrollmentToken.t; + case _i45.MicroArea: + return _i45.MicroArea.t; + case _i46.OtpChallenge: + return _i46.OtpChallenge.t; + case _i47.Patient: + return _i47.Patient.t; + case _i48.PushToken: + return _i48.PushToken.t; + case _i50.TriageSession: + return _i50.TriageSession.t; + case _i51.Ubs: + return _i51.Ubs.t; + case _i52.User: + return _i52.User.t; + case _i53.UserCredential: + return _i53.UserCredential.t; + case _i54.Visit: + return _i54.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 20d7d2cb..f2e9e71f 100644 --- a/backend/sinalacs_server/lib/src/generated/protocol.yaml +++ b/backend/sinalacs_server/lib/src/generated/protocol.yaml @@ -11,6 +11,8 @@ devices: - registerPushToken: health: - check: +notices: + - sendSegmented: onboarding: - generateEnrollmentToken: - completeEnrollment: 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..efa8a9b0 --- /dev/null +++ b/backend/sinalacs_server/lib/src/infrastructure/database/orm_notice_recipient_store.dart @@ -0,0 +1,60 @@ +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. 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" + JOIN patients p ON p."id" = u."id" + WHERE u."microAreaId" = @micro::uuid + 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" + ''', + 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/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/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/runtime/alert_runtime.dart b/backend/sinalacs_server/lib/src/runtime/alert_runtime.dart index 436aca9b..095a154e 100644 --- a/backend/sinalacs_server/lib/src/runtime/alert_runtime.dart +++ b/backend/sinalacs_server/lib/src/runtime/alert_runtime.dart @@ -26,7 +26,10 @@ 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/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'; @@ -48,6 +51,7 @@ class AlertRuntime { static final AlertRuntime instance = AlertRuntime._(); AppConfig? _config; + PushSender? Function()? _noticeSenderOverride; MqttAlertDispatcher? _dispatcher; DevelopmentAuthService? _auth; HealthDataCipher? _healthDataCipher; @@ -120,6 +124,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 @@ -216,6 +227,20 @@ class AlertRuntime { pushTokens: OrmPushTokenStore(session: () => session), ); + /// 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 : GorushClient(baseUrl: url, timeout: config.gorushTimeout)), + audit: auditTrailFor(session), + ); + } + /// Registro do aparelho para avisos segmentados (RF14). PushTokenService pushTokenServiceFor(Session session) => PushTokenService(store: OrmPushTokenStore(session: () => session)); 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 index e0eced2a..9a5984f3 100644 --- a/backend/sinalacs_server/test/integration/data_subject_rights_endpoint_test.dart +++ b/backend/sinalacs_server/test/integration/data_subject_rights_endpoint_test.dart @@ -265,7 +265,7 @@ void main() { endpoints.patients.acceptTermsOfUse(sessionBuilder, accessToken: acsToken), throwsA(isA()), ); - expect(await ConsentLog.db.count(session), 0); + expect(await ConsentLog.db.count(session, where: (t) => t.userId.equals(UuidValue.fromString(_patientId))), 0); }); test('hasAcceptedCurrentTerms: falso antes, verdadeiro depois de aceitar', () async { @@ -298,7 +298,7 @@ void main() { await endpoints.patients.acceptTermsOfUse(sessionBuilder, accessToken: token); await endpoints.patients.acceptTermsOfUse(sessionBuilder, accessToken: token); - expect(await ConsentLog.db.count(session), 1); + 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 { @@ -315,7 +315,7 @@ void main() { ), throwsA(isA()), ); - expect(await ConsentLog.db.count(session), 0); + 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 { 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/notices_endpoint_test.dart b/backend/sinalacs_server/test/integration/notices_endpoint_test.dart new file mode 100644 index 00000000..98a15197 --- /dev/null +++ b/backend/sinalacs_server/test/integration/notices_endpoint_test.dart @@ -0,0 +1,308 @@ +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 _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, +}) 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, + ), + ); + 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 sem o texto do aviso', () 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)); + }); + + 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/push_token_endpoint_test.dart b/backend/sinalacs_server/test/integration/push_token_endpoint_test.dart index 62c316a8..31263964 100644 --- a/backend/sinalacs_server/test/integration/push_token_endpoint_test.dart +++ b/backend/sinalacs_server/test/integration/push_token_endpoint_test.dart @@ -111,6 +111,48 @@ Future _seed( } +/// 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())); @@ -224,8 +266,6 @@ void main() { final id = UuidValue.fromString(_racePatientId); await PushToken.db.deleteWhere(session, where: (t) => t.userId.equals(id)); await ConsentLog.db.deleteWhere(session, where: (t) => t.userId.equals(id)); - await Patient.db.deleteWhere(session, where: (t) => t.id.equals(id)); - await Acs.db.deleteWhere(session, where: (t) => t.id.equals(UuidValue.fromString(_raceAcsId))); await User.db.deleteWhere( session, where: (t) => t.id.equals(id) | t.id.equals(UuidValue.fromString(_raceAcsId)), @@ -239,8 +279,7 @@ void main() { test('depois de registrar × revogar em paralelo, denied nunca convive com token', () async { final session = sessionBuilder.build(); - await _seed(session, - ubsId: _raceUbsId, microAreaId: _raceMicroAreaId, patientId: _racePatientId, acsId: _raceAcsId, enrollmentId: 'ACS-CORRIDA-PUSH'); + await _seedRaceLean(session); try { final consents = OrmDataSubjectRightsStore( session: () => sessionBuilder.build(), @@ -286,8 +325,7 @@ void main() { test('aparelho de outro titular sem consentimento perde o vínculo do dono antigo', () async { final session = sessionBuilder.build(); - await _seed(session, - ubsId: _raceUbsId, microAreaId: _raceMicroAreaId, patientId: _racePatientId, acsId: _raceAcsId, enrollmentId: 'ACS-CORRIDA-PUSH'); + await _seedRaceLean(session); try { final consents = OrmDataSubjectRightsStore( session: () => sessionBuilder.build(), 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 eb0a1fff..bd205518 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 @@ -21,25 +21,27 @@ import 'package:sinalacs_server/src/generated/api/alert_status_result.dart' 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; -import 'package:sinalacs_server/src/generated/api/enrollment_token_result.dart' +import 'package:sinalacs_server/src/generated/api/notice_send_result.dart' as _i9; -import 'package:sinalacs_server/src/generated/api/enrollment_result.dart' +import 'package:sinalacs_server/src/generated/api/enrollment_token_result.dart' as _i10; -import 'package:sinalacs_server/src/generated/api/micro_area_patient.dart' +import 'package:sinalacs_server/src/generated/api/enrollment_result.dart' as _i11; -import 'package:sinalacs_server/src/generated/api/patient_data_overview.dart' +import 'package:sinalacs_server/src/generated/api/micro_area_patient.dart' as _i12; -import 'package:sinalacs_server/src/generated/api/patient_consent_record.dart' +import 'package:sinalacs_server/src/generated/api/patient_data_overview.dart' as _i13; -import 'package:sinalacs_server/src/generated/enums/consent_purpose.dart' +import 'package:sinalacs_server/src/generated/api/patient_consent_record.dart' as _i14; -import 'package:sinalacs_server/src/generated/api/patient_data_subject_request_record.dart' +import 'package:sinalacs_server/src/generated/enums/consent_purpose.dart' as _i15; -import 'package:sinalacs_server/src/generated/api/triage_result.dart' as _i16; +import 'package:sinalacs_server/src/generated/api/patient_data_subject_request_record.dart' + as _i16; +import 'package:sinalacs_server/src/generated/api/triage_result.dart' as _i17; import 'package:sinalacs_server/src/generated/api/visit_sync_result.dart' - as _i17; -import 'package:sinalacs_server/src/generated/api/visit_sync_entry.dart' as _i18; +import 'package:sinalacs_server/src/generated/api/visit_sync_entry.dart' + as _i19; 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'; @@ -162,6 +164,8 @@ class TestEndpoints { late final _HealthEndpoint health; + late final _NoticesEndpoint notices; + late final _OnboardingEndpoint onboarding; late final _PatientsEndpoint patients; @@ -194,6 +198,10 @@ class _InternalTestEndpoints extends TestEndpoints endpoints, serializationManager, ); + notices = _NoticesEndpoint( + endpoints, + serializationManager, + ); onboarding = _OnboardingEndpoint( endpoints, serializationManager, @@ -569,6 +577,56 @@ class _HealthEndpoint { } } +class _NoticesEndpoint { + _NoticesEndpoint( + this._endpointDispatch, + this._serializationManager, + ); + + final _i2.EndpointDispatch _endpointDispatch; + + final _i2.SerializationManager _serializationManager; + + _i3.Future<_i9.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<_i9.NoticeSendResult>); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } +} + class _OnboardingEndpoint { _OnboardingEndpoint( this._endpointDispatch, @@ -579,7 +637,7 @@ class _OnboardingEndpoint { final _i2.SerializationManager _serializationManager; - _i3.Future<_i9.EnrollmentTokenResult> generateEnrollmentToken( + _i3.Future<_i10.EnrollmentTokenResult> generateEnrollmentToken( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, required String patientId, @@ -606,7 +664,7 @@ class _OnboardingEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i9.EnrollmentTokenResult>); + as _i3.Future<_i10.EnrollmentTokenResult>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -614,7 +672,7 @@ class _OnboardingEndpoint { }); } - _i3.Future<_i10.EnrollmentResult> completeEnrollment( + _i3.Future<_i11.EnrollmentResult> completeEnrollment( _i1.TestSessionBuilder sessionBuilder, { required String token, required bool healthDataConsent, @@ -647,7 +705,7 @@ class _OnboardingEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i10.EnrollmentResult>); + as _i3.Future<_i11.EnrollmentResult>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -666,7 +724,7 @@ class _PatientsEndpoint { final _i2.SerializationManager _serializationManager; - _i3.Future> listMicroArea( + _i3.Future> listMicroArea( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, }) async { @@ -689,7 +747,7 @@ class _PatientsEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future>); + as _i3.Future>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -763,7 +821,7 @@ class _PatientsEndpoint { }); } - _i3.Future<_i12.PatientDataOverview> myData( + _i3.Future<_i13.PatientDataOverview> myData( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, }) async { @@ -786,7 +844,7 @@ class _PatientsEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i12.PatientDataOverview>); + as _i3.Future<_i13.PatientDataOverview>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -794,10 +852,10 @@ class _PatientsEndpoint { }); } - _i3.Future<_i13.PatientConsentRecord> updateConsent( + _i3.Future<_i14.PatientConsentRecord> updateConsent( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, - required _i14.ConsentPurpose purpose, + required _i15.ConsentPurpose purpose, required bool granted, }) async { return _i1.callAwaitableFunctionAndHandleExceptions(() async { @@ -823,7 +881,7 @@ class _PatientsEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i13.PatientConsentRecord>); + as _i3.Future<_i14.PatientConsentRecord>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -831,7 +889,7 @@ class _PatientsEndpoint { }); } - _i3.Future<_i13.PatientConsentRecord> acceptTermsOfUse( + _i3.Future<_i14.PatientConsentRecord> acceptTermsOfUse( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, }) async { @@ -854,7 +912,7 @@ class _PatientsEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i13.PatientConsentRecord>); + as _i3.Future<_i14.PatientConsentRecord>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -893,7 +951,7 @@ class _PatientsEndpoint { }); } - _i3.Future<_i15.PatientDataSubjectRequestRecord> requestDataDeletion( + _i3.Future<_i16.PatientDataSubjectRequestRecord> requestDataDeletion( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, }) async { @@ -916,7 +974,7 @@ class _PatientsEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i15.PatientDataSubjectRequestRecord>); + as _i3.Future<_i16.PatientDataSubjectRequestRecord>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -924,7 +982,7 @@ class _PatientsEndpoint { }); } - _i3.Future<_i15.PatientDataSubjectRequestRecord> requestDataCorrection( + _i3.Future<_i16.PatientDataSubjectRequestRecord> requestDataCorrection( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, required String details, @@ -951,7 +1009,7 @@ class _PatientsEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i15.PatientDataSubjectRequestRecord>); + as _i3.Future<_i16.PatientDataSubjectRequestRecord>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -970,7 +1028,7 @@ class _TriageEndpoint { final _i2.SerializationManager _serializationManager; - _i3.Future<_i16.TriageResult> evaluate( + _i3.Future<_i17.TriageResult> evaluate( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, required bool chestPain, @@ -1007,7 +1065,7 @@ class _TriageEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i16.TriageResult>); + as _i3.Future<_i17.TriageResult>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -1026,10 +1084,10 @@ class _VisitsEndpoint { final _i2.SerializationManager _serializationManager; - _i3.Future> sync( + _i3.Future> sync( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, - required List<_i18.VisitSyncEntry> visits, + required List<_i19.VisitSyncEntry> visits, }) async { return _i1.callAwaitableFunctionAndHandleExceptions(() async { var _localUniqueSession = @@ -1053,7 +1111,7 @@ class _VisitsEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future>); + as _i3.Future>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -1061,7 +1119,7 @@ class _VisitsEndpoint { }); } - _i3.Future> pull( + _i3.Future> pull( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, required DateTime since, @@ -1088,7 +1146,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/unit/notice_service_test.dart b/backend/sinalacs_server/test/unit/notice_service_test.dart new file mode 100644 index 00000000..8a6885e4 --- /dev/null +++ b/backend/sinalacs_server/test/unit/notice_service_test.dart @@ -0,0 +1,173 @@ +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 = []; + + @override + Future> consentedTargets({ + required String microAreaId, + required bool chronicOnly, + }) async { + lastMicroAreaId = microAreaId; + lastChronicOnly = chronicOnly; + return targets; + } + + @override + Future deleteTokens(List tokens) async { + 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('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'); + } + }); +} From 944d801ee4c8907bc10e2d7a8b02d5678e8a57ab Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 29 Sep 2026 17:05:05 -0400 Subject: [PATCH 068/498] =?UTF-8?q?feat(acs):=20tela=20de=20envio=20de=20a?= =?UTF-8?q?viso=20comunit=C3=A1rio=20(RF14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- apps/acs/lib/app/app.dart | 105 +++++++++++++++- apps/acs/lib/core/network/backend_client.dart | 41 +++++++ apps/acs/test/notices_screen_test.dart | 114 ++++++++++++++++++ apps/acs/test/support/fakes.dart | 26 ++++ 4 files changed, 284 insertions(+), 2 deletions(-) create mode 100644 apps/acs/test/notices_screen_test.dart diff --git a/apps/acs/lib/app/app.dart b/apps/acs/lib/app/app.dart index cb053156..b34c53fb 100644 --- a/apps/acs/lib/app/app.dart +++ b/apps/acs/lib/app/app.dart @@ -2002,8 +2002,109 @@ 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: const TextStyle(color: AcsColors.redOnSurface)), + ], + if (_result != null) ...[ + const SizedBox(height: 12), + Text(_result!, key: const Key('notice_result')), + ], + ]); +} Widget _page(List children) => ListView(padding: const EdgeInsets.all(20), children: [Card(child: Padding(padding: const EdgeInsets.all(20), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: children)))]); diff --git a/apps/acs/lib/core/network/backend_client.dart b/apps/acs/lib/core/network/backend_client.dart index 08d7cfce..288b0aab 100644 --- a/apps/acs/lib/core/network/backend_client.dart +++ b/apps/acs/lib/core/network/backend_client.dart @@ -86,6 +86,15 @@ abstract class AcsBackend { /// "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(); } @@ -142,6 +151,14 @@ class MisconfiguredBackend implements AcsBackend { @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 @@ -355,6 +372,24 @@ class BackendClient implements AcsBackend { ); } + @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(); @@ -388,6 +423,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/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/support/fakes.dart b/apps/acs/test/support/fakes.dart index dfa4b0ba..4cd3cd0f 100644 --- a/apps/acs/test/support/fakes.dart +++ b/apps/acs/test/support/fakes.dart @@ -166,6 +166,32 @@ class FakeAcsBackend implements AcsBackend { ); } + /// 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 []; From 96ab8cec55bd3849ae013fde5573c73eee99e9c4 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 29 Sep 2026 17:42:23 -0400 Subject: [PATCH 069/498] feat(paciente): provider Riverpod e canal nativo para o token de push (RF14) Co-Authored-By: Claude Sonnet 5.5 --- apps/patient/lib/app/app.dart | 20 ++- .../core/push/native_push_token_source.dart | 31 +++++ .../lib/core/push/push_token_provider.dart | 10 ++ .../lib/core/push/push_token_source.dart | 11 +- apps/patient/lib/main.dart | 3 +- apps/patient/pubspec.lock | 36 +++++- apps/patient/pubspec.yaml | 1 + apps/patient/test/push_riverpod_test.dart | 119 ++++++++++++++++++ 8 files changed, 222 insertions(+), 9 deletions(-) create mode 100644 apps/patient/lib/core/push/native_push_token_source.dart create mode 100644 apps/patient/lib/core/push/push_token_provider.dart create mode 100644 apps/patient/test/push_riverpod_test.dart diff --git a/apps/patient/lib/app/app.dart b/apps/patient/lib/app/app.dart index cff919f7..8643fa67 100644 --- a/apps/patient/lib/app/app.dart +++ b/apps/patient/lib/app/app.dart @@ -2,6 +2,7 @@ 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'; @@ -23,6 +24,7 @@ import 'package:sinalacs_patient/core/legal/legal_documents.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'; @@ -96,7 +98,23 @@ class _SinalAcsAppState extends State { late final ConsentPreferences _consentPreferences = widget.consentPreferences ?? SqfliteConsentPreferences(); late final QrScanner _qrScanner = widget.qrScanner ?? scanQrWithCamera; - late final PushTokenSource _pushTokens = widget.pushTokens ?? const NoPushTokenSource(); + // 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(); + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _pushTokens = widget.pushTokens ?? _providerSource(); + } + + PushTokenSource _providerSource() { + try { + return ProviderScope.containerOf(context, listen: false).read(pushTokenSourceProvider); + } catch (_) { + return 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 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 index 67d6cc81..f39e6013 100644 --- a/apps/patient/lib/core/push/push_token_source.dart +++ b/apps/patient/lib/core/push/push_token_source.dart @@ -11,11 +11,12 @@ class PushDevice { final String platform; } -/// De onde o app tira o token de push. A implementação real (token nativo do -/// FCM no Android e do APNs no iOS, entregue depois ao Gorush pelo backend — -/// decisão §3.2 do documento de decisões de produto) entra quando o Gorush e as -/// credenciais existirem; até lá [NoPushTokenSource] mantém o registro inerte, e -/// servidor e telas já estão prontos para a troca. +/// De onde o app tira o token de push. A implementação real, +/// `NativePushTokenSource` (canal `sinalacs/push_token`, ainda sem o lado +/// nativo), lê o token do FCM no Android e do APNs no iOS, e o backend o entrega +/// depois ao Gorush (decisão §3.2 do documento de decisões de produto). Até o +/// lado nativo e as credenciais existirem, o canal não responde e o registro +/// fica inerte; servidor e telas já estão prontos. abstract interface class PushTokenSource { /// `null` quando o aparelho não tem token (sem provedor, sem permissão). Future currentDevice(); 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 1da8398b..3035b690 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: @@ -351,6 +367,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: @@ -475,6 +499,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: diff --git a/apps/patient/pubspec.yaml b/apps/patient/pubspec.yaml index e64c280b..513214e1 100644 --- a/apps/patient/pubspec.yaml +++ b/apps/patient/pubspec.yaml @@ -31,6 +31,7 @@ dependencies: # 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/push_riverpod_test.dart b/apps/patient/test/push_riverpod_test.dart new file mode 100644 index 00000000..f1d36011 --- /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(); + 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(); + 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); + }); +} From 65dd4f6c71d69d983a519d97494136c63f08cbc6 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 29 Sep 2026 17:44:15 -0400 Subject: [PATCH 070/498] =?UTF-8?q?docs:=20registra=20o=20envio=20de=20avi?= =?UTF-8?q?sos=20por=20Gorush=20e=20o=20que=20ainda=20n=C3=A3o=20est=C3=A1?= =?UTF-8?q?=20provado?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- PROGRESS.md | 19 +++++++++++++++++++ apps/CLAUDE.md | 2 +- backend/CLAUDE.md | 2 +- spec/lgpd_data_audit.md | 1 + spec/lgpd_design.md | 6 ++++-- spec/stack.md | 2 +- 6 files changed, 27 insertions(+), 5 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 45795914..ff27aed2 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1176,3 +1176,22 @@ Só especificação; nenhum código mudou. `spec/stack.md`, `spec/PRD_system.md` - **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 está provado nem pronto:** +- **Nenhum teste fala com um Gorush ou com o FCM/APNs de verdade.** O cliente é provado contra um servidor HTTP falso; nunca se viu um push chegar a um aparelho. +- **O lado nativo do canal (Kotlin/Swift) não existe:** o app do paciente nunca obtém um token real, então `push_tokens` só tem linhas de teste. +- 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`. +- 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. diff --git a/apps/CLAUDE.md b/apps/CLAUDE.md index 5c0199b9..205dcfaa 100644 --- a/apps/CLAUDE.md +++ b/apps/CLAUDE.md @@ -32,7 +32,7 @@ The RPC trusts a development CA that is **separate** from the broker's: `scripts 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). -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 default `NoPushTokenSource` returns `null` until a Firebase project exists (RF14 §3.2), and a refusal or failure never blocks the home or the emergency alert. +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 native Kotlin/Swift side does not exist yet**, so the channel is silent and the app degrades to no push. A refusal or failure never blocks the home or the emergency alert. 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`. diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 2d5991f8..e60d309e 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -65,7 +65,7 @@ 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 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); 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), `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), `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 +**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 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); 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, `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), `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), `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 ú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 diff --git a/spec/lgpd_data_audit.md b/spec/lgpd_data_audit.md index 3f6b9779..75cca5b7 100644 --- a/spec/lgpd_data_audit.md +++ b/spec/lgpd_data_audit.md @@ -108,6 +108,7 @@ A tabela abaixo consolida o mapeamento exaustivo de dados persistidos pelo backe | | `token` | `text` | Identificador de aparelho | Emitido pelo FCM/APNs | Identifica um aparelho, não uma pessoa; junto de `userId` liga os dois. Índice único: o mesmo token nunca tem dois donos. | | | `platform` | `text` | Metadado Técnico | `android` \| `ios` | Escolhe o provedor do envio. | | | `createdAt` / `updatedAt` | `timestamp without time zone` | Metadado Técnico | Relógio do servidor | Primeiro registro e última confirmação do token. | +| | (`audit_logs`) | — | Metadado Técnico | `resourceType = community_notice` | Uma linha por aviso comunitário enviado: `userId` do ACS, `resourceId` = microárea, `result` = `granted` ou `no_recipients`. O texto do aviso nunca é gravado. | | **audit_logs** | `id` | `uuid` | Pseudonimizado | UUID v4 (`gen_random_uuid()`) | Identificador do registro de auditoria (LGPD-RF11). | | | `userId` | `uuid` | Pseudonimizado | Chave estrangeira (`users.id`) | Identifica o operador que executou a ação auditada. | | | `actionType` | `text` | Metadado Técnico | Enum textual (`READ`, `WRITE`, `DELETE`, etc.) | Operação registrada. | diff --git a/spec/lgpd_design.md b/spec/lgpd_design.md index 5ee4ccb8..59f7a517 100644 --- a/spec/lgpd_design.md +++ b/spec/lgpd_design.md @@ -69,8 +69,10 @@ locais quando o consentimento espelhado no aparelho **Aviso — `ConsentPurpose.segmentedPush` continua sem leitor.** RF14 (avisos segmentados por push) não tem nenhum código de envio no repositório ainda — -depende de hospedar o Gorush e provisionar credenciais FCM/APNs (§3.2 do -mesmo documento de decisões), não apenas de implementação. Não há +tem hoje um leitor: `notices.sendSegmented` (RF14) consulta a linha de +consentimento mais recente de cada titular antes de montar a lista de +destinatários. O envio real ainda depende de hospedar o Gorush e provisionar +credenciais FCM/APNs (§3.2 do mesmo documento de decisões). Não há o que "respeitar" hoje porque nada envia. Quando `notices.sendSegmented` for implementado, ele **deve** consultar o consentimento de `segmentedPush` antes de enviar, com o mesmo padrão de recusa por omissão adotado aqui para diff --git a/spec/stack.md b/spec/stack.md index 17331b53..a11d6f12 100644 --- a/spec/stack.md +++ b/spec/stack.md @@ -44,7 +44,7 @@ onboarding e consentimento (§2, RF02) e a metade central→dispositivo da sincronização (§5, RF15 — pull incremental; a leitura fica do lado do ACS, não há geração de mudança do lado do paciente ainda). Lembretes locais (§3.1, RF06) também têm implementação no app do paciente. Ainda **não** -implementados: push segmentado (§3.2, RF14 — Gorush; falta hospedá-lo e +implementados de ponta a ponta: push segmentado (§3.2, RF14 — o código do envio existe, com Gorush; falta hospedá-lo e provisionar as credenciais FCM/APNs) e geofencing (§4, RF12 — só o contrato de dados `arrivalMethod` foi desenhado, sem o serviço de geofence em primeiro plano): From aa4f3aeb240d2b4a3e894f04e5fe4c8f5e518c30 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 29 Sep 2026 17:50:44 -0400 Subject: [PATCH 071/498] =?UTF-8?q?fix(backend):=20timeout=20do=20envio=20?= =?UTF-8?q?=C3=A9=20resultado=20desconhecido,=20aceitos=20sem=20counts,=20?= =?UTF-8?q?MismatchSenderId=20n=C3=A3o=20apaga=20token=20(RF14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Achados do revisor final. Docs que diziam envio pendente corrigidas. Co-Authored-By: Claude Sonnet 5.5 --- PROGRESS.md | 9 +++-- .../application/notices/notice_service.dart | 11 +++++- .../infrastructure/push/gorush_client.dart | 26 ++++++++++--- .../test/unit/gorush_client_test.dart | 39 +++++++++++++++++-- .../test/unit/notice_service_test.dart | 10 +++++ ...26-09-16-decisoes-produto-pos-validacao.md | 6 ++- infra/docker/gorush/README.md | 13 ++++++- infra/docker/gorush/config.yml | 3 ++ spec/PRD_system.md | 2 +- spec/stack.md | 2 +- 10 files changed, 104 insertions(+), 17 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index ff27aed2..6a96b332 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1094,8 +1094,9 @@ O que **não** foi feito, de propósito: `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` é registrado mas não tem efeito**: não há Gorush hospedado nem credenciais - FCM/APNs (RF14). A descrição na tela diz isso. +- **`segmentedPush` tem leitor no código, mas nenhum aviso chega a um aparelho ainda**: + `notices.sendSegmented` respeita o consentimento, mas não há Gorush hospedado, credenciais + FCM/APNs nem lado nativo do token (RF14, ver "RF14: envio de avisos segmentados com Gorush"). ## QR Code do onboarding e documentos legais (2026-09-29) @@ -1160,7 +1161,7 @@ Plano: `docs/superpowers/plans/2026-09-29-menores-adiados-e-push-do-paciente.md` - Revogar grava o `denied` e apaga os tokens em duas operações: se a segunda falhar, o consentimento já está revogado e o token fica até a próxima revogação. - `devices.registerPushToken` não grava linha de auditoria (a revogação já deixa `consent_log`); a troca de dono do token não deixa rastro. - Sem teto de tokens por titular; sem teste do caso "fonte de token que nunca completa"; `PushTokenScope.of` sem `maybeOf`. -- Envio segmentado (`notices.sendSegmented`), tela de avisos do ACS e a captura do token nativo: dependem de hospedar o Gorush e provisionar credenciais FCM/APNs (§3.2, revisado em 2026-09-29 — Gorush no lugar de integrar o Firebase). +- 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". Continuam pendentes o Gorush hospedado, as credenciais FCM/APNs e o lado nativo do token (§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 e revisão jurídica do texto 2026.1. - 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). @@ -1195,3 +1196,5 @@ Plano: `docs/superpowers/plans/2026-09-29-rf14-gorush-avisos-segmentados.md`, br - 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. diff --git a/backend/sinalacs_server/lib/src/application/notices/notice_service.dart b/backend/sinalacs_server/lib/src/application/notices/notice_service.dart index 3ad1443e..c1a6de8d 100644 --- a/backend/sinalacs_server/lib/src/application/notices/notice_service.dart +++ b/backend/sinalacs_server/lib/src/application/notices/notice_service.dart @@ -102,7 +102,16 @@ class NoticeService { PushMessage(title: cleanTitle, body: cleanMessage, data: const {'screen': 'notices'}), targets, ); - } on PushGatewayException { + } 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(user, microAreaId, 'unknown'); + throw NoticeDeliveryException( + message: 'O envio demorou e o resultado é desconhecido: alguns pacientes ' + 'podem já ter recebido o aviso. Confira antes de reenviar.', + ); + } throw NoticeDeliveryException( message: 'Não foi possível entregar o aviso agora. Tente de novo em instantes.', ); diff --git a/backend/sinalacs_server/lib/src/infrastructure/push/gorush_client.dart b/backend/sinalacs_server/lib/src/infrastructure/push/gorush_client.dart index f3920535..02cfd203 100644 --- a/backend/sinalacs_server/lib/src/infrastructure/push/gorush_client.dart +++ b/backend/sinalacs_server/lib/src/infrastructure/push/gorush_client.dart @@ -34,10 +34,15 @@ class PushSendReport { /// 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); + 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'; } @@ -62,13 +67,16 @@ class GorushClient implements PushSender { 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 (firebase-admin-go) 'baddevicetoken', 'devicetokennotfortopic', - 'mismatchsenderid', ]; @override @@ -97,7 +105,10 @@ class GorushClient implements PushSender { } on PushGatewayException { rethrow; } on TimeoutException { - throw const PushGatewayException('O Gorush não respondeu a tempo.'); + throw const PushGatewayException( + 'O Gorush não respondeu a tempo.', + outcomeUnknown: true, + ); } on SocketException { throw const PushGatewayException('O Gorush está inacessível.'); } on HttpException { @@ -127,7 +138,12 @@ class GorushClient implements PushSender { final token = log['token']; if (token is String && _invalidTokenErrors.any(error.contains)) invalid.add(token); } - final counts = json['counts']; - return PushSendReport(accepted: counts is int ? counts : (total - failed).clamp(0, total), invalidTokens: invalid); + // `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/test/unit/gorush_client_test.dart b/backend/sinalacs_server/test/unit/gorush_client_test.dart index e19d0641..38fd5a32 100644 --- a/backend/sinalacs_server/test/unit/gorush_client_test.dart +++ b/backend/sinalacs_server/test/unit/gorush_client_test.dart @@ -93,23 +93,56 @@ void main() { 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()), + throwsA(isA().having((e) => e.outcomeUnknown, 'outcomeUnknown', isFalse)), ); }); - test('servidor que não responde estoura o tempo limite como PushGatewayException', () async { + 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()), + throwsA(isA().having((e) => e.outcomeUnknown, 'outcomeUnknown', isTrue)), ); }); diff --git a/backend/sinalacs_server/test/unit/notice_service_test.dart b/backend/sinalacs_server/test/unit/notice_service_test.dart index 8a6885e4..9eb55f69 100644 --- a/backend/sinalacs_server/test/unit/notice_service_test.dart +++ b/backend/sinalacs_server/test/unit/notice_service_test.dart @@ -137,6 +137,16 @@ void main() { 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('sem Gorush configurado o envio é recusado com mensagem clara', () async { final off = NoticeService(store: store, sender: null, audit: audit); await expectLater( diff --git a/docs/superpowers/specs/2026-09-16-decisoes-produto-pos-validacao.md b/docs/superpowers/specs/2026-09-16-decisoes-produto-pos-validacao.md index 8b21876a..3b066e8c 100644 --- a/docs/superpowers/specs/2026-09-16-decisoes-produto-pos-validacao.md +++ b/docs/superpowers/specs/2026-09-16-decisoes-produto-pos-validacao.md @@ -224,8 +224,10 @@ específico já decidido em §2. **Estado atual:** o lado do paciente registra o token do aparelho (`devices.registerPushToken`, tabela `push_tokens`, só com o consentimento -`segmentedPush` vigente). Não há envio segmentado, nem tela de avisos no ACS, -nem servidor de push. `NoticesScreen` continua descartando a entrada. +`segmentedPush` vigente), o backend envia (`notices.sendSegmented`, cliente do +Gorush, restrito ao ACS — o perfil admin não existe ainda) e a tela de avisos do +ACS chama esse endpoint. Falta hospedar o Gorush, as credenciais FCM/APNs e o +lado nativo do token no app. Nenhum push real foi visto chegando a um aparelho. **Decisão aprovada (revisada em 2026-09-29: Gorush no lugar de um SDK/console Firebase como ponto de integração):** diff --git a/infra/docker/gorush/README.md b/infra/docker/gorush/README.md index 43c736b2..a48a0c5f 100644 --- a/infra/docker/gorush/README.md +++ b/infra/docker/gorush/README.md @@ -15,7 +15,18 @@ O Gorush é um relé, **não substitui** as credenciais dos provedores. Coloque - `apns-key.p8` — chave de autenticação do APNs (iOS), com `GORUSH_IOS_KEY_ID` e `GORUSH_IOS_TEAM_ID` no `.env`. -Nada disso é versionado nem gerado por `bootstrap_env.sh`. +Nada disso é versionado nem gerado por `bootstrap_env.sh` (a decisão §3.2 falava em +"padrão `bootstrap_env.sh`" para o que for segredo aleatório; credenciais de provedor +não são aleatórias, são emitidas por ele). + +## O que ainda não foi verificado contra um Gorush real + +O cliente do backend foi provado só contra um servidor HTTP falso. Confira, antes de +ligar em produção: (1) se `counts` e `logs` da resposta síncrona têm o formato assumido; +(2) se o `log.hide_token` padrão mascara o token na resposta — nesse caso a poda de +tokens inválidos não casa com `push_tokens.token`; (3) as strings de erro do FCM v1 e do +APNs; (4) se o Gorush sobe sem credenciais (com o `restart: unless-stopped`, pode entrar +em loop); (5) fixe uma tag em vez de `:latest`. ## Ligar o backend diff --git a/infra/docker/gorush/config.yml b/infra/docker/gorush/config.yml index 82c53c89..373f4b71 100644 --- a/infra/docker/gorush/config.yml +++ b/infra/docker/gorush/config.yml @@ -15,6 +15,9 @@ ios: team_id: "" production: false log: + # O texto do aviso não deve ir para `docker logs`: o backend já não o grava + # na auditoria, e o relé não pode ser o ponto onde ele vaza. + hide_messages: true format: json access_log: "-" error_log: "-" diff --git a/spec/PRD_system.md b/spec/PRD_system.md index e361ae41..c8962c9f 100644 --- a/spec/PRD_system.md +++ b/spec/PRD_system.md @@ -170,7 +170,7 @@ documento de decisão, não implementação: | RF10 (mapa) | Geocélula arredondada, não posição exata (§1) | Não | | RF02 + LGPD-RF02 (onboarding/consentimento) | Token de convite de uso único + consentimento por finalidade (§2) | Não | | RF06 (lembretes) | Local ao dispositivo, sem endpoint (§3.1) | Não | -| RF14 (avisos push) | Contrato com Gorush definido (§3.2, revisado 2026-09-29); lado do paciente pronto (`devices.registerPushToken`, `push_tokens`, `PushTokenSource`); envio, Gorush e captura do token nativo pendentes | **Parcial** — falta hospedar o Gorush e provisionar credenciais FCM/APNs | +| RF14 (avisos push) | Contrato com Gorush definido (§3.2, revisado 2026-09-29); lado do paciente e envio prontos em código (`devices.registerPushToken`, `push_tokens`, `PushTokenSource`, `notices.sendSegmented`, tela de avisos do ACS); Gorush hospedado, credenciais e lado nativo do token pendentes | **Parcial** — falta hospedar o Gorush e provisionar credenciais FCM/APNs | | RF12 (geofencing) | Geofence atrelado a visita ativa, sem rastreamento contínuo (§4) | Parcial — submissão à loja pendente | | RF15 (sync central→dispositivo) | Pull incremental por cursor (§5) | Não | | RNF03 (criptografia Postgres) | AES-256-GCM em nível de aplicação (§6) | Não | diff --git a/spec/stack.md b/spec/stack.md index a11d6f12..c13c7af1 100644 --- a/spec/stack.md +++ b/spec/stack.md @@ -66,7 +66,7 @@ provisionar as credenciais FCM/APNs) e geofencing (§4, RF12 — só o contrato restrita a quem consentiu (`segmentedPush`); o backend entrega a lista de tokens ao Gorush, que fala com o FCM/APNs. O app do paciente adota `flutter_riverpod` **somente** na captura e no registro do token - (`pushTokenProvider`); o resto da injeção segue por `InheritedWidget`. O Gorush ainda precisa de credencial FCM (Android) e chave + (`pushTokenSourceProvider`); o resto da injeção segue por `InheritedWidget`. O Gorush ainda precisa de credencial FCM (Android) e chave APNs (iOS), então a pendência passa de "projeto Firebase" para "hospedar o Gorush e provisionar essas credenciais" — decisão de infra, não de código. * **Geofencing (RF12):** rejeitado rastreamento contínuo em segundo plano do From 80cae83496394d172420aa71e8aedef83220dd73 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 29 Sep 2026 18:04:08 -0400 Subject: [PATCH 072/498] =?UTF-8?q?docs:=20revisa=20o=20plano=20dos=20meno?= =?UTF-8?q?res=20do=20push=20e=20do=20aviso=20de=2015=20dias=20contra=20o?= =?UTF-8?q?=20c=C3=B3digo=20atual?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- ...s-do-push-e-aviso-de-mudanca-dos-termos.md | 37 +++++++++++-------- 1 file changed, 22 insertions(+), 15 deletions(-) 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 index 2017ff10..f6734bcf 100644 --- 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 @@ -8,15 +8,17 @@ **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 44 infos (avisos: zero). +- `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 (`9300…`) e `enrollmentId` próprio, e limpam à mão; o seed de `ACS-001` derruba outros arquivos em paralelo. +- 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 @@ -92,7 +94,7 @@ test('recusa por falta de consentimento não é auditada e lança', () async { }); ``` -(O `_FakePushTokenStore` decide `ownerChanged` quando a linha já existe com outro `userId`. `PushTokenService` passa a receber `AuditTrail audit`, e o `setUp` usa o `FakeAuditTrail` de `data_subject_rights_service_test.dart`, copiado para o arquivo ou movido para `test/support/fake_audit_trail.dart`.) +(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. @@ -149,13 +151,13 @@ Expected: PASS. - [ ] **Step 4: Testes de integração vermelhos** -Em `push_token_endpoint_test.dart`, grupo de corrida (ids `9200`, mesmo `cleanup`), três testes novos: +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 { - // seed + consentimento granted + token registrado (via store) - // chama recordConsentRevokingPush com um entry inválido de propósito - // (userId inexistente => violação de FK do consent_logs) e espera o erro + // _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á }); @@ -170,7 +172,7 @@ test('troca de dono devolve ownerChanged', () async { }); ``` -Para o primeiro, prove a atomicidade com uma falha real do banco: `entry.userId` que não existe em `users` faz o `insertRow` violar a FK; o `deleteWhere` do mesmo bloco não pode ter efeito (`count` do token do titular real continua 1, e nenhuma linha `denied` existe para ele). Como o titular do `entry` é inexistente, mande o `entry` com o `userId` de um segundo usuário semeado e faça a falha vir de `purpose` fora do enum? Não: use a FK. O que importa é que a transação inteira reverta. +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. @@ -187,14 +189,17 @@ Expected: FAIL — `recordConsentRevokingPush` não existe. return session.db.transaction((transaction) async { await lockPerSubject(session, transaction, namespace: lockNamespacePushToken, key: entry.userId); - final row = await ConsentLog.db.insertRow( + await PushToken.db.deleteWhere( session, - signedConsentLog(entry, signature: _signature, origin: 'painel-titular'), + where: (t) => t.userId.equals(userUuid), transaction: transaction, ); - await PushToken.db.deleteWhere( + if (_debugFailAfterTokenDelete) { + throw StateError('falha injetada depois de apagar os tokens (só em teste)'); + } + final row = await ConsentLog.db.insertRow( session, - where: (t) => t.userId.equals(userUuid), + signedConsentLog(entry, signature: _signature, origin: 'painel-titular'), transaction: transaction, ); return row.id!.uuid; @@ -202,6 +207,8 @@ Expected: FAIL — `recordConsentRevokingPush` não existe. } ``` +`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 @@ -222,7 +229,7 @@ O teto roda nos dois caminhos que gravam (insere e atualiza), para que trocar de - [ ] **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 44 infos, zero avisos. +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 @@ -467,7 +474,7 @@ Expected: PASS. Se `endpoint_auth_posture_test.dart` ou o teste de cobertura de - [ ] **Step 4: Suíte e commit** Run: `cd backend/sinalacs_server && dart test && dart analyze` -Expected: verde e estável em 5 execuções; 44 infos. +Expected: verde e estável em 5 execuções; 50 infos. ```bash git add backend/sinalacs_server apps/patient/lib @@ -622,7 +629,7 @@ git commit -m "feat(paciente): cartão de aviso de mudança dos termos e push se - [ ] **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 44 infos e zero avisos, paciente e ACS verdes, `ci_invariants` ok. +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** From fc1d8810157bcefe58bbc957ccd71bb94005ef4d Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 29 Sep 2026 18:08:38 -0400 Subject: [PATCH 073/498] =?UTF-8?q?fix(backend):=20revoga=C3=A7=C3=A3o=20d?= =?UTF-8?q?e=20push=20at=C3=B4mica,=20auditoria=20da=20troca=20de=20dono?= =?UTF-8?q?=20e=20teto=20de=20tokens=20(RF14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../patients/data_subject_rights_service.dart | 44 +++--- .../patients/push_token_service.dart | 53 +++++-- .../orm_data_subject_rights_store.dart | 54 +++++-- .../database/orm_push_token_store.dart | 52 ++++--- .../lib/src/runtime/alert_runtime.dart | 6 +- .../integration/push_token_endpoint_test.dart | 133 ++++++++++++++++-- .../data_subject_rights_service_test.dart | 54 +++---- .../test/unit/push_token_service_test.dart | 45 ++++-- 8 files changed, 317 insertions(+), 124 deletions(-) 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 index f16f2e61..7abbcb86 100644 --- 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 @@ -5,7 +5,6 @@ import 'package:sinalacs_server/src/application/onboarding/onboarding_service.da 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/push_token_service.dart' show PushTokenStore; import 'package:sinalacs_server/src/generated/protocol.dart'; /// Persistência das operações do titular sobre os próprios dados. Interface @@ -16,6 +15,11 @@ abstract interface class DataSubjectRightsStore { /// 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 @@ -45,6 +49,11 @@ abstract interface class DataSubjectRightsStore { }); } +/// 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); @@ -62,16 +71,13 @@ class DataSubjectRightsService { DataSubjectRightsService({ required DataSubjectRightsStore store, required AuditTrail audit, - PushTokenStore? pushTokens, DateTime Function()? clock, }) : _store = store, _audit = audit, - _pushTokens = pushTokens, _clock = clock ?? DateTime.now; final DataSubjectRightsStore _store; final AuditTrail _audit; - final PushTokenStore? _pushTokens; final DateTime Function() _clock; /// Concede ou revoga uma finalidade opcional. `healthDataProcessing` é @@ -97,13 +103,13 @@ class DataSubjectRightsService { ); } - final record = await _record(user, purpose: purpose, action: granted ? 'granted' : 'denied'); - // Sem consentimento, sem token: o aparelho deixa de estar ligado ao titular - // no mesmo instante da revogação (RF14). - if (purpose == ConsentPurpose.segmentedPush && !granted) { - await _pushTokens?.deleteAllFor(user.id); - } - return record; + // 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 @@ -133,31 +139,35 @@ class DataSubjectRightsService { ); } - 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)); + return isCurrentAcceptance( + await _store.latestConsent(user.id, ConsentPurpose.termsOfUse), + version: consentPolicyVersion, + ); } Future _record( AuthenticatedUser user, { required ConsentPurpose purpose, required String action, + bool revokePush = false, }) async { final now = _clock().toUtc(); - final id = await _store.recordConsent(ConsentLogEntry( + 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', 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 index 0a699c8f..ef279cf5 100644 --- a/backend/sinalacs_server/lib/src/application/patients/push_token_service.dart +++ b/backend/sinalacs_server/lib/src/application/patients/push_token_service.dart @@ -1,3 +1,4 @@ +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'; @@ -8,23 +9,33 @@ 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 - /// (`deleteAllFor`) espera o mesmo lock. Se o token já existe, muda o dono e - /// renova `updatedAt`. Devolve `false` sem consentimento — 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. - Future registerIfConsented({ + /// (`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, }); - - /// Apaga todos os tokens do titular (sob o mesmo lock por titular do - /// registro) e devolve quantos eram. - Future deleteAllFor(String userId); } +/// 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; + /// Teto do token: o do FCM tem cerca de 160 caracteres, o do APNs 64. const int pushTokenMaxLength = 4096; const Set pushPlatforms = {'android', 'ios'}; @@ -35,11 +46,16 @@ const Set pushPlatforms = {'android', 'ios'}; /// `segmentedPush` vigente: o token liga aparelho a titular, então sem base /// legal ele nem é guardado. class PushTokenService { - PushTokenService({required PushTokenStore store, DateTime Function()? clock}) - : _store = store, + 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( @@ -59,17 +75,28 @@ class PushTokenService { !pushPlatforms.contains(platform)) { throw DataRightsException(message: 'Aparelho inválido para receber avisos.'); } - final registered = await _store.registerIfConsented( + final outcome = await _store.registerIfConsented( userId: user.id, microAreaId: user.microAreaId, token: trimmed, platform: platform, now: _clock().toUtc(), ); - if (!registered) { + 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; o token nunca entra na trilha. + if (outcome == PushRegistration.ownerChanged) { + await _audit.recordSafely(AuditEvent( + userId: user.id, + actionType: 'write', + resourceType: 'push_token', + result: 'granted', + )); + } } } 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 index 92e1c2da..83bc553e 100644 --- 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 @@ -1,3 +1,4 @@ +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' @@ -36,13 +37,16 @@ class OrmDataSubjectRightsStore implements DataSubjectRightsStore { required Session Function() session, required String chainSecret, required HealthDataCipher cipher, + @visibleForTesting bool debugFailAfterTokenDelete = false, }) : _session = session, _signature = ConsentSignature(secret: chainSecret), - _cipher = cipher; + _cipher = cipher, + _debugFailAfterTokenDelete = debugFailAfterTokenDelete; final Session Function() _session; final ConsentSignature _signature; final HealthDataCipher _cipher; + final bool _debugFailAfterTokenDelete; @override Future recordConsent(ConsentLogEntry entry) async { @@ -53,6 +57,34 @@ class OrmDataSubjectRightsStore implements DataSubjectRightsStore { return row.id!.uuid; } + /// 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 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, @@ -69,16 +101,16 @@ class OrmDataSubjectRightsStore implements DataSubjectRightsStore { 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 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, 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 index 6f241dd9..33bb4e9d 100644 --- 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 @@ -15,7 +15,7 @@ class OrmPushTokenStore implements PushTokenStore { /// 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({ + Future registerIfConsented({ required String userId, required String? microAreaId, required String token, @@ -45,8 +45,9 @@ class OrmPushTokenStore implements PushTokenStore { if (existing != null && existing.userId != userUuid) { await PushToken.db.deleteRow(session, existing, transaction: transaction); } - return false; + return PushRegistration.refused; } + final ownerChanged = existing != null && existing.userId != userUuid; if (existing != null) { await PushToken.db.updateRow( session, @@ -58,35 +59,32 @@ class OrmPushTokenStore implements PushTokenStore { ), transaction: transaction, ); - return true; + } else { + await PushToken.db.insertRow( + session, + PushToken( + userId: userUuid, + microAreaId: areaUuid, + token: token, + platform: platform, + createdAt: now, + updatedAt: now, + ), + transaction: transaction, + ); } - await PushToken.db.insertRow( - session, - PushToken( - userId: userUuid, - microAreaId: areaUuid, - token: token, - platform: platform, - createdAt: now, - updatedAt: now, - ), - transaction: transaction, - ); - return true; - }); - } - - @override - Future deleteAllFor(String userId) async { - final session = _session(); - return session.db.transaction((transaction) async { - await lockPerSubject(session, transaction, namespace: lockNamespacePushToken, key: userId); - final removed = await PushToken.db.deleteWhere( + // Teto por titular: os mais antigos saem, nunca o que acabou de entrar. + final mine = await PushToken.db.find( session, - where: (t) => t.userId.equals(UuidValue.fromString(userId)), + where: (t) => t.userId.equals(userUuid), + orderBy: (t) => t.updatedAt, + orderDescending: true, transaction: transaction, ); - return removed.length; + for (final old in mine.skip(maxPushTokensPerUser)) { + await PushToken.db.deleteRow(session, old, transaction: transaction); + } + return ownerChanged ? PushRegistration.ownerChanged : PushRegistration.registered; }); } } diff --git a/backend/sinalacs_server/lib/src/runtime/alert_runtime.dart b/backend/sinalacs_server/lib/src/runtime/alert_runtime.dart index 095a154e..3cf9343e 100644 --- a/backend/sinalacs_server/lib/src/runtime/alert_runtime.dart +++ b/backend/sinalacs_server/lib/src/runtime/alert_runtime.dart @@ -224,7 +224,6 @@ class AlertRuntime { cipher: healthDataCipher, ), audit: auditTrailFor(session), - pushTokens: OrmPushTokenStore(session: () => session), ); /// Aviso comunitário do ACS (RF14). Sem `GORUSH_URL` o serviço nasce sem relé e @@ -243,7 +242,10 @@ class AlertRuntime { /// Registro do aparelho para avisos segmentados (RF14). PushTokenService pushTokenServiceFor(Session session) => - PushTokenService(store: OrmPushTokenStore(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) => diff --git a/backend/sinalacs_server/test/integration/push_token_endpoint_test.dart b/backend/sinalacs_server/test/integration/push_token_endpoint_test.dart index 31263964..2a5cb62a 100644 --- a/backend/sinalacs_server/test/integration/push_token_endpoint_test.dart +++ b/backend/sinalacs_server/test/integration/push_token_endpoint_test.dart @@ -1,4 +1,6 @@ import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/patients/push_token_service.dart' + show PushRegistration, 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' @@ -264,8 +266,9 @@ void main() { Future cleanup(Session session) async { final id = UuidValue.fromString(_racePatientId); - await PushToken.db.deleteWhere(session, where: (t) => t.userId.equals(id)); - await ConsentLog.db.deleteWhere(session, where: (t) => t.userId.equals(id)); + 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)), @@ -307,10 +310,13 @@ void main() { platform: 'android', now: DateTime.now().toUtc(), ), - () async { - await consent('denied'); - await tokens.deleteAllFor(_racePatientId); - }(), + consents.recordConsentRevokingPush(ConsentLogEntry( + userId: _racePatientId, + purpose: ConsentPurpose.segmentedPush, + action: 'denied', + version: consentPolicyVersion, + timestamp: DateTime.now().toUtc(), + )), ]); final left = await PushToken.db.count( session, @@ -348,7 +354,7 @@ void main() { platform: 'android', now: DateTime.now().toUtc(), ), - isTrue, + PushRegistration.registered, ); // O ACS de teste faz o papel da segunda pessoa: nunca consentiu. @@ -360,7 +366,7 @@ void main() { now: DateTime.now().toUtc(), ); - expect(registered, isFalse); + expect(registered, PushRegistration.refused); expect( await PushToken.db.count(session, where: (t) => t.token.equals('tok-compartilhado')), 0, @@ -370,6 +376,117 @@ void main() { 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(); + await _seedRaceLean(session); + try { + 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(); + await _seedRaceLean(session); + try { + 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(); + await _seedRaceLean(session); + try { + 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), PushRegistration.registered); + expect(await register(_raceAcsId), PushRegistration.ownerChanged); + expect(await register(_raceAcsId), PushRegistration.registered); + expect(await PushToken.db.count(session, where: (t) => t.token.equals('tok-dono')), 1); + } finally { + await cleanup(session); + } + }); }, rollbackDatabase: RollbackDatabase.disabled, ); 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 index d9830229..e64de72b 100644 --- a/backend/sinalacs_server/test/unit/data_subject_rights_service_test.dart +++ b/backend/sinalacs_server/test/unit/data_subject_rights_service_test.dart @@ -3,7 +3,6 @@ import 'package:sinalacs_server/src/application/auth/development_auth_service.da 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/push_token_service.dart'; import 'package:sinalacs_server/src/generated/protocol.dart'; import 'package:test/test.dart'; @@ -44,6 +43,14 @@ class FakeDataSubjectRightsStore implements DataSubjectRightsStore { 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); @@ -111,26 +118,6 @@ class FakeDataSubjectRightsStore implements DataSubjectRightsStore { } } -class FakePushTokenStore implements PushTokenStore { - var deleteCalls = []; - - @override - Future registerIfConsented({ - required String userId, - required String? microAreaId, - required String token, - required String platform, - required DateTime now, - }) async => - true; - - @override - Future deleteAllFor(String userId) async { - deleteCalls.add(userId); - return 0; - } -} - class FakeAuditTrail extends AuditTrail { FakeAuditTrail({this.failOnRecord = false}); @@ -155,22 +142,17 @@ void main() { service = DataSubjectRightsService(store: store, audit: audit, clock: () => _now); }); - group('revogar segmentedPush apaga os tokens de push (RF14)', () { - test('só a revogação de segmentedPush chama deleteAllFor', () async { - final pushTokens = FakePushTokenStore(); - final svc = DataSubjectRightsService( - store: store, - audit: audit, - pushTokens: pushTokens, - clock: () => _now, - ); - - await svc.updateConsent(_patient, purpose: ConsentPurpose.segmentedPush, granted: true); - await svc.updateConsent(_patient, purpose: ConsentPurpose.localReminders, granted: false); - expect(pushTokens.deleteCalls, isEmpty); + 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 svc.updateConsent(_patient, purpose: ConsentPurpose.segmentedPush, granted: false); - expect(pushTokens.deleteCalls, [_patientId]); + 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'); }); }); diff --git a/backend/sinalacs_server/test/unit/push_token_service_test.dart b/backend/sinalacs_server/test/unit/push_token_service_test.dart index 6fa05e80..f484107d 100644 --- a/backend/sinalacs_server/test/unit/push_token_service_test.dart +++ b/backend/sinalacs_server/test/unit/push_token_service_test.dart @@ -1,3 +1,4 @@ +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'; @@ -28,37 +29,41 @@ const _acs = AuthenticatedUser( class _FakePushTokenStore implements PushTokenStore { var consent = true; - var deleted = 0; final rows = {}; @override - Future registerIfConsented({ + Future registerIfConsented({ required String userId, required String? microAreaId, required String token, required String platform, required DateTime now, }) async { - if (!consent) return false; + if (!consent) return PushRegistration.refused; + final previous = rows[token]; rows[token] = (userId: userId, platform: platform); - return true; + return previous != null && previous.userId != userId + ? PushRegistration.ownerChanged + : PushRegistration.registered; } +} + +class _FakeAudit extends AuditTrail { + final events = []; @override - Future deleteAllFor(String userId) async { - deleted++; - rows.removeWhere((_, r) => r.userId == userId); - return 1; - } + Future record(AuditEvent event) async => events.add(event); } void main() { late _FakePushTokenStore store; + late _FakeAudit audit; late PushTokenService service; setUp(() { store = _FakePushTokenStore(); - service = PushTokenService(store: store, clock: () => DateTime.utc(2026, 9, 29)); + audit = _FakeAudit(); + service = PushTokenService(store: store, audit: audit, clock: () => DateTime.utc(2026, 9, 29)); }); test('sem consentimento vigente, recusa e não grava', () async { @@ -98,4 +103,24 @@ void main() { throwsA(isA()), ); }); + + 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); + expect('${audit.events.single.resourceId} ${audit.events.single.result}'.contains('tok-1'), isFalse); + }); + + 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); + }); } From 876bbab895c689b5edad97d6c7e991269095594f Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 29 Sep 2026 18:11:20 -0400 Subject: [PATCH 074/498] =?UTF-8?q?feat(backend):=20agenda=20e=20aviso=20d?= =?UTF-8?q?e=2015=20dias=20de=20mudan=C3=A7a=20dos=20termos=20(LGPD-RF18)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../src/protocol/api/terms_change_notice.dart | 98 +++ .../lib/src/protocol/client.dart | 47 +- .../lib/src/protocol/protocol.dart | 576 ++++++++-------- .../patients/data_subject_rights_service.dart | 30 + .../patients/terms_change_schedule.dart | 41 ++ .../lib/src/endpoints/patients_endpoint.dart | 24 + .../generated/api/terms_change_notice.dart | 109 +++ .../lib/src/generated/endpoints.dart | 19 + .../lib/src/generated/protocol.dart | 628 +++++++++--------- .../lib/src/generated/protocol.yaml | 1 + .../models/api/terms_change_notice.spy.yaml | 8 + .../data_subject_rights_endpoint_test.dart | 25 + .../test_tools/serverpod_test_tools.dart | 63 +- .../data_subject_rights_service_test.dart | 50 ++ .../test/unit/terms_change_schedule_test.dart | 45 ++ 15 files changed, 1144 insertions(+), 620 deletions(-) create mode 100644 backend/sinalacs_client/lib/src/protocol/api/terms_change_notice.dart create mode 100644 backend/sinalacs_server/lib/src/application/patients/terms_change_schedule.dart create mode 100644 backend/sinalacs_server/lib/src/generated/api/terms_change_notice.dart create mode 100644 backend/sinalacs_server/lib/src/models/api/terms_change_notice.spy.yaml create mode 100644 backend/sinalacs_server/test/unit/terms_change_schedule_test.dart 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/client.dart b/backend/sinalacs_client/lib/src/protocol/client.dart index 5ab24a5b..fce48de0 100644 --- a/backend/sinalacs_client/lib/src/protocol/client.dart +++ b/backend/sinalacs_client/lib/src/protocol/client.dart @@ -34,13 +34,15 @@ import 'package:sinalacs_client/src/protocol/api/patient_consent_record.dart' as _i13; import 'package:sinalacs_client/src/protocol/enums/consent_purpose.dart' as _i14; -import 'package:sinalacs_client/src/protocol/api/patient_data_subject_request_record.dart' +import 'package:sinalacs_client/src/protocol/api/terms_change_notice.dart' as _i15; -import 'package:sinalacs_client/src/protocol/api/triage_result.dart' as _i16; +import 'package:sinalacs_client/src/protocol/api/patient_data_subject_request_record.dart' + as _i16; +import 'package:sinalacs_client/src/protocol/api/triage_result.dart' as _i17; import 'package:sinalacs_client/src/protocol/api/visit_sync_result.dart' - as _i17; -import 'package:sinalacs_client/src/protocol/api/visit_sync_entry.dart' as _i18; -import 'protocol.dart' as _i19; + as _i18; +import 'package:sinalacs_client/src/protocol/api/visit_sync_entry.dart' as _i19; +import 'protocol.dart' as _i20; /// Ciclo do alerta vermelho. /// @@ -444,11 +446,22 @@ class EndpointPatients extends EndpointAuthenticated { {'accessToken': accessToken}, ); + /// Aviso de mudança dos termos ativo agora (LGPD-RF18, 15 dias de antecedência), + /// ou `null`. Só paciente. Sem leitura de banco e sem linha de auditoria: a + /// agenda é uma constante do repositório e nada do titular é lido nem gravado. + _i2.Future<_i15.TermsChangeNotice?> termsChangeNotice({ + required String accessToken, + }) => caller.callServerEndpoint<_i15.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<_i15.PatientDataSubjectRequestRecord> requestDataDeletion({ + _i2.Future<_i16.PatientDataSubjectRequestRecord> requestDataDeletion({ required String accessToken, - }) => caller.callServerEndpoint<_i15.PatientDataSubjectRequestRecord>( + }) => caller.callServerEndpoint<_i16.PatientDataSubjectRequestRecord>( 'patients', 'requestDataDeletion', {'accessToken': accessToken}, @@ -457,10 +470,10 @@ class EndpointPatients extends EndpointAuthenticated { /// 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<_i15.PatientDataSubjectRequestRecord> requestDataCorrection({ + _i2.Future<_i16.PatientDataSubjectRequestRecord> requestDataCorrection({ required String accessToken, required String details, - }) => caller.callServerEndpoint<_i15.PatientDataSubjectRequestRecord>( + }) => caller.callServerEndpoint<_i16.PatientDataSubjectRequestRecord>( 'patients', 'requestDataCorrection', { @@ -487,7 +500,7 @@ class EndpointTriage extends EndpointAuthenticated { @override String get name => 'triage'; - _i2.Future<_i16.TriageResult> evaluate({ + _i2.Future<_i17.TriageResult> evaluate({ required String accessToken, required bool chestPain, required bool difficultyBreathing, @@ -495,7 +508,7 @@ class EndpointTriage extends EndpointAuthenticated { required bool persistentVomiting, required bool bleeding, required bool severeWeakness, - }) => caller.callServerEndpoint<_i16.TriageResult>( + }) => caller.callServerEndpoint<_i17.TriageResult>( 'triage', 'evaluate', { @@ -526,10 +539,10 @@ class EndpointVisits extends EndpointAuthenticated { @override String get name => 'visits'; - _i2.Future> sync({ + _i2.Future> sync({ required String accessToken, - required List<_i18.VisitSyncEntry> visits, - }) => caller.callServerEndpoint>( + required List<_i19.VisitSyncEntry> visits, + }) => caller.callServerEndpoint>( 'visits', 'sync', { @@ -541,10 +554,10 @@ class EndpointVisits extends EndpointAuthenticated { /// 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', { @@ -574,7 +587,7 @@ class Client extends _i1.ServerpodClientShared { bool? disconnectStreamsOnLostInternetConnection, }) : super( host, - _i19.Protocol(), + _i20.Protocol(), securityContext: securityContext, streamingConnectionTimeout: streamingConnectionTimeout, connectionTimeout: connectionTimeout, diff --git a/backend/sinalacs_client/lib/src/protocol/protocol.dart b/backend/sinalacs_client/lib/src/protocol/protocol.dart index 5c51b7ed..4d8b9cd2 100644 --- a/backend/sinalacs_client/lib/src/protocol/protocol.dart +++ b/backend/sinalacs_client/lib/src/protocol/protocol.dart @@ -30,45 +30,46 @@ import 'api/patient_data_subject_request_record.dart' as _i16; import 'api/patient_risk_event.dart' as _i17; import 'api/red_alert_result.dart' as _i18; import 'api/service_health.dart' as _i19; -import 'api/triage_result.dart' as _i20; -import 'api/visit_sync_entry.dart' as _i21; -import 'api/visit_sync_result.dart' as _i22; -import 'audit_log.dart' as _i23; -import 'consent_log.dart' as _i24; -import 'data_subject_request.dart' as _i25; -import 'enrollment_token.dart' as _i26; -import 'enums/alert_status.dart' as _i27; -import 'enums/arrival_method.dart' as _i28; -import 'enums/consent_purpose.dart' as _i29; -import 'enums/data_subject_request_status.dart' as _i30; -import 'enums/data_subject_request_type.dart' as _i31; -import 'enums/risk_level.dart' as _i32; -import 'enums/sync_status.dart' as _i33; -import 'enums/user_role.dart' as _i34; -import 'exceptions/alert_dispatch_unavailable_exception.dart' as _i35; -import 'exceptions/alert_permission_exception.dart' as _i36; -import 'exceptions/alert_validation_exception.dart' as _i37; -import 'exceptions/authentication_failed_exception.dart' as _i38; -import 'exceptions/data_rights_exception.dart' as _i39; -import 'exceptions/endpoint_disabled_exception.dart' as _i40; -import 'exceptions/enrollment_exception.dart' as _i41; -import 'exceptions/notice_delivery_exception.dart' as _i42; -import 'exceptions/otp_request_exception.dart' as _i43; -import 'micro_area.dart' as _i44; -import 'otp_challenge.dart' as _i45; -import 'patient.dart' as _i46; -import 'push_token.dart' as _i47; -import 'triage_answer.dart' as _i48; -import 'triage_session.dart' as _i49; -import 'ubs.dart' as _i50; -import 'user.dart' as _i51; -import 'user_credential.dart' as _i52; -import 'visit.dart' as _i53; +import 'api/terms_change_notice.dart' as _i20; +import 'api/triage_result.dart' as _i21; +import 'api/visit_sync_entry.dart' as _i22; +import 'api/visit_sync_result.dart' as _i23; +import 'audit_log.dart' as _i24; +import 'consent_log.dart' as _i25; +import 'data_subject_request.dart' as _i26; +import 'enrollment_token.dart' as _i27; +import 'enums/alert_status.dart' as _i28; +import 'enums/arrival_method.dart' as _i29; +import 'enums/consent_purpose.dart' as _i30; +import 'enums/data_subject_request_status.dart' as _i31; +import 'enums/data_subject_request_type.dart' as _i32; +import 'enums/risk_level.dart' as _i33; +import 'enums/sync_status.dart' as _i34; +import 'enums/user_role.dart' as _i35; +import 'exceptions/alert_dispatch_unavailable_exception.dart' as _i36; +import 'exceptions/alert_permission_exception.dart' as _i37; +import 'exceptions/alert_validation_exception.dart' as _i38; +import 'exceptions/authentication_failed_exception.dart' as _i39; +import 'exceptions/data_rights_exception.dart' as _i40; +import 'exceptions/endpoint_disabled_exception.dart' as _i41; +import 'exceptions/enrollment_exception.dart' as _i42; +import 'exceptions/notice_delivery_exception.dart' as _i43; +import 'exceptions/otp_request_exception.dart' as _i44; +import 'micro_area.dart' as _i45; +import 'otp_challenge.dart' as _i46; +import 'patient.dart' as _i47; +import 'push_token.dart' as _i48; +import 'triage_answer.dart' as _i49; +import 'triage_session.dart' as _i50; +import 'ubs.dart' as _i51; +import 'user.dart' as _i52; +import 'user_credential.dart' as _i53; +import 'visit.dart' as _i54; import 'package:sinalacs_client/src/protocol/api/micro_area_patient.dart' - as _i54; -import 'package:sinalacs_client/src/protocol/api/visit_sync_result.dart' as _i55; -import 'package:sinalacs_client/src/protocol/api/visit_sync_entry.dart' as _i56; +import 'package:sinalacs_client/src/protocol/api/visit_sync_result.dart' + as _i56; +import 'package:sinalacs_client/src/protocol/api/visit_sync_entry.dart' as _i57; export 'acs.dart'; export 'alert.dart'; export 'alert_delivery_record.dart'; @@ -87,6 +88,7 @@ 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/triage_result.dart'; export 'api/visit_sync_entry.dart'; export 'api/visit_sync_result.dart'; @@ -211,107 +213,110 @@ class Protocol extends _i1.SerializationManager { if (t == _i19.ServiceHealth) { return _i19.ServiceHealth.fromJson(data) as T; } - if (t == _i20.TriageResult) { - return _i20.TriageResult.fromJson(data) as T; + if (t == _i20.TermsChangeNotice) { + return _i20.TermsChangeNotice.fromJson(data) as T; + } + if (t == _i21.TriageResult) { + return _i21.TriageResult.fromJson(data) as T; } - if (t == _i21.VisitSyncEntry) { - return _i21.VisitSyncEntry.fromJson(data) as T; + if (t == _i22.VisitSyncEntry) { + return _i22.VisitSyncEntry.fromJson(data) as T; } - if (t == _i22.VisitSyncResult) { - return _i22.VisitSyncResult.fromJson(data) as T; + if (t == _i23.VisitSyncResult) { + return _i23.VisitSyncResult.fromJson(data) as T; } - if (t == _i23.AuditLog) { - return _i23.AuditLog.fromJson(data) as T; + if (t == _i24.AuditLog) { + return _i24.AuditLog.fromJson(data) as T; } - if (t == _i24.ConsentLog) { - return _i24.ConsentLog.fromJson(data) as T; + if (t == _i25.ConsentLog) { + return _i25.ConsentLog.fromJson(data) as T; } - if (t == _i25.DataSubjectRequest) { - return _i25.DataSubjectRequest.fromJson(data) as T; + if (t == _i26.DataSubjectRequest) { + return _i26.DataSubjectRequest.fromJson(data) as T; } - if (t == _i26.EnrollmentToken) { - return _i26.EnrollmentToken.fromJson(data) as T; + if (t == _i27.EnrollmentToken) { + return _i27.EnrollmentToken.fromJson(data) as T; } - if (t == _i27.AlertStatus) { - return _i27.AlertStatus.fromJson(data) as T; + if (t == _i28.AlertStatus) { + return _i28.AlertStatus.fromJson(data) as T; } - if (t == _i28.ArrivalMethod) { - return _i28.ArrivalMethod.fromJson(data) as T; + if (t == _i29.ArrivalMethod) { + return _i29.ArrivalMethod.fromJson(data) as T; } - if (t == _i29.ConsentPurpose) { - return _i29.ConsentPurpose.fromJson(data) as T; + if (t == _i30.ConsentPurpose) { + return _i30.ConsentPurpose.fromJson(data) as T; } - if (t == _i30.DataSubjectRequestStatus) { - return _i30.DataSubjectRequestStatus.fromJson(data) as T; + if (t == _i31.DataSubjectRequestStatus) { + return _i31.DataSubjectRequestStatus.fromJson(data) as T; } - if (t == _i31.DataSubjectRequestType) { - return _i31.DataSubjectRequestType.fromJson(data) as T; + if (t == _i32.DataSubjectRequestType) { + return _i32.DataSubjectRequestType.fromJson(data) as T; } - if (t == _i32.RiskLevel) { - return _i32.RiskLevel.fromJson(data) as T; + if (t == _i33.RiskLevel) { + return _i33.RiskLevel.fromJson(data) as T; } - if (t == _i33.SyncStatus) { - return _i33.SyncStatus.fromJson(data) as T; + if (t == _i34.SyncStatus) { + return _i34.SyncStatus.fromJson(data) as T; } - if (t == _i34.UserRole) { - return _i34.UserRole.fromJson(data) as T; + if (t == _i35.UserRole) { + return _i35.UserRole.fromJson(data) as T; } - if (t == _i35.AlertDispatchUnavailableException) { - return _i35.AlertDispatchUnavailableException.fromJson(data) as T; + if (t == _i36.AlertDispatchUnavailableException) { + return _i36.AlertDispatchUnavailableException.fromJson(data) as T; } - if (t == _i36.AlertPermissionException) { - return _i36.AlertPermissionException.fromJson(data) as T; + if (t == _i37.AlertPermissionException) { + return _i37.AlertPermissionException.fromJson(data) as T; } - if (t == _i37.AlertValidationException) { - return _i37.AlertValidationException.fromJson(data) as T; + if (t == _i38.AlertValidationException) { + return _i38.AlertValidationException.fromJson(data) as T; } - if (t == _i38.AuthenticationFailedException) { - return _i38.AuthenticationFailedException.fromJson(data) as T; + if (t == _i39.AuthenticationFailedException) { + return _i39.AuthenticationFailedException.fromJson(data) as T; } - if (t == _i39.DataRightsException) { - return _i39.DataRightsException.fromJson(data) as T; + if (t == _i40.DataRightsException) { + return _i40.DataRightsException.fromJson(data) as T; } - if (t == _i40.EndpointDisabledException) { - return _i40.EndpointDisabledException.fromJson(data) as T; + if (t == _i41.EndpointDisabledException) { + return _i41.EndpointDisabledException.fromJson(data) as T; } - if (t == _i41.EnrollmentException) { - return _i41.EnrollmentException.fromJson(data) as T; + if (t == _i42.EnrollmentException) { + return _i42.EnrollmentException.fromJson(data) as T; } - if (t == _i42.NoticeDeliveryException) { - return _i42.NoticeDeliveryException.fromJson(data) as T; + if (t == _i43.NoticeDeliveryException) { + return _i43.NoticeDeliveryException.fromJson(data) as T; } - if (t == _i43.OtpRequestException) { - return _i43.OtpRequestException.fromJson(data) as T; + if (t == _i44.OtpRequestException) { + return _i44.OtpRequestException.fromJson(data) as T; } - if (t == _i44.MicroArea) { - return _i44.MicroArea.fromJson(data) as T; + if (t == _i45.MicroArea) { + return _i45.MicroArea.fromJson(data) as T; } - if (t == _i45.OtpChallenge) { - return _i45.OtpChallenge.fromJson(data) as T; + if (t == _i46.OtpChallenge) { + return _i46.OtpChallenge.fromJson(data) as T; } - if (t == _i46.Patient) { - return _i46.Patient.fromJson(data) as T; + if (t == _i47.Patient) { + return _i47.Patient.fromJson(data) as T; } - if (t == _i47.PushToken) { - return _i47.PushToken.fromJson(data) as T; + if (t == _i48.PushToken) { + return _i48.PushToken.fromJson(data) as T; } - if (t == _i48.TriageAnswer) { - return _i48.TriageAnswer.fromJson(data) as T; + if (t == _i49.TriageAnswer) { + return _i49.TriageAnswer.fromJson(data) as T; } - if (t == _i49.TriageSession) { - return _i49.TriageSession.fromJson(data) as T; + if (t == _i50.TriageSession) { + return _i50.TriageSession.fromJson(data) as T; } - if (t == _i50.Ubs) { - return _i50.Ubs.fromJson(data) as T; + if (t == _i51.Ubs) { + return _i51.Ubs.fromJson(data) as T; } - if (t == _i51.User) { - return _i51.User.fromJson(data) as T; + if (t == _i52.User) { + return _i52.User.fromJson(data) as T; } - if (t == _i52.UserCredential) { - return _i52.UserCredential.fromJson(data) as T; + if (t == _i53.UserCredential) { + return _i53.UserCredential.fromJson(data) as T; } - if (t == _i53.Visit) { - return _i53.Visit.fromJson(data) as T; + if (t == _i54.Visit) { + return _i54.Visit.fromJson(data) as T; } if (t == _i1.getType<_i2.Acs?>()) { return (data != null ? _i2.Acs.fromJson(data) : null) as T; @@ -376,131 +381,134 @@ class Protocol extends _i1.SerializationManager { if (t == _i1.getType<_i19.ServiceHealth?>()) { return (data != null ? _i19.ServiceHealth.fromJson(data) : null) as T; } - if (t == _i1.getType<_i20.TriageResult?>()) { - return (data != null ? _i20.TriageResult.fromJson(data) : null) as T; + if (t == _i1.getType<_i20.TermsChangeNotice?>()) { + return (data != null ? _i20.TermsChangeNotice.fromJson(data) : null) as T; } - if (t == _i1.getType<_i21.VisitSyncEntry?>()) { - return (data != null ? _i21.VisitSyncEntry.fromJson(data) : null) as T; + if (t == _i1.getType<_i21.TriageResult?>()) { + return (data != null ? _i21.TriageResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i22.VisitSyncResult?>()) { - return (data != null ? _i22.VisitSyncResult.fromJson(data) : null) as T; + if (t == _i1.getType<_i22.VisitSyncEntry?>()) { + return (data != null ? _i22.VisitSyncEntry.fromJson(data) : null) as T; } - if (t == _i1.getType<_i23.AuditLog?>()) { - return (data != null ? _i23.AuditLog.fromJson(data) : null) as T; + if (t == _i1.getType<_i23.VisitSyncResult?>()) { + return (data != null ? _i23.VisitSyncResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i24.ConsentLog?>()) { - return (data != null ? _i24.ConsentLog.fromJson(data) : null) as T; + if (t == _i1.getType<_i24.AuditLog?>()) { + return (data != null ? _i24.AuditLog.fromJson(data) : null) as T; } - if (t == _i1.getType<_i25.DataSubjectRequest?>()) { - return (data != null ? _i25.DataSubjectRequest.fromJson(data) : null) + if (t == _i1.getType<_i25.ConsentLog?>()) { + return (data != null ? _i25.ConsentLog.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i26.DataSubjectRequest?>()) { + return (data != null ? _i26.DataSubjectRequest.fromJson(data) : null) as T; } - if (t == _i1.getType<_i26.EnrollmentToken?>()) { - return (data != null ? _i26.EnrollmentToken.fromJson(data) : null) as T; + if (t == _i1.getType<_i27.EnrollmentToken?>()) { + return (data != null ? _i27.EnrollmentToken.fromJson(data) : null) as T; } - if (t == _i1.getType<_i27.AlertStatus?>()) { - return (data != null ? _i27.AlertStatus.fromJson(data) : null) as T; + if (t == _i1.getType<_i28.AlertStatus?>()) { + return (data != null ? _i28.AlertStatus.fromJson(data) : null) as T; } - if (t == _i1.getType<_i28.ArrivalMethod?>()) { - return (data != null ? _i28.ArrivalMethod.fromJson(data) : null) as T; + if (t == _i1.getType<_i29.ArrivalMethod?>()) { + return (data != null ? _i29.ArrivalMethod.fromJson(data) : null) as T; } - if (t == _i1.getType<_i29.ConsentPurpose?>()) { - return (data != null ? _i29.ConsentPurpose.fromJson(data) : null) as T; + if (t == _i1.getType<_i30.ConsentPurpose?>()) { + return (data != null ? _i30.ConsentPurpose.fromJson(data) : null) as T; } - if (t == _i1.getType<_i30.DataSubjectRequestStatus?>()) { + if (t == _i1.getType<_i31.DataSubjectRequestStatus?>()) { return (data != null - ? _i30.DataSubjectRequestStatus.fromJson(data) + ? _i31.DataSubjectRequestStatus.fromJson(data) : null) as T; } - if (t == _i1.getType<_i31.DataSubjectRequestType?>()) { - return (data != null ? _i31.DataSubjectRequestType.fromJson(data) : null) + if (t == _i1.getType<_i32.DataSubjectRequestType?>()) { + return (data != null ? _i32.DataSubjectRequestType.fromJson(data) : null) as T; } - if (t == _i1.getType<_i32.RiskLevel?>()) { - return (data != null ? _i32.RiskLevel.fromJson(data) : null) as T; + if (t == _i1.getType<_i33.RiskLevel?>()) { + return (data != null ? _i33.RiskLevel.fromJson(data) : null) as T; } - if (t == _i1.getType<_i33.SyncStatus?>()) { - return (data != null ? _i33.SyncStatus.fromJson(data) : null) as T; + if (t == _i1.getType<_i34.SyncStatus?>()) { + return (data != null ? _i34.SyncStatus.fromJson(data) : null) as T; } - if (t == _i1.getType<_i34.UserRole?>()) { - return (data != null ? _i34.UserRole.fromJson(data) : null) as T; + if (t == _i1.getType<_i35.UserRole?>()) { + return (data != null ? _i35.UserRole.fromJson(data) : null) as T; } - if (t == _i1.getType<_i35.AlertDispatchUnavailableException?>()) { + if (t == _i1.getType<_i36.AlertDispatchUnavailableException?>()) { return (data != null - ? _i35.AlertDispatchUnavailableException.fromJson(data) + ? _i36.AlertDispatchUnavailableException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i36.AlertPermissionException?>()) { + if (t == _i1.getType<_i37.AlertPermissionException?>()) { return (data != null - ? _i36.AlertPermissionException.fromJson(data) + ? _i37.AlertPermissionException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i37.AlertValidationException?>()) { + if (t == _i1.getType<_i38.AlertValidationException?>()) { return (data != null - ? _i37.AlertValidationException.fromJson(data) + ? _i38.AlertValidationException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i38.AuthenticationFailedException?>()) { + if (t == _i1.getType<_i39.AuthenticationFailedException?>()) { return (data != null - ? _i38.AuthenticationFailedException.fromJson(data) + ? _i39.AuthenticationFailedException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i39.DataRightsException?>()) { - return (data != null ? _i39.DataRightsException.fromJson(data) : null) + if (t == _i1.getType<_i40.DataRightsException?>()) { + return (data != null ? _i40.DataRightsException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i40.EndpointDisabledException?>()) { + if (t == _i1.getType<_i41.EndpointDisabledException?>()) { return (data != null - ? _i40.EndpointDisabledException.fromJson(data) + ? _i41.EndpointDisabledException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i41.EnrollmentException?>()) { - return (data != null ? _i41.EnrollmentException.fromJson(data) : null) + if (t == _i1.getType<_i42.EnrollmentException?>()) { + return (data != null ? _i42.EnrollmentException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i42.NoticeDeliveryException?>()) { - return (data != null ? _i42.NoticeDeliveryException.fromJson(data) : null) + if (t == _i1.getType<_i43.NoticeDeliveryException?>()) { + return (data != null ? _i43.NoticeDeliveryException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i43.OtpRequestException?>()) { - return (data != null ? _i43.OtpRequestException.fromJson(data) : null) + if (t == _i1.getType<_i44.OtpRequestException?>()) { + return (data != null ? _i44.OtpRequestException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i44.MicroArea?>()) { - return (data != null ? _i44.MicroArea.fromJson(data) : null) as T; + if (t == _i1.getType<_i45.MicroArea?>()) { + return (data != null ? _i45.MicroArea.fromJson(data) : null) as T; } - if (t == _i1.getType<_i45.OtpChallenge?>()) { - return (data != null ? _i45.OtpChallenge.fromJson(data) : null) as T; + if (t == _i1.getType<_i46.OtpChallenge?>()) { + return (data != null ? _i46.OtpChallenge.fromJson(data) : null) as T; } - if (t == _i1.getType<_i46.Patient?>()) { - return (data != null ? _i46.Patient.fromJson(data) : null) as T; + if (t == _i1.getType<_i47.Patient?>()) { + return (data != null ? _i47.Patient.fromJson(data) : null) as T; } - if (t == _i1.getType<_i47.PushToken?>()) { - return (data != null ? _i47.PushToken.fromJson(data) : null) as T; + if (t == _i1.getType<_i48.PushToken?>()) { + return (data != null ? _i48.PushToken.fromJson(data) : null) as T; } - if (t == _i1.getType<_i48.TriageAnswer?>()) { - return (data != null ? _i48.TriageAnswer.fromJson(data) : null) as T; + if (t == _i1.getType<_i49.TriageAnswer?>()) { + return (data != null ? _i49.TriageAnswer.fromJson(data) : null) as T; } - if (t == _i1.getType<_i49.TriageSession?>()) { - return (data != null ? _i49.TriageSession.fromJson(data) : null) as T; + if (t == _i1.getType<_i50.TriageSession?>()) { + return (data != null ? _i50.TriageSession.fromJson(data) : null) as T; } - if (t == _i1.getType<_i50.Ubs?>()) { - return (data != null ? _i50.Ubs.fromJson(data) : null) as T; + if (t == _i1.getType<_i51.Ubs?>()) { + return (data != null ? _i51.Ubs.fromJson(data) : null) as T; } - if (t == _i1.getType<_i51.User?>()) { - return (data != null ? _i51.User.fromJson(data) : null) as T; + if (t == _i1.getType<_i52.User?>()) { + return (data != null ? _i52.User.fromJson(data) : null) as T; } - if (t == _i1.getType<_i52.UserCredential?>()) { - return (data != null ? _i52.UserCredential.fromJson(data) : null) as T; + if (t == _i1.getType<_i53.UserCredential?>()) { + return (data != null ? _i53.UserCredential.fromJson(data) : null) as T; } - if (t == _i1.getType<_i53.Visit?>()) { - return (data != null ? _i53.Visit.fromJson(data) : null) as T; + if (t == _i1.getType<_i54.Visit?>()) { + return (data != null ? _i54.Visit.fromJson(data) : null) as T; } if (t == List) { return (data as List).map((e) => deserialize(e)).toList() as T; @@ -529,24 +537,24 @@ class Protocol extends _i1.SerializationManager { ) as T; } - if (t == List<_i54.MicroAreaPatient>) { + if (t == List<_i55.MicroAreaPatient>) { return (data as List) - .map((e) => deserialize<_i54.MicroAreaPatient>(e)) + .map((e) => deserialize<_i55.MicroAreaPatient>(e)) .toList() as T; } if (t == List) { return (data as List).map((e) => deserialize(e)).toList() as T; } - if (t == List<_i55.VisitSyncResult>) { + if (t == List<_i56.VisitSyncResult>) { return (data as List) - .map((e) => deserialize<_i55.VisitSyncResult>(e)) + .map((e) => deserialize<_i56.VisitSyncResult>(e)) .toList() as T; } - if (t == List<_i56.VisitSyncEntry>) { + if (t == List<_i57.VisitSyncEntry>) { return (data as List) - .map((e) => deserialize<_i56.VisitSyncEntry>(e)) + .map((e) => deserialize<_i57.VisitSyncEntry>(e)) .toList() as T; } @@ -573,41 +581,42 @@ class Protocol extends _i1.SerializationManager { _i17.PatientRiskEvent => 'PatientRiskEvent', _i18.RedAlertResult => 'RedAlertResult', _i19.ServiceHealth => 'ServiceHealth', - _i20.TriageResult => 'TriageResult', - _i21.VisitSyncEntry => 'VisitSyncEntry', - _i22.VisitSyncResult => 'VisitSyncResult', - _i23.AuditLog => 'AuditLog', - _i24.ConsentLog => 'ConsentLog', - _i25.DataSubjectRequest => 'DataSubjectRequest', - _i26.EnrollmentToken => 'EnrollmentToken', - _i27.AlertStatus => 'AlertStatus', - _i28.ArrivalMethod => 'ArrivalMethod', - _i29.ConsentPurpose => 'ConsentPurpose', - _i30.DataSubjectRequestStatus => 'DataSubjectRequestStatus', - _i31.DataSubjectRequestType => 'DataSubjectRequestType', - _i32.RiskLevel => 'RiskLevel', - _i33.SyncStatus => 'SyncStatus', - _i34.UserRole => 'UserRole', - _i35.AlertDispatchUnavailableException => + _i20.TermsChangeNotice => 'TermsChangeNotice', + _i21.TriageResult => 'TriageResult', + _i22.VisitSyncEntry => 'VisitSyncEntry', + _i23.VisitSyncResult => 'VisitSyncResult', + _i24.AuditLog => 'AuditLog', + _i25.ConsentLog => 'ConsentLog', + _i26.DataSubjectRequest => 'DataSubjectRequest', + _i27.EnrollmentToken => 'EnrollmentToken', + _i28.AlertStatus => 'AlertStatus', + _i29.ArrivalMethod => 'ArrivalMethod', + _i30.ConsentPurpose => 'ConsentPurpose', + _i31.DataSubjectRequestStatus => 'DataSubjectRequestStatus', + _i32.DataSubjectRequestType => 'DataSubjectRequestType', + _i33.RiskLevel => 'RiskLevel', + _i34.SyncStatus => 'SyncStatus', + _i35.UserRole => 'UserRole', + _i36.AlertDispatchUnavailableException => 'AlertDispatchUnavailableException', - _i36.AlertPermissionException => 'AlertPermissionException', - _i37.AlertValidationException => 'AlertValidationException', - _i38.AuthenticationFailedException => 'AuthenticationFailedException', - _i39.DataRightsException => 'DataRightsException', - _i40.EndpointDisabledException => 'EndpointDisabledException', - _i41.EnrollmentException => 'EnrollmentException', - _i42.NoticeDeliveryException => 'NoticeDeliveryException', - _i43.OtpRequestException => 'OtpRequestException', - _i44.MicroArea => 'MicroArea', - _i45.OtpChallenge => 'OtpChallenge', - _i46.Patient => 'Patient', - _i47.PushToken => 'PushToken', - _i48.TriageAnswer => 'TriageAnswer', - _i49.TriageSession => 'TriageSession', - _i50.Ubs => 'Ubs', - _i51.User => 'User', - _i52.UserCredential => 'UserCredential', - _i53.Visit => 'Visit', + _i37.AlertPermissionException => 'AlertPermissionException', + _i38.AlertValidationException => 'AlertValidationException', + _i39.AuthenticationFailedException => 'AuthenticationFailedException', + _i40.DataRightsException => 'DataRightsException', + _i41.EndpointDisabledException => 'EndpointDisabledException', + _i42.EnrollmentException => 'EnrollmentException', + _i43.NoticeDeliveryException => 'NoticeDeliveryException', + _i44.OtpRequestException => 'OtpRequestException', + _i45.MicroArea => 'MicroArea', + _i46.OtpChallenge => 'OtpChallenge', + _i47.Patient => 'Patient', + _i48.PushToken => 'PushToken', + _i49.TriageAnswer => 'TriageAnswer', + _i50.TriageSession => 'TriageSession', + _i51.Ubs => 'Ubs', + _i52.User => 'User', + _i53.UserCredential => 'UserCredential', + _i54.Visit => 'Visit', _ => null, }; } @@ -658,73 +667,75 @@ class Protocol extends _i1.SerializationManager { return 'RedAlertResult'; case _i19.ServiceHealth(): return 'ServiceHealth'; - case _i20.TriageResult(): + case _i20.TermsChangeNotice(): + return 'TermsChangeNotice'; + case _i21.TriageResult(): return 'TriageResult'; - case _i21.VisitSyncEntry(): + case _i22.VisitSyncEntry(): return 'VisitSyncEntry'; - case _i22.VisitSyncResult(): + case _i23.VisitSyncResult(): return 'VisitSyncResult'; - case _i23.AuditLog(): + case _i24.AuditLog(): return 'AuditLog'; - case _i24.ConsentLog(): + case _i25.ConsentLog(): return 'ConsentLog'; - case _i25.DataSubjectRequest(): + case _i26.DataSubjectRequest(): return 'DataSubjectRequest'; - case _i26.EnrollmentToken(): + case _i27.EnrollmentToken(): return 'EnrollmentToken'; - case _i27.AlertStatus(): + case _i28.AlertStatus(): return 'AlertStatus'; - case _i28.ArrivalMethod(): + case _i29.ArrivalMethod(): return 'ArrivalMethod'; - case _i29.ConsentPurpose(): + case _i30.ConsentPurpose(): return 'ConsentPurpose'; - case _i30.DataSubjectRequestStatus(): + case _i31.DataSubjectRequestStatus(): return 'DataSubjectRequestStatus'; - case _i31.DataSubjectRequestType(): + case _i32.DataSubjectRequestType(): return 'DataSubjectRequestType'; - case _i32.RiskLevel(): + case _i33.RiskLevel(): return 'RiskLevel'; - case _i33.SyncStatus(): + case _i34.SyncStatus(): return 'SyncStatus'; - case _i34.UserRole(): + case _i35.UserRole(): return 'UserRole'; - case _i35.AlertDispatchUnavailableException(): + case _i36.AlertDispatchUnavailableException(): return 'AlertDispatchUnavailableException'; - case _i36.AlertPermissionException(): + case _i37.AlertPermissionException(): return 'AlertPermissionException'; - case _i37.AlertValidationException(): + case _i38.AlertValidationException(): return 'AlertValidationException'; - case _i38.AuthenticationFailedException(): + case _i39.AuthenticationFailedException(): return 'AuthenticationFailedException'; - case _i39.DataRightsException(): + case _i40.DataRightsException(): return 'DataRightsException'; - case _i40.EndpointDisabledException(): + case _i41.EndpointDisabledException(): return 'EndpointDisabledException'; - case _i41.EnrollmentException(): + case _i42.EnrollmentException(): return 'EnrollmentException'; - case _i42.NoticeDeliveryException(): + case _i43.NoticeDeliveryException(): return 'NoticeDeliveryException'; - case _i43.OtpRequestException(): + case _i44.OtpRequestException(): return 'OtpRequestException'; - case _i44.MicroArea(): + case _i45.MicroArea(): return 'MicroArea'; - case _i45.OtpChallenge(): + case _i46.OtpChallenge(): return 'OtpChallenge'; - case _i46.Patient(): + case _i47.Patient(): return 'Patient'; - case _i47.PushToken(): + case _i48.PushToken(): return 'PushToken'; - case _i48.TriageAnswer(): + case _i49.TriageAnswer(): return 'TriageAnswer'; - case _i49.TriageSession(): + case _i50.TriageSession(): return 'TriageSession'; - case _i50.Ubs(): + case _i51.Ubs(): return 'Ubs'; - case _i51.User(): + case _i52.User(): return 'User'; - case _i52.UserCredential(): + case _i53.UserCredential(): return 'UserCredential'; - case _i53.Visit(): + case _i54.Visit(): return 'Visit'; } return null; @@ -790,107 +801,110 @@ class Protocol extends _i1.SerializationManager { if (dataClassName == 'ServiceHealth') { return deserialize<_i19.ServiceHealth>(data['data']); } + if (dataClassName == 'TermsChangeNotice') { + return deserialize<_i20.TermsChangeNotice>(data['data']); + } if (dataClassName == 'TriageResult') { - return deserialize<_i20.TriageResult>(data['data']); + return deserialize<_i21.TriageResult>(data['data']); } if (dataClassName == 'VisitSyncEntry') { - return deserialize<_i21.VisitSyncEntry>(data['data']); + return deserialize<_i22.VisitSyncEntry>(data['data']); } if (dataClassName == 'VisitSyncResult') { - return deserialize<_i22.VisitSyncResult>(data['data']); + return deserialize<_i23.VisitSyncResult>(data['data']); } if (dataClassName == 'AuditLog') { - return deserialize<_i23.AuditLog>(data['data']); + return deserialize<_i24.AuditLog>(data['data']); } if (dataClassName == 'ConsentLog') { - return deserialize<_i24.ConsentLog>(data['data']); + return deserialize<_i25.ConsentLog>(data['data']); } if (dataClassName == 'DataSubjectRequest') { - return deserialize<_i25.DataSubjectRequest>(data['data']); + return deserialize<_i26.DataSubjectRequest>(data['data']); } if (dataClassName == 'EnrollmentToken') { - return deserialize<_i26.EnrollmentToken>(data['data']); + return deserialize<_i27.EnrollmentToken>(data['data']); } if (dataClassName == 'AlertStatus') { - return deserialize<_i27.AlertStatus>(data['data']); + return deserialize<_i28.AlertStatus>(data['data']); } if (dataClassName == 'ArrivalMethod') { - return deserialize<_i28.ArrivalMethod>(data['data']); + return deserialize<_i29.ArrivalMethod>(data['data']); } if (dataClassName == 'ConsentPurpose') { - return deserialize<_i29.ConsentPurpose>(data['data']); + return deserialize<_i30.ConsentPurpose>(data['data']); } if (dataClassName == 'DataSubjectRequestStatus') { - return deserialize<_i30.DataSubjectRequestStatus>(data['data']); + return deserialize<_i31.DataSubjectRequestStatus>(data['data']); } if (dataClassName == 'DataSubjectRequestType') { - return deserialize<_i31.DataSubjectRequestType>(data['data']); + return deserialize<_i32.DataSubjectRequestType>(data['data']); } if (dataClassName == 'RiskLevel') { - return deserialize<_i32.RiskLevel>(data['data']); + return deserialize<_i33.RiskLevel>(data['data']); } if (dataClassName == 'SyncStatus') { - return deserialize<_i33.SyncStatus>(data['data']); + return deserialize<_i34.SyncStatus>(data['data']); } if (dataClassName == 'UserRole') { - return deserialize<_i34.UserRole>(data['data']); + return deserialize<_i35.UserRole>(data['data']); } if (dataClassName == 'AlertDispatchUnavailableException') { - return deserialize<_i35.AlertDispatchUnavailableException>(data['data']); + return deserialize<_i36.AlertDispatchUnavailableException>(data['data']); } if (dataClassName == 'AlertPermissionException') { - return deserialize<_i36.AlertPermissionException>(data['data']); + return deserialize<_i37.AlertPermissionException>(data['data']); } if (dataClassName == 'AlertValidationException') { - return deserialize<_i37.AlertValidationException>(data['data']); + return deserialize<_i38.AlertValidationException>(data['data']); } if (dataClassName == 'AuthenticationFailedException') { - return deserialize<_i38.AuthenticationFailedException>(data['data']); + return deserialize<_i39.AuthenticationFailedException>(data['data']); } if (dataClassName == 'DataRightsException') { - return deserialize<_i39.DataRightsException>(data['data']); + return deserialize<_i40.DataRightsException>(data['data']); } if (dataClassName == 'EndpointDisabledException') { - return deserialize<_i40.EndpointDisabledException>(data['data']); + return deserialize<_i41.EndpointDisabledException>(data['data']); } if (dataClassName == 'EnrollmentException') { - return deserialize<_i41.EnrollmentException>(data['data']); + return deserialize<_i42.EnrollmentException>(data['data']); } if (dataClassName == 'NoticeDeliveryException') { - return deserialize<_i42.NoticeDeliveryException>(data['data']); + return deserialize<_i43.NoticeDeliveryException>(data['data']); } if (dataClassName == 'OtpRequestException') { - return deserialize<_i43.OtpRequestException>(data['data']); + return deserialize<_i44.OtpRequestException>(data['data']); } if (dataClassName == 'MicroArea') { - return deserialize<_i44.MicroArea>(data['data']); + return deserialize<_i45.MicroArea>(data['data']); } if (dataClassName == 'OtpChallenge') { - return deserialize<_i45.OtpChallenge>(data['data']); + return deserialize<_i46.OtpChallenge>(data['data']); } if (dataClassName == 'Patient') { - return deserialize<_i46.Patient>(data['data']); + return deserialize<_i47.Patient>(data['data']); } if (dataClassName == 'PushToken') { - return deserialize<_i47.PushToken>(data['data']); + return deserialize<_i48.PushToken>(data['data']); } if (dataClassName == 'TriageAnswer') { - return deserialize<_i48.TriageAnswer>(data['data']); + return deserialize<_i49.TriageAnswer>(data['data']); } if (dataClassName == 'TriageSession') { - return deserialize<_i49.TriageSession>(data['data']); + return deserialize<_i50.TriageSession>(data['data']); } if (dataClassName == 'Ubs') { - return deserialize<_i50.Ubs>(data['data']); + return deserialize<_i51.Ubs>(data['data']); } if (dataClassName == 'User') { - return deserialize<_i51.User>(data['data']); + return deserialize<_i52.User>(data['data']); } if (dataClassName == 'UserCredential') { - return deserialize<_i52.UserCredential>(data['data']); + return deserialize<_i53.UserCredential>(data['data']); } if (dataClassName == 'Visit') { - return deserialize<_i53.Visit>(data['data']); + return deserialize<_i54.Visit>(data['data']); } return super.deserializeByClassName(data); } 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 index 7abbcb86..8f798860 100644 --- 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 @@ -5,6 +5,7 @@ import 'package:sinalacs_server/src/application/onboarding/onboarding_service.da 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/generated/protocol.dart'; /// Persistência das operações do titular sobre os próprios dados. Interface @@ -49,6 +50,19 @@ abstract interface class DataSubjectRightsStore { }); } +/// 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}) => @@ -72,13 +86,16 @@ class DataSubjectRightsService { required DataSubjectRightsStore store, required AuditTrail audit, DateTime Function()? clock, + TermsChangeSchedule? termsChange, }) : _store = store, _audit = audit, + _termsChange = termsChange ?? upcomingTermsChange, _clock = clock ?? DateTime.now; final DataSubjectRightsStore _store; final AuditTrail _audit; final DateTime Function() _clock; + final TermsChangeSchedule? _termsChange; /// Concede ou revoga uma finalidade opcional. `healthDataProcessing` é /// recusado nas duas direções: é a base legal do app inteiro — inclusive do @@ -139,6 +156,19 @@ class DataSubjectRightsService { ); } + /// Aviso de mudança dos termos ativo agora, ou `null` (LGPD-RF18, 15 dias de + /// antecedência). Sem I/O: a agenda é uma constante do repositório. + 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, + ); + } + /// `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 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..c04af28f --- /dev/null +++ b/backend/sinalacs_server/lib/src/application/patients/terms_change_schedule.dart @@ -0,0 +1,41 @@ +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 (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. Não é +/// `const` porque `DateTime.difference` não é constante. +/// +/// 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 { + 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; + + /// 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); +} + +/// Nada agendado hoje. +final TermsChangeSchedule? upcomingTermsChange = null; diff --git a/backend/sinalacs_server/lib/src/endpoints/patients_endpoint.dart b/backend/sinalacs_server/lib/src/endpoints/patients_endpoint.dart index 5a03c220..fbcc23fc 100644 --- a/backend/sinalacs_server/lib/src/endpoints/patients_endpoint.dart +++ b/backend/sinalacs_server/lib/src/endpoints/patients_endpoint.dart @@ -173,6 +173,30 @@ class PatientsEndpoint extends AuthenticatedEndpoint { } } + /// Aviso de mudança dos termos ativo agora (LGPD-RF18, 15 dias de antecedência), + /// ou `null`. Só paciente. Sem leitura de banco e sem linha de auditoria: a + /// agenda é uma constante do repositório e nada do titular é lido nem gravado. + 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); + } + } + /// Pedido de exclusão/anonimização dos próprios dados (LGPD-RF08). /// Idempotente enquanto houver um pedido de exclusão em aberto. Future requestDataDeletion( 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/endpoints.dart b/backend/sinalacs_server/lib/src/generated/endpoints.dart index 8216febc..ef3f3758 100644 --- a/backend/sinalacs_server/lib/src/generated/endpoints.dart +++ b/backend/sinalacs_server/lib/src/generated/endpoints.dart @@ -605,6 +605,25 @@ class Endpoints extends _i1.EndpointDispatch { accessToken: params['accessToken'], ), ), + '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 _i8.PatientsEndpoint) + .termsChangeNotice( + session, + accessToken: params['accessToken'], + ), + ), 'requestDataDeletion': _i1.MethodConnector( name: 'requestDataDeletion', params: { diff --git a/backend/sinalacs_server/lib/src/generated/protocol.dart b/backend/sinalacs_server/lib/src/generated/protocol.dart index 6fbdd928..fdfdcf03 100644 --- a/backend/sinalacs_server/lib/src/generated/protocol.dart +++ b/backend/sinalacs_server/lib/src/generated/protocol.dart @@ -31,46 +31,47 @@ import 'api/patient_data_subject_request_record.dart' as _i17; import 'api/patient_risk_event.dart' as _i18; import 'api/red_alert_result.dart' as _i19; import 'api/service_health.dart' as _i20; -import 'api/triage_result.dart' as _i21; -import 'api/visit_sync_entry.dart' as _i22; -import 'api/visit_sync_result.dart' as _i23; -import 'audit_log.dart' as _i24; -import 'consent_log.dart' as _i25; -import 'data_subject_request.dart' as _i26; -import 'enrollment_token.dart' as _i27; -import 'enums/alert_status.dart' as _i28; -import 'enums/arrival_method.dart' as _i29; -import 'enums/consent_purpose.dart' as _i30; -import 'enums/data_subject_request_status.dart' as _i31; -import 'enums/data_subject_request_type.dart' as _i32; -import 'enums/risk_level.dart' as _i33; -import 'enums/sync_status.dart' as _i34; -import 'enums/user_role.dart' as _i35; -import 'exceptions/alert_dispatch_unavailable_exception.dart' as _i36; -import 'exceptions/alert_permission_exception.dart' as _i37; -import 'exceptions/alert_validation_exception.dart' as _i38; -import 'exceptions/authentication_failed_exception.dart' as _i39; -import 'exceptions/data_rights_exception.dart' as _i40; -import 'exceptions/endpoint_disabled_exception.dart' as _i41; -import 'exceptions/enrollment_exception.dart' as _i42; -import 'exceptions/notice_delivery_exception.dart' as _i43; -import 'exceptions/otp_request_exception.dart' as _i44; -import 'micro_area.dart' as _i45; -import 'otp_challenge.dart' as _i46; -import 'patient.dart' as _i47; -import 'push_token.dart' as _i48; -import 'triage_answer.dart' as _i49; -import 'triage_session.dart' as _i50; -import 'ubs.dart' as _i51; -import 'user.dart' as _i52; -import 'user_credential.dart' as _i53; -import 'visit.dart' as _i54; +import 'api/terms_change_notice.dart' as _i21; +import 'api/triage_result.dart' as _i22; +import 'api/visit_sync_entry.dart' as _i23; +import 'api/visit_sync_result.dart' as _i24; +import 'audit_log.dart' as _i25; +import 'consent_log.dart' as _i26; +import 'data_subject_request.dart' as _i27; +import 'enrollment_token.dart' as _i28; +import 'enums/alert_status.dart' as _i29; +import 'enums/arrival_method.dart' as _i30; +import 'enums/consent_purpose.dart' as _i31; +import 'enums/data_subject_request_status.dart' as _i32; +import 'enums/data_subject_request_type.dart' as _i33; +import 'enums/risk_level.dart' as _i34; +import 'enums/sync_status.dart' as _i35; +import 'enums/user_role.dart' as _i36; +import 'exceptions/alert_dispatch_unavailable_exception.dart' as _i37; +import 'exceptions/alert_permission_exception.dart' as _i38; +import 'exceptions/alert_validation_exception.dart' as _i39; +import 'exceptions/authentication_failed_exception.dart' as _i40; +import 'exceptions/data_rights_exception.dart' as _i41; +import 'exceptions/endpoint_disabled_exception.dart' as _i42; +import 'exceptions/enrollment_exception.dart' as _i43; +import 'exceptions/notice_delivery_exception.dart' as _i44; +import 'exceptions/otp_request_exception.dart' as _i45; +import 'micro_area.dart' as _i46; +import 'otp_challenge.dart' as _i47; +import 'patient.dart' as _i48; +import 'push_token.dart' as _i49; +import 'triage_answer.dart' as _i50; +import 'triage_session.dart' as _i51; +import 'ubs.dart' as _i52; +import 'user.dart' as _i53; +import 'user_credential.dart' as _i54; +import 'visit.dart' as _i55; import 'package:sinalacs_server/src/generated/api/micro_area_patient.dart' - as _i55; -import 'package:sinalacs_server/src/generated/api/visit_sync_result.dart' as _i56; -import 'package:sinalacs_server/src/generated/api/visit_sync_entry.dart' +import 'package:sinalacs_server/src/generated/api/visit_sync_result.dart' as _i57; +import 'package:sinalacs_server/src/generated/api/visit_sync_entry.dart' + as _i58; export 'acs.dart'; export 'alert.dart'; export 'alert_delivery_record.dart'; @@ -89,6 +90,7 @@ 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/triage_result.dart'; export 'api/visit_sync_entry.dart'; export 'api/visit_sync_result.dart'; @@ -2007,107 +2009,110 @@ class Protocol extends _i1.SerializationManagerServer { if (t == _i20.ServiceHealth) { return _i20.ServiceHealth.fromJson(data) as T; } - if (t == _i21.TriageResult) { - return _i21.TriageResult.fromJson(data) as T; + if (t == _i21.TermsChangeNotice) { + return _i21.TermsChangeNotice.fromJson(data) as T; } - if (t == _i22.VisitSyncEntry) { - return _i22.VisitSyncEntry.fromJson(data) as T; + if (t == _i22.TriageResult) { + return _i22.TriageResult.fromJson(data) as T; } - if (t == _i23.VisitSyncResult) { - return _i23.VisitSyncResult.fromJson(data) as T; + if (t == _i23.VisitSyncEntry) { + return _i23.VisitSyncEntry.fromJson(data) as T; } - if (t == _i24.AuditLog) { - return _i24.AuditLog.fromJson(data) as T; + if (t == _i24.VisitSyncResult) { + return _i24.VisitSyncResult.fromJson(data) as T; } - if (t == _i25.ConsentLog) { - return _i25.ConsentLog.fromJson(data) as T; + if (t == _i25.AuditLog) { + return _i25.AuditLog.fromJson(data) as T; } - if (t == _i26.DataSubjectRequest) { - return _i26.DataSubjectRequest.fromJson(data) as T; + if (t == _i26.ConsentLog) { + return _i26.ConsentLog.fromJson(data) as T; } - if (t == _i27.EnrollmentToken) { - return _i27.EnrollmentToken.fromJson(data) as T; + if (t == _i27.DataSubjectRequest) { + return _i27.DataSubjectRequest.fromJson(data) as T; } - if (t == _i28.AlertStatus) { - return _i28.AlertStatus.fromJson(data) as T; + if (t == _i28.EnrollmentToken) { + return _i28.EnrollmentToken.fromJson(data) as T; } - if (t == _i29.ArrivalMethod) { - return _i29.ArrivalMethod.fromJson(data) as T; + if (t == _i29.AlertStatus) { + return _i29.AlertStatus.fromJson(data) as T; } - if (t == _i30.ConsentPurpose) { - return _i30.ConsentPurpose.fromJson(data) as T; + if (t == _i30.ArrivalMethod) { + return _i30.ArrivalMethod.fromJson(data) as T; } - if (t == _i31.DataSubjectRequestStatus) { - return _i31.DataSubjectRequestStatus.fromJson(data) as T; + if (t == _i31.ConsentPurpose) { + return _i31.ConsentPurpose.fromJson(data) as T; } - if (t == _i32.DataSubjectRequestType) { - return _i32.DataSubjectRequestType.fromJson(data) as T; + if (t == _i32.DataSubjectRequestStatus) { + return _i32.DataSubjectRequestStatus.fromJson(data) as T; } - if (t == _i33.RiskLevel) { - return _i33.RiskLevel.fromJson(data) as T; + if (t == _i33.DataSubjectRequestType) { + return _i33.DataSubjectRequestType.fromJson(data) as T; } - if (t == _i34.SyncStatus) { - return _i34.SyncStatus.fromJson(data) as T; + if (t == _i34.RiskLevel) { + return _i34.RiskLevel.fromJson(data) as T; } - if (t == _i35.UserRole) { - return _i35.UserRole.fromJson(data) as T; + if (t == _i35.SyncStatus) { + return _i35.SyncStatus.fromJson(data) as T; } - if (t == _i36.AlertDispatchUnavailableException) { - return _i36.AlertDispatchUnavailableException.fromJson(data) as T; + if (t == _i36.UserRole) { + return _i36.UserRole.fromJson(data) as T; } - if (t == _i37.AlertPermissionException) { - return _i37.AlertPermissionException.fromJson(data) as T; + if (t == _i37.AlertDispatchUnavailableException) { + return _i37.AlertDispatchUnavailableException.fromJson(data) as T; } - if (t == _i38.AlertValidationException) { - return _i38.AlertValidationException.fromJson(data) as T; + if (t == _i38.AlertPermissionException) { + return _i38.AlertPermissionException.fromJson(data) as T; } - if (t == _i39.AuthenticationFailedException) { - return _i39.AuthenticationFailedException.fromJson(data) as T; + if (t == _i39.AlertValidationException) { + return _i39.AlertValidationException.fromJson(data) as T; } - if (t == _i40.DataRightsException) { - return _i40.DataRightsException.fromJson(data) as T; + if (t == _i40.AuthenticationFailedException) { + return _i40.AuthenticationFailedException.fromJson(data) as T; } - if (t == _i41.EndpointDisabledException) { - return _i41.EndpointDisabledException.fromJson(data) as T; + if (t == _i41.DataRightsException) { + return _i41.DataRightsException.fromJson(data) as T; } - if (t == _i42.EnrollmentException) { - return _i42.EnrollmentException.fromJson(data) as T; + if (t == _i42.EndpointDisabledException) { + return _i42.EndpointDisabledException.fromJson(data) as T; } - if (t == _i43.NoticeDeliveryException) { - return _i43.NoticeDeliveryException.fromJson(data) as T; + if (t == _i43.EnrollmentException) { + return _i43.EnrollmentException.fromJson(data) as T; } - if (t == _i44.OtpRequestException) { - return _i44.OtpRequestException.fromJson(data) as T; + if (t == _i44.NoticeDeliveryException) { + return _i44.NoticeDeliveryException.fromJson(data) as T; } - if (t == _i45.MicroArea) { - return _i45.MicroArea.fromJson(data) as T; + if (t == _i45.OtpRequestException) { + return _i45.OtpRequestException.fromJson(data) as T; } - if (t == _i46.OtpChallenge) { - return _i46.OtpChallenge.fromJson(data) as T; + if (t == _i46.MicroArea) { + return _i46.MicroArea.fromJson(data) as T; } - if (t == _i47.Patient) { - return _i47.Patient.fromJson(data) as T; + if (t == _i47.OtpChallenge) { + return _i47.OtpChallenge.fromJson(data) as T; } - if (t == _i48.PushToken) { - return _i48.PushToken.fromJson(data) as T; + if (t == _i48.Patient) { + return _i48.Patient.fromJson(data) as T; } - if (t == _i49.TriageAnswer) { - return _i49.TriageAnswer.fromJson(data) as T; + if (t == _i49.PushToken) { + return _i49.PushToken.fromJson(data) as T; } - if (t == _i50.TriageSession) { - return _i50.TriageSession.fromJson(data) as T; + if (t == _i50.TriageAnswer) { + return _i50.TriageAnswer.fromJson(data) as T; } - if (t == _i51.Ubs) { - return _i51.Ubs.fromJson(data) as T; + if (t == _i51.TriageSession) { + return _i51.TriageSession.fromJson(data) as T; } - if (t == _i52.User) { - return _i52.User.fromJson(data) as T; + if (t == _i52.Ubs) { + return _i52.Ubs.fromJson(data) as T; } - if (t == _i53.UserCredential) { - return _i53.UserCredential.fromJson(data) as T; + if (t == _i53.User) { + return _i53.User.fromJson(data) as T; } - if (t == _i54.Visit) { - return _i54.Visit.fromJson(data) as T; + if (t == _i54.UserCredential) { + return _i54.UserCredential.fromJson(data) as T; + } + if (t == _i55.Visit) { + return _i55.Visit.fromJson(data) as T; } if (t == _i1.getType<_i3.Acs?>()) { return (data != null ? _i3.Acs.fromJson(data) : null) as T; @@ -2172,131 +2177,134 @@ class Protocol extends _i1.SerializationManagerServer { if (t == _i1.getType<_i20.ServiceHealth?>()) { return (data != null ? _i20.ServiceHealth.fromJson(data) : null) as T; } - if (t == _i1.getType<_i21.TriageResult?>()) { - return (data != null ? _i21.TriageResult.fromJson(data) : null) as T; + if (t == _i1.getType<_i21.TermsChangeNotice?>()) { + return (data != null ? _i21.TermsChangeNotice.fromJson(data) : null) as T; + } + if (t == _i1.getType<_i22.TriageResult?>()) { + return (data != null ? _i22.TriageResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i22.VisitSyncEntry?>()) { - return (data != null ? _i22.VisitSyncEntry.fromJson(data) : null) as T; + if (t == _i1.getType<_i23.VisitSyncEntry?>()) { + return (data != null ? _i23.VisitSyncEntry.fromJson(data) : null) as T; } - if (t == _i1.getType<_i23.VisitSyncResult?>()) { - return (data != null ? _i23.VisitSyncResult.fromJson(data) : null) as T; + if (t == _i1.getType<_i24.VisitSyncResult?>()) { + return (data != null ? _i24.VisitSyncResult.fromJson(data) : null) as T; } - if (t == _i1.getType<_i24.AuditLog?>()) { - return (data != null ? _i24.AuditLog.fromJson(data) : null) as T; + if (t == _i1.getType<_i25.AuditLog?>()) { + return (data != null ? _i25.AuditLog.fromJson(data) : null) as T; } - if (t == _i1.getType<_i25.ConsentLog?>()) { - return (data != null ? _i25.ConsentLog.fromJson(data) : null) as T; + if (t == _i1.getType<_i26.ConsentLog?>()) { + return (data != null ? _i26.ConsentLog.fromJson(data) : null) as T; } - if (t == _i1.getType<_i26.DataSubjectRequest?>()) { - return (data != null ? _i26.DataSubjectRequest.fromJson(data) : null) + if (t == _i1.getType<_i27.DataSubjectRequest?>()) { + return (data != null ? _i27.DataSubjectRequest.fromJson(data) : null) as T; } - if (t == _i1.getType<_i27.EnrollmentToken?>()) { - return (data != null ? _i27.EnrollmentToken.fromJson(data) : null) as T; + if (t == _i1.getType<_i28.EnrollmentToken?>()) { + return (data != null ? _i28.EnrollmentToken.fromJson(data) : null) as T; } - if (t == _i1.getType<_i28.AlertStatus?>()) { - return (data != null ? _i28.AlertStatus.fromJson(data) : null) as T; + if (t == _i1.getType<_i29.AlertStatus?>()) { + return (data != null ? _i29.AlertStatus.fromJson(data) : null) as T; } - if (t == _i1.getType<_i29.ArrivalMethod?>()) { - return (data != null ? _i29.ArrivalMethod.fromJson(data) : null) as T; + if (t == _i1.getType<_i30.ArrivalMethod?>()) { + return (data != null ? _i30.ArrivalMethod.fromJson(data) : null) as T; } - if (t == _i1.getType<_i30.ConsentPurpose?>()) { - return (data != null ? _i30.ConsentPurpose.fromJson(data) : null) as T; + if (t == _i1.getType<_i31.ConsentPurpose?>()) { + return (data != null ? _i31.ConsentPurpose.fromJson(data) : null) as T; } - if (t == _i1.getType<_i31.DataSubjectRequestStatus?>()) { + if (t == _i1.getType<_i32.DataSubjectRequestStatus?>()) { return (data != null - ? _i31.DataSubjectRequestStatus.fromJson(data) + ? _i32.DataSubjectRequestStatus.fromJson(data) : null) as T; } - if (t == _i1.getType<_i32.DataSubjectRequestType?>()) { - return (data != null ? _i32.DataSubjectRequestType.fromJson(data) : null) + if (t == _i1.getType<_i33.DataSubjectRequestType?>()) { + return (data != null ? _i33.DataSubjectRequestType.fromJson(data) : null) as T; } - if (t == _i1.getType<_i33.RiskLevel?>()) { - return (data != null ? _i33.RiskLevel.fromJson(data) : null) as T; + if (t == _i1.getType<_i34.RiskLevel?>()) { + return (data != null ? _i34.RiskLevel.fromJson(data) : null) as T; } - if (t == _i1.getType<_i34.SyncStatus?>()) { - return (data != null ? _i34.SyncStatus.fromJson(data) : null) as T; + if (t == _i1.getType<_i35.SyncStatus?>()) { + return (data != null ? _i35.SyncStatus.fromJson(data) : null) as T; } - if (t == _i1.getType<_i35.UserRole?>()) { - return (data != null ? _i35.UserRole.fromJson(data) : null) as T; + if (t == _i1.getType<_i36.UserRole?>()) { + return (data != null ? _i36.UserRole.fromJson(data) : null) as T; } - if (t == _i1.getType<_i36.AlertDispatchUnavailableException?>()) { + if (t == _i1.getType<_i37.AlertDispatchUnavailableException?>()) { return (data != null - ? _i36.AlertDispatchUnavailableException.fromJson(data) + ? _i37.AlertDispatchUnavailableException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i37.AlertPermissionException?>()) { + if (t == _i1.getType<_i38.AlertPermissionException?>()) { return (data != null - ? _i37.AlertPermissionException.fromJson(data) + ? _i38.AlertPermissionException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i38.AlertValidationException?>()) { + if (t == _i1.getType<_i39.AlertValidationException?>()) { return (data != null - ? _i38.AlertValidationException.fromJson(data) + ? _i39.AlertValidationException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i39.AuthenticationFailedException?>()) { + if (t == _i1.getType<_i40.AuthenticationFailedException?>()) { return (data != null - ? _i39.AuthenticationFailedException.fromJson(data) + ? _i40.AuthenticationFailedException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i40.DataRightsException?>()) { - return (data != null ? _i40.DataRightsException.fromJson(data) : null) + if (t == _i1.getType<_i41.DataRightsException?>()) { + return (data != null ? _i41.DataRightsException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i41.EndpointDisabledException?>()) { + if (t == _i1.getType<_i42.EndpointDisabledException?>()) { return (data != null - ? _i41.EndpointDisabledException.fromJson(data) + ? _i42.EndpointDisabledException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i42.EnrollmentException?>()) { - return (data != null ? _i42.EnrollmentException.fromJson(data) : null) + if (t == _i1.getType<_i43.EnrollmentException?>()) { + return (data != null ? _i43.EnrollmentException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i43.NoticeDeliveryException?>()) { - return (data != null ? _i43.NoticeDeliveryException.fromJson(data) : null) + if (t == _i1.getType<_i44.NoticeDeliveryException?>()) { + return (data != null ? _i44.NoticeDeliveryException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i44.OtpRequestException?>()) { - return (data != null ? _i44.OtpRequestException.fromJson(data) : null) + if (t == _i1.getType<_i45.OtpRequestException?>()) { + return (data != null ? _i45.OtpRequestException.fromJson(data) : null) as T; } - if (t == _i1.getType<_i45.MicroArea?>()) { - return (data != null ? _i45.MicroArea.fromJson(data) : null) as T; + if (t == _i1.getType<_i46.MicroArea?>()) { + return (data != null ? _i46.MicroArea.fromJson(data) : null) as T; } - if (t == _i1.getType<_i46.OtpChallenge?>()) { - return (data != null ? _i46.OtpChallenge.fromJson(data) : null) as T; + if (t == _i1.getType<_i47.OtpChallenge?>()) { + return (data != null ? _i47.OtpChallenge.fromJson(data) : null) as T; } - if (t == _i1.getType<_i47.Patient?>()) { - return (data != null ? _i47.Patient.fromJson(data) : null) as T; + if (t == _i1.getType<_i48.Patient?>()) { + return (data != null ? _i48.Patient.fromJson(data) : null) as T; } - if (t == _i1.getType<_i48.PushToken?>()) { - return (data != null ? _i48.PushToken.fromJson(data) : null) as T; + if (t == _i1.getType<_i49.PushToken?>()) { + return (data != null ? _i49.PushToken.fromJson(data) : null) as T; } - if (t == _i1.getType<_i49.TriageAnswer?>()) { - return (data != null ? _i49.TriageAnswer.fromJson(data) : null) as T; + if (t == _i1.getType<_i50.TriageAnswer?>()) { + return (data != null ? _i50.TriageAnswer.fromJson(data) : null) as T; } - if (t == _i1.getType<_i50.TriageSession?>()) { - return (data != null ? _i50.TriageSession.fromJson(data) : null) as T; + if (t == _i1.getType<_i51.TriageSession?>()) { + return (data != null ? _i51.TriageSession.fromJson(data) : null) as T; } - if (t == _i1.getType<_i51.Ubs?>()) { - return (data != null ? _i51.Ubs.fromJson(data) : null) as T; + if (t == _i1.getType<_i52.Ubs?>()) { + return (data != null ? _i52.Ubs.fromJson(data) : null) as T; } - if (t == _i1.getType<_i52.User?>()) { - return (data != null ? _i52.User.fromJson(data) : null) as T; + if (t == _i1.getType<_i53.User?>()) { + return (data != null ? _i53.User.fromJson(data) : null) as T; } - if (t == _i1.getType<_i53.UserCredential?>()) { - return (data != null ? _i53.UserCredential.fromJson(data) : null) as T; + if (t == _i1.getType<_i54.UserCredential?>()) { + return (data != null ? _i54.UserCredential.fromJson(data) : null) as T; } - if (t == _i1.getType<_i54.Visit?>()) { - return (data != null ? _i54.Visit.fromJson(data) : null) as T; + if (t == _i1.getType<_i55.Visit?>()) { + return (data != null ? _i55.Visit.fromJson(data) : null) as T; } if (t == List) { return (data as List).map((e) => deserialize(e)).toList() as T; @@ -2325,24 +2333,24 @@ class Protocol extends _i1.SerializationManagerServer { ) as T; } - if (t == List<_i55.MicroAreaPatient>) { + if (t == List<_i56.MicroAreaPatient>) { return (data as List) - .map((e) => deserialize<_i55.MicroAreaPatient>(e)) + .map((e) => deserialize<_i56.MicroAreaPatient>(e)) .toList() as T; } if (t == List) { return (data as List).map((e) => deserialize(e)).toList() as T; } - if (t == List<_i56.VisitSyncResult>) { + if (t == List<_i57.VisitSyncResult>) { return (data as List) - .map((e) => deserialize<_i56.VisitSyncResult>(e)) + .map((e) => deserialize<_i57.VisitSyncResult>(e)) .toList() as T; } - if (t == List<_i57.VisitSyncEntry>) { + if (t == List<_i58.VisitSyncEntry>) { return (data as List) - .map((e) => deserialize<_i57.VisitSyncEntry>(e)) + .map((e) => deserialize<_i58.VisitSyncEntry>(e)) .toList() as T; } @@ -2372,41 +2380,42 @@ class Protocol extends _i1.SerializationManagerServer { _i18.PatientRiskEvent => 'PatientRiskEvent', _i19.RedAlertResult => 'RedAlertResult', _i20.ServiceHealth => 'ServiceHealth', - _i21.TriageResult => 'TriageResult', - _i22.VisitSyncEntry => 'VisitSyncEntry', - _i23.VisitSyncResult => 'VisitSyncResult', - _i24.AuditLog => 'AuditLog', - _i25.ConsentLog => 'ConsentLog', - _i26.DataSubjectRequest => 'DataSubjectRequest', - _i27.EnrollmentToken => 'EnrollmentToken', - _i28.AlertStatus => 'AlertStatus', - _i29.ArrivalMethod => 'ArrivalMethod', - _i30.ConsentPurpose => 'ConsentPurpose', - _i31.DataSubjectRequestStatus => 'DataSubjectRequestStatus', - _i32.DataSubjectRequestType => 'DataSubjectRequestType', - _i33.RiskLevel => 'RiskLevel', - _i34.SyncStatus => 'SyncStatus', - _i35.UserRole => 'UserRole', - _i36.AlertDispatchUnavailableException => + _i21.TermsChangeNotice => 'TermsChangeNotice', + _i22.TriageResult => 'TriageResult', + _i23.VisitSyncEntry => 'VisitSyncEntry', + _i24.VisitSyncResult => 'VisitSyncResult', + _i25.AuditLog => 'AuditLog', + _i26.ConsentLog => 'ConsentLog', + _i27.DataSubjectRequest => 'DataSubjectRequest', + _i28.EnrollmentToken => 'EnrollmentToken', + _i29.AlertStatus => 'AlertStatus', + _i30.ArrivalMethod => 'ArrivalMethod', + _i31.ConsentPurpose => 'ConsentPurpose', + _i32.DataSubjectRequestStatus => 'DataSubjectRequestStatus', + _i33.DataSubjectRequestType => 'DataSubjectRequestType', + _i34.RiskLevel => 'RiskLevel', + _i35.SyncStatus => 'SyncStatus', + _i36.UserRole => 'UserRole', + _i37.AlertDispatchUnavailableException => 'AlertDispatchUnavailableException', - _i37.AlertPermissionException => 'AlertPermissionException', - _i38.AlertValidationException => 'AlertValidationException', - _i39.AuthenticationFailedException => 'AuthenticationFailedException', - _i40.DataRightsException => 'DataRightsException', - _i41.EndpointDisabledException => 'EndpointDisabledException', - _i42.EnrollmentException => 'EnrollmentException', - _i43.NoticeDeliveryException => 'NoticeDeliveryException', - _i44.OtpRequestException => 'OtpRequestException', - _i45.MicroArea => 'MicroArea', - _i46.OtpChallenge => 'OtpChallenge', - _i47.Patient => 'Patient', - _i48.PushToken => 'PushToken', - _i49.TriageAnswer => 'TriageAnswer', - _i50.TriageSession => 'TriageSession', - _i51.Ubs => 'Ubs', - _i52.User => 'User', - _i53.UserCredential => 'UserCredential', - _i54.Visit => 'Visit', + _i38.AlertPermissionException => 'AlertPermissionException', + _i39.AlertValidationException => 'AlertValidationException', + _i40.AuthenticationFailedException => 'AuthenticationFailedException', + _i41.DataRightsException => 'DataRightsException', + _i42.EndpointDisabledException => 'EndpointDisabledException', + _i43.EnrollmentException => 'EnrollmentException', + _i44.NoticeDeliveryException => 'NoticeDeliveryException', + _i45.OtpRequestException => 'OtpRequestException', + _i46.MicroArea => 'MicroArea', + _i47.OtpChallenge => 'OtpChallenge', + _i48.Patient => 'Patient', + _i49.PushToken => 'PushToken', + _i50.TriageAnswer => 'TriageAnswer', + _i51.TriageSession => 'TriageSession', + _i52.Ubs => 'Ubs', + _i53.User => 'User', + _i54.UserCredential => 'UserCredential', + _i55.Visit => 'Visit', _ => null, }; } @@ -2457,73 +2466,75 @@ class Protocol extends _i1.SerializationManagerServer { return 'RedAlertResult'; case _i20.ServiceHealth(): return 'ServiceHealth'; - case _i21.TriageResult(): + case _i21.TermsChangeNotice(): + return 'TermsChangeNotice'; + case _i22.TriageResult(): return 'TriageResult'; - case _i22.VisitSyncEntry(): + case _i23.VisitSyncEntry(): return 'VisitSyncEntry'; - case _i23.VisitSyncResult(): + case _i24.VisitSyncResult(): return 'VisitSyncResult'; - case _i24.AuditLog(): + case _i25.AuditLog(): return 'AuditLog'; - case _i25.ConsentLog(): + case _i26.ConsentLog(): return 'ConsentLog'; - case _i26.DataSubjectRequest(): + case _i27.DataSubjectRequest(): return 'DataSubjectRequest'; - case _i27.EnrollmentToken(): + case _i28.EnrollmentToken(): return 'EnrollmentToken'; - case _i28.AlertStatus(): + case _i29.AlertStatus(): return 'AlertStatus'; - case _i29.ArrivalMethod(): + case _i30.ArrivalMethod(): return 'ArrivalMethod'; - case _i30.ConsentPurpose(): + case _i31.ConsentPurpose(): return 'ConsentPurpose'; - case _i31.DataSubjectRequestStatus(): + case _i32.DataSubjectRequestStatus(): return 'DataSubjectRequestStatus'; - case _i32.DataSubjectRequestType(): + case _i33.DataSubjectRequestType(): return 'DataSubjectRequestType'; - case _i33.RiskLevel(): + case _i34.RiskLevel(): return 'RiskLevel'; - case _i34.SyncStatus(): + case _i35.SyncStatus(): return 'SyncStatus'; - case _i35.UserRole(): + case _i36.UserRole(): return 'UserRole'; - case _i36.AlertDispatchUnavailableException(): + case _i37.AlertDispatchUnavailableException(): return 'AlertDispatchUnavailableException'; - case _i37.AlertPermissionException(): + case _i38.AlertPermissionException(): return 'AlertPermissionException'; - case _i38.AlertValidationException(): + case _i39.AlertValidationException(): return 'AlertValidationException'; - case _i39.AuthenticationFailedException(): + case _i40.AuthenticationFailedException(): return 'AuthenticationFailedException'; - case _i40.DataRightsException(): + case _i41.DataRightsException(): return 'DataRightsException'; - case _i41.EndpointDisabledException(): + case _i42.EndpointDisabledException(): return 'EndpointDisabledException'; - case _i42.EnrollmentException(): + case _i43.EnrollmentException(): return 'EnrollmentException'; - case _i43.NoticeDeliveryException(): + case _i44.NoticeDeliveryException(): return 'NoticeDeliveryException'; - case _i44.OtpRequestException(): + case _i45.OtpRequestException(): return 'OtpRequestException'; - case _i45.MicroArea(): + case _i46.MicroArea(): return 'MicroArea'; - case _i46.OtpChallenge(): + case _i47.OtpChallenge(): return 'OtpChallenge'; - case _i47.Patient(): + case _i48.Patient(): return 'Patient'; - case _i48.PushToken(): + case _i49.PushToken(): return 'PushToken'; - case _i49.TriageAnswer(): + case _i50.TriageAnswer(): return 'TriageAnswer'; - case _i50.TriageSession(): + case _i51.TriageSession(): return 'TriageSession'; - case _i51.Ubs(): + case _i52.Ubs(): return 'Ubs'; - case _i52.User(): + case _i53.User(): return 'User'; - case _i53.UserCredential(): + case _i54.UserCredential(): return 'UserCredential'; - case _i54.Visit(): + case _i55.Visit(): return 'Visit'; } className = _i2.Protocol().getClassNameForObject(data); @@ -2593,107 +2604,110 @@ class Protocol extends _i1.SerializationManagerServer { if (dataClassName == 'ServiceHealth') { return deserialize<_i20.ServiceHealth>(data['data']); } + if (dataClassName == 'TermsChangeNotice') { + return deserialize<_i21.TermsChangeNotice>(data['data']); + } if (dataClassName == 'TriageResult') { - return deserialize<_i21.TriageResult>(data['data']); + return deserialize<_i22.TriageResult>(data['data']); } if (dataClassName == 'VisitSyncEntry') { - return deserialize<_i22.VisitSyncEntry>(data['data']); + return deserialize<_i23.VisitSyncEntry>(data['data']); } if (dataClassName == 'VisitSyncResult') { - return deserialize<_i23.VisitSyncResult>(data['data']); + return deserialize<_i24.VisitSyncResult>(data['data']); } if (dataClassName == 'AuditLog') { - return deserialize<_i24.AuditLog>(data['data']); + return deserialize<_i25.AuditLog>(data['data']); } if (dataClassName == 'ConsentLog') { - return deserialize<_i25.ConsentLog>(data['data']); + return deserialize<_i26.ConsentLog>(data['data']); } if (dataClassName == 'DataSubjectRequest') { - return deserialize<_i26.DataSubjectRequest>(data['data']); + return deserialize<_i27.DataSubjectRequest>(data['data']); } if (dataClassName == 'EnrollmentToken') { - return deserialize<_i27.EnrollmentToken>(data['data']); + return deserialize<_i28.EnrollmentToken>(data['data']); } if (dataClassName == 'AlertStatus') { - return deserialize<_i28.AlertStatus>(data['data']); + return deserialize<_i29.AlertStatus>(data['data']); } if (dataClassName == 'ArrivalMethod') { - return deserialize<_i29.ArrivalMethod>(data['data']); + return deserialize<_i30.ArrivalMethod>(data['data']); } if (dataClassName == 'ConsentPurpose') { - return deserialize<_i30.ConsentPurpose>(data['data']); + return deserialize<_i31.ConsentPurpose>(data['data']); } if (dataClassName == 'DataSubjectRequestStatus') { - return deserialize<_i31.DataSubjectRequestStatus>(data['data']); + return deserialize<_i32.DataSubjectRequestStatus>(data['data']); } if (dataClassName == 'DataSubjectRequestType') { - return deserialize<_i32.DataSubjectRequestType>(data['data']); + return deserialize<_i33.DataSubjectRequestType>(data['data']); } if (dataClassName == 'RiskLevel') { - return deserialize<_i33.RiskLevel>(data['data']); + return deserialize<_i34.RiskLevel>(data['data']); } if (dataClassName == 'SyncStatus') { - return deserialize<_i34.SyncStatus>(data['data']); + return deserialize<_i35.SyncStatus>(data['data']); } if (dataClassName == 'UserRole') { - return deserialize<_i35.UserRole>(data['data']); + return deserialize<_i36.UserRole>(data['data']); } if (dataClassName == 'AlertDispatchUnavailableException') { - return deserialize<_i36.AlertDispatchUnavailableException>(data['data']); + return deserialize<_i37.AlertDispatchUnavailableException>(data['data']); } if (dataClassName == 'AlertPermissionException') { - return deserialize<_i37.AlertPermissionException>(data['data']); + return deserialize<_i38.AlertPermissionException>(data['data']); } if (dataClassName == 'AlertValidationException') { - return deserialize<_i38.AlertValidationException>(data['data']); + return deserialize<_i39.AlertValidationException>(data['data']); } if (dataClassName == 'AuthenticationFailedException') { - return deserialize<_i39.AuthenticationFailedException>(data['data']); + return deserialize<_i40.AuthenticationFailedException>(data['data']); } if (dataClassName == 'DataRightsException') { - return deserialize<_i40.DataRightsException>(data['data']); + return deserialize<_i41.DataRightsException>(data['data']); } if (dataClassName == 'EndpointDisabledException') { - return deserialize<_i41.EndpointDisabledException>(data['data']); + return deserialize<_i42.EndpointDisabledException>(data['data']); } if (dataClassName == 'EnrollmentException') { - return deserialize<_i42.EnrollmentException>(data['data']); + return deserialize<_i43.EnrollmentException>(data['data']); } if (dataClassName == 'NoticeDeliveryException') { - return deserialize<_i43.NoticeDeliveryException>(data['data']); + return deserialize<_i44.NoticeDeliveryException>(data['data']); } if (dataClassName == 'OtpRequestException') { - return deserialize<_i44.OtpRequestException>(data['data']); + return deserialize<_i45.OtpRequestException>(data['data']); } if (dataClassName == 'MicroArea') { - return deserialize<_i45.MicroArea>(data['data']); + return deserialize<_i46.MicroArea>(data['data']); } if (dataClassName == 'OtpChallenge') { - return deserialize<_i46.OtpChallenge>(data['data']); + return deserialize<_i47.OtpChallenge>(data['data']); } if (dataClassName == 'Patient') { - return deserialize<_i47.Patient>(data['data']); + return deserialize<_i48.Patient>(data['data']); } if (dataClassName == 'PushToken') { - return deserialize<_i48.PushToken>(data['data']); + return deserialize<_i49.PushToken>(data['data']); } if (dataClassName == 'TriageAnswer') { - return deserialize<_i49.TriageAnswer>(data['data']); + return deserialize<_i50.TriageAnswer>(data['data']); } if (dataClassName == 'TriageSession') { - return deserialize<_i50.TriageSession>(data['data']); + return deserialize<_i51.TriageSession>(data['data']); } if (dataClassName == 'Ubs') { - return deserialize<_i51.Ubs>(data['data']); + return deserialize<_i52.Ubs>(data['data']); } if (dataClassName == 'User') { - return deserialize<_i52.User>(data['data']); + return deserialize<_i53.User>(data['data']); } if (dataClassName == 'UserCredential') { - return deserialize<_i53.UserCredential>(data['data']); + return deserialize<_i54.UserCredential>(data['data']); } if (dataClassName == 'Visit') { - return deserialize<_i54.Visit>(data['data']); + return deserialize<_i55.Visit>(data['data']); } if (dataClassName.startsWith('serverpod.')) { data['className'] = dataClassName.substring(10); @@ -2721,32 +2735,32 @@ class Protocol extends _i1.SerializationManagerServer { return _i6.AlertIdempotencyKey.t; case _i7.AlertOutboxEntry: return _i7.AlertOutboxEntry.t; - case _i24.AuditLog: - return _i24.AuditLog.t; - case _i25.ConsentLog: - return _i25.ConsentLog.t; - case _i26.DataSubjectRequest: - return _i26.DataSubjectRequest.t; - case _i27.EnrollmentToken: - return _i27.EnrollmentToken.t; - case _i45.MicroArea: - return _i45.MicroArea.t; - case _i46.OtpChallenge: - return _i46.OtpChallenge.t; - case _i47.Patient: - return _i47.Patient.t; - case _i48.PushToken: - return _i48.PushToken.t; - case _i50.TriageSession: - return _i50.TriageSession.t; - case _i51.Ubs: - return _i51.Ubs.t; - case _i52.User: - return _i52.User.t; - case _i53.UserCredential: - return _i53.UserCredential.t; - case _i54.Visit: - return _i54.Visit.t; + case _i25.AuditLog: + return _i25.AuditLog.t; + case _i26.ConsentLog: + return _i26.ConsentLog.t; + case _i27.DataSubjectRequest: + return _i27.DataSubjectRequest.t; + case _i28.EnrollmentToken: + return _i28.EnrollmentToken.t; + case _i46.MicroArea: + return _i46.MicroArea.t; + case _i47.OtpChallenge: + return _i47.OtpChallenge.t; + case _i48.Patient: + return _i48.Patient.t; + case _i49.PushToken: + return _i49.PushToken.t; + case _i51.TriageSession: + return _i51.TriageSession.t; + case _i52.Ubs: + return _i52.Ubs.t; + case _i53.User: + return _i53.User.t; + case _i54.UserCredential: + return _i54.UserCredential.t; + case _i55.Visit: + return _i55.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 f2e9e71f..5a4114e0 100644 --- a/backend/sinalacs_server/lib/src/generated/protocol.yaml +++ b/backend/sinalacs_server/lib/src/generated/protocol.yaml @@ -24,6 +24,7 @@ patients: - updateConsent: - acceptTermsOfUse: - hasAcceptedCurrentTerms: + - termsChangeNotice: - requestDataDeletion: - requestDataCorrection: triage: 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/test/integration/data_subject_rights_endpoint_test.dart b/backend/sinalacs_server/test/integration/data_subject_rights_endpoint_test.dart index 9a5984f3..0e83f679 100644 --- a/backend/sinalacs_server/test/integration/data_subject_rights_endpoint_test.dart +++ b/backend/sinalacs_server/test/integration/data_subject_rights_endpoint_test.dart @@ -278,6 +278,31 @@ void main() { 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); 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 bd205518..9673dec9 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 @@ -35,13 +35,15 @@ import 'package:sinalacs_server/src/generated/api/patient_consent_record.dart' as _i14; import 'package:sinalacs_server/src/generated/enums/consent_purpose.dart' as _i15; -import 'package:sinalacs_server/src/generated/api/patient_data_subject_request_record.dart' +import 'package:sinalacs_server/src/generated/api/terms_change_notice.dart' as _i16; -import 'package:sinalacs_server/src/generated/api/triage_result.dart' as _i17; +import 'package:sinalacs_server/src/generated/api/patient_data_subject_request_record.dart' + as _i17; +import 'package:sinalacs_server/src/generated/api/triage_result.dart' as _i18; import 'package:sinalacs_server/src/generated/api/visit_sync_result.dart' - as _i18; -import 'package:sinalacs_server/src/generated/api/visit_sync_entry.dart' as _i19; +import 'package:sinalacs_server/src/generated/api/visit_sync_entry.dart' + as _i20; 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'; @@ -951,7 +953,38 @@ class _PatientsEndpoint { }); } - _i3.Future<_i16.PatientDataSubjectRequestRecord> requestDataDeletion( + _i3.Future<_i16.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<_i16.TermsChangeNotice?>); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } + + _i3.Future<_i17.PatientDataSubjectRequestRecord> requestDataDeletion( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, }) async { @@ -974,7 +1007,7 @@ class _PatientsEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i16.PatientDataSubjectRequestRecord>); + as _i3.Future<_i17.PatientDataSubjectRequestRecord>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -982,7 +1015,7 @@ class _PatientsEndpoint { }); } - _i3.Future<_i16.PatientDataSubjectRequestRecord> requestDataCorrection( + _i3.Future<_i17.PatientDataSubjectRequestRecord> requestDataCorrection( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, required String details, @@ -1009,7 +1042,7 @@ class _PatientsEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i16.PatientDataSubjectRequestRecord>); + as _i3.Future<_i17.PatientDataSubjectRequestRecord>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -1028,7 +1061,7 @@ class _TriageEndpoint { final _i2.SerializationManager _serializationManager; - _i3.Future<_i17.TriageResult> evaluate( + _i3.Future<_i18.TriageResult> evaluate( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, required bool chestPain, @@ -1065,7 +1098,7 @@ class _TriageEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future<_i17.TriageResult>); + as _i3.Future<_i18.TriageResult>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -1084,10 +1117,10 @@ class _VisitsEndpoint { final _i2.SerializationManager _serializationManager; - _i3.Future> sync( + _i3.Future> sync( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, - required List<_i19.VisitSyncEntry> visits, + required List<_i20.VisitSyncEntry> visits, }) async { return _i1.callAwaitableFunctionAndHandleExceptions(() async { var _localUniqueSession = @@ -1111,7 +1144,7 @@ class _VisitsEndpoint { _localUniqueSession, _localCallContext.arguments, ) - as _i3.Future>); + as _i3.Future>); return _localReturnValue; } finally { await _localUniqueSession.close(); @@ -1119,7 +1152,7 @@ class _VisitsEndpoint { }); } - _i3.Future> pull( + _i3.Future> pull( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, required DateTime since, @@ -1146,7 +1179,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/unit/data_subject_rights_service_test.dart b/backend/sinalacs_server/test/unit/data_subject_rights_service_test.dart index e64de72b..fa387c0a 100644 --- a/backend/sinalacs_server/test/unit/data_subject_rights_service_test.dart +++ b/backend/sinalacs_server/test/unit/data_subject_rights_service_test.dart @@ -3,6 +3,7 @@ import 'package:sinalacs_server/src/application/auth/development_auth_service.da 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/generated/protocol.dart'; import 'package:test/test.dart'; @@ -156,6 +157,55 @@ void main() { }); }); + 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, + termsChange: agenda, + ); + + test('sem agenda, não há aviso', () { + expect(service.termsChangeNotice(_patient), isNull); + }); + + test('com agenda ativa, devolve versão, vigência e resumo', () { + final notice = 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', () { + expect(comAgenda(() => agenda.effectiveFrom).termsChangeNotice(_patient), isNull); + }); + + test('antes da publicação não há aviso', () { + expect( + comAgenda(() => agenda.publishedAt.subtract(const Duration(seconds: 1))) + .termsChangeNotice(_patient), + isNull, + ); + }); + + test('só paciente', () { + expect(() => comAgenda(() => _now).termsChangeNotice(_acs), throwsA(isA())); + }); + + test('não grava nada nem audita (leitura sem I/O)', () { + comAgenda(() => _now).termsChangeNotice(_patient); + expect(store.consents, isEmpty); + expect(audit.events, isEmpty); + }); + }); + group('acceptTermsOfUse (LGPD-RF18)', () { test('grava "granted" para termsOfUse com a versão vigente e audita', () async { final record = await service.acceptTermsOfUse(_patient); 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..412463e5 --- /dev/null +++ b/backend/sinalacs_server/test/unit/terms_change_schedule_test.dart @@ -0,0 +1,45 @@ +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('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); + } + }); +} From 5a09bed6732181c2fa8701acda08ad5dca646476 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 29 Sep 2026 18:14:31 -0400 Subject: [PATCH 075/498] =?UTF-8?q?feat(paciente):=20cart=C3=A3o=20de=20av?= =?UTF-8?q?iso=20de=20mudan=C3=A7a=20dos=20termos=20e=20push=20sem=20quebr?= =?UTF-8?q?ar=20fora=20do=20escopo=20(LGPD-RF18)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- apps/patient/lib/app/app.dart | 46 +++++++- .../core/legal/terms_change_notice_card.dart | 71 ++++++++++++ .../lib/core/network/backend_client.dart | 13 +++ .../lib/core/push/push_token_source.dart | 6 + apps/patient/test/push_registration_test.dart | 25 +++++ .../test/support/fake_patient_backend.dart | 18 +++ .../test/terms_change_notice_test.dart | 105 ++++++++++++++++++ 7 files changed, 281 insertions(+), 3 deletions(-) create mode 100644 apps/patient/lib/core/legal/terms_change_notice_card.dart create mode 100644 apps/patient/test/terms_change_notice_test.dart diff --git a/apps/patient/lib/app/app.dart b/apps/patient/lib/app/app.dart index 8643fa67..50cbc554 100644 --- a/apps/patient/lib/app/app.dart +++ b/apps/patient/lib/app/app.dart @@ -15,12 +15,14 @@ import 'package:sinalacs_client/sinalacs_client.dart' 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'; @@ -434,7 +436,7 @@ 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.of(context))); + unawaited(registerPushDevice(BackendScope.of(context), PushTokenScope.maybeOf(context))); final needsTerms = await _needsTerms(); if (!mounted) return; MaterialPageRoute home() => MaterialPageRoute( @@ -787,7 +789,7 @@ class _OnboardingScreenState extends State { // Intencionalmente silencioso — ver comentário acima. } if (!mounted) return; - unawaited(registerPushDevice(BackendScope.of(context), PushTokenScope.of(context))); + unawaited(registerPushDevice(BackendScope.of(context), PushTokenScope.maybeOf(context))); // 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. @@ -997,12 +999,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. @@ -1054,6 +1086,14 @@ class _PatientHomeShellState extends State { child: Column( children: [ if (_sessionExpired) _SessionExpiredBanner(onReenter: _reenter), + if (_notice != null && !_noticeDismissed) + TermsChangeNoticeCard( + notice: _notice!, + onDismiss: () => setState(() => _noticeDismissed = true), + onRead: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const LegalDocumentsScreen()), + ), + ), Expanded(child: content), ], ), @@ -1785,7 +1825,7 @@ class _MyDataScreenState extends State { try { final record = await backend.updateConsent(purpose: purpose, granted: granted); if (purpose == ConsentPurpose.segmentedPush && granted && mounted) { - unawaited(registerPushDevice(backend, PushTokenScope.of(context))); + unawaited(registerPushDevice(backend, PushTokenScope.maybeOf(context))); } // A decisão já está gravada no servidor: aplicar no aparelho aqui, sem // depender do recarregamento abaixo — se ele falhar, um lembrete 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..c4a5110d --- /dev/null +++ b/apps/patient/lib/core/legal/terms_change_notice_card.dart @@ -0,0 +1,71 @@ +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, + }); + + final TermsChangeNotice notice; + final VoidCallback onRead; + final VoidCallback onDismiss; + + static String _date(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: [ + const Icon(Icons.info_outline, color: PatientColors.accentOnSurface), + const SizedBox(width: 8), + const Expanded( + child: 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 ${_date(notice.effectiveFrom)}. ' + '${notice.summary}', + ), + 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 637b0d64..8455d9f4 100644 --- a/apps/patient/lib/core/network/backend_client.dart +++ b/apps/patient/lib/core/network/backend_client.dart @@ -142,6 +142,10 @@ abstract class PatientBackend { /// 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. @@ -257,6 +261,9 @@ class MisconfiguredBackend implements PatientBackend { @override Future hasAcceptedCurrentTerms() async => _recusar(); + @override + Future termsChangeNotice() async => _recusar(); + @override Future registerPushToken({required String token, required String platform}) async => _recusar(); @@ -559,6 +566,12 @@ class BackendClient implements PatientBackend { ); } + @override + Future termsChangeNotice() async { + final token = await _requireToken(); + return _guard(() => _client.patients.termsChangeNotice(accessToken: token)); + } + @override Future hasAcceptedCurrentTerms() async { final token = await _requireToken(); diff --git a/apps/patient/lib/core/push/push_token_source.dart b/apps/patient/lib/core/push/push_token_source.dart index f39e6013..eec24e45 100644 --- a/apps/patient/lib/core/push/push_token_source.dart +++ b/apps/patient/lib/core/push/push_token_source.dart @@ -57,6 +57,12 @@ class PushTokenScope extends InheritedWidget { 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/test/push_registration_test.dart b/apps/patient/test/push_registration_test.dart index fbbe0b03..1e2fe8f6 100644 --- a/apps/patient/test/push_registration_test.dart +++ b/apps/patient/test/push_registration_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:sinalacs_patient/app/app.dart'; @@ -66,9 +68,32 @@ void main() { 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 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 _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/support/fake_patient_backend.dart b/apps/patient/test/support/fake_patient_backend.dart index b26b6075..dd506aa3 100644 --- a/apps/patient/test/support/fake_patient_backend.dart +++ b/apps/patient/test/support/fake_patient_backend.dart @@ -73,6 +73,24 @@ class FakePatientBackend implements PatientBackend { 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; 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..9ab69765 --- /dev/null +++ b/apps/patient/test/terms_change_notice_test.dart @@ -0,0 +1,105 @@ +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/app/app.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.', +); + +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 { + await tester.pumpWidget(const MaterialApp( + home: PatientHomeShell(initialDestination: PatientDestination.reminders), + )); + 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(); + }); +} From c607da3158328a11d66a4878f5bcbbaa05250ec3 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 29 Sep 2026 18:21:56 -0400 Subject: [PATCH 076/498] =?UTF-8?q?docs:=20registra=20os=20menores=20do=20?= =?UTF-8?q?push=20fechados=20e=20o=20aviso=20de=20mudan=C3=A7a=20dos=20ter?= =?UTF-8?q?mos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- PROGRESS.md | 28 ++++++++++++++++++++++------ apps/CLAUDE.md | 2 +- backend/CLAUDE.md | 2 +- spec/lgpd_data_audit.md | 3 ++- 4 files changed, 26 insertions(+), 9 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 6a96b332..c0218914 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1122,7 +1122,7 @@ Plano: `docs/superpowers/plans/2026-09-29-aceite-do-termo-no-login-otp.md`, bran **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 e revisão jurídica do texto seguem pendentes. +- 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. @@ -1142,7 +1142,7 @@ Plano: `docs/superpowers/plans/2026-09-29-fechamento-de-pendencias-do-paciente.m **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, push (RF14) e o aviso de 15 dias seguem pendentes. +- 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) @@ -1158,12 +1158,12 @@ Plano: `docs/superpowers/plans/2026-09-29-menores-adiados-e-push-do-paciente.md` **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: se a segunda falhar, o consentimento já está revogado e o token fica até a próxima revogação. -- `devices.registerPushToken` não grava linha de auditoria (a revogação já deixa `consent_log`); a troca de dono do token não deixa rastro. -- Sem teto de tokens por titular; sem teste do caso "fonte de token que nunca completa"; `PushTokenScope.of` sem `maybeOf`. +- ~~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". Continuam pendentes o Gorush hospedado, as credenciais FCM/APNs e o lado nativo do token (§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 e revisão jurídica do texto 2026.1. +- ~~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. @@ -1198,3 +1198,19 @@ Plano: `docs/superpowers/plans/2026-09-29-rf14-gorush-avisos-segmentados.md`, br **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. diff --git a/apps/CLAUDE.md b/apps/CLAUDE.md index 205dcfaa..b778ac27 100644 --- a/apps/CLAUDE.md +++ b/apps/CLAUDE.md @@ -32,7 +32,7 @@ The RPC trusts a development CA that is **separate** from the broker's: `scripts 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). -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 native Kotlin/Swift side does not exist yet**, so the channel is silent and the app degrades to no push. A refusal or failure never blocks the home or the emergency alert. In the ACS app, "Avisos à comunidade" (`NoticesScreen`) calls `AcsBackend.sendNotice` → `notices.sendSegmented`. +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 native Kotlin/Swift side does not exist yet**, so the channel is silent and the app degrades to no push. 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. `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`. diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index e60d309e..cabe1cdf 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -65,7 +65,7 @@ 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 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); 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, `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), `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), `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 +**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 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); 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, `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, sem I/O e sem auditoria; a agenda é a constante `upcomingTermsChange` em `terms_change_schedule.dart`, hoje `null`, e `TermsChangeSchedule` se recusa a existir com menos de 15 dias entre publicação e vigência — para anunciar uma mudança, troque a constante e só na vigência mude `consentPolicyVersion` e `legalDocumentsVersion`), `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), `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 ú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 diff --git a/spec/lgpd_data_audit.md b/spec/lgpd_data_audit.md index 75cca5b7..60302252 100644 --- a/spec/lgpd_data_audit.md +++ b/spec/lgpd_data_audit.md @@ -105,9 +105,10 @@ A tabela abaixo consolida o mapeamento exaustivo de dados persistidos pelo backe | **push_tokens** | `id` | `uuid` | Pseudonimizado | UUID v4 (`gen_random_uuid()`) | Identificador da linha do token de push (RF14). | | | `userId` | `uuid` | Pseudonimizado | Chave estrangeira (`users.id`) | Liga o aparelho ao titular; apagado quando o consentimento `segmentedPush` é revogado. | | | `microAreaId` | `uuid` | Pseudonimizado | Copiado do token de acesso | Segmentação dos avisos por microárea. | -| | `token` | `text` | Identificador de aparelho | Emitido pelo FCM/APNs | Identifica um aparelho, não uma pessoa; junto de `userId` liga os dois. Índice único: o mesmo token nunca tem dois donos. | +| | `token` | `text` | Identificador de aparelho | Emitido pelo FCM/APNs | Identifica um aparelho, não uma pessoa; junto de `userId` liga os dois. Índice único: o mesmo token nunca tem dois donos. No máximo 10 linhas por titular (`maxPushTokensPerUser`). | | | `platform` | `text` | Metadado Técnico | `android` \| `ios` | Escolhe o provedor do envio. | | | `createdAt` / `updatedAt` | `timestamp without time zone` | Metadado Técnico | Relógio do servidor | Primeiro registro e última confirmação do token. | +| | (`audit_logs`) | — | Metadado Técnico | `resourceType = push_token` | Só a troca de dono de um token de push (`result = granted`): o token nunca é gravado na trilha. | | | (`audit_logs`) | — | Metadado Técnico | `resourceType = community_notice` | Uma linha por aviso comunitário enviado: `userId` do ACS, `resourceId` = microárea, `result` = `granted` ou `no_recipients`. O texto do aviso nunca é gravado. | | **audit_logs** | `id` | `uuid` | Pseudonimizado | UUID v4 (`gen_random_uuid()`) | Identificador do registro de auditoria (LGPD-RF11). | | | `userId` | `uuid` | Pseudonimizado | Chave estrangeira (`users.id`) | Identifica o operador que executou a ação auditada. | From 2f2931c56d4b5bae449fb4df48427a8fe26423ce Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 29 Sep 2026 18:28:46 -0400 Subject: [PATCH 077/498] =?UTF-8?q?fix:=20cart=C3=A3o=20de=20aviso=20fora?= =?UTF-8?q?=20da=20aba=20de=20urg=C3=AAncia;=20regra=20dos=2015=20dias=20s?= =?UTF-8?q?em=20assert;=20docs=20do=20LGPD=20(RF14/LGPD-RF18)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Achados do revisor final. Co-Authored-By: Claude Sonnet 5.5 --- apps/patient/lib/app/app.dart | 4 ++- .../test/terms_change_notice_test.dart | 32 +++++++++++++++++++ backend/CLAUDE.md | 2 +- .../patients/terms_change_schedule.dart | 27 ++++++++++------ .../test/unit/terms_change_schedule_test.dart | 13 ++++++-- spec/lgpd_design.md | 6 ++-- 6 files changed, 68 insertions(+), 16 deletions(-) diff --git a/apps/patient/lib/app/app.dart b/apps/patient/lib/app/app.dart index 50cbc554..60ff6019 100644 --- a/apps/patient/lib/app/app.dart +++ b/apps/patient/lib/app/app.dart @@ -1086,7 +1086,9 @@ class _PatientHomeShellState extends State { child: Column( children: [ if (_sessionExpired) _SessionExpiredBanner(onReenter: _reenter), - if (_notice != null && !_noticeDismissed) + // 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) TermsChangeNoticeCard( notice: _notice!, onDismiss: () => setState(() => _noticeDismissed = true), diff --git a/apps/patient/test/terms_change_notice_test.dart b/apps/patient/test/terms_change_notice_test.dart index 9ab69765..607130b2 100644 --- a/apps/patient/test/terms_change_notice_test.dart +++ b/apps/patient/test/terms_change_notice_test.dart @@ -102,4 +102,36 @@ void main() { 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); + }); } diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index cabe1cdf..12b8093f 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -65,7 +65,7 @@ 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 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); 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, `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, sem I/O e sem auditoria; a agenda é a constante `upcomingTermsChange` em `terms_change_schedule.dart`, hoje `null`, e `TermsChangeSchedule` se recusa a existir com menos de 15 dias entre publicação e vigência — para anunciar uma mudança, troque a constante e só na vigência mude `consentPolicyVersion` e `legalDocumentsVersion`), `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), `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 +**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 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); 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, `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, sem I/O e sem auditoria; a agenda é a constante `upcomingTermsChange` em `terms_change_schedule.dart`, hoje `null`, 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, troque a constante e só na vigência mude `consentPolicyVersion` e `legalDocumentsVersion`), `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), `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 ú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 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 index c04af28f..5919fb87 100644 --- a/backend/sinalacs_server/lib/src/application/patients/terms_change_schedule.dart +++ b/backend/sinalacs_server/lib/src/application/patients/terms_change_schedule.dart @@ -7,10 +7,12 @@ const Duration termsChangeNoticePeriod = Duration(days: 15); /// 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. Não é -/// `const` porque `DateTime.difference` não é constante. +/// 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. /// /// 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 @@ -21,11 +23,18 @@ class TermsChangeSchedule { 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', - ); + }) { + 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; diff --git a/backend/sinalacs_server/test/unit/terms_change_schedule_test.dart b/backend/sinalacs_server/test/unit/terms_change_schedule_test.dart index 412463e5..fc797170 100644 --- a/backend/sinalacs_server/test/unit/terms_change_schedule_test.dart +++ b/backend/sinalacs_server/test/unit/terms_change_schedule_test.dart @@ -1,3 +1,5 @@ +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'; @@ -16,16 +18,23 @@ 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()), + 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())); + expect(() => agenda(versao: consentPolicyVersion), throwsA(isA())); }); test('ativa na publicação, inativa na vigência', () { diff --git a/spec/lgpd_design.md b/spec/lgpd_design.md index 59f7a517..4b66f717 100644 --- a/spec/lgpd_design.md +++ b/spec/lgpd_design.md @@ -167,7 +167,7 @@ como um item separado a lembrar depois. | **Artigos LGPD** | 6º, VI; 9º | | **Critério de Aceite** | ✓ Política de Privacidade com linguagem clara e acessível
✓ Resumo visual do fluxo de dados no app
✓ Notificações sobre mudanças nas políticas
✓ Canal de dúvidas sobre tratamento de dados | -> **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 canal digital de dúvidas. +> **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 e canal digital de dúvidas. O aviso com 15 dias de antecedência existe (`TermsChangeSchedule`, `patients.termsChangeNotice` e um cartão dispensável na home), mas a agenda está vazia e o canal é só dentro do app. ### LGPD-RF11 - Controle de Acesso e RBAC @@ -260,7 +260,7 @@ como um item separado a lembrar depois. | **Conteúdo Mínimo** | Regras de uso, responsabilidades, proibições, propriedade intelectual, limitação de responsabilidade, jurisdição, alterações no termo. | | **Critério de Aceite** | ✓ Texto em linguagem simples (acessibilidade para idosos e baixo letramento)
✓ Disponibilizado no app e no site
✓ Aceite explícito no cadastro
✓ Controle de versões disponível para consulta | -> **Estado (2026-09-29):** implementado no app paciente — ver PROGRESS.md "QR Code do onboarding e documentos legais". Pacientes que entram por OTP sem onboarding são convidados a aceitar no primeiro login (`patients.acceptTermsOfUse`), e de novo quando a versão mudar; o convite não é obrigatório para acessar o alerta de emergência. Pendentes: revisão jurídica do texto e aviso com 15 dias de antecedência. +> **Estado (2026-09-29):** implementado no app paciente — ver PROGRESS.md "QR Code do onboarding e documentos legais". Pacientes que entram por OTP sem onboarding são convidados a aceitar no primeiro login (`patients.acceptTermsOfUse`), e de novo quando a versão mudar; o convite não é obrigatório para acessar o alerta de emergência. Pendentes: revisão jurídica do texto. O aviso com 15 dias de antecedência existe (`TermsChangeSchedule`, `patients.termsChangeNotice`, cartão dispensável na home; agenda vazia). ### LGPD-RF19 - Política de Privacidade @@ -272,7 +272,7 @@ como um item separado a lembrar depois. | **Conteúdo Mínimo** | Identificação do controlador, dados coletados (por funcionalidade), finalidades específicas, bases legais, compartilhamento, transferências, retenção, direitos, medidas de segurança, DPO/encarregado. | | **Critério de Aceite** | ✓ Linguagem acessível para leigos (recomendado: Nível de leitura 8º ano)
✓ Tópicos claros e organizados
✓ Versão resumida (sumário visual) e versão completa
✓ Atualização comunicada com no mínimo 15 dias de antecedência | -> **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. +> **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. O aviso com 15 dias de antecedência existe (`TermsChangeSchedule`, `patients.termsChangeNotice`, cartão dispensável na home; agenda vazia) e o reaceite a cada nova versão também (`hasAcceptedCurrentTerms`). ### LGPD-RF20 - Aviso de Consentimento (Banner/Modal) From 49d311f580df826d2c75cdcd7a81352851ed913d Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 29 Sep 2026 18:29:00 -0400 Subject: [PATCH 078/498] docs: registra os achados do revisor final dos menores do push e do aviso de 15 dias Co-Authored-By: Claude Sonnet 5.5 --- PROGRESS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/PROGRESS.md b/PROGRESS.md index c0218914..0f02330a 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1214,3 +1214,5 @@ Plano: `docs/superpowers/plans/2026-09-29-menores-do-push-e-aviso-de-mudanca-dos - 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. From 8375a68ae6ba153caee56faebfacfb9198fb2eca Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 29 Sep 2026 21:00:55 -0400 Subject: [PATCH 079/498] docs: plano dos menores do RF14 e do texto novo dos termos Co-Authored-By: Claude Sonnet 5.5 --- ...menores-do-rf14-e-texto-novo-dos-termos.md | 573 ++++++++++++++++++ 1 file changed, 573 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-30-menores-do-rf14-e-texto-novo-dos-termos.md 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. From d96af6b778d5b9d7e1e1131aecfb957ddb1c0941 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 29 Sep 2026 21:03:38 -0400 Subject: [PATCH 080/498] =?UTF-8?q?fix(backend):=20poda=20do=20teto=20por?= =?UTF-8?q?=20titular,=20desempates=20est=C3=A1veis=20e=20rastro=20do=20do?= =?UTF-8?q?no=20anterior=20do=20token=20(RF14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../patients/data_subject_rights_service.dart | 8 +- .../patients/push_token_service.dart | 26 +++- .../orm_data_subject_rights_store.dart | 12 +- .../database/orm_push_token_store.dart | 39 +++-- .../integration/push_token_endpoint_test.dart | 147 ++++++++++++++++-- .../data_subject_rights_service_test.dart | 12 +- .../test/unit/push_token_service_test.dart | 20 ++- 7 files changed, 219 insertions(+), 45 deletions(-) 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 index 8f798860..5a3b3688 100644 --- 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 @@ -86,16 +86,16 @@ class DataSubjectRightsService { required DataSubjectRightsStore store, required AuditTrail audit, DateTime Function()? clock, - TermsChangeSchedule? termsChange, + TermsChangeSchedule? Function()? termsChangeReader, }) : _store = store, _audit = audit, - _termsChange = termsChange ?? upcomingTermsChange, + _termsChangeReader = termsChangeReader ?? (() => upcomingTermsChange), _clock = clock ?? DateTime.now; final DataSubjectRightsStore _store; final AuditTrail _audit; final DateTime Function() _clock; - final TermsChangeSchedule? _termsChange; + final TermsChangeSchedule? Function() _termsChangeReader; /// Concede ou revoga uma finalidade opcional. `healthDataProcessing` é /// recusado nas duas direções: é a base legal do app inteiro — inclusive do @@ -160,7 +160,7 @@ class DataSubjectRightsService { /// antecedência). Sem I/O: a agenda é uma constante do repositório. TermsChangeNoticeSnapshot? termsChangeNotice(AuthenticatedUser user) { _requirePatient(user); - final schedule = _termsChange; + final schedule = _termsChangeReader(); if (schedule == null || !schedule.isActiveAt(_clock().toUtc())) return null; return TermsChangeNoticeSnapshot( version: schedule.version, 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 index ef279cf5..7d3eb611 100644 --- a/backend/sinalacs_server/lib/src/application/patients/push_token_service.dart +++ b/backend/sinalacs_server/lib/src/application/patients/push_token_service.dart @@ -20,7 +20,7 @@ abstract interface class PushTokenStore { /// /// Depois de gravar, o titular nunca fica com mais de [maxPushTokensPerUser] /// tokens: os mais antigos (por `updatedAt`) saem. - Future registerIfConsented({ + Future registerIfConsented({ required String userId, required String? microAreaId, required String token, @@ -32,6 +32,15 @@ abstract interface class PushTokenStore { /// 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; @@ -75,14 +84,14 @@ class PushTokenService { !pushPlatforms.contains(platform)) { throw DataRightsException(message: 'Aparelho inválido para receber avisos.'); } - final outcome = await _store.registerIfConsented( + final result = await _store.registerIfConsented( userId: user.id, microAreaId: user.microAreaId, token: trimmed, platform: platform, now: _clock().toUtc(), ); - if (outcome == PushRegistration.refused) { + if (result.outcome == PushRegistration.refused) { throw DataRightsException( message: 'Ative "Avisos da equipe de saúde" em Meus Dados para receber avisos.', ); @@ -90,13 +99,22 @@ class PushTokenService { // 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 (outcome == PushRegistration.ownerChanged) { + 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/infrastructure/database/orm_data_subject_rights_store.dart b/backend/sinalacs_server/lib/src/infrastructure/database/orm_data_subject_rights_store.dart index 83bc553e..af30e39f 100644 --- 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 @@ -97,8 +97,10 @@ class OrmDataSubjectRightsStore implements DataSubjectRightsStore { 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, + orderByList: (t) => [ + Order(column: t.timestamp, orderDescending: true), + Order(column: t.id, orderDescending: true), + ], transaction: transaction, ); final snapshot = latest == null @@ -126,8 +128,10 @@ class OrmDataSubjectRightsStore implements DataSubjectRightsStore { 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, + orderByList: (t) => [ + Order(column: t.timestamp, orderDescending: true), + Order(column: t.id, orderDescending: true), + ], ); return row == null ? null 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 index 33bb4e9d..0696ada6 100644 --- 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 @@ -15,7 +15,7 @@ class OrmPushTokenStore implements PushTokenStore { /// 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({ + Future registerIfConsented({ required String userId, required String? microAreaId, required String token, @@ -32,8 +32,12 @@ class OrmPushTokenStore implements PushTokenStore { session, where: (t) => t.userId.equals(userUuid) & t.purpose.equals(ConsentPurpose.segmentedPush.name), - orderBy: (t) => t.timestamp, - orderDescending: true, + // 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( @@ -45,9 +49,10 @@ class OrmPushTokenStore implements PushTokenStore { if (existing != null && existing.userId != userUuid) { await PushToken.db.deleteRow(session, existing, transaction: transaction); } - return PushRegistration.refused; + 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, @@ -73,18 +78,30 @@ class OrmPushTokenStore implements PushTokenStore { transaction: transaction, ); } - // Teto por titular: os mais antigos saem, nunca o que acabou de entrar. + // 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), - orderBy: (t) => t.updatedAt, - orderDescending: true, + 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, ); - for (final old in mine.skip(maxPushTokensPerUser)) { - await PushToken.db.deleteRow(session, old, 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 ? PushRegistration.ownerChanged : PushRegistration.registered; + return ownerChanged + ? PushRegistrationResult(PushRegistration.ownerChanged, previousOwnerId: previousOwnerId) + : const PushRegistrationResult(PushRegistration.registered); }); } } diff --git a/backend/sinalacs_server/test/integration/push_token_endpoint_test.dart b/backend/sinalacs_server/test/integration/push_token_endpoint_test.dart index 2a5cb62a..404e4080 100644 --- a/backend/sinalacs_server/test/integration/push_token_endpoint_test.dart +++ b/backend/sinalacs_server/test/integration/push_token_endpoint_test.dart @@ -1,6 +1,6 @@ import 'package:serverpod/serverpod.dart'; import 'package:sinalacs_server/src/application/patients/push_token_service.dart' - show PushRegistration, maxPushTokensPerUser; + 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' @@ -282,8 +282,8 @@ void main() { test('depois de registrar × revogar em paralelo, denied nunca convive com token', () async { final session = sessionBuilder.build(); - await _seedRaceLean(session); try { + await _seedRaceLean(session); final consents = OrmDataSubjectRightsStore( session: () => sessionBuilder.build(), chainSecret: _chainSecret, @@ -331,8 +331,8 @@ void main() { test('aparelho de outro titular sem consentimento perde o vínculo do dono antigo', () async { final session = sessionBuilder.build(); - await _seedRaceLean(session); try { + await _seedRaceLean(session); final consents = OrmDataSubjectRightsStore( session: () => sessionBuilder.build(), chainSecret: _chainSecret, @@ -347,13 +347,14 @@ void main() { timestamp: DateTime.now().toUtc(), )); expect( - await tokens.registerIfConsented( + (await tokens.registerIfConsented( userId: _racePatientId, microAreaId: _raceMicroAreaId, token: 'tok-compartilhado', platform: 'android', now: DateTime.now().toUtc(), - ), + )) + .outcome, PushRegistration.registered, ); @@ -366,7 +367,7 @@ void main() { now: DateTime.now().toUtc(), ); - expect(registered, PushRegistration.refused); + expect(registered.outcome, PushRegistration.refused); expect( await PushToken.db.count(session, where: (t) => t.token.equals('tok-compartilhado')), 0, @@ -396,8 +397,8 @@ void main() { test('revogação atômica: falha depois de apagar os tokens desfaz tudo', () async { final session = sessionBuilder.build(); - await _seedRaceLean(session); try { + await _seedRaceLean(session); await grant(consentStore(), _racePatientId); final tokens = OrmPushTokenStore(session: () => sessionBuilder.build()); await tokens.registerIfConsented( @@ -436,8 +437,8 @@ void main() { test('passou do teto, o token mais antigo sai e o novo entra', () async { final session = sessionBuilder.build(); - await _seedRaceLean(session); try { + await _seedRaceLean(session); await grant(consentStore(), _racePatientId); final tokens = OrmPushTokenStore(session: () => sessionBuilder.build()); final base = DateTime.now().toUtc(); @@ -465,13 +466,13 @@ void main() { test('troca de dono devolve ownerChanged; repetir devolve registered', () async { final session = sessionBuilder.build(); - await _seedRaceLean(session); 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( + Future register(String userId) => tokens.registerIfConsented( userId: userId, microAreaId: _raceMicroAreaId, token: 'tok-dono', @@ -479,14 +480,134 @@ void main() { now: DateTime.now().toUtc(), ); - expect(await register(_racePatientId), PushRegistration.registered); - expect(await register(_raceAcsId), PushRegistration.ownerChanged); - expect(await register(_raceAcsId), PushRegistration.registered); + 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/unit/data_subject_rights_service_test.dart b/backend/sinalacs_server/test/unit/data_subject_rights_service_test.dart index fa387c0a..508a0a1a 100644 --- a/backend/sinalacs_server/test/unit/data_subject_rights_service_test.dart +++ b/backend/sinalacs_server/test/unit/data_subject_rights_service_test.dart @@ -169,9 +169,19 @@ void main() { store: store, audit: audit, clock: clock, - termsChange: agenda, + termsChangeReader: () => agenda, ); + 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); + }); + test('sem agenda, não há aviso', () { expect(service.termsChangeNotice(_patient), isNull); }); diff --git a/backend/sinalacs_server/test/unit/push_token_service_test.dart b/backend/sinalacs_server/test/unit/push_token_service_test.dart index f484107d..8da562f5 100644 --- a/backend/sinalacs_server/test/unit/push_token_service_test.dart +++ b/backend/sinalacs_server/test/unit/push_token_service_test.dart @@ -32,19 +32,19 @@ class _FakePushTokenStore implements PushTokenStore { final rows = {}; @override - Future registerIfConsented({ + Future registerIfConsented({ required String userId, required String? microAreaId, required String token, required String platform, required DateTime now, }) async { - if (!consent) return PushRegistration.refused; + if (!consent) return const PushRegistrationResult(PushRegistration.refused); final previous = rows[token]; rows[token] = (userId: userId, platform: platform); return previous != null && previous.userId != userId - ? PushRegistration.ownerChanged - : PushRegistration.registered; + ? PushRegistrationResult(PushRegistration.ownerChanged, previousOwnerId: previous.userId) + : const PushRegistrationResult(PushRegistration.registered); } } @@ -104,15 +104,19 @@ void main() { ); }); - test('troca de dono é auditada; primeiro registro e repetição não', () async { + 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.single.resourceType, 'push_token'); - expect(audit.events.single.userId, _otherPatient.id); - expect('${audit.events.single.resourceId} ${audit.events.single.result}'.contains('tok-1'), isFalse); + + 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 { From d9ee73089e3883171a9c3cb3a63d71359bab852a Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 29 Sep 2026 21:06:48 -0400 Subject: [PATCH 081/498] =?UTF-8?q?fix(backend):=20cliente=20do=20Gorush?= =?UTF-8?q?=20encerr=C3=A1vel=20e=20tolerante,=20envio=20n=C3=A3o=20vira?= =?UTF-8?q?=20erro=20por=20falha=20de=20poda,=20LEFT=20JOIN=20e=20desempat?= =?UTF-8?q?e=20(RF14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../application/notices/notice_service.dart | 8 ++- .../database/orm_notice_recipient_store.dart | 11 ++-- .../infrastructure/push/gorush_client.dart | 22 ++++++-- .../lib/src/runtime/alert_runtime.dart | 19 ++++++- .../integration/notices_endpoint_test.dart | 50 ++++++++++++++++++- .../test/unit/gorush_client_test.dart | 48 ++++++++++++++++-- .../test/unit/notice_service_test.dart | 18 +++++++ 7 files changed, 162 insertions(+), 14 deletions(-) diff --git a/backend/sinalacs_server/lib/src/application/notices/notice_service.dart b/backend/sinalacs_server/lib/src/application/notices/notice_service.dart index c1a6de8d..4cb3db5d 100644 --- a/backend/sinalacs_server/lib/src/application/notices/notice_service.dart +++ b/backend/sinalacs_server/lib/src/application/notices/notice_service.dart @@ -117,9 +117,13 @@ class NoticeService { ); } if (report.invalidTokens.isNotEmpty) { - await _store.deleteTokens(report.invalidTokens); + 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, 'granted'); + await _record(user, microAreaId, report.accepted > 0 ? 'granted' : 'not_delivered'); return NoticeSendSnapshot(recipients: targets.length, accepted: report.accepted); } 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 index efa8a9b0..a23fc7fb 100644 --- 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 @@ -12,7 +12,10 @@ class OrmNoticeRecipientStore implements NoticeRecipientStore { /// 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. Parâmetros sempre nomeados, nunca interpolados. + /// 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, @@ -23,14 +26,14 @@ class OrmNoticeRecipientStore implements NoticeRecipientStore { SELECT pt."token" AS token, pt."platform" AS platform FROM push_tokens pt JOIN users u ON u."id" = pt."userId" - JOIN patients p ON p."id" = u."id" + LEFT JOIN patients p ON p."id" = u."id" WHERE u."microAreaId" = @micro::uuid AND u."role" = 'patient' - AND (NOT @chronic OR p."isChronic") + 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 LIMIT 1 + ORDER BY c."timestamp" DESC, c."id" DESC LIMIT 1 ), 'denied') = 'granted' ORDER BY pt."token" ''', diff --git a/backend/sinalacs_server/lib/src/infrastructure/push/gorush_client.dart b/backend/sinalacs_server/lib/src/infrastructure/push/gorush_client.dart index 02cfd203..30a21211 100644 --- a/backend/sinalacs_server/lib/src/infrastructure/push/gorush_client.dart +++ b/backend/sinalacs_server/lib/src/infrastructure/push/gorush_client.dart @@ -63,6 +63,9 @@ class GorushClient implements PushSender { final Duration _timeout; 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; @@ -114,7 +117,13 @@ class GorushClient implements PushSender { } on HttpException { throw const PushGatewayException('Falha de HTTP ao falar com o Gorush.'); } on FormatException { - throw const PushGatewayException('O Gorush respondeu algo ilegível.'); + // 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.'); } } @@ -127,8 +136,15 @@ class GorushClient implements PushSender { if (response.statusCode < 200 || response.statusCode >= 300) { throw PushGatewayException('O Gorush respondeu com status ${response.statusCode}.'); } - final json = raw.isEmpty ? {} : jsonDecode(raw) as Map; - final logs = (json['logs'] as List?) ?? const []; + 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>()) { diff --git a/backend/sinalacs_server/lib/src/runtime/alert_runtime.dart b/backend/sinalacs_server/lib/src/runtime/alert_runtime.dart index 3cf9343e..c872d369 100644 --- a/backend/sinalacs_server/lib/src/runtime/alert_runtime.dart +++ b/backend/sinalacs_server/lib/src/runtime/alert_runtime.dart @@ -52,6 +52,11 @@ class 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; @@ -139,6 +144,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 @@ -235,11 +243,20 @@ class AlertRuntime { store: OrmNoticeRecipientStore(session: () => session), sender: override != null ? override() - : (url == null ? null : GorushClient(baseUrl: url, timeout: config.gorushTimeout)), + : (url == null ? null : _gorushClientFor(url)), audit: auditTrailFor(session), ); } + 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( diff --git a/backend/sinalacs_server/test/integration/notices_endpoint_test.dart b/backend/sinalacs_server/test/integration/notices_endpoint_test.dart index 98a15197..83a78674 100644 --- a/backend/sinalacs_server/test/integration/notices_endpoint_test.dart +++ b/backend/sinalacs_server/test/integration/notices_endpoint_test.dart @@ -1,3 +1,5 @@ +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'; @@ -20,6 +22,7 @@ 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( @@ -127,6 +130,7 @@ Future _addPatient( required String id, required String microAreaId, required bool chronic, + bool withPatientRow = true, }) async { final now = DateTime.now().toUtc(); await User.db.insertRow( @@ -142,6 +146,7 @@ Future _addPatient( updatedAt: now, ), ); + if (!withPatientRow) return; await Patient.db.insertRow( session, await encryptedPatient( @@ -281,7 +286,7 @@ void main() { expect(await PushToken.db.count(session, where: (t) => t.token.equals('tok-a3')), 1); }); - test('grava uma linha community_notice sem o texto do aviso', () async { + 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')); @@ -290,6 +295,49 @@ void main() { 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 { diff --git a/backend/sinalacs_server/test/unit/gorush_client_test.dart b/backend/sinalacs_server/test/unit/gorush_client_test.dart index 38fd5a32..e0e90f89 100644 --- a/backend/sinalacs_server/test/unit/gorush_client_test.dart +++ b/backend/sinalacs_server/test/unit/gorush_client_test.dart @@ -8,7 +8,7 @@ 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) { + FakeGorush._(this._server, this.status, this.response, this.hang, this.rawBody) { _server.listen((request) async { requests++; final raw = await utf8.decoder.bind(request).join(); @@ -18,7 +18,7 @@ class FakeGorush { request.response ..statusCode = status ..headers.contentType = ContentType.json - ..write(jsonEncode(response)); + ..write(rawBody ?? jsonEncode(response)); await request.response.close(); }); } @@ -27,13 +27,23 @@ class FakeGorush { int status = 200, Map response = const {}, bool hang = false, + String? rawBody, }) async => - FakeGorush._(await HttpServer.bind(InternetAddress.loopbackIPv4, 0), status, response, hang); + 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 = {}; @@ -174,6 +184,38 @@ void main() { 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()), + ); + }); + test('lista vazia não chama o Gorush', () async { final gw = await FakeGorush.start(); addTearDown(gw.close); diff --git a/backend/sinalacs_server/test/unit/notice_service_test.dart b/backend/sinalacs_server/test/unit/notice_service_test.dart index 9eb55f69..17f065d4 100644 --- a/backend/sinalacs_server/test/unit/notice_service_test.dart +++ b/backend/sinalacs_server/test/unit/notice_service_test.dart @@ -29,6 +29,7 @@ class _FakeRecipientStore implements NoticeRecipientStore { String? lastMicroAreaId; bool? lastChronicOnly; final deleted = []; + Object? deleteFailure; @override Future> consentedTargets({ @@ -42,6 +43,8 @@ class _FakeRecipientStore implements NoticeRecipientStore { @override Future deleteTokens(List tokens) async { + final failure = deleteFailure; + if (failure != null) throw failure; deleted.addAll(tokens); return tokens.length; } @@ -147,6 +150,21 @@ void main() { 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( From db4bd60a58d69ff345b85da061b3ace3e0ac91e2 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 29 Sep 2026 21:10:12 -0400 Subject: [PATCH 082/498] =?UTF-8?q?feat(paciente):=20texto=20novo=20dos=20?= =?UTF-8?q?termos=20durante=20os=2015=20dias,=20contraste=20e=20sem=C3=A2n?= =?UTF-8?q?tica=20do=20cart=C3=A3o=20(LGPD-RF18)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- apps/patient/lib/app/app.dart | 68 +++++++++++++++---- apps/patient/lib/app/legal_screens.dart | 22 ++++-- .../lib/core/legal/legal_documents.dart | 22 ++++++ .../core/legal/terms_change_notice_card.dart | 30 ++++++-- apps/patient/test/contrast_tokens_test.dart | 10 ++- .../test/terms_change_notice_test.dart | 63 +++++++++++++++++ .../test/upcoming_legal_documents_test.dart | 50 ++++++++++++++ 7 files changed, 237 insertions(+), 28 deletions(-) create mode 100644 apps/patient/test/upcoming_legal_documents_test.dart diff --git a/apps/patient/lib/app/app.dart b/apps/patient/lib/app/app.dart index 60ff6019..f3a1662f 100644 --- a/apps/patient/lib/app/app.dart +++ b/apps/patient/lib/app/app.dart @@ -47,6 +47,7 @@ class SinalAcsApp extends StatefulWidget { this.consentPreferences, this.qrScanner, this.pushTokens, + this.upcomingDocuments, }); /// Backend do app. **Obrigatório, e construído em `main.dart`.** @@ -87,6 +88,10 @@ class SinalAcsApp extends StatefulWidget { /// 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; + @override State createState() => _SinalAcsAppState(); } @@ -134,13 +139,16 @@ class _SinalAcsAppState extends State { consentPreferences: _consentPreferences, child: PushTokenScope( source: _pushTokens, - child: QrScannerScope( - scanner: _qrScanner, - child: MaterialApp( - title: 'SinalACS Paciente', - debugShowCheckedModeBanner: false, - theme: buildPatientTheme(), - home: const PatientLoginScreen(), + child: UpcomingDocumentsScope( + documents: widget.upcomingDocuments ?? upcomingLegalDocuments, + child: QrScannerScope( + scanner: _qrScanner, + child: MaterialApp( + title: 'SinalACS Paciente', + debugShowCheckedModeBanner: false, + theme: buildPatientTheme(), + home: const PatientLoginScreen(), + ), ), ), ), @@ -150,6 +158,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 @@ -1089,13 +1112,30 @@ class _PatientHomeShellState extends State { // 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) - TermsChangeNoticeCard( - notice: _notice!, - onDismiss: () => setState(() => _noticeDismissed = true), - onRead: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const LegalDocumentsScreen()), - ), - ), + 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), ], ), diff --git a/apps/patient/lib/app/legal_screens.dart b/apps/patient/lib/app/legal_screens.dart index e8bd603f..09bc517f 100644 --- a/apps/patient/lib/app/legal_screens.dart +++ b/apps/patient/lib/app/legal_screens.dart @@ -9,19 +9,27 @@ import 'package:sinalacs_patient/core/network/backend_scope.dart'; /// documento, três toques (painel de privacidade em até 3 cliques, /// LGPD-RF03). class LegalDocumentsScreen extends StatelessWidget { - const LegalDocumentsScreen({super.key}); + 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: const Text('Privacidade e termos')), + 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 const [ - ('legal_open_privacy', privacyPolicy), - ('legal_open_terms', termsOfUse), + for (final (key, document) in [ + ('legal_open_privacy', upcoming?.privacy ?? privacyPolicy), + ('legal_open_terms', upcoming?.terms ?? termsOfUse), ]) Card( child: ListTile( @@ -33,7 +41,9 @@ class LegalDocumentsScreen extends StatelessWidget { ), title: Text(document.title), subtitle: Text( - 'Versão ${document.version} · vigente desde ${document.effectiveDate}', + 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( diff --git a/apps/patient/lib/core/legal/legal_documents.dart b/apps/patient/lib/core/legal/legal_documents.dart index b1500374..3087e392 100644 --- a/apps/patient/lib/core/legal/legal_documents.dart +++ b/apps/patient/lib/core/legal/legal_documents.dart @@ -67,6 +67,28 @@ class LegalDocument { 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 troque `upcomingTermsChange` no backend, 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', diff --git a/apps/patient/lib/core/legal/terms_change_notice_card.dart b/apps/patient/lib/core/legal/terms_change_notice_card.dart index c4a5110d..64c1278c 100644 --- a/apps/patient/lib/core/legal/terms_change_notice_card.dart +++ b/apps/patient/lib/core/legal/terms_change_notice_card.dart @@ -11,13 +11,19 @@ class TermsChangeNoticeCard extends StatelessWidget { required this.notice, required this.onRead, required this.onDismiss, + this.onReadNew, }); final TermsChangeNotice notice; final VoidCallback onRead; final VoidCallback onDismiss; - static String _date(DateTime value) { + /// 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}'; @@ -37,10 +43,13 @@ class TermsChangeNoticeCard extends StatelessWidget { children: [ const Icon(Icons.info_outline, color: PatientColors.accentOnSurface), const SizedBox(width: 8), - const Expanded( - child: Text( - 'Os termos vão mudar', - style: TextStyle(fontWeight: FontWeight.bold), + Expanded( + child: Semantics( + header: true, + child: const Text( + 'Os termos vão mudar', + style: TextStyle(fontWeight: FontWeight.bold), + ), ), ), IconButton( @@ -52,9 +61,18 @@ class TermsChangeNoticeCard extends StatelessWidget { ], ), Text( - 'A versão ${notice.version} passa a valer em ${_date(notice.effectiveFrom)}. ' + '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( diff --git a/apps/patient/test/contrast_tokens_test.dart b/apps/patient/test/contrast_tokens_test.dart index da28e0e4..fa880fa0 100644 --- a/apps/patient/test/contrast_tokens_test.dart +++ b/apps/patient/test/contrast_tokens_test.dart @@ -11,12 +11,15 @@ void main() { const normalText = 4.5; const largeTextOrUi = 3.0; - const cases = <(String, Color, Color, double)>[ + // 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 = buildPatientTheme().colorScheme; + final cases = <(String, Color, Color, double)>[ ('branco sobre scaffold', Colors.white, PatientColors.background, normalText), ('branco sobre card', Colors.white, PatientColors.surfaceRaised, normalText), // Achado A do relatório antigo era falso positivo: passa com folga. ('white54 sobre card (rodapé da triagem)', Colors.white54, PatientColors.surfaceRaised, normalText), - ('yellow #E0A800 sobre card (risco amarelo)', Color(0xFFE0A800), PatientColors.surfaceRaised, normalText), + ('yellow #E0A800 sobre card (risco amarelo)', const Color(0xFFE0A800), PatientColors.surfaceRaised, normalText), // Variantes de texto que o app usa em vez do fill puro (ver os testes de // documentação abaixo para a prova de que o fill sozinho falha). ('dangerOnSurface sobre card', PatientColors.dangerOnSurface, PatientColors.surfaceRaised, normalText), @@ -24,6 +27,9 @@ void main() { ('accentOnSurface sobre card', PatientColors.accentOnSurface, PatientColors.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, PatientColors.surfaceRaised, normalText), + ('TextButton do cartão de aviso (primary) sobre card', scheme.primary, PatientColors.surfaceRaised, normalText), + ('ícone de fechar do cartão de aviso (onSurfaceVariant) sobre card', scheme.onSurfaceVariant, PatientColors.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), ]; diff --git a/apps/patient/test/terms_change_notice_test.dart b/apps/patient/test/terms_change_notice_test.dart index 607130b2..3bc5ceac 100644 --- a/apps/patient/test/terms_change_notice_test.dart +++ b/apps/patient/test/terms_change_notice_test.dart @@ -4,6 +4,7 @@ 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/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'; @@ -15,6 +16,30 @@ final _aviso = TermsChangeNotice( 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: privacyPolicy.history, + ), + terms: LegalDocument( + id: 'terms', + title: 'Termo de Uso', + version: versao, + effectiveDate: '20/10/2026', + summary: termsOfUse.summary, + sections: termsOfUse.sections, + history: 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'); @@ -134,4 +159,42 @@ void main() { 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('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/upcoming_legal_documents_test.dart b/apps/patient/test/upcoming_legal_documents_test.dart new file mode 100644 index 00000000..52874d2d --- /dev/null +++ b/apps/patient/test/upcoming_legal_documents_test.dart @@ -0,0 +1,50 @@ +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); +} + +/// Passa pelo tipo declarado: o analisador enxerga a constante como `null` fixo. +UpcomingLegalDocuments? _atual() => upcomingLegalDocuments; + +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(); + 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); + } + }); +} From b70f5b54f72ab8122ec8b45a2f1f4dfe78ed649b Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 29 Sep 2026 21:13:52 -0400 Subject: [PATCH 083/498] docs: registra os menores do RF14 fechados e o texto novo dos termos Co-Authored-By: Claude Sonnet 5.5 --- PROGRESS.md | 19 +++++++++++++++++++ apps/CLAUDE.md | 2 +- backend/CLAUDE.md | 2 +- spec/lgpd_data_audit.md | 4 ++-- spec/lgpd_design.md | 4 ++-- 5 files changed, 25 insertions(+), 6 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 0f02330a..1dbe3955 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1216,3 +1216,22 @@ Plano: `docs/superpowers/plans/2026-09-29-menores-do-push-e-aviso-de-mudanca-dos - 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. + diff --git a/apps/CLAUDE.md b/apps/CLAUDE.md index b778ac27..001f4a98 100644 --- a/apps/CLAUDE.md +++ b/apps/CLAUDE.md @@ -32,7 +32,7 @@ The RPC trusts a development CA that is **separate** from the broker's: `scripts 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). -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 native Kotlin/Swift side does not exist yet**, so the channel is silent and the app degrades to no push. 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. `PushTokenScope.maybeOf` falls back to no push instead of throwing. In the ACS app, "Avisos à comunidade" (`NoticesScreen`) calls `AcsBackend.sendNotice` → `notices.sendSegmented`. +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 native Kotlin/Swift side does not exist yet**, so the channel is silent and the app degrades to no push. 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`. diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 12b8093f..3f4c109c 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -65,7 +65,7 @@ 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 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); 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, `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, sem I/O e sem auditoria; a agenda é a constante `upcomingTermsChange` em `terms_change_schedule.dart`, hoje `null`, 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, troque a constante e só na vigência mude `consentPolicyVersion` e `legalDocumentsVersion`), `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), `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 +**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 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); 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 (estourar é "resultado desconhecido", não "tente de novo", 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, sem I/O e sem auditoria; a agenda é a constante `upcomingTermsChange` em `terms_change_schedule.dart`, hoje `null`, 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), 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 ú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 diff --git a/spec/lgpd_data_audit.md b/spec/lgpd_data_audit.md index 60302252..5b265e25 100644 --- a/spec/lgpd_data_audit.md +++ b/spec/lgpd_data_audit.md @@ -108,8 +108,8 @@ A tabela abaixo consolida o mapeamento exaustivo de dados persistidos pelo backe | | `token` | `text` | Identificador de aparelho | Emitido pelo FCM/APNs | Identifica um aparelho, não uma pessoa; junto de `userId` liga os dois. Índice único: o mesmo token nunca tem dois donos. No máximo 10 linhas por titular (`maxPushTokensPerUser`). | | | `platform` | `text` | Metadado Técnico | `android` \| `ios` | Escolhe o provedor do envio. | | | `createdAt` / `updatedAt` | `timestamp without time zone` | Metadado Técnico | Relógio do servidor | Primeiro registro e última confirmação do token. | -| | (`audit_logs`) | — | Metadado Técnico | `resourceType = push_token` | Só a troca de dono de um token de push (`result = granted`): o token nunca é gravado na trilha. | -| | (`audit_logs`) | — | Metadado Técnico | `resourceType = community_notice` | Uma linha por aviso comunitário enviado: `userId` do ACS, `resourceId` = microárea, `result` = `granted` ou `no_recipients`. O texto do aviso nunca é gravado. | +| | (`audit_logs`) | — | Metadado Técnico | `resourceType = push_token` | Só a troca de dono de um token de push: uma linha com o `userId` do novo dono (`result = granted`) e outra com o do dono anterior (`result = lost`), para quem perdeu o vínculo sem agir também ter rastro. O token nunca é gravado na trilha. | +| | (`audit_logs`) | — | Metadado Técnico | `resourceType = community_notice` | Uma linha por aviso comunitário enviado: `userId` do ACS, `resourceId` = microárea, `result` = `granted` (algum aparelho aceitou), `not_delivered` (nenhum aceitou), `unknown` (o relé não respondeu a tempo) ou `no_recipients`. O texto do aviso nunca é gravado. | | **audit_logs** | `id` | `uuid` | Pseudonimizado | UUID v4 (`gen_random_uuid()`) | Identificador do registro de auditoria (LGPD-RF11). | | | `userId` | `uuid` | Pseudonimizado | Chave estrangeira (`users.id`) | Identifica o operador que executou a ação auditada. | | | `actionType` | `text` | Metadado Técnico | Enum textual (`READ`, `WRITE`, `DELETE`, etc.) | Operação registrada. | diff --git a/spec/lgpd_design.md b/spec/lgpd_design.md index 4b66f717..fe1aed7c 100644 --- a/spec/lgpd_design.md +++ b/spec/lgpd_design.md @@ -260,7 +260,7 @@ como um item separado a lembrar depois. | **Conteúdo Mínimo** | Regras de uso, responsabilidades, proibições, propriedade intelectual, limitação de responsabilidade, jurisdição, alterações no termo. | | **Critério de Aceite** | ✓ Texto em linguagem simples (acessibilidade para idosos e baixo letramento)
✓ Disponibilizado no app e no site
✓ Aceite explícito no cadastro
✓ Controle de versões disponível para consulta | -> **Estado (2026-09-29):** implementado no app paciente — ver PROGRESS.md "QR Code do onboarding e documentos legais". Pacientes que entram por OTP sem onboarding são convidados a aceitar no primeiro login (`patients.acceptTermsOfUse`), e de novo quando a versão mudar; o convite não é obrigatório para acessar o alerta de emergência. Pendentes: revisão jurídica do texto. O aviso com 15 dias de antecedência existe (`TermsChangeSchedule`, `patients.termsChangeNotice`, cartão dispensável na home; agenda vazia). +> **Estado (2026-09-29):** implementado no app paciente — ver PROGRESS.md "QR Code do onboarding e documentos legais". Pacientes que entram por OTP sem onboarding são convidados a aceitar no primeiro login (`patients.acceptTermsOfUse`), e de novo quando a versão mudar; o convite não é obrigatório para acessar o alerta de emergência. Pendentes: revisão jurídica do texto. O aviso com 15 dias de antecedência existe (`TermsChangeSchedule`, `patients.termsChangeNotice`, cartão dispensável na home, fora da aba de urgência; agenda vazia. Quando o app já embarca o texto da versão anunciada, o cartão também oferece lê-lo antes de valer). ### LGPD-RF19 - Política de Privacidade @@ -272,7 +272,7 @@ como um item separado a lembrar depois. | **Conteúdo Mínimo** | Identificação do controlador, dados coletados (por funcionalidade), finalidades específicas, bases legais, compartilhamento, transferências, retenção, direitos, medidas de segurança, DPO/encarregado. | | **Critério de Aceite** | ✓ Linguagem acessível para leigos (recomendado: Nível de leitura 8º ano)
✓ Tópicos claros e organizados
✓ Versão resumida (sumário visual) e versão completa
✓ Atualização comunicada com no mínimo 15 dias de antecedência | -> **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. O aviso com 15 dias de antecedência existe (`TermsChangeSchedule`, `patients.termsChangeNotice`, cartão dispensável na home; agenda vazia) e o reaceite a cada nova versão também (`hasAcceptedCurrentTerms`). +> **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. O aviso com 15 dias de antecedência existe (`TermsChangeSchedule`, `patients.termsChangeNotice`, cartão dispensável na home, fora da aba de urgência; agenda vazia. Quando o app já embarca o texto da versão anunciada, o cartão também oferece lê-lo antes de valer) e o reaceite a cada nova versão também (`hasAcceptedCurrentTerms`). ### LGPD-RF20 - Aviso de Consentimento (Banner/Modal) From 2d38bcc623d7a03d6210aa7082e474f4d7c6310b Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 29 Sep 2026 21:21:47 -0400 Subject: [PATCH 084/498] =?UTF-8?q?fix(paciente):=20detalhe=20do=20texto?= =?UTF-8?q?=20novo=20n=C3=A3o=20se=20diz=20vigente;=20leitor=20da=20agenda?= =?UTF-8?q?=20n=C3=A3o=20trata=20'n=C3=A3o=20entendi'=20como=20'sem=20agen?= =?UTF-8?q?da'=20(LGPD-RF18)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Achados do revisor final. Co-Authored-By: Claude Sonnet 5.5 --- PROGRESS.md | 3 + apps/patient/lib/app/legal_screens.dart | 27 +++++++-- .../test/terms_change_notice_test.dart | 40 ++++++++++++- .../test/upcoming_legal_documents_test.dart | 56 +++++++++++++++---- spec/lgpd_data_audit.md | 2 +- 5 files changed, 109 insertions(+), 19 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 1dbe3955..580175c4 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1234,4 +1234,7 @@ Plano: `docs/superpowers/plans/2026-09-30-menores-do-rf14-e-texto-novo-dos-termo - 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. +- 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`. diff --git a/apps/patient/lib/app/legal_screens.dart b/apps/patient/lib/app/legal_screens.dart index 09bc517f..c8470788 100644 --- a/apps/patient/lib/app/legal_screens.dart +++ b/apps/patient/lib/app/legal_screens.dart @@ -48,7 +48,10 @@ class LegalDocumentsScreen extends StatelessWidget { trailing: const Icon(Icons.chevron_right), onTap: () => Navigator.of(context).push( MaterialPageRoute( - builder: (_) => LegalDocumentScreen(document: document), + builder: (_) => LegalDocumentScreen( + document: document, + effectiveLabel: upcoming == null ? null : (effectiveLabel ?? document.effectiveDate), + ), ), ), ), @@ -64,10 +67,24 @@ class LegalDocumentsScreen extends StatelessWidget { /// 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}); + 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( @@ -77,7 +94,9 @@ class LegalDocumentScreen extends StatelessWidget { padding: const EdgeInsets.all(16), children: [ Text( - 'Versão ${document.version} · vigente desde ${document.effectiveDate}', + 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: const TextStyle(color: Colors.white70), ), @@ -136,7 +155,7 @@ class LegalDocumentScreen extends StatelessWidget { ListTile( contentPadding: EdgeInsets.zero, title: Text( - '${entry.version} · ${entry.version == document.version ? 'vigente desde' : 'de'} ${entry.date}', + '${entry.version} · ${_historyVerb(entry)} ${_historyDate(entry)}', ), subtitle: Text(entry.changes), ), diff --git a/apps/patient/test/terms_change_notice_test.dart b/apps/patient/test/terms_change_notice_test.dart index 3bc5ceac..9b049ab4 100644 --- a/apps/patient/test/terms_change_notice_test.dart +++ b/apps/patient/test/terms_change_notice_test.dart @@ -25,7 +25,10 @@ UpcomingLegalDocuments _textoNovoDe(String versao) => UpcomingLegalDocuments( effectiveDate: '20/10/2026', summary: privacyPolicy.summary, sections: privacyPolicy.sections, - history: privacyPolicy.history, + history: [ + LegalVersion(version: versao, date: '20/10/2026', changes: 'Texto novo.'), + ...privacyPolicy.history, + ], ), terms: LegalDocument( id: 'terms', @@ -34,7 +37,10 @@ UpcomingLegalDocuments _textoNovoDe(String versao) => UpcomingLegalDocuments( effectiveDate: '20/10/2026', summary: termsOfUse.summary, sections: termsOfUse.sections, - history: termsOfUse.history, + history: [ + LegalVersion(version: versao, date: '20/10/2026', changes: 'Texto novo.'), + ...termsOfUse.history, + ], ), ); @@ -172,6 +178,36 @@ void main() { 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 diff --git a/apps/patient/test/upcoming_legal_documents_test.dart b/apps/patient/test/upcoming_legal_documents_test.dart index 52874d2d..9efcc6b1 100644 --- a/apps/patient/test/upcoming_legal_documents_test.dart +++ b/apps/patient/test/upcoming_legal_documents_test.dart @@ -3,30 +3,62 @@ 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`. +/// 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) { - 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); + 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() { - test('o leitor devolve null com a agenda vazia e a versão quando há agenda', () { - expect(backendUpcomingVersion('final TermsChangeSchedule? upcomingTermsChange = null;'), isNull); + 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( - "final TermsChangeSchedule? upcomingTermsChange = TermsChangeSchedule(version: '2026.2', publishedAt: x);", - ), + 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', diff --git a/spec/lgpd_data_audit.md b/spec/lgpd_data_audit.md index 5b265e25..e511b694 100644 --- a/spec/lgpd_data_audit.md +++ b/spec/lgpd_data_audit.md @@ -108,7 +108,7 @@ A tabela abaixo consolida o mapeamento exaustivo de dados persistidos pelo backe | | `token` | `text` | Identificador de aparelho | Emitido pelo FCM/APNs | Identifica um aparelho, não uma pessoa; junto de `userId` liga os dois. Índice único: o mesmo token nunca tem dois donos. No máximo 10 linhas por titular (`maxPushTokensPerUser`). | | | `platform` | `text` | Metadado Técnico | `android` \| `ios` | Escolhe o provedor do envio. | | | `createdAt` / `updatedAt` | `timestamp without time zone` | Metadado Técnico | Relógio do servidor | Primeiro registro e última confirmação do token. | -| | (`audit_logs`) | — | Metadado Técnico | `resourceType = push_token` | Só a troca de dono de um token de push: uma linha com o `userId` do novo dono (`result = granted`) e outra com o do dono anterior (`result = lost`), para quem perdeu o vínculo sem agir também ter rastro. O token nunca é gravado na trilha. | +| | (`audit_logs`) | — | Metadado Técnico | `resourceType = push_token` | Só a troca de dono de um token de push: uma linha com o `userId` do novo dono (`result = granted`) e outra com o do dono anterior (`result = lost`), para quem perdeu o vínculo sem agir também ter rastro. **Atenção à leitura:** na linha `lost`, `userId` é o titular **afetado**, não o autor da ação (quem agiu foi o novo dono); um relatório "o que o titular X fez" precisa excluir `result = lost`. As duas linhas gravadas no mesmo instante ligam os dois titulares como donos do mesmo aparelho. Sem consentimento, o registro recusado também apaga o vínculo do dono anterior e **não** grava rastro (lacuna conhecida). O token nunca é gravado na trilha. | | | (`audit_logs`) | — | Metadado Técnico | `resourceType = community_notice` | Uma linha por aviso comunitário enviado: `userId` do ACS, `resourceId` = microárea, `result` = `granted` (algum aparelho aceitou), `not_delivered` (nenhum aceitou), `unknown` (o relé não respondeu a tempo) ou `no_recipients`. O texto do aviso nunca é gravado. | | **audit_logs** | `id` | `uuid` | Pseudonimizado | UUID v4 (`gen_random_uuid()`) | Identificador do registro de auditoria (LGPD-RF11). | | | `userId` | `uuid` | Pseudonimizado | Chave estrangeira (`users.id`) | Identifica o operador que executou a ação auditada. | From 3c3d6bef0aab6f125ea9fee8472dccc0edd9b71d Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Wed, 30 Sep 2026 11:35:44 -0400 Subject: [PATCH 085/498] =?UTF-8?q?chore:=20ignora=20google-services.json?= =?UTF-8?q?=20(credencial=20do=20projeto=20Firebase,=20n=C3=A3o=20versiona?= =?UTF-8?q?da)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 5601e59e..509f714f 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,7 @@ apps/patient/assets/certs/ # 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 From 69810c195a095846470f4441b0d4eadbe6272b44 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Wed, 30 Sep 2026 11:35:44 -0400 Subject: [PATCH 086/498] docs: plano do Gorush com FCM e teste ponta a ponta no emulador Co-Authored-By: Claude Sonnet 5.5 --- .../2026-09-30-gorush-fcm-e2e-emulador.md | 537 ++++++++++++++++++ 1 file changed, 537 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-30-gorush-fcm-e2e-emulador.md 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). From f1c7fb91a5eb22623269e68abb497572739da578 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Wed, 30 Sep 2026 11:40:19 -0400 Subject: [PATCH 087/498] =?UTF-8?q?fix(infra):=20Gorush=201.22.0=20fixada,?= =?UTF-8?q?=20iOS=20desligado,=20log=20vis=C3=ADvel=20e=20token=20sem=20m?= =?UTF-8?q?=C3=A1scara;=20resposta=20real=20do=20FCM=20vira=20teste=20de?= =?UTF-8?q?=20contrato=20(RF14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Medido contra o FCM real: o token volta mascarado (a poda nunca casava), a string de erro do FCM v1 não era reconhecida e ios.enabled sem chave APNs derrubava o boot. Co-Authored-By: Claude Sonnet 5.5 --- .../infrastructure/push/gorush_client.dart | 15 ++++-- .../unit/fixtures/gorush_invalid_token.json | 16 ++++++ .../fixtures/gorush_invalid_token_masked.json | 16 ++++++ .../test/unit/gorush_client_test.dart | 50 +++++++++++++++++++ docker-compose.yml | 2 +- infra/docker/gorush/README.md | 43 ++++++++++------ infra/docker/gorush/config.yml | 15 ++++-- scripts/qa/gorush_smoke.sh | 38 ++++++++++++++ 8 files changed, 171 insertions(+), 24 deletions(-) create mode 100644 backend/sinalacs_server/test/unit/fixtures/gorush_invalid_token.json create mode 100644 backend/sinalacs_server/test/unit/fixtures/gorush_invalid_token_masked.json create mode 100755 scripts/qa/gorush_smoke.sh diff --git a/backend/sinalacs_server/lib/src/infrastructure/push/gorush_client.dart b/backend/sinalacs_server/lib/src/infrastructure/push/gorush_client.dart index 30a21211..75001715 100644 --- a/backend/sinalacs_server/lib/src/infrastructure/push/gorush_client.dart +++ b/backend/sinalacs_server/lib/src/infrastructure/push/gorush_client.dart @@ -77,7 +77,9 @@ class GorushClient implements PushSender { 'notregistered', 'unregistered', 'invalidregistration', - 'requested entity was not found', // FCM v1 (firebase-admin-go) + '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', ]; @@ -104,7 +106,7 @@ class GorushClient implements PushSender { }); try { - return await _post(body, targets.length).timeout(_timeout); + return await _post(body, targets.length, {for (final t in targets) t.token}).timeout(_timeout); } on PushGatewayException { rethrow; } on TimeoutException { @@ -127,7 +129,7 @@ class GorushClient implements PushSender { } } - Future _post(String body, int total) async { + Future _post(String body, int total, Set sent) async { final request = await _http.postUrl(Uri.parse('$_baseUrl/api/push')); request.headers.contentType = ContentType.json; request.write(body); @@ -152,7 +154,12 @@ class GorushClient implements PushSender { failed++; final error = '${log['error']}'.toLowerCase(); final token = log['token']; - if (token is String && _invalidTokenErrors.any(error.contains)) invalid.add(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 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 index e0e90f89..f5dd9582 100644 --- a/backend/sinalacs_server/test/unit/gorush_client_test.dart +++ b/backend/sinalacs_server/test/unit/gorush_client_test.dart @@ -216,6 +216,56 @@ void main() { ); }); + 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); diff --git a/docker-compose.yml b/docker-compose.yml index d860c285..5d1134d7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -85,7 +85,7 @@ services: # 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:latest + image: appleboy/gorush:1.22.0 profiles: [push] restart: unless-stopped command: ["-c", "/config.yml"] diff --git a/infra/docker/gorush/README.md b/infra/docker/gorush/README.md index a48a0c5f..9aae40c4 100644 --- a/infra/docker/gorush/README.md +++ b/infra/docker/gorush/README.md @@ -19,19 +19,30 @@ Nada disso é versionado nem gerado por `bootstrap_env.sh` (a decisão §3.2 fal "padrão `bootstrap_env.sh`" para o que for segredo aleatório; credenciais de provedor não são aleatórias, são emitidas por ele). -## O que ainda não foi verificado contra um Gorush real - -O cliente do backend foi provado só contra um servidor HTTP falso. Confira, antes de -ligar em produção: (1) se `counts` e `logs` da resposta síncrona têm o formato assumido; -(2) se o `log.hide_token` padrão mascara o token na resposta — nesse caso a poda de -tokens inválidos não casa com `push_tokens.token`; (3) as strings de erro do FCM v1 e do -APNs; (4) se o Gorush sobe sem credenciais (com o `restart: unless-stopped`, pode entrar -em loop); (5) fixe uma tag em vez de `:latest`. - -## Ligar o backend - -No `.env`: `GORUSH_URL=http://gorush:8088`. Vazio desliga o envio: o backend sobe -normalmente e `notices.sendSegmented` recusa com uma mensagem clara. - -O `config.yml` deste diretório usa a imagem `appleboy/gorush`; fixe a tag testada -no `docker-compose.yml` e confira os nomes das chaves no README dela. +## O que foi verificado contra o Gorush 1.22.0 e o FCM reais (2026-09-30) + +Com a chave real do projeto `sinal-acs` e um token **falso** (nada é entregue a ninguém; +`scripts/qa/gorush_smoke.sh`): + +- **A chave e a API FCM V1 funcionam:** o FCM respondeu com um erro de *token* (`The + registration token is not a valid FCM registration token`), não de autenticação. +- **Forma da resposta:** HTTP 200, `{"counts": 1, "logs": [{"type": "failed-push", + "platform": "android", "token": …, "message": "(message redacted)", "error": …}], + "success": "ok"}`. **`counts` conta notificações enfileiradas, não entregas** (vale `1` + mesmo com a falha): o backend calcula "aceitos" como alvos menos falhas. +- **`log.hide_token` (padrão `true`) mascara o token na resposta** (`***…xx`): com isso a + poda de tokens inválidos nunca casaria com `push_tokens.token`. O `config.yml` define + `hide_token: false`; o custo é o token de aparelho aparecer no log do Gorush. O backend + só apaga um token que ele mesmo enviou. +- **`ios.enabled: true` sem `apns-key.p8` faz o Gorush encerrar no boot com código 1**, e + `log.access_log`/`error_log` com `"-"` faz o Gorush não escrever log nenhum (o erro do + boot fica invisível): o `config.yml` usa `ios.enabled: false` e `stdout`/`stderr`. +- **Imagem:** fixada em `appleboy/gorush:1.22.0`, que já traz `HEALTHCHECK` + (`/bin/gorush --ping`) e roda como `gorush` (uid 1000). A chave deve ter modo `600` e ser + legível por esse uid; se o uid do dono da chave for outro, use `chown`/grupo, não `644`. + +## O que ainda não foi verificado + +- Entrega real a um aparelho (Task 4 do plano `2026-09-30-gorush-fcm-e2e-emulador.md`). +- iOS/APNs: sem app iOS e sem chave APNs. +- A string de erro de outros casos do FCM (cota, mensagem malformada) além do token inválido. diff --git a/infra/docker/gorush/config.yml b/infra/docker/gorush/config.yml index 373f4b71..df92c21e 100644 --- a/infra/docker/gorush/config.yml +++ b/infra/docker/gorush/config.yml @@ -8,16 +8,25 @@ android: enabled: true credential: "" key_path: /credentials/fcm-service-account.json +# iOS fica desligado até existir app iOS e chave APNs: com `enabled: true` e sem o +# arquivo `apns-key.p8` o Gorush encerra no boot com código 1 (medido na 1.22.0). ios: - enabled: true + enabled: false key_path: /credentials/apns-key.p8 key_id: "" team_id: "" production: false +# `stdout`/`stderr`, e não "-": com "-" o Gorush 1.22.0 não escreve log nenhum e o +# motivo de uma falha de boot fica invisível (medido). log: # O texto do aviso não deve ir para `docker logs`: o backend já não o grava # na auditoria, e o relé não pode ser o ponto onde ele vaza. hide_messages: true + # `false` de propósito: com o padrão (`true`) o Gorush devolve o token MASCARADO + # (`***…xx`) na resposta e a poda de tokens inválidos nunca casa com + # `push_tokens.token` (medido contra o FCM real). Custo: o token de aparelho + # aparece no log do Gorush; ele não liga a nenhuma pessoa sem o banco. + hide_token: false format: json - access_log: "-" - error_log: "-" + access_log: stdout + error_log: stderr diff --git a/scripts/qa/gorush_smoke.sh b/scripts/qa/gorush_smoke.sh new file mode 100755 index 00000000..d2ed30f5 --- /dev/null +++ b/scripts/qa/gorush_smoke.sh @@ -0,0 +1,38 @@ +#!/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; o token é falso, então nada é entregue. +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.$$" From 84de261da193ad7ca9b0e6698e97095dd6d97fa0 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Wed, 30 Sep 2026 11:44:36 -0400 Subject: [PATCH 088/498] =?UTF-8?q?feat(paciente):=20lado=20nativo=20Andro?= =?UTF-8?q?id=20do=20token=20FCM,=20com=20o=20plugin=20do=20Google=20Servi?= =?UTF-8?q?ces=20s=C3=B3=20quando=20h=C3=A1=20google-services.json=20(RF14?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provado no emulador: token FCM real com o arquivo; sem ele o APK compila e o app degrada sem push. Co-Authored-By: Claude Sonnet 5.5 --- apps/patient/android/app/build.gradle.kts | 8 +++++ .../android/app/src/main/AndroidManifest.xml | 4 +++ .../prismrr/sinalacs/patient/MainActivity.kt | 33 ++++++++++++++++++- apps/patient/android/settings.gradle.kts | 1 + .../push_native_token_test.dart | 24 ++++++++++++++ 5 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 apps/patient/integration_test/push_native_token_test.dart 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 33f24dfb..e322058d 100644 --- a/apps/patient/android/app/src/main/AndroidManifest.xml +++ b/apps/patient/android/app/src/main/AndroidManifest.xml @@ -16,6 +16,10 @@ obrigatória para instalar — sem ela, a pessoa digita o código. --> + + + 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/push_native_token_test.dart b/apps/patient/integration_test/push_native_token_test.dart new file mode 100644 index 00000000..03b3202f --- /dev/null +++ b/apps/patient/integration_test/push_native_token_test.dart @@ -0,0 +1,24 @@ +/// 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'; + +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 :'); + }); +} From c8605ba6f30f865bbd69813fc825ce9c3f2f2eac Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Wed, 30 Sep 2026 11:45:34 -0400 Subject: [PATCH 089/498] test(e2e): registro real do token FCM e ferramenta de envio do ACS (RF14) Co-Authored-By: Claude Sonnet 5.5 --- apps/acs/tool/send_notice.dart | 70 +++++++++++++++++++ .../integration_test/push_register_test.dart | 54 ++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 apps/acs/tool/send_notice.dart create mode 100644 apps/patient/integration_test/push_register_test.dart diff --git a/apps/acs/tool/send_notice.dart b/apps/acs/tool/send_notice.dart new file mode 100644 index 00000000..e285013c --- /dev/null +++ b/apps/acs/tool/send_notice.dart @@ -0,0 +1,70 @@ +/// 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. 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'; + +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 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 { + await backend.developmentLogin(role: 'acs'); + 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/patient/integration_test/push_register_test.dart b/apps/patient/integration_test/push_register_test.dart new file mode 100644 index 00000000..dfe50327 --- /dev/null +++ b/apps/patient/integration_test/push_register_test.dart @@ -0,0 +1,54 @@ +/// 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'; + +Future?> _devRpcCaBytes() async { + try { + final data = await rootBundle.load(BackendConfig.rpcCaAsset); + return data.buffer.asUint8List(); + } catch (_) { + return null; + } +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + test('registra no backend o token FCM real do aparelho, com consentimento', () 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 backend.developmentLogin(role: 'patient'); + + 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 + }); +} From 9fabc1c8e35ef2b03eb73bc1d9078e12816a9291 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Wed, 30 Sep 2026 11:59:21 -0400 Subject: [PATCH 090/498] =?UTF-8?q?test(e2e):=20aviso=20do=20ACS=20chega?= =?UTF-8?q?=20ao=20emulador=20pelo=20Gorush=20e=20FCM=20reais;=20conex?= =?UTF-8?q?=C3=A3o=20com=20tempo=20pr=C3=B3prio=20(RF14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provado no emulador-5554: backend -> Gorush 1.22.0 -> FCM -> bandeja. Casos negativos observados: token falso podado, ios mantido, Gorush parado com a mensagem certa (o estouro antes de conectar não é 'resultado desconhecido') e revogação sem destinatários. Co-Authored-By: Claude Sonnet 5.5 --- .../integration_test/push_register_test.dart | 17 +- apps/patient/tool/push_consent.dart | 50 ++++++ .../infrastructure/push/gorush_client.dart | 25 ++- .../test/unit/gorush_client_test.dart | 31 ++++ scripts/qa/push_e2e.sh | 154 ++++++++++++++++++ 5 files changed, 272 insertions(+), 5 deletions(-) create mode 100644 apps/patient/tool/push_consent.dart create mode 100755 scripts/qa/push_e2e.sh diff --git a/apps/patient/integration_test/push_register_test.dart b/apps/patient/integration_test/push_register_test.dart index dfe50327..d44ffb8c 100644 --- a/apps/patient/integration_test/push_register_test.dart +++ b/apps/patient/integration_test/push_register_test.dart @@ -21,10 +21,18 @@ Future?> _devRpcCaBytes() async { } } +/// 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'); + void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - test('registra no backend o token FCM real do aparelho, com consentimento', () async { + test('registra no backend o token FCM real do aparelho, com consentimento', 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}). ' @@ -50,5 +58,12 @@ void main() { 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/tool/push_consent.dart b/apps/patient/tool/push_consent.dart new file mode 100644 index 00000000..7cf6a43d --- /dev/null +++ b/apps/patient/tool/push_consent.dart @@ -0,0 +1,50 @@ +/// 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'; + +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 backend.developmentLogin(role: 'patient'); + 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/backend/sinalacs_server/lib/src/infrastructure/push/gorush_client.dart b/backend/sinalacs_server/lib/src/infrastructure/push/gorush_client.dart index 75001715..f47aa820 100644 --- a/backend/sinalacs_server/lib/src/infrastructure/push/gorush_client.dart +++ b/backend/sinalacs_server/lib/src/infrastructure/push/gorush_client.dart @@ -54,13 +54,23 @@ abstract interface class PushSender { /// 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, HttpClient? httpClient}) - : _baseUrl = baseUrl, + 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]. @@ -106,7 +116,8 @@ class GorushClient implements PushSender { }); try { - return await _post(body, targets.length, {for (final t in targets) t.token}).timeout(_timeout); + return await _post(body, targets.length, {for (final t in targets) t.token}) + .timeout(_timeout); } on PushGatewayException { rethrow; } on TimeoutException { @@ -130,7 +141,13 @@ class GorushClient implements PushSender { } Future _post(String body, int total, Set sent) async { - final request = await _http.postUrl(Uri.parse('$_baseUrl/api/push')); + 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(); diff --git a/backend/sinalacs_server/test/unit/gorush_client_test.dart b/backend/sinalacs_server/test/unit/gorush_client_test.dart index f5dd9582..e181ba1e 100644 --- a/backend/sinalacs_server/test/unit/gorush_client_test.dart +++ b/backend/sinalacs_server/test/unit/gorush_client_test.dart @@ -55,6 +55,19 @@ class FakeGorush { 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': []}); @@ -156,6 +169,24 @@ void main() { ); }); + 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; diff --git a/scripts/qa/push_e2e.sh b/scripts/qa/push_e2e.sh new file mode 100755 index 00000000..131d78ea --- /dev/null +++ b/scripts/qa/push_e2e.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +# +# Ponta a ponta do RF14 no emulador: o ACS envia um aviso e ele chega à bandeja do +# aparelho, passando por backend -> Gorush -> FCM. +# +# ./scripts/qa/push_e2e.sh # caminho feliz +# ./scripts/qa/push_e2e.sh --negativos # + token falso/ios, revogação e Gorush parado +# +# Pré-requisitos: emulador `emulator-5554` (Google Play, com internet), a chave da conta +# de serviço em infra/docker/gorush/credentials/fcm-service-account.json e o +# google-services.json em apps/patient/android/app/. NÃO roda na CI: precisa de +# credenciais reais do projeto Firebase. Sai com 3 quando falta a chave. +# +# Nunca imprime a chave nem o token FCM. +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" + +negativos=0 +for arg in "$@"; do + case "$arg" in + --negativos) negativos=1 ;; + *) echo "argumento desconhecido: $arg" >&2; exit 2 ;; + esac +done + +# -- guarda da chave --------------------------------------------------------- +key=infra/docker/gorush/credentials/fcm-service-account.json +instrucao='No console do Firebase: Configurações do projeto > Contas de serviço > Gerar nova chave privada. +Salve em infra/docker/gorush/credentials/fcm-service-account.json (modo 600) e confira que a API +"Firebase Cloud Messaging API (V1)" está ativada no projeto.' +[[ -f "$key" ]] || { echo "FALTA $key"; echo "$instrucao"; exit 3; } +git check-ignore -q "$key" || { echo "a chave NÃO está ignorada pelo git"; exit 3; } +[[ "$(stat -c %a "$key")" == 600 ]] || { echo "o modo da chave deve ser 600 (chmod 600 $key)"; exit 3; } +python3 - <<'PYEOF' || exit 3 +import json, sys +try: + k = json.load(open('infra/docker/gorush/credentials/fcm-service-account.json')) + g = json.load(open('apps/patient/android/app/google-services.json')) +except FileNotFoundError as e: + print('arquivo ausente:', e.filename); sys.exit(1) +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 + +# O ambiente do shell pode trazer um GORUSH_CREDENTIALS_DIR que é um ARQUIVO (o Compose +# o prioriza sobre o .env e montaria o arquivo como /credentials: o Gorush cai no boot). +if [[ -n "${GORUSH_CREDENTIALS_DIR:-}" && "$GORUSH_CREDENTIALS_DIR" != "$repo_root/infra/docker/gorush/credentials" ]]; then + echo "aviso: ignorando GORUSH_CREDENTIALS_DIR=$GORUSH_CREDENTIALS_DIR do ambiente (usa o diretório do repositório)" +fi +export GORUSH_CREDENTIALS_DIR="$repo_root/infra/docker/gorush/credentials" +export GORUSH_URL=http://gorush:8088 + +pg_user="$(grep '^POSTGRES_USER=' .env | cut -d= -f2)" +pg_db="$(grep '^POSTGRES_DB=' .env | cut -d= -f2)" +psql_q() { docker exec sinalacs-postgres psql -U "$pg_user" -d "$pg_db" -Atc "$1"; } +app=br.com.prismrr.sinalacs.patient +dev=emulator-5554 +adb -s "$dev" get-state >/dev/null 2>&1 || { echo "emulador $dev não encontrado (adb devices)"; exit 4; } + +# -- stack ------------------------------------------------------------------- +echo "== stack com o perfil push" +docker compose --profile push up -d --build >/dev/null 2>&1 +for _ in $(seq 1 60); do + [[ "$(docker compose --profile push ps serverpod gorush --format '{{.Status}}' 2>/dev/null | grep -c healthy)" -ge 2 ]] && break + sleep 3 +done +[[ "$(docker compose --profile push ps serverpod gorush --format '{{.Status}}' 2>/dev/null | grep -c healthy)" -ge 2 ]] \ + || { echo 'erro: serverpod e gorush não ficaram saudáveis'; docker compose --profile push ps; exit 1; } + +adb -s "$dev" reverse tcp:8443 tcp:443 >/dev/null +hold_pid="" +cleanup() { + [[ -n "$hold_pid" ]] && kill "$hold_pid" 2>/dev/null || true + docker compose --profile push start gorush >/dev/null 2>&1 || true + psql_q 'delete from push_tokens' >/dev/null 2>&1 || true +} +trap cleanup EXIT + +send() { ( cd apps/acs && dart run tool/send_notice.dart --title "SinalACS e2e" --message "$1" 2>&1 | head -2 | tr '\n' ' '; echo "rc=${PIPESTATUS[0]}" ); } + +# -- registro real, segurando o app instalado ---------------------------------- +# `flutter test` desinstala o app ao terminar e o token FCM morre junto: o teste segura +# o app por PUSH_HOLD_SECONDS depois de registrar, e o envio acontece nesse intervalo. +echo "== registro do token FCM real (segura o app por 240 s)" +psql_q 'delete from push_tokens' >/dev/null +( cd apps/patient && flutter test integration_test/push_register_test.dart -d "$dev" \ + --dart-define=SINALACS_HOST=https://localhost:8443/ --dart-define=PUSH_HOLD_SECONDS=240 \ + > /tmp/push_e2e_hold.txt 2>&1 ) & +hold_pid=$! +for _ in $(seq 1 120); do + [[ "$(psql_q 'select count(*) from push_tokens' 2>/dev/null | head -1)" -ge 1 ]] && break + sleep 2 +done +[[ "$(psql_q 'select count(*) from push_tokens' | head -1)" -ge 1 ]] \ + || { echo 'erro: o token não foi registrado (veja /tmp/push_e2e_hold.txt)'; exit 1; } +echo "tokens no banco: $(psql_q 'select count(*) from push_tokens' | head -1)" + +adb -s "$dev" shell pm grant "$app" android.permission.POST_NOTIFICATIONS >/dev/null 2>&1 || true +adb -s "$dev" shell cmd notification cancel_all >/dev/null 2>&1 || true +# FCM não mostra mensagem de notificação na bandeja com o app em primeiro plano. +adb -s "$dev" shell input keyevent KEYCODE_HOME +sleep 3 + +# -- caminho feliz -------------------------------------------------------------- +echo "== envio do ACS" +saida="$(send 'Teste do Gorush')" +echo "$saida" +grep -q 'recipients=1 accepted=1' <<<"$saida" || { echo 'FALHOU: esperado recipients=1 accepted=1'; exit 1; } +sleep 10 +dump="$(adb -s "$dev" shell dumpsys notification --noredact 2>/dev/null)" +grep -A30 "pkg=$app" <<<"$dump" | grep -q 'SinalACS e2e' || { echo 'FALHOU: título não está na bandeja'; exit 1; } +grep -A30 "pkg=$app" <<<"$dump" | grep -q 'Teste do Gorush' || { echo 'FALHOU: texto não está na bandeja'; exit 1; } +[[ "$(psql_q "select result from audit_logs where \"resourceType\"='community_notice' order by timestamp desc limit 1" | head -1)" == granted ]] \ + || { echo 'FALHOU: auditoria não é granted'; exit 1; } +echo 'caminho feliz: OK' + +# -- casos negativos ------------------------------------------------------------- +if [[ "$negativos" -eq 1 ]]; then + echo "== token falso (android) + token ios, com o real vivo" + for spec in "repeat('x',150)|android" "repeat('i',64)|ios"; do + psql_q "insert into push_tokens (\"userId\",\"microAreaId\",token,platform,\"createdAt\",\"updatedAt\") select \"userId\",\"microAreaId\",${spec%%|*},'${spec##*|}',now(),now() from push_tokens limit 1" >/dev/null + done + saida="$(send 'Mistura real falso ios')"; echo "$saida"; sleep 8 + [[ "$(psql_q "select count(*) from push_tokens where token=repeat('x',150)" | head -1)" == 0 ]] || { echo 'FALHOU: o token falso não foi podado'; exit 1; } + [[ "$(psql_q "select count(*) from push_tokens where platform='ios'" | head -1)" == 1 ]] || { echo 'FALHOU: a linha ios foi apagada'; exit 1; } + adb -s "$dev" shell dumpsys notification --noredact 2>/dev/null | grep -q 'Mistura real falso ios' || { echo 'FALHOU: o token real não recebeu'; exit 1; } + echo 'token falso podado, ios mantido, token real recebeu: OK' + + echo "== Gorush parado, com tokens no banco" + docker compose --profile push stop gorush >/dev/null 2>&1 + antes="$(psql_q 'select count(*) from push_tokens' | head -1)" + saida="$(send 'Gorush parado')"; echo "$saida" + grep -q 'rc=2' <<<"$saida" || { echo 'FALHOU: esperado rc=2'; exit 1; } + grep -q 'Tente de novo' <<<"$saida" || { echo 'FALHOU: a mensagem deveria mandar tentar de novo'; exit 1; } + [[ "$(psql_q 'select count(*) from push_tokens' | head -1)" == "$antes" ]] || { echo 'FALHOU: algum token foi apagado'; exit 1; } + docker compose --profile push start gorush >/dev/null 2>&1 + echo 'Gorush parado: OK' + + echo "== revogar zera os destinatários" + ( cd apps/patient && dart run tool/push_consent.dart --revoke 2>&1 | head -1 ) + [[ "$(psql_q 'select count(*) from push_tokens' | head -1)" == 0 ]] || { echo 'FALHOU: a revogação não apagou os tokens'; exit 1; } + saida="$(send 'Depois de revogar')"; echo "$saida" + grep -q 'recipients=0' <<<"$saida" || { echo 'FALHOU: esperado recipients=0'; exit 1; } + ( cd apps/patient && dart run tool/push_consent.dart --grant 2>&1 | head -1 ) + echo 'revogação: OK' +fi + +echo +echo 'OK — o aviso chegou ao emulador' From 24d7329d200b6d5b4446676335fda91493a49db3 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Wed, 30 Sep 2026 12:02:51 -0400 Subject: [PATCH 091/498] =?UTF-8?q?docs:=20registra=20o=20que=20foi=20prov?= =?UTF-8?q?ado=20contra=20o=20Gorush=20e=20o=20FCM=20reais=20no=20emulador?= =?UTF-8?q?=20e=20o=20que=20n=C3=A3o=20foi=20(RF14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- PROGRESS.md | 37 +++++++++++++++---- apps/CLAUDE.md | 2 +- backend/CLAUDE.md | 2 +- ...26-09-16-decisoes-produto-pos-validacao.md | 7 +++- infra/docker/gorush/README.md | 28 +++++++++++++- spec/PRD_system.md | 2 +- spec/stack.md | 9 ++--- 7 files changed, 68 insertions(+), 19 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 580175c4..13a962d5 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1094,9 +1094,9 @@ O que **não** foi feito, de propósito: `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, mas nenhum aviso chega a um aparelho ainda**: - `notices.sendSegmented` respeita o consentimento, mas não há Gorush hospedado, credenciais - FCM/APNs nem lado nativo do token (RF14, ver "RF14: envio de avisos segmentados com Gorush"). +- **`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) @@ -1161,7 +1161,7 @@ Plano: `docs/superpowers/plans/2026-09-29-menores-adiados-e-push-do-paciente.md` - ~~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". Continuam pendentes o Gorush hospedado, as credenciais FCM/APNs e o lado nativo do token (§3.2, revisado em 2026-09-29). +- 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). @@ -1189,9 +1189,9 @@ Plano: `docs/superpowers/plans/2026-09-29-rf14-gorush-avisos-segmentados.md`, br - **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 está provado nem pronto:** -- **Nenhum teste fala com um Gorush ou com o FCM/APNs de verdade.** O cliente é provado contra um servidor HTTP falso; nunca se viu um push chegar a um aparelho. -- **O lado nativo do canal (Kotlin/Swift) não existe:** o app do paciente nunca obtém um token real, então `push_tokens` só tem linhas de teste. +**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`. - O `async` do app do paciente subiu de 2.11.0 para 2.13.1 por causa do Riverpod 3. @@ -1238,3 +1238,26 @@ Plano: `docs/superpowers/plans/2026-09-30-menores-do-rf14-e-texto-novo-dos-termo **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). diff --git a/apps/CLAUDE.md b/apps/CLAUDE.md index 001f4a98..483d3041 100644 --- a/apps/CLAUDE.md +++ b/apps/CLAUDE.md @@ -32,7 +32,7 @@ The RPC trusts a development CA that is **separate** from the broker's: `scripts 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). -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 native Kotlin/Swift side does not exist yet**, so the channel is silent and the app degrades to no push. 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 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**. `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. 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`. diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 3f4c109c..7ad7b8b7 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -65,7 +65,7 @@ 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 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); 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 (estourar é "resultado desconhecido", não "tente de novo", 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, sem I/O e sem auditoria; a agenda é a constante `upcomingTermsChange` em `terms_change_schedule.dart`, hoje `null`, 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), 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 +**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 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); 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, sem I/O e sem auditoria; a agenda é a constante `upcomingTermsChange` em `terms_change_schedule.dart`, hoje `null`, 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), 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 ú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 diff --git a/docs/superpowers/specs/2026-09-16-decisoes-produto-pos-validacao.md b/docs/superpowers/specs/2026-09-16-decisoes-produto-pos-validacao.md index 3b066e8c..6119452a 100644 --- a/docs/superpowers/specs/2026-09-16-decisoes-produto-pos-validacao.md +++ b/docs/superpowers/specs/2026-09-16-decisoes-produto-pos-validacao.md @@ -226,8 +226,11 @@ específico já decidido em §2. (`devices.registerPushToken`, tabela `push_tokens`, só com o consentimento `segmentedPush` vigente), o backend envia (`notices.sendSegmented`, cliente do Gorush, restrito ao ACS — o perfil admin não existe ainda) e a tela de avisos do -ACS chama esse endpoint. Falta hospedar o Gorush, as credenciais FCM/APNs e o -lado nativo do token no app. Nenhum push real foi visto chegando a um aparelho. +ACS chama esse endpoint. **Provado em 2026-09-30 no emulador Android** (projeto +Firebase `sinal-acs`, Gorush 1.22.0 no Compose local): o aviso do ACS chega à +bandeja do aparelho, com o lado nativo Android do token implementado. Faltam +iOS/APNs, um teste em aparelho físico e hospedar o Gorush fora do Compose local +(ver PROGRESS.md, "RF14: Gorush e FCM provados no emulador"). **Decisão aprovada (revisada em 2026-09-29: Gorush no lugar de um SDK/console Firebase como ponto de integração):** diff --git a/infra/docker/gorush/README.md b/infra/docker/gorush/README.md index 9aae40c4..f1948482 100644 --- a/infra/docker/gorush/README.md +++ b/infra/docker/gorush/README.md @@ -41,8 +41,32 @@ Com a chave real do projeto `sinal-acs` e um token **falso** (nada é entregue a (`/bin/gorush --ping`) e roda como `gorush` (uid 1000). A chave deve ter modo `600` e ser legível por esse uid; se o uid do dono da chave for outro, use `chown`/grupo, não `644`. +## Entrega real, observada no emulador Android (2026-09-30) + +`scripts/qa/push_e2e.sh --negativos` (precisa da chave e do emulador; não roda na CI): + +- Um aviso do ACS chega à **bandeja** do aparelho com o título e o texto enviados; o FCM + devolve `NotRegistered` para o token de um app desinstalado (já na lista de erros + reconhecidos), e o token é podado. +- Token falso (150 caracteres) é podado; uma linha `ios` com `ios.enabled: false` **não é + apagada** e **não derruba** o Android. `accepted` **superconta** tokens `ios` nessa + situação (o Gorush os descarta sem log). +- Gorush parado: o nome `gorush` não resolve e a conexão fica pendurada; o backend agora + tem tempo de conexão próprio (2 s) e responde "inacessível, tente de novo" em ~3 s, sem + apagar token algum. Um estouro **depois** de o pedido sair continua sendo "resultado + desconhecido". +- Sem a permissão `POST_NOTIFICATIONS` (Android 13+) o token registra mas o aviso não + aparece; com o app em primeiro plano o FCM não mostra a mensagem de notificação. +- **Cuidado com `GORUSH_CREDENTIALS_DIR`:** precisa ser um **diretório**. O ambiente do shell + prevalece sobre o `.env`; um valor apontando para um arquivo monta esse arquivo como + `/credentials` e o Gorush cai no boot. +- **`hide_token: false` imprime tokens de aparelho em `docker logs`.** Restrinja o acesso ao + log do Gorush. + ## O que ainda não foi verificado -- Entrega real a um aparelho (Task 4 do plano `2026-09-30-gorush-fcm-e2e-emulador.md`). -- iOS/APNs: sem app iOS e sem chave APNs. +- iOS/APNs: sem app iOS e sem chave APNs (`ios.enabled: false`). +- Aparelho físico, celular bloqueado, economia de bateria e aparelho sem Play Services. +- Renovação do token (`onNewToken`) e abrir uma tela ao tocar na notificação. - A string de erro de outros casos do FCM (cota, mensagem malformada) além do token inválido. +- Gorush hospedado fora do Compose local. diff --git a/spec/PRD_system.md b/spec/PRD_system.md index c8962c9f..4f346e23 100644 --- a/spec/PRD_system.md +++ b/spec/PRD_system.md @@ -170,7 +170,7 @@ documento de decisão, não implementação: | RF10 (mapa) | Geocélula arredondada, não posição exata (§1) | Não | | RF02 + LGPD-RF02 (onboarding/consentimento) | Token de convite de uso único + consentimento por finalidade (§2) | Não | | RF06 (lembretes) | Local ao dispositivo, sem endpoint (§3.1) | Não | -| RF14 (avisos push) | Contrato com Gorush definido (§3.2, revisado 2026-09-29); lado do paciente e envio prontos em código (`devices.registerPushToken`, `push_tokens`, `PushTokenSource`, `notices.sendSegmented`, tela de avisos do ACS); Gorush hospedado, credenciais e lado nativo do token pendentes | **Parcial** — falta hospedar o Gorush e provisionar credenciais FCM/APNs | +| RF14 (avisos push) | Contrato com Gorush definido (§3.2, revisado 2026-09-29); lado do paciente e envio prontos em código (`devices.registerPushToken`, `push_tokens`, `PushTokenSource`, `notices.sendSegmented`, tela de avisos do ACS); entrega provada no emulador Android (2026-09-30); iOS/APNs, aparelho físico e Gorush hospedado fora do Compose local pendentes | **Parcial** — Android provado em emulador; faltam iOS/APNs e hospedagem | | RF12 (geofencing) | Geofence atrelado a visita ativa, sem rastreamento contínuo (§4) | Parcial — submissão à loja pendente | | RF15 (sync central→dispositivo) | Pull incremental por cursor (§5) | Não | | RNF03 (criptografia Postgres) | AES-256-GCM em nível de aplicação (§6) | Não | diff --git a/spec/stack.md b/spec/stack.md index c13c7af1..18e3f9da 100644 --- a/spec/stack.md +++ b/spec/stack.md @@ -44,8 +44,7 @@ onboarding e consentimento (§2, RF02) e a metade central→dispositivo da sincronização (§5, RF15 — pull incremental; a leitura fica do lado do ACS, não há geração de mudança do lado do paciente ainda). Lembretes locais (§3.1, RF06) também têm implementação no app do paciente. Ainda **não** -implementados de ponta a ponta: push segmentado (§3.2, RF14 — o código do envio existe, com Gorush; falta hospedá-lo e -provisionar as credenciais FCM/APNs) e geofencing (§4, RF12 — só o contrato de dados +implementados de ponta a ponta: push segmentado (§3.2, RF14 — provado no emulador Android em 2026-09-30, com Gorush; faltam iOS/APNs, aparelho físico e hospedar o Gorush fora do Compose local) e geofencing (§4, RF12 — só o contrato de dados `arrivalMethod` foi desenhado, sem o serviço de geofence em primeiro plano): * **Criptografia de colunas no PostgreSQL (RNF03/INV-04, §6 — implementada):** @@ -66,9 +65,9 @@ provisionar as credenciais FCM/APNs) e geofencing (§4, RF12 — só o contrato restrita a quem consentiu (`segmentedPush`); o backend entrega a lista de tokens ao Gorush, que fala com o FCM/APNs. O app do paciente adota `flutter_riverpod` **somente** na captura e no registro do token - (`pushTokenSourceProvider`); o resto da injeção segue por `InheritedWidget`. O Gorush ainda precisa de credencial FCM (Android) e chave - APNs (iOS), então a pendência passa de "projeto Firebase" para "hospedar o - Gorush e provisionar essas credenciais" — decisão de infra, não de código. + (`pushTokenSourceProvider`); o resto da injeção segue por `InheritedWidget`. O Gorush precisa de credencial FCM (Android, já usada no teste) e chave + APNs (iOS, ainda inexistente): a pendência é hospedar o Gorush e provisionar a + chave APNs — decisão de infra, não de código. * **Geofencing (RF12):** rejeitado rastreamento contínuo em segundo plano do ACS; adotado geofence único atrelado a uma visita ativa, com serviço em primeiro plano e notificação persistente, para evitar a política mais From d5ecd739ffc130f0032fea789907dc7873e9231b Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Wed, 30 Sep 2026 12:15:29 -0400 Subject: [PATCH 092/498] =?UTF-8?q?fix(paciente):=20auto-init=20do=20FCM?= =?UTF-8?q?=20desligado=20e=20testes=20de=20push=20s=C3=B3=20com=20PUSH=5F?= =?UTF-8?q?E2E;=20docs=20do=20LGPD=20corrigidas=20(RF14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Achados do revisor final. O auto-init contatava o Google em toda abertura do app, antes do login e sem consentimento. Os testes de push exigem credenciais e quebrariam e2e.sh --full para quem não as tem. Co-Authored-By: Claude Sonnet 5.5 --- PROGRESS.md | 11 ++++++-- apps/CLAUDE.md | 2 +- .../android/app/src/main/AndroidManifest.xml | 5 ++++ .../push_native_token_test.dart | 8 +++++- .../integration_test/push_register_test.dart | 8 +++++- .../test/android_push_manifest_test.dart | 25 ++++++++++++++++++ scripts/qa/push_e2e.sh | 2 +- spec/lgpd_design.md | 26 +++++++++++-------- 8 files changed, 70 insertions(+), 17 deletions(-) create mode 100644 apps/patient/test/android_push_manifest_test.dart diff --git a/PROGRESS.md b/PROGRESS.md index 13a962d5..556fe581 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1192,7 +1192,7 @@ Plano: `docs/superpowers/plans/2026-09-29-rf14-gorush-avisos-segmentados.md`, br **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`. +- 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. @@ -1233,7 +1233,7 @@ Plano: `docs/superpowers/plans/2026-09-30-menores-do-rf14-e-texto-novo-dos-termo - 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. +- ~~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`. @@ -1261,3 +1261,10 @@ Plano: `docs/superpowers/plans/2026-09-30-gorush-fcm-e2e-emulador.md`, branch `f - 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). diff --git a/apps/CLAUDE.md b/apps/CLAUDE.md index 483d3041..dbc205eb 100644 --- a/apps/CLAUDE.md +++ b/apps/CLAUDE.md @@ -32,7 +32,7 @@ The RPC trusts a development CA that is **separate** from the broker's: `scripts 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). -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**. `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. 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 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. The app still asks FCM for the token after every login even without `segmentedPush` consent (the server refuses and does not store it) — a known LGPD gap, see `spec/lgpd_design.md`. `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`. diff --git a/apps/patient/android/app/src/main/AndroidManifest.xml b/apps/patient/android/app/src/main/AndroidManifest.xml index e322058d..71e8fbb7 100644 --- a/apps/patient/android/app/src/main/AndroidManifest.xml +++ b/apps/patient/android/app/src/main/AndroidManifest.xml @@ -60,6 +60,11 @@ + + ?> _devRpcCaBytes() async { /// `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', timeout: const Timeout(Duration(minutes: 6)), () async { + 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}). ' 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/scripts/qa/push_e2e.sh b/scripts/qa/push_e2e.sh index 131d78ea..001cfff1 100755 --- a/scripts/qa/push_e2e.sh +++ b/scripts/qa/push_e2e.sh @@ -89,7 +89,7 @@ send() { ( cd apps/acs && dart run tool/send_notice.dart --title "SinalACS e2e" echo "== registro do token FCM real (segura o app por 240 s)" psql_q 'delete from push_tokens' >/dev/null ( cd apps/patient && flutter test integration_test/push_register_test.dart -d "$dev" \ - --dart-define=SINALACS_HOST=https://localhost:8443/ --dart-define=PUSH_HOLD_SECONDS=240 \ + --dart-define=SINALACS_HOST=https://localhost:8443/ --dart-define=PUSH_HOLD_SECONDS=240 --dart-define=PUSH_E2E=1 \ > /tmp/push_e2e_hold.txt 2>&1 ) & hold_pid=$! for _ in $(seq 1 120); do diff --git a/spec/lgpd_design.md b/spec/lgpd_design.md index fe1aed7c..89ae3b44 100644 --- a/spec/lgpd_design.md +++ b/spec/lgpd_design.md @@ -67,17 +67,21 @@ locais quando o consentimento espelhado no aparelho (`core/consent/consent_preferences.dart`) é `true`, com padrão de recusa (`false`) quando não há registro local. -**Aviso — `ConsentPurpose.segmentedPush` continua sem leitor.** RF14 (avisos -segmentados por push) não tem nenhum código de envio no repositório ainda — -tem hoje um leitor: `notices.sendSegmented` (RF14) consulta a linha de -consentimento mais recente de cada titular antes de montar a lista de -destinatários. O envio real ainda depende de hospedar o Gorush e provisionar -credenciais FCM/APNs (§3.2 do mesmo documento de decisões). Não há -o que "respeitar" hoje porque nada envia. Quando `notices.sendSegmented` for -implementado, ele **deve** consultar o consentimento de `segmentedPush` -antes de enviar, com o mesmo padrão de recusa por omissão adotado aqui para -`localReminders` — tratar isso como parte da implementação de RF14, não -como um item separado a lembrar depois. +**`ConsentPurpose.segmentedPush` tem leitor, com uma lacuna no aparelho.** RF14 +(avisos segmentados por push) respeita o consentimento no **servidor**: +`notices.sendSegmented` usa a linha de consentimento **mais recente** de cada +titular (não a existência do token) antes de montar a lista, e +`devices.registerPushToken` só guarda o token com o consentimento vigente. O envio +foi provado de ponta a ponta num emulador Android em 2026-09-30 (ver PROGRESS.md, +"RF14: Gorush e FCM provados no emulador"). + +**Lacuna:** no **aparelho**, o app ainda pede o token ao FCM depois de todo login, +mesmo sem consentimento (o servidor recusa e não o guarda, mas o aparelho já falou +com o Google). O auto-init do FCM, que o faria em toda abertura do app, antes do +login, está desligado no manifesto. Fechar a lacuna exige que o app conheça o +consentimento antes de pedir o token (espelho local, como `localReminders`), o que +não foi feito. O texto do aviso também passa pelo FCM (Google): decisão de produto da +§3.2, não tratada aqui. ### LGPD-RF03 - Gerenciamento de Preferências de Privacidade From bfec72c004da0e0392d23faf1b1604a877969180 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Wed, 30 Sep 2026 16:28:28 -0400 Subject: [PATCH 093/498] =?UTF-8?q?fix(paciente):=20token=20de=20push=20s?= =?UTF-8?q?=C3=B3=20=C3=A9=20pedido=20ao=20provedor=20com=20consentimento?= =?UTF-8?q?=20de=20avisos=20vigente=20(RF14,=20LGPD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- PROGRESS.md | 9 + apps/CLAUDE.md | 2 +- apps/patient/lib/app/app.dart | 2 +- .../lib/core/push/push_token_source.dart | 22 +- apps/patient/test/patient_app_mvp_test.dart | 3 +- apps/patient/test/push_registration_test.dart | 71 +++- apps/patient/test/push_riverpod_test.dart | 4 +- .../test/support/fake_patient_backend.dart | 19 + .../2026-09-30-finalizacao-app-paciente.md | 354 ++++++++++++++++++ spec/lgpd_design.md | 17 +- 10 files changed, 488 insertions(+), 15 deletions(-) create mode 100644 docs/superpowers/plans/2026-09-30-finalizacao-app-paciente.md diff --git a/PROGRESS.md b/PROGRESS.md index 556fe581..f6ad8689 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1268,3 +1268,12 @@ Plano: `docs/superpowers/plans/2026-09-30-gorush-fcm-e2e-emulador.md`, branch `f - 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` consulta `myData` antes de perguntar o token ao FCM e fecha na dúvida (sem consentimento vigente, falha ou mais de 3 s). Conceder em "Meus dados" registra sem nova leitura. Fecha a lacuna LGPD de "pede o token depois de todo login". +- **Custo:** `myData` grava uma linha de auditoria de leitura por login (só com fonte de token real). Alternativa: endpoint leve `hasGrantedConsent`. +- Testes do paciente: 230. diff --git a/apps/CLAUDE.md b/apps/CLAUDE.md index dbc205eb..b67f08c2 100644 --- a/apps/CLAUDE.md +++ b/apps/CLAUDE.md @@ -32,7 +32,7 @@ The RPC trusts a development CA that is **separate** from the broker's: `scripts 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). -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. The app still asks FCM for the token after every login even without `segmentedPush` consent (the server refuses and does not store it) — a known LGPD gap, see `spec/lgpd_design.md`. `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 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 `myData` shows `segmentedPush` currently granted (3 s ceiling, fail closed; skipped when the source is `NoPushTokenSource`; a grant made in "Meus dados" passes `consentKnownGranted: true`). `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`. diff --git a/apps/patient/lib/app/app.dart b/apps/patient/lib/app/app.dart index f3a1662f..1e6a74e5 100644 --- a/apps/patient/lib/app/app.dart +++ b/apps/patient/lib/app/app.dart @@ -1867,7 +1867,7 @@ class _MyDataScreenState extends State { try { final record = await backend.updateConsent(purpose: purpose, granted: granted); if (purpose == ConsentPurpose.segmentedPush && granted && mounted) { - unawaited(registerPushDevice(backend, PushTokenScope.maybeOf(context))); + 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 diff --git a/apps/patient/lib/core/push/push_token_source.dart b/apps/patient/lib/core/push/push_token_source.dart index eec24e45..8830fd74 100644 --- a/apps/patient/lib/core/push/push_token_source.dart +++ b/apps/patient/lib/core/push/push_token_source.dart @@ -1,4 +1,8 @@ +import 'dart:async'; + import 'package:flutter/widgets.dart'; +import 'package:sinalacs_client/sinalacs_client.dart' show ConsentPurpose; +import 'package:sinalacs_patient/core/consent/consent_decisions.dart'; import 'package:sinalacs_patient/core/network/backend_client.dart'; /// Aparelho apto a receber push: o token do provedor e a plataforma. @@ -33,8 +37,24 @@ class NoPushTokenSource implements PushTokenSource { /// 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. -Future registerPushDevice(PatientBackend backend, PushTokenSource source) async { +const _consentLookupTimeout = Duration(seconds: 3); + +Future registerPushDevice( + PatientBackend backend, + PushTokenSource source, { + bool consentKnownGranted = false, +}) async { + // Sem fonte de token não há o que perguntar ao provedor, e a consulta ao + // painel (`myData`) grava uma linha de auditoria de leitura a cada login. + 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 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); diff --git a/apps/patient/test/patient_app_mvp_test.dart b/apps/patient/test/patient_app_mvp_test.dart index f34e4ee2..d9d79df8 100644 --- a/apps/patient/test/patient_app_mvp_test.dart +++ b/apps/patient/test/patient_app_mvp_test.dart @@ -1308,9 +1308,10 @@ void main() { final backend = FakePatientBackend()..myDataResult = overview(); await tester.pumpWidget(SinalAcsApp(backend: backend)); await login(tester); + final depoisDoLogin = backend.myDataCallCount; // o login consulta o consentimento de avisos await openMyData(tester); - expect(backend.myDataCallCount, 1); + expect(backend.myDataCallCount - depoisDoLogin, 1); expect(find.text('Fulano de Tal'), findsOneWidget); expect(find.textContaining('10/03/1975'), findsOneWidget); expect(find.textContaining('Ciclana, (11) 90000-0000'), findsOneWidget); diff --git a/apps/patient/test/push_registration_test.dart b/apps/patient/test/push_registration_test.dart index 1e2fe8f6..fda382f3 100644 --- a/apps/patient/test/push_registration_test.dart +++ b/apps/patient/test/push_registration_test.dart @@ -32,7 +32,7 @@ Future login(WidgetTester tester) async { void main() { testWidgets('depois do login registra o token do aparelho', (tester) async { - final backend = FakePatientBackend(); + final backend = FakePatientBackend()..grantPushConsent(); await tester.pumpWidget(SinalAcsApp(backend: backend, pushTokens: const _FakeSource(_aparelho))); await login(tester); @@ -50,6 +50,7 @@ void main() { 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); @@ -78,6 +79,64 @@ void main() { 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', 'revoked', DateTime.utc(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() + ..grantPushConsent() + ..myDataFailure = 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('myData que nunca responde não atrasa a home e nada é pedido', (tester) async { + final backend = FakePatientBackend()..grantPushConsent(); + final gate = Completer(); + backend.myDataGate = 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)); + expect(source.calls, 0); + }); + + 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) { @@ -88,6 +147,16 @@ void main() { }); } +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'); diff --git a/apps/patient/test/push_riverpod_test.dart b/apps/patient/test/push_riverpod_test.dart index f1d36011..fdfcdecf 100644 --- a/apps/patient/test/push_riverpod_test.dart +++ b/apps/patient/test/push_riverpod_test.dart @@ -38,7 +38,7 @@ void _mockCanal(Future Function(MethodCall call)? handler) { void main() { testWidgets('o provider entrega o token da fonte e o registro chega ao backend', (tester) async { - final backend = FakePatientBackend(); + final backend = FakePatientBackend()..grantPushConsent(); await tester.pumpWidget(ProviderScope( overrides: [ pushTokenSourceProvider @@ -61,7 +61,7 @@ void main() { }); testWidgets('a fonte injetada por parâmetro tem precedência sobre o provider', (tester) async { - final backend = FakePatientBackend(); + final backend = FakePatientBackend()..grantPushConsent(); await tester.pumpWidget(ProviderScope( overrides: [ pushTokenSourceProvider diff --git a/apps/patient/test/support/fake_patient_backend.dart b/apps/patient/test/support/fake_patient_backend.dart index dd506aa3..8bdff3c3 100644 --- a/apps/patient/test/support/fake_patient_backend.dart +++ b/apps/patient/test/support/fake_patient_backend.dart @@ -57,6 +57,24 @@ class FakePatientBackend implements PatientBackend { BackendFailure? myDataFailure; int myDataCallCount = 0; + /// Definido, segura [myData] até ser completado — backend lento ou pendurado. + Completer? myDataGate; + + /// 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})>[]; @@ -363,6 +381,7 @@ class FakePatientBackend implements PatientBackend { @override Future myData() async { myDataCallCount++; + await myDataGate?.future; final failure = myDataFailure; if (failure != null) throw failure; return myDataResult; 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/spec/lgpd_design.md b/spec/lgpd_design.md index 89ae3b44..5416d8e1 100644 --- a/spec/lgpd_design.md +++ b/spec/lgpd_design.md @@ -67,7 +67,7 @@ locais quando o consentimento espelhado no aparelho (`core/consent/consent_preferences.dart`) é `true`, com padrão de recusa (`false`) quando não há registro local. -**`ConsentPurpose.segmentedPush` tem leitor, com uma lacuna no aparelho.** RF14 +**`ConsentPurpose.segmentedPush` tem leitor, no servidor e no aparelho.** RF14 (avisos segmentados por push) respeita o consentimento no **servidor**: `notices.sendSegmented` usa a linha de consentimento **mais recente** de cada titular (não a existência do token) antes de montar a lista, e @@ -75,13 +75,14 @@ titular (não a existência do token) antes de montar a lista, e foi provado de ponta a ponta num emulador Android em 2026-09-30 (ver PROGRESS.md, "RF14: Gorush e FCM provados no emulador"). -**Lacuna:** no **aparelho**, o app ainda pede o token ao FCM depois de todo login, -mesmo sem consentimento (o servidor recusa e não o guarda, mas o aparelho já falou -com o Google). O auto-init do FCM, que o faria em toda abertura do app, antes do -login, está desligado no manifesto. Fechar a lacuna exige que o app conheça o -consentimento antes de pedir o token (espelho local, como `localReminders`), o que -não foi feito. O texto do aviso também passa pelo FCM (Google): decisão de produto da -§3.2, não tratada aqui. +**Aparelho (2026-09-30):** o app só pede o token ao FCM depois de `patients.myData` +mostrar a decisão vigente de `segmentedPush` como `granted` (teto de 3 s; falha, +estouro ou "nunca decidiu" = não pergunta). Ao conceder em "Meus dados" o registro +segue direto, sem nova leitura. O auto-init do FCM, que falaria com o Google em toda +abertura, está desligado no manifesto. Custo aceito: `myData` grava uma linha de +auditoria de leitura por login de quem tem fonte de token; um endpoint leve de +consentimento evitaria isso. O texto do aviso também passa pelo FCM (Google): +decisão de produto da §3.2, não tratada aqui. ### LGPD-RF03 - Gerenciamento de Preferências de Privacidade From c4f1c7c1e135667656ff5b3ccc35281e739e054e Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Wed, 30 Sep 2026 16:35:43 -0400 Subject: [PATCH 094/498] docs: registra a bateria e2e do paciente no emulador 5554 e o que segue aberto Co-Authored-By: Claude Sonnet 5.5 --- PROGRESS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/PROGRESS.md b/PROGRESS.md index f6ad8689..6fd8ff71 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1277,3 +1277,5 @@ Plano: `docs/superpowers/plans/2026-09-30-finalizacao-app-paciente.md`, branch ` - `registerPushDevice` consulta `myData` antes de perguntar o token ao FCM e fecha na dúvida (sem consentimento vigente, falha ou mais de 3 s). Conceder em "Meus dados" registra sem nova leitura. Fecha a lacuna LGPD de "pede o token depois de todo login". - **Custo:** `myData` grava uma linha de auditoria de leitura por login (só com fonte de token real). Alternativa: endpoint leve `hasGrantedConsent`. - Testes do paciente: 230. +- **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, MFA/refresh token, menores deferidos do RF14, endpoint leve de consentimento (evita a auditoria de leitura por login). From 1168873f1b7b0f784b030de85914f579caeffb35 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Wed, 30 Sep 2026 17:05:14 -0400 Subject: [PATCH 095/498] =?UTF-8?q?fix(paciente,backend):=20consulta=20lev?= =?UTF-8?q?e=20de=20consentimento=20(patients.hasGrantedConsent)=20no=20lu?= =?UTF-8?q?gar=20de=20myData=20no=20login;=20achados=20da=20revis=C3=A3o?= =?UTF-8?q?=20independente=20(RF14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O login lia o painel inteiro (dossiê decifrado + linha de auditoria de leitura) só para decidir se pedia o token ao FCM. O endpoint novo devolve um bool, sem auditoria. Também: onboarding só registra com a caixa marcada, testes do teto de 3 s e de conceder sem nova consulta, docs corrigidas. Co-Authored-By: Claude Sonnet 5.5 --- PROGRESS.md | 6 +-- apps/CLAUDE.md | 2 +- apps/patient/lib/app/app.dart | 7 ++- .../lib/core/network/backend_client.dart | 14 ++++++ .../lib/core/push/push_token_source.dart | 29 +++++++----- apps/patient/test/onboarding_flow_test.dart | 44 +++++++++++++++++++ apps/patient/test/patient_app_mvp_test.dart | 6 ++- apps/patient/test/push_registration_test.dart | 25 ++++++++--- .../test/support/fake_patient_backend.dart | 20 +++++++-- backend/CLAUDE.md | 2 +- .../lib/src/protocol/client.dart | 16 +++++++ .../patients/data_subject_rights_service.dart | 10 +++++ .../lib/src/endpoints/patients_endpoint.dart | 20 +++++++++ .../lib/src/generated/endpoints.dart | 25 +++++++++++ .../lib/src/generated/protocol.yaml | 1 + .../data_subject_rights_endpoint_test.dart | 31 +++++++++++++ .../test_tools/serverpod_test_tools.dart | 35 +++++++++++++++ .../data_subject_rights_service_test.dart | 36 +++++++++++++++ spec/lgpd_design.md | 23 ++++++---- 19 files changed, 315 insertions(+), 37 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 6fd8ff71..f1c6780c 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1274,8 +1274,8 @@ Plano: `docs/superpowers/plans/2026-09-30-gorush-fcm-e2e-emulador.md`, branch `f Plano: `docs/superpowers/plans/2026-09-30-finalizacao-app-paciente.md`, branch `fix/patient`. -- `registerPushDevice` consulta `myData` antes de perguntar o token ao FCM e fecha na dúvida (sem consentimento vigente, falha ou mais de 3 s). Conceder em "Meus dados" registra sem nova leitura. Fecha a lacuna LGPD de "pede o token depois de todo login". -- **Custo:** `myData` grava uma linha de auditoria de leitura por login (só com fonte de token real). Alternativa: endpoint leve `hasGrantedConsent`. -- Testes do paciente: 230. +- `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, MFA/refresh token, menores deferidos do RF14, endpoint leve de consentimento (evita a auditoria de leitura por login). diff --git a/apps/CLAUDE.md b/apps/CLAUDE.md index b67f08c2..5f429fa2 100644 --- a/apps/CLAUDE.md +++ b/apps/CLAUDE.md @@ -32,7 +32,7 @@ The RPC trusts a development CA that is **separate** from the broker's: `scripts 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). -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 `myData` shows `segmentedPush` currently granted (3 s ceiling, fail closed; skipped when the source is `NoPushTokenSource`; a grant made in "Meus dados" passes `consentKnownGranted: true`). `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 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`. diff --git a/apps/patient/lib/app/app.dart b/apps/patient/lib/app/app.dart index 1e6a74e5..adb8974e 100644 --- a/apps/patient/lib/app/app.dart +++ b/apps/patient/lib/app/app.dart @@ -812,7 +812,12 @@ class _OnboardingScreenState extends State { // Intencionalmente silencioso — ver comentário acima. } if (!mounted) return; - unawaited(registerPushDevice(BackendScope.of(context), PushTokenScope.maybeOf(context))); + // 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. diff --git a/apps/patient/lib/core/network/backend_client.dart b/apps/patient/lib/core/network/backend_client.dart index 8455d9f4..4553b658 100644 --- a/apps/patient/lib/core/network/backend_client.dart +++ b/apps/patient/lib/core/network/backend_client.dart @@ -151,6 +151,11 @@ abstract class PatientBackend { /// 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(); @@ -261,6 +266,9 @@ class MisconfiguredBackend implements PatientBackend { @override Future hasAcceptedCurrentTerms() async => _recusar(); + @override + Future hasGrantedConsent(ConsentPurpose purpose) async => _recusar(); + @override Future termsChangeNotice() async => _recusar(); @@ -578,6 +586,12 @@ class BackendClient implements PatientBackend { 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(); diff --git a/apps/patient/lib/core/push/push_token_source.dart b/apps/patient/lib/core/push/push_token_source.dart index 8830fd74..44b6d719 100644 --- a/apps/patient/lib/core/push/push_token_source.dart +++ b/apps/patient/lib/core/push/push_token_source.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:flutter/widgets.dart'; import 'package:sinalacs_client/sinalacs_client.dart' show ConsentPurpose; -import 'package:sinalacs_patient/core/consent/consent_decisions.dart'; import 'package:sinalacs_patient/core/network/backend_client.dart'; /// Aparelho apto a receber push: o token do provedor e a plataforma. @@ -16,11 +15,11 @@ class PushDevice { } /// De onde o app tira o token de push. A implementação real, -/// `NativePushTokenSource` (canal `sinalacs/push_token`, ainda sem o lado -/// nativo), lê o token do FCM no Android e do APNs no iOS, e o backend o entrega -/// depois ao Gorush (decisão §3.2 do documento de decisões de produto). Até o -/// lado nativo e as credenciais existirem, o canal não responde e o registro -/// fica inerte; servidor e telas já estão prontos. +/// `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(); @@ -33,27 +32,33 @@ class NoPushTokenSource implements PushTokenSource { 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. -const _consentLookupTimeout = Duration(seconds: 3); - +/// +/// 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, e a consulta ao - // painel (`myData`) grava uma linha de auditoria de leitura a cada login. + // 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 overview = await backend.myData().timeout(_consentLookupTimeout); - if (currentConsentDecisions(overview.consents)[ConsentPurpose.segmentedPush] != true) return; + final granted = await backend + .hasGrantedConsent(ConsentPurpose.segmentedPush) + .timeout(_consentLookupTimeout); + if (!granted) return; } final device = await source.currentDevice(); if (device == null) return; diff --git a/apps/patient/test/onboarding_flow_test.dart b/apps/patient/test/onboarding_flow_test.dart index c3755823..8b91837e 100644 --- a/apps/patient/test/onboarding_flow_test.dart +++ b/apps/patient/test/onboarding_flow_test.dart @@ -6,6 +6,7 @@ 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'; @@ -40,6 +41,25 @@ 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'; @@ -125,6 +145,30 @@ void main() { 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.'), diff --git a/apps/patient/test/patient_app_mvp_test.dart b/apps/patient/test/patient_app_mvp_test.dart index d9d79df8..f04622e9 100644 --- a/apps/patient/test/patient_app_mvp_test.dart +++ b/apps/patient/test/patient_app_mvp_test.dart @@ -1060,10 +1060,13 @@ void main() { 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'))); @@ -1308,10 +1311,9 @@ void main() { final backend = FakePatientBackend()..myDataResult = overview(); await tester.pumpWidget(SinalAcsApp(backend: backend)); await login(tester); - final depoisDoLogin = backend.myDataCallCount; // o login consulta o consentimento de avisos await openMyData(tester); - expect(backend.myDataCallCount - depoisDoLogin, 1); + expect(backend.myDataCallCount, 1); expect(find.text('Fulano de Tal'), findsOneWidget); expect(find.textContaining('10/03/1975'), findsOneWidget); expect(find.textContaining('Ciclana, (11) 90000-0000'), findsOneWidget); diff --git a/apps/patient/test/push_registration_test.dart b/apps/patient/test/push_registration_test.dart index fda382f3..c9dcb51a 100644 --- a/apps/patient/test/push_registration_test.dart +++ b/apps/patient/test/push_registration_test.dart @@ -94,7 +94,7 @@ void main() { (tester) async { final backend = FakePatientBackend() ..addConsentRecord('segmentedPush', 'granted', DateTime.utc(2026, 9, 1)) - ..addConsentRecord('segmentedPush', 'revoked', DateTime.utc(2026, 9, 2)); + ..addConsentRecord('segmentedPush', 'denied', DateTime.utc(2026, 9, 2)); final source = _CountingSource(); await tester.pumpWidget(SinalAcsApp(backend: backend, pushTokens: source)); await login(tester); @@ -102,10 +102,10 @@ void main() { expect(source.calls, 0); }); - testWidgets('myData falhando no login fecha: nada é pedido nem registrado', (tester) async { + testWidgets('consulta de consentimento falhando no login fecha: nada é pedido nem registrado', (tester) async { final backend = FakePatientBackend() ..grantPushConsent() - ..myDataFailure = const BackendFailure('falha'); + ..hasGrantedConsentFailure = const BackendFailure('falha'); final source = _CountingSource(); await tester.pumpWidget(SinalAcsApp(backend: backend, pushTokens: source)); await login(tester); @@ -114,17 +114,30 @@ void main() { expect(find.byType(PatientHomeShell), findsOneWidget); }); - testWidgets('myData que nunca responde não atrasa a home e nada é pedido', (tester) async { + testWidgets('consulta que nunca responde não atrasa a home; a resposta tardia é descartada', + (tester) async { final backend = FakePatientBackend()..grantPushConsent(); final gate = Completer(); - backend.myDataGate = gate; + 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)); + 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 { diff --git a/apps/patient/test/support/fake_patient_backend.dart b/apps/patient/test/support/fake_patient_backend.dart index 8bdff3c3..d5df71ec 100644 --- a/apps/patient/test/support/fake_patient_backend.dart +++ b/apps/patient/test/support/fake_patient_backend.dart @@ -1,6 +1,7 @@ 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'; @@ -57,8 +58,6 @@ class FakePatientBackend implements PatientBackend { BackendFailure? myDataFailure; int myDataCallCount = 0; - /// Definido, segura [myData] até ser completado — backend lento ou pendurado. - Completer? myDataGate; /// Acrescenta ao histórico uma decisão sobre [purpose] (`granted` ou /// `revoked`), como o servidor faria ao gravar em `consent_logs`. @@ -127,6 +126,22 @@ class FakePatientBackend implements PatientBackend { 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; @@ -381,7 +396,6 @@ class FakePatientBackend implements PatientBackend { @override Future myData() async { myDataCallCount++; - await myDataGate?.future; final failure = myDataFailure; if (failure != null) throw failure; return myDataResult; diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 7ad7b8b7..ddb026e2 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -65,7 +65,7 @@ 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 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); 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, sem I/O e sem auditoria; a agenda é a constante `upcomingTermsChange` em `terms_change_schedule.dart`, hoje `null`, 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), 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 +**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 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, sem I/O e sem auditoria; a agenda é a constante `upcomingTermsChange` em `terms_change_schedule.dart`, hoje `null`, 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), 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 ú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 diff --git a/backend/sinalacs_client/lib/src/protocol/client.dart b/backend/sinalacs_client/lib/src/protocol/client.dart index fce48de0..05ccb101 100644 --- a/backend/sinalacs_client/lib/src/protocol/client.dart +++ b/backend/sinalacs_client/lib/src/protocol/client.dart @@ -446,6 +446,22 @@ class EndpointPatients extends EndpointAuthenticated { {'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 _i14.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. Sem leitura de banco e sem linha de auditoria: a /// agenda é uma constante do repositório e nada do titular é lido nem gravado. 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 index 5a3b3688..01cf9004 100644 --- 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 @@ -181,6 +181,16 @@ class DataSubjectRightsService { ); } + /// `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, diff --git a/backend/sinalacs_server/lib/src/endpoints/patients_endpoint.dart b/backend/sinalacs_server/lib/src/endpoints/patients_endpoint.dart index fbcc23fc..c58b962b 100644 --- a/backend/sinalacs_server/lib/src/endpoints/patients_endpoint.dart +++ b/backend/sinalacs_server/lib/src/endpoints/patients_endpoint.dart @@ -173,6 +173,26 @@ class PatientsEndpoint extends AuthenticatedEndpoint { } } + /// 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. Sem leitura de banco e sem linha de auditoria: a /// agenda é uma constante do repositório e nada do titular é lido nem gravado. diff --git a/backend/sinalacs_server/lib/src/generated/endpoints.dart b/backend/sinalacs_server/lib/src/generated/endpoints.dart index ef3f3758..9043ab8d 100644 --- a/backend/sinalacs_server/lib/src/generated/endpoints.dart +++ b/backend/sinalacs_server/lib/src/generated/endpoints.dart @@ -605,6 +605,31 @@ class Endpoints extends _i1.EndpointDispatch { 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<_i11.ConsentPurpose>(), + nullable: false, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => (endpoints['patients'] as _i8.PatientsEndpoint) + .hasGrantedConsent( + session, + accessToken: params['accessToken'], + purpose: params['purpose'], + ), + ), 'termsChangeNotice': _i1.MethodConnector( name: 'termsChangeNotice', params: { diff --git a/backend/sinalacs_server/lib/src/generated/protocol.yaml b/backend/sinalacs_server/lib/src/generated/protocol.yaml index 5a4114e0..6c74db11 100644 --- a/backend/sinalacs_server/lib/src/generated/protocol.yaml +++ b/backend/sinalacs_server/lib/src/generated/protocol.yaml @@ -24,6 +24,7 @@ patients: - updateConsent: - acceptTermsOfUse: - hasAcceptedCurrentTerms: + - hasGrantedConsent: - termsChangeNotice: - requestDataDeletion: - requestDataCorrection: 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 index 0e83f679..4aa8260b 100644 --- a/backend/sinalacs_server/test/integration/data_subject_rights_endpoint_test.dart +++ b/backend/sinalacs_server/test/integration/data_subject_rights_endpoint_test.dart @@ -315,6 +315,37 @@ void main() { ); }); + 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); 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 9673dec9..1d36169b 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 @@ -953,6 +953,41 @@ class _PatientsEndpoint { }); } + _i3.Future hasGrantedConsent( + _i1.TestSessionBuilder sessionBuilder, { + required String accessToken, + required _i15.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<_i16.TermsChangeNotice?> termsChangeNotice( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, 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 index 508a0a1a..dfba96a8 100644 --- a/backend/sinalacs_server/test/unit/data_subject_rights_service_test.dart +++ b/backend/sinalacs_server/test/unit/data_subject_rights_service_test.dart @@ -316,6 +316,42 @@ void main() { }); }); + 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); diff --git a/spec/lgpd_design.md b/spec/lgpd_design.md index 5416d8e1..0856bd7c 100644 --- a/spec/lgpd_design.md +++ b/spec/lgpd_design.md @@ -75,14 +75,21 @@ titular (não a existência do token) antes de montar a lista, e foi provado de ponta a ponta num emulador Android em 2026-09-30 (ver PROGRESS.md, "RF14: Gorush e FCM provados no emulador"). -**Aparelho (2026-09-30):** o app só pede o token ao FCM depois de `patients.myData` -mostrar a decisão vigente de `segmentedPush` como `granted` (teto de 3 s; falha, -estouro ou "nunca decidiu" = não pergunta). Ao conceder em "Meus dados" o registro -segue direto, sem nova leitura. O auto-init do FCM, que falaria com o Google em toda -abertura, está desligado no manifesto. Custo aceito: `myData` grava uma linha de -auditoria de leitura por login de quem tem fonte de token; um endpoint leve de -consentimento evitaria isso. O texto do aviso também passa pelo FCM (Google): -decisão de produto da §3.2, não tratada aqui. +**Aparelho (2026-09-30):** o app só pede o token ao FCM depois de +`patients.hasGrantedConsent(segmentedPush)` devolver `true` (um `bool`, teto de 3 s; +falha, estouro ou "nunca decidiu" = não pergunta; resposta tardia é descartada). +A consulta **não** lê o painel "Meus dados" e não grava auditoria de leitura: a +primeira versão usava `myData`, que decifra e devolve o dossiê clínico e grava uma +linha "titular abriu o painel" a cada login — trocada na revisão independente. Quem +acabou de gravar a concessão (onboarding ou interruptor em "Meus dados") registra +sem perguntar. O auto-init do FCM, que falaria com o Google em toda abertura, está +desligado no manifesto. + +**Limite conhecido:** se o consentimento for revogado (neste ou em outro aparelho) +entre a resposta do servidor e o pedido do token, o aparelho pode falar com o +Google uma vez depois da revogação. O servidor continua certo: `registerPushToken` +relê o consentimento sob lock e não guarda token sem base legal. O texto do aviso +também passa pelo FCM (Google): decisão de produto da §3.2, não tratada aqui. ### LGPD-RF03 - Gerenciamento de Preferências de Privacidade From 2c9d4ab19383d793ef0edcbe3fb226ae2c801b03 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Wed, 30 Sep 2026 17:26:46 -0400 Subject: [PATCH 096/498] test(e2e): stack de e2e com o banco de teste e sem login de desenvolvimento Co-Authored-By: Claude Sonnet 5.5 --- .gitignore | 3 +++ docker-compose.e2e.yml | 16 ++++++++++++++++ scripts/qa/e2e_stack.sh | 40 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+) create mode 100644 docker-compose.e2e.yml create mode 100755 scripts/qa/e2e_stack.sh diff --git a/.gitignore b/.gitignore index 509f714f..852af3c8 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,6 @@ 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/ diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml new file mode 100644 index 00000000..98e53f1d --- /dev/null +++ b/docker-compose.e2e.yml @@ -0,0 +1,16 @@ +# 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" + SMS_GATEWAY: log + GORUSH_URL: http://gorush:8088 diff --git a/scripts/qa/e2e_stack.sh b/scripts/qa/e2e_stack.sh new file mode 100755 index 00000000..12b68e8b --- /dev/null +++ b/scripts/qa/e2e_stack.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Sobe/derruba a stack de e2e contra o banco de teste. Ver docker-compose.e2e.yml. +# e2e_stack.sh up | seed | down | psql "" +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 2>&1 + 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. + dc stop serverpod >/dev/null 2>&1 || true + 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 do `seed`). + dc up -d --build --force-recreate --no-deps serverpod gorush traefik mosquitto >/dev/null 2>&1 + 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 ;; + 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 ) ;; + down) + dc stop serverpod >/dev/null 2>&1 || true + pg "drop database if exists $db with (force)" postgres >/dev/null ;; + psql) pg "$2" ;; + *) echo 'uso: e2e_stack.sh up|seed|down|psql ""'; exit 2 ;; +esac From 58d8231c83d3a00cd391ea5115990260c9262dcc Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Wed, 30 Sep 2026 17:28:00 -0400 Subject: [PATCH 097/498] =?UTF-8?q?test(e2e):=20fixtures=20sint=C3=A9ticas?= =?UTF-8?q?=20geradas=20por=20execu=C3=A7=C3=A3o=20e=20seeder=20do=20banco?= =?UTF-8?q?=20de=20teste?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../bin/seed_e2e_fixtures.dart | 117 +++++++++++ .../infrastructure/testing/e2e_fixtures.dart | 186 ++++++++++++++++++ .../test/unit/e2e_fixtures_test.dart | 54 +++++ 3 files changed, 357 insertions(+) create mode 100644 backend/sinalacs_server/bin/seed_e2e_fixtures.dart create mode 100644 backend/sinalacs_server/lib/src/infrastructure/testing/e2e_fixtures.dart create mode 100644 backend/sinalacs_server/test/unit/e2e_fixtures_test.dart 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..06aa3179 --- /dev/null +++ b/backend/sinalacs_server/bin/seed_e2e_fixtures.dart @@ -0,0 +1,117 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; + +import 'package:postgres/postgres.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/testing/e2e_fixtures.dart'; + +/// Seed da stack de e2e (`docker-compose.e2e.yml`): UBS, microáreas, 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; + if ((env['APP_ENV'] ?? 'development') != 'development') { + stderr.writeln('Recusando rodar: APP_ENV=${env['APP_ENV']}; este seed é só de desenvolvimento.'); + exit(2); + } + if (env['SERVERPOD_DATABASE_NAME'] != 'sinalacs_e2e') { + stderr.writeln('Recusando rodar: este seed só escreve no banco sinalacs_e2e.'); + exit(2); + } + final password = env['SERVERPOD_DATABASE_PASSWORD']; + if (password == null || password.isEmpty) { + stderr.writeln('SERVERPOD_DATABASE_PASSWORD não definida.'); + exit(2); + } + + 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 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), + ); + + try { + await connection.runTx((tx) async { + await tx.execute( + Sql.named('INSERT INTO "ubs" ("id","name","address","city","state") ' + "VALUES (@id, 'UBS E2E', 'Endereço de teste', 'São Paulo', 'SP')"), + 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); + await tx.execute( + Sql.named('INSERT INTO "patients" ("id","emergencyContact","isChronic","chronicConditionsEncrypted","chronicConditionsKeyVersion") ' + "VALUES (@id,'Contato E2E',@chronic,@cipher,@ver)"), + parameters: {'id': p.id, 'chronic': p.chronic, 'cipher': encrypted.ciphertextBase64, 'ver': encrypted.keyVersion}, + ); + } + // O ACS entra 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. + await tx.execute( + Sql.named('INSERT INTO "users" ("id","cpfHash","name","birthDate","role","microAreaId","createdAt","updatedAt") ' + "VALUES (@id,@hash,'ACS E2E','1985-01-01','acs',@area,NOW(),NOW())"), + parameters: {'id': fixtures.acs.id, 'hash': 'e2e-acs-${fixtures.acs.id}', 'area': fixtures.microAreaId}, + ); + await tx.execute( + Sql.named('INSERT INTO "acs" ("id","enrollmentId","ubsId","active") VALUES (@id,@matricula,@ubs,true)'), + parameters: {'id': fixtures.acs.id, 'matricula': fixtures.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': fixtures.acs.id, + 'hash': acsDigest.hashBase64, + 'salt': acsDigest.saltBase64, + 'memoryKb': acsDigest.memoryKb, + 'iterations': acsDigest.iterations, + 'parallelism': acsDigest.parallelism, + }, + ); + }); + } finally { + await connection.close(); + } + + final out = File(args.isNotEmpty ? args.first : '.e2e/fixtures.json'); + out.parent.createSync(recursive: true); + out.writeAsStringSync(jsonEncode(fixtures.toJson())); + Process.runSync('chmod', ['600', out.path]); + stdout.writeln('Fixtures de e2e gravadas (${fixtures.patients.length} pacientes, 1 ACS) em ${out.path}.'); +} 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..c2ea41b6 --- /dev/null +++ b/backend/sinalacs_server/lib/src/infrastructure/testing/e2e_fixtures.dart @@ -0,0 +1,186 @@ +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; + + /// `main`, `chronic` 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)}…)'; +} + +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)'; +} + +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, + ); + } + + 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), + ], + ); +} 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..17c45025 --- /dev/null +++ b/backend/sinalacs_server/test/unit/e2e_fixtures_test.dart @@ -0,0 +1,54 @@ +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.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.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.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 não vaza CPF nem senha', () { + final f = generateE2eFixtures(Random(4)); + final texto = '$f ${f.patients.first} ${f.acs}'; + for (final p in f.patients) { + expect(texto.contains(p.cpf), isFalse); + } + expect(texto.contains(f.acs.password), isFalse); + }); +} From ec89dad802fd58220869a25ddfeae5f4600893db Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Wed, 30 Sep 2026 17:28:25 -0400 Subject: [PATCH 098/498] =?UTF-8?q?test(e2e):=20rel=C3=A9=20do=20c=C3=B3di?= =?UTF-8?q?go=20OTP=20do=20gateway=20de=20log=20para=20o=20emulador?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- scripts/qa/otp_relay.py | 39 ++++++++++++++++++++++++++++++++++++ scripts/qa/otp_relay_test.py | 16 +++++++++++++++ 2 files changed, 55 insertions(+) create mode 100755 scripts/qa/otp_relay.py create mode 100644 scripts/qa/otp_relay_test.py diff --git a/scripts/qa/otp_relay.py b/scripts/qa/otp_relay.py new file mode 100755 index 00000000..75a23b90 --- /dev/null +++ b/scripts/qa/otp_relay.py @@ -0,0 +1,39 @@ +#!/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)) + saida = subprocess.run(["docker", "logs", "--since", desde, self.container], + capture_output=True, text=True) + code = parse_latest_code(saida.stdout + saida.stderr) + 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() diff --git a/scripts/qa/otp_relay_test.py b/scripts/qa/otp_relay_test.py new file mode 100644 index 00000000..790c07c9 --- /dev/null +++ b/scripts/qa/otp_relay_test.py @@ -0,0 +1,16 @@ +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() From efc32d2d0b820d3817ab4f959451303d51115e67 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Wed, 30 Sep 2026 17:36:41 -0400 Subject: [PATCH 099/498] =?UTF-8?q?test(paciente):=20integra=C3=A7=C3=A3o?= =?UTF-8?q?=20entra=20pelo=20OTP=20real=20com=20fixtures;=20login=20de=20d?= =?UTF-8?q?esenvolvimento=20s=C3=B3=20como=20fallback=20da=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../backend_connection_test.dart | 21 ++-- .../integration_test/push_register_test.dart | 4 +- apps/patient/integration_test/smoke_test.dart | 11 ++- .../integration_test/support/e2e_login.dart | 39 ++++++++ apps/patient/test/e2e_login_test.dart | 98 +++++++++++++++++++ apps/patient/test/support/e2e_config.dart | 67 +++++++++++++ apps/patient/test/support/e2e_login_core.dart | 57 +++++++++++ apps/patient/tool/live_check.dart | 4 +- apps/patient/tool/push_consent.dart | 4 +- apps/patient/tool/support/host_login.dart | 28 ++++++ scripts/qa/e2e_stack.sh | 3 +- 11 files changed, 317 insertions(+), 19 deletions(-) create mode 100644 apps/patient/integration_test/support/e2e_login.dart create mode 100644 apps/patient/test/e2e_login_test.dart create mode 100644 apps/patient/test/support/e2e_config.dart create mode 100644 apps/patient/test/support/e2e_login_core.dart create mode 100644 apps/patient/tool/support/host_login.dart 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/push_register_test.dart b/apps/patient/integration_test/push_register_test.dart index ba253ee6..798058bb 100644 --- a/apps/patient/integration_test/push_register_test.dart +++ b/apps/patient/integration_test/push_register_test.dart @@ -12,6 +12,8 @@ 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); @@ -46,7 +48,7 @@ void main() { } final backend = BackendClient(trustedCaBytes: caBytes); addTearDown(backend.close); - await backend.developmentLogin(role: 'patient'); + await loginPatient(backend); final device = await const NativePushTokenSource() .currentDevice() 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..824d766e --- /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 = 'main'}) { + 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 = 'main', +}) => loginPatientWith(backend, e2eConfig(), relayUrl: _otpRelay, role: role); diff --git a/apps/patient/test/e2e_login_test.dart b/apps/patient/test/e2e_login_test.dart new file mode 100644 index 00000000..7dd16608 --- /dev/null +++ b/apps/patient/test/e2e_login_test.dart @@ -0,0 +1,98 @@ +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 { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final desdes = []; + var chamadas = 0; + server.listen((request) async { + 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('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 antes = DateTime.now().toUtc().millisecondsSinceEpoch; + + 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 relé só deve devolver códigos POSTERIORES ao pedido. + expect(desdes, hasLength(3)); + expect(int.parse(desdes.first), greaterThanOrEqualTo(antes)); + }); + + 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/support/e2e_config.dart b/apps/patient/test/support/e2e_config.dart new file mode 100644 index 00000000..556ee5df --- /dev/null +++ b/apps/patient/test/support/e2e_config.dart @@ -0,0 +1,67 @@ +/// 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; + final acs = (map['acs']! as Map).cast(); + 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..bcacb103 --- /dev/null +++ b/apps/patient/test/support/e2e_login_core.dart @@ -0,0 +1,57 @@ +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 = DateTime.now().toUtc().millisecondsSinceEpoch; + 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 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/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 index 7cf6a43d..1945c2f6 100644 --- a/apps/patient/tool/push_consent.dart +++ b/apps/patient/tool/push_consent.dart @@ -12,6 +12,8 @@ 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')) { @@ -38,7 +40,7 @@ Future main(List args) async { } final backend = BackendClient(host: host, trustedCaBytes: await caFile.readAsBytes()); try { - await backend.developmentLogin(role: 'patient'); + await loginPatientOnHost(backend); await backend.updateConsent(purpose: ConsentPurpose.segmentedPush, granted: grant); stdout.writeln('consent=${grant ? 'granted' : 'revoked'}'); } on BackendFailure catch (failure) { 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/scripts/qa/e2e_stack.sh b/scripts/qa/e2e_stack.sh index 12b68e8b..0592629f 100755 --- a/scripts/qa/e2e_stack.sh +++ b/scripts/qa/e2e_stack.sh @@ -34,7 +34,8 @@ case "${1:-}" in dart run bin/seed_e2e_fixtures.dart ../../.e2e/fixtures.json ) ;; down) dc stop serverpod >/dev/null 2>&1 || true - pg "drop database if exists $db with (force)" postgres >/dev/null ;; + pg "drop database if exists $db with (force)" postgres >/dev/null + rm -f .e2e/fixtures.json ;; # descreve pacientes de um banco que não existe mais psql) pg "$2" ;; *) echo 'uso: e2e_stack.sh up|seed|down|psql ""'; exit 2 ;; esac From 6b4a5a685f984f7eefbd0e1558efec00c27ee699 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Wed, 30 Sep 2026 17:42:55 -0400 Subject: [PATCH 100/498] =?UTF-8?q?test(paciente):=20jornada=20completa=20?= =?UTF-8?q?pela=20tela=20contra=20o=20banco=20de=20teste=20(OTP,=20termos,?= =?UTF-8?q?=20triagem,=20alerta,=20status,=20Meus=20dados,=20territ=C3=B3r?= =?UTF-8?q?io)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../integration_test/full_journey_test.dart | 188 ++++++++++++++++++ .../integration_test/push_register_test.dart | 2 +- .../integration_test/support/e2e_login.dart | 4 +- .../infrastructure/testing/e2e_fixtures.dart | 7 +- .../test/unit/e2e_fixtures_test.dart | 6 + 5 files changed, 203 insertions(+), 4 deletions(-) create mode 100644 apps/patient/integration_test/full_journey_test.dart 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..f39906e5 --- /dev/null +++ b/apps/patient/integration_test/full_journey_test.dart @@ -0,0 +1,188 @@ +/// 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". 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 'dart:io' show SecurityContext; + +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_client/sinalacs_client.dart' as rpc; +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 = DateTime.now().toUtc().millisecondsSinceEpoch; + 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); + }); + + 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); + }); + + test('território (API): o ACS da microárea lista os pacientes dela e não o de outra área', () async { + final ca = (await rootBundle.load(BackendConfig.rpcCaAsset)).buffer.asUint8List(); + final client = rpc.Client( + BackendClient.resolveHost(null), + securityContext: SecurityContext()..setTrustedCertificatesBytes(ca), + )..connectivityMonitor = null; + addTearDown(client.close); + + final session = await client.auth.loginInstitutional( + matricula: config!.acsMatricula, + password: config.acsPassword, + ); + final names = (await client.patients.listMicroArea(accessToken: session.accessToken)).map((p) => p.name).toSet(); + + expect(names, containsAll([config.patient('main').name, config.patient('chronic').name])); + expect(names, isNot(contains(config.patient('outsider').name))); + }); + }, + ); +} diff --git a/apps/patient/integration_test/push_register_test.dart b/apps/patient/integration_test/push_register_test.dart index 798058bb..1ba3a209 100644 --- a/apps/patient/integration_test/push_register_test.dart +++ b/apps/patient/integration_test/push_register_test.dart @@ -48,7 +48,7 @@ void main() { } final backend = BackendClient(trustedCaBytes: caBytes); addTearDown(backend.close); - await loginPatient(backend); + await loginPatient(backend, role: 'push'); final device = await const NativePushTokenSource() .currentDevice() diff --git a/apps/patient/integration_test/support/e2e_login.dart b/apps/patient/integration_test/support/e2e_login.dart index 824d766e..1a6fc858 100644 --- a/apps/patient/integration_test/support/e2e_login.dart +++ b/apps/patient/integration_test/support/e2e_login.dart @@ -24,7 +24,7 @@ E2eConfig? e2eConfig() => _fixtures.isEmpty : E2eConfig.fromMap((jsonDecode(_fixtures) as Map).cast()); /// Microárea que a sessão do paciente [role] deve trazer. -String expectedMicroArea({String role = 'main'}) { +String expectedMicroArea({String role = 'api'}) { final config = e2eConfig(); return config == null ? developmentMicroAreaId @@ -35,5 +35,5 @@ String expectedMicroArea({String role = 'main'}) { /// login de desenvolvimento. Ver [loginPatientWith]. Future loginPatient( BackendClient backend, { - String role = 'main', + String role = 'api', }) => loginPatientWith(backend, e2eConfig(), relayUrl: _otpRelay, role: role); diff --git a/backend/sinalacs_server/lib/src/infrastructure/testing/e2e_fixtures.dart b/backend/sinalacs_server/lib/src/infrastructure/testing/e2e_fixtures.dart index c2ea41b6..00b6d313 100644 --- a/backend/sinalacs_server/lib/src/infrastructure/testing/e2e_fixtures.dart +++ b/backend/sinalacs_server/lib/src/infrastructure/testing/e2e_fixtures.dart @@ -16,7 +16,8 @@ class E2ePatient { final String id; - /// `main`, `chronic` ou `outsider` (outra microárea). + /// `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; @@ -180,6 +181,10 @@ E2eFixtures generateE2eFixtures(Random 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), ], ); diff --git a/backend/sinalacs_server/test/unit/e2e_fixtures_test.dart b/backend/sinalacs_server/test/unit/e2e_fixtures_test.dart index 17c45025..e6e11f5b 100644 --- a/backend/sinalacs_server/test/unit/e2e_fixtures_test.dart +++ b/backend/sinalacs_server/test/unit/e2e_fixtures_test.dart @@ -38,6 +38,12 @@ void main() { } }); + 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()); From a23d7e04f91d0c5a6baa940db5e2bb1cb8acb49e Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Wed, 30 Sep 2026 17:47:40 -0400 Subject: [PATCH 101/498] =?UTF-8?q?test(e2e):=20push=20do=20ACS=20instituc?= =?UTF-8?q?ional=20at=C3=A9=20o=20emulador=20contra=20o=20banco=20de=20tes?= =?UTF-8?q?te,=20sem=20tocar=20o=20de=20desenvolvimento?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- apps/acs/test/send_notice_login_test.dart | 34 +++++++++ apps/acs/tool/send_notice.dart | 19 ++++- apps/acs/tool/send_notice_login.dart | 24 +++++++ apps/patient/tool/push_consent.dart | 2 +- .../qa/__pycache__/otp_relay.cpython-314.pyc | Bin 0 -> 3233 bytes scripts/qa/patient_full_e2e.sh | 65 ++++++++++++++++++ scripts/qa/push_e2e.sh | 47 ++++++++++++- 7 files changed, 185 insertions(+), 6 deletions(-) create mode 100644 apps/acs/test/send_notice_login_test.dart create mode 100644 apps/acs/tool/send_notice_login.dart create mode 100644 scripts/qa/__pycache__/otp_relay.cpython-314.pyc create mode 100755 scripts/qa/patient_full_e2e.sh 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/tool/send_notice.dart b/apps/acs/tool/send_notice.dart index e285013c..c992e634 100644 --- a/apps/acs/tool/send_notice.dart +++ b/apps/acs/tool/send_notice.dart @@ -3,7 +3,8 @@ /// 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 +/// 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). @@ -13,6 +14,8 @@ 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; @@ -45,6 +48,14 @@ Future main(List args) async { 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.'); @@ -54,7 +65,11 @@ Future main(List args) async { final backend = BackendClient(host: host, trustedCaBytes: await caFile.readAsBytes()); try { - await backend.developmentLogin(role: 'acs'); + 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, 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/patient/tool/push_consent.dart b/apps/patient/tool/push_consent.dart index 1945c2f6..6dbfc5c5 100644 --- a/apps/patient/tool/push_consent.dart +++ b/apps/patient/tool/push_consent.dart @@ -40,7 +40,7 @@ Future main(List args) async { } final backend = BackendClient(host: host, trustedCaBytes: await caFile.readAsBytes()); try { - await loginPatientOnHost(backend); + await loginPatientOnHost(backend, role: 'push'); await backend.updateConsent(purpose: ConsentPurpose.segmentedPush, granted: grant); stdout.writeln('consent=${grant ? 'granted' : 'revoked'}'); } on BackendFailure catch (failure) { diff --git a/scripts/qa/__pycache__/otp_relay.cpython-314.pyc b/scripts/qa/__pycache__/otp_relay.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..78b1aa2514d3687a1e3a342fd0eb987bac78d5ab GIT binary patch literal 3233 zcmb7GT}&L;6~41Gvpf64@@sG~HnVXc-WYamLTl>SZt+iyn;O;w!A=Vd5pTtjBHYN59rdADQDmo2cHPRnRC0x?`Vj;PWMs*e+J~l3POy|dRBF$i-C=Ru zJoK*iocnX`Ip>~x&Ufcvydr|2{qx;F{`CDILVsfezlBPjtrUmQ98wTPBS_)Sag_6T zfCfCyQ{LkO6@UlMi9JCY>W+(M^j&NPqVNavC zr{>$FN4PeS@SDH)Jf}on4-CgU1I3lhtG+~;cb2-%5S{(rKN&5YgaawqTK+^fY*}Bv zZCRxwe0axIj<2-wq=|xSejuI`GB*0UpBCVb5pi8WoB`X+G*T@ zlc`9gZ+owSW!NoaM<;j+!+zgJ&Xyf&*agZgIhahDp!AKM26D?E*|N3#+sLS4>p#PU zDRM?r@)?KX?Hw|{l3)G#14G8ozx#`%te5i8yHb{S?7yV|nL|U~BqNzpMs80)XAt>0ljjCG zKTQm%uO>9eDS@5YRmu|n3udkNi~m4_BLp+ureq$ zH&nXW*&2s=ujx)Z%U?|Lw(;D(Fl2%okhUH{FyLN%!*Bb#pm8FA;{Vqf8W9Z=(e{bHgd)(f%N?4nX?dr;CEMO&47BtdZ8?9mrT@p~KOov}u!!HTzyN$U)ZXrG835df z>sp=`2v*@8$QP(9FbtWEow5P9bF_V6CXZ($Y%ZO*G&7g1Cj$8C3VDrAxDj}I3>6b% z6PGu0l(33$MF3wi+y(FxSR(_0!(#zQpmYs9V~(YP{z{O z4Hlm!kHef>;h7@X$=f-A?x;7Mz#5>D8)aS;`;IG4kC_$*Mofy`AkMK!0cHpew#Hl@ zz#rUU0aLRe43Cll!j5Jdnkz6-2fl6qHd!OA6j|)lvu6g}cnNe;#qlm!bN&s4;a~kV zdSm8SGi&k0Jii%@FLYh)x;DKY-M1O5TIjplck|SGOup6nxVHZKl|}w`_*QtW;kAv1 z)M`WO&h%34lDO7zVrlZzhVHf6lk?K!s=6B$A5^UD>-hcY2XFoEt%uU1&OcYKb!OJ8 zF3gLMs~T^_KZq|%cUtczm)`zM@1J^Cn)^3X9ZPl3c&_^U^Wxtt<;7jMn{GAT6(7Xz z#Xe0OTdO=iFKk9D-#hlaLz_};Luy==8gIV7E;TRKZ6pq_CJx_CJ#1S~^sQWYd*gz( zdO=&ipo8E-1hd2@p&eRJpt};}8aiZghgu ze5&~35%!D`sBz@RAh#{^a>{$Jy)rGo{IGwq_V(^uyYD1E-m|{9{gJRzdH&J#ns9MLz^elOOc?)?M?$0L-+uwI CTj-Sl literal 0 HcmV?d00001 diff --git a/scripts/qa/patient_full_e2e.sh b/scripts/qa/patient_full_e2e.sh new file mode 100755 index 00000000..e1ea7a6a --- /dev/null +++ b/scripts/qa/patient_full_e2e.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# +# Teste completo do app do paciente no emulador-5554, contra o BANCO DE TESTE. +# +# ./scripts/qa/patient_full_e2e.sh # jornada + conexão + push (Gorush e FCM reais) +# ./scripts/qa/patient_full_e2e.sh --sem-push # só a jornada e a conexão (sem credenciais do FCM) +# +# O que faz: sobe a stack de e2e (scripts/qa/e2e_stack.sh: banco `sinalacs_e2e` no +# postgres-test, sem login de desenvolvimento), semeia fixtures geradas na hora (UUIDs e +# CPFs novos a cada execução), sobe o relé do código OTP e roda, no emulador: +# - integration_test/full_journey_test.dart (OTP, termos, triagem, alerta, status, +# Meus dados e território, pela tela) +# - integration_test/backend_connection_test.dart (RPC, determinismo, idempotência) +# Sem --sem-push, em seguida scripts/qa/push_e2e.sh --e2e-db --negativos (o aviso do ACS +# chega à bandeja pelo Gorush e FCM reais). Nada é escrito no banco de desenvolvimento; +# o banco de e2e e o manifesto (.e2e/fixtures.json) são apagados ao final. +# +# Pré-requisitos: emulador `emulator-5554` (para o push: Google Play e internet), o `.env` +# (./scripts/dev/bootstrap_env.sh) e, para o push, as credenciais descritas em push_e2e.sh. +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" + +push=1 +for arg in "$@"; do + case "$arg" in + --sem-push) push=0 ;; + *) echo "argumento desconhecido: $arg" >&2; exit 2 ;; + esac +done + +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 + ./scripts/qa/e2e_stack.sh down >/dev/null 2>&1 || true +} +trap cleanup EXIT + +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 +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 >/dev/null 2>&1 & +relay_pid=$! + +fixtures="$(cat .e2e/fixtures.json)" +echo "== jornada e conexão no emulador" +( 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 [[ "$push" -eq 1 ]]; then + # push_e2e.sh sobe a própria stack de e2e (fixtures novas): derruba esta antes. + kill "$relay_pid" 2>/dev/null || true; relay_pid="" + ./scripts/qa/e2e_stack.sh down >/dev/null 2>&1 + echo "== push (Gorush e FCM reais)" + ./scripts/qa/push_e2e.sh --e2e-db --negativos +fi +echo 'OK — teste completo do app do paciente contra o banco de teste' diff --git a/scripts/qa/push_e2e.sh b/scripts/qa/push_e2e.sh index 001cfff1..4d1825b8 100755 --- a/scripts/qa/push_e2e.sh +++ b/scripts/qa/push_e2e.sh @@ -5,6 +5,12 @@ # # ./scripts/qa/push_e2e.sh # caminho feliz # ./scripts/qa/push_e2e.sh --negativos # + token falso/ios, revogação e Gorush parado +# ./scripts/qa/push_e2e.sh --e2e-db --negativos # idem, contra o banco de TESTE (sinalacs_e2e) +# +# Com --e2e-db a stack sobe por scripts/qa/e2e_stack.sh (banco de teste efêmero, sem +# login de desenvolvimento): o paciente entra por OTP real (relé local do código) e o +# ACS por matrícula e senha, ambos de fixtures geradas na hora. Nada é escrito no +# banco de desenvolvimento. # # Pré-requisitos: emulador `emulator-5554` (Google Play, com internet), a chave da conta # de serviço em infra/docker/gorush/credentials/fcm-service-account.json e o @@ -19,9 +25,11 @@ cd "$repo_root" export PATH="$PATH:$HOME/Android/Sdk/platform-tools:$HOME/flutter/bin" negativos=0 +e2e_db=0 for arg in "$@"; do case "$arg" in --negativos) negativos=1 ;; + --e2e-db) e2e_db=1 ;; *) echo "argumento desconhecido: $arg" >&2; exit 2 ;; esac done @@ -57,14 +65,31 @@ export GORUSH_URL=http://gorush:8088 pg_user="$(grep '^POSTGRES_USER=' .env | cut -d= -f2)" pg_db="$(grep '^POSTGRES_DB=' .env | cut -d= -f2)" -psql_q() { docker exec sinalacs-postgres psql -U "$pg_user" -d "$pg_db" -Atc "$1"; } +if [[ "$e2e_db" -eq 1 ]]; then + psql_q() { ./scripts/qa/e2e_stack.sh psql "$1"; } +else + psql_q() { docker exec sinalacs-postgres psql -U "$pg_user" -d "$pg_db" -Atc "$1"; } +fi app=br.com.prismrr.sinalacs.patient dev=emulator-5554 adb -s "$dev" get-state >/dev/null 2>&1 || { echo "emulador $dev não encontrado (adb devices)"; exit 4; } # -- stack ------------------------------------------------------------------- -echo "== stack com o perfil push" -docker compose --profile push up -d --build >/dev/null 2>&1 +relay_pid="" +if [[ "$e2e_db" -eq 1 ]]; then + echo "== stack de e2e (banco de teste) com o Gorush" + ./scripts/qa/e2e_stack.sh up + ./scripts/qa/e2e_stack.sh seed + python3 scripts/qa/otp_relay.py >/dev/null 2>&1 & + relay_pid=$! + export ACS_MATRICULA ACS_PASSWORD E2E_FIXTURES_FILE="$repo_root/.e2e/fixtures.json" + ACS_MATRICULA="$(python3 -c "import json;print(json.load(open('.e2e/fixtures.json'))['acs']['matricula'])")" + ACS_PASSWORD="$(python3 -c "import json;print(json.load(open('.e2e/fixtures.json'))['acs']['password'])")" + e2e_fixtures="$(cat .e2e/fixtures.json)" +else + echo "== stack com o perfil push" + docker compose --profile push up -d --build >/dev/null 2>&1 +fi for _ in $(seq 1 60); do [[ "$(docker compose --profile push ps serverpod gorush --format '{{.Status}}' 2>/dev/null | grep -c healthy)" -ge 2 ]] && break sleep 3 @@ -73,11 +98,15 @@ done || { echo 'erro: serverpod e gorush não ficaram saudáveis'; docker compose --profile push ps; exit 1; } adb -s "$dev" reverse tcp:8443 tcp:443 >/dev/null +adb -s "$dev" reverse tcp:8765 tcp:8765 >/dev/null hold_pid="" cleanup() { [[ -n "$hold_pid" ]] && kill "$hold_pid" 2>/dev/null || true + [[ -n "$relay_pid" ]] && kill "$relay_pid" 2>/dev/null || true docker compose --profile push start gorush >/dev/null 2>&1 || true psql_q 'delete from push_tokens' >/dev/null 2>&1 || true + # O banco de e2e e o manifesto (com a senha do ACS) não sobram depois do teste. + [[ "$e2e_db" -eq 1 ]] && ./scripts/qa/e2e_stack.sh down >/dev/null 2>&1 || true } trap cleanup EXIT @@ -90,6 +119,7 @@ echo "== registro do token FCM real (segura o app por 240 s)" psql_q 'delete from push_tokens' >/dev/null ( cd apps/patient && flutter test integration_test/push_register_test.dart -d "$dev" \ --dart-define=SINALACS_HOST=https://localhost:8443/ --dart-define=PUSH_HOLD_SECONDS=240 --dart-define=PUSH_E2E=1 \ + ${e2e_fixtures:+--dart-define=E2E_FIXTURES="$e2e_fixtures"} \ > /tmp/push_e2e_hold.txt 2>&1 ) & hold_pid=$! for _ in $(seq 1 120); do @@ -99,6 +129,15 @@ done [[ "$(psql_q 'select count(*) from push_tokens' | head -1)" -ge 1 ]] \ || { echo 'erro: o token não foi registrado (veja /tmp/push_e2e_hold.txt)'; exit 1; } echo "tokens no banco: $(psql_q 'select count(*) from push_tokens' | head -1)" +# O servidor impõe 60 s entre dois pedidos de OTP do MESMO paciente: o registro acabou de +# pedir um, e as chamadas de consentimento abaixo precisam esperar a janela passar. +otp_last="$(date +%s)" +esperar_otp() { + [[ "$e2e_db" -eq 1 ]] || return 0 + local falta=$(( otp_last + 65 - $(date +%s) )) + (( falta > 0 )) && { echo "(aguardando ${falta}s: intervalo mínimo entre dois OTP do mesmo paciente)"; sleep "$falta"; } + otp_last="$(date +%s)" +} adb -s "$dev" shell pm grant "$app" android.permission.POST_NOTIFICATIONS >/dev/null 2>&1 || true adb -s "$dev" shell cmd notification cancel_all >/dev/null 2>&1 || true @@ -142,10 +181,12 @@ if [[ "$negativos" -eq 1 ]]; then echo 'Gorush parado: OK' echo "== revogar zera os destinatários" + esperar_otp ( cd apps/patient && dart run tool/push_consent.dart --revoke 2>&1 | head -1 ) [[ "$(psql_q 'select count(*) from push_tokens' | head -1)" == 0 ]] || { echo 'FALHOU: a revogação não apagou os tokens'; exit 1; } saida="$(send 'Depois de revogar')"; echo "$saida" grep -q 'recipients=0' <<<"$saida" || { echo 'FALHOU: esperado recipients=0'; exit 1; } + esperar_otp ( cd apps/patient && dart run tool/push_consent.dart --grant 2>&1 | head -1 ) echo 'revogação: OK' fi From 841fdf532ecdb35c44b0aabc958158c41cfdb949 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Wed, 30 Sep 2026 17:56:18 -0400 Subject: [PATCH 102/498] =?UTF-8?q?test(e2e):=20runner=20=C3=BAnico=20do?= =?UTF-8?q?=20teste=20completo=20do=20paciente,=20espera=20ativa=20da=20ba?= =?UTF-8?q?ndeja=20e=20documenta=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- PROGRESS.md | 12 + apps/CLAUDE.md | 2 + backend/CLAUDE.md | 2 +- ...-teste-completo-paciente-banco-de-teste.md | 982 ++++++++++++++++++ scripts/qa/push_e2e.sh | 18 +- 5 files changed, 1010 insertions(+), 6 deletions(-) create mode 100644 docs/superpowers/plans/2026-09-30-teste-completo-paciente-banco-de-teste.md diff --git a/PROGRESS.md b/PROGRESS.md index f1c6780c..edd1f7bb 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1279,3 +1279,15 @@ Plano: `docs/superpowers/plans/2026-09-30-finalizacao-app-paciente.md`, branch ` - 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, MFA/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. diff --git a/apps/CLAUDE.md b/apps/CLAUDE.md index 5f429fa2..92a17c46 100644 --- a/apps/CLAUDE.md +++ b/apps/CLAUDE.md @@ -36,6 +36,8 @@ The patient login is passwordless now (RF01), and no longer uses `developmentLog 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`), 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/`. Risk classification now comes **only** from `triage.evaluate`: the patient app's client-side string-matching rule is gone, and its triage form asks the six symptoms the server's engine actually takes. The ACS dashboard is fed by `AlertQueue`, which receives alerts over MQTT and orders them deterministically by risk and then by age; it rejects alerts from another micro-area and de-duplicates re-deliveries (QoS 1 is at-least-once). diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index ddb026e2..63a6b75e 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -65,7 +65,7 @@ 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 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, sem I/O e sem auditoria; a agenda é a constante `upcomingTermsChange` em `terms_change_schedule.dart`, hoje `null`, 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), 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 +**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 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, sem I/O e sem auditoria; a agenda é a constante `upcomingTermsChange` em `terms_change_schedule.dart`, hoje `null`, 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), 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 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/scripts/qa/push_e2e.sh b/scripts/qa/push_e2e.sh index 4d1825b8..3c1cc9b5 100755 --- a/scripts/qa/push_e2e.sh +++ b/scripts/qa/push_e2e.sh @@ -150,10 +150,18 @@ echo "== envio do ACS" saida="$(send 'Teste do Gorush')" echo "$saida" grep -q 'recipients=1 accepted=1' <<<"$saida" || { echo 'FALHOU: esperado recipients=1 accepted=1'; exit 1; } -sleep 10 -dump="$(adb -s "$dev" shell dumpsys notification --noredact 2>/dev/null)" -grep -A30 "pkg=$app" <<<"$dump" | grep -q 'SinalACS e2e' || { echo 'FALHOU: título não está na bandeja'; exit 1; } -grep -A30 "pkg=$app" <<<"$dump" | grep -q 'Teste do Gorush' || { echo 'FALHOU: texto não está na bandeja'; exit 1; } +# A entrega pelo FCM leva de 1 a dezenas de segundos: espera o texto aparecer em vez de +# dormir um tempo fixo (um `sleep` curto é uma corrida com a rede do emulador). +na_bandeja() { # $1 = texto que deve estar na bandeja do app; espera até 45 s + local _ + for _ in $(seq 1 45); do + adb -s "$dev" shell dumpsys notification --noredact 2>/dev/null | grep -A30 "pkg=$app" | grep -q "$1" && return 0 + sleep 1 + done + return 1 +} +na_bandeja 'SinalACS e2e' || { echo 'FALHOU: título não está na bandeja'; exit 1; } +na_bandeja 'Teste do Gorush' || { echo 'FALHOU: texto não está na bandeja'; exit 1; } [[ "$(psql_q "select result from audit_logs where \"resourceType\"='community_notice' order by timestamp desc limit 1" | head -1)" == granted ]] \ || { echo 'FALHOU: auditoria não é granted'; exit 1; } echo 'caminho feliz: OK' @@ -167,7 +175,7 @@ if [[ "$negativos" -eq 1 ]]; then saida="$(send 'Mistura real falso ios')"; echo "$saida"; sleep 8 [[ "$(psql_q "select count(*) from push_tokens where token=repeat('x',150)" | head -1)" == 0 ]] || { echo 'FALHOU: o token falso não foi podado'; exit 1; } [[ "$(psql_q "select count(*) from push_tokens where platform='ios'" | head -1)" == 1 ]] || { echo 'FALHOU: a linha ios foi apagada'; exit 1; } - adb -s "$dev" shell dumpsys notification --noredact 2>/dev/null | grep -q 'Mistura real falso ios' || { echo 'FALHOU: o token real não recebeu'; exit 1; } + na_bandeja 'Mistura real falso ios' || { echo 'FALHOU: o token real não recebeu'; exit 1; } echo 'token falso podado, ios mantido, token real recebeu: OK' echo "== Gorush parado, com tokens no banco" From e73a763ae65481520e90968fc5db743fee30a98e Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Wed, 30 Sep 2026 18:09:42 -0400 Subject: [PATCH 103/498] =?UTF-8?q?test(e2e):=20achados=20da=20revis=C3=A3?= =?UTF-8?q?o=20independente=20=E2=80=94=20senha=20do=20ACS=20fora=20do=20a?= =?UTF-8?q?rgv,=20rel=C3=B3gio=20do=20host=20no=20OTP,=20avisos=20do=20dow?= =?UTF-8?q?n=20e=20guarda=20do=20rel=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- PROGRESS.md | 1 + apps/CLAUDE.md | 2 +- .../integration_test/full_journey_test.dart | 25 +------- apps/patient/test/e2e_login_test.dart | 21 ++++++- apps/patient/test/support/e2e_config.dart | 8 ++- apps/patient/test/support/e2e_login_core.dart | 22 ++++++- apps/patient/tool/territory_check.dart | 55 ++++++++++++++++++ .../qa/__pycache__/otp_relay.cpython-314.pyc | Bin 3233 -> 3658 bytes scripts/qa/e2e_stack.sh | 7 ++- scripts/qa/otp_relay.py | 5 ++ scripts/qa/patient_full_e2e.sh | 38 ++++++++++-- scripts/qa/push_e2e.sh | 22 ++++++- 12 files changed, 166 insertions(+), 40 deletions(-) create mode 100644 apps/patient/tool/territory_check.dart diff --git a/PROGRESS.md b/PROGRESS.md index edd1f7bb..16426d8d 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1291,3 +1291,4 @@ Plano: `docs/superpowers/plans/2026-09-30-teste-completo-paciente-banco-de-teste - **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. diff --git a/apps/CLAUDE.md b/apps/CLAUDE.md index 92a17c46..49bb6752 100644 --- a/apps/CLAUDE.md +++ b/apps/CLAUDE.md @@ -36,7 +36,7 @@ The patient login is passwordless now (RF01), and no longer uses `developmentLog 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`), 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. +**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/`. diff --git a/apps/patient/integration_test/full_journey_test.dart b/apps/patient/integration_test/full_journey_test.dart index f39906e5..5deef614 100644 --- a/apps/patient/integration_test/full_journey_test.dart +++ b/apps/patient/integration_test/full_journey_test.dart @@ -1,6 +1,6 @@ /// 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". Só roda com as fixtures +/// 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. /// @@ -13,13 +13,10 @@ /// impressos. library; -import 'dart:io' show SecurityContext; - 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_client/sinalacs_client.dart' as rpc; 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'; @@ -73,7 +70,7 @@ void main() { 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 = DateTime.now().toUtc().millisecondsSinceEpoch; + final pedidoEm = await relayNow(_relay); await tapKey(tester, 'enter_button'); await pumpUntil(tester, find.byKey(const Key('otp_code_field'))); return pedidoEm; @@ -165,24 +162,6 @@ void main() { expect(find.text(config.patient('main').name), findsNothing); expect(find.text(config.patient('outsider').name), findsNothing); }); - - test('território (API): o ACS da microárea lista os pacientes dela e não o de outra área', () async { - final ca = (await rootBundle.load(BackendConfig.rpcCaAsset)).buffer.asUint8List(); - final client = rpc.Client( - BackendClient.resolveHost(null), - securityContext: SecurityContext()..setTrustedCertificatesBytes(ca), - )..connectivityMonitor = null; - addTearDown(client.close); - - final session = await client.auth.loginInstitutional( - matricula: config!.acsMatricula, - password: config.acsPassword, - ); - final names = (await client.patients.listMicroArea(accessToken: session.accessToken)).map((p) => p.name).toSet(); - - expect(names, containsAll([config.patient('main').name, config.patient('chronic').name])); - expect(names, isNot(contains(config.patient('outsider').name))); - }); }, ); } diff --git a/apps/patient/test/e2e_login_test.dart b/apps/patient/test/e2e_login_test.dart index 7dd16608..ce073042 100644 --- a/apps/patient/test/e2e_login_test.dart +++ b/apps/patient/test/e2e_login_test.dart @@ -18,10 +18,16 @@ Map _manifesto() => { /// 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 == '/now') { + request.response.write('$relogioDoHost'); + await request.response.close(); + return; + } desdes.add(request.uri.queryParameters['since'] ?? ''); if (chamadas++ < naoRespondidas) { request.response.statusCode = 404; @@ -53,6 +59,15 @@ void main() { 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')}'; @@ -64,7 +79,6 @@ void main() { 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 antes = DateTime.now().toUtc().millisecondsSinceEpoch; final session = await loginPatientWith( backend, @@ -79,9 +93,10 @@ void main() { expect(backend.otpRequests.single.cpf, '11144477735'); expect(backend.otpRequests.single.birthDate, DateTime(1970, 2, 2)); expect(backend.otpVerifications.single.code, '123456'); - // O relé só deve devolver códigos POSTERIORES ao pedido. + // 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(int.parse(desdes.first), greaterThanOrEqualTo(antes)); + expect(desdes.every((d) => d == '1700000000000'), isTrue); }); test('relé mudo: falha com a dica de subir o relé, sem chamar o verifyOtp', () async { diff --git a/apps/patient/test/support/e2e_config.dart b/apps/patient/test/support/e2e_config.dart index 556ee5df..fd5426c8 100644 --- a/apps/patient/test/support/e2e_config.dart +++ b/apps/patient/test/support/e2e_config.dart @@ -44,7 +44,9 @@ class E2eConfig { static E2eConfig? fromMap(Map map) { final list = map['patients']; if (list is! List) return null; - final acs = (map['acs']! as Map).cast(); + // 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()) @@ -57,8 +59,8 @@ class E2eConfig { ), ], microAreaId: map['microAreaId']! as String, - acsMatricula: acs['matricula']! as String, - acsPassword: acs['password']! as String, + acsMatricula: (acs['matricula'] as String?) ?? '', + acsPassword: (acs['password'] as String?) ?? '', ); } diff --git a/apps/patient/test/support/e2e_login_core.dart b/apps/patient/test/support/e2e_login_core.dart index bcacb103..859d5db0 100644 --- a/apps/patient/test/support/e2e_login_core.dart +++ b/apps/patient/test/support/e2e_login_core.dart @@ -23,12 +23,32 @@ Future loginPatientWith( }) async { if (config == null) return backend.developmentLogin(role: 'patient'); final fixture = config.patient(role); - final pedidoEm = DateTime.now().toUtc().millisecondsSinceEpoch; + 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); + } +} + /// O código mais recente escrito DEPOIS de [sinceMs] (epoch, ms). Future codeFromRelay( String relayUrl, 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/scripts/qa/__pycache__/otp_relay.cpython-314.pyc b/scripts/qa/__pycache__/otp_relay.cpython-314.pyc index 78b1aa2514d3687a1e3a342fd0eb987bac78d5ab..438f7d6ce50c3f39f512f4657cf05f27308f5de3 100644 GIT binary patch delta 1020 zcmb7DTSyd97(VOF*%@~(>ZT5Ed-1lSX)3wuCMu#tr4ZBTZZ0i|w3BY=uANy|)`yZ_ zN`f>_P*4wD1nJG_wHrM}PcFN%I`q&}L=Pbp^cJ0Q8#kY-e_;Oq|K`7*@64mB_fD}{ z6l{Qqbp7R}7tAy9Db2COr2t^R{iN_)`Xn3&AaWCn(1AZ$OT3H`eNYsOQsgVjV;&F$ zaxr!=p1!@h2P0+Spl^8L5)%uT?OS&I4!RqCzkP*jf#xRwe3razYPo{6bKolsN6~cv z@6ys~=wBd1mjmE5#*qrSrlnZTY!6UK)*r2_%$T4>c3>GEmRu%M&?S6= z@dm6Aio*q@njqzLut=fs7wF+?_e1NA(6cs$Q}}c3a)XH%bSqQ@@`&Ap2SeuQa8up_ zAV|wcOuL{7+JYCY9kqX77MDTk|EO&KM=FD`zy(K6wW(y0Zi~ei@K?rRkA5c0oLPiz z7vd2$dWVpR?=x=vifI>`Bt(-{bb4+wF^!j4zmL)qNT;H)gia@-v#M@YV@8^8)6`gG zLPaQ!kZ~n+czIq$DV>^96S$8Z!mru!s}WLr349PMR}*(rw^JFh<_5LRi7WnP|7tSJ zxjyoxIo_S--S@OCU-xh<=j}{;J2zr)X0qPV%|Iv@P}2c58<^VWOIL=MhjY9$%{w2A zt{qw%TMs=BJvqPO+wyj2YWx}3@tkWQ?Hb6q2DALo4sR!Yp0^fPS}aHL37fB?k<6$W zgD!y`mPf`_M_u@qt(P-6L>%Rk{caPx^ Gf9e4yLw zTRvt6!?2yJRA{P(6Di7i88OnKuuY0M(pe5pnWcw*Du+`k%DOyYGK<*@GvTp}P0?Ax z9QowiatBrGxN~9;Ar#@Tkm9UMwwO%{!CSKmljNxX|Hcst%ivh$YGwZcH8GcYO?{6q zA?d6_Xq6hL4?4s8K`tU;L;8c0`>(i8ACPmm2VLHKF0cMN{I-gu_eHJ-zC8oGR?wt#R0~FToA_yqV*DmHy1R zssBZA%L!h8b`ERW8Tg)WPO2qi74QgsvRrXUBGH1jJC|3Am{qSfY`H7MCAB{V_$HOI zBoiavj71SIc*tnztr X72bFky(60Ukxu_Yvj3=oPOtm}PyTr( diff --git a/scripts/qa/e2e_stack.sh b/scripts/qa/e2e_stack.sh index 0592629f..1443392b 100755 --- a/scripts/qa/e2e_stack.sh +++ b/scripts/qa/e2e_stack.sh @@ -13,6 +13,9 @@ pg() { docker exec -e PGPASSWORD="$TEST_DATABASE_PASSWORD" sinalacs-postgres-tes case "${1:-}" in up) + if docker ps --format '{{.Names}}' | grep -qx sinalacs-serverpod; then + echo 'aviso: o backend em execução (sinalacs-serverpod) será substituído pelo de e2e; ao final, rode `docker compose up -d` para voltar à stack de desenvolvimento.' >&2 + fi dc up -d postgres-test >/dev/null 2>&1 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. @@ -33,9 +36,11 @@ case "${1:-}" in SERVERPOD_DATABASE_PASSWORD="$TEST_DATABASE_PASSWORD" \ dart run bin/seed_e2e_fixtures.dart ../../.e2e/fixtures.json ) ;; down) + # O manifesto (com a senha do ACS) sai primeiro: se o drop falhar, ele não sobra. + rm -f .e2e/fixtures.json dc stop serverpod >/dev/null 2>&1 || true pg "drop database if exists $db with (force)" postgres >/dev/null - rm -f .e2e/fixtures.json ;; # descreve pacientes de um banco que não existe mais + echo 'A stack de e2e parou (o container sinalacs-serverpod é o mesmo da de desenvolvimento): rode `docker compose up -d` para voltar à de desenvolvimento.' ;; psql) pg "$2" ;; *) echo 'uso: e2e_stack.sh up|seed|down|psql ""'; exit 2 ;; esac diff --git a/scripts/qa/otp_relay.py b/scripts/qa/otp_relay.py index 75a23b90..6aa363cf 100755 --- a/scripts/qa/otp_relay.py +++ b/scripts/qa/otp_relay.py @@ -20,6 +20,11 @@ class Handler(BaseHTTPRequestHandler): def do_GET(self): url = urlparse(self.path) + if url.path == "/now": + # Relógio do host: o corte do código usa ESTE, não o do aparelho. + self.send_response(200); self.send_header("Content-Type", "text/plain"); self.end_headers() + self.wfile.write(str(int(time.time() * 1000)).encode()) + return if url.path != "/code": self.send_error(404); return since_ms = int(parse_qs(url.query).get("since", ["0"])[0]) diff --git a/scripts/qa/patient_full_e2e.sh b/scripts/qa/patient_full_e2e.sh index e1ea7a6a..8cd93816 100755 --- a/scripts/qa/patient_full_e2e.sh +++ b/scripts/qa/patient_full_e2e.sh @@ -8,8 +8,9 @@ # O que faz: sobe a stack de e2e (scripts/qa/e2e_stack.sh: banco `sinalacs_e2e` no # postgres-test, sem login de desenvolvimento), semeia fixtures geradas na hora (UUIDs e # CPFs novos a cada execução), sobe o relé do código OTP e roda, no emulador: -# - integration_test/full_journey_test.dart (OTP, termos, triagem, alerta, status, -# Meus dados e território, pela tela) +# - integration_test/full_journey_test.dart (OTP, termos, triagem, alerta, status e +# Meus dados, pela tela) +# - tool/territory_check.dart (no host: o ACS lista os pacientes da microárea dele e não o de outra) # - integration_test/backend_connection_test.dart (RPC, determinismo, idempotência) # Sem --sem-push, em seguida scripts/qa/push_e2e.sh --e2e-db --negativos (o aviso do ACS # chega à bandeja pelo Gorush e FCM reais). Nada é escrito no banco de desenvolvimento; @@ -35,9 +36,27 @@ dev=emulator-5554 adb -s "$dev" get-state >/dev/null 2>&1 || { echo "emulador $dev não encontrado (adb devices)"; exit 4; } relay_pid="" +# O relé precisa ser ESTE processo: um relé antigo esquecido na porta responderia com código velho. +iniciar_rele() { + 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 + 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 && kill -0 "$relay_pid" 2>/dev/null && return 0 + sleep 0.25 + done + echo 'erro: o relé do OTP não subiu' >&2 + exit 1 +} cleanup() { [[ -n "$relay_pid" ]] && kill "$relay_pid" 2>/dev/null || true - ./scripts/qa/e2e_stack.sh down >/dev/null 2>&1 || true + rm -f .e2e/fixtures.json # primeiro: o manifesto 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 @@ -47,14 +66,21 @@ echo "== stack de e2e (banco de teste)" ./scripts/dev/sync_dev_ca.sh >/dev/null 2>&1 || true 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 >/dev/null 2>&1 & -relay_pid=$! +iniciar_rele -fixtures="$(cat .e2e/fixtures.json)" +# Sem o bloco `acs`: a senha do ACS vai por ambiente, nunca pelo argv do flutter test +# (visível em `ps`) nem compilada no APK. +fixtures="$(python3 -c "import json,sys;d=json.load(open('.e2e/fixtures.json'));d.pop('acs',None);print(json.dumps(d))")" echo "== jornada e conexão no emulador" ( 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" ) +echo "== território (ACS institucional, no host)" +( cd apps/patient + ACS_MATRICULA="$(python3 -c "import json;print(json.load(open('../../.e2e/fixtures.json'))['acs']['matricula'])")" \ + ACS_PASSWORD="$(python3 -c "import json;print(json.load(open('../../.e2e/fixtures.json'))['acs']['password'])")" \ + E2E_FIXTURES_FILE="$repo_root/.e2e/fixtures.json" dart run tool/territory_check.dart ) + if [[ "$push" -eq 1 ]]; then # push_e2e.sh sobe a própria stack de e2e (fixtures novas): derruba esta antes. kill "$relay_pid" 2>/dev/null || true; relay_pid="" diff --git a/scripts/qa/push_e2e.sh b/scripts/qa/push_e2e.sh index 3c1cc9b5..8d5e0ec6 100755 --- a/scripts/qa/push_e2e.sh +++ b/scripts/qa/push_e2e.sh @@ -76,6 +76,20 @@ adb -s "$dev" get-state >/dev/null 2>&1 || { echo "emulador $dev não encontrado # -- stack ------------------------------------------------------------------- relay_pid="" +# O relé precisa ser ESTE processo: um relé antigo esquecido na porta responderia com código velho. +iniciar_rele() { + 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 + iniciar_rele + for _ in $(seq 1 20); do + curl -fs http://127.0.0.1:8765/now >/dev/null 2>&1 && kill -0 "$relay_pid" 2>/dev/null && return 0 + sleep 0.25 + done + echo 'erro: o relé do OTP não subiu' >&2 + exit 1 +} if [[ "$e2e_db" -eq 1 ]]; then echo "== stack de e2e (banco de teste) com o Gorush" ./scripts/qa/e2e_stack.sh up @@ -85,7 +99,8 @@ if [[ "$e2e_db" -eq 1 ]]; then export ACS_MATRICULA ACS_PASSWORD E2E_FIXTURES_FILE="$repo_root/.e2e/fixtures.json" ACS_MATRICULA="$(python3 -c "import json;print(json.load(open('.e2e/fixtures.json'))['acs']['matricula'])")" ACS_PASSWORD="$(python3 -c "import json;print(json.load(open('.e2e/fixtures.json'))['acs']['password'])")" - e2e_fixtures="$(cat .e2e/fixtures.json)" + # Sem o bloco `acs`: a senha do ACS vai por ambiente, nunca pelo argv do flutter test. + e2e_fixtures="$(python3 -c "import json,sys;d=json.load(open('.e2e/fixtures.json'));d.pop('acs',None);print(json.dumps(d))")" else echo "== stack com o perfil push" docker compose --profile push up -d --build >/dev/null 2>&1 @@ -106,7 +121,10 @@ cleanup() { docker compose --profile push start gorush >/dev/null 2>&1 || true psql_q 'delete from push_tokens' >/dev/null 2>&1 || true # O banco de e2e e o manifesto (com a senha do ACS) não sobram depois do teste. - [[ "$e2e_db" -eq 1 ]] && ./scripts/qa/e2e_stack.sh down >/dev/null 2>&1 || true + if [[ "$e2e_db" -eq 1 ]]; then + rm -f .e2e/fixtures.json + ./scripts/qa/e2e_stack.sh down >/dev/null 2>&1 || echo 'aviso: e2e_stack.sh down falhou; o banco sinalacs_e2e pode ter sobrado (docker exec sinalacs-postgres-test psql -U postgres -c "drop database sinalacs_e2e")' >&2 + fi } trap cleanup EXIT From b1d491cef6c7b5ed1622a8ecbc1f61e185e58c6a Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Wed, 30 Sep 2026 18:30:36 -0400 Subject: [PATCH 104/498] =?UTF-8?q?test(e2e):=20fecha=20os=20Minors=20da?= =?UTF-8?q?=20revis=C3=A3o=20=E2=80=94=20Host=20do=20rel=C3=A9,=20guarda?= =?UTF-8?q?=20do=20seeder,=20manifesto=20privado,=20contagem=20de=20OTP=20?= =?UTF-8?q?e=20scripts=20mais=20claros?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .gitignore | 3 ++ PROGRESS.md | 1 + .../integration_test/full_journey_test.dart | 2 + apps/patient/test/e2e_login_test.dart | 15 ++++++ apps/patient/test/support/e2e_login_core.dart | 15 ++++++ .../bin/seed_e2e_fixtures.dart | 19 ++------ .../infrastructure/testing/e2e_fixtures.dart | 45 ++++++++++++++++++ .../test/unit/e2e_fixtures_test.dart | 41 ++++++++++++++++ .../qa/__pycache__/otp_relay.cpython-314.pyc | Bin 3658 -> 0 bytes scripts/qa/e2e_stack.sh | 26 ++++++++-- scripts/qa/otp_relay.py | 29 ++++++++++- scripts/qa/otp_relay_test.py | 20 +++++++- scripts/qa/push_e2e.sh | 7 ++- spec/lgpd_design.md | 12 +++++ 14 files changed, 211 insertions(+), 24 deletions(-) delete mode 100644 scripts/qa/__pycache__/otp_relay.cpython-314.pyc diff --git a/.gitignore b/.gitignore index 852af3c8..d55112df 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,6 @@ 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/PROGRESS.md b/PROGRESS.md index 16426d8d..077f3288 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1292,3 +1292,4 @@ Plano: `docs/superpowers/plans/2026-09-30-teste-completo-paciente-banco-de-teste - **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. diff --git a/apps/patient/integration_test/full_journey_test.dart b/apps/patient/integration_test/full_journey_test.dart index 5deef614..3703e13e 100644 --- a/apps/patient/integration_test/full_journey_test.dart +++ b/apps/patient/integration_test/full_journey_test.dart @@ -116,6 +116,8 @@ void main() { 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 { diff --git a/apps/patient/test/e2e_login_test.dart b/apps/patient/test/e2e_login_test.dart index ce073042..bdbd3cf8 100644 --- a/apps/patient/test/e2e_login_test.dart +++ b/apps/patient/test/e2e_login_test.dart @@ -23,6 +23,12 @@ Future<(HttpServer, List)> _rele({required String codigo, int naoRespond 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(); @@ -99,6 +105,15 @@ void main() { 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(); diff --git a/apps/patient/test/support/e2e_login_core.dart b/apps/patient/test/support/e2e_login_core.dart index 859d5db0..2b9abedd 100644 --- a/apps/patient/test/support/e2e_login_core.dart +++ b/apps/patient/test/support/e2e_login_core.dart @@ -49,6 +49,21 @@ Future relayNow(String relayUrl) async { } } +/// 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, diff --git a/backend/sinalacs_server/bin/seed_e2e_fixtures.dart b/backend/sinalacs_server/bin/seed_e2e_fixtures.dart index 06aa3179..53382e48 100644 --- a/backend/sinalacs_server/bin/seed_e2e_fixtures.dart +++ b/backend/sinalacs_server/bin/seed_e2e_fixtures.dart @@ -20,19 +20,12 @@ import 'package:sinalacs_server/src/infrastructure/testing/e2e_fixtures.dart'; /// `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; - if ((env['APP_ENV'] ?? 'development') != 'development') { - stderr.writeln('Recusando rodar: APP_ENV=${env['APP_ENV']}; este seed é só de desenvolvimento.'); - exit(2); - } - if (env['SERVERPOD_DATABASE_NAME'] != 'sinalacs_e2e') { - stderr.writeln('Recusando rodar: este seed só escreve no banco sinalacs_e2e.'); - exit(2); - } - final password = env['SERVERPOD_DATABASE_PASSWORD']; - if (password == null || password.isEmpty) { - stderr.writeln('SERVERPOD_DATABASE_PASSWORD não definida.'); + 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). @@ -110,8 +103,6 @@ Future main(List args) async { } final out = File(args.isNotEmpty ? args.first : '.e2e/fixtures.json'); - out.parent.createSync(recursive: true); - out.writeAsStringSync(jsonEncode(fixtures.toJson())); - Process.runSync('chmod', ['600', out.path]); + writeManifestPrivately(out, jsonEncode(fixtures.toJson())); stdout.writeln('Fixtures de e2e gravadas (${fixtures.patients.length} pacientes, 1 ACS) em ${out.path}.'); } diff --git a/backend/sinalacs_server/lib/src/infrastructure/testing/e2e_fixtures.dart b/backend/sinalacs_server/lib/src/infrastructure/testing/e2e_fixtures.dart index 00b6d313..36a6a052 100644 --- a/backend/sinalacs_server/lib/src/infrastructure/testing/e2e_fixtures.dart +++ b/backend/sinalacs_server/lib/src/infrastructure/testing/e2e_fixtures.dart @@ -1,3 +1,4 @@ +import 'dart:io'; import 'dart:math'; /// Dados SINTÉTICOS de uma execução de e2e. Nada aqui é estável entre @@ -189,3 +190,47 @@ E2eFixtures generateE2eFixtures(Random random) { ], ); } + +/// 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/test/unit/e2e_fixtures_test.dart b/backend/sinalacs_server/test/unit/e2e_fixtures_test.dart index e6e11f5b..a771332a 100644 --- a/backend/sinalacs_server/test/unit/e2e_fixtures_test.dart +++ b/backend/sinalacs_server/test/unit/e2e_fixtures_test.dart @@ -1,3 +1,4 @@ +import 'dart:io'; import 'dart:math'; import 'package:sinalacs_server/src/application/auth/cpf.dart'; @@ -57,4 +58,44 @@ void main() { } expect(texto.contains(f.acs.password), isFalse); }); + + 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/scripts/qa/__pycache__/otp_relay.cpython-314.pyc b/scripts/qa/__pycache__/otp_relay.cpython-314.pyc deleted file mode 100644 index 438f7d6ce50c3f39f512f4657cf05f27308f5de3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3658 zcmc&0TWk~Ab;dKc$B#HqFdGswE+mT~u_0ualm$w7EMX~$87Bd{NhX7Rleitvn3?N@ zq-vGF4O>;v6%wp=rH1ZSu>A_96@0W3RaBMFST^f0LSm)%qx;8hkhWiK&mH^5jnybOH7_x||9Ke!p@uVg_#Sli!vgk_ioMq)5C&PePT7PB^V zV22Gk%-N8~JV3`8XTJ-(`rX*wM(DXSLca%l`n}lO#*DGCtyFvkh8T(O0UhMSe#seR zaLpD5u&y8@xyM{RTzM}rRx1g;7a1n`eTHda#@s;T8LJC1V_fVzC7SAy${c5VZvs!V z&C~FosK?n(pr9Y+J;zGk1CFskkE65`d!15LU{8742Hx3e|HHoQaX65K)RoV~q%I~_ z9wbv!x;Qu#Bg-iTqgiE6oKW?t2{EG>il~byomG{jZiu_hl?P%HnMj+_)tOWpX;_b< zZWNAqy@MOPNhAWdh|Gk6W)SdG9hxqhIH_kb(PScHCIv#@s#c(_e4>l$%5S_ANj>o+ zWDr5-L_|DgVzj{_qN|ye-@_V=sR&OiDN{610&GH^K$|9p;uPrAV16R@ws$v(2D@w> zx+!9m4im|VELPx%;{NV~k$v#nzsaJ+G@4Sx3>b<1?G96>AH4JyL~Yq=vX-YqB*p9@J9E~ z-&*DOE@2qffrv?vfqv9=>-zwsfP^syvrsl1;|-YG%II)4FfTdadOIZ!Mwi56_XvXp z$+?pWyDWD(vhxilv0h1-T_k^zk8t5#xF{2m;>L%4Aah+9Xp(mg)wxz;DXOxrDKtE*POkxP*t1CUJFtg z6)3qj%c(lSYm^FPS5;k#rF^kgim5=!EeR2p`g)wHzE_kl;~3rWOl(Jmm(EDvsG<-l zz5+wchy(oRt6->XsyV}rZuFekt_mHUfex?all&L<#X2ai0wYeT27ZBq=xG97!0hlIBHOrF#uYeOk0;%kltMoLmA>g3s z@+MLzEe^UT$Rlo%(_kEQbd|cic7${+&xh#r8LH?Qbk= zkJUnZe0BIzaTw)>QDJy$&0lx@oonwD{Vh3v%g6Cg8$TVod+Aq~K6!V!=V|-Fe5g0y z`rTsdv0Ur1eCzRo|HLzY1JyHI@a-V%gVzRcpD6gnI~~s(x8A(E#NGAW@#KShior-O z7`Zq5sPU0AA3XZ#%F|$9zVY~?@Vu_+R?P=Bt2?@Xck2iVj4r@f ztWtVF>?sA&N8T4{NEc-aS&cN3Rmk1IWRlEVV4ewnTc7bJEC<3KOORzvNh4Xdyt16u zlUbF_{jz)|tElCjT3Mb<875XC2Q^)m4N}>R17zrh!P+9rCRT7NAuAXgsmob-j)0JQ zSx%^mX(m$%oIt87%VE~4sX_u9IqEU{7zSyL_CTt>Nt(+)-UEx*>UJ%h`I@hH?*68O zb=JIOU??4dFcACUJ*&y`yl3NCnmPhSJ=|ouWf{rB_njdO8Dl-5^f_`x=()Jw%u|6=@c)7*jKbNY+q4 z;Kd_P>h>Ghc6G^O`_2i6OKv`DoCRXy0Q09XyyRGx{fc+8{5O7v4HX&j1#{>Hv-b;+ z?|Sq5&8tms=RKzvTwn7Jw)2$`WNTIfyIwH>Uv@LRe{sIRz4nX`-0m&q z*2ndQ{l`CyFE!rXc4ynY(66@_TDzX`tF`Bz%;xzEMIPmN^f^EE702++wtfEwbu1(m diff --git a/scripts/qa/e2e_stack.sh b/scripts/qa/e2e_stack.sh index 1443392b..1f17acb4 100755 --- a/scripts/qa/e2e_stack.sh +++ b/scripts/qa/e2e_stack.sh @@ -9,26 +9,42 @@ unset GORUSH_CREDENTIALS_DIR # o Compose o prioriza sobre o .env (ver PROGRESS 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"; } +# `-e PGPASSWORD` sem valor herda do ambiente: a senha não aparece no argv (`ps`). +export PGPASSWORD="$TEST_DATABASE_PASSWORD" +pg() { docker exec -e PGPASSWORD sinalacs-postgres-test psql -U postgres -d "${2:-$db}" -Atc "$1"; } case "${1:-}" in up) if docker ps --format '{{.Names}}' | grep -qx sinalacs-serverpod; then echo 'aviso: o backend em execução (sinalacs-serverpod) será substituído pelo de e2e; ao final, rode `docker compose up -d` para voltar à stack de desenvolvimento.' >&2 fi - dc up -d postgres-test >/dev/null 2>&1 - until docker exec sinalacs-postgres-test pg_isready -U postgres -d sinalacs_test >/dev/null 2>&1; do sleep 1; done + log="$(mktemp)"; trap 'rm -f "$log"' EXIT + falhou() { echo "erro: $1" >&2; tail -n 25 "$log" >&2; exit 1; } + dc up -d postgres-test >"$log" 2>&1 || falhou 'o postgres-test não subiu' + ok=0 + for _ in $(seq 1 60); do + docker exec sinalacs-postgres-test pg_isready -U postgres -d sinalacs_test >/dev/null 2>&1 && { ok=1; break; } + sleep 1 + done + [[ "$ok" -eq 1 ]] || falhou 'o postgres-test não ficou pronto em 60 s' # Recria o banco: cada execução parte de um banco vazio. dc stop serverpod >/dev/null 2>&1 || true 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 do `seed`). - dc up -d --build --force-recreate --no-deps serverpod gorush traefik mosquitto >/dev/null 2>&1 + # Sem `--no-deps` o Compose sobe também o postgres de desenvolvimento; COM ele, os + # certificados do Traefik e o passwordfile do Mosquitto precisam existir de uma subida + # anterior da stack normal — a falta aparece aqui em vez de sumir. + for f in infra/docker/traefik/runtime/certs/ca.crt; do + [[ -f "$f" ]] || falhou "falta $f: suba a stack normal uma vez (docker compose up -d) para gerar os certificados" + done + dc up -d --build --force-recreate --no-deps serverpod gorush traefik mosquitto >"$log" 2>&1 \ + || falhou 'a subida do backend falhou' 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 ;; + echo 'erro: serverpod não ficou saudável' >&2; dc logs --tail 40 serverpod >&2; exit 1 ;; seed) ( cd backend/sinalacs_server && \ SERVERPOD_DATABASE_HOST=localhost SERVERPOD_DATABASE_PORT=9090 \ diff --git a/scripts/qa/otp_relay.py b/scripts/qa/otp_relay.py index 6aa363cf..405b00a4 100755 --- a/scripts/qa/otp_relay.py +++ b/scripts/qa/otp_relay.py @@ -15,22 +15,43 @@ def parse_latest_code(log_text): achados = PADRAO.findall(log_text) return achados[-1] if achados else None +def host_permitido(host, porta): + """Só `localhost:` ou `127.0.0.1:`: um navegador com DNS rebinding + chega aqui com o `Host` do site malicioso e é recusado.""" + return (host or "").lower() in (f"localhost:{porta}", f"127.0.0.1:{porta}") + +# O relé se encerra sozinho: um runner morto por SIGKILL não deixa um leitor do +# `docker logs` da stack de desenvolvimento ocupando a porta. +VIDA_MAXIMA_S = 45 * 60 + +def count_codes(log_text): + return len(PADRAO.findall(log_text)) + class Handler(BaseHTTPRequestHandler): + porta = 8765 container = "sinalacs-serverpod" def do_GET(self): + if not host_permitido(self.headers.get("Host"), self.porta): + self.send_error(403); return url = urlparse(self.path) if url.path == "/now": # Relógio do host: o corte do código usa ESTE, não o do aparelho. self.send_response(200); self.send_header("Content-Type", "text/plain"); self.end_headers() self.wfile.write(str(int(time.time() * 1000)).encode()) return - if url.path != "/code": + if url.path not in ("/code", "/count"): 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)) saida = subprocess.run(["docker", "logs", "--since", desde, self.container], capture_output=True, text=True) + if url.path == "/count": + # Quantos códigos foram emitidos depois de `since`: prova que um erro de + # digitação não gastou outro pedido de SMS. + self.send_response(200); self.send_header("Content-Type", "text/plain"); self.end_headers() + self.wfile.write(str(count_codes(saida.stdout + saida.stderr)).encode()) + return code = parse_latest_code(saida.stdout + saida.stderr) if code is None: self.send_error(404); return @@ -41,4 +62,8 @@ 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() + import threading + Handler.porta = int(sys.argv[1]) if len(sys.argv) > 1 else 8765 + servidor = HTTPServer(("127.0.0.1", Handler.porta), Handler) + threading.Timer(VIDA_MAXIMA_S, servidor.shutdown).start() + servidor.serve_forever() diff --git a/scripts/qa/otp_relay_test.py b/scripts/qa/otp_relay_test.py index 790c07c9..5608c8f1 100644 --- a/scripts/qa/otp_relay_test.py +++ b/scripts/qa/otp_relay_test.py @@ -1,5 +1,5 @@ import unittest -from otp_relay import parse_latest_code +from otp_relay import parse_latest_code, host_permitido, count_codes class T(unittest.TestCase): def test_sem_linha(self): @@ -12,5 +12,23 @@ def test_pega_a_mais_recente(self): def test_ignora_seis_digitos_fora_da_linha_do_gateway(self): self.assertIsNone(parse_latest_code("pedido 123456 recebido\n")) +class ContagemTest(unittest.TestCase): + L = "[SMS-GATEWAY=log] código de acesso: %s (gateway de desenvolvimento — nenhum SMS foi enviado)\n" + + def test_sem_linhas(self): + self.assertEqual(count_codes("nada\n"), 0) + + def test_conta_uma_por_pedido_e_ignora_ruido(self): + self.assertEqual(count_codes(self.L % "111111" + "ruído 222222\n" + self.L % "333333"), 2) + +class HostTest(unittest.TestCase): + def test_aceita_so_o_proprio_relé_em_loopback(self): + for h in ("localhost:8765", "127.0.0.1:8765", "LOCALHOST:8765"): + self.assertTrue(host_permitido(h, 8765), h) + + def test_recusa_outro_host_contra_dns_rebinding(self): + for h in (None, "", "evil.example:8765", "localhost:9999", "127.0.0.1", "localhost.evil.example:8765"): + self.assertFalse(host_permitido(h, 8765), h) + if __name__ == "__main__": unittest.main() diff --git a/scripts/qa/push_e2e.sh b/scripts/qa/push_e2e.sh index 8d5e0ec6..2a6d4fdc 100755 --- a/scripts/qa/push_e2e.sh +++ b/scripts/qa/push_e2e.sh @@ -171,9 +171,12 @@ grep -q 'recipients=1 accepted=1' <<<"$saida" || { echo 'FALHOU: esperado recipi # A entrega pelo FCM leva de 1 a dezenas de segundos: espera o texto aparecer em vez de # dormir um tempo fixo (um `sleep` curto é uma corrida com a rede do emulador). na_bandeja() { # $1 = texto que deve estar na bandeja do app; espera até 45 s - local _ + local _ dump for _ in $(seq 1 45); do - adb -s "$dev" shell dumpsys notification --noredact 2>/dev/null | grep -A30 "pkg=$app" | grep -q "$1" && return 0 + # Captura antes de filtrar: `grep -q` fecha o pipe no 1º achado, e sob `pipefail` o + # SIGPIPE do `grep -A` viraria um "não achou" falso. + dump="$(adb -s "$dev" shell dumpsys notification --noredact 2>/dev/null || true)" + grep -A30 "pkg=$app" <<<"$dump" | grep -q "$1" && return 0 sleep 1 done return 1 diff --git a/spec/lgpd_design.md b/spec/lgpd_design.md index 0856bd7c..f1cd4724 100644 --- a/spec/lgpd_design.md +++ b/spec/lgpd_design.md @@ -260,6 +260,18 @@ também passa pelo FCM (Google): decisão de produto da §3.2, não tratada aqui --- +### Dados sintéticos em teste e2e (CPF gerado) + +A stack de e2e (`docker-compose.e2e.yml`, `bin/seed_e2e_fixtures.dart`) gera CPFs +aleatórios **com dígito verificador válido**, porque o login exige essa validade. Não +existe faixa de CPF reconhecidamente fictícia: um valor gerado pode, por acaso, +coincidir com o CPF de uma pessoa real. O risco é aceito porque o valor nunca aparece +sozinho: vem com nome, nascimento e contato inventados, vive só num banco efêmero +(apagado ao final), só é gravado como HMAC (`users.cpfHash`), o manifesto que o guarda +é `0600` e apagado ao final, e nenhum log, mensagem de erro ou `toString` o imprime. +Se a coincidência virar preocupação, a saída é trocar a geração por uma lista fixa de +CPFs de documentação (como faz `seed_cpf_hashes.dart` para a stack de desenvolvimento). + ## 2. Requisitos para Termo de Uso e Política de Privacidade ### LGPD-RF18 - Termo de Uso From 6eec8e1a54e93b78c2c66b8d5c336a8fba34bca5 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Wed, 30 Sep 2026 19:10:20 -0400 Subject: [PATCH 105/498] 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 --- scripts/ci/decode_secret_file.sh | 116 ++++++++++++++++ scripts/ci/decode_secret_file_test.sh | 183 ++++++++++++++++++++++++++ 2 files changed, 299 insertions(+) create mode 100755 scripts/ci/decode_secret_file.sh create mode 100755 scripts/ci/decode_secret_file_test.sh diff --git a/scripts/ci/decode_secret_file.sh b/scripts/ci/decode_secret_file.sh new file mode 100755 index 00000000..28261f67 --- /dev/null +++ b/scripts/ci/decode_secret_file.sh @@ -0,0 +1,116 @@ +#!/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 diff --git a/scripts/ci/decode_secret_file_test.sh b/scripts/ci/decode_secret_file_test.sh new file mode 100755 index 00000000..04a0a7e7 --- /dev/null +++ b/scripts/ci/decode_secret_file_test.sh @@ -0,0 +1,183 @@ +#!/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; } From d007f29f6c4b48eac462869ac6ec08e0b9b2551c Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Wed, 30 Sep 2026 19:10:31 -0400 Subject: [PATCH 106/498] =?UTF-8?q?ci(android-e2e):=20push=20e2e=20com=20o?= =?UTF-8?q?=20Gorush=20e=20o=20FCM=20reais=20quando=20h=C3=A1=20credenciai?= =?UTF-8?q?s,=20por=20=C3=BAltimo=20e=20propagando=20o=20resultado?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- scripts/qa/ci_push_e2e.sh | 26 ++++++++++++++ scripts/qa/ci_push_e2e_test.sh | 62 ++++++++++++++++++++++++++++++++++ scripts/qa/run_android_e2e.sh | 9 ++++- 3 files changed, 96 insertions(+), 1 deletion(-) create mode 100755 scripts/qa/ci_push_e2e.sh create mode 100755 scripts/qa/ci_push_e2e_test.sh diff --git a/scripts/qa/ci_push_e2e.sh b/scripts/qa/ci_push_e2e.sh new file mode 100755 index 00000000..53f30f1d --- /dev/null +++ b/scripts/qa/ci_push_e2e.sh @@ -0,0 +1,26 @@ +#!/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" diff --git a/scripts/qa/ci_push_e2e_test.sh b/scripts/qa/ci_push_e2e_test.sh new file mode 100755 index 00000000..ad02ef96 --- /dev/null +++ b/scripts/qa/ci_push_e2e_test.sh @@ -0,0 +1,62 @@ +#!/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; } diff --git a/scripts/qa/run_android_e2e.sh b/scripts/qa/run_android_e2e.sh index 597891aa..75e5e731 100755 --- a/scripts/qa/run_android_e2e.sh +++ b/scripts/qa/run_android_e2e.sh @@ -12,7 +12,7 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" cd "$repo_root" -trap 'docker compose down' EXIT +trap 'docker compose --profile push down' EXIT # GOOGLE_MAPS_API_KEY não é mais obrigatória: a CI roda só o smoke de cada app # (e2e.sh --emulator, sem --full), e o map_flow_test.dart ficou fora dele. Se @@ -29,3 +29,10 @@ set +a 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" From c4a51791ca8cc34c821c2faf61aa1169a30acbb2 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Wed, 30 Sep 2026 19:10:47 -0400 Subject: [PATCH 107/498] ci: invariantes das credenciais do FCM e do emulador com Play Services Co-Authored-By: Claude Sonnet 5.5 --- scripts/qa/ci_invariants.sh | 76 +++++++++++++++++++++++++++- scripts/qa/ci_invariants_fcm_test.sh | 56 ++++++++++++++++++++ 2 files changed, 130 insertions(+), 2 deletions(-) create mode 100755 scripts/qa/ci_invariants_fcm_test.sh diff --git a/scripts/qa/ci_invariants.sh b/scripts/qa/ci_invariants.sh index 9e3fb821..a002081d 100755 --- a/scripts/qa/ci_invariants.sh +++ b/scripts/qa/ci_invariants.sh @@ -17,7 +17,7 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -exec python3 - "$repo_root/.github/workflows/ci.yml" "$@" <<'PY' +exec python3 - "${CI_WORKFLOW_PATH:-$repo_root/.github/workflows/ci.yml}" "$@" <<'PY' import json import sys @@ -181,9 +181,81 @@ def check_checks_obrigatorios(): falhas.append(f'FINDING-5: {nome} tem if: no nível do job; pode nunca reportar') +# Credenciais do FCM (chave de conta de serviço e google-services.json) no android-e2e. +# Os secrets valem acesso ao projeto Firebase; estas propriedades têm de sobreviver a +# edições: +# 1. cada secret entra por `env:` do PASSO — nunca dentro de `run:` (injeção de script, e +# um `echo` o vazaria) nem no `env:` do JOB (toda ação de terceiros o veria); +# 2. os passos que os decodificam vêm ANTES do E2E (o build do paciente e o push e2e os +# leem); +# 3. um passo `if: always()` apaga os três arquivos depois; +# 4. o push só entrega com Play Services: o emulador precisa de `target: google_apis` (o +# padrão da action é a imagem AOSP, sem ele), e a chave do cache do AVD precisa +# conter o target, senão um AVD antigo seria restaurado em cima do novo. +SEGREDOS_FCM = { + 'FCM_CREDENTIALS_BASE64': 'Decodifica a credencial do FCM', + 'GOOGLE_SERVICES_JSON_BASE64': 'Decodifica o google-services.json', +} +LIMPEZA_FCM = 'Remove as credenciais do FCM' +LIMPEZAS_ESPERADAS = ( + 'decode_secret_file.sh --cleanup-temp GOOGLE_APPLICATION_CREDENTIALS', + '--cleanup-file apps/patient/android/app/google-services.json', + '--cleanup-file infra/docker/gorush/credentials/fcm-service-account.json', +) +TARGET_EMULADOR = 'google_apis' + + +def check_credenciais_fcm(): + for nome_job, job in jobs.items(): + for segredo in SEGREDOS_FCM: + if segredo in (job.get('env') or {}): + falhas.append(f'FCM: {nome_job} declara {segredo} no env: do job; declare só no passo') + for passo in job.get('steps') or []: + if f'secrets.{segredo}' in (passo.get('run') or ''): + falhas.append(f"FCM: o passo {passo.get('name')!r} de {nome_job} usa secrets.{segredo} dentro de run:; passe por env: do passo") + passos = (jobs.get('android-e2e') or {}).get('steps') or [] + nomes = [p.get('name', '') for p in passos] + indice_e2e = nomes.index('E2E no emulador Android') if 'E2E no emulador Android' in nomes else None + ultimo = -1 + for segredo, nome_passo in SEGREDOS_FCM.items(): + if nome_passo not in nomes: + falhas.append(f'FCM: android-e2e sem o passo {nome_passo!r}') + continue + i = nomes.index(nome_passo) + ultimo = max(ultimo, i) + passo = passos[i] + if (passo.get('env') or {}).get(segredo) != '${{ secrets.' + segredo + ' }}': + falhas.append(f'FCM: o passo {nome_passo!r} precisa de env: {segredo}: ${{{{ secrets.{segredo} }}}}') + if 'decode_secret_file.sh' not in (passo.get('run') or ''): + falhas.append(f'FCM: o passo {nome_passo!r} precisa chamar scripts/ci/decode_secret_file.sh') + if indice_e2e is not None and i > 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}') + + CHECKS = [check_jobs, check_gatilhos, check_sem_filtro_de_paths, check_concorrencia, check_limpeza_do_workspace, check_versoes_de_acoes, - check_runner, check_checks_obrigatorios] + check_runner, check_checks_obrigatorios, check_credenciais_fcm] for check in CHECKS: check() diff --git a/scripts/qa/ci_invariants_fcm_test.sh b/scripts/qa/ci_invariants_fcm_test.sh new file mode 100755 index 00000000..f254a190 --- /dev/null +++ b/scripts/qa/ci_invariants_fcm_test.sh @@ -0,0 +1,56 @@ +#!/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" < Date: Wed, 30 Sep 2026 19:11:17 -0400 Subject: [PATCH 108/498] 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 --- .github/workflows/ci.yml | 41 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3787b7ba..9265ce87 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,11 @@ 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 + - 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 serverpod-backend: runs-on: ubuntu-24.04 defaults: @@ -214,6 +219,27 @@ jobs: GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }} steps: - uses: actions/checkout@v7 + # 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 # 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 @@ -254,12 +280,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 @@ -282,6 +311,7 @@ jobs: 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 +326,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 From 86e13e24de21868c5bea878168d559a19f8dd32a Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Wed, 30 Sep 2026 19:11:34 -0400 Subject: [PATCH 109/498] docs: credenciais do FCM e push e2e no CI (secrets, destinos e o que o runner prova) Co-Authored-By: Claude Sonnet 5.5 --- CLAUDE.md | 2 +- PROGRESS.md | 11 +++++++++++ infra/docker/gorush/README.md | 17 +++++++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2c4d8ae7..8db3db6d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -105,7 +105,7 @@ 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 `-`. +- 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 `-`. `workflow-lint` also runs `scripts/ci/decode_secret_file_test.sh`, `scripts/qa/ci_push_e2e_test.sh` and `scripts/qa/ci_invariants_fcm_test.sh`, 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 gaps in the guard are issues #19–#24. - Never commit real patient data, credentials, or the dev Docker Compose secrets into anything beyond local development. diff --git a/PROGRESS.md b/PROGRESS.md index 077f3288..eab5a33d 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1293,3 +1293,14 @@ Plano: `docs/superpowers/plans/2026-09-30-teste-completo-paciente-banco-de-teste - **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. diff --git a/infra/docker/gorush/README.md b/infra/docker/gorush/README.md index f1948482..12fa496f 100644 --- a/infra/docker/gorush/README.md +++ b/infra/docker/gorush/README.md @@ -70,3 +70,20 @@ Com a chave real do projeto `sinal-acs` e um token **falso** (nada é entregue a - Renovação do token (`onNewToken`) e abrir uma tela ao tocar na notificação. - A string de erro de outros casos do FCM (cota, mensagem malformada) além do token inválido. - Gorush hospedado fora do Compose local. + +## 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. From 32e6e88d8d85be2cbf3f35bbda41c1f4a9e9e160 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Wed, 30 Sep 2026 19:18:48 -0400 Subject: [PATCH 110/498] =?UTF-8?q?ci:=20Gorush=20roda=20com=20o=20uid=20d?= =?UTF-8?q?o=20dono=20da=20chave=200600=20e=20as=20credenciais=20s=C3=B3?= =?UTF-8?q?=20existem=20em=20disco=20logo=20antes=20do=20E2E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Achados da revisão independente: o container (uid 1000) não lia a chave do usuário do runner (uid 1001), e as credenciais ficavam em disco durante ações de terceiros. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/ci.yml | 54 ++++++++++++++-------------- PROGRESS.md | 1 + docker-compose.yml | 5 +++ infra/docker/gorush/README.md | 7 ++++ scripts/qa/ci_invariants.sh | 8 +++++ scripts/qa/ci_invariants_fcm_test.sh | 2 ++ scripts/qa/ci_push_e2e.sh | 5 +++ scripts/qa/ci_push_e2e_test.sh | 4 +++ 8 files changed, 60 insertions(+), 26 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9265ce87..266f3120 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -219,32 +219,6 @@ jobs: GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }} steps: - uses: actions/checkout@v7 - # 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 - # 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"' \ @@ -307,6 +281,34 @@ 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: diff --git a/PROGRESS.md b/PROGRESS.md index eab5a33d..a295765e 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1304,3 +1304,4 @@ Plano: `docs/superpowers/plans/2026-09-30-ci-credencial-fcm.md`, branch `fix/pat - `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. diff --git a/docker-compose.yml b/docker-compose.yml index 5d1134d7..22099347 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -89,6 +89,11 @@ services: 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:-} diff --git a/infra/docker/gorush/README.md b/infra/docker/gorush/README.md index 12fa496f..dbdc3a49 100644 --- a/infra/docker/gorush/README.md +++ b/infra/docker/gorush/README.md @@ -87,3 +87,10 @@ 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. + +**Dono da chave no runner.** A chave precisa ser `0600` (o `push_e2e.sh` exige) e a imagem do +Gorush roda como uid 1000, mas no runner o dono é o usuário `runner` (uid 1001): o Gorush cairia +com `cannot read credentials file … permission denied`. Por isso o serviço `gorush` do +`docker-compose.yml` roda com `user: ${GORUSH_UID:-1000}:${GORUSH_GID:-1000}` e o +`ci_push_e2e.sh` exporta o uid/gid de quem instalou a chave. Fora do CI o padrão 1000 é o da imagem. + diff --git a/scripts/qa/ci_invariants.sh b/scripts/qa/ci_invariants.sh index a002081d..3d2b4dfb 100755 --- a/scripts/qa/ci_invariants.sh +++ b/scripts/qa/ci_invariants.sh @@ -230,6 +230,14 @@ def check_credenciais_fcm(): falhas.append(f'FCM: o passo {nome_passo!r} precisa chamar scripts/ci/decode_secret_file.sh') if indice_e2e is not None and i > indice_e2e: falhas.append(f'FCM: {nome_passo!r} vem DEPOIS do E2E; o arquivo não existiria nele') + # As credenciais só podem existir em disco quando o passo do E2E (que as usa) roda: entre a + # primeira decodificação e ele não pode haver ação de terceiros (setup-java, flutter-action, + # cache...), que leria os arquivos sem precisar deles. Só passos `run:` ficam no meio. + decodificados = [nomes.index(n) for n in SEGREDOS_FCM.values() if n in nomes] + if decodificados and indice_e2e is not None: + for passo in passos[min(decodificados) + 1:indice_e2e]: + if passo.get('uses'): + falhas.append(f"FCM: a ação {passo['uses']!r} roda com as credenciais já em disco; decodifique logo antes de 'E2E no emulador Android'") 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') diff --git a/scripts/qa/ci_invariants_fcm_test.sh b/scripts/qa/ci_invariants_fcm_test.sh index f254a190..5aa346cc 100755 --- a/scripts/qa/ci_invariants_fcm_test.sh +++ b/scripts/qa/ci_invariants_fcm_test.sh @@ -47,6 +47,8 @@ muta limpeza_sem_chave_do_gorush \ "t.replace('--cleanup-file infra/docker/gorush/credentials/fcm-service-account.json', 'true', 1)" muta decodifica_depois_do_e2e \ "t.replace(' - name: Decodifica o google-services.json', ' - name: Decodifica o google-services.json (movido)', 1)" +muta credencial_em_disco_durante_acao_de_terceiros \ + "t.replace(' - name: E2E no emulador Android\n', ' - uses: actions/setup-java@v6\n - name: E2E no emulador Android\n', 1)" muta emulador_sem_play_services \ "t.replace(' target: google_apis\n', '', 1)" muta cache_do_avd_sem_o_target \ diff --git a/scripts/qa/ci_push_e2e.sh b/scripts/qa/ci_push_e2e.sh index 53f30f1d..00add862 100755 --- a/scripts/qa/ci_push_e2e.sh +++ b/scripts/qa/ci_push_e2e.sh @@ -23,4 +23,9 @@ if [[ -z "$origem" || ! -f "$origem" || ! -f "$google_services" ]]; then fi install -D -m 600 "$origem" "$destino" +# O Gorush (imagem com uid 1000) precisa ler esta chave 0600, que pertence ao usuário do +# runner (uid 1001): o container roda com o uid/gid de quem a instalou (docker-compose.yml). +export GORUSH_UID GORUSH_GID +GORUSH_UID="$(id -u)" +GORUSH_GID="$(id -g)" exec "$push" diff --git a/scripts/qa/ci_push_e2e_test.sh b/scripts/qa/ci_push_e2e_test.sh index ad02ef96..c12c2c9c 100755 --- a/scripts/qa/ci_push_e2e_test.sh +++ b/scripts/qa/ci_push_e2e_test.sh @@ -3,6 +3,7 @@ set -uo pipefail cd "$(dirname "$0")/../.." orig="$PWD/scripts/qa/ci_push_e2e.sh" +repo="$PWD" 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; } @@ -14,6 +15,7 @@ nova_arvore() { #!/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" +echo "${GORUSH_UID:-}:${GORUSH_GID:-}" >"$(dirname "$0")/uid_gid" exit "${STUB_RC:-0}" STUB chmod +x "$R/stub_push.sh" "$R/scripts/qa/ci_push_e2e.sh" @@ -50,6 +52,8 @@ 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 "o container do Gorush roda com o uid/gid do host (a chave 0600 é do usuário do runner)" '[[ "$(cat "$R/uid_gid")" == "$(id -u):$(id -g)" ]]' +afirma "o docker-compose.yml usa GORUSH_UID/GORUSH_GID no serviço gorush" 'sed -n "/^ gorush:/,/^ serverpod:/p" "$repo/docker-compose.yml" | grep -q "GORUSH_UID" && sed -n "/^ gorush:/,/^ serverpod:/p" "$repo/docker-compose.yml" | grep -q "GORUSH_GID"' afirma "a saída não imprime o conteúdo da chave" '! grep -q "FAKE" <<<"$out"' nova_arvore From 16f74359d94bc541464e987b9bae9e87d1707644 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Wed, 30 Sep 2026 19:28:21 -0400 Subject: [PATCH 111/498] =?UTF-8?q?ci:=20erro=20de=20base64=20inv=C3=A1lid?= =?UTF-8?q?o=20traz=20diagn=C3=B3stico=20sem=20valores=20(tamanho,=20alfab?= =?UTF-8?q?eto,=20JSON=20em=20claro,=20base64url)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O GitHub mascara o secret no log; sem isto um secret cadastrado errado é indiagnosticável. Co-Authored-By: Claude Sonnet 5.5 --- scripts/ci/decode_secret_file.sh | 11 ++++++++++- scripts/ci/decode_secret_file_test.sh | 16 ++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/scripts/ci/decode_secret_file.sh b/scripts/ci/decode_secret_file.sh index 28261f67..b7da4934 100755 --- a/scripts/ci/decode_secret_file.sh +++ b/scripts/ci/decode_secret_file.sh @@ -79,7 +79,16 @@ falhar() { # 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." + # Diagnóstico SEM valores: só tamanho, contagens e o formato geral. Sem ele, um secret + # cadastrado errado (JSON em claro, base64url, truncado) é impossível de diagnosticar, já + # que o GitHub o mascara no log. + limpo="$(printf '%s' "$segredo" | tr -d '[:space:]')" + fora="$(printf '%s' "$limpo" | tr -d 'A-Za-z0-9+/=' | wc -c | tr -d ' ')" + dica='' + [[ "$limpo" == \{* ]] && dica+=" O valor começa com '{': parece o JSON em claro, e não o base64 (use: base64 -w0 arquivo.json)." + [[ "$limpo" == \"* || "$limpo" == \'* ]] && dica+=' O valor começa com aspas: cadastre-o sem aspas.' + [[ "$limpo" == *[-_]* ]] && dica+=' Tem "-" ou "_": parece base64url; o secret usa o base64 padrão (base64 -w0).' + falhar "$env_nome não é um base64 válido (tamanho=${#limpo}; resto=$(( ${#limpo} % 4 )) por 4; caracteres fora do alfabeto base64=$fora).$dica" fi # Só confirma a FORMA. Saída e erro descartados: um traceback do Python imprimiria o dado. diff --git a/scripts/ci/decode_secret_file_test.sh b/scripts/ci/decode_secret_file_test.sh index 04a0a7e7..aaf5746f 100755 --- a/scripts/ci/decode_secret_file_test.sh +++ b/scripts/ci/decode_secret_file_test.sh @@ -90,6 +90,21 @@ t_service_account_invalido_nao_vaza() { done } +t_base64_invalido_da_dica_sem_vazar() { + novo_ambiente + export FCM_SECRET="$(sa_json)" # o JSON em claro, colado no lugar do base64 + out="$(sa 2>&1)"; rc=$? + afirma "JSON em claro: exit 1 e a dica diz que parece JSON, não base64" '[[ $rc -eq 1 ]] && grep -q "parece o JSON em claro" <<<"$out"' + afirma "JSON em claro: a dica não vaza o conteúdo" '! grep -q "$FAKE_KEY" <<<"$out" && ! grep -q "client_email" <<<"$out"' + export FCM_SECRET="$(sa_json | base64 -w0 | tr '+/' '-_')A-_-_" + out="$(sa 2>&1)" + afirma "base64url: a dica aponta o alfabeto e traz contagens" 'grep -q "tamanho=" <<<"$out" && grep -q "fora do alfabeto" <<<"$out"' + export FCM_SECRET="$(sa_json | base64 -w0 | head -c 41)" # base64 truncado: tamanho que não fecha em múltiplo de 4 + out="$(sa 2>&1)" + afirma "truncado: a dica mostra o resto do tamanho por 4" 'grep -q "resto=1" <<<"$out"' + afirma "truncado: nada do conteúdo na saída" '! grep -q "$FAKE_KEY" <<<"$out"' +} + t_google_services_grava_no_destino() { novo_ambiente export GS_SECRET="$(gs_json | base64 -w0)" @@ -171,6 +186,7 @@ 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_base64_invalido_da_dica_sem_vazar t_google_services_grava_no_destino t_google_services_invalido t_nao_sobrescreve_fora_do_ci From 9e35b7ba0dc60efa300fbf942376195e310d49f5 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Wed, 30 Sep 2026 19:46:19 -0400 Subject: [PATCH 112/498] docs: CI com Gorush e FCM reais provado no runner (run 36790685752) e plano das credenciais do FCM Co-Authored-By: Claude Sonnet 5.5 --- PROGRESS.md | 2 + .../plans/2026-09-30-ci-credencial-fcm.md | 971 ++++++++++++++++++ 2 files changed, 973 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-30-ci-credencial-fcm.md diff --git a/PROGRESS.md b/PROGRESS.md index a295765e..bddb49a5 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1305,3 +1305,5 @@ Plano: `docs/superpowers/plans/2026-09-30-ci-credencial-fcm.md`, branch `fix/pat - **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). 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). From cc22d2b7e2bddebcde7f92fd96302b34d203fb21 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Wed, 30 Sep 2026 19:50:58 -0400 Subject: [PATCH 113/498] =?UTF-8?q?ci:=20decode=5Fsecret=5Ffile=20endureci?= =?UTF-8?q?do=20=E2=80=94=20set=20+x,=20nome=20de=20vari=C3=A1vel=20valida?= =?UTF-8?q?do,=20cleanup=20normaliza=20o=20caminho=20e=20o=20tempor=C3=A1r?= =?UTF-8?q?io=20n=C3=A3o=20sobra=20(M4=E2=80=93M7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- scripts/ci/decode_secret_file.sh | 23 +++++++++++++++--- scripts/ci/decode_secret_file_test.sh | 34 ++++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/scripts/ci/decode_secret_file.sh b/scripts/ci/decode_secret_file.sh index b7da4934..1693deed 100755 --- a/scripts/ci/decode_secret_file.sh +++ b/scripts/ci/decode_secret_file.sh @@ -14,9 +14,14 @@ # 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). +# Um xtrace herdado do ambiente (SHELLOPTS=xtrace, `bash -x`) imprimiria o secret: desliga já. +set +x set -euo pipefail umask 077 +# Os nomes viram expansão indireta (${!nome}): um nome como `a[$(cmd)]` executaria `cmd`. +nome_valido() { [[ "$1" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; } + 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='' @@ -34,11 +39,17 @@ while [[ $# -gt 0 ]]; do done if [[ "$modo" == limpar_temp ]]; then + nome_valido "$alvo" || uso 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" + # redefinido a variável para um arquivo que não é nosso. O caminho é normalizado antes da + # comparação: `$RUNNER_TEMP/../x` começa pelo prefixo certo e sai do diretório. + if [[ -n "$arquivo" && -n "${RUNNER_TEMP:-}" ]]; then + resolvido="$(realpath -m -- "$arquivo")" + base="$(realpath -m -- "$RUNNER_TEMP")" + if [[ "$resolvido" == "$base"/* ]]; then + rm -f -- "$resolvido" + fi fi exit 0 fi @@ -54,6 +65,8 @@ fi # -- decodificar ------------------------------------------------------------- [[ -n "$env_nome" && -n "$tipo" ]] || uso +nome_valido "$env_nome" || uso +[[ -z "$temp_export" ]] || nome_valido "$temp_export" || uso [[ "$tipo" == service_account || "$tipo" == google_services ]] || uso if [[ -n "$temp_export" && -n "$destino" ]] || [[ -z "$temp_export" && -z "$destino" ]]; then uso; fi @@ -70,6 +83,8 @@ fi dir="${RUNNER_TEMP:-$(mktemp -d)}" tmp="$(mktemp --suffix=.json "$dir/secret.XXXXXX")" +# Qualquer saída antes do fim (chmod, mkdir ou mv que falham, sinal) apaga o temporário. +trap 'rm -f -- "$tmp"' EXIT falhar() { rm -f -- "$tmp" echo "::error title=secret::$1" @@ -116,10 +131,12 @@ chmod 600 "$tmp" if [[ -n "$destino" ]]; then mkdir -p "$(dirname "$destino")" mv -f -- "$tmp" "$destino" + trap - EXIT 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 + trap - EXIT # este arquivo é o resultado: fica até o --cleanup-temp echo "::notice title=secret::$env_nome decodificado em $tmp ($temp_export definida para os próximos passos; conteúdo não impresso)." fi diff --git a/scripts/ci/decode_secret_file_test.sh b/scripts/ci/decode_secret_file_test.sh index aaf5746f..7c3c4313 100755 --- a/scripts/ci/decode_secret_file_test.sh +++ b/scripts/ci/decode_secret_file_test.sh @@ -105,6 +105,36 @@ t_base64_invalido_da_dica_sem_vazar() { afirma "truncado: nada do conteúdo na saída" '! grep -q "$FAKE_KEY" <<<"$out"' } +t_endurecimento_do_script() { + novo_ambiente + # M4: um xtrace herdado do ambiente (SHELLOPTS=xtrace) imprimiria o secret no log. + export FCM_SECRET="$(sa_json | base64 -w0)" + out="$(env SHELLOPTS=xtrace "$script" --env FCM_SECRET --kind service_account --temp-export GOOGLE_APPLICATION_CREDENTIALS 2>&1)" + afirma "M4 o xtrace herdado realmente está ligado neste teste (sem isso ele seria vácuo)" 'grep -q "^+" <<<"$out"' + afirma "M4 xtrace herdado: o secret não aparece na saída" '! grep -qF "$FCM_SECRET" <<<"$out" && ! grep -q "$FAKE_KEY" <<<"$out"' + # M5: o nome da variável vira expansão indireta; um nome hostil não pode executar código. + novo_ambiente + marca="$T/EXECUTOU" + "$script" --env "a[\$(touch $marca)]" --kind service_account --temp-export X >/dev/null 2>&1; rc=$? + afirma "M5 --env com nome hostil: exit 2 e nada executado" '[[ $rc -eq 2 && ! -e "$marca" ]]' + GOOGLE_APPLICATION_CREDENTIALS='x' "$script" --cleanup-temp "a[\$(touch $marca)]" >/dev/null 2>&1; rc=$? + afirma "M5 --cleanup-temp com nome hostil: exit 2 e nada executado" '[[ $rc -eq 2 && ! -e "$marca" ]]' + "$script" --env A --kind service_account --temp-export '1x;y' >/dev/null 2>&1; rc=$? + afirma "M5 --temp-export com nome inválido: exit 2" '[[ $rc -eq 2 ]]' + # M6: um caminho que só COMEÇA por RUNNER_TEMP mas sai dele por ".." não pode ser apagado. + novo_ambiente + alheio="$T/alheio.json"; echo x >"$alheio" + GOOGLE_APPLICATION_CREDENTIALS="$RUNNER_TEMP/../alheio.json" "$script" --cleanup-temp GOOGLE_APPLICATION_CREDENTIALS + afirma "M6 cleanup-temp com '..': não apaga fora do RUNNER_TEMP" '[[ -e "$alheio" ]]' + # M7: se algo falha DEPOIS de decodificar, o temporário não pode sobrar. + novo_ambiente + export GS_SECRET="$(gs_json | base64 -w0)" + echo x >"$T/arquivo" # um ARQUIVO no lugar do diretório pai: o mkdir -p do destino falha + out="$("$script" --env GS_SECRET --kind google_services --to "$T/arquivo/sub/google-services.json" 2>&1)"; rc=$? + afirma "M7 falha no mkdir: exit != 0 e nenhum temporário sobra" '[[ $rc -ne 0 ]] && vazio_em "$RUNNER_TEMP"' + afirma "M7 a falha não vaza o conteúdo" '! grep -q "$FAKE_KEY" <<<"$out" && ! grep -q "fake-proj" <<<"$out"' +} + t_google_services_grava_no_destino() { novo_ambiente export GS_SECRET="$(gs_json | base64 -w0)" @@ -178,7 +208,8 @@ t_uso_incorreto() { } 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"' + afirma "o script não liga set -x (vazaria o secret no log)" '! grep -Eq "^[[:space:]]*set -[a-z]*x|set -o xtrace" "$script"' + afirma "o script desliga um xtrace herdado antes de qualquer outra coisa" '[[ "$(grep -v "^[[:space:]]*#" "$script" | grep -v "^[[:space:]]*$" | head -1)" == "set +x" ]]' } t_sem_secret_termina_bem_e_nao_cria_nada @@ -187,6 +218,7 @@ t_service_account_quebrado_com_crlf t_preserva_o_que_ja_estava_em_github_env t_service_account_invalido_nao_vaza t_base64_invalido_da_dica_sem_vazar +t_endurecimento_do_script t_google_services_grava_no_destino t_google_services_invalido t_nao_sobrescreve_fora_do_ci From e99decaa18bfcbd500b0ae98110cbc17ac8eb65f Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Wed, 30 Sep 2026 19:51:52 -0400 Subject: [PATCH 114/498] ci: guarda cobre env de workflow, with:, toJSON(secrets), colchetes e exige o E2E e a limpeza depois dele (M1, M2) Co-Authored-By: Claude Sonnet 5.5 --- scripts/qa/ci_invariants.sh | 20 ++++++++++++++++++++ scripts/qa/ci_invariants_fcm_test.sh | 16 +++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/scripts/qa/ci_invariants.sh b/scripts/qa/ci_invariants.sh index 3d2b4dfb..763b9cfb 100755 --- a/scripts/qa/ci_invariants.sh +++ b/scripts/qa/ci_invariants.sh @@ -19,6 +19,7 @@ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" exec python3 - "${CI_WORKFLOW_PATH:-$repo_root/.github/workflows/ci.yml}" "$@" <<'PY' import json +import re import sys import yaml @@ -206,6 +207,21 @@ TARGET_EMULADOR = 'google_apis' def check_credenciais_fcm(): + # Varredura do TEXTO bruto (sem as linhas de comentário): os secrets só podem aparecer UMA + # vez cada, na linha `env:` do passo que os decodifica. Isso cobre os contornos que a + # leitura estrutural não vê: `env:` de workflow, `with:` de uma ação, `env:` de outro + # passo/job, `secrets['NOME']` e `toJSON(secrets)` (que entrega TODOS os secrets). + with open(caminho, encoding='utf-8') as arquivo: + texto = '\n'.join(l for l in arquivo.read().splitlines() if not l.lstrip().startswith('#')) + for segredo in SEGREDOS_FCM: + usos = (len(re.findall(r'secrets\s*\.\s*' + segredo + r'\b', texto)) + + len(re.findall(r'secrets\s*\[\s*[\'"]' + segredo + r'[\'"]\s*\]', texto))) + if usos != 1: + falhas.append(f'FCM: {segredo} aparece {usos}x no workflow; só pode aparecer 1x, no env: do passo que o decodifica') + if re.search(r'toJSON\(\s*secrets\s*\)', texto): + falhas.append('FCM: toJSON(secrets) entrega todos os secrets, inclusive os do FCM; não use') + if re.search(r'secrets\s*\[\s*[^\'"\s]', texto): + falhas.append('FCM: secrets[] é dinâmico e poderia alcançar os secrets do FCM; use o nome literal') for nome_job, job in jobs.items(): for segredo in SEGREDOS_FCM: if segredo in (job.get('env') or {}): @@ -216,6 +232,8 @@ def check_credenciais_fcm(): passos = (jobs.get('android-e2e') or {}).get('steps') or [] nomes = [p.get('name', '') for p in passos] indice_e2e = nomes.index('E2E no emulador Android') if 'E2E no emulador Android' in nomes else None + if indice_e2e is None: + falhas.append("FCM: android-e2e sem o passo 'E2E no emulador Android'; a ordem das credenciais não pode ser conferida") ultimo = -1 for segredo, nome_passo in SEGREDOS_FCM.items(): if nome_passo not in nomes: @@ -243,6 +261,8 @@ def check_credenciais_fcm(): falhas.append(f'FCM: nenhum passo {LIMPEZA_FCM!r} depois das decodificações') else: passo = limpeza[0] + if indice_e2e is not None and passos.index(passo) < indice_e2e: + falhas.append(f'FCM: o passo {LIMPEZA_FCM!r} vem ANTES do E2E; apagaria as credenciais que ele usa') if passo.get('if') != 'always()': falhas.append(f'FCM: o passo {LIMPEZA_FCM!r} precisa de if: always()') for trecho in LIMPEZAS_ESPERADAS: diff --git a/scripts/qa/ci_invariants_fcm_test.sh b/scripts/qa/ci_invariants_fcm_test.sh index 5aa346cc..97daec6b 100755 --- a/scripts/qa/ci_invariants_fcm_test.sh +++ b/scripts/qa/ci_invariants_fcm_test.sh @@ -46,7 +46,21 @@ muta limpeza_sem_google_services \ muta limpeza_sem_chave_do_gorush \ "t.replace('--cleanup-file infra/docker/gorush/credentials/fcm-service-account.json', 'true', 1)" muta decodifica_depois_do_e2e \ - "t.replace(' - name: Decodifica o google-services.json', ' - name: Decodifica o google-services.json (movido)', 1)" + "(lambda m: t.replace(m.group(0), '', 1).replace(' # Apaga as credenciais mesmo se o E2E falhar', m.group(0) + ' # Apaga as credenciais mesmo se o E2E falhar', 1))(__import__('re').search(r' # Credenciais do FCM para o push e2e.*?(?= - name: E2E no emulador Android)', t, __import__('re').S))" +muta limpeza_antes_do_e2e \ + "(lambda m: t.replace(m.group(0), '', 1).replace(' - name: E2E no emulador Android\n', m.group(0) + ' - name: E2E no emulador Android\n', 1))(__import__('re').search(r' # Apaga as credenciais mesmo se o E2E falhar.*?(?= - name: Remove pg_data)', t, __import__('re').S))" +muta passo_do_e2e_renomeado \ + "t.replace(' - name: E2E no emulador Android\n', ' - name: Android E2E\n', 1)" +muta secret_no_env_do_workflow \ + "t.replace('\njobs:\n', '\nenv:\n VAZA: \${{ secrets.FCM_CREDENTIALS_BASE64 }}\njobs:\n', 1)" +muta secret_em_with_de_acao \ + "t.replace(' name: android-e2e-metrics\n', ' name: android-e2e-metrics\n token: \${{ secrets.GOOGLE_SERVICES_JSON_BASE64 }}\n', 1)" +muta secret_por_tojson \ + "t.replace(' name: android-e2e-metrics\n', ' name: android-e2e-metrics\n token: \${{ toJSON(secrets) }}\n', 1)" +muta secret_por_colchetes \ + "t.replace(' name: android-e2e-metrics\n', ' name: android-e2e-metrics\n token: \${{ secrets[\'FCM_CREDENTIALS_BASE64\'] }}\n', 1)" +muta secret_no_env_de_outro_passo \ + "t.replace(' - name: Remove pg_data/ do workspace\n', ' - name: Remove pg_data/ do workspace\n env:\n OUTRO: \${{ secrets.GOOGLE_SERVICES_JSON_BASE64 }}\n', 1)" muta credencial_em_disco_durante_acao_de_terceiros \ "t.replace(' - name: E2E no emulador Android\n', ' - uses: actions/setup-java@v6\n - name: E2E no emulador Android\n', 1)" muta emulador_sem_play_services \ From f0666dc117285568693891cf7ac94387b47d562b Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Wed, 30 Sep 2026 19:54:51 -0400 Subject: [PATCH 115/498] =?UTF-8?q?ci:=20fecha=20os=2010=20Minors=20da=20r?= =?UTF-8?q?evis=C3=A3o=20do=20CI=20do=20FCM=20(trap=20down,=20cabe=C3=A7al?= =?UTF-8?q?ho=20e=20project=5Fid=20do=20push=5Fe2e,=20grupo=20de=20process?= =?UTF-8?q?os)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- PROGRESS.md | 1 + scripts/qa/ci_push_e2e_test.sh | 37 ++++++++++++++++++++++++++++++++++ scripts/qa/push_e2e.sh | 24 ++++++++++++++++------ scripts/qa/run_android_e2e.sh | 4 +++- 4 files changed, 59 insertions(+), 7 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index bddb49a5..dfaa7ff0 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1307,3 +1307,4 @@ Plano: `docs/superpowers/plans/2026-09-30-ci-credencial-fcm.md`, branch `fix/pat - **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. diff --git a/scripts/qa/ci_push_e2e_test.sh b/scripts/qa/ci_push_e2e_test.sh index c12c2c9c..ac690904 100755 --- a/scripts/qa/ci_push_e2e_test.sh +++ b/scripts/qa/ci_push_e2e_test.sh @@ -62,5 +62,42 @@ echo '{}' >"$R/apps/patient/android/app/google-services.json" out="$(roda)"; rc=$? afirma "o exit do push e2e é propagado (7)" '[[ $rc -eq 7 ]]' +# ---- M3: uma falha do `docker compose down` no trap não pode trocar o código de saída ---------- +stub="$raiz/stub_bin"; mkdir -p "$stub"; printf '#!/bin/sh\nexit 1\n' >"$stub/docker"; chmod +x "$stub/docker" +linha_trap="$(grep '^trap ' "$repo/scripts/qa/run_android_e2e.sh")" +PATH="$stub:$PATH" bash -c "set -e; $linha_trap; exit 7"; rc=$? +afirma "M3 o trap de down que falha preserva o exit do script (7)" '[[ $rc -eq 7 ]]' +PATH="$stub:$PATH" bash -c "set -e; $linha_trap; exit 0"; rc=$? +afirma "M3 o trap de down que falha não transforma sucesso em falha" '[[ $rc -eq 0 ]]' + +# ---- M8: o cabeçalho do push_e2e.sh não pode mais dizer que NÃO roda na CI ---------------------- +afirma "M8 o cabeçalho do push_e2e.sh não afirma 'NÃO roda na CI'" '! sed -n 1,25p "$repo/scripts/qa/push_e2e.sh" | grep -q "NÃO roda na CI"' + +# ---- M9: no CI o log é público — o project_id não pode ser impresso ------------------------------ +guarda="$raiz/guarda.py" +sed -n "/^python3 - <<'PYEOF'/,/^PYEOF/p" "$repo/scripts/qa/push_e2e.sh" | sed '1d;$d' >"$guarda" +mkdir -p "$raiz/g/infra/docker/gorush/credentials" "$raiz/g/apps/patient/android/app" +echo '{"type":"service_account","project_id":"proj-secreto-123","client_email":"x@y","private_key":"k"}' >"$raiz/g/infra/docker/gorush/credentials/fcm-service-account.json" +echo '{"project_info":{"project_id":"proj-secreto-123"},"client":[{"client_info":{"android_client_info":{"package_name":"br.com.prismrr.sinalacs.patient"}}}]}' >"$raiz/g/apps/patient/android/app/google-services.json" +out_ci="$(cd "$raiz/g" && GITHUB_ACTIONS=true python3 "$guarda" 2>&1)"; rc_ci=$? +out_local="$(cd "$raiz/g" && env -u GITHUB_ACTIONS python3 "$guarda" 2>&1)" +afirma "M9 a guarda roda com chaves válidas (exit 0)" '[[ $rc_ci -eq 0 ]]' +afirma "M9 no CI o project_id não é impresso" '! grep -q "proj-secreto-123" <<<"$out_ci" && grep -q "chave ok" <<<"$out_ci"' +afirma "M9 fora do CI o project_id continua aparecendo (útil ao dev)" 'grep -q "proj-secreto-123" <<<"$out_local"' + +# ---- M10: parar o push e2e tem de matar o `flutter test` (neto do subshell), não só o subshell -- +fn="$(sed -n '/^parar_arvore()/,/^}/p' "$repo/scripts/qa/push_e2e.sh")" +afirma "M10 push_e2e.sh define parar_arvore e liga o controle de jobs (set -m)" '[[ -n "$fn" ]] && grep -q "^set -m" "$repo/scripts/qa/push_e2e.sh"' +marca="$raiz/neto.pid" +bash -c "set -m; $fn +( sh -c 'echo \$\$ >$marca; exec sleep 300' & wait ) & +hold=\$! +for _ in 1 2 3 4 5 6 7 8 9 10; do [ -s $marca ] && break; sleep 0.2; done +parar_arvore \$hold +sleep 0.5" 2>/dev/null +neto="$(cat "$marca" 2>/dev/null || echo 0)" +afirma "M10 o neto (flutter test) morre junto" '[[ "$neto" != 0 ]] && ! kill -0 "$neto" 2>/dev/null' +kill "$neto" 2>/dev/null || true + n="$(wc -l <"$marcador")" echo; [[ "$n" -eq 0 ]] && echo "OK — ci_push_e2e.sh" || { echo "$n asserção(ões) falharam"; exit 1; } diff --git a/scripts/qa/push_e2e.sh b/scripts/qa/push_e2e.sh index 2a6d4fdc..da3ddadc 100755 --- a/scripts/qa/push_e2e.sh +++ b/scripts/qa/push_e2e.sh @@ -12,13 +12,23 @@ # ACS por matrícula e senha, ambos de fixtures geradas na hora. Nada é escrito no # banco de desenvolvimento. # -# Pré-requisitos: emulador `emulator-5554` (Google Play, com internet), a chave da conta -# de serviço em infra/docker/gorush/credentials/fcm-service-account.json e o -# google-services.json em apps/patient/android/app/. NÃO roda na CI: precisa de -# credenciais reais do projeto Firebase. Sai com 3 quando falta a chave. +# Pré-requisitos: emulador `emulator-5554` (com Play Services e internet: Google Play ou +# `google_apis`), a chave da conta de serviço em +# infra/docker/gorush/credentials/fcm-service-account.json e o google-services.json em +# apps/patient/android/app/. Precisa de credenciais reais do projeto Firebase, então só roda +# onde elas existem: na máquina de quem as tem e no job `android-e2e` da CI, que as decodifica +# dos secrets (ver scripts/qa/ci_push_e2e.sh). Sai com 3 quando falta a chave. # # Nunca imprime a chave nem o token FCM. set -euo pipefail +# Controle de jobs: cada processo em segundo plano vira um grupo próprio, e `parar_arvore` mata o +# grupo inteiro (o `flutter test` é NETO do subshell que o lança; matar só o subshell o deixava vivo). +set -m +parar_arvore() { + local pid="${1:-}" + [[ -n "$pid" ]] || return 0 + kill -- -"$pid" 2>/dev/null || kill "$pid" 2>/dev/null || true +} repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" cd "$repo_root" @@ -52,7 +62,9 @@ except FileNotFoundError as e: 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 +# No CI o log é público: o project_id não é impresso lá. Nunca imprime a chave. +import os +print('chave ok para o projeto', '(oculto no CI)' if os.environ.get('GITHUB_ACTIONS') == 'true' else k['project_id']) PYEOF # O ambiente do shell pode trazer um GORUSH_CREDENTIALS_DIR que é um ARQUIVO (o Compose @@ -116,7 +128,7 @@ adb -s "$dev" reverse tcp:8443 tcp:443 >/dev/null adb -s "$dev" reverse tcp:8765 tcp:8765 >/dev/null hold_pid="" cleanup() { - [[ -n "$hold_pid" ]] && kill "$hold_pid" 2>/dev/null || true + parar_arvore "$hold_pid" [[ -n "$relay_pid" ]] && kill "$relay_pid" 2>/dev/null || true docker compose --profile push start gorush >/dev/null 2>&1 || true psql_q 'delete from push_tokens' >/dev/null 2>&1 || true diff --git a/scripts/qa/run_android_e2e.sh b/scripts/qa/run_android_e2e.sh index 75e5e731..0832ab4b 100755 --- a/scripts/qa/run_android_e2e.sh +++ b/scripts/qa/run_android_e2e.sh @@ -12,7 +12,9 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" cd "$repo_root" -trap 'docker compose --profile push down' EXIT +# `|| true`: com `set -e`, um `down` que falha trocaria o código de saída do script (um sucesso +# viraria falha, e o exit do push e2e viraria 1). +trap 'docker compose --profile push down || true' EXIT # GOOGLE_MAPS_API_KEY não é mais obrigatória: a CI roda só o smoke de cada app # (e2e.sh --emulator, sem --full), e o map_flow_test.dart ficou fora dele. Se From 9bf0fa38090ce44b8ab6f34673a0e9cad150f827 Mon Sep 17 00:00:00 2001 From: Rosialdo <79479462+Rosialdo@users.noreply.github.com> Date: Wed, 30 Sep 2026 23:16:50 -0400 Subject: [PATCH 116/498] add slide presentation --- video/showcase/preview.html | 350 ++++++++++++++++++++++++++++++ video/showcase/sinalacs.css | 360 +++++++++++++++++++++++++++++++ video/showcase/slides.md | 412 ++++++++++++++++++++++++++++++++++++ 3 files changed, 1122 insertions(+) create mode 100644 video/showcase/preview.html create mode 100644 video/showcase/sinalacs.css create mode 100644 video/showcase/slides.md diff --git a/video/showcase/preview.html b/video/showcase/preview.html new file mode 100644 index 00000000..a8e6ff5d --- /dev/null +++ b/video/showcase/preview.html @@ -0,0 +1,350 @@ +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. +

+
+
+
+
+
+
📸
+ Capturar: docs/screenshots/patient/02-autenticacao.png +
+
+
+

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

+
+
+
+
+
+
📸
+ Capturar: docs/screenshots/patient/03-triagem.png +
(recomendado: passo 3, com Risco: Vermelho já visível) +
+
+
+

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

+
+
+
📸
+ patient/04-acompanhamento.png +
Enviado → Visualizado → Em análise → Agendado +
+
+
📸
+ patient/05-lembretes.png +
Medicamentos e rotina, com ativação +
+
+
📸
+ patient/06-perfil.png +
Condições crônicas — "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

+
+
+
+
+
+
📸
+ Capturar: docs/screenshots/acs/06-acionamento.png +
+
+
+

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/video/showcase/sinalacs.css b/video/showcase/sinalacs.css new file mode 100644 index 00000000..5827ddd1 --- /dev/null +++ b/video/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/video/showcase/slides.md b/video/showcase/slides.md new file mode 100644 index 00000000..91814fcf --- /dev/null +++ b/video/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. +

+ +--- + + +
+
+
+
📸
+ Capturar: docs/screenshots/patient/02-autenticacao.png +
+
+
+

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 + +--- + + +
+
+
+
📸
+ Capturar: docs/screenshots/patient/03-triagem.png +
(recomendado: passo 3, com Risco: Vermelho já visível) +
+
+
+

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

+ +
+
+
📸
+ patient/04-acompanhamento.png +
Enviado → Visualizado → Em análise → Agendado +
+
+
📸
+ patient/05-lembretes.png +
Medicamentos e rotina, com ativação +
+
+
📸
+ patient/06-perfil.png +
Condições crônicas — "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 + +--- + +
+
+
+
📸
+ Capturar: docs/screenshots/acs/06-acionamento.png +
+
+
+

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 +

From b4763406a4cfa0072f911163919536d389174aa1 Mon Sep 17 00:00:00 2001 From: sarah Date: Wed, 30 Sep 2026 23:36:22 -0400 Subject: [PATCH 117/498] =?UTF-8?q?docs:=20adiciona=20guia=20de=20publica?= =?UTF-8?q?=C3=A7=C3=A3o=20na=20Google=20Play=20e=20p=C3=B3s-an=C3=A1lise?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/README.md | 1 + docs/publicacao-google-play.md | 927 +++++++++++++++++++++++++++++++++ 2 files changed, 928 insertions(+) create mode 100644 docs/publicacao-google-play.md diff --git a/docs/README.md b/docs/README.md index f14b44b8..0d64b754 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,6 +5,7 @@ - [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) As capturas de tela usam dados sintéticos e representam o estado atual de protótipo das interfaces Flutter. diff --git a/docs/publicacao-google-play.md b/docs/publicacao-google-play.md new file mode 100644 index 00000000..7c96da11 --- /dev/null +++ b/docs/publicacao-google-play.md @@ -0,0 +1,927 @@ +# 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 botão de entrar só funciona quando `devLoginEnabled` é + verdadeiro, e o padrão é `kDebugMode`. Em release, o login fica desativado. + +### 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 local; o botão só funciona com `devLoginEnabled`, que por padrão vale `kDebugMode` | Em release o login fica desativado: o revisor 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` e, em release, o botão de entrar fica desativado (`devLoginEnabled` vale `kDebugMode`) | 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 From 5bdd0e9586dbc1fb17dd12fe2475089965918137 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Thu, 1 Oct 2026 16:15:51 -0400 Subject: [PATCH 118/498] chore: tira .flutter-plugins e .flutter-plugins-dependencies do versionamento MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arquivos gerados por flutter pub get, com caminhos absolutos da máquina; conflitavam em todo merge. Closes #31 Co-Authored-By: Claude Sonnet 5.5 --- apps/acs/.flutter-plugins | 16 ---------------- apps/acs/.flutter-plugins-dependencies | 1 - apps/acs/.gitignore | 1 + apps/admin/.gitignore | 1 + apps/patient/.flutter-plugins | 13 ------------- apps/patient/.flutter-plugins-dependencies | 1 - apps/patient/.gitignore | 1 + 7 files changed, 3 insertions(+), 31 deletions(-) delete mode 100644 apps/acs/.flutter-plugins delete mode 100644 apps/acs/.flutter-plugins-dependencies delete mode 100644 apps/patient/.flutter-plugins delete mode 100644 apps/patient/.flutter-plugins-dependencies 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 b7ef3813..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":"shared_preferences_foundation","path":"/home/rock/.pub-cache/hosted/pub.dev/shared_preferences_foundation-2.5.7/","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":"shared_preferences_android","path":"/home/rock/.pub-cache/hosted/pub.dev/shared_preferences_android-2.4.28/","native_build":true,"dependencies":[],"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":"shared_preferences_foundation","path":"/home/rock/.pub-cache/hosted/pub.dev/shared_preferences_foundation-2.5.7/","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},{"name":"shared_preferences_linux","path":"/home/rock/.pub-cache/hosted/pub.dev/shared_preferences_linux-2.4.1/","native_build":false,"dependencies":["path_provider_linux"],"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},{"name":"shared_preferences_windows","path":"/home/rock/.pub-cache/hosted/pub.dev/shared_preferences_windows-2.4.1/","native_build":false,"dependencies":["path_provider_windows"],"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},{"name":"shared_preferences_web","path":"/home/rock/.pub-cache/hosted/pub.dev/shared_preferences_web-2.4.3/","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":"shared_preferences","dependencies":["shared_preferences_android","shared_preferences_foundation","shared_preferences_linux","shared_preferences_web","shared_preferences_windows"]},{"name":"shared_preferences_android","dependencies":[]},{"name":"shared_preferences_foundation","dependencies":[]},{"name":"shared_preferences_linux","dependencies":["path_provider_linux"]},{"name":"shared_preferences_web","dependencies":[]},{"name":"shared_preferences_windows","dependencies":["path_provider_windows"]},{"name":"sqflite","dependencies":["sqflite_android","sqflite_darwin"]},{"name":"sqflite_android","dependencies":[]},{"name":"sqflite_darwin","dependencies":[]},{"name":"sqflite_sqlcipher","dependencies":[]}],"date_created":"2026-10-01 14:50:31.800366","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/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/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 718c077c..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":"mobile_scanner","path":"/home/rock/.pub-cache/hosted/pub.dev/mobile_scanner-7.4.2/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_foundation","path":"/home/rock/.pub-cache/hosted/pub.dev/shared_preferences_foundation-2.5.7/","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_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":"mobile_scanner","path":"/home/rock/.pub-cache/hosted/pub.dev/mobile_scanner-7.4.2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_android","path":"/home/rock/.pub-cache/hosted/pub.dev/shared_preferences_android-2.4.28/","native_build":true,"dependencies":[],"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_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":"mobile_scanner","path":"/home/rock/.pub-cache/hosted/pub.dev/mobile_scanner-7.4.2/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_foundation","path":"/home/rock/.pub-cache/hosted/pub.dev/shared_preferences_foundation-2.5.7/","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},{"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},{"name":"shared_preferences_linux","path":"/home/rock/.pub-cache/hosted/pub.dev/shared_preferences_linux-2.4.1/","native_build":false,"dependencies":["path_provider_linux"],"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},{"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},{"name":"shared_preferences_windows","path":"/home/rock/.pub-cache/hosted/pub.dev/shared_preferences_windows-2.4.1/","native_build":false,"dependencies":["path_provider_windows"],"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},{"name":"mobile_scanner","path":"/home/rock/.pub-cache/hosted/pub.dev/mobile_scanner-7.4.2/","dependencies":[],"dev_dependency":false},{"name":"shared_preferences_web","path":"/home/rock/.pub-cache/hosted/pub.dev/shared_preferences_web-2.4.3/","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":"mobile_scanner","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"shared_preferences","dependencies":["shared_preferences_android","shared_preferences_foundation","shared_preferences_linux","shared_preferences_web","shared_preferences_windows"]},{"name":"shared_preferences_android","dependencies":[]},{"name":"shared_preferences_foundation","dependencies":[]},{"name":"shared_preferences_linux","dependencies":["path_provider_linux"]},{"name":"shared_preferences_web","dependencies":[]},{"name":"shared_preferences_windows","dependencies":["path_provider_windows"]},{"name":"sqflite","dependencies":["sqflite_android","sqflite_darwin"]},{"name":"sqflite_android","dependencies":[]},{"name":"sqflite_darwin","dependencies":[]},{"name":"sqflite_sqlcipher","dependencies":[]}],"date_created":"2026-10-01 14:51:41.257540","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/ From 181d785dc0ffe921d9f37835651fecb842bfd80c Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Thu, 1 Oct 2026 16:32:35 -0400 Subject: [PATCH 119/498] =?UTF-8?q?fix(paciente,acs):=20textos=20brancos?= =?UTF-8?q?=20fixos=20somem=20no=20tema=20claro=20=E2=80=94=20usa=20onSurf?= =?UTF-8?q?aceVariant/outlineVariant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Colors.white24/54/70 vinham do tema escuro e ficavam invisíveis sobre o fundo claro (Lembretes, Status, Meus dados do paciente; código de convite do ACS), medido no emulador. Troca por tokens do tema e adiciona guarda que varre lib/ nos dois apps. Co-Authored-By: Claude Sonnet 5.5 --- apps/acs/lib/app/invite_screen.dart | 4 +- apps/acs/test/no_fixed_white_text_test.dart | 22 ++++++++ apps/patient/lib/app/app.dart | 50 +++++++++---------- apps/patient/lib/app/legal_screens.dart | 2 +- .../test/no_fixed_white_text_test.dart | 22 ++++++++ 5 files changed, 72 insertions(+), 28 deletions(-) create mode 100644 apps/acs/test/no_fixed_white_text_test.dart create mode 100644 apps/patient/test/no_fixed_white_text_test.dart diff --git a/apps/acs/lib/app/invite_screen.dart b/apps/acs/lib/app/invite_screen.dart index b2cf4c46..3326faea 100644 --- a/apps/acs/lib/app/invite_screen.dart +++ b/apps/acs/lib/app/invite_screen.dart @@ -258,10 +258,10 @@ class _InviteScreenState extends State { textAlign: TextAlign.center, ), const SizedBox(height: 8), - const Text( + Text( 'Se a câmera do paciente não funcionar, ele pode digitar este código:', textAlign: TextAlign.center, - style: TextStyle(color: Colors.white70), + style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), ), SelectableText( invite.token, 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/patient/lib/app/app.dart b/apps/patient/lib/app/app.dart index 3338044b..130b24b0 100644 --- a/apps/patient/lib/app/app.dart +++ b/apps/patient/lib/app/app.dart @@ -666,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), ), ), ], @@ -885,11 +885,11 @@ class _OnboardingScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const Text( + 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( @@ -1231,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), @@ -2182,9 +2182,9 @@ class _MyDataScreenState extends State { 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( @@ -2199,9 +2199,9 @@ class _MyDataScreenState extends State { 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( @@ -2215,9 +2215,9 @@ class _MyDataScreenState extends State { 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)), + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text('Nenhum pedido feito.', style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant)), ) else ...data.requests.map( @@ -2464,9 +2464,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), ), ], ), @@ -2488,7 +2488,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), ), ), ], @@ -2509,7 +2509,7 @@ class _StatusCard extends StatelessWidget { RiskLevel.red => ('Vermelho', context.patientRisk.dangerOnSurface), RiskLevel.yellow => ('Amarelo', const Color(0xFFE0A800)), RiskLevel.green => ('Verde', context.patientRisk.accentOnSurface), - null => ('Não classificado', Colors.white70), + null => ('Não classificado', Theme.of(context).colorScheme.onSurfaceVariant), }; final statusLabel = switch (status.status) { AlertStatus.pending => 'Enviado — aguardando confirmação da equipe', @@ -2759,12 +2759,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), ), ), ), @@ -2796,14 +2796,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), ), ), ) diff --git a/apps/patient/lib/app/legal_screens.dart b/apps/patient/lib/app/legal_screens.dart index cc5e4078..f1efa572 100644 --- a/apps/patient/lib/app/legal_screens.dart +++ b/apps/patient/lib/app/legal_screens.dart @@ -98,7 +98,7 @@ class LegalDocumentScreen extends StatelessWidget { ? 'Versão ${document.version} · vigente desde ${document.effectiveDate}' : 'Versão ${document.version} · passa a valer em $effectiveLabel', key: const Key('legal_version'), - style: const TextStyle(color: Colors.white70), + style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), ), const SizedBox(height: 16), const Text( 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'); + }); +} From 31e36b0160e6cebee2f7d746f5ab2a9d44347157 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Thu, 1 Oct 2026 16:46:47 -0400 Subject: [PATCH 120/498] =?UTF-8?q?docs:=20plano=20da=20issue=20do=20.flut?= =?UTF-8?q?ter-plugins-dependencies=20e=20da=20confer=C3=AAncia=20visual?= =?UTF-8?q?=20do=20tema=20claro?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- ...s-dependencies-e-conferencia-tema-claro.md | 225 ++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-01-issue-flutter-plugins-dependencies-e-conferencia-tema-claro.md 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. From eccf83c4196125e9c1bfcb2998b57c727ffe4ded Mon Sep 17 00:00:00 2001 From: rosialdo Date: Thu, 1 Oct 2026 17:20:58 -0400 Subject: [PATCH 121/498] finish of insert iamges on slide --- apps/acs/.flutter-plugins-dependencies | 2 +- apps/admin/.flutter-plugins | 2 + apps/patient/.flutter-plugins-dependencies | 2 +- video/showcase/images/acs-acionamento.png | Bin 0 -> 141798 bytes video/showcase/images/acs-dashboard.png | Bin 0 -> 173146 bytes video/showcase/images/acs-login.png | Bin 0 -> 92167 bytes video/showcase/images/acs-mapa.png | Bin 0 -> 108024 bytes .../showcase/images/acs-territorializacao.png | Bin 0 -> 112977 bytes video/showcase/images/acs-visita.png | Bin 0 -> 118228 bytes video/showcase/images/admin-alertas.png | Bin 0 -> 205730 bytes video/showcase/images/admin-auditoria.png | Bin 0 -> 180151 bytes video/showcase/images/admin-indicadores.png | Bin 0 -> 132127 bytes video/showcase/images/admin-microarea.png | Bin 0 -> 177103 bytes .../images/paciente-acompanhamento.png | Bin 0 -> 101037 bytes .../showcase/images/paciente-autenticacao.png | Bin 0 -> 132408 bytes video/showcase/images/paciente-emergencia.png | Bin 0 -> 120996 bytes video/showcase/images/paciente-lembretes.png | Bin 0 -> 110801 bytes video/showcase/images/paciente-perfil.png | Bin 0 -> 127654 bytes video/showcase/images/paciente-triagem.png | Bin 0 -> 113818 bytes video/showcase/preview.html | 146 +++++++++--------- video/showcase/slides.md | 82 +++++----- 21 files changed, 118 insertions(+), 116 deletions(-) create mode 100644 apps/admin/.flutter-plugins create mode 100644 video/showcase/images/acs-acionamento.png create mode 100644 video/showcase/images/acs-dashboard.png create mode 100644 video/showcase/images/acs-login.png create mode 100644 video/showcase/images/acs-mapa.png create mode 100644 video/showcase/images/acs-territorializacao.png create mode 100644 video/showcase/images/acs-visita.png create mode 100644 video/showcase/images/admin-alertas.png create mode 100644 video/showcase/images/admin-auditoria.png create mode 100644 video/showcase/images/admin-indicadores.png create mode 100644 video/showcase/images/admin-microarea.png create mode 100644 video/showcase/images/paciente-acompanhamento.png create mode 100644 video/showcase/images/paciente-autenticacao.png create mode 100644 video/showcase/images/paciente-emergencia.png create mode 100644 video/showcase/images/paciente-lembretes.png create mode 100644 video/showcase/images/paciente-perfil.png create mode 100644 video/showcase/images/paciente-triagem.png diff --git a/apps/acs/.flutter-plugins-dependencies b/apps/acs/.flutter-plugins-dependencies index ccbe13e5..a7d88d1b 100644 --- a/apps/acs/.flutter-plugins-dependencies +++ b/apps/acs/.flutter-plugins-dependencies @@ -1 +1 @@ -{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"connectivity_plus","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\connectivity_plus-7.3.1\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_secure_storage-9.2.4\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"geolocator_apple","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\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":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\google_maps_flutter_ios-2.18.6\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"integration_test","path":"C:\\\\src\\\\flutter\\\\packages\\\\integration_test\\\\","native_build":true,"dependencies":[],"dev_dependency":true},{"name":"path_provider_foundation","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_foundation-2.5.1\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_foundation","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_foundation-2.5.7\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\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":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_sqlcipher-3.2.0\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"connectivity_plus","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\connectivity_plus-7.3.1\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_plugin_android_lifecycle","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_plugin_android_lifecycle-2.0.35\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_secure_storage-9.2.4\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"geolocator_android","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\geolocator_android-4.6.2\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"google_maps_flutter_android","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\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":"C:\\\\src\\\\flutter\\\\packages\\\\integration_test\\\\","native_build":true,"dependencies":[],"dev_dependency":true},{"name":"jni","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\jni-1.0.3\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni_flutter","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\jni_flutter-1.0.3\\\\","native_build":true,"dependencies":["jni"],"dev_dependency":false},{"name":"path_provider_android","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_android-2.3.1\\\\","native_build":false,"dependencies":["jni","jni_flutter"],"dev_dependency":false},{"name":"shared_preferences_android","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_android-2.4.28\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_android","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_android-2.4.0\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_sqlcipher","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_sqlcipher-3.2.0\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"connectivity_plus","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\connectivity_plus-7.3.1\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_macos","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_secure_storage_macos-3.1.3\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"geolocator_apple","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\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":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_foundation-2.5.1\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_foundation","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_foundation-2.5.7\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\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":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_sqlcipher-3.2.0\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"connectivity_plus","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\connectivity_plus-7.3.1\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_linux","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_secure_storage_linux-1.2.3\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\jni-1.0.3\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_linux","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_linux-2.2.2\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_linux","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_linux-2.4.1\\\\","native_build":false,"dependencies":["path_provider_linux"],"dev_dependency":false}],"windows":[{"name":"connectivity_plus","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\connectivity_plus-7.3.1\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_windows","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_secure_storage_windows-3.1.2\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"geolocator_windows","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\geolocator_windows-0.2.5\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\jni-1.0.3\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_windows","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_windows-2.3.0\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_windows","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_windows-2.4.1\\\\","native_build":false,"dependencies":["path_provider_windows"],"dev_dependency":false}],"web":[{"name":"connectivity_plus","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\connectivity_plus-7.3.1\\\\","dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_web","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_secure_storage_web-1.2.1\\\\","dependencies":[],"dev_dependency":false},{"name":"geolocator_web","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\geolocator_web-4.1.4\\\\","dependencies":[],"dev_dependency":false},{"name":"google_maps_flutter_web","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\google_maps_flutter_web-0.6.3+1\\\\","dependencies":[],"dev_dependency":false},{"name":"shared_preferences_web","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_web-2.4.3\\\\","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":"shared_preferences","dependencies":["shared_preferences_android","shared_preferences_foundation","shared_preferences_linux","shared_preferences_web","shared_preferences_windows"]},{"name":"shared_preferences_android","dependencies":[]},{"name":"shared_preferences_foundation","dependencies":[]},{"name":"shared_preferences_linux","dependencies":["path_provider_linux"]},{"name":"shared_preferences_web","dependencies":[]},{"name":"shared_preferences_windows","dependencies":["path_provider_windows"]},{"name":"sqflite","dependencies":["sqflite_android","sqflite_darwin"]},{"name":"sqflite_android","dependencies":[]},{"name":"sqflite_darwin","dependencies":[]},{"name":"sqflite_sqlcipher","dependencies":[]}],"date_created":"2026-09-16 09:13:19.254755","version":"3.44.8","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file +{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"connectivity_plus","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\connectivity_plus-7.3.1\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_secure_storage-9.2.4\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"geolocator_apple","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\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":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\google_maps_flutter_ios-2.18.6\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"integration_test","path":"C:\\\\flutter\\\\packages\\\\integration_test\\\\","native_build":true,"dependencies":[],"dev_dependency":true},{"name":"path_provider_foundation","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_foundation-2.5.1\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_foundation","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_foundation-2.5.7\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\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":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_sqlcipher-3.2.0\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"connectivity_plus","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\connectivity_plus-7.3.1\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_plugin_android_lifecycle","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_plugin_android_lifecycle-2.0.35\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_secure_storage-9.2.4\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"geolocator_android","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\geolocator_android-4.6.2\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"google_maps_flutter_android","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\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":"C:\\\\flutter\\\\packages\\\\integration_test\\\\","native_build":true,"dependencies":[],"dev_dependency":true},{"name":"jni","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\jni-1.0.3\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni_flutter","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\jni_flutter-1.0.3\\\\","native_build":true,"dependencies":["jni"],"dev_dependency":false},{"name":"path_provider_android","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_android-2.3.1\\\\","native_build":false,"dependencies":["jni","jni_flutter"],"dev_dependency":false},{"name":"shared_preferences_android","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_android-2.4.28\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_android","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_android-2.4.0\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_sqlcipher","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_sqlcipher-3.2.0\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"connectivity_plus","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\connectivity_plus-7.3.1\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_macos","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_secure_storage_macos-3.1.3\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"geolocator_apple","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\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":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_foundation-2.5.1\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_foundation","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_foundation-2.5.7\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\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":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_sqlcipher-3.2.0\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"connectivity_plus","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\connectivity_plus-7.3.1\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_linux","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_secure_storage_linux-1.2.3\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\jni-1.0.3\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_linux","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_linux-2.2.2\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_linux","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_linux-2.4.1\\\\","native_build":false,"dependencies":["path_provider_linux"],"dev_dependency":false}],"windows":[{"name":"connectivity_plus","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\connectivity_plus-7.3.1\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_windows","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_secure_storage_windows-3.1.2\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"geolocator_windows","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\geolocator_windows-0.2.5\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\jni-1.0.3\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_windows","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_windows-2.3.0\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_windows","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_windows-2.4.1\\\\","native_build":false,"dependencies":["path_provider_windows"],"dev_dependency":false}],"web":[{"name":"connectivity_plus","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\connectivity_plus-7.3.1\\\\","dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_web","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_secure_storage_web-1.2.1\\\\","dependencies":[],"dev_dependency":false},{"name":"geolocator_web","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\geolocator_web-4.1.4\\\\","dependencies":[],"dev_dependency":false},{"name":"google_maps_flutter_web","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\google_maps_flutter_web-0.6.3+1\\\\","dependencies":[],"dev_dependency":false},{"name":"shared_preferences_web","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_web-2.4.3\\\\","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":"shared_preferences","dependencies":["shared_preferences_android","shared_preferences_foundation","shared_preferences_linux","shared_preferences_web","shared_preferences_windows"]},{"name":"shared_preferences_android","dependencies":[]},{"name":"shared_preferences_foundation","dependencies":[]},{"name":"shared_preferences_linux","dependencies":["path_provider_linux"]},{"name":"shared_preferences_web","dependencies":[]},{"name":"shared_preferences_windows","dependencies":["path_provider_windows"]},{"name":"sqflite","dependencies":["sqflite_android","sqflite_darwin"]},{"name":"sqflite_android","dependencies":[]},{"name":"sqflite_darwin","dependencies":[]},{"name":"sqflite_sqlcipher","dependencies":[]}],"date_created":"2026-10-01 16:54:44.991481","version":"3.44.8","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file diff --git a/apps/admin/.flutter-plugins b/apps/admin/.flutter-plugins new file mode 100644 index 00000000..b3be2c73 --- /dev/null +++ b/apps/admin/.flutter-plugins @@ -0,0 +1,2 @@ +# This is a generated file; do not edit or check into version control. +integration_test=C:\\flutter\\packages\\integration_test\\ diff --git a/apps/patient/.flutter-plugins-dependencies b/apps/patient/.flutter-plugins-dependencies index 53917f0a..6417f758 100644 --- a/apps/patient/.flutter-plugins-dependencies +++ b/apps/patient/.flutter-plugins-dependencies @@ -1 +1 @@ -{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"connectivity_plus","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\connectivity_plus-7.3.1\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_local_notifications","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_local_notifications-17.2.4\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"geolocator_apple","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\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":"C:\\\\src\\\\flutter\\\\packages\\\\integration_test\\\\","native_build":true,"dependencies":[],"dev_dependency":true},{"name":"shared_preferences_foundation","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_foundation-2.5.7\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\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":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_sqlcipher-3.2.0\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"connectivity_plus","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\connectivity_plus-7.3.1\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_local_notifications","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_local_notifications-17.2.4\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"geolocator_android","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\geolocator_android-4.6.2\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"integration_test","path":"C:\\\\src\\\\flutter\\\\packages\\\\integration_test\\\\","native_build":true,"dependencies":[],"dev_dependency":true},{"name":"shared_preferences_android","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_android-2.4.28\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_android","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_android-2.4.0\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_sqlcipher","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_sqlcipher-3.2.0\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"connectivity_plus","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\connectivity_plus-7.3.1\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_local_notifications","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_local_notifications-17.2.4\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"geolocator_apple","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\geolocator_apple-2.3.14\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_foundation","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_foundation-2.5.7\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\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":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_sqlcipher-3.2.0\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"connectivity_plus","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\connectivity_plus-7.3.1\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"flutter_local_notifications_linux","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_local_notifications_linux-4.0.1\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"path_provider_linux","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_linux-2.2.2\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_linux","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_linux-2.4.1\\\\","native_build":false,"dependencies":["path_provider_linux"],"dev_dependency":false}],"windows":[{"name":"connectivity_plus","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\connectivity_plus-7.3.1\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"geolocator_windows","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\geolocator_windows-0.2.5\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_windows","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_windows-2.3.0\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_windows","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_windows-2.4.1\\\\","native_build":false,"dependencies":["path_provider_windows"],"dev_dependency":false}],"web":[{"name":"connectivity_plus","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\connectivity_plus-7.3.1\\\\","dependencies":[],"dev_dependency":false},{"name":"geolocator_web","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\geolocator_web-4.1.4\\\\","dependencies":[],"dev_dependency":false},{"name":"shared_preferences_web","path":"C:\\\\Users\\\\Vinicius\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_web-2.4.3\\\\","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":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"shared_preferences","dependencies":["shared_preferences_android","shared_preferences_foundation","shared_preferences_linux","shared_preferences_web","shared_preferences_windows"]},{"name":"shared_preferences_android","dependencies":[]},{"name":"shared_preferences_foundation","dependencies":[]},{"name":"shared_preferences_linux","dependencies":["path_provider_linux"]},{"name":"shared_preferences_web","dependencies":[]},{"name":"shared_preferences_windows","dependencies":["path_provider_windows"]},{"name":"sqflite","dependencies":["sqflite_android","sqflite_darwin"]},{"name":"sqflite_android","dependencies":[]},{"name":"sqflite_darwin","dependencies":[]},{"name":"sqflite_sqlcipher","dependencies":[]}],"date_created":"2026-09-16 09:14:43.014473","version":"3.44.8","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file +{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"connectivity_plus","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\connectivity_plus-7.3.1\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_local_notifications","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_local_notifications-17.2.4\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"geolocator_apple","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\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":"C:\\\\flutter\\\\packages\\\\integration_test\\\\","native_build":true,"dependencies":[],"dev_dependency":true},{"name":"shared_preferences_foundation","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_foundation-2.5.7\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\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":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_sqlcipher-3.2.0\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"connectivity_plus","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\connectivity_plus-7.3.1\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_local_notifications","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_local_notifications-17.2.4\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"geolocator_android","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\geolocator_android-4.6.2\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"integration_test","path":"C:\\\\flutter\\\\packages\\\\integration_test\\\\","native_build":true,"dependencies":[],"dev_dependency":true},{"name":"shared_preferences_android","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_android-2.4.28\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_android","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_android-2.4.0\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_sqlcipher","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_sqlcipher-3.2.0\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"connectivity_plus","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\connectivity_plus-7.3.1\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_local_notifications","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_local_notifications-17.2.4\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"geolocator_apple","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\geolocator_apple-2.3.14\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_foundation","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_foundation-2.5.7\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\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":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_sqlcipher-3.2.0\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"connectivity_plus","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\connectivity_plus-7.3.1\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"flutter_local_notifications_linux","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_local_notifications_linux-4.0.1\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"path_provider_linux","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_linux-2.2.2\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_linux","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_linux-2.4.1\\\\","native_build":false,"dependencies":["path_provider_linux"],"dev_dependency":false}],"windows":[{"name":"connectivity_plus","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\connectivity_plus-7.3.1\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"geolocator_windows","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\geolocator_windows-0.2.5\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_windows","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_windows-2.3.0\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_windows","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_windows-2.4.1\\\\","native_build":false,"dependencies":["path_provider_windows"],"dev_dependency":false}],"web":[{"name":"connectivity_plus","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\connectivity_plus-7.3.1\\\\","dependencies":[],"dev_dependency":false},{"name":"geolocator_web","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\geolocator_web-4.1.4\\\\","dependencies":[],"dev_dependency":false},{"name":"shared_preferences_web","path":"C:\\\\Users\\\\PCMALOCA\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_web-2.4.3\\\\","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":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"shared_preferences","dependencies":["shared_preferences_android","shared_preferences_foundation","shared_preferences_linux","shared_preferences_web","shared_preferences_windows"]},{"name":"shared_preferences_android","dependencies":[]},{"name":"shared_preferences_foundation","dependencies":[]},{"name":"shared_preferences_linux","dependencies":["path_provider_linux"]},{"name":"shared_preferences_web","dependencies":[]},{"name":"shared_preferences_windows","dependencies":["path_provider_windows"]},{"name":"sqflite","dependencies":["sqflite_android","sqflite_darwin"]},{"name":"sqflite_android","dependencies":[]},{"name":"sqflite_darwin","dependencies":[]},{"name":"sqflite_sqlcipher","dependencies":[]}],"date_created":"2026-10-01 16:39:38.178487","version":"3.44.8","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file diff --git a/video/showcase/images/acs-acionamento.png b/video/showcase/images/acs-acionamento.png new file mode 100644 index 0000000000000000000000000000000000000000..ff063440ba885c6815905bf0847041030e9c13be GIT binary patch literal 141798 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
3tK{)b3Xzsla=(9lhB&Sva2ju-9YB3~A>=4l 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 literal 0 HcmV?d00001 diff --git a/video/showcase/images/acs-dashboard.png b/video/showcase/images/acs-dashboard.png new file mode 100644 index 0000000000000000000000000000000000000000..a9a23fedc12210031504bb868d9cca19384ef1da GIT binary patch literal 173146 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 z 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 H5z@=Yo%`TR*hY2wpV8Mn$(HRrMFje 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 F{2$O+$}#`| literal 0 HcmV?d00001 diff --git a/video/showcase/images/acs-login.png b/video/showcase/images/acs-login.png new file mode 100644 index 0000000000000000000000000000000000000000..40e5999c2964b88663027c0dcbff67093e7616c1 GIT binary patch literal 92167 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 literal 0 HcmV?d00001 diff --git a/video/showcase/images/acs-mapa.png b/video/showcase/images/acs-mapa.png new file mode 100644 index 0000000000000000000000000000000000000000..d7eb6d7a5c8c485cea1bd549c27c330f908e8092 GIT binary patch literal 108024 zcmeFZWmJ`27dE;P>5@je1O$=p?oy;dI;Et$k&tc>>F$#5Zj_Sl?(T+f@jTBv#`$-? zKj+`M#}GD~y>ItgYsPiWc};@kWF=4#@ev^q2#S=Xr~(A?k`n?!(1nKu&sg9GOn`4t zb_x>0kkTQ-9q_|TJ7Fm$cyPJH8wNrkq!1}lAtmSJ{RJmyC9yf!V>dU~ql|1hVU!GC zcp6e4eXD#@X@<)VYMfVM=x##YJw@JunONvhJAOE>Xpw@Fl1F@VtpeoKTr&%X95b>0 zcTQUMfBqz}un@#Vp6&l(!8l3}7k`ER@5RMvYWK-I@ZT#x18

{NF2}7XtUc*YLt} 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

htM4H+JZAY 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 literal 0 HcmV?d00001 diff --git a/video/showcase/images/acs-visita.png b/video/showcase/images/acs-visita.png new file mode 100644 index 0000000000000000000000000000000000000000..d08a1e3a761a5b5cc0915acece64b50b090e7bdf GIT binary patch literal 118228 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�n-7%a61xFdSFxm19AllY~TMfAq}7 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%0A7)mY*5YG=eM zq3u0%k!_6q0C6m$?S6s6%L?zk?#=zkhj4r;)~860u^W0|1jk6su8H5iXbl}u){EHt z8Y*1|{Wet3qy(CeqpW87!f7u4A~%k-tSuuf5Bc9nNq8J6ZzbSKN(Lxzzs#X#;3Ws| zens2%53Tk%P(8oQnTJ6FtBt2kZ2`=jwW`Y2v({<&k!IB6i2`rT*F1syt z7XnTj!KH?zLtb#nX*{liI1Em@Ou^R3>J5x_t{a{_GE3mc@T0Wk>=uS`FRL*Xoq7rZlj-(P;s z^1aKX7;c^Ftb2Vj%*^4!^=EB{QOia3%5wxUMdLr+8cjU)+kXZg)AWWkc*n(Cb**|G z_zP@>v*{@I^L>#%rsjAzGCvp(CKr(xXZzyPc4WW{ZIraP z^GMk4;y@M0wJl!8k;K`5bc5Y{f*Omsr@v5ipg^nYq+R6P5DXoqfn=sAuu@b0{qz0$ zhNB92&pr(WulbgLyQ{ehSNwln^iZ7@Y6P}IgHh4nkyFN;fvr7V(bnYTWTKF}@sUG!Y1{WzT=^|!>)t#ApT@ZHy0k5sn2=2-qpTbc*V;75q}6Ko4mXa#={B0@ z&P~nlB)TWZbpifh#*=cJ#m95ISUrdC3ml%BeGPC=Hd@Uf!V^5(;N;U`QxrNJ9~;X$ z{8~^D!G|ONNwfH|_j^_Q4P-r&7r(0=%lEPlNpPSySPG7YC3Yv(#<02N*6s;Ab(BB) ze(mPdQ2-Jbb6nY88#K$V~KcujVQ&VkIQCe|I=B*%GA8(Fk`Ns34 zEssPKiPL9(YEq5O{a27ZpTiC|*9-cKx$cZPVoQck*yuS>p#g zw>5KEOw9cfv;8lm&YO<5yX^@QZJ)mKo20qEu2Z|zEn5#7*_>?@Nq5`RTZr*Z=z!iy z*(-eY(1$8EyDm#m*W1#uZtv&9$wpcK0^I*zFGFPG)h)hHOsS^7^gN#eo*ANi^?9hl5yqfG5PEf^lAcU z*M;UYnl3A#&vK3y>YROS?1%Js(F9K+GK{T;x3JE@^k8e4sgJ)*OX3S)M9c<;W@d3L zTF`PR=bw4O(?OFAPvWbCUwTp+6JM&=zFA8ar3)F(HvDukTp!K*)<;FG2N#dpYiXX8 z-@!DTlHRD0{u4a7^M!s}MI9|TeU>umRb z|D>Y|FY#F-YrcNzJ*B4q|LOlbm!K#w2Ma-~09A=diVkm{(_;~l$#S#dp+ATaz-B@s zD1I57h9`k-NnJb}y3ftGHOGH-riLUDAL7wzPHg29ZM5VPP|iOl^B)GdEOhHtIFKRk zbr2FEl8vBgR13~rb@Jo+N5y`SmD9>UKw!OXN z5)kD#gv}AA%>TzwR4BDI)c8OBLg5LRU&phdnBMXjMGUCkz=aRmns()IT*G43LEBNY zSQ{aMRxhsb10v+%#z4uiA*;?!gqcQz!P~e5wYG=34&l(MO%kwGxrCed3L!KcEtz^(}&f>FPYbd6fTp#{( MNkxf5u{VDI8~s}=WdHyG literal 0 HcmV?d00001 diff --git a/video/showcase/images/admin-alertas.png b/video/showcase/images/admin-alertas.png new file mode 100644 index 0000000000000000000000000000000000000000..1f55fc691eaa840924af5a5ab6fb1041a3822179 GIT binary patch literal 205730 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

jUG7K~1~1 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

@$?S(Mv!@0>r&FGP0PM_~-6qrWmFO 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%0AOH*nIgJWLquE>lqVK&U5OlOu6(gLaqAS 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 mbNXcGVCi@~sHjj@iq% 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�pA>-r~q1CAPX2FP;z%o+2-0zv;m2Y#opA5>XZc44{L8bkupL96Mz?CfXrOfqa3%iVeS+N0NDLc)Fs z(fDD2$!6m7S-`4)1ORPMfu0Xs1PtPC!QHx+S*)) z192JBywUwFB5eX}J78b!MH3~Eo}HgxqKoL@T<%5qoLu*uZK))ng8e&PVTK+hc`|-$ z>+tY^xlRI%w*f8&1DP)@5OJzHw*QTg7G{{LJ_N@O!g}ddvo@&O7(Ef*hhnKG6PGwH zs8V9<_N|#jD1G->jg*b(QczUv^kWTUY6$^<++Vz+JvCp@(KJ_3zyXy-XD4gH3kYv2 z32?>zAf;9R&MFP$4$~EOtYcDAL(MIGVHw*}$R=rIo}SY+1~?AfB;)c5C#`ZxC$r8+ z;>1Nd=DObOT5b_Nz;;`Fiu_evbZ~tuOhTDB5`JH9=4-OA8Q_vX?u%F|1-1J(3CZVB z%b5T!^vUtYsGIv%H2{GVj=BkWQ!$3EMMfgVN}~~IB6daijLZMJGh=t-IqeFUy7muT z%=-ErWFv!G__+;C_M7K2`vcxAJfB{>G=7G>eY!N=pFWEk{$AgiO?hAO^i~H8v21#C z=rx~46=2x_Apj07C3*ZgD-`Kcbt<&h=GfvOxI#(e9YjtdA4?B$4{mRyp z&-C=2vf8B_Kl6FeL__+b=U;Ce(6D5XY7Tv;etCbXmuAc4lV( zzg!--Tj;crRh_ew#c97|HZj(lW;EiLRPQNZxi0U03X3050pT75V0vHaHUMr1(2%*N zl}9jH{`~nXy5{jWA&P<{3il}cjgqKA%EYq+`~bnQnAx^rpCvE zv9}OV^8FC{NY^niUgxqzQmY$&6%Fv-kwftN`Usj?rwNE5-!mY@B%ae0 zg|7eB@9*ol*wv_FyKar^=K#9~ZDKWCJ5CUq1cR5!Re7Pn+!;SccOr5ag1Snx3jrVqVI#^NZ zIR)N8vwH2|&qkMBlnL_C8jlt9NgVa%Um0Ac4z0ZRm^uql>lfm z_t(yj45+$Q$HDOO+xilHY>&n~q^0X+iVRzD=^o!89=(9%=bIe25}Lo*E@&qqTE>*N zSBg)HPcNt=v={3Ok4*I>EVZ-(cH8h_U4r3Fq`*5>TVMNyZ7RWs4|ker@$FwrjS5DA zulrk&j5VgFmQ+>1f%_ZeJl#ziKI;`W&;HIiBG*2{v!gI+4OdU`my=J!@&^Iu~-%-ooZMuWoZ3Qsx`Pm4Q zvA9U5XWltUs1m`!Ei$X=f1qeP2{Sdg*xvtcJ+H|8+dK=Ruqm%^K^gXfvJ)h(CctKu zX0*KQ6+WJSHf7IK@sism^7)0$3rLz@C#^2t$)G*6G4gGhtGc0Va z;L?giqYQ`Eic#@=F9#H4z3PHk>UnsUcef!~cX;~>3ar$03<6?Kl;DjFFHktQbVuAm zK~dueXwdt59&JN?bdceGVr9>&FGDd(Q28b%E}|eAGX8+Jb+Nm@-&(o-lP-mi6at*Y z+5TK@D+5h=OZ^pCsfdIqTFVEoOmca71(pj!qF<)ZKHC+Gg_-1=XL^6&!KqP!M3k25 zOqm#)wIq?c`Lt%PlEFiTEUiimNpkcJGO`s@yKS$!WKY5EwuL6iSxX+i)paIN-X7L1OGd9}_S9uY;iYR-^b*g!0U9MCWEkwGd{ zU@1#+bF+Uf^3*EBno)+itSkWySF}Y0TuO8aTyaK6H~LNEXNUrw6yP!=_g{v%1Gyg- zkaI&fKqv*I@5;_@AWjh8s!luW{biR-g6s=HZ|@Sz1LF*oLlCf!PtKcxDy_tMso3gD z0l0kwQQh)MOjuc`HjFEkAx0IOGPga3+pPOb!;f0eNmd#BS*cA6_vUIv8px&Rlaota zR$yG7`pjift$GC2#IXU1tNhpv12npg!)M?@jxWxepWUZ#2F?9lUXcZQ7ow-mlxXH6 zC4+yjo=o)o?}>Qbylk~;gGBkUkFNto60o=39&qNjBr!#v9k!n~*fX%(xc=(bs3;>* z-8Eu=lV->JI{!P?fm=&jdh_IBdzuWM9nw5GYrH%tExHqKAhlw_frLlRK#&#owz=w*a(~H8zsx4wjZS6_qFB*e1T6RVK=3+9syHDr4E?03+UF(2A)WA|P;qCRCvX zZJ`XorRQWlmo#+oJlJz~u3;eS1lOLsiM|)56I6 zGPb}W1aZ&jIoXbl2}!#oB`T#sjH&I7XL|oyn&*VWq&774IK>8}fWOom2pFVV?LS}G z*etKE0|p_Rf`^OH*^S5Jv{x*zi!^Tt#ZbigmvSWvSU%{6YGNWIJFAdK7&}n8E~>O`R&xlG1!(@kHUE^P{;2!{U6Z5aOXG)FHb>+tAR^J=muULj?T8J$?r* z1m+{Mz@q!NwNwl@qDANL5&&8G>pC-lO`@3L(VSmD2=aqGW70HlPf1}4=-LxF@tByH zBRQMF{_E{$wz?3G?za8(=^8pjqO$03YPugxAj3`+jE=5S9u^%V{sgU||GquZDNxf4 z{h@VsimG=@@~f8Jf?;NX;_+?)NQUw4aDbRT`Q9b(j#7_16M{&_oXEvPBweJ^QBenu zZfc5movrQi*#uxyI^k#xq*7qec}caNj20+Yq09gPankE&a739AFPiv5z0Q$Yt zWXL?GuE{HAWsd9fcTJgqqEs{G#Kf}3k~C1|RodDD&5n*i!}k7udrBGe`T1q*{It(a zE8H!LmBQ2{hLISj3`qG>U9EVcs%Ame5E+P)OHJF>IU1=l1XldxuSFkw-p8+Gd5xei zLttW(i;GLfiBGVjwg2^p4XI3G- zM`N8FeDz(nV7-d+Z$Q>5$0xLoOyXj&u`wHdHUa?Z#*Cp4gL2=855uFgKuoI(VjcN^ zdmD5v$rs(19D^L^r^kbPcRv7Pp_IV@K%^(9EYfRDTCO3ICR2=fe_ZWnX+f>kdFwtg zHP+EL#2r%2dtclxZ{!F%C04*g8-^}`02kP6yuJOC1yA$K($gepc+kCS)bg|CwzVbE zbbM5)0rz8{@!{mElo6-XKj$ANx*c%N4GRm$n|zw>cTac8IB)7mh=VGE8|$jwcs2W# z?I0Xh_wPO*4Qw3S8rRaoc~du3P7!l+J6d~zE!RL)xt*tbk*J(fBH9;lW2 zO*S?*{gl$&aY-MK!~^iuC@xe$KaRIMce!sZDkkLb1`epMJP^__fhHBaUFN&0s*2`n zzDK&Ttu$zHT)J20T)!^313-x;PJGFzxU+`=mnN={){BggC5aaD4$657e9IHAHvaBK zV7eH5(9QBRk}NR!i`eSyWc-&~&@gm;pW} z%THhIG*i2$zFSbsI*a&Az$Y9{PO*fUn))nx1G&lX-;pk3I*fweo=L`$W~TsdURszh9I|S^?eDjTZ`yP)c*g{jwx_H{>6>EUZox<(iqF4?~KH`ODFT{!G6K z1c*QS1~haFte-fLa1?;+P?yY~KZfZIS?0YGru6i53G8vTXXd6xo}j6uRIa^8OhEx& znx{0h_J`5Yc5|bk$V(j3q5DNpy}-D-p7{Jmdt(VYAIiK4o6@*_0hOCo`?uKR50(gW zsu_a=aDc48n#9FbE_cBP;A22#+Mf!W%_sWdDWz3kzw#`eGkdxZG%X<2wyMYaWj8t5 zzqZOXI%Xh*oXYs}M-2gf$2`v7;SPU$2=(R#a_Y;s)*fpkzF|OyyNN+v2L=&D zH4AI2_C%4m7SMq;Z}zUY(GeT*iNi0+*SEg}o}ON^A33eFKBuzs)5(aMInbrc-HknZ zLs1?;7&h?n)j0^`KDWtfVK0nwi7@aw(Hk@ORX zuGms^eOpg%JQq)L?Qdfv(~d#XD{@Zy6h~K(C@{55Ap*UCc9~MDaxDx{zJ$*BFB4A% z&#P}Q=0&4)#1Hb@fIxfz39lahq-%fiOOl$XSxy+BT3 zKn##V@X6}zWm9Ej=c9vESQ0IcsS{acn>^F*X(kIi|J!V*j+mJOT$C3#HoJtln(4 z!ZO3{a&R9az4BXc@si(X-sH+6qLYLe12DeP6Yyqb-!ef#AbQ2JQ6wZpTk)p6>FNvP zTT1?#B1Fi~TueAILc*lsFMs6%NIAAwYM<;G6I|^9!^fI4VSJ>pUpjzUEDRz*}wfv z7GVoBIyn;BJN(y1 zj&RlLk$dAAmwIqb=WTQ+#Dt=W` z!W%8OK0b-l>RH%wOLMKn=DfLqVmD-G8{E6Cz8=(UY*noWeSA*~k1^kUA3;1#=IAQ2 zWOPJgLHK4x!G@9M3Z@>w`$D64LFC!LxBMvAj{{MMe@s1{}oUVH*EatKJ8`WS6KnL!y0MROAvBw7ZrM7(J^|g%`*jbHSdw@Wi zZrnpu1T&>kD>~j$uEe!9vGf`EOJj4nZhmu71mYEt{zh^d7)UYj-Z&winlBl^ha@Gc zJ>P%VW=G9?T%ef`&}@A+L`KDN)L!=f^z{AU3a`q`#n2nDL2}vpHaf$3thtpJluEqD z|6NX@BlwP$2JlBBzm((Kd2`Y0wQkEx{=9NY(N8G?g3&m9W@ids%}^3C15=}x@d6{D z)!+8{8nH)1i-|VXK&AT@S`d^{S&+$G@7$Bo!ryOb3~F3}Fk#iEM)2Mx@5gMVz9O85 z+&&_JRK~X}mf?ke8@?hbvg7o3&?v`Tqw@WC@13rRwb^FSfI1MX2+6x}N68wQ>(X2C z5SyAxBoVb%VBT9w=rnkXznUsFIv}XMa}^PowRf>%2IPv0edcPETzgTP3txHWQ+xaT zxV*OSIPaZP&h)3p!&DOB8Ngzn?QcCYDP1yq>s#;&%YW{5H)G=)2&H6v^Z0y?h6^H4 zU%slvFYrO%K(wQ!;Ici&`H8T6@^KGwT%W#z172JsEvl)cgWn@RC7|j5$jxvfrK{lz z8@2FVL5||YJTW}dYhdq|SMJ8ec6)a_y-c;ntrcu0j@g+~m?&pVlGX<2)sLw~i@&{^ zs`0$;A0QAQv*fo+J>N8&*XMKGuY0RZh8JWoL-75Lz? ziFWn`2dU{*|5EiP@g=})pmk}lkRbk{#CSlad7T{vibnvQTkfB$e0~2eJ-Z55OiUeU zy4FU7P~%sJg0=?u9}Cs8B9Xs#f6I8zmctFd`7BqMPzno!QW;mQ&91Cm_fQOwzk&jR z+uC#wev!P?WNTaWH(oCzsZ{;x<7lPNFU=eLZ^(lrqgblmv<#BY5y4r98T?eTHJ)VFt3c3uL-fy51C@CxP2#SKOUswRB1_tPlzrJ%QpOllul6ojF zC7~9MPmFm#_D0b$Yw!Jgec34^Gvb;bjW5X_#5IpI?Q4wUPG1sLwQg9c7-gMeC5sh- z8{QWNXQ(4WF+#(8$UiQLa4c4T(6J?mMu|!!Bf*4#fbbP@8YKP5{*aFhd!2%VTd4Ju zXW2^$PK38-ztA5fLPFwU3>Ov_zDeP^uBIfjF{04|wgAY1bgn?z+e6S8rIucUFA8TX znqX^Sfl~LUpBmU9R5X^A1;(@D6uTlBEoTBg7&MQ6c?b?MnR*L|H;4R8xihZMpGPJa zLqYEv+0ZIQK*if=4v^@BZ){Ok?H@PSyj2m$<(x~X)SfaMaSeq)q^muRtmQ`aX*ZLS zw3?kXOrbtOcqm{8PmFLLKe5c(QvPNXgmpETZULa2USggp6pQZ70w3X&#Xu5M3jXOd zD1>;L4&13f?#9H#oQ$uTBtT3q2a-4}FO%C%#}QoF+#eoDO3Sw@>}{6g1V!Sj03kR3 zI-FR%yUmN411Z8gKOY5jk-z%P^qZA6r~cdq5yd7(sXMlp{)pJN#p`Do^@mvVTLlbZ z^ZreX&L9*;p^yLMGQ**2I}14mC9~ad?RXBpnHX<3oK>IP0t6{=n@f zz&}jkCNW=L&#kLdI}95C@#NX~wX-vzkZoyt+=tNeq(5Z=psYlE6u0e?azQDc-__Kp zl+uL7tb<&RgoMz|iwdKTu1F7ScKM_p;Q?$gt*XKgP@Ez2#oy0LFRGlk#py5EQBhzOxkuv@Q-a5gRB~CW}>((~r2!fWh==&V|AkGF!)|DUbkyjGu;bDNUsaXf;4b zBgI8_+j7$Dof%H7sgE=tMRddntaV#^S~Pdr{;whu24I?lTc7Z>SgkcBs$?U=ner%y zWv0XKE-W4wI&e_CMOX{!u&VYQBpJ z`?V7=lzqp2BeyuY$S@^}51_Gg8f^(K;og#{?Q1y7=4V~d<39`0^$rTH5LfNlNd~ou z>J#I4yQ<%0m!>^1o2LD6Q1PAzKtOK&8YD1L^|PakphcYyL%sYs2yWe6~wFrvI= zxReU-KyGh6;trURepW4EuVuzhDq4Ik|EP#%QKD|092oJxPIaK!NBf86AZkw6M;IM} zY>nC4>aSTB0WO7Zy|U5p9+~+M8_)z2Paq{ZFmSy(r@;(g?JtY>p5%qGteB293Veo$&i7{tIq}-*fJ>*g)?jUcBHOagFt@;I zg$)i$WvD2;O8}78a|it}`@+5FTyw}lo7YQ&WndSs(3OrYrH{k#`LiCEXX@+M-NrytQVK&dDuIE4g}?lh zIJz{WQ%-JrPi$RIMI!{P?g|jK4S(Ni>zu`sy0k;WOhE%2u0^f-b43VDe?XXQk=a>W zUq+U&SOZcS-s@HIys=C%W~?-vgM$O;YN3}3`RF#%>xW+?0y(g8?NaQjJ#M z{%PeN^8__^7a(Mcd9UbLBo3BGbOpxT#793^q~;XopcX|&j_c%j39jz$66!SuuyS%B zNf+%TE&K&DN}iiXYOVT-#>l^i3CRgo4biqN`5snAocII=W0Cs;HW|Q&5_h2bYa>y# zsy6!(3x9TZmL%RLN7%H}iW#~53ztn%k$K5AQtx=55&^+UXEIvLPLpJ8tA`&uJwM-y zZ?6UU%aR4AQ2#I!=pM-@SMwADnZx9rE;W!J=uL2KvpZy?3c7YQ6zOfN9#)dmvSWgI z9Bl;PAQEWpNq(Ca5p)f>3(7)mn zXzdRq@Du57^LUPotzWdlVp>%x^@-}~d}jg=(Im{b)^=X7Dkv!SLP2e2-aWiPLm00i z4D?)8v9aAvu4=1nx3Va=6XT0QAT5;$zLoM=NKWx%)h|c~X{&lrXbW%!eHLdLI)g4G z0_Gv{Bm)IS$$|a1A)!^V-zmSq{KFFx622L$KL3nsZ&B55g=;NL`^ER+|KqBGU zxCkb5L{>@xX*!T*{V*S6S4hulNSV$*{#yF05E`GCel$4|K;QW+QUNnY+UD9)XgqdS zmyetGU_tq*2uc5=eoH4i8r*IO57E=xYMse2cQOM#KI%b%vHwDVI%m&$J2n_k>U4CB z7>RsxLNx7_0mwNHjdhKXK|vSbsRN=t7+{c1f581^1jeO4q2HrhdocbllD!z>BB9yf zsNqrxBfIXylcHh(#!JgubqUP9KIMK|>gI#hjEA(no!wPXBflahc4PJEv=gQs(A;{GG%dU_Dm3U^y=zxA2W*Ux&Dez1I^!8~Y#e&Uk$SYn-B!B-S-WS_B`{?%75Vq-XU)HwTz7dr?~A?J)|WYk zPoCZeK|x@!VTtGLMA5(VNCeX2{QS{gg1sNXq%8kLDa^QexD(}zH!aq;yLkim@aXfL zNN%zXR4?lP{Au6c9Ox=I8Ns5A65T>0G>%?Vg8q^PBMV~`Vs3Ri0!+Vkp^<8Cu}iV? zZHM6V){mHv=n~wh35*33eO3)}c83512Jf&?Sh7@%=}Rhj1E8(I$CgK)`30n%t4CW{YyNiRd9}lS-bp&<#AW9{Kv>Ksj`JqS1H$=(yR(0Za{W6-fKl~KxY`14n z_18)x^Dg=NYN4K9!9Q^K@gZb;rtJ+)x!z6Sm}*x4M2FB-e`4Nn6YfB;R;d7~+oMp_ zlX-q#W|?e257pxn@yoGd@bOKNOh2`skBkdfv0aE7|?OwA5xuF*lgs>9p=b4 zP@m_wV{17Clkir6zwb)Sw%qQlIvd(qU5yBZx0n1vahdk%wL`bZtJ4=fB%BQFOO6`^TD2L2P-G-jep1}^D zm^Xqr$N9CvDY-^QV&1~|tkq=lXt6Tr-{dvhPK^RGl^?agEyf7^TIb|kYFtiEwp6N( znCq8?s;M`gOfJJ;Zf+*|&=x@a<*7@%=E*hZT8i3Qs7v0wEVRvo=6|Z&A>!1tXmEE= zx#!vqCE)t-ZGs|jcNFUg$B4_$cr6yYZK$|zSB#UjM7O5|*niqAL$dDsj@&=(e9ocx z#L>+$*kh6^^V2Y^xA((?;+i4Xju-N2t|2`Jpn^1* zFwut(%y*cMyX&IQeirjjx6(b6ox(u;^lvk;?;NbvxRaY}P`B$-A34rbC3Q3yYDxlJ zUcs!{q>#yD(AhKIM`>~!qx5M@+_+pAgE*7(=^-rjX$u>D@fTF3T2pOQ%H`N$`Eu@) z@$}2`k=G0ig9I!(HQ;*;QfhFSOM7m*);3dXSsZdKI6c@_;?7l+m&-LZx?Z$5{py`@^8(3*xXl*4Z0`V&piA@R5Ntcg+hMCj@uYo>Zgc8g%8 z^~8=Ul+c8Y{b_3{4j5*QQ)+Cs4j#$k@Di7UQILa2kY?ePAbIqRZ7F5P|4{?eHZ zaH5#lKil0`Gau>B5bPJ`{a07JyW2;((@Kyrc3<37Ien<~Mf?XkMmjR?eaZKTS1Z6e zI=Zm1&_JSAmL*#~PtZSTeRuU&e@gV92SiK)L6!GC3k#BWn3Jh}_0<+>i^+ExhYc6F zq7zHEe9U(w#Glpw)bre&o*y$#+I(44S5Ia&>8vcPtc-cS2{f2g!gwEkrP%iM^<515 zr)>i#5DN2R3@b~^Nz*B<9Ksw)h08wzCN{#;sz>Z0X z${HyY?SwSwK?Z>|2P94SkWR!%1Q6CVb=XP-EMzAudK}K z=GI~AmhH?NI3wGK-mYX)h)tL9TR!CbJ4!e0qG)`!^ui$G&OP9=-yNtWO_51^Qm<45 zL5k5$x-*0z!!k=a_s<`v^=|_U=vG!|{bmL{^LvZrrGFP`%dDQwHjcJaRoMF$7d641 zaju>QoIX+t7aVV_EUje0%`@N?SGmXj^Koh^dDK=)C~lBS_y4M=sFf zDVLiky%*gbtBV6R%~4_trGn0=S3P5Gg09BUIx(cl+6eYJ?hc z8LaPfeofjp(T#Sj4Mn{RA+-+e41SY6Fej_va-Mt7b&J18B|4EY!$s<@Q^h4CY55=p z-2!9$XXOruV(_&7XnPw=QLyZE8Y!j85-vSnmzP;v4-5hc=uU(5=3Xh{VHDUHE)A~d zS73tuVLi9Lo_=3T3x9*N4f~v=!N9+Zz$fdDo&oWEjP;`w*0JeRrO10%R~xmEx8*co z^U0)Sv?HduQSxb#`d)35QBhGXxV72-a~l0CHZ=Vn5m5yEU$ZKb-c~I<_PePE8teKThOjyKIebJ9ZI9kt%o)hwr-kZRjDRyo#phwap1iS9wNKw^G(wz+q) zmC9Mz*gRj4mjlwINv-3OySDVj9A0%{S%N8FTQj5|)cf`vr(bGw|k6Q~q2W(l}pJ0}i z=D4j~5%VlMItH6?8E)$PLbyjGau@?^LcG*VS{qvcu{8HuIg*wYdD&e_>n zXsR?-)Q#VB@p`$OxX&1jko7JwK&YGq;3XX zSUs5FU)0<@n$rhPm-k6W=WWHn;Z>EYc_!Ec_GjjKFdENe+SaFZExwM6AjR)FQoGAL z8S^~O6*T%mij?mNmiAq)XYh81Gj7{tb)QV3QtCMs=Qim0FLNGcB2bXgN7n%G2p`x!ivim>l-+UBMqrNP z^5__C?i&RXYER5+|O-`_(iE3>O zS`D`qTYpGDd>AcLC}1^cw-B8OQcESWAS%8BSJls+Q$ALXs-}PT2jrw5KjQ4u%g+wl zyoX9FTJ?eoQDOI+W$8?{5r?Zvo&8=Oo|bI!;lgV|oWCW*FKy3HVv-69E)SP-z>|y3 z{F4k26|J;e%@a4U3ng*eHIjV8B^>eUm6g4<0>5^Koj%(T$|E8M@i_1;&+Gn;gdP|o zqrCO<=I#c7BHpu!|1?ad%)uS&ruMIY+VpQdG;_p_{mR+*ovHX-Mpvq%;)v@lHDy_G zs{mKu(v*@&VAu!qA(Hpzi%?rp!jK&S`i_R6u43fjo7TYCOc3U6b+a@W#hsv!q`q>ZtL~cV=Xc{7+rJwk>K86~h zCM%@>7ql{EfORmo_~i~RsEqVxDJwgh(M8w0=Q}+_t?H+(P8)?6ND0p8M)eIhYyzIw z_+;NYFd$eSWMAVA;&?Ys&U@RXSO+xJ!<(i?s&Uct+fw3%E8k@zHeDE%n&`E%`I#Oj z3Qgi+9$;-M)^kr!uc#gSa0@l6SE6~nA(QQTd)o`yI(0P}S;1@d^;LB+9FPh)B|N-_ z^CS1(g#k%;XH?sxtgt-j{{CGFa{5iXu*i zBHqSy?@-bYm=YuskEf(GK%T8i)Kn2+n9e#rA8E5O>w{pdTAjGZS-4aKMn*T|>jt8L z35KiseKv>NGS6w^dDipC;HnU$-6oV`OlAdC~ws^nI|O=a1*QR+AV~o@X2>_ADu(Mo6)Ev4DUu zlCNWxnshVYGgV^{1i)zpIXT@;hI^bsU^-Ap*sXgOF*aQ`Nf!gz4r6Vps*pL0EzipT z%%R!mKnt{j*wN8(Yhyisw$>$j57o=?F#%CQ@Q%5p(*p#Zd-v{nhiCZW96aBXIMPQ% zMo5ucU0wAhd`l<@8kQIb{iiX%*C-jd6zD@K^gq4sRrr8(H}s(c|3CGj@^X7*1heIx zos1hfxj^*&LWb1D)%E4{bPBP`?354qTwH3&4RY!q%yZV< z;g#%Gk``IY12x}&zQx#>mrW1A81}iniU|w~O&U7duU$0+4#4SszbaT7{8v{&@~(Hc zO9WcjCwu`i61{adYUi$(v58I&Hetopp|4!wBfd=Om3;2mbIBaq(@wkGnJ2`=L?avx z)&7`xDT_YH?E3wL>w9}yz^iZ@ZF~T!nv1B#`T04!d@(eTDJCGnq$J?`9BZfv&~FV( zk0!_U)tWsQ^fW%V&KzKJa0dbJsMz!DE@S_x6Gi#CQP4 zc^|&Api}(P#->hp>lqgpSJ&XLcTRl`5?H(kpBjf1m(tSNHq*X=)iFlE*!qV8yMNqe zJ-C34U{$KNw(fUVR~L1E9ROFTi;a(6GFMl7ry%hlgk4Cq7BNQivo}+nZflnG#lD%H zg2xbs3vjAS$^|2fX|MR4$BDx;Kz6uAil3|aZF|eS+Dh1}+BNu&qFvsFcfjy;pId%1TsPkAR-7t#D&DJyM-N+$1G2TC_Zk^9NlpOjlb=EZR;zR$)GBS}3DY+?zYy=WH`1 zUut!Guf13!)fe9Nem*L-JMX;%CMI_}JKwCV6wq<*;TdLt+SR{}01F!iI^VK}lmqpU zu*BI7JND5>oZJ^<%?oQ~QTfNKP69a_zr$GeH?)C#3hVA+kAjw5QSFg{a`%{p!q1a@chexNZX4BBneIO;2bOIuTwRLsIPFhJ|GSE0&V(Rm2n??Ow z3(yPm2Z}a&>Z5_(7vsqjrF7}4tr(#0y0^~K{|om+Y7l#HX!8KnL%k}K&8_BTmwDL8 z7x&BfMtA50IcQ{Hh}w!(a?fz+WH-5*__aE03>{_coN_%VBg`#iSE8%fs|uZVo1L9D zbcje9XotLFJs_VKYN@%hy;=w|P+Ar$v26~^ktsMKux}OKW6*)`rm~?Sn2x_nOGUZ3 z3?%b4>p^I2)pspETwJGtgA?VtA8}sjxV!xMa|3L7x+qz$} z?l*Fs3R%oWPfg7tf@2PJfU)j1KTGl6TG?lmn5uBduiBrP%p2EEtdTJrE3_I?h>u=S z);EfdXoW>Qv_Bqh1&X}44Oe;WmXlLMnvf#FfImn#dVkON%Z_W<#L3<4{IH^b%Bn&; z^CnITb!WvY5`sa_tLM#nsuYO%ZG|1HsEX;734&L4FZuaNz;74!(3HZQU8JpGe?qsYBfQ(P|S z)@)-vxrwA&eADGQF4}1~GwR9nJRK|DbU28kq!#(yjKRo+XV{6shFU~|=-DrzG4pcJ z|Dm#gJ{fDy3NMo&I2nhz0;{6svaJccscMapQ8nm?d|gLAM~!TMEH9?5jdlVF`FgQ| zJL((AK%?6DV%I5=H}@Rw$7Pjged_^E%)Qh7=c9;-pB&`^cqxK)9av^8);F<(b%)_W z6cQ8^6s~)f<56*m`ioksp&&b4 zFh>QwMS!21fqOSyH%uSsc>TH)%P28?&5WAh@8N#AbwXIg(tYc8WB~#}y;{sf3Z%}H zm_$wZbhp1W4K25A#;*%@cFJ>C7yQ+wrKRV)q~KIuRY6`4I7J#=?T6i~XJ4tSt4j=- z?Kc&J)JDBV*(hL;%s*dy9>@Y2X}f@+whLqI^mLn=DjR$FBPuH4y!$`b+wu%&;h8TI z^7hTNWkkA<5BcV-?3xL%JC&?T-ztM2}T%1}}c$ha+FFVWr(%Pkg95)ymSID5F zZfs;D@6||?>{*6qxMBlLYQid+m0?iBZ>E5BW)9@^d)zB}YfXJk<+;IKQX9PqUKfMsrZ=k&KT&D;V2dC2X&OBGp zBNDwO+u7Y2DOcwxw`)bh;9Yf`yV+P?+Zs|W-1z;3?{uC4%}aA?C++^ zEkncg4bZ4wY3t;8>>qi1b|7zWECeo+ZQZA~&h;>pmX2|J<~&(FgA~B;$7?g8$35W! z&&CLparUl0;B@81VHr6_cLeHhq%h(W=S{YOB^2M+b_n}UDEV@h2A6|~s`#^&zh|9{c{ zed9E#TKB-K^5FDn_~#811kruDe}83bD-GZVkw0RJ9l8jkDwssXLY|Lbenv)ib3eYw z7d>ChP8thItlui!-&#sL)$$mm7C2jJuArmK|HbcGQxEA?>%Afy?P^ZH`utg;r1}F$ z^8t}DR<{D<{;1(es$RzjSP^(=paQ^}9rlMr_~*1}sb4Dq47N~qF2^_HQ&eOmYi;2M z|C7MIVX?+Kr-rx6jAAkXP?w{x%=gnTtRmU7HMAHa}DKr&ll zWaS zO@+r#>8L0A_}KRxn~gS_#UO3dt353~*Y+GXe#BDZ8EhHrSlPhKw_RvgP@!-Y1 z`&C)Qz`$o0T46>%yu98@d~6AHMmx>X7kL&Ex~b|JI6Pm?nm0swW&apU!BgPzLUpWd#; z+d5Xol9UNnk+_|VquxRRTLo@i=gSVuZ1Srs8^#IKNlo&AbQ~Jg?_8Np^&#(SpHyu{ z#v0md_-wE`LW;=l9K zShAllto}XO)x}v2{NxY|vx0)cYi&|1iSWe2LcLd)Ab6i@C|pGNN3^=8wpKFS(a1oh zhN>5cR=>p-_CFnKv(bkgHWWYKh@m*>U`37S%GlGa+W;W&#?NJPH|~?R?(kN@^37*}{WAr;5F01EQaI~&-R4wOR3^wM>F1IF#kCjj zQl4;w`WS*xZoU*pDU%_hqM!~P(=jT8Lve9)8m|SL9iMs%$ZyXN05!|HTxI;!p%6`c z*Y$MS3KkY120rRDq>-p;KNl+(&z$SpCT(sX*?~@4l+a zIKNo2#s5&ggJ^7MXc)dkXhpEj$H)5&8-J4ay1cx6IG9G6U{!m089o+3I<7|dio-Kh%jJ@f z!csh%9NfUhOc}HRPN<}sg`H_))VO>x)kyobG};F)+?UU zH>bR{@;obrjtG)_$ekIcvx}mWD6E@7k;uRW6bWps>{}6KTNd9EUVdrxQ>!wql*2^- zTU>2vcW0z{9Ky)~HUdE^Mvmp$@-8m5O;7Vzw$Y8ld^rKq991uLkHH#EDKctI_ zi5Z`q3rlamxCkJH9sd3$WIB4VxnMv5uoMR-k%z`#gaQEJ@bu}k37uK2HP52Z=Ck+V3UaE0 zY1iBgc^$_72Kh?ce;3n`w)eaLK#>BJ*#fi}z-Mu=EaSbCw98|rkXwz$tg+v)h)nZY zL!#5IL21jPA`s-YaVl%*#}J4iBRd?!6+Pa~TCAoVq7NkA^3Z|4vJW3F3Yj*XKxIn+OALx|2 zicwWW{?+1xEf-E#bEq|!<$WwS6rhgF+aHi6Nf?~j6%jpV50S63kk`i~;{Jq42?^Mn zJ+}shF4O>idY71oi`NLyAlCh!hrr_Hb-zCfQdU&VU709FDpqFsfP#iH2r+BLxHra9 zQMphh0uHPK%FR>Y(6yD=T71zImg&S(n?vv$XhUYn1rYN%vDNj0ZeR!qJugAplLBr4LcotT@$0VA&_L|{8Vo+h*UjvEOxNTR zV00_(M@_G5O{Meq3KlmOUVulPuJO^%nrsqJ><9%Dxho(uXBL;V+-6i2|x7ng9!KFLL)c$MwS*|okTd; zPFmsEibStj`d)(Ohq`+tgx7dM37@ormR6BpGs76EXVC{>S1!cFXtJzG^{3?lx+;yw zqK4fOtaKF^b+OX(&_PDm#Dtx_x8Ct@9FTJVk`7qyN{Br(UDkoIeK76*(>Dm|Iv44t z2EqcNba;!1=o7C8Z%WNoI2xPN&AM7*P0_Dya!Lw18iVpzt#0|A3Xyu3B3+&NuJ7cb zu?c~VE=;Ic%ryS5;MoXLvUM z{TML@HwJ7s?e0XaB#-0X(E7Xw6<~O`hHA@Sxt@${JZS}VGDHc?I&IUtp6@!r*SFX( zs+7Ty!3jA`t~;b%Z--@Z1_re?1$pv4VKna>>z&5u_v^)O&-T%}ySq%P#-UJn;kZ=# zwKWpJQlgT=Qb7g+q%8ZlYn8Q>RaM~(#83|Q`_hx;sD8N#bOtmtg5Um{LWcH!o6i1p(Q;w(~ZY40&Zav%e9xHcGcB3JFL~gC=6#v*n%CPRAC` z!amK(+Uf36Qw@`xBut;a1NTT!F#oGn9x7>?wycqhfwPfQBg~N(aNZt^a;MJlTJc={d*zD4{KpcF7QchXAGz za6ers&C<$B#bm8U`_>Vfz>U$K9hacxn5~}@_2mjmO0>l(Z$VTUP7dFigB(=%?>_eF zwb9NVNqNQ}83+2Jk`T0bJmp3@GL@M@G1b%6JIvMU|2_{hu*L1%hpd>@FPv1Z+~CpUui zGc!SRx~w!Zv6a2OqQ4}wutYI(nw$Qp_@iGl+_*TE07tl;br37u!~v%5lfHg>3ZQex z!@&Ctx9xdBIP~ba z4EE8znwKb;F|&T?+xEkRSI`6q<6ur5&!ExSrk-(lkDp+1F)vo}vAhAif=Pe2J+ zrR7phRn?1iPi=+4Ffw?P78o8NSpE0|=%}wN(%|PdbcOc|;O8c)aZksrzJte9cT+i# z17h&|+>Q$C3oii2iSO-8PJgy2=6`2G@yN6IEMWVnXC>zojO~Jg6lOj}GL-Kcs*sAM6^(#pQYc ztB#5cA6nS9+&ghJG^BM9s{?gQc)H&8KYjq|Hyk27YOGUiAXqIWK0;^JnC^~@IWnmv zs#N7@_$kk*+OoqQ7t6^BY`3M&k*BI1SNY7!M<&GBcB-Y;DdykFkYV_cl#*Xd~^V4|I$U;G~L zkwnHKRRY9tmF0YqFP!jCm59YxAD>15>C#Zrew-B)T3y@9`{M4`-91@|0UXTk1qLmb zqf{~fk=6OR5;7_VY=N_{byo8_{naRAS@Hb`SF78*g&@@J{?jGb;!jRt@t*02$6)N5 zWj~;SnZm*XgEs*2lX`dx2ad4ze;}^g(3e}g4*3w7D!NI-Vcd*VtaSUsk#&A{b>g9i-^3FGao2@MX11{nm?)v z7$gk;ROWD8RAx`5COI=8SJ}6^x?A=3A8Bf1EeH3wd94&Tg z1`vMaA^asLubIw8E^2>wngStr?TPAYo#h%wpsoGUdYnX>;McoY= z99KNw>z&NkL?&VzM355XVEc?$q%Ao)zF0aJz`KeE==rT`z^b}wi|BHm?Q$f28g@s9?!N_t&O$O)cpMZMSZ zinWi<-M9`sPav|dsQ#X=`H+aM731FnE3j-B%f1YNW%3ND{YRmC_g$~s+(|{LD1gEL z{gs3pI@i?apYH%e4L*!Rr__HBW<3a5DB^oPdk5Jg^r2c@lhlHl&xWg{dwe&oQW|qz z7&XQn%Pb_oERK!8TMln8$ea`@l%Nc!LP2-c{Wx+cayn&%_Mz5hy9 zU%y;14gkgmWRXUjmUqV(FyDDSdk?^JT^S(DAmFg|1qFT#5*GlAq1URDf`BDOMT13> zyF0K3puiL-NGssm(_-tP_=P$APYgpL%v@g-T^ZE&Twh&2wU~%Z3k*a>gFk~#;57_X zu0;XWW@de>BF*U(%#HkzO@$**))y4AQUf|q;QFFosZde@!h*JF?ykwG@2)y!jdEm0 zgo4qk`k|X1KB∋IjWt8A3>f>bl0cG`dKou;uzPzBb_Qt&%8@9z~K< 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%0DL7(UMuxCd`VdyHC~ku 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 Pf*>U>Csz8w!0&$n_sE8T literal 0 HcmV?d00001 diff --git a/video/showcase/images/admin-microarea.png b/video/showcase/images/admin-microarea.png new file mode 100644 index 0000000000000000000000000000000000000000..69deeacdbb21f012b42795edfe969cb1216944ce GIT binary patch literal 177103 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 z zuOxmyS2&8`Xg5}sF#m{vb?io;zq!pD{cpAXO$w_@2cA3u|V(^}OSXK%~2C?uD-{n4(!Yp-l3|H+w?|W`kfN>i|QakkMLY!fMvy;^M#EZQOcIhN(!0X=3}Lv zt}*FfrNl-DCf-#@sK1pz>>iU*)>rI805AJql?vkj`E!@owENadSG{v^ufe8Gc^EOz zQd4Cy(iNfTVVmNoCZg~=8RL;&yRpebbF$%mKUWlXLkQy+QAASSrKHQ2<1fK1v}%Ui zTNU%2?0ZX7Y4(H&*LJ5W9||llKdUHwAm9nv>w5DBVq>N361;bnn^p7%QPi@k&VsIi z+=8Bd&mB>-B&66(NnPMKHw;QyDcvy0wjy49mh@5l_4#evAem3R>IbE_Tlk04|InXe z^{2cHGwqR!p_{?uD@)a9Y}C6ea!AFjUT zJ8DO2eY3?zWz(y1dc5~YxE&iG|K4o__p-~eUq_y$GM9_5NHZd`0}ifQ;-4$ae6`ZB z@J!m7Q2eN;mFO*GE<78g-lrot?2Wfs)+kS9r zIF&aq@##Q`qltw1(cimajnsEsa$2%u9qE5+MrX1qa?pnUc~hN$c#^JR*jCeC@kTcu z)jbG_mn--a9bPL0{m$EQ4kQcUwW4B^0|$!DCXvZEQ@Hh$C)cCmqhHQ6)|JbUl(wb1Y4URK zWbtIUW&gh9G~3N9Y$qA&Uye}3-dYWwNJZy-oY}y_V1_}ll#~pH`E?jQ749HSVR0A< zSA%RdJtfM%S5}+e@R#$x`E+r3vi%hIP^=k5|6Rp; zF2La|FRL0nIAiTtl=0-7+ROvk02WIjj~0TG53rE%1G}~%Pb-&5kLj2VTT8lOAqMP}WFs;YLIB`C&LQ73*?_Ts)8XLc--1uarXdd*CWtL6>mq`KfCz!jIkKN_u$Oh@_X zB0Nezhto&!tB2(#0Mgz$R4opJ0`&mTHWLyZJq3tskLGDAwN0DksxdL+G?V8pv?o00 zvKotv6?)C9Q#j?v0#@k$*?Q5!kO2s(#%T@f92G=GHMt9LR%gro-u5PD(VFznMCZ3_ zO)~n*8F;^0mP9j7b`ZF89*VzJh>cq_Sc%U)*LBaHeb_H8)v+=gsJ_DETV(LX{V4uX zDDmR8ebVL1%1Fz@Xm%0PRfe#iqoECQNJ#GEy{bHA#XLZ|(T$N$(X;-~m4INTO8i0P zCAM$T`a_ZzzG4_Pm4TNuJZikAVJ4-jRFgZt5rtnKSF6WVY^uA}rs;pIv;a&53Pw zE45*!)*Xa3pUAamsJj89A*FuQYpwe3zD+1q83@kXL4 ztHQ@Yk_5W)qA-zcUNXLV^@@YQIl?sZ5~f8bJV=3E?apO-B`L; z9lWl(?Z~sLLFtB`NiIFI7Gs=PTX{;v!{St{;6a5d*SWZ3=^|ZRLb$HwoIC45$P*&s zCsXwIMOAtKC0D=h=*P|r* z^w-FEdQ!A>lL!CgjKHTNr#b0zIP*0bS!hO@jDkj|nK*UK!1~GYpK)@uULqQwgtDLW zd+`kVVkl05quc8bGt=u6SIhot@aBhi>kaS$fmQRN+oMs5I!?>y1u6%7bw^?sNuXPk zvs&Q?3+TDr)9qyU+|=RlRB;2Vb~C`mXxSo+u!|R=?zF)e9D#r&PUj1ac5F~l!^Ff| zpG~SIYcissaP}N~CY7#H)er`sH9L2+a$hz??OcU#1f?Z=z z+j-|%sU_w6W08NhRS=8|Ra@rk?J^C0Q0sc^u$NEJqUrr?3poonJbXl&!MxUCnX}~u zb(Gu>s1J4WXuhYbZ`yCM;C4)fgKL<=o!iiW&3$!XY`JjV!EIx2d$@|A<6pr2cs0ET zU4JlM4<4Ca59nwyvZM-zI%}y($5$6`n##dRw(NFb2q+zq4PNr@^!I6rNojsSVhyMM=jeHYOgsqIefxFzhxb2WSY{5kRqAi_TFsXE(s ze7BLvY()#WxbEt8!1JETG?KNT_g*t^O2MoGL@PH6xhbcnr|Z6Iv(mS`yG~>OX)_|x zH|XRJ`vcGv<)vyq!uAWTP9O?V3JK&0l1?=uAJ%9zfl;D$$)89PUcYxg87{!h1Pnzv z|6IFR*gV!eWxK=A&DQg>lFl4`l`4+=&#r634h#aqs_t^8ikOPcXS-a0iW{)tcG^sy zY*YVd|21SW<6x+d{}@VOvnv_gop_&lxXRb;W%#}0Tuc+?;xxWh;Fz6THE8D~Bg@Oo zE=Dq=rUnfix_swYbVy>uK7dbx7Mm?1J(y?5Z-8YEof=Gs{mne=KFF@U`l;PzvEDwR zA4aFJ9QA38FOkobWbc};?I}P6@eg{~Gdgw?;BRz9zc@>@OkrydGOs%#;rQ?pa8okM z@&Vy-7OTR1kI`j=nLB4)kCMwsgz(8@FMyPjwp*qKD z@>x84`6S1KNuT-d?qTAR|CiF>&bN>4#!ChTfaN-Dkx5GJ_38$j?7nr|8VZ#l5bg9x zW##f;sRj9~`vHSr0262G!nxsW2l`j||A@oP4N5|)zGaS1}<+^f#l(N{SR)Z_3ttf z-VxAX)5xWx4W^g^CkWP=Cx!k56hu^p_P5~2oFq$EE-q%eHR{q^+gEJvkF0#IcMe9_ zh+qgo+(jqr_c)TR=bgP%!NdSCNR;FSf2yp#eihIf*_in%sl!P7M6D6nrj=>CM6}V=530T6Q~1p zG}`(QWv{cd`(t7DddaBy>2^@!_0ZTfTm+htAX#4i+LAQiQ4Ehr=P44B4T#rmIY|fz zc-PR(w@R(tpQ=X=ZP8HCc9syjhgtL8c;6(|F%$aMP5;gvyt#{Am1E0%MCk!{xzHA7jHA6Qv+ zw_ClA4=FQ9S%vJdCvqo0G&iit?{mAyPN4Vh_C}sO_AmhF()X9g6(Mf~JG;B@>NkXG zM^Sd=B}D}}UsN_vAHbCR?e6xXT}g|Mwx{6obuSC0dA+*bYtU|VL@)QaW@4_gCp%0@ z(Ds&YeI$Snh*z~Grljm0AcX=)8g?r&UZwFreMKzJk9o?rA|cp};LOmuxn;NEzD`%j zz;xb0nr=QJ0O1s}qx+-T>6Qn+%7r+I1Ddw|**V=f0?vjJ`;o6Au0|Botrsv6snK}X zFT7!UrSu-$2<3!{q2qGSk=()Lt=Zy%#n4ut>T>J6W;RND^fx3s8V$Gw+BsM z2K$~v`~yXF_ePe6PVz8$pA_oB(!3<#@%0W>Ufn!IlCINzr)TEdaSK;yUMK_rC&4dr zA|7g-4&61qguHJP+M~Q=w9f{oTfmzT0N~@(@`E+(Tpg{{XnUKOi4${8cB~#9EpUK( z2iBjUE3lIK6CS6-+f7UlLKD0`G|<%Z@`ac3~$)WmyEN&Ar2Q8eS-`i z#mu<$2)c=BnM33uC8Pjmo!kZy>KFRK@@Qy6Au1T6-{a$-1Z2UyR=y#?_iMW9j7m>> z-&M8Wn%eB%d_BLA$Z3h@F5;~Ftq9S~-26>Os_*&BISO;D^7?8Fev2g^{Q_9%*F>Gs zeP>~U0PCT-v57^(&5o7zMiv1ja^Fdt9Xe|~4bG<_^U{-gDnO_^w70mp&xiL9oBp#Y zZSQbU;2{3XJN&49h5_+~Q0D62d1BC>1A+g~kKo^MJpT#u|KIdXk4_=H@f9zcy)QJwiJJ)y6p~m*6Y6q_BCu$_5XQisO7z}(*cowvDcdi^8h%7Ft z{A`xagjPancdk_buGaZ?wLgoGcccT2Ry&YlK)g>g1w432PRZ6zoD0i5Ukbl?(fn|# z=8S?nJtG9g?|l$K7!)w+smIPd03M@!c6Ek8YVRAMtq1R%6*ff27Z=0u-LBTOK9Ox~ zZdL#y8ML*%y!Z!ynLi%ixk7U7K^o=t$(ginW&u3pdh;r+9utowrj>PaYR$kJUYU~e z-R_={wl3sksg$JfP5ESSpuCK z{g2o^$9MQHE-t9v(+LU4zL6UrKVjq}06Lqj{MuS4IMGjGCnr7gn}b07khjNva<}YJ ziTge5@6PUO_jbI_&nTFY)HeV2LFHT5FKIZNta#FrsXzecdP{4y*g)ez2%_&{!TLQA z2)#y2M4H!8LjSDmscU{I4?C`HmJME*=%16mb2E%wI%=l)ddE z#WqPqU?W%a;?gSkj=(tmp8oyGP!SV^9^ND*e#`u6vw`{b=qwHcbM4OtLR%E+_rz4+ znC+24lXJSkIu&#m_lI zx8tcnHbjJf+r)hEIXgsWh$_Pho+B0mlgW1f-H?YFsA4BvkN_19ff(UqYmSWO!@DL_ zg3fSm0TVN;=-Il)V(Kl4j6hTLo=}fAj}{oL2)pc>s4^B5R#n}2=m-piS}JR4 zR9emR51_}JH;6!G=jZ1SZj(l=^u2M`Jt2u=qM||dZB3^Kb`l_I+I#!`=ObG6WK$Ok zQW5Yp_Q+x9Hob|m%ixJGAY>E;edl-pT=tr=v$8ogdI<}YU*d6u)yO)B4KR@7H%5d1uAY;7gD@HpmfpSn+)0O3m%$o1W^?BvvxIEP?tN+>c0 zCPrdyw(qk=O%0nCWEKJM^p^DZ@2T~Mk;Q?uhj`ox94x!LyCC+}%axH7L)p>Y0kd*n zDQx6JZCjN9yuqBp+~;P+#Ws~y0UwFHo11y!X%pdvg_BO8xK-`4;9YvM2b85E{|!9pP|_`8LXEI)t1(;I4lT=ZnTa|q?-mFOQy!_bg-$RN){@8->=O*_N=mOBz%0!#b(?|Ygi_~SFf0KhfE+JCm-Oyg-idHU@>kVaQ`;j+QQ4Lx=GTu()bE^>7PC;y+vPysHJ;SkWsGJX*N~dwG$iN zfoQa@wI$sJ=SA*m2+|@FN{5qrEz<&UXKZYwR-6R^*j^(&gI^msFy6pWt`{7%fli&l ztP`bx2$Qle3d3#f(p9+Mc4@cf zPcY8|YKCc3*mR$9=`qiK!(1#ZFYmqJb~+hmP`A0#GO6obTsC;Bu=#0u(+axJiFw@T z{QC5Z93%xc2f4|0%0gk#*AQsP8!%ofSp{j7up>+YD4Le|9CLF{Ukg=?D0&jvD(Dz` zGVY$D>L;@+iV%UIN>I)Wr^+%lBV*PhsHx_m0}ltgAF1!#1UN&%G4vW8chJH-ZES2M zH#e_}T6k7P^?c0)VMlYfxH$3rXy3c?)NV1-V=~WEjiwyZJ_VBdFSaZzj%>#!W~!!} zsUR`{>Z8N>p!sn{qM_UFTRICWtZS)I?E8G?l=TrQws(AMMG)PxY4ae$55TktQL$e6cDc_ZU0yu9HXTT&vNw0!+ zU~y~@2NX;1oL*h~GkMN>ZM-WcsH&Pvd1OuEEt5p{9-eZB*lYFf_|as&(v#vWrbfKl2F zj&(qAb2W6BKc}=A529mk7#x1oHi&n&pX`vfMS{J%>ix>PwWk`ZOk-(>1JpaLczghv z9r=Fn`u;)OsrUy>v!FR2F!y}}pMmUZ5d}rS#gM#?0ZuJH4b|SxKP8lfR$>^c9RNh& z#>OV;ZHJJ*NH0;_&+qnL9->-OC=rgeHnxtwEj5_uU%_s?xe53pB@HB8u)lcR`;sw< zMhH{djL|-LUf(2qkrMB?Qp1_9ojvlzraBx6D`&ml>rqa&t4Yobuo3SflPyVJZ5b{vfvZ0}RUgO{(ca@b% zq@(HAnXN}e+1mxo1dK@oUmC*8jntW(f^uUJ)*9NY&m~h(=HHt1GBLo2F&9);hX?lt z-~7*43pb;lo}rrjii$S?Ryj0!fho2-L)Ix=iMIbW;En(os85&TqT*e*SA6>?E7G7T z53PFh_oRS&^Cy7{QJscw-%PntSKeq{Z_nCJ&RngyvqDdT9c~ODFlr7+H_>WxfFB+n z{uUfA>~NtEJ=>2E816kbs0!Y-yKk>m#j*WLto~-`_O1zPW!H{fUZ?3 z=F=CmYO25(O(}FF9JOe3JFqGq-?$H@JYsA~Wl)^9M#R6>NK0chyytLlyI{{X7Hh?> zs;T`|DIEGK{|dk;y`NdcU4h2Y?O( ziq?CHDcIg}Up@GNfQ{4x62=Rai~Fi~+uMWl^kr!9$~e2b&=C0aksS>1$KNXbMs6d5ikezibJUqbmH?VMe|)pbz#(3&CgLD}600j~-_xBctr<8Ak&z_p_?9=Rrg>MHH$e|4xbfg5#E$ z{DtuKT@7!Rg$I%{0FpC)zKjm3{NC}nk!Q+fqWhx&`8eof;$p4zFK0ugAib>*606xB zQ*QtmlnQ7t9i0Cg)x~RjxT`3|UJDGvm*te{wL7~5U-E;67N5~={lXmB2`O=`jM(ZH zWugTY~M|`rm>Pt1_}$38u?6d?H)#8z)S6 z)4KxKYFJAW3o0Ss<6=Vh7c~m0Xu1YE@j{T0UL!nrcAb_;OnSFo$L}=gzS~4QtFFGj zq^xDct0?tF>a(L10RU>cmZlwxv?vZX?dl9|{-bSoZ^#X!(TC{O>t!s;ytgQJ?3E$j z@%eDeZVtR>+;9+17nd($q5uJs0&$eC6M z{@Aod%6kf)^bw4=m@MV5zFu&FlB2KKKK%M~V-EwFYiQuG{d^nUc_6&x)wkhd_p>VN zKd$*Ns9Uh{XTPsBBjQ^_WV3HAf2mMsXA-2BLt9&;*%elDIBkUy7yB+3drpKv6vJx0v{-V0~j)rfx6V@v17D$AKxU ziy2I5$z%x2dOX=T;EmN|qIK?YnA_V=PEQf9j+66z3l|YKBnQp}bxEDvqR4U{ZTC*XN=L? zr8+kD-d)4duAyJ=p7}sV>D0U<**xe@ZhvD7d3bEyY04=D{-KZMn;IK8Dvp|*oFuvx zP+v|1uOOqY3=f=XXE#@!j0my_vbCFV%2xK*mAup1IO`!itk)1CNt3 z0=#Xpxfut({6oFvdr2oFWQU;URJ?e4wf5N22LM-+Xp_VShYYmT6O$a8_@U^7CyA=x zgr7_U=Tb?Aaj&KR7a7H>u9Xpzxww7sLs4P%LzcRX0@>78t1`Xybz7cHx{h>US=}#A z;3QHQ3Aohe)9~gRz-;63cs?7{_P{6fx}dW=JvC%nlB*;f9uS~^ltnrORdywn`;Y-V8$rkYBxs_50la;n2Iw)p6mbJ&+3^X^ zNVV7eBe$L&VeoT$DBiQ2vC6l!v_e5XJ6uA*!Np-Nm!Dv1RN(C%Y_SX;3~%ZvO<4k3 z_Qm=IC6ejE;aAb;Jr||Tm1ti3Hz}qmDM^1yo;<#hBX4zp$!|U^|7H9LEy^~W2&%&Zn!7(06mPzv^cZ`h!%7Ib7 zb8m0&&BZFr-a}8%XAnEY4c)5dA-=ji|Ku)L`Gvnz0j_50wZd2X&%(wi`269$tThrJ zKZ=MWi(~PI=Y^(2OUkkE4e<#yAy=t}1MifwBt`M>IxD>Vu7 zQlaUW2Mp@63M7w3&ieA`hyIIxB~|E<_T|s+MOr`~qjao4cKvz~fJKQ+%uIe7 zDF#o>&QeuHr$|Z;52>UiCx1^&o?mMfEiwhD#&7d`{5{nz7uOfD9cM4mu<-e4c;jsw zbqZn$sfgYIArg&&YDJ)Krid^t8vk>|js(JiZ$v-Pme!RSXUWQ5Zza&Fw48IA6qX1P zz1Hd35KUxqL5r4~c#TFdhE^{jo{;EWOVikdC;dCL!BY+U?~ZQ&{SCz1x03{q&qd70 z0}HEem_Sa6;JK!yWpG;%6nfkoLEU`P=H-W;B^?EY5PD;T@Gzric={qYtH(PNX+}3# zk3tO<6|#XjhUR8goj^taq@=IyGm?9_up)KoC43tIeGecBI3?QeDk;O}O@e(Qfp!4aAmypiv z?33jlNoTXp=TLop6043O-*aHwk6#+){H#Y%wqEWy`0kb(^P4YtW5e0mbIsjoZiXJ4 zRu)4^b@;K(<~I>}(uL$dAOH`5z#Cv(!>-Qs=l`TTKKlsVKs|J{RULg;!%YDj2S+3F z!#(r&raRZ3du-se?Q*-exeh<}O)o<^^V}Qm_~T-4dv8GmXtn9Q@fhA8HF~cgL&?n8 z=pvClTlj6HyN|GlBDE%F`z;39OC81DqztHh+FH5KbE^D!&$yTa7jAmVDY7amq8i@g z>6wKjs?13!A0Yyk@%PM>PjLt}Aip|d%vA%4diD85x8=yGT0-*#8vdL({{||7_&_)e z49xcJI?aKWAUEdnCT`@9!c;KkmOswha+b9$^&1@TB4#(qfMlWGbDMr^G<5`TVriqW zQb9v5q$MblXm#8W94i4CqhFLxb%4i5o5%`_Y&6^sl-D*Si~u6Uy{Iy3|DvsJ;8xPh z%6jc_w1VHdvU1}R>0e+z2#Pa19TL%UspUVgv$E!7S4O|@=J^u!3=g_E-#}G+zeaj+ zdOp*cFNcbYaeH*SpIN;=!&vZ&l`epZpvc;p7>~6%C_5jb1>*d*zJ7V2So4d?;dKvm zW9~;&jE$=>=-_d#Z(L2l_j0Tc%cg9(+u8my{8}3`0B{h%EgDMadeKVSk_l)`!sbgP zHusa7FRJ~NqIdVtK#oJ)Bm-r_g8c4$BNth2Zu@eh;aq5Ze7-+vy?VV}jl?bBiQfSZ z8Z6)A%X9DOXbY=kSL%9@qxe!A{OSxF5ufe{!OctE&S2`j-Mm5kob1xD3c*c{BjK++ zIXi$PMqfR?!Vl=8ly*uCt1B-v2Ax(ooHnUPG#f$y{|6EWyFo6YkqZ_?f`I*Px>eWl zU_=@TO9AcB5EoqxD5)*$l(2a*l+AbY)uH-i(KkNtJ-MMF@GgQ0RFzaz6}z8vU|`-+ z^n!)@TJV+~$>yAtuTz@)Xc5yTgub-qEA8*-cjF+PQ5C;53TuQRGdr8aFG{T7WThDf zoJ}B}1OwECiOkCx?=^RI^!?apespZ}-_P#Ir7TYa3>{WVYcY3&1!;FO7<5jc2XLVE zKEL=?^B6)(MtcrJq#X=&0B&^uIA(se1lY#;HGOMB{2;j;gy^!s?)%RJCyIWVd>R7(vk>PGY zVuJ{K5L3=)-JV=TfM$OKPltPNW^LWN^*{-QV6B{eCO5w`CcK`4;((qr9S@Lpqt6L0 z@p!_b<#u9r?1*_nzYt~eYePqeB;-->FD2k)5-!Ki}T2p55@uxp;@>Z&3i>fD{PSv!Y~G+Uz)?P#G8z=&tRz-@Y@@NHc{RO#AIb zNDxr91|}vGv1;BAMN&#JD1`mB1nyjU+c`-f7YD>d^fL~KIFcOkL*1QXLVhDp5ny$$ zjeBcb@t|AIr(vRi=|FtOnuLP~Pj#+=s+?9tNQe~O#?5dFDeRWrc~(0=B})UG z4_h_hacr$mQU!MlDr(H-Ge4ZO)`698Oo#ahl1oqs_?p@H*v7iG^u}6JR>lr9(%H1KOjoNUdS#nN{*Ee>3 z)kqo)`nrz9?n)Ko^@rp)!li51dz-qsx!R<^$ty1i_4BeYp1+I4i+Q)IByW1hH90Bk z{V(Yw8!GXVbH$xM6u-&%lpAiGq=mJBCEDA7nx zNdY-2DC#sc5O1G!zCUQ6N_49WGKsuv0$xne(_uhpnt3{%q_-!j`Gk72l4vq~e6@q-v4XjDhdt0Er7%|@s z5VvDvVj7x+wn30Gg1O z_Kv+=@%=311U0;ctj0lb~4)@!zU#h6+pR2?Plob>%|GsPZ;{(sxP}@w>IkDVbQB%FULkub+PnHy` zZR;yW*^&oS#@%D#Lao+#l1nWtlEb1QGDv1X~fjqVjKk!XwwgtfX3;We_m*X^OD4aJj+K< zOKD_l(UT7^O%Um;rAFfu5`qd!N@gGACRm^jN`A|qIb$F$Xq{-?bhb-m*XJ@nxbHS+ z|8yDZdJN$dltxx&(CjSo&kK%vlP{g#S8WaV!bycQ&>YhzJri_W0lA%w2Y`bnE6DB7 zJ&KN&!-$Jzv}9nfL%Et7n>IpAyR7xk#rTD=R7kIW{UH{Ei++N`%pc3WIgsT5utOIc z$GPK^i@zFga1VH+F^^vx8DvH#DAOBT#;ji)8nTpUS_XGfm9NPh5fSt8_p&UCPTRU8a5i?TIcE zi^-dX{i(th@hOQ~35v#|P=KXR4D};DoBUB*h5{HlF$Z_xO_i;*c2)P@gzp~1?YEzx z4<$SLe1x*e5jQLaRnwNRbY8RzfL4l|qG3}08@bhnN+_&ml>ep8!Qo=|cAMTPq#hhs zd{3*q64=|Qpo&4%s((a08$|@Z-z)2ukYO&Su9GiM&!R2E!ruUiwa29Tdqz4UuHjqjDddvga?@;sl(cQi2e z>+5wZ-}{Xng+VflBO%9-g5x`2D*&+3wJWa%zL7t$%=$~GhC{XX%4L6aHQcRHl(SO= zI)?VvtjbhQ_Ny2tt#4E}0eA)rJJ$Ab_I&Mb2>o2Clf}hVNX1~`B^-#!H&-~ameSEC z>iC6JG6N$ET*&248+J!;dBx6s3{kMsWgd^hE2E>);BXz%kpJHRA|3=9 zw1dXq0MzUoW$-U9Mgnv3iJIuSR;w=y7K0;4;B~Hnd&3P@bE7LcfK*vO@`6D!W+$>q z(#dyim2co=`)gtzI&^4aVeyN>>;64kR%S!edwcmmCdQ%tkNTjf!<+Z5TxoZ=>#ypc z{zmmVDjH$}RfK?OuqG23a5ywtZN&|q4(q=jfD^(4B&5Q)xVV5^-j_7-w4Z?}YjSE* zC$oUEAx7(aR9rwt-ILGEbg3_h+=j-GwmRGrjx)1>R1$bYa&iDKE1yAtdc$`0I+zcy z#<|Y#u%p+U!(NC>Hw?|DhP%bdfq3Bx+8ll9>77WKQtylDnXp9&exC0veOyjo!6jRFgWTDOMCMo;F~mB z3yLUOLZW}O@3XCK&-w+%)FwMcgSPbM^f+aOYK>9wzQE1{Ovz-vYcQ0|g$<$?eM2Jx znjZKcrlwRm<)T3Ep4C&deXX#(o6exOoo{5d;Ats`pqA16}L0c;RV6eSWX*JGGGoh>? z7slXujR?ZYDxx?4;qYGo;rR6MM^RO5&uh?G@4^1Hq-2^jVnlFwcT!2JSp?uCWWdlq&U!=()+1tq<^!$&Fh|#Dii!qbcvwFquY{T`jP9@0u%yYW7i z1GjIUf`MTa$;x1J;}57o(+(0@5-mZ53ZK4hjhcij(By}Uo~D;`8~aZ zt`{BZusyngv^~O8G}n1mAbSKUiJFIdJxfGerspc!gm|3x`CTd#a}$% z0$S4}Hbz`kz?F|zQ|#}yHlEjIjW81G!NF>3Dk$=Qw({i5Cgl6%tniYCLEKA0O)HSF z)~Z)VL?rCk#t&Esp!bmh9J9zta6Q(WVDR*@$whx?KGKvD&TfO^-uJvffEn%Yp9_+Z zFm^8CU$U_=GJj}aPwxF9DlGzT>nqeHE3X{d8%YQq1N+j(VlJ`4ru6S8vU((90|QV? z0@l_(r?ZL$D=H1LJrHj(Tdrk(uLerc3X>5F2p!A^T$(`?arCQ=i&7wI0f~|A-Fxh- zfBr(aX=qebU-|%6M!+ zO+b2nb@MYI$aa2B7zEN(F+>YLG0QZd;tw>pVq(x%t-gUSgmfMr7vTd+YWakM(RW)8 zR#s2+PhWuV)e+Vo^Aivpkake{h(nQ<_|sdU$G+Zwr~}CX!N7HDKJ9=5f#3fK(7wYA z|F&U-5S@=WAiAOORDS{~c3%q0mKwT-5K{PDvcF_Y3c7|=`QgjoP^^w)B3XTZs&ZP9 zmDO~^?Ile9An-hIwrLhOTcsi)G4aq##2e}f-kY=`(U4qa8nn-BBZk0kN>y|hoCyQ_nNP`w2e$1+t+O?Pfxq?W4_ z;B<9CcU^PxD}<5usVQM)lu?>8x68!_>YBw55J+J%v{V@Rt^JAK!E&2+^w%zjnSs%A zck$oP$af}kg1BleNkwl@gEGF}8(fq!fpTB_&^m-N*+WCL9;ch^0|NtcG6(a?9G8uD z<-ea{kbkHzlf5Fh>Vl)wY$%R)1LgbhV89EtQ-E&SGeH?1#G<<^G-5LvOmg(}{&+tb zy!iPg+YXebPOoM=j~}ug~BluVq#L$>Q8A|)zu;D*`UdZ0z+&kJS2AD$m=oh95l_l=|L2ek}BFi z%*5#*9-itD3fkP-Ep{>17agHxeBf627#bN{#Us+j{j-J zbNt_x*t$qp|6TKcFZ%yFPNOZA+}@Rl*6-BjG$F-d2SE`^j#$t!RA7FF0xwtyqoRE5 z^k1!{q@LrUw8!g^8m(Ur{zlI<`?mj1Yr`YK^S@e2Z40)`c4J~fTS+CU-aT1kyT1Mk zz0*j5cTPy{qXKzg-r!e&@?V=>_1JWVxj%X3OcEv-JmjENmdI<-nGDhpCKRTz#U0i zdEuzxeMtU20aTo|F?${k{lANk?XoY9E&r81hinS9|N8_4?|*uDIsbRBvo&xNG$h=A z<@L3SWj9e`ow>Gw}&N}NsDvhs9(~a%D)e@TJVx^Ku z#G$g4l@PDyVcxWpr#kSEq!9k@F zStUoK7gulpeP{@L=vzWUB%FwLiS3M(Jl#_}b4Gz`Ol_m_u6U!}#?wv;;j+BMJ=*4$ zR<*wLzp?5AV-`aZuD+MGV_m50_g47Oesxs}Uy?6(5?#dMP$T)7*^6Q+nM9mP^ZeDu z3*>{9Qd?Z!$5nW>hAmW&#qDsm^YgPz|^4 zOG<6e-|OHUrAQ}RTiF~qy2%O;>5K42!L=V;DThZ(_Vt~DxH~mY1i#mEJupu}RX^}& zB-k4^I+rGJsc_-x#31Xy{LkCJ3xxc?mmk!{c|@$N%24p;^I(Gn}hLbSXb&ADxp!(lEc@=i@e^f5HP{5Si`Q*P;gDUyc-bO>w|!d7kc zmX}*yuMgQJS=le~XUhtnSm)^1J`0rSZ}06SSc7)lffaPkipCG?Jwf}o9GF?w3ZsGx zt`0|9yJr%~&N*8T=V>R_QSapY3cULHjCnKSSz9A#N z-d(}3uo;zJ?Xt6jN)4&M&5!sp{ehSQ#7=}h*o;qKy?(O9e z!)7B}5^_0bYG~3N&QNALdN;SUNG=Ep`qJ#5K=0Z<_Qr;z2(0AB$9Zmvh2@)3eKDTq z-owbvy>;tFt;$oUhYx#R`FE6I4f6E#^p0K{Zhz;CV$5!D-_9@R$_mw(P8D|&T=hle z%vaban5uUMN@89Du{_u#e-SuRCo#LMqrHY9IW1-f86n~-W>>vCZX#= zGm2s;&-Ro*ecdUS!grl}s5sD-dGZuqo~ct_E26oKj!Vm<)OJ_3eliX;azAZ2^!&?Y zxzw7|N${_c6dq_Kg??&No}~+0&Te^kxsAI2$iYhrw-@EMq9~(NnC^1!Wd>GTZZoD@ z>%-1UzQ^4;p4C+^L%FLi@$Q?kDHB35o>F<^k#`4KusMno1;>9;+(gBtXEKoA?k3FH zSDPO#Bf0%s?u95S!rh297Rv%KHNKz|S)6_KledQm{3i#(?4Uyz@xw!xr*5;EvXZ8P z*217imzGMq%4+9s>tv2K&#Cj_l-BX4ZJURqDzEpX>&=>XvIMeGQTdlgUiU@c8y#4U z&f}uVtgWpVhMcfdqC(LF^fjmxsy>){sJ#dVr- zK9c_`d)jiA>OF&d(#>O2{@K#gC}p)}kc*nQzuPrEh5Vp&=bqix!$Ygx z$t*t-E+@H?x5Jn?XxXKuwB!Z(W`nf{U~bBRuSX5qW%TtztH62>f<97+4u>Y`D3xrU zQJSMGGP;joX9&5j(|qJHJW#F zO27u-fU>2g>WhsRNJuX~JVO!1!CNYy_0Gx06BPs7S$(QPQ(a%Z*YP0a6-PG7Fijh+ zX2rm+=MVMTNy|D3g@xnkfJ0=&vXY2{d9zN388bzRQX|jDfCw`3gcFdd2;TC65loA) zBa3#u_3~T&I(rdelo}i>p1XmA0Hnm8a&05Cx>rX=b)K=x;AUpCnXO}5b-Fpx(J?h1 zGg9OQ7^Q^cs;_pZO2X*hA}P6Ee52Kqe*b|(<%ik_cFR!hwu;}LQA>@xamE>yI94jV zm~)!tJa^SUSJ0keM;;tqCZKy)Tyf;d9(&?EcvEE)o1YQ+xft_x znkhE&Ze3e+Vq9jA*z9J%KSnM))tRbEC@JN?rPC~HxkK$6o{~J6uaP}^CLorUN%U~$ z4BwH=q*(>Q7+nSR9gMQlOUt@cce;0^~L+0efUa~F5>nR};9qmB&UbX2m z5jo&+zG|la=!+bXZqmmWXY{`>`YahCK3`|4@32yq#+^JLpTiHJ^w+!Gl+A+!OAGGQ z*xVh|Cwb`p_e{Ae@JwP+2F;2C)14o+2m1OxTp}aCn)2T(qf=ag&PMw(Fgan`K6$$2 zJHPtxZxOrTG|QW;+@x4N9xf)k2M z8A^_(izmdgyZ+h2CHPRgfMa)l;1EF2x-B`urrsGYE|tnHL@Ye$i zvvLjGoCEGqdX>hn-PH27W6g@n^KTpYp|7F5J8U%x0;6htf(r78#r2#EA<1Ehmg z=|y@8y*CS>ARtJQ-lT@!I|x#ww@?Bh(jgFfLQOa;KF|Ms?}vBXJI?)ZKAk-VA4pdA zUVE;!=9=?2=bFE5d4Wr|A6|_lexEdD6yg-qP)xk5N8xZamM-_D(+g*?Dz~*RRQ^PsKUpdBUAj;I%6AG#Lsg8&-W8ss0LEZSTdO?GJ@DN)Yj%nk6Pr| z%SOaC?yqOG%POLeF$FSYf6Gb(lcxQ5vRIl0$zvLRD~m*`a{HC~TQ4G3CAHV^Yb6N- zdT=ml&f6r|3eth>N-pf;+-9-KhdzbXVvy8ZkTecV!mj)_V?K4 zZCLwZ!Z<0Z+8(RYmy`Khw_?0EYOMoQR8&TL*rQ!HPmgXf3UNsw#;%hT8YDXWRlWvg ziiO_@?%`&W-dMh$iri~lX-)w({AI{D?A;SP#LW_F!<2$th9lM4fQgM-3g^C~bC(8=d19`7!=TD$D)o8)zFV}!XMQ<1T<1Mh;Kcq|IkQf0~)E1rW0Ji z=aEBA7KgC^`TB!vdSU+*>aqGe%RNxsNzkrbT!WfhUlph;aq(SQr^7=}YWQGu zNn~Wc-|4U4OxTNH<_3m#QBbl~I)!<`rML2KYPEDA8@aT28U;012Ggp%Q+GeoR@qKy zzMVK-yT_g%QgzT;{W`77(B|mK+W4$_Npf)e*@dOKIg=2E*n#@+<>z_varNk%Q7z&d z@JUa4G5?v}p%z)phLAm!`j)PhlmZ#em{8TX=xF(2Gs40tQuZ`9hk5UIIe^IOaY(dkiu@1xz6NON)y!Yx>w5cZXs8?=uQf7cb?j zpI}+GtSo^;FQ_E7c5_%$g1J7y#xt|2R@#v)cn>OM*Af!(FRgWP3jq)5y? zE~l5!31e+VHK*rbx*g*pdHB)emf`k7Us8(0le9QF*`Pys+L}TbX^9f-HaSifP#i8@ z7eWOIj09dN+3d#4|ICQc{IGnhKxzP%;i;2ks8>_QA^(KP< zpyW~xE`mWxuu4w=lCzTLtEdMkq{4n2g;B4+iuOty5l)HA0juC)J!I?o0cF(;nuRc) z+usJo?x7d{lMApv(0leIr;6Wt<`uA+L#en8a&W}tJusN&9G4!I=-Kchg&%G~#eDNG zRlwv0X5r*XcyByo4Q-5^Ksu@E+cz}4qm&V*5`mt%5L_x6@_dr7R?Xb5gMsw8rc30 zm%&PrPTjcoSGAe_7=`PAaV{R|E&Jw8LZ_h0APtR(YiSNbRL(=OK3nxKn^e>O^0cTM z%JoQ;fF2Mny=Z%AojQvi1Q^+%9G z01V~6?vYi8MbUQRhVzBcRW?$ee!`kO!9MVtew0q zfl4v(d9+rcp_vO&8LOZ&xfCY3dTT~=zS(wRd@U+B6*hn{Pbwzp6C&*5GHMg2XztOL ziW0Chol#a%fi_0Oq$?LC0`|n;vDyuPo_BiLBN%f1n`HB4$W-K_LuAP?IG?piiIW=2 zdSmYJ(yua!-s^lclAev$Cvj*e?>!_rJ11wBID)5!wwN)?LWzbJ%`Er(cz+!dla!=a zum^}DUu$1yw9sV|l)%|#)Ki7rBIk5Bc+YLvrxs2l05+D4#Tbery%ss!g#|b4hKLz* z$E;XD$>&X_Yh847SXx_jLPEZ`i3#v;K9`}B()`EqA42T>xw|6LhBC8zEA3afQzTtv zz?|9zhirg+{?bWt8|4qS94RBuo+wz36l`wk$e}_OiS@b$erTJj09gd~=2|F8$&Ez4 zAm*rJ!>-IP-_@z-tmEL$)DBp{4rj@7l(BV?LhjhiL`>B--SUafHAvd@UYxC=RORfh zl)&gjHy`ataOnwXCnQjZ3fGqsJWJC!b-o*PfTRGVjvfF< zBl6QT8c|1}OM`v`oab)BN^D9(epnrKI8NNwGv6WQv9#-EN!i3qF)#q1(NAN(CMDHH zpxd%rP(=Y!g`<|r`qe9!b=t2GrlfEdO8or?O#-1*rIAXDu$;Psm;Qz02tfq3D(5Th zi0^rvvWooZ08#YE_v_=@s_3t#gDJ7G89Xfk+7-*b1!2@hJmvtB1&G(1n~tpya2t1< zFk7X_nPpw6a&X;0)x*ZLYAP0k@AqSh6`#?F2eDCqD8DsfW%P)w|$ zJnb*s)+z`1q*wt4!tRfI(g9p1_~!C*PBPuUd{P|owgfQkUp^^S%Kz|5{cl2Y|1;zl z{og1mQJ&!cgiU9W5U^8#xDu|`3<95&%|Con1;pngWTQU1$cKB)pm@Y$k~ zSZi(nrN7uLkvTvPTdjHjiaAg6iZI8UpQMjuoQNnRk$m8e1Sk^#o+QDvUuFqUD4-^Q zp=$p?TruPTJAK)GZjv#JpkD(~;Q*d3ZT!Qu2%-*1Hp0)3|DvrTL?;!e5~lkESWy*J z_|@PAlv92$-LJ9DfKP~$uYmi!P zT->vIOLghyU*?*Du)r*m23Hd;jiLKD-HAHF9hm;P9r3x43lBL~zvc~}(|J)!@O*>; z9}BVbS|+-WKqwV>={bS>rL_*2*7wff=d^g13&XdG3TXc!lp4R`sKNX%p;R(u@2|hJ z;C~ll{U67Z@Us6OV6A3DAhQE(`x4!)zo~za64Y_WeGm0nqI>D$1U%_;(vOibFw=-)kgMP?m~n@;eRtq*>Kv(yyv@28!znE zdY_X1AAYJouaZS(zFrYlyz-Zwm?+}c)Qj-b+^{!6Y# zRKWHh+MfR#2l}&3Sbv(DU?gG@f0Zy1$!EQ)%JK$PmbzN6%gb8sYz61f6BZItAT!f; z_rm@;;=k-|MCqcA*z|Vm3la10!?R=~^*d7ujfhl5Gcqutje~DKT+h8GTo^?Am)4Dl zNcJt)hGXZ~_OBDtP5)lFr9#1;= zH%QTXm!fgmEhk7Indx5+DI($9me)iI{92#Y{vB*b8E_ws^}wuW6>>iYxK`BA7p}|vCB-93PT|w1 z)$R?<5+(n4v4|I}3%FEW6aL#Y&;`Va{$AHhz;YRQ+;!#ee|j1Rwu?6O|GDAQ{{(yt zt4M_lYz1`wHd3v$q5e;c|M!s4KacqTz0+*QBF?o=fy0kOL#udaH#gpD7CU&shtHp5 zCpn46Ya1G%!LsYc&_#OdPIXi2E#a5FO-wW5BOxCNqm!O| z=ck%syJB0w&TNSG(ZU@a zALeOCUU9QV0>f2E`;{YoU4zfVC7pt5^h_36QjL|HT`~x2spss0h!@nh3yT`B`v$Le z$d|#a_fAhM$bM6gaR!9A=*jHbk7V$2v)uBEBQJ#!=7X7Rz!6M?t~uj-fgDUUD0Y!N zSbYJI+zG3e-GP%tfi0d(>{ z+hUFOFeO6BirzXP84eP#DN^0i*N!p}wLMDw?wkfSugD2%Bh#DV$LeC%Vd~NRfZ(gr zcF+)q?K&mAet5$%hnwAmOmwCB$#sbnc#^0`YH403*~YZ2$EV5)0?j%b^z!HCcX?Yp$iw8LJYU(sZGf9h!6fu$cM!m{vT%)O8)@oPjcBR*} zyWitIz9M8}w*Fwgs7|AA5c{7yqW5&AP6cx{O8lNx*}`7CA|H-c$MQM_Vb>?1xt#`n z*$;rNc`rLVckO5b$vtWYw0XLXNvJ%fkn`%jhUDBoHHq13Y!AD~is^3{M{WwIP$?zZ z^z;kKM`P*5e{>mnX7^=XGHzHerpcye2$-~~HOY@_NyB@cxgxLuLcp}m{)J;%J08y3 zM+zC10T6n$S*cdi6PNwuOj3*OYLXzQ2n)=rh76Joz?|44-zWny+N`Vx*7$jIQTNlA z891Ahi6>Pb*2mfR{_WdES;~nP?HHOdQuX<$A5YTSCnmJBWUq0KmIZL>X0Fb9AwzSr zbEnPuKl2sPQsfEEo|Qac_ity-rW4cpEm44n*Ib>zjXLnzCkX{);C1Wu|-ow3} zCT8YE?Fx&82!nFxDptjWoT0DA=&2ecN+|Q;6sq>HXY;+flZy+iNgcoiP20<2i6;}! zN2`A^%Y`CK)p&QVdZVYk@oF^(~ z5YbOf73lFj1yen)QJwcXo%|TQ*6ky$;~jLH9@PGwHM7*2WQ}UA+E7nb+3{*vNw_<% zR8?B~B}H98S}H=b7ddDZ@ObZ=sjLuK9;lNy6_hRa&oUciUux&3F_eMuB8at(TGfCx z0#+0k#>-pAmA7i;k&dbz@8SZR@X`K58EC;AK6Y|||B;Q}u{H}u#~ zt25DqLFWUqJk;^Q6Y8YXwF40rhP{)t<$Mwerh2M6NYZ$b5?Y#5E7lV~Go#N{Jz4Us zsq|JPbimogMZof%h-YZ%h8Ak?+JMG!q&%V% zaaBz~8C@&?9^dSFL}DN@g7v0bC~h9Pqs1flEeBbGZg8LW^&JH`?^ID(z57Ob=~v^R z;ESAGxI*lPSUM(25U8LkQP;y)pI5HtEtS+E{j4j`PS&W8Pp26d7tlpu8Udt+N5FRM z6}5{C=BNfqtxGc!T@xQKS_8?=>vpgiuKbJ&30j-)2op_q)et+_fPq;;P$u2%B-K|) zuWD(${U}=X7jxx!P+P2=CO0}9Lt#H%rw67Mywn`p-y5u4@6_m!;`w2i!5u*+ub`ss z*-HgLg+P$?lvVom2EA}h9&wtt;hJSw8Xu!ZLv?1oY@X!-x6Mpx#{2b)?Cc`aA>X1Y zZl|RSIDsT1B`z#oJRsTD`Wd!}@HwzP%lLIG61dX(;DMnVaY8idkikvw1!Qs0?>ZeT zg>KTKe9bV6-GjZ+P=PYIk%s|}#03zedR&qPGB1Gd~f+|-k{;{a`ALz|wUcGJ)ez#z%7bjXXIY0`GO zN?YaIPm?0~O$+5ReS$ehB+L!`RgM+Y2>+CmkepMa*&bSRc0ttox@}_W?!V+WdG*%T^2M7Er2AqS89m(y3AB@UvGRktl0 zOnSAFY(?$h)rc#?@6TT#i2ijXB|fE$?z0JJKtO={i6Tu9t$GCgXyy_pe<_4f?se8> zvaxz;QhP*M#Ya$s>&xTF!|xIS@A~InNr#56=B(AIlpjlu=4dK_q=wfYjt?{~EaEKu zkE)u}Fh2RS7oapC=!X!->LSEV3atfb*|#KG`O>eeH_s ziQrtl^W+VBk;KA)fPJ}qBypp-#L@yfn|QPF1@mQce#Atr363e!7M_=pgA{xtQS^la z3L>z}N;FW9$jwB-BMTgU#TU1=S0XQwJysz@=H*$temtygz55HC72fAZY-v~IfB<#@ zwB%04_rr@4XUEf`{v*}K+R=$ZwwY@+%1?r#;`w*1^T1%+tOnvm{g$bkc76r`Cv;HU8k5=n8oJ!Sj}03{1@lf! zHriQ%Jik<xMZX^BP zs{{GY9&QcSBr2sCAv_W%s~-}5M?XHF3l;zh&wAMhQs9@g{aRw6`~HXhaneDM*YpS^ zmX6*&KxQR%8rtsFtpg#U10*a3W=etPddVZXlh?oH77xf2ZQ(!kNI=ejYuP8&V#}A> z{k~gDQ0lF5eG$7)h5?)Gm&SH^yjISGYkw8tff~?*uv0kbgl>#Wt4O-0DFsmfXk7E> zL;AP>Yf+~9?B7TDO}|aTZyFX)5EK0oCO9Ur?QMc(K?rQ?81AB?+A(OJ3OjtGR*#>ohCQ|u`uMqaO5|?G`uGA)Z6)Cos#Aeh?b4qc`vi!pN za)mCe_ja+^{=6+@n&0)P;QXLcwCQ-!#Bhv8U423K+IgYyrg8p`7t#JFX3cShI{82c z*45W>fTRncWUW^NPf3Rxa!y|)ym@oHQ!~|p{XNko%Oi30eXR~MQ?4Y1m(&yU*czR3 zSpnN}HZZ1F>!C9F;Hl9M7#Sf=hrhDwj|P0?G2!^dB-T4Mk#tJqarR=a*i#W8dG+d8 z@?N0Qz5fh|DGqY&yozo@XCOZDq0tBLpv+KP;%E^F_MPYDlp;!J*{fpl$Yx@bK%PvPplx3D{wGH)a%e2Kp!37EZ9`d49X3MHzTiuX>V#flbNaCj~t`NGMoczPd0bR1h+(tSkqckiutjO?x~< zZ@lsH@dB|P3mP%+7~qMrvhzIk+u4!jKT0LG!wdS&W6T~b6-DojZiv7u?Hfi$M>H#P zN(hMywm=Jf{ZrqGS7$>MN}P~eX)5ZMtzf`V$EW%0_a`k9v+d9d+Ke|!Rc-ybZ_qcO zW{ifTM)_WYjOn(+1cVS;kY3ywfb-*1V`s}sv`K9V_F^8^XFp%`boafMv6yRF?@tmK z?p2X+5Kn!8tMkmq>(;&`CTG-=vB){aosN~+*Rn>?LV-Od=QVfQ?t#n)KGD>&+Kn2; z+S%GB|51y6y;7Z!>x>|ivKkmU)~aWcoew$XLO0-VE*$TV8->MF`OFFy>7gjYR!U8k z3YWQb^4jHO-R?81KYMo$HJuew+;B|7e2>F<@Ls3SV$LSDCWGs9}3NQFP5BD++v`@3320vv%4vQz(s(Isf4cFggAT1fkRnx5w?}O~BcLtU za}wO>=gDb5Sza}{;4Pgf>wQ}0=oEw*rV{Y8MWs&f%4((vyUCM_@7QHTiGmPprP~U3 z2aDYhU^g??A45DbX(XtE7nh8HWq9Z3hnNEf@2v$D>VkJ=1Xs!sNL9Z+b~+I7kj{*0%C~*;kKA#C7>fcyAfxl$xV{Bvm=I=a?FeHsmS_2GN4$ zS0s6K?4#aV4Q4P9QC>Eu_Ij)(gHT(ok3(8vfE`@FU`-kj+pM;$w1D(g;!wmE_AJS& z-ZKD?sS$wuWEzzuMAg7Im8SNEvwlZj3tJG^t%p_CQ_uB0s0<=I6Sa{ikrsm|z5CMb zR7+fUTX6LfiGm_$TdN>vH;hbJT*7wso!Hi6iIBey`*H0QkLVN?HQGA+6I<*O4E>Is zfR!iTbJEa|^L?oNi6JA=Q&<;8EJsf}^Hv)Nt4UL|`Y!2EIGt#B&I0kl;8_lf7~8bY z($Dex+2+Hg{u1F0q5+Yu_lh?i>nt64+=z;b$~RiZ zXNch$cI_0()QlJz4Fs41vSV7PxCKa9ykmCX7x7=luG`xL>|#7vsB#rW+!dwzjuGEQ zNAeByU`4mzR+VODL;O?C)mz||b|&?$$p8l_Dl>Vt)0WN2&o2r;ya59{kS@P{0pbKp z37-J-NeVAG{hiNuelefvLy7{yWdn zsG#$VlBnD97sNn~`Sf+Plxz24u-jp)mEV4cc27^af5sGm9!B#rq^P*HbiJ3T?3KVa z$+^`CsnqW1jQ3OoD0D_&*3*{VtDxWX0DQ;jcNA{uk4o#TUTtm)qdW4%85F@X1>zJaSw8-`{{N|MwvKF%i#$o>4#CipUPv zt4V^BDb!uVY_w3t4CUH!>D9?7(Jn=fTqDbeO869=Daw!!p3y~v%>xp^egwh74==LV z&DbfLZP3EnQTyFJo_+unc?V*XyN-k8OjKNYCKu^m8#>eTKJ>!TFgfC$A3R$u3Xi!5DGEi2BLhuBqGM#gI5#T{JQuxPy3c$UU zg`Sh0ZO%?}{u=GwSKCc~?aJ4%u&{oY_whygW_t@1kKmriiizXDD5cnN(E?>VGOag3 zCBLCR#`MZhsL_RARyOkteXv*kaP2(Di%DB_-W^{;zxwgYx}530z4op|kL?xxbTyar z56zP9E9yQ`(rU{+r7nZBuX$cr*FDHtqJS1`j7(bTyY0yWCZ( z`$`kn(U_EQW?4I@;ducN!cbF=u%|wHBiN|?+K}thd&*_Ai6S1Sj~y(s5v)mYO#5>M z44gVwe6Xp2M#!sxH#$2fvc?8hkPY&O*FrNsP~5q3&qK%qhdeB^fo0aEdhVDf1zaC6 zokCURh~WJ)y>-ZhXABTuM6bV|m0k$u(&K(Xj@~jY?%01q-IXX(@6@XWfX};CHwX@v zDRO4(Mn3mPQU)YJk19WoGe(+IOrf@R8? z!HVhA>)p=V>>Lf?U1CI2yu~fiV9@Jy zQLgErB@)w`QlrpAOS!Xn;b3(Sb} zA2(#6jsbtY`<{}svy?QBwRLq+V7a;3A+?GU1vD3e$uz{$?$@ji}x2+~~rEU7yq;Iq$m{&zNy1(}(RZMcU=|#F{@7@9IhsQle zj$kG41_;qmTgUd7d}ngZURpBu6W(RfoU2=TOKs#>#m~JQv-!eZ4WvhBYa@@qiTn3o zGA0&E=!?=U2aER2m7s}I)LgvyvoYZ5tz1Ww!1~tA)B?xG^GV~wKoV(jo#{tBw7$O~KP>+F34>Z= z2F!xB`EN@-nIBUrv-Ld_Dw{}}VX^Yob|TeJUOf3n#mXSw4E)`0rH373)G5$26c_7cU;#8Y3mO zwkqNks9(qXV$tNs<^zpSQr7KmBHJ{xS5l*_@d8$?yJE-A#_Rq(hTv6*`yb*!<1$G>s4n(nM*8fv^m3RQH{_3-eJ*lBIbaWp$Z zRUKpF-@xn5a$)(NE>w>mVR~3KVNqGXkGDs8_Sb}v`F@_YqE!&6LyBnPa|z(-j!T0> zD68#*%|k0lyv}_osotmUaNB_4%vJEAIAqj@Zan#sN!@OFt=B0gC+q-#{cEETe^t9S zZ)ny71Ofr=yVSUJQtET*;?syzP-u!n8CFXYq(rdOjDI_4`M)7#DOH*02z9sx)rJ59p&+HG=>o{n;%X#5V2wtR%Gv4i71iT)=KviwkEcM(V1 zW^j*f)w|yIMr&)!ET_u3DQfaII|?FkGMYY{xyosRRTpfDCw=YEE!qaPi>`@&D`UCY zX#%)_YsI(UPV8w@17qv)`G~~kX4H882VRyE$RIYAk=zbw2ATgWV z!r#W_=MsvEnOPkm6CA*F^E-vr>|!vQ4dYw_{Pit6F0ntRA*p${*_5Xm%QdaXcKIem z%BZw*vXcT@Mn?wVn5Hsu5d9pLa=2qzd0Wt7oOuur{k7BtCFyMulXN#M`!#F?9Iy_j zLTB`2t|RaK$-D;wknxuQ)4O}npLi0&ZCGi<^*&l>ygJV)XgnTSZXe>*@FK~vt^qD- z{uAI;=(fGE7zae%G&swYB&7WG8+tlvcPk!}3>X7Dn`_^i$pjXdz3xwM3xk^uM20uACS2(@0Q8i8v zzP<`MC9TeDHZ;7Nv=^Vd2YsVHCs_QENe-BMylK!+FugWBH{SdA0K&&!G(cKKCF;kR zSyz=01Yf$I8iiOi>#C>IMAsR0cR%h&Z;j`F>(9`Nzkc*$?VI}RB13T_a25vVBWr#7 zeV9hdqB58~w7L!=;OD2*` zl)})sXSh100p!+BnPLYPx-mk&etxhVH-&{Xt}O&mk^8uk7Fsj_rUXh|?rqM^6$5}< zMhryTDZU$&n?)Wq70AAfe}1At)mz%(togmbAyV3at~Zgt!+N+fe~s^5NGuFVhxep@+i{7;n;Vq<+hd* zd)*g0rHss{KPm+(c86xEAZ3m%o{gS#WUSH!Ew}!ZY6}4j-jH>I_>HQUe{8&vGu2cVr zL;i}dR4f3#jYGpx&aD$;o;|8ZPpY<(UBk*29i3(n2+U;DC}tyzyt^a^L!PU-Q>Vwx z=?yAW#ON4%^gZ0u_y(XQ>ayHQ)1tb~4_u=fz?62yDSoAf7H2?V-LI>4pYe5w2?&u9ONfdTWct#q(8;2glqG! zb?#&*Vy!rPb?>FXYdGBljEsy3NSXVVtk)^^~%|iV@41D66Vt?IKR|5Xl1$bcKusP zGnez3LBj#+xeUJi<3e$rJivC#>meY&f}!bv8}WEK@%e$57aW3m_3X23^gb+7iWVJ6 z6wrj7QMZ{bl#A-Rc=_V$lq};=I^ItpT?NvFDX}byXv_;8^qU?q(AB%Q_-T_qo3^B( zy8Ejc01Uzz`QTPtGZorN)l>#QJdO&gM4kDktW+BPzZ5svXhij{dVAK|?*5`3tFio1 zh92xts5&5OF9?fHN`iWkQqiIbn{&>=qc>`(IMrbG?p(9UKYpiMe2}^>OzJt@tv^TqFCX&Db)567Z7s-zT0ZnJ}3y zjtf-;HUB)1rr0v_hw%@^wb^9*QUqS;47TV87H){wA@?xUanJRjXk%zx4y&Ob{*7mC z-IeKEgE>r+BH8`8bAMjyTm=7-tUvRYJVe^^w5hh>7JmU^2hlQ%Im?_oZuaBsX@%Ue_pLHaz+V7YxZ zS$gxu1>DCU-wu@0&U9-I;#HpaiGGj%*pfQABZg&Y{aos(&mI%=L_kQOV^XbMH#YjjAG7sJ0V4MEAyG_R zyjgEc!_Ic{Nsv7wm(T4kPmd?+8N?>UV86Ft zHJ)yKE2_4%FlWY`9tlRM7W##}*x8;r2}<8AZ6!LzqT?rhRhk$_KoW6_&6iw@~@oW1Jk+8_M^F%-rR4)LD69$QRC6X4g z8!G<^oRzS9`-#Yiu`fwLomI8o0w=>SSbg@U0rsOTYi@4iV^IV4-jeTS4vv!NpgJ9Q z#E0$HzP0C44#y__jP%t;M##X(7TqA2Q2^_g*R*1$!s3XTQu7IhUo5OyjhW|BD4WN; zPulWK?gLic#_gupJ~&ZO9{#?^*%Bpf{HK_`we8xdiH^Rpi!J^qmq3BPk1;N5wf;EK zYuEN~{#oGS8&OT%vDYA5mji2LE{rjtGm=w^(toAo%#!{2`3t3I-D-;l98b}hbx=6! zX=xhIa(^oO-qXciMy;Bp(qrYrqZ>EXwu}Q`$aSzhVCEFk&hhT3D&R;aiQM>;H>>T1 zp)vGXx!}9=Q>?VA`Z7_ih}chbP((;DgM=FmXUj>xHs|$1L?xwZ*_}u!aid8=ThtHb zkWXjZ(`l2HfDRUYR_1?O9HBqQ9GmCdxBb=qtWI7y>3v(9sUn@2{3IZJ8nZPgf!f-X zZM0JomgLpZSHCz+fvhj={YcHLUg{DEFkP9gPWy2Vw>QJb^L7pwYcz)wGP(^Ixp+ZU z_iJ0*wGjU!GN;eKQ>p`=Fp)3@)RCNl$c2fXV<6-E3#X#u#g&b85Wj8qT^?%JV+M}~ zPHAc3n^f=A+Jk%SkS?o^k&*kTtMMfi^B-c37P8DCSEe8JDVjICIAFShm2|`dic1|v zd%e#4pDc$>i*CODNhw}>_*rYZmy@GxV7+&Yj(6oK z&Y|Axg{_%=5Otb0{b4;U6w>_dZpv^fAd6|-M=l-yK?@n1u;%}GYijq=lELps`$Dm% zrpc5n=U4CI6RyMyP z5aj`<9%!ite2ltkKrqH@wh%K}VxeC`FvFIJ!IrbDy;696i^LZYD;t|ONC2djcFX@$ z;mknqcMA*ig;?{gxvhS^B^pX~1N9Fzl7|2C>?p zx*q=IXV>A*L5r$^!RQ*E+QoUNr~IJQUA;@{1KxWxBr@e!7=%Z5HoG@v(>6WMul%0&Mhr*$z@#dZS=0}Q8ViP-P;~bZTPeJLdYV#1MFeh{p}+Y zNYwsGcIGaJ7Kl4GpYBjUcy-BjY)oU0-%6EO790L)RY!bh-qxAr(m;djT^{@a{V8T} zS^1zz-Cy$M%draK_b)!vOVCHGr1~n};qckEI%7}J0fpcI$MHF!Y@dBi_px;k?i%|)J2%3k z|MFLY4$v=dBCD^}yeI){nuI%AEHLW+i@CKq5?W4$PxqcX;8|XFbSxaMIlf(yUrv_o zV*L|>#8X7@w7^eb5I|N^=Dt9+Awdy(Pm1y4p?5ndS|68V0hn~ZX;2BrM@#}5-p*gu zB3#*AILu6z>3pf@8tE16foxzT^^AV9u?dIQh;RNj$rJ9k_XY3YG*RQN)q-J&On^uq z`Jr3x%+Fm8fbe*F)wcJHoDV)ee)x!8#?b#9*qrY@VJ${Hs2XeT)|q-bdDKVhe5sIv z{Zx^k!3%ro8tH!5_Mdx`aGjrReH1~`EW&9~@6@9#9Ys-Jwx^EgyV5?|-Xe0XbUQXU z4;zTl)|mfRqGhrdfw*#YHzc=#mTtoiFeC*+wM#NeYwxhHjNVN1q>AD#%PU?JZ-vKg zuikked2lQE60vb!8qeja>7RgD$b4z3d!S!dXY6kBwaDkzUS9Fj+>baq!E$cTL5tm7 zT_=-*@aQBa{REVMU{6(3)%kSzqvBJ9!c$Xun>0n@rtjavsArEEPn((^b3C}CeEoy5 znRyU&qIk~K{d(KLuRn)tk29R%EhpERgtnp`8ylD4K6=MNq@HUM-3){gy5eF^57QP9Q1 zg)s7X()F{GMLs$a|AG(9ukPmMU2Bb~vNe8d^$C~JwT-tl;;2`6`O<^l#pQR*> z@KM}@SOx*RhyM6oKCn-)*IK@O>B?pt_~8?{j(>rn)M=h~>GRr`JH;McCW`);1{kVP zi?6`V26$Vafeyd)D`;+h#wt>uUlw)V=LP5!$z@@}uk!{)$jYhXNW&B;#>ttO>RlfH ze)v!K)XmIN>fjjMb1-d?HVH9Ju5o&s`@723)VbuQ}cz^z-J;s1dV& z1Oa{-!|+evK;s|#cmCY_r!iA@7tvptgVyX4|GYP7{A%rA+5DfrwOS<2#xl?roIzMX zk#iA0Ye<5=0Q!dE`rEg>E?_KO-)cO5a@rX3u#ypc?}e)xKe^uBxH&1$;sQ zX9My_8C-MRcj4gRAb0}C7N+NkbgGVpl0(0sqN*{jZoHHIl#(Ba+h@ioa&kIvK=aan zR9*x7*jH~!`m)cTYrTsKdhKcN-f0`Oy`rB?XKZVKiTT0>qSdHspXl;^^#rarwM2kN z;qtw@wIh4PF@trDP>B&JEAC0oa828i4HO!0A8I8^q`9C5V5p5o-{isWmv*L1psup_ zqP}u10PjAJ)h~7(b3LpjF(G(=1zRH`cEzZ15qIGvF>vSUI)dQ zNj51FzcnsjYi;v#NiWU`UUzdl^lO&^Mi6Jc9#J45lq#Gqr<`SA7#bUE%{+41neE{j z9vVttEe7*ejmJ28*xx}%HL$NfK12+Zvvz2ZkT5W4#GY1odJf>82!#;%e;a)7YKOi} zdu3~5^Cv-;dTvdY`uS3)?>6>(!gu2%+LCG#nz8Y5CN>_%OF`10-gAugNqz@und|SY z8(}Bh<2jFTjkgcIy{@)zoO=rRUHuJfj9}?PB}jt(=J4Ch)Ai?vCqKhM>0Js6s{7zB z6YHqA5eXt|42=iJ(6;V0e0p*7QfE1V0Jt$K%TQ2U_=;rehjT>0Ea+U~)TWqWek0w% zGFfj4qEj8iHy32XRUIP zbv3<+6N#)!N@q~vBNO2=okzhH3-=W1r5K+1HxmU8w+Dk=@uD6cfv}jiu_^Gxf9`tw zo@e@CvtzB>YSEzn!2@@7HMMsiQs`MD7=nh|SyZsH7l|{#QdqBXSl<HT(6sOrOFTAiu(j)jsbua_d9JNMDbB8G?A$^HR zRns6mi%o^IR+doG>l)A=*9-uYubBChh!E+0Xp}Zk6=QrFmp=*2YPzy{M`wpOmHMI+ zozKjqm>ynvhL5JL%$pR}LDaGf@dC!}!iQ%qe*Bx37O>uyu2R z0YaIP@+U9Z9;6r4>w2PAKwb957=DWQIQO;WdigiUUTnapYI`Ac>$+k0fJ_Gr3g}Bd zfVG}!x}d5yRW)b{F>D#9q9RP2w&MXD@%B30?IAb2Jw~L#8%;AZ!sjT6rjxly{0tB` z0|i^FWe~KNKwa;u+>}w=%Qxk#~tO<4^)I-=Iep(`H@5cOlJMOBnJ%619SWfr*0 zfIHRIQxO!`7u-6fPI$vbjjxSjo}LjJnvm6rh=`;OixF{ut9RneLCe)g(s{ z&>a-=3C;X!{^%1G_iUqYvJj&dAr~#8Xrj)DrV~*vwuHE-clJJ5l6CdY$R!Ha`t_M> zfM&u~zYCcgN<}$3((-$u%OvpA*?FJ<5Y~rCPbqUi?5=J3``b$4jQ(4T|3%$fM@8Aa zVWWd6hzN*CNQ#I^cS)%rAuTNpQqtX_bW4MjbTs`(}JhSI{_P+OhU)Oc-%V=5gdo;_6$9Hj-rKqS?3(K8=#nvO=9Wzp1O6|Oc zx;mE~9D$%L?($_>B7x`I)n>v<(p9}4@ZzCWrp@!F;suwfxhUp4U!WN?M*N{0V~+{JL>lT;9Y`H#G$&YYSiG zDgWym?~@rFjSYIm%36X}!SOe7B^oWkmN3)^aUaS(i-Y5{Ulkdsy&<9U3JSrPyi)-Z z;-3_y7=Dyg^M?`ycRW5g`;}TvxMOavqi5`M)cxc;z(=@PF@_Eg8)F~KNlPoO{Rx&3 zZ}mcX&0n|h5fZs=4R3O{fq8;^+9~SSdQ{shrKkeh&pqti9<24%sSNw2;R0W#--)Tr z6J=g2jwgNa{WkD)AqSV|LZBLP^M@0?BwF&1NqxM{d~3Rcf z4HHGvrO?u5LW%}V$UBfUA6qtJK}yRiMM_~5`~tH&CWd}~ouVsi+MnFMvdM^vrX5Xl z)(%Lkh@m%Wr5up4JtQ#92Jpy#k~8hvU-3R7?Qh?v<=55CE$7G(g@bmAN3Rl?PEKWE zb{8J!(#gMo+~EN@g#a8+4!dP?)aKJO{u>UVumpx#;0X68Xf7`Air$HR)TNB<&wl-u zwX6pr0dfW+;sqQW6{GmXKj}wXoWSju^1UF6ytQ@pY3++Z5i1AMhX_h7D@O;Pyg&@# zQ)87?3QC6oDnZs-c!59I7Mj62f&7k24adB76A_W|5M-+;Uo^Hv2WB>)QlX@$Q5l*8 zIYv5pJ1{N6e|_q6J(th}jcIq}EFIFXxGz``r!)|F+jnhd2T2xfIr?aB2XjLNBx z{wR2Q5=pBr`XU|3jpo3lrLV$^Jz9zJa)dI;*t0swFC%!6&0Jx%Uoa38fs|aInjW zOUuHmVK;OWoTExwd*rQ`VjZ1nE1l6P@fRW&*G?^iA1J`f0W#tnl|@Z89wb}p(|C@N zA&TE5Bocb!EEg772xbCK)sMldW4)3?b z7zp(HLpI~>m(Sg!kSEwP^x{4z^1_^&y11Jteqewi&!}lJ% z?aB5xkPr6uSYcu!I^ChXD?ghAd7ilxj+}wa;wIUyY$y5FKM3$QgwI-GstFFu0mh=xwbwss+ZU*}8e9#<79Ky<~n2OpBFd$QzUx z+m{KsB?bPWG1OAhAt>T^Wgg2y1B=TgO(yI#J{;hA-<)R=0t4+wAHL%Ltt=$Hx~gNN z_DLoqJ(!JzgD>XL06kFy_7d?D^p%-{f`v-2A-GttwB)JfZ3! z5c?mHwmm>SSnup3dn55oAgGg|ufwTW}gE~qppaf^^ z+?*}PlH~AvK{ZPDuN%tM9%ztP%dMXQ(%m*V_(A-+DKHG-)^;LC+#D73at>4E=H~vp zk%DxMX024(=H})7Tnzn-^D6IYig8! zyOB(}_)4pIyPjRkd(La!UUGmiul)ue=WF=K9{Dq0L?9v#4&Xv4jVz zm@6ND)ducOt*(BYUu88lA2-g-*-qO^nf`BES_SoGv}#0~ znFSf%z<5o6S^y6sE~@zG!U^o_Ge7Z5TFBW_e}Xt#YJX0ifG7ii8hMg{?al+_ z?jUxWO+tk)+`x>PR8`YEJ25d2Yd7uzifGy#VF@S8#eYDH*GrW@DQd84>{?G@RV> z&y;LS2?~gI-qjGzes%!D@Xsi3_nci5lPdmOCOX>9;R6rntQ4W}U03zzOkt0)TXTBi zbhmsCfi?Kf!oM(1Zf^bMGcuJSb`|WN_50oAX&dTnn19DVzEY1&MS5NCJ=L~JVWy9Kq_2C=q98v zNh3jb*|(!Z_4XXWW_#F}_1x^t=M^lLGYQT>Ed_L3`I)`_o&+HWC-`<$kR&*pP$i7p#y z>&S^~dYH&}slr~4Vo(E=S@bBkOFn9Dn6A}o*DL$YjtT*sNtpKCRlO1gvo>ve{+Q`A zJmx%T8YmJ2Gel1(o$ca4o*E*7=X)x2HxmHC+!9DNivh5t4;0 zEMWJ7x&lnJFF__B)w^C%TlAS8~x;2zoVf)WJC;jWx>QdVG$`|Qx@(VX+*ZHDt^YV*ncbV;STAk~?tG&gTk(40Udk|G*P!ZduJ-Wj|qkGHS9YiVlJ9O^@u2OkG`}0pjOH_7fhyr&|Y& z^?J<^+|E!^%0jXB%3{0c00j@spk5brSh3o{!P;e6{GqK08^XZ2FdI8}`Z4qg%@8~~4g*&7i4rL;?MtXQrJ`p$|+=C}L&Xpr`iEV=6ak5ew z7E|B>YhNxr+Tt`3dF@n&X^b7pYRi?QATb0p?%*h^n~ z_3G0v2l)kxN*$A)nFg7vic;6S^7#d(R##Fl_7ds3H1*zcK9ZUSyL7_lcTe-_*}c&k zPfJ`y7lU+XoipUQ50~K@4E-9K~CXq2)sofZs~gR>!enT?%nSK zGO*?Ga~L_~T+bEL`+lA*H+A&C=HTL7{G?1UtNEr&o>59V!5`q98`!NN#j|Cv0kP6U zyHR=UN9x9f2xT*?;RTD?d{sePKF+W+z03QFUzNaa6Up}m8Y!^2PAewZ9-g25f>o~= zz6CIE8jb90y0^t**?uSf9@tn1c(FJXLQ|We-^}zPpDi5J9ELlHy?4E*=T3Qzi1uJD zXuHkwA42?jTE(Y9N~&u*7fHW@q&D4wF`S&D#sfV4E)Ft6bg@i>@0Bx#;F&?5Mg0!o z7P@#m@>Z%j8e`dK=y}+ggH>g9n_KTEYZHvHl&5W*x$MYU72aUEmnF#)mx7^UB(||- z+}6>8+ht*0)D$>#c=op;#;#Qc^%g25N#4(>z}w&6;rN~` zr=o!$vaq%BM#`W8RJ*U=(`S@$5Fx{<5E{=FZVU=4i^qz%Zevjn0s`uq@X-^y8a_T@ z5D^7aQf}!a5TMR=J8Y8$b@?Z8omZl0=q>G&;!d|~eM`Ttjr6Zm+mmzhG%?=OI3RjKy4oc1eu8%eRTHS(nY6qUk^1s&AXBpI|(B78{<7 zZOT440wys4Mx1ok>=xt0K!Q{RaxQ@0wamAPJ=@s!j%f3HlA#HaPzvHb?{7|WHCp(v z!MZb9T_TqMH7O|Q#TQ{$oR_n7&5KJbkENx%qz%4HG0@S`*WacbMdk@+ravfCeRO(S z4pVoWi2abv4;ugzjsLcr#A{>iMJV}}8S~KCxL;Zt3J2Pc4@!lvqkAKobS+CB4D=!B zA#ZZBt7>IcB*Ghlq{SmmAAVk~G7XRwq?gLFuB?XbLi{_MV3O1;2kGSC$XG`}+D9Tz#U>5j-7*et!q^f!RtXE&Oz*O( zOJ^I3xa35dhddU__H|?cJ;ChZBvZVN0Jep{oF!B0cF%Wchhw5S(6AMCWRZAX4r`aO z5S!H1rlYD;0ZrzxMQqrkHxl<2Xrj}vlUL*=3)WM{dkTQz+DkRH$y!IDd6#>AhHEEZ zDSR|XBcq1$6O+Zw%Jf+aAs_`sSaNcXSyZPPduR!>9CY@50{qdxt^rF+ahZf0=Z zAL5$^zsmZO^b+k4k@^bR=Iwb(~qYju|h0-#LsNZNk^qLyU)(NODn1wSZ@u8=BROI(fWZqv^v2gmTGAOIE z!NAPU1Xi{un1s*BQoYvW_|fq3@p-0abeD4FCu{G$K3&-t6rU&*((y6e0tiXd19taY zqpUvVTcw<;gM(Ewx@F`1;$1&BcI(kFoT4M}Ab{(9TXupO0-#x4kT(VJ;DxHh38E_C z=>rygAq{;GPNVHPQv_{GPhVW7m3XtY%SyC9OwB7rx?`^UT?d>_=qzdQ=g#;8MOft~ zR+BLQVRwZr=5`=$-eUpl=YTJe(~=Jam{J=5dHYaC+4sd?g8TZ+1ZNRFr`wDpu>i+@ zF&R~?5Vf^$Po1a*_%9JMeXoqvgVUoQRnk{zr6bG!PEgln8cpC}sDy9$w)5Zqo33OI zfDip{hCHn~q&W56Nm5?@Vkri)k*DPq9gR^mD8Cg)C+!!0TP+W2+l>#Xrz!(UhJHQ1 z)Se=+rDKyl_*o1#1i)DMCN+#_2m(9d#N;@C7?*ixfn+KQcW=Zc+e~!VCRdA)9JmpGEAR`k|u( z4*IFLnYlG3pICg#SLf4j{bAoclNN66;u(`u$S?V5p2dif`3W~rd za3-a3jtMi#v-ZPdEAjRI4eZTsuJKp`8wyAE>cJv&yO@)I=mwIbw8@UkGVwBigcM;U z0!F%KMMVpgNTQP~x5vV1A<^K>N>e?OdOq0P@9hf8 zXrKKHFb_aR>vaAe16lU#P9bJ6w|@LcK2wrZz?=UnVZ;8OmSzrjbBF@mR`Xcwy%H%) zHx<;4DzEp*RKU+hHo8gJ=V9d=!cYnEXg+YbpnREi>(RmA4@4W=Rkf*hT90X5T@!}= z$Ux+a=dcJVC3iVeEJzgEEYF=!@z&L+_AKuJJ6Nj?$ld8mPPI0BzzswK!5QhkrxMGZ z-A{j7gr^i2Yh0f?rdU|8a7FXS6c-hJ*2}*!po#jb;JXdV3Qq#XtAX~RH%mD|XA_jilnbvaB{H1C_6-lLUf$zTUqPtl9wmUwUEy_+oJ%7SX-^F7mxT zM*n?X*KZZev*V4;SQ0CMr2?%d9x7TT!bsEHyy2ub$_e0DE&m2f>2rqmLmp!j#D@F& z_6S^|K#(;6e>!#TMgR#Fp~YC@gr$TD?I+ok=i`KZiI{{r{m1?wV_f=~FyeCxcDnDY zYYtXEZpgo4V|xzKF)|PJU!P^JOighv%MixjGMnaItQVJ6Ap|l_P)-5Xsv=vOU|d#A zw5rbHh^acHatLGw3)mirqlYh7lo&Y8+s3D#6o-YG?{VvRJqEyK%-q`_*Rnr=_$6U~ z)1SPCV#z`D8c^2^9#$4Pw}r*UAo=t6Jq5(~9*dP8a5}6#?|(6VPRc)9bc1JrkIzPJ ziG{^?&$Ns4^z<|>{k6nXO?CB|Ec7Qxdh4FGIZX%PtSp03A$2ucD*o*iw8^BD$jkvQ z>yNR$EQ)P;p1R`+-G6373Q}jiDSGBb@ZHsK6&MJD7uQyN?)j@8R^(L8W){vg;7>U_ z7flxx6u@yz@1A8p1?>6)khKN^ZmYGoJyXf5^4nq!H#4DT3PuNY^1PEGJ=kZfx8WyR^?NqfCz12TsJsJu>+ zqL<_pK&SQF2V?(=WF{#nWPX+)V`q5xOcc!=-OXkD>X8@LjKkwVu__ME!#&dyy_VYw zT`0kmM-iNNx{e_Z+tV{9WUQotft z5i1!2WDs~ihSM&?QGUB+JM7FL@*98s`jj-#`(iwfzX4()Bg2-e0LXh}V4ZhctR?3Q z2ik-eanOgnDV|5H53jBsQ&AwLUVBTw;!>Tzy#?F8%awU}1Ck5A^mq(1pMH94slw>Zr>7)o`D4TzSXz-3ayf4a2<4A%Lh{xUr`iMgz(F6REUFt-|}Y1-VjON=8|jte+V4iz;n6 zI|hd8L9-de3vjcf=u_3aCpGzX#Hk*~WLjERFOqh4fr`QqR1*p8a?0{Qf%?+e)U;)E zGFRzyh`ZRQvg^S#hifar;|GG5F8EqbKQQbnt8@W#B1Xs`AV&G}iKePX2tXf!4hTxj zIW##Lpad&6VQuY16CL$rm=ouc9o7OWl3qeUoF<1T-Bq7xfYPu#5qwhO;gR*l#Ye8+m=7LYVMhEYy#g%FUpz z%Hu^2Jh?QTO-i!5v2&`P*)f+1J8QKu)4RWy0{fc$gw;d;2QOa|KN%x|02SRDbh_5A z76LYt);hbyYC2l_4$!pkz3Y13{ld_vCx8J>R}n9sG#=r8CG)*E|t%zzOHWdDMw{lnfzOLXN^47 zbn^TN2++FLtb!Le^f!&p1-<*SKsSNJBODzp@MmkjD*BXoUOgg#n(q%Cbpn%h9^830 zdm0)c3c_+*7w|kQ!Na~OUBgjSgsxCoJ35Gp>v&07QGqw?dz^jtH?RcvZE2!v+W@+` zxz*Y+B>#4>)b<{wQ0v+;Jh?5Q#>uVzs#TeCKT01~`dNwi#LLU-G=K@q%Ik`xiYy=7tL`t-L1=YgBs z_g^H7M(Kx(gouAqW7}cK6m%dBR!ulM>cT8xCkt2yKyQku%JS2DHX#LIU$IGB(ms6r zDhreB?*45fcnh6*k4sMZG>(E@j|jRmKWD+@lfU)jrox=&lWdaTgbvQrLtr7CN82kc zWd-Uom7pLiRzAW!z)Lb+7XChvBd5;Fx-g$}F+1wc5%`v+p#Vayn~u&3 z3>)y25lN9jy*oSD?E6NmD|*MUdL%%yva){uuXGDQiPF;2eaLbeEY|}7P$6|Q3=BGE z#sLLN6IPeq_?el>Vj=*Ry|Fh~;rT~?24$qCS#6{=m9%qvP2ONh0Fju-038NNk^^S8 z2$1ZUnwpkvH-993@9{v3e9y|>&S&`*Upx`XM|B0vRUn=Ofd-SX;&)_9ShVf>bRWrx zCZMlBnJneT*N|iVJ%Iw`BtY_uU+ToOi>HS^S+y@iu(0Gog`ITcCIY8mr+x6%#^x8%^-86#0vM2Q(MNYl^C!06kbo`T@f8purrC_zM=3Oy z4z=2RY@vR1<^#~i{x!hCIU=0!As`~~cwF4#bU5Uq9`7YmOF3&C?m7lYOMU&1IOs*& ztVSd{!~4SPtnF(O8F^4@r_V&jMo_+ilMC=aj9puMvL`CObifH z<^=!4Ty8)CTdhB{*VuYBin07+GVv@Fv~vI=7~;jq=a^O|;hgOT-W_rCA2DP4Oe~0| zjsD>f2*7Evz(x<0eX&)(j|^oKpFnuX3H)j@zdt$$wOxP<=6{rsql`$=vrJRw?7luu zpMNnF`pfkVE4caLf+w#Om6VWA7$gDjkTO+~(cu4nibbumlCM&Mr?8SeC#a1M0GCCS zn_WC8m3ZAH+~V7_1O#C>v`(DSnY&nErsi z>6RRbv7hW>g1c~v^}kC-nP^LCkdKiDq}c(lJ~?MCW&0SL{LuS*^&vVshOQ6?{LkBn z+FEMJtM@xKzg&lp+k^HX$lI?5HXHo8N1z`j14xL5=0K!K7i_1c1Al=0`Twwb6pa4= zhnm;_bEB3m+`t~(wXR@Qu_FX4)vt2)!Dq*xV9IY4!y*39b>+MRay=vOJPb>b@@R2Z zq6N7JM-REupO0Mq1(W3=8{pS@_*gzois%WUB zw>~Ed0(b0EB6da*cUtT3{xi<`7BJ4{1vK2Dv$>0tM*dr(HKq|gR zUH=#TvzSoJ5Qun==pG0>Fu+r6G@cg$ZBc;%dlol*ACT|2{omf-yx5Ig3E*e04f#3p z6#6=eV1T*n|F5#?caPcEkyl540O|i>oi&ZJ9lH1>5I`IM*!Uo;LV-F~WmT0=8l%!< zed`jMUN8FJu{zSC@#HFirl~M*w6+=chbT?@I!4P+v9OAiuVYlAUkR{d43)uuJ8?aa zdr}UX*b2SK|2yKLIX@KaA8}17xY<%k;jWzm5GrBY+B2LJ67LT%R)`m-8tr72C8JEz zy!Vw~oz@=Xf4}EkAJM{JWi;&3b|2OXR?D-B!4b}eT?f(~frD1|RGrRIu~OY*I*|@i zVOv`ToU>@;!~wJN7YcTyuH|~rM9TdGu5p>&y{Kec@<%E*@`~TQwzhy=k=>^(;!zil7`>SX-hxzB)*Y($%#HjzKj1y6GWwT_yeR zxHk6MA?PTOY-yLrvI18y?`cNm+`OtTK+i@q%O4)ZC6bj%^-s zTa1m42K?Kga#Rw{YT&73i_J29aRUn7IPV!nT^<#wi!)=(Ic#;fJLXWMYSiI-d4G`)MiX>xVt z^7!EJXJRm35*pU8hzQC`4o-Pxt-!)r(#&MO@QoTL4R9o-ok~ntb~f7G&8DldX;n1w zoz-d_4;k(hI7W%FcVTxEBb73Rg`3^GHi7mJaiW~+*I&Ve%&&P*N&DM7)6&b!d(73< zzqKMJNQ!0pODn37JWx9w9Z=?pD$8I4P27@{8R?fVlJ)N7w?AWJ-xBnBKBN4I@(=Xa za+*#ox`}50rzrv~L#F?yJ&w$_!i2V-I3_;(x1h1H@nSFzcgu9Q{Gu6M0w6dM*cwKj;G%a}#aK{1miCmzIxG}e2TRSU4Iwa(9f*H|c=DO0A^^T6#!uH;| z!btZ}_}KRpaJ)`SmxS_&!NG#thQx=?650D%tI*^Le)%If*vvlIOsN8RoIF}9gC1nkA@h+Gv!HT$=3f1Iwz zRCFwj=qnn*ToaQ`>O{83oeL$Ftv7#sLC_<32vDE4nUbc5lEE|eA7P2A@rnAzIv)~C zu&|!&*tj}yjbAuj&akj^MoeibD0nL@;!c*iGM`Zh{_X!hBEmhUP4+Cq(_bm9$*UDc`YY3V=IfUxaE$}cmHw|>?chAqt>Y!rwW_=x9) z{U`+w?NS>&)!&9vprgC;C79QrQ?yTeav^1(3A=iS6yOVjvJ`gMl)d)BNG@kanlVgq*(xBVWgB($a8YCFE?H0mn!OFP6gcztWRawqG zKvVUf(!PNiOa&V0cBrRgkG&@Qs~oXLC1+7gA&i-QP>LwWSWlwi77oGR=VfrL4m%wxAnThe6<>(I|Hj% zf5Z)uzz$?qb`KwkkY}6OhdH7tcI30oNQH#pl5Q|pnC}WDmb%`zaM{X%rmEHw&l{vZ zzsn3}c{QqRP9K$7kvDm^Zvk4-9sukkXb&5?Kgs*^{90yO+2MJ3VU=@GI$p^k_@<5E%@Q&vz2OU2%{d-HK4Fv>}9=u>V% zHUFxkwar#(c@W@2e%;(Y+gL|+Y5-M=-NFD~Plskj7DfSil$EkiP|!SfWgqxjM$ z!`=60^M}kFoSZBS$ff`fWMqu^N%coc0A&RxmtXb4^kzlsUr<*SK^H{$xY+EKKNi4NkHhhYM{TzyHOSdg0S$ZDnV=gL+1{O*9`w`t4=|1Vn z#{0j$k8$;>s8rR}?CPh(LaXro&@S{#6G;k-7}J2V6ZA9J*j=*eE#cvk5r!{Cj@Q~Y zpt;An8@wU(fk@Ilv6EGa)0R;bfOV9reP+0xPqvI*YFrKSzAUZ82n8dU$^wR%5E zxjg`%45&ZE-Zk=J4$XmNvHo}X1WUr{zxYAHePMHRP8r|*wiS$H2;|$J_g1!NA3xbR zrJg@Be_C&8X1sG!3MxrbK@U>2D@Fx(Vv-plrZ?{(vJBrIV*>f%E?65#{V{0!WcYUQ z%H!S@6`K?z#`L|HU-n!6S&hRbcG;HB=`U-XVvMeU{9SH(IdVA14AUL>={-0;+R84= zA7q^&+Pzaykbw#=2yS<4FKyJpPgSt zfL2n;-<+2hI}#%ncy5En;Vn2&hakwBqY>e}X_|Y6Vfm`Ql#r1bWjIZ{+YVbwo zibWE#M#r6#AX=!we013(gC{N%`om~o9v{#1(_s}XJ zURl}ir2!x7nV)eAHxMGC&q_c+gTaM6o41Qv$)?r`T0Lcg~;B{ zHk%q7zZx7s7n5~J3h$U|`DoY(lrEbIM>PKg+;$$?9A|cmBO+IzrMbCdtQmg(I&+y1E_h-5&reljVPhc-7Wu$W75M zN7rkaiCxF*30Sm81ex06qH1c}iM36?omoE{&b9yhM0pGF^fdVQ2c#bJT27a>Mu*$l zo*9{ymFni#*G|<>EM92Pih$7bj+W84DTQ1#oain4Yh(fu;j+gMI+4{ho_iN%s6+~JS`i5&OKQc4!#PQb**_X%J;uzh4UJee8>&54R&;u(Gj>R+E*KjpO5`^}-e-hJ0{# zeuPcN#}t*gx>##FI6CRGTYvWV?8?3tTv#$jhg!k$93y(v8PLLnAf}D2M@~gyf6d2%*r3vn0_-%E=(1ws2tNz zj&kYkE$6Q_2#$@JwlS+Ps~DdBYHOr~iz5F_W6Ckw{I^m+1v%QDv!Tv!gNyzKL`hu9 z#O%cE!0~Jw+mYK^Q?cnMsZ>_>*RQoaPOC0%9ub0EW#)Fe-LaU#V&o>Y07iC9$OQ7O z82W1S(9j7)Jo$)WFGC^UNaKty>rtN3mwo}Adu(4J#P)bmcp&~>gYjstyvEsT@Ry~| ziserDMIdwMbd_nkEgW@uWjUN#3bXzsow+0F@^~qWmf3RT6cr+O{g~9gcV0TNgWT2Z z$~;xb(=cn@o_8f+W4>sMj2SvsDu3P)N!_ul6vxP1%sEtth6-P9^Men0ql?}*x%a@s zgbashc26g&H>K@FL^2xBf-w7qN?5q`PDZRp1}sDI>A9ubE@p)G zLYY18*sZ5&Y2w1&j|$oKm#T~Bw@mqu+Dk7|5%UBF2J}7fy_)U4ngz`D7={#LT|@+GE&TW0;OtY>JG*h$EE5m%AQabCqhkb(?9<=*YO zH>=@IO}v27JhtatK!7)&eUQE%sS;8BQT+sA_K;K(2d}7*z}qIzz8GIJFPZme@fUp* zcQ0^p3zSR3cx@5MKPnxhH(w;GRjhZmb_9RvELcv)+Hc3B674h+a(1{>h0Qn8W04Bf z+0&DTJ8k8u*10+B?zH7J3e}O9({2>1R|_^dE{97J_};?ZaoShov@{;`5hma7TMrq; zS)PW`Lm)r6e`bVD8C4YNQoWvcD>$+zc<;DHkScU;%C}=rBSotAoM*gH4L=x>L|F8# zss8QfYX>rV*A1pJhq=0oyHIX8HQ}AuQ}UWk<*Uw65_p2qje3%EC#<945>xLYcZk)Y z)hC|qbWFA0Vta%;jNDDJ(sU;K=Ay4Uw!QEExaZTX)A{K{XNl##FzHl|W2O1=-dSy< zi(Pns^83!pA_lq33hlePm9Sr&rsM5oj`K<;3pLk2(Iv-C;$b?vv)9eS*7G_2v=n?d zrCa;P<<#hQ8_#a9&uE31=+)I~*bTU1?lhPMKN z{OjT>vzLnMl*WV`&`VZ-oQs*CWpJtdF~zi{q>8Dl@~y0#q4>Hb^e`$F@on?{1LWB6 z?}x(MPbW0+VHbxu%ON-^1lK!_=LD&?8}g`_@NVhiRD z+j6Un*#+|2uE*{89c@Il<-6etx_1_~TF=~`&1{*fz<)o1p@rj6$lGYjs;I^v-_oSM zz&g*ZgK?S6=2~eaf7V3=zpOCqphjHmUuzi4r}1!D#yY$;ZNi6R zla+UD7Z;1Ak`ho{OscKkYhLx`JAO*2*7>+tGxD?EqF^Z$6KXMPmg=6~L)pP8{D;f@ zxp&xXj=PQEBoj*jVok-MgMPb3TK8{Ql*E~S@?o$jBfGy(6+ zA2?u2tH~?RR{eFZh~>1S-v2RUIMQ%4Ws8`k=KEmQb#=PmE*vgMpk1>Oc>!d7p>f(p zZj0U6yEC1asn>QlOK_o(s4zb0Fw01;dv`XMeAxXlneX=OEbd%Ad^PSybb?xG=@ugJZuW)x=?BvY4_#5Lj=1)(#TJ5q#%ySwDlh{jGAg+L^r}r56{+Efy$An(_(sUnU%ar^kl?0 z4ogiQt&_EWwNh(Wv&mA*`FKGpio<6CNow3`>$;cBvUgUrDTGBfX!>{+4 zDNKi*t`S~bCc{p_si{}p^fR}_d?RJT{x8BP$hi1H09M$n)$bHkc}z+}?r1lAmIG^# z%#xJs*Q~2_qWPL3>bmPyP?S=&J>H2KMFU6v?mEumf_)+Q9JZgyvlySzGcctej%Gd8 zLD=!jH&}l-nsz_N)BgT{Y5^{*!pQmG30`-}61*PTGwdv!Iy@>UiGjn4{Ygo8XR3m0 zkJmomx}557{QbDPUaZylHpNr-YnLrY$+M$wx5c=Aj>eO)+mAzOn(Ofb=b=UXHgLWMN$bTH=VNF?{Mw$*^vO%>nUj}?C2;a^ zNv50d4d;t&lNYIi*M&q}Hs3Eb@m+Vks)(S_w-S9a8&lD!gMgPD4Xg za;aiHg-p9~DgT_~bgMVqjYrUNk4Can+if!|p3m}Q;T^0>MaJ`+A;D=>1|m^}3Ql=PCzMlnk_eTm}uPf9)-902f#L<|wPoZmK-;Fn_18gWb^=MeL069?eTh?fArPqme}&!j?YpmMc=B%)>C*) zY;KmSVZkMym+41k!p6z{qSnK!U8tE69o{= z^R;~JRGb`IkFD9QdU{?w zh*#aGgGJJDiXqiL68xar7XGug6pSEM{Y7 zJK$)UZ?A#R;(8>t`l@EicCBO9`8eIZ-eFarjO*HI^JtYid2Dti+u}5{_lfc#>1J`C;JtfOhwfQ4?eAU{@P*qfObu(m0PpnVIJMJN<8#WF5KR&9(|kI%l0b zIMtD};)j5CP8d4Dl4yJVOtOU{)W!fjo4CG~WImAsqQHpzU zw{0Tlj8S5$r_rQaFgVuzJmjMo`Af00xfeWsp~PecN)H3_m zBDEG-blRyo^!@CC0?Bm+E8iHbd~1YoHDRZq^_uTc>BlzBqbss$#~^l@dktN)$&Kp* z9@lkOi`HY`WvV*IaFp9GIVy^FR_iiDwffd16TZO&gkfSK#F{*J8~#E>Af+qqSQh}# z^J2qa7O{0jQeTmQj-SyUqE@lwZInWW%qG-t_s0=8#RroLvhUMY`u6^Cnl82x#S{aJkQ;uFq1;)=m|q z*2m9@Rut>#Fd*Unq1cgtOL056Ip)%TO7aDD>C6JPp>{om`A(p2X9*K?;9ilsKF5s1 zd295O;*X(p=Oibyv7st3MGspPWSK$)*r8jGA3{FNy|NmW566M!SP_cF35NyXBz>#< zyBIbO?#{&t4HM&RqxC)Pj%Xx1&VsD2AcXARtv8;tfW_gG`W~N#!qbeyOx5{>x5hT- zqT5%S?qw>^W=HE`hAehCzCj{^lG$vHwcEC7Tqh+unc~r;%js6f!q3~~X?mt`&-;_w z!@&FVvgnA6MRKg==qTBDAp%@xB2}Jscfjn&cUhTHT{M--B{u2&AD(PyT=gyHHDWc4~DIo*H7GSmZgJ{`t6O>!{>& z3HcoTtw5eiiPqDXJg=G#5|+lh*^T#H)fynDd>=0@OLNRH@wXn*U9+y?tV2+!yl1L+8flF} zColD!J@Ve~D2>R_I~tACKmAiR^aJEhqs@1|d3ft?H@!rhhEiRL7jv2=DRUXp+e-w^ z1A^Se)8VO{Mlua7&Nie&jjorIhhh|D`gHtaYE#`8Lj(6T#jvaHBSzOmkQm2l9Hbs1 zD@|*>x1Ast$dF&|wEp0K7IT*pN_4BpfAUYCdU+&!AVB+O4O+8sGY}VUzvramcK?T7 zA8fa*KpmuaxKyoo`e$sYL9JqA_{cjwBeQzaosSJ!oj%-0<}r|P8V~+`^A2n$blK9W;GlDR28#L%p(dh6*Rv30 znm4@KvvlWAKEGx5)QP!`@9zn&8C zQ_SMZL@HLw5IVW@}YNJGeyq zboZiDg((b7|Jd;A+3L?pcC%&b?l%ELvK_mQH$}q(0<3z9l@??9-&I1E!^nx@WCE4L za1jxW`{l%uj?jRBWx82PWGIGLzGSc7BOJ# zplNcRq8>_Dt2jCwFZ$L&OM}1vI&WjjdU-ip`-W}2m|l~c>h;o3e~LiIc{K_`_p%Ek zsBKP|uee;y%-zL0k-*wiQHn~!kZgFMQD<22$6M~ADVqh9Yq*i1vqKd*qS7Sl3t@%3 z-Q)q!zQX#8{+XJ-9WeQzd{&A{dsRat`cOglmECGY*tVygt%mcZ3g$=+8V>0*Z*=V3 zBWv#Yql$PAgKnrc0|ZhPMoVWud3CzI9IIXuu*t4XOEFV#QgLyHUkzeP&Q9rs$>d+% zB-`~C*f-M4(BJhckWWrA7vjy zv|gN=j4Z=ow~aN+#r25eei74&qEHgcOA~%pw^4!t65y-lcGwda8&W-2BAvBerxv7D_j(eUV!K8)k@BzC+%hN{+<+trV3 z{XjtV-LCMo11Y^_7(-g+iXIcg1;mvXg&C?~n6!*jZV zhn$Of9K^i|d_w<$D9-EmLj}c1am7w}_@|*Jc)K&uz_Egf_Swth<md`W@`_(OfCPD zm>-SO?wfr3^0_CrB!I-Cj+fly=8%ZZSci1HO481Ff-9Cuoj^g$V|A{cKgE@4yu_0x z#nbw$?!=#zx%!S#6)jrlex(8xcy;l$MUu-BhV`fy%FyOLL02-nr3DD5aI+D9vlE4( z>ho+e8L&**z&WLwyZ;w^Zyl8N_x+1Lgdp8r0@6~_QqrJwNVn46($XCwC@o!5N_QjO z-5}lFaQEZq_dVy{Kh8bp%$>P2XXe8UFg(0-@3q%n>-AdE&8pKt-vk!(>Vc0CA>7SH zDEUIYL_HWRnYb=?+Xp|l{YdlU`70zSi1oQr1voZyJ6zL6>mIjQYc_?CF>AfTX*{;_ zXnH*f&irM%*WJdnlXv?Qg6I^O`t?`cz!)fMyK0kgTpRLf<_n4jM%|B@6VjW3O0z{q zp{obS)vP=p7Gm!CqNCa#mGp3mc_;H1S^}=YI{dFC;XIr{eJBQwP3v`8h%Z^5ZoO+B zTr`>P?LmOGqEB>}`ox*Uo-`Typ3cK@7~bLipnqzy^J!zBaGIcx5_qC|e3m4fA%ROb zFoX&fv#w+N`*O*9IQ}>3xOpjVCP1wT0sobtXsUs1ck)N5E4S4R!?~UZci6pFb@YQ* zR5-`bm8g#0Lp9LYA|u~Lj%hQxgBHkyZ?->_J89Z8Kk=8dx=JU^s|g-q*t z95yNdR4cJ|zy5yzKvpSZKBabSwRcyD;PD~RwhO8IPJ6|XIjK4f!)tl|Y8Y{lIi=a@ zI(fvT`92Gv6V+GqmQy8B!k`mL&1QP_zRQ?0=-K{TVfnj(#LUmD5rt;_NB6T*`-?;Z z@xwI#B7thL+rg0xugzkiP5MR~kT z;;iLHKb+;MbKRw|)^ZJ*ES>{C5=I#QfOhBnHHXo|oo2b&@%%S_A9vZ5GueiVu568J z8(ldcByhEHELg;epAPFMZZ>U4(C;=ke*`q6dv{9<@L$DWJ5y&*Vv+#^4rw`fy$n8r zjN*}&1{$6$-9i6hMe(0! z$TtKxVtSm`C52Ri0EbPU#|IF^fAOCOj#}`o=Y?1ZyV^n^dpfY)Hqw6}`MRF^;I7y3 zQ48Xo=^NMGY_Infk8f-1c_0uM^iRe-R1T1FmQ@5uO9{2zo{2cR@2to?1TtNDk-v@d zef&$}Y3AIg{oZJv2kMXg9SmH~G7wdj4~AOMW1tsR?fi{J=>*vuEUa6ThknQfKAi6I z!Bxk5Mb&}sn^ciw9?%B8JdNFRMYXMvGD*ai1IcNykcnWWs7M}18up55+7Mc7-h<1J zSS^9hqeKvha}uERe|c%E-r%(L9vndox(jv=Q0?I$-#3s*D?{co!Gz@(K_?G7FIKGo z86oIj|N9g8e?QAYcbSmiT|-VE7JOP3K2Zm}MIc6ku!!JSj9xK1>He&9{y&qwAZqOs zty2UC{g)!oQ&RBjwR`1DJFBL+H`dKhpdVM6oL+WSI$w@x5&adbx*U0UbXR-m3F;H^ zmmQVdcH@)UHsJ5&#=?QacuVW$kTQJena#|t7B12wb^HPaJ<=3LQj+lpPM{zj>^Y1kp zcO2$Hyw8+DX#$DG4V6pfgIE+3Q8HA6>Ohx^ReGtn1uOMw8Vt~DT4zBEEBWPVa`X9ju2mDVK6gc4j<1GGruYbqze^Gn?J5c`))W6#1f5p!EccuKhQvO{j|E`q( zUH|A`z45Q!_*ZZIPxZ#H=PpY?Q2y2h_>%{cHwe`%5V;xooo=qebbXW*^sLy@!-M7WXQxVXEIa8Wj7Lu zsXDs#=Vhx$c~MrzB9QJV5S*BrS{vSX(9dz{%?gbb2T8eu#{cx%0Wr|8qGA5iab{Zp z;cq4>G+pSF@V|Af`5ztZb!(Gav48+geFI;a_=XtJTuZFFT4aXwBmrk}qgbBXW7v2+iZoCfjQj z5g&(b_?8n?#sfV|X$~qwnI=XxB9SW|pt?aw{yrP@X%O^kKG$98fszGO79>#FN(v$P zF|O)41ig8s5cK)vT2Rz1SejhHNg-MUQ0d}2hK<(>JA|H;IoS@LSQ@Q?jYe+81Dd>? z@BH_|@T}4@V^?WZg@)GFXrM5KH4k{fStRHM(Ut@0EAgAELwt|dXbpAF;Q#A?Uf?~F zhB`XngMuW97d98_Y3Ny535q#5IRipNWwW{&SXtW_>Rq|&6sRn8u|whA-Iaf@$6I?_ zd3nL9zI#`3Z6BI{&dbkld`isF~b(d3nLp1!Dp0;K$KJ@L}bSDKb_i83lzfD#R_Nh~s%;QW@!92(rhJSHI~eBR<k>p zy%cHtS%3TN>K2iThDIj5m?|^jv-I}!+};YQ^;_EifK(fn&|7-kehR7bEn$_L3e|b?1FCs=8O&wgH(JpNK!wemv7E} zl02=Ln3%wYs09SD(J#;$t7G42aEIj!#`L@oi40ZIq9DKtrj~lCq^z77_zo+U_Uig( z`+ZFYz55<-ih#49AfFQ^RP>&Z6p*FMx@77ps&4Wt8O}fkd zV@$LtL>i|{LSftmhtp(!`WTh>?+r82yV}}d;SlTEr{)OK()5Uz9-m!`5dIN*>QGA> zj84{((H?BK`Uh#gQQJ|yD%qOn%I%g4ykaW9>*UpO=+?uBxndI9sM1GRv(=6s={GW6 z6Ghk7(~MN3@G6N6DXFO>ONvr8H8pM1)9^VtDTE?l*_O>rA7&d-NVp4KH`lJ8u(pkq zyZ(xDe7NKDJeiTQu;#bFdoC`4W0cdB!lmC`xN@bK`I&z?Qa z`H&+mBjXn-XNp?Lagb(FPJrkPuR<#q`00~jV>AgNp;2!(!`01P>1>&rcsA0z$#-vd zZ5HZ6rRj#Fz}g!3JMabh$a*EM*Rp91L@hGnwXfJ?`0&E$FfsV zBk&Xrh>r`^pgD<|No!=y1qKBfA2v1ey4;zORD^|S#Wg6GTyXhU_3(wO)VBZyv!%b^ zcp3D{W~nLg&!6X@_X-sq52C}zoZcJ4YxPot&fXl2YHu{1o7$GSIRcmm7&cC;2UR=h z7`%DYks%hmyt@|O>K|P7Cui&V#p?HqH~LEu`*MyilS{id)>c+jK>Ya8Y#%q7p?XB- z7HBaq1F(^gx5j_LrRL)!N|btOcdsVTjr-aebL5^$KtN}G`fkGV-GnR?-#z8XjgZ$3)r%LT z9+w9;MYPyhO8n5*%=ZtEj_yvXEYfO7|NI%MlY+#8$I7faUxK0XZ`Oy*FMP4I`J+D| zWn={V^5tmkkddGGeo|l{jZtm~WARX5U%S4?11v;b0{v5JYUa;XUN=mxI;`>x>NI1Cgu|u=f zW~If&_fdc@**W4B2t45NR8aN+ZSIT4sp0a8GII;1x&p%q%FItC*YB^cTIZ5|a6m>r z??3iiwvuF5SBQ#I5y*;4p04qLkwAp!{0|C!0NvRyL@-JaqJCz3*$r&!3qio9gf08C)Z; z`(?dMM8%<#9hD{t&0nh}u$gw*4PuDPGinQvd0{Y*UXF-}?)v<*K5OxsUj_8wy18UOHhTb`+Hp-@$OXV2Zbn?0KtD+SaxPBGprhYK>_)4@rIFY zHMfJ@shEU>FK(s^ROm*yK|@eU35nX#2P5M=5rtkA4Jzg6Lc6szC2GkmOLcALiZCq{ z54#GE8w3P4cBKRS!Z1~Z#z${2FE2tu!isFoH-8-Xdi&xPw1ALF^8^M2v`thtkKR=7 zK6C(mF1NTeap(C%_ma*iw<~lKV@nvb^%JGEKw0w)5Z9_ax(WYsr7;t+&wcCkOiV?W z_Mzf_hGG=29p3!xh3!HMB$1Gi(6}2x9Juk!yd{8(axoeIJwnLkP$7A_y|y3z*CG*m z{O=YCcn1Fd{(ek;8hP`3aOb`G%Ep=7g>pxJD(XxFOQt5?(8x$JNfU&y@Iy|ICGqhp zgbRw0DLt=WGD9=9mWbXr*HZD(GS9JPlX!{Z=;S0671@NY XRc;f%%h%X%ya=wZ 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 literal 0 HcmV?d00001 diff --git a/video/showcase/images/paciente-acompanhamento.png b/video/showcase/images/paciente-acompanhamento.png new file mode 100644 index 0000000000000000000000000000000000000000..f8188d0bee693759ccd7401104770e645970c00a GIT binary patch literal 101037 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

9LkliW=a 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


X|cRNYny-r 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
|LU-TUt__L=*3U>3oWD#=P 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 literal 0 HcmV?d00001 diff --git a/video/showcase/images/paciente-emergencia.png b/video/showcase/images/paciente-emergencia.png new file mode 100644 index 0000000000000000000000000000000000000000..8f39eec2bec959c65f1c46cde0ae0c9b4f6c1e16 GIT binary patch literal 120996 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%0Ad~P5b(8lh*Xc7L# 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
  • o%bycWQ<5hK{|SxuGWtWH5V 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 literal 0 HcmV?d00001 diff --git a/video/showcase/images/paciente-perfil.png b/video/showcase/images/paciente-perfil.png new file mode 100644 index 0000000000000000000000000000000000000000..dc702fafee15a040ddec8a087ebcde14673e1488 GIT binary patch literal 127654 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�fVY(Q5-1>RV+VhDUrR@7Mt0FZfe^L_ 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 Ch-)+e literal 0 HcmV?d00001 diff --git a/video/showcase/images/paciente-triagem.png b/video/showcase/images/paciente-triagem.png new file mode 100644 index 0000000000000000000000000000000000000000..4890c35a6a5d73caf30403a1832ac0130a9ab908 GIT binary patch literal 113818 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

    SinalACS

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

    -

    Showcase — protótipo

    +

    Showcase do 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,
    +

    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.

    + 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

    @@ -22,7 +22,7 @@

    e-SUS APS

    WhatsApp

    -

    Desorganiza — comunicação sem estrutura nem prioridade.

    +

    Desorganiza comunicação sem estrutura nem prioridade.

    SinalACS

    @@ -35,11 +35,11 @@

    Arquitetura em um slide

    3 apps Flutter

    -

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

    +

    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.

    +

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

    PostgreSQL + MQTT/TLS

    @@ -58,13 +58,12 @@

    Traefik

    -
    -
    📸
    - Capturar: docs/screenshots/patient/02-autenticacao.png +
    + Login do paciente com CPF e data de nascimento
    -

    Paciente — Autenticação inclusiva

    +

    Paciente: Autenticação inclusiva