From b6cfffd9db2f1514e76b6a86f2154910b0d3d3c0 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Mon, 28 Sep 2026 20:55:26 -0400 Subject: [PATCH 01/90] =?UTF-8?q?feat(lgpd):=20schema=20de=20pedidos=20do?= =?UTF-8?q?=20titular=20e=20linha=20de=20consentimento=20assinada=20num=20?= =?UTF-8?q?lugar=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 1369e77..28287cc 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 5c1bd49..6b3b302 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 a1b57a7..4294cb9 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 0000000..ac22a03 --- /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 0000000..7e3ab70 --- /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 0000000..15e53e0 --- /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 0000000..7ebeab6 --- /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 0000000..c349a71 --- /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 2fed766..40bf5de 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 7a4a3a3..b7ef8e6 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 02cdca6..a924321 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 f98430f..9742e17 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 0000000..d38f09a --- /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 0000000..a2b8a37 --- /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 0000000..236f319 --- /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 0000000..37ee31f --- /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 0000000..962b512 --- /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 182fd66..4e1d5be 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 09dc683..464ae8c 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 0000000..5be2728 --- /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 b150da2..791ac84 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 0000000..8615df8 --- /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 0000000..bd903dd --- /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 0000000..34bf27e --- /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 0000000..4cea0d3 --- /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 0000000..866b5a6 --- /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 0000000..48a85f1 --- /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 0000000..bc44f5d --- /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 0000000..c0b5247 --- /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 0000000..f990c1a --- /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 0000000..b20e1bf --- /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 5ee746e..adca361 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 0000000..074fdcf --- /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 0000000..ed08016 --- /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 bda2b76..840210b 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 02/90] =?UTF-8?q?feat(lgpd):=20servi=C3=A7o=20de=20direito?= =?UTF-8?q?s=20do=20titular=20=E2=80=94=20revoga=C3=A7=C3=A3o=20por=20fina?= =?UTF-8?q?lidade=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 0000000..4c483f1 --- /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 0000000..b2a0337 --- /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 03/90] 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 bba152a..a5e084c 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 a924321..df5afc4 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 3e947a9..1baec80 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 c223e54..92ae3f9 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 0000000..99f4e44 --- /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 1372f27..65cfde5 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 09665e1..d1e4ff3 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 0000000..744a1c9 --- /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 1a5f8b6..48f11b4 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 04/90] =?UTF-8?q?feat(paciente):=20contrato=20de=20direito?= =?UTF-8?q?s=20do=20titular=20no=20cliente=20e=20decis=C3=A3o=20vigente=20?= =?UTF-8?q?de=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 0000000..ce675e0 --- /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 6678bee..aea0342 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 0000000..e5e06ca --- /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 0000000..a047fd9 --- /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 6b3b302..671c9aa 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 7cfb313..1566c98 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 05/90] 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 1984fdb..fe68da9 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 28287cc..d396605 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 06/90] =?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 fe68da9..f728e3d 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 d396605..8666922 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 07/90] 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 f58b0f6..5697ae5 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 8c47ad3..f7e6be1 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 16fee68..4af7f9e 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 08/90] 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 f728e3d..a0dbbae 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 8666922..ded7a26 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 09/90] 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 0811ea3..64b1e9b 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 bef679b..3499bda 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 10/90] 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 64b1e9b..0000000 --- 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 3499bda..0000000 --- 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 bc66ae3..5ffb09b 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 11/90] 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 0000000..42a2fa2 --- /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 12/90] =?UTF-8?q?feat(paciente):=20conte=C3=BAdo=20version?= =?UTF-8?q?ado=20do=20Termo=20de=20Uso=20e=20da=20Pol=C3=ADtica=20de=20Pri?= =?UTF-8?q?vacidade=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 0000000..b150037 --- /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 0000000..54d64f4 --- /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 13/90] =?UTF-8?q?feat(paciente):=20telas=20de=20Privacidad?= =?UTF-8?q?e=20e=20termos=20com=20resumo=20visual=20e=20hist=C3=B3rico=20(?= =?UTF-8?q?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 a0dbbae..bf22be1 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 0000000..4873014 --- /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 0000000..3ded899 --- /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 ded7a26..41408ca 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 14/90] 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 a5e084c..54a82ec 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 23069b5..3d07d42 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 847015a..cfa5bae 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 4c483f1..d435f03 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 b446256..e526d4a 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 1baec80..95af933 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 ca102b5..6902cbf 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 7b0f78a..6d388ef 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 ef8de48..643eaec 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 48f11b4..8384720 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 b2a0337..25a8417 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 72d5836..1ddb5b9 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 15/90] =?UTF-8?q?feat(paciente):=20aceite=20expl=C3=ADcito?= =?UTF-8?q?=20do=20Termo=20de=20Uso=20e=20da=20Pol=C3=ADtica=20no=20cadast?= =?UTF-8?q?ro=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 bf22be1..7c0920c 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 ce675e0..718e91a 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 aea0342..03b7824 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 bf0b733..4e320f4 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 fa8c5e5..17adb68 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 671c9aa..62233bb 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 16/90] 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 a053196..08d7cfc 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 2ab0d7c..845ac25 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 911115c..b831ded 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 4094764..494a4f3 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 17/90] 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 b6b2223..cb05315 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 0000000..e6225ab --- /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 0f1089b..93e6f46 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 fed1877..26aa87c 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 0000000..251e383 --- /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 18/90] =?UTF-8?q?feat(paciente):=20leitura=20do=20QR=20Cod?= =?UTF-8?q?e=20do=20convite=20pela=20c=C3=A2mera=20no=20onboarding=20(RF02?= =?UTF-8?q?)?= 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 ebccdd3..33f24df 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 0000000..329579b --- /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 0000000..00d09c8 --- /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 38f9553..1da8398 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 4640b90..e64c280 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 0000000..f91023c --- /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 17adb68..ad17303 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 19/90] 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 f7e6be1..76fb60f 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 4ca7340..5563b5f 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 2f8022b..c9d8170 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 a1d2996..34142ef 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 8775274691516189f050ff061cbc99a3e7e39ae6 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 29 Sep 2026 09:24:20 -0400 Subject: [PATCH 20/90] =?UTF-8?q?fix:=20achados=20da=20revis=C3=A3o=20fina?= =?UTF-8?q?l=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 76fb60f..ff93566 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 e6225ab..658d3d2 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 251e383..6c6f7ee 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 494a4f3..e3cd14b 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 4873014..983d6a3 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 329579b..d2816da 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 a3f4d22..da28e0e 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 3ded899..f161f9d 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 0000000..206012c --- /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 d98bab041684fc38ae2cca9138d85143c445e751 Mon Sep 17 00:00:00 2001 From: Herbert Rocha Date: Tue, 29 Sep 2026 09:43:03 -0400 Subject: [PATCH 21/90] 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 0000000..ca90619 --- /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 22/90] 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 54a82ec..08ff630 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 d435f03..ec1b707 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 df5afc4..e323e58 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 95af933..615b900 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 92ae3f9..bd17d4d 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 744a1c9..f2abe5c 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 8384720..4288312 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 25a8417..7a18a52 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 23/90] 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 ce599ce..b1c628a 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 983d6a3..4ef8887 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 0000000..d5368ea --- /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 03b7824..775b7dc 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 41408ca..fd9afc0 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 62233bb..f9353a1 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 0000000..de63d41 --- /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 0000000..2ee00b4 --- /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 24/90] =?UTF-8?q?docs:=20sess=C3=A3o=20do=20onboarding=20j?= =?UTF-8?q?=C3=A1=20=C3=A9=20de=201h;=20registra=20o=20aceite=20do=20termo?= =?UTF-8?q?=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 ff93566..4ba72d5 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 5563b5f..64dbd42 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 4af7f9e..6565cf8 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 dc4cd2d..b127969 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 e526d4a..1e2efbe 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 34142ef..1e967b2 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 25/90] =?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 4ba72d5..0a06659 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 fd6a93f..9999b2d 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 b1c628a..e5a78ca 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 4ef8887..e8bd603 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 f9353a1..fa7c31a 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 2ee00b4..5c795ae 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 26/90] =?UTF-8?q?docs:=20plano=20de=20fechamento=20das=20p?= =?UTF-8?q?end=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 0000000..891581f --- /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 27/90] =?UTF-8?q?feat(backend):=20status=20do=20aceite=20s?= =?UTF-8?q?em=20ler=20o=20painel,=20aceite=20idempotente,=20exclus=C3=A3o?= =?UTF-8?q?=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 08ff630..6a2bb14 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 ec1b707..21961df 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 e323e58..5a79f4f 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 615b900..1c0b16b 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 bd17d4d..f24e70e 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 99f4e44..393282b 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 f2abe5c..cd2f768 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 4288312..0fe5c51 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 7a18a52..cd15387 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 28/90] =?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 e5a78ca..09730d5 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 d5368ea..0000000 --- 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 775b7dc..039eb87 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 ad17303..c4084b3 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 fd9afc0..4dcd4d3 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 fa7c31a..52bfc72 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 de63d41..0000000 --- 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 5c795ae..2974ba8 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 29/90] 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 658d3d2..4079136 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 6c6f7ee..240127f 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 e3cd14b..dfa4b0b 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 30/90] =?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 0a06659..a6f84fe 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 64dbd42..2b9fa2b 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 6565cf8..0a1cfe1 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 31/90] =?UTF-8?q?fix(acs):=20validade=20do=20convite=20med?= =?UTF-8?q?ida=20do=20recebimento,=20imune=20ao=20rel=C3=B3gio=20do=20apar?= =?UTF-8?q?elho?= 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 a6f84fe..55146a3 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 4079136..232c192 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 240127f..55a8339 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 32/90] =?UTF-8?q?fix(backend):=20aceite=20do=20termo=20at?= =?UTF-8?q?=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 039eb87..cc25ba6 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 21961df..91c9b88 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 5a79f4f..5a03c22 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 393282b..92e1c2d 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 0000000..8466efa --- /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 cd2f768..e0eced2 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 cd15387..7bdafe1 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 33/90] 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 6a2bb14..34837ae 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 40bf5de..cbe9a77 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 0000000..0da3d44 --- /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 91c9b88..f16f2e6 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 0000000..8036ac6 --- /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 0000000..b16f811 --- /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 1c0b16b..b8fe899 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 4e1d5be..d4b59e3 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 f24e70e..20d7d2c 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 0000000..4112922 --- /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 0000000..021d69d --- /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 0000000..9b861e4 --- /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 d1e4ff3..436aca9 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 0000000..e400447 --- /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 0000000..e4b759e --- /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 0000000..b551d5b --- /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 0000000..44888e3 --- /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 0000000..19b4d97 --- /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 adca361..64b206f 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 0000000..0887a73 --- /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 0fe5c51..eb0a1ff 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 7bdafe1..54a6227 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 0000000..cb12fed --- /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 34/90] =?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 09730d5..a495f4c 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 c4084b3..c375582 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 4dcd4d3..d28297b 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 35/90] =?UTF-8?q?feat(paciente):=20registra=20o=20token=20?= =?UTF-8?q?de=20push=20do=20aparelho=20quando=20h=C3=A1=20consentimento=20?= =?UTF-8?q?(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 a495f4c..cff919f 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 cc25ba6..637b0d6 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 0000000..af32140 --- /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 d28297b..f34e4ee 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 0000000..fbbe0b0 --- /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 52bfc72..b26b607 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 36/90] 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 5697ae5..2c4d8ae 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 55146a3..95f405a 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 2b9fa2b..5c0199b 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 0a1cfe1..2d5991f 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 0887a73..1b6791e 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 c9d8170..c47821d 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 840210b..3f6b977 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 37/90] 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 0000000..7781e0f --- /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 38/90] =?UTF-8?q?fix(backend):=20registro=20de=20token=20e?= =?UTF-8?q?=20revoga=C3=A7=C3=A3o=20sob=20o=20mesmo=20lock=20por=20titular?= =?UTF-8?q?=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 95f405a..553d92b 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 8036ac6..0a699c8 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 021d69d..6f241dd 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 8466efa..49763a1 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 1b6791e..62c316a 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 54a6227..d983022 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 cb12fed..6fa05e8 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 39/90] =?UTF-8?q?docs:=20RF14=20passa=20a=20usar=20Gorush;?= =?UTF-8?q?=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 553d92b..4579591 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 af32140..67d6cc8 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 0000000..2017ff1 --- /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 0000000..51aa26c --- /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 2160d27..8b21876 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 c47821d..e361ae4 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 1e967b2..5ee4ccb 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 68908e4..17331b5 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 682a5d8..ab2c789 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 40/90] 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 e82cadb..362e16f 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 5ffb09b..5601e59 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 4e13fb4..2d14da8 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 eb5babf..432e4e6 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 a29c438..d860c28 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 0000000..43c736b --- /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 0000000..82c53c8 --- /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 41/90] =?UTF-8?q?feat(backend):=20cliente=20do=20Gorush=20?= =?UTF-8?q?com=20tempo=20limite=20e=20poda=20de=20tokens=20inv=C3=A1lidos?= =?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 --- .../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 0000000..f392053 --- /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 0000000..e19d064 --- /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 42/90] =?UTF-8?q?feat(backend):=20notices.sendSegmented=20?= =?UTF-8?q?com=20segmenta=C3=A7=C3=A3o=20SQL=20e=20consentimento=20vigente?= =?UTF-8?q?=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 0000000..c0b9844 --- /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 34837ae..5ab24a5 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 0000000..51b62c9 --- /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 cbe9a77..5c51b7e 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 0000000..3ad1443 --- /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 0000000..0cc222e --- /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 0000000..eee2855 --- /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 b8fe899..8216feb 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 0000000..e0a0c35 --- /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 d4b59e3..6fbdd92 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 20d7d2c..f2e9e71 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 0000000..efa8a9b --- /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 0000000..75271fa --- /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 0000000..1409646 --- /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 436aca9..095a154 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 e0eced2..9a5984f 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 669039e..9c27906 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 0000000..98a1519 --- /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 62c316a..3126396 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 eb0a1ff..bd20551 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 0000000..8a6885e --- /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 43/90] =?UTF-8?q?feat(acs):=20tela=20de=20envio=20de=20avi?= =?UTF-8?q?so=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 cb05315..b34c53f 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 08d7cfc..288b0aa 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 0000000..36535f5 --- /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 dfa4b0b..4cd3cd0 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 44/90] 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 cff919f..8643fa6 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 0000000..a64304b --- /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 0000000..6ace929 --- /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 67d6cc8..f39e601 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 451d2d0..7704107 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 1da8398..3035b69 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 e64c280..513214e 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 0000000..f1d3601 --- /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 45/90] =?UTF-8?q?docs:=20registra=20o=20envio=20de=20aviso?= =?UTF-8?q?s=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 4579591..ff27aed 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 5c0199b..205dcfa 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 2d5991f..e60d309 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 3f6b977..75cca5b 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 5ee4ccb..59f7a51 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 17331b5..a11d6f1 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 46/90] =?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 ff27aed..6a96b33 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 3ad1443..c1a6de8 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 f392053..02cfd20 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 e19d064..38fd5a3 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 8a6885e..9eb55f6 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 8b21876..3b066e8 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 43c736b..a48a0c5 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 82c53c8..373f4b7 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 e361ae4..c8962c9 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 a11d6f1..c13c7af 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 47/90] =?UTF-8?q?docs:=20revisa=20o=20plano=20dos=20menore?= =?UTF-8?q?s=20do=20push=20e=20do=20aviso=20de=2015=20dias=20contra=20o=20?= =?UTF-8?q?c=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 2017ff1..f6734bc 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 48/90] =?UTF-8?q?fix(backend):=20revoga=C3=A7=C3=A3o=20de?= =?UTF-8?q?=20push=20at=C3=B4mica,=20auditoria=20da=20troca=20de=20dono=20?= =?UTF-8?q?e=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 f16f2e6..7abbcb8 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 0a699c8..ef279cf 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 92e1c2d..83bc553 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 6f241dd..33bb4e9 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 095a154..3cf9343 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 3126396..2a5cb62 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 d983022..e64de72 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 6fa05e8..f484107 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 49/90] =?UTF-8?q?feat(backend):=20agenda=20e=20aviso=20de?= =?UTF-8?q?=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 0000000..bdecad6 --- /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 5ab24a5..fce48de 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 5c51b7e..4d8b9cd 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 7abbcb8..8f79886 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 0000000..c04af28 --- /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 5a03c22..fbcc23f 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 0000000..c756833 --- /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 8216feb..ef3f375 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 6fbdd92..fdfdcf0 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 f2e9e71..5a4114e 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 0000000..bc3cfef --- /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 9a5984f..0e83f67 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 bd20551..9673dec 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 e64de72..fa387c0 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 0000000..412463e --- /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 50/90] =?UTF-8?q?feat(paciente):=20cart=C3=A3o=20de=20avis?= =?UTF-8?q?o=20de=20mudan=C3=A7a=20dos=20termos=20e=20push=20sem=20quebrar?= =?UTF-8?q?=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 8643fa6..50cbc55 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 0000000..c4a5110 --- /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 637b0d6..8455d9f 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 f39e601..eec24e4 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 fbbe0b0..1e2fe8f 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 b26b607..dd506aa 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 0000000..9ab6976 --- /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 51/90] =?UTF-8?q?docs:=20registra=20os=20menores=20do=20pu?= =?UTF-8?q?sh=20fechados=20e=20o=20aviso=20de=20mudan=C3=A7a=20dos=20termo?= =?UTF-8?q?s?= 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 6a96b33..c021891 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 205dcfa..b778ac2 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 e60d309..cabe1cd 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 75cca5b..6030225 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 52/90] =?UTF-8?q?fix:=20cart=C3=A3o=20de=20aviso=20fora=20?= =?UTF-8?q?da=20aba=20de=20urg=C3=AAncia;=20regra=20dos=2015=20dias=20sem?= =?UTF-8?q?=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 50cbc55..60ff601 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 9ab6976..607130b 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 cabe1cd..12b8093 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 c04af28..5919fb8 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 412463e..fc79717 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 59f7a51..4b66f71 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 53/90] 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 c021891..0f02330 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 54/90] 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 0000000..11bdd58 --- /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 55/90] =?UTF-8?q?fix(backend):=20poda=20do=20teto=20por=20?= =?UTF-8?q?titular,=20desempates=20est=C3=A1veis=20e=20rastro=20do=20dono?= =?UTF-8?q?=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 8f79886..5a3b368 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 ef279cf..7d3eb61 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 83bc553..af30e39 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 33bb4e9..0696ada 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 2a5cb62..404e408 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 fa387c0..508a0a1 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 f484107..8da562f 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 56/90] =?UTF-8?q?fix(backend):=20cliente=20do=20Gorush=20e?= =?UTF-8?q?ncerr=C3=A1vel=20e=20tolerante,=20envio=20n=C3=A3o=20vira=20err?= =?UTF-8?q?o=20por=20falha=20de=20poda,=20LEFT=20JOIN=20e=20desempate=20(R?= =?UTF-8?q?F14)?= 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 c1a6de8..4cb3db5 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 efa8a9b..a23fc7f 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 02cfd20..30a2121 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 3cf9343..c872d36 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 98a1519..83a7867 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 38fd5a3..e0e90f8 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 9eb55f6..17f065d 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 57/90] =?UTF-8?q?feat(paciente):=20texto=20novo=20dos=20te?= =?UTF-8?q?rmos=20durante=20os=2015=20dias,=20contraste=20e=20sem=C3=A2nti?= =?UTF-8?q?ca=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 60ff601..f3a1662 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 e8bd603..09bc517 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 b150037..3087e39 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 c4a5110..64c1278 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 da28e0e..fa880fa 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 607130b..3bc5cea 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 0000000..52874d2 --- /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 58/90] 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 0f02330..1dbe395 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 b778ac2..001f4a9 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 12b8093..3f4c109 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 6030225..5b265e2 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 4b66f71..fe1aed7 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 59/90] =?UTF-8?q?fix(paciente):=20detalhe=20do=20texto=20n?= =?UTF-8?q?ovo=20n=C3=A3o=20se=20diz=20vigente;=20leitor=20da=20agenda=20n?= =?UTF-8?q?=C3=A3o=20trata=20'n=C3=A3o=20entendi'=20como=20'sem=20agenda'?= =?UTF-8?q?=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 1dbe395..580175c 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 09bc517..c847078 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 3bc5cea..9b049ab 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 52874d2..9efcc6b 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 5b265e2..e511b69 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 60/90] =?UTF-8?q?chore:=20ignora=20google-services.json=20?= =?UTF-8?q?(credencial=20do=20projeto=20Firebase,=20n=C3=A3o=20versionada)?= 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 5601e59..509f714 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 61/90] 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 0000000..3e4a2c8 --- /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 62/90] =?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 30a2121..7500171 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 0000000..4e6d385 --- /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 0000000..a9b8145 --- /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 e0e90f8..f5dd958 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 d860c28..5d1134d 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 a48a0c5..9aae40c 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 373f4b7..df92c21 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 0000000..d2ed30f --- /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 63/90] =?UTF-8?q?feat(paciente):=20lado=20nativo=20Android?= =?UTF-8?q?=20do=20token=20FCM,=20com=20o=20plugin=20do=20Google=20Service?= =?UTF-8?q?s=20s=C3=B3=20quando=20h=C3=A1=20google-services.json=20(RF14)?= 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 36e0f79..3923a78 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 33f24df..e322058 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 c21f0c5..196d24a 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 0000000..03b3202 --- /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 64/90] 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 0000000..e285013 --- /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 0000000..dfe5032 --- /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 65/90] =?UTF-8?q?test(e2e):=20aviso=20do=20ACS=20chega=20a?= =?UTF-8?q?o=20emulador=20pelo=20Gorush=20e=20FCM=20reais;=20conex=C3=A3o?= =?UTF-8?q?=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 dfe5032..d44ffb8 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 0000000..7cf6a43 --- /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 7500171..f47aa82 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 f5dd958..e181ba1 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 0000000..131d78e --- /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 66/90] =?UTF-8?q?docs:=20registra=20o=20que=20foi=20provad?= =?UTF-8?q?o=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 580175c..13a962d 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 001f4a9..483d304 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 3f4c109..7ad7b8b 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 3b066e8..6119452 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 9aae40c..f194848 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 c8962c9..4f346e2 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 c13c7af..18e3f9d 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 67/90] =?UTF-8?q?fix(paciente):=20auto-init=20do=20FCM=20d?= =?UTF-8?q?esligado=20e=20testes=20de=20push=20s=C3=B3=20com=20PUSH=5FE2E;?= =?UTF-8?q?=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 13a962d..556fe58 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 483d304..dbc205e 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 e322058..71e8fbb 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 0000000..53151b5 --- /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 131d78e..001cfff 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 fe1aed7..89ae3b4 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 68/90] =?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 556fe58..f6ad868 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 dbc205e..b67f08c 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 f3a1662..1e6a74e 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 eec24e4..8830fd7 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 f34e4ee..d9d79df 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 1e2fe8f..fda382f 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 f1d3601..fdfcdec 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 dd506aa..8bdff3c 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 0000000..d3e76bd --- /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 89ae3b4..5416d8e 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 69/90] 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 f6ad868..6fd8ff7 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 70/90] =?UTF-8?q?fix(paciente,backend):=20consulta=20leve?= =?UTF-8?q?=20de=20consentimento=20(patients.hasGrantedConsent)=20no=20lug?= =?UTF-8?q?ar=20de=20myData=20no=20login;=20achados=20da=20revis=C3=A3o=20?= =?UTF-8?q?independente=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 6fd8ff7..f1c6780 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 b67f08c..5f429fa 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 1e6a74e..adb8974 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 8455d9f..4553b65 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 8830fd7..44b6d71 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 c375582..8b91837 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 d9d79df..f04622e 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 fda382f..c9dcb51 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 8bdff3c..d5df71e 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 7ad7b8b..ddb026e 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 fce48de..05ccb10 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 5a3b368..01cf900 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 fbcc23f..c58b962 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 ef3f375..9043ab8 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 5a4114e..6c74db1 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 0e83f67..4aa8260 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 9673dec..1d36169 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 508a0a1..dfba96a 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 5416d8e..0856bd7 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 71/90] 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 509f714..852af3c 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 0000000..98e53f1 --- /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 0000000..12b68e8 --- /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 72/90] =?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 0000000..06aa317 --- /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 0000000..c2ea41b --- /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 0000000..17c4502 --- /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 73/90] =?UTF-8?q?test(e2e):=20rel=C3=A9=20do=20c=C3=B3digo?= =?UTF-8?q?=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 0000000..75a23b9 --- /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 0000000..790c07c --- /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 74/90] =?UTF-8?q?test(paciente):=20integra=C3=A7=C3=A3o=20?= =?UTF-8?q?entra=20pelo=20OTP=20real=20com=20fixtures;=20login=20de=20dese?= =?UTF-8?q?nvolvimento=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 a863089..d2d692d 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 ba253ee..798058b 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 edee8c8..02ee968 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 0000000..824d766 --- /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 0000000..7dd1660 --- /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 0000000..556ee5d --- /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 0000000..bcacb10 --- /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 69d090f..8e46a88 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 7cf6a43..1945c2f 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 0000000..0534b40 --- /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 12b68e8..0592629 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 75/90] =?UTF-8?q?test(paciente):=20jornada=20completa=20pe?= =?UTF-8?q?la=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 0000000..f39906e --- /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 798058b..1ba3a20 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 824d766..1a6fc85 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 c2ea41b..00b6d31 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 17c4502..e6e11f5 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 76/90] =?UTF-8?q?test(e2e):=20push=20do=20ACS=20institucio?= =?UTF-8?q?nal=20at=C3=A9=20o=20emulador=20contra=20o=20banco=20de=20teste?= =?UTF-8?q?,=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 0000000..c69f621 --- /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 e285013..c992e63 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 0000000..1e740cb --- /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 1945c2f..6dbfc5c 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 0000000..e1ea7a6 --- /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 001cfff..4d1825b 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 77/90] =?UTF-8?q?test(e2e):=20runner=20=C3=BAnico=20do=20t?= =?UTF-8?q?este=20completo=20do=20paciente,=20espera=20ativa=20da=20bandej?= =?UTF-8?q?a=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 f1c6780..edd1f7b 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 5f429fa..92a17c4 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 ddb026e..63a6b75 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 0000000..11ae439 --- /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 4d1825b..3c1cc9b 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 78/90] =?UTF-8?q?test(e2e):=20achados=20da=20revis=C3=A3o?= =?UTF-8?q?=20independente=20=E2=80=94=20senha=20do=20ACS=20fora=20do=20ar?= =?UTF-8?q?gv,=20rel=C3=B3gio=20do=20host=20no=20OTP,=20avisos=20do=20down?= =?UTF-8?q?=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 edd1f7b..16426d8 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 92a17c4..49bb675 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 f39906e..5deef61 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 7dd1660..ce07304 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 556ee5d..fd5426c 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 bcacb10..859d5db 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 0000000..4f4e00a --- /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 0592629..1443392 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 75a23b9..6aa363c 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 e1ea7a6..8cd9381 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 3c1cc9b..8d5e0ec 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 79/90] =?UTF-8?q?test(e2e):=20fecha=20os=20Minors=20da=20r?= =?UTF-8?q?evis=C3=A3o=20=E2=80=94=20Host=20do=20rel=C3=A9,=20guarda=20do?= =?UTF-8?q?=20seeder,=20manifesto=20privado,=20contagem=20de=20OTP=20e=20s?= =?UTF-8?q?cripts=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 852af3c..d55112d 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 16426d8..077f328 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 5deef61..3703e13 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 ce07304..bdbd3cf 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 859d5db..2b9abed 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 06aa317..53382e4 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 00b6d31..36a6a05 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 e6e11f5..a771332 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 1443392..1f17acb 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 6aa363c..405b00a 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 790c07c..5608c8f 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 8d5e0ec..2a6d4fd 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 0856bd7..f1cd472 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 80/90] 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 0000000..28261f6 --- /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 0000000..04a0a7e --- /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 81/90] =?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 0000000..53f30f1 --- /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 0000000..ad02ef9 --- /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 597891a..75e5e73 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 82/90] 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 9e3fb82..a002081 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 0000000..f254a19 --- /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 83/90] 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 3787b7b..9265ce8 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 84/90] 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 2c4d8ae..8db3db6 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 077f328..eab5a33 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 f194848..12fa496 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 85/90] =?UTF-8?q?ci:=20Gorush=20roda=20com=20o=20uid=20do?= =?UTF-8?q?=20dono=20da=20chave=200600=20e=20as=20credenciais=20s=C3=B3=20?= =?UTF-8?q?existem=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 9265ce8..266f312 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 eab5a33..a295765 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 5d1134d..2209934 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 12fa496..dbdc3a4 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 a002081..3d2b4df 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 f254a19..5aa346c 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 53f30f1..00add86 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 ad02ef9..c12c2c9 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 86/90] =?UTF-8?q?ci:=20erro=20de=20base64=20inv=C3=A1lido?= =?UTF-8?q?=20traz=20diagn=C3=B3stico=20sem=20valores=20(tamanho,=20alfabe?= =?UTF-8?q?to,=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 28261f6..b7da493 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 04a0a7e..aaf5746 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 87/90] 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 a295765..bddb49a 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 0000000..913fba0 --- /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 88/90] =?UTF-8?q?ci:=20decode=5Fsecret=5Ffile=20endurecido?= =?UTF-8?q?=20=E2=80=94=20set=20+x,=20nome=20de=20vari=C3=A1vel=20validado?= =?UTF-8?q?,=20cleanup=20normaliza=20o=20caminho=20e=20o=20tempor=C3=A1rio?= =?UTF-8?q?=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 b7da493..1693dee 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 aaf5746..7c3c431 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 89/90] 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 3d2b4df..763b9cf 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 5aa346c..97daec6 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 90/90] =?UTF-8?q?ci:=20fecha=20os=2010=20Minors=20da=20rev?= =?UTF-8?q?is=C3=A3o=20do=20CI=20do=20FCM=20(trap=20down,=20cabe=C3=A7alho?= =?UTF-8?q?=20e=20project=5Fid=20do=20push=5Fe2e,=20grupo=20de=20processos?= =?UTF-8?q?)?= 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 bddb49a..dfaa7ff 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 c12c2c9..ac69090 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 2a6d4fd..da3ddad 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 75e5e73..0832ab4 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