Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Two core flows:

A third app, `apps/admin`, is the backoffice (Indicadores, Microáreas, Alertas, Auditoria, Pedidos do titular), reading real data from the backend since #40/#41, managing accounts since #43 (ACS create/link/activate/deactivate, password and MFA reset, staff MFA reset, audit scoped to the coordinator's UBS) and handling LGPD data-subject requests since #42 (a coordinator/admin starts review, completes or rejects them). Since #44 the Alertas and Indicadores screens refresh by a 30 s polling pulse and a coordinator/admin can escalate a red alert unacknowledged past `RED_ALERT_ESCALATION_SECONDS` (default 300 s; the alert stays `pending`, the ACS gets a reinforcement; the emulator e2e passed end to end on 2026-10-12). Since #45 the Auditoria screen filters and pages the trail, shows a per-row seal and a global chain verification computed by the server (with a tail checkpoint), and the admin (not the coordinator) can export a nine-column CSV with the export itself recorded in the same transaction; the emulator e2e, including a deliberate tampering detected on screen and by `audit_chain_check`, passed on 2026-10-13/14. Since #46 the backoffice has an Avisos tab (schedule, cancel and history of community notices; a coordinator only for his UBS) and, for the admin only, a Termos sub-tab (draft, legal review, publication of terms versions; publication is blocked until the legal review is recorded and requires an effective date at least 15 days ahead). Proved: backend unit/integration tests, widget tests, a hermetic emulator test of the tab, and a push e2e in which a scheduled notice reaches the emulator through Gorush and FCM; the legal review of the real terms text and the manual switch of the in-force version remain outside the code (`PROGRESS.md`, section #46).

Main RPC endpoints (`backend/sinalacs_server/lib/src/endpoints/`): `auth` (`loginInstitutional`, `requestOtp`, `verifyOtp`, `developmentLogin` — the last only with `ENABLE_DEV_LOGIN`), `onboarding`, `triage.evaluate`, `alerts` (`createRedAlert`, `acknowledge`, `statusFor`), `patients` (`listMicroArea`, `myData`, chronic conditions, `updateConsent`, `requestDataDeletion`, `requestDataCorrection`), `visits` (`sync` e `pull` com a sessão do ACS; `syncLegacy`, que usa a sessão só como transporte das visitas legadas sem dono; `syncDeferred` e `revokeUploadToken`, autenticados pelo token de envio diferido do dono, não pelo JWT), `admin` (`indicators`, `microAreas`, `alerts` e `auditLogs` — leitura do backoffice escopada por papel/UBS desde #40; desde a #43 a gestão de contas: `acs`/`staff`, `createAcs`, `setAcsMicroArea`, `setAcsActive`, `resetAcsPassword`, `resetAcsMfa` e `resetStaffMfa`; desde a #44 `escalateAlert`, `alertsPulse` e `endMonitoringSession`; desde a #46 `scheduleNotice`, `cancelScheduledNotice`, `noticeHistory`, `termsVersions`, `draftTermsVersion`, `markTermsReviewed` e `publishTermsVersion` — detalhe em `backend/CLAUDE.md`), `health.check`.
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`, `resolve`, `statusFor`), `patients` (`listMicroArea`, `myData`, chronic conditions, `updateConsent`, `requestDataDeletion`, `requestDataCorrection`), `visits` (`sync` e `pull` com a sessão do ACS; `syncLegacy`, que usa a sessão só como transporte das visitas legadas sem dono; `syncDeferred` e `revokeUploadToken`, autenticados pelo token de envio diferido do dono, não pelo JWT), `admin` (`indicators`, `microAreas`, `alerts` e `auditLogs` — leitura do backoffice escopada por papel/UBS desde #40; desde a #43 a gestão de contas: `acs`/`staff`, `createAcs`, `setAcsMicroArea`, `setAcsActive`, `resetAcsPassword`, `resetAcsMfa` e `resetStaffMfa`; desde a #44 `escalateAlert`, `alertsPulse` e `endMonitoringSession`; desde a #46 `scheduleNotice`, `cancelScheduledNotice`, `noticeHistory`, `termsVersions`, `draftTermsVersion`, `markTermsReviewed` e `publishTermsVersion` — detalhe em `backend/CLAUDE.md`), `health.check`.

`PROGRESS.md` holds milestone status and the open items with owners. `spec/validation_report.md` is stale in both directions (items it lists as open are closed, and its test counts are far below the real ones) — verify against the code before trusting it.

Expand Down
8 changes: 7 additions & 1 deletion PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1652,7 +1652,7 @@ Plano: `docs/superpowers/plans/2026-10-14-issue-46-avisos-e-termos.md`; achados

Relatório item a item de `spec/PRD_system.md` contra o código, com evidência medida a partir de `develop` em `92106d1`:
[docs/auditoria-prd/2026-10-15-lacunas-prd.md](docs/auditoria-prd/2026-10-15-lacunas-prd.md).
Contagem dos 64 IDs do PRD: 24 entregue, 27 parcial, 4 ausente, 9 externo (mais 57 linhas sem ID próprio, para SLOs, STRIDE, criptografia, acessibilidade, telemetria, métricas do §1.3 e módulos dos apps).
Contagem dos 64 IDs do PRD: 26 entregue, 25 parcial, 4 ausente, 9 externo (mais 57 linhas sem ID próprio, para SLOs, STRIDE, criptografia, acessibilidade, telemetria, métricas do §1.3 e módulos dos apps).
Os três P0 do backlog: colunas de risco em texto plano (INV-04), o estado "Atendido" que o paciente nunca vê (RF05) e o SMS real do OTP (RF01).
Backlog priorizado na seção final do relatório; conferência por `python3 scripts/qa/cobertura_prd.py --final`.

Expand All @@ -1662,3 +1662,9 @@ Backlog priorizado na seção final do relatório; conferência por `python3 scr
- Decisão: `alerts.riskLevel` permanece legível (só `red` é gravado; chave dos filtros SQL da INV-03). Registrada no PRD (INV-04) e em `spec/lgpd_data_audit.md` §2.3; amarrada pelo teste `INV-04 (#75)` de `test/unit/red_alert_discard_paths_test.dart`.
- Provas: `test/unit/encrypted_risk_level_test.dart`; testes `#75` em `test/integration/health_data_encryption_test.dart` (linha bruta por `row_to_json` sem risco legível, ida e volta, `After` nulo e conclusão, linha antiga fora do `pull` e `rejected` só para ela no `sync`) e em `test/integration/patient_data_overview_endpoint_test.dart`.
- Aberto: migrações sem backfill (volume antigo precisa ser recriado); rotação de chave continua inexistente (`keyVersion` só marcador), como nas demais colunas cifradas.

## Estado "Atendido" do alerta (#76) — 2026-10-16

- Entregue: `alerts.resolve` (só ACS do território, só de `acknowledged`, idempotente, auditado na mesma transação: `resolved`, `already_resolved`, `denied_not_acknowledged`); coluna `alerts.resolvedAt` (migração aditiva); o ACK trava a linha e não rebaixa `resolved`; `alerts.statusFor` devolve `resolvedAt`; o pulso do backoffice anda com a conclusão. ACS: botão "Concluir atendimento" no cartão confirmado, com diálogo. Paciente: "Atendido pela equipe de saúde" e a hora da conclusão.
- Provas: `test/unit/red_alert_service_test.dart` (grupo `resolve (#76)`), `test/integration/red_alert_cycle_test.dart` (grupo `concluir o atendimento (#76)`), `test/integration/admin_monitoring_store_test.dart`, `apps/acs/test/alert_resolve_test.dart`, `apps/patient/test/patient_app_mvp_test.dart`.
- Aberto: sem prova no emulador (o e2e do paciente não tem ACS concluindo); o contador "reconhecidos" do backoffice (`acknowledgedRedAlerts`) conta só `acknowledged`, então um alerta concluído sai dele (o TMRAV não muda); a conclusão vive só na fila em memória do ACS, como o ACK.
3 changes: 3 additions & 0 deletions apps/acs/integration_test/full_journey_e2e.dart
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,9 @@ class _EnvioDeVisitasForaDoAr implements AcsBackend {
@override
Future<api.AlertAckResult> acknowledge({required String alertId}) => _inner.acknowledge(alertId: alertId);
@override
Future<api.AlertResolveResult> resolveAlert({required String alertId}) =>
_inner.resolveAlert(alertId: alertId);
@override
Future<List<api.VisitSyncResult>> syncVisits(List<api.VisitSyncEntry> visits, {String? expectedUserId}) =>
_rede(() => _inner.syncVisits(visits, expectedUserId: expectedUserId));
@override
Expand Down
101 changes: 90 additions & 11 deletions apps/acs/lib/app/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1625,6 +1625,58 @@ class _AcsHomeShellState extends State<AcsHomeShell> with WidgetsBindingObserver
}
}

/// Alertas com a conclusão em voo. O diálogo modal já impede dois toques;
/// este conjunto impede uma segunda conclusão do mesmo alerta enquanto a
/// chamada ao servidor não volta (o servidor também é idempotente).
final Set<String> _concluindo = <String>{};

/// "Concluir atendimento" (#76): confirma com o ACS, porque o paciente passa
/// a ver "Atendido" e o servidor não volta atrás.
Future<void> _resolve(PrioritizedAlert alert) async {
if (_concluindo.contains(alert.alertId)) return;
final messenger = ScaffoldMessenger.maybeOf(context);
void aviso(String texto) => messenger
?..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(texto)));
final confirmou = await showDialog<bool>(
context: context,
builder: (dialog) => AlertDialog(
title: const Text('Concluir o atendimento?'),
content: const Text(
'O paciente verá "Atendido" no aplicativo. Conclua só depois de atender o paciente.',
),
actions: [
TextButton(
key: const Key('cancelar_conclusao'),
onPressed: () => Navigator.of(dialog).pop(false),
style: TextButton.styleFrom(minimumSize: const Size(48, 52)),
child: const Text('Cancelar'),
),
FilledButton(
key: const Key('confirmar_conclusao'),
onPressed: () => Navigator.of(dialog).pop(true),
style: FilledButton.styleFrom(minimumSize: const Size(48, 52)),
child: const Text('Concluir'),
),
],
),
);
if (confirmou != true || !mounted) return;
_concluindo.add(alert.alertId);
try {
final result = await BackendScope.of(context).resolveAlert(alertId: alert.alertId);
if (result.resolved && mounted) _queue.markResolved(alert.alertId);
aviso(result.resolved
? 'Atendimento concluído. O paciente já vê "Atendido".'
: 'A central não reconheceu este alerta.');
} on BackendFailure catch (failure) {
// Nunca engolir: o alerta segue confirmado e por concluir.
aviso(failure.message);
} finally {
_concluindo.remove(alert.alertId);
}
}

bool _saindo = false;
bool _limpando = false;

Expand Down Expand Up @@ -1964,6 +2016,7 @@ class _AcsHomeShellState extends State<AcsHomeShell> with WidgetsBindingObserver
onRetryFeed: _feedErrorIsTransient ? _retryFeedNow : null,
storageError: _storageNotice,
onAcknowledge: _acknowledge,
onResolve: _resolve,
onEscalate: (alert) => setState(() { _selected = alert; destination = AcsDestination.escalation; }),
onVisit: (alert) => setState(() { _selected = alert; destination = AcsDestination.visit; }),
),
Expand Down Expand Up @@ -2169,6 +2222,7 @@ class DashboardScreen extends StatelessWidget {
this.onRetryFeed,
this.storageError,
this.onAcknowledge,
this.onResolve,
this.onEscalate,
this.onVisit,
});
Expand All @@ -2189,6 +2243,7 @@ class DashboardScreen extends StatelessWidget {
/// descobre sozinho — que as visitas do dia não estão sendo salvas.
final InfraNotice? storageError;
final void Function(PrioritizedAlert alert)? onAcknowledge;
final void Function(PrioritizedAlert alert)? onResolve;
final void Function(PrioritizedAlert alert)? onEscalate;
final void Function(PrioritizedAlert alert)? onVisit;

Expand Down Expand Up @@ -2229,6 +2284,7 @@ class DashboardScreen extends StatelessWidget {
child: _AlertCard(
alert: alert,
onAcknowledge: onAcknowledge,
onResolve: onResolve,
onEscalate: onEscalate,
onVisit: onVisit,
),
Expand Down Expand Up @@ -2282,10 +2338,11 @@ class _InfraBanner extends StatelessWidget {
}

class _AlertCard extends StatelessWidget {
const _AlertCard({required this.alert, this.onAcknowledge, this.onEscalate, this.onVisit});
const _AlertCard({required this.alert, this.onAcknowledge, this.onEscalate, this.onVisit, this.onResolve});

final PrioritizedAlert alert;
final void Function(PrioritizedAlert alert)? onAcknowledge;
final void Function(PrioritizedAlert alert)? onResolve;
final void Function(PrioritizedAlert alert)? onEscalate;
final void Function(PrioritizedAlert alert)? onVisit;

Expand Down Expand Up @@ -2326,7 +2383,8 @@ class _AlertCard extends StatelessWidget {
container: true,
label: 'Paciente ${alert.patientId.substring(0, 8)}, $label'
'${alert.escalated ? ', reforço da coordenação' : ''}'
'${alert.acknowledged ? ', recebimento confirmado' : ''}, '
'${alert.acknowledged ? ', recebimento confirmado' : ''}'
'${alert.resolved ? ', atendimento concluído' : ''}, '
'recebido às ${_time(alert.triggeredAt)}',
excludeSemantics: true,
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Expand All @@ -2349,7 +2407,16 @@ class _AlertCard extends StatelessWidget {
),
const SizedBox(height: 6),
Text('Recebido às ${_time(alert.triggeredAt)} • local ${alert.locationHash}'),
if (alert.acknowledged) Padding(
if (alert.resolved) Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(children: [
Icon(Icons.task_alt, size: 16, color: context.acsRisk.greenOnSurface),
const SizedBox(width: 6),
Text('Atendimento concluído',
key: Key('resolved_${alert.alertId}'),
style: TextStyle(color: context.acsRisk.greenOnSurface)),
]),
) else if (alert.acknowledged) Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(children: [
Icon(Icons.check_circle, size: 16, color: context.acsRisk.greenOnSurface),
Expand All @@ -2369,17 +2436,29 @@ class _AlertCard extends StatelessWidget {
child: const Text('Confirmar recebimento'),
),
),
const SizedBox(height: 8),
SizedBox(
if (alert.acknowledged && !alert.resolved) SizedBox(
width: double.infinity,
child: OutlinedButton(
onPressed: isRed
? (onEscalate == null ? null : () => onEscalate!(alert))
: (onVisit == null ? null : () => onVisit!(alert)),
style: OutlinedButton.styleFrom(minimumSize: const Size(48, 52)),
child: Text(isRed ? 'Acionar SAMU / Atender' : 'Iniciar rota de visita'),
child: FilledButton.icon(
key: Key('resolve_${alert.alertId}'),
onPressed: onResolve == null ? null : () => onResolve!(alert),
style: FilledButton.styleFrom(minimumSize: const Size(48, 52)),
icon: const Icon(Icons.task_alt),
label: const Text('Concluir atendimento'),
),
),
if (!alert.resolved) ...[
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: OutlinedButton(
onPressed: isRed
? (onEscalate == null ? null : () => onEscalate!(alert))
: (onVisit == null ? null : () => onVisit!(alert)),
style: OutlinedButton.styleFrom(minimumSize: const Size(48, 52)),
child: Text(isRed ? 'Acionar SAMU / Atender' : 'Iniciar rota de visita'),
),
),
],
]),
),
);
Expand Down
17 changes: 17 additions & 0 deletions apps/acs/lib/core/network/backend_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,10 @@ abstract class AcsBackend {

Future<AlertAckResult> acknowledge({required String alertId});

/// Conclui o atendimento de um alerta já confirmado (#76): o paciente passa
/// a ver "Atendido".
Future<AlertResolveResult> resolveAlert({required String alertId});

/// [expectedUserId], quando presente, é o dono do lote: se a sessão que vai
/// assinar a chamada — conferida DEPOIS de o token ser resolvido (talvez por
/// renovação) e imediatamente antes da rede — não é dele, lança
Expand Down Expand Up @@ -249,6 +253,8 @@ class MisconfiguredBackend implements AcsBackend {

@override
Future<AlertAckResult> acknowledge({required String alertId}) async => _recusar();
@override
Future<AlertResolveResult> resolveAlert({required String alertId}) async => _recusar();

@override
Future<List<VisitSyncResult>> syncVisits(List<VisitSyncEntry> visits, {String? expectedUserId}) async =>
Expand Down Expand Up @@ -722,6 +728,17 @@ class BackendClient implements AcsBackend {
);
}

/// Conclui o atendimento (#76). Alerta inexistente ou de outra microárea
/// volta `resolved: false`; alerta ainda não confirmado é recusado pelo
/// servidor com texto pronto, que o `_guard` repassa.
@override
Future<AlertResolveResult> resolveAlert({required String alertId}) async {
final token = await _requireToken();
return _guard(
() => _client.alerts.resolve(accessToken: token, alertId: alertId),
);
}

/// Envia as visitas registradas offline.
///
/// O lote inteiro roda em uma transação no servidor, e cada visita volta com
Expand Down
21 changes: 20 additions & 1 deletion apps/acs/lib/core/services/alert_queue.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class PrioritizedAlert {
required this.triggeredAt,
this.acknowledged = false,
this.escalated = false,
this.resolved = false,
});

factory PrioritizedAlert.fromMqtt(ReceivedMqttAlert alert) {
Expand Down Expand Up @@ -49,7 +50,10 @@ class PrioritizedAlert {
/// gravado no aparelho nem carrega quem escalonou.
final bool escalated;

PrioritizedAlert copyWith({bool? acknowledged, bool? escalated}) => PrioritizedAlert(
/// O ACS concluiu o atendimento (#76). Implica [acknowledged].
final bool resolved;

PrioritizedAlert copyWith({bool? acknowledged, bool? escalated, bool? resolved}) => PrioritizedAlert(
alertId: alertId,
patientId: patientId,
microAreaId: microAreaId,
Expand All @@ -59,6 +63,7 @@ class PrioritizedAlert {
triggeredAt: triggeredAt,
acknowledged: acknowledged ?? this.acknowledged,
escalated: escalated ?? this.escalated,
resolved: resolved ?? this.resolved,
);

/// Peso da ordenação. Menor vem primeiro.
Expand Down Expand Up @@ -147,10 +152,24 @@ class AlertQueue extends ChangeNotifier {
return true;
}

/// Marca o atendimento como concluído (e, por consequência, confirmado).
/// Devolve `false` se o alerta não está na fila.
bool markResolved(String alertId) {
final index = _alerts.indexWhere((item) => item.alertId == alertId);
if (index < 0) return false;

_alerts[index] = _alerts[index].copyWith(acknowledged: true, resolved: true);
_sort();
notifyListeners();
return true;
}

void _sort() {
_alerts.sort((a, b) {
// Confirmados descem, para não competir com quem ainda espera.
if (a.acknowledged != b.acknowledged) return a.acknowledged ? 1 : -1;
// Concluídos descem abaixo dos confirmados: já não pedem ação.
if (a.resolved != b.resolved) return a.resolved ? 1 : -1;
final byRisk = a._riskRank.compareTo(b._riskRank);
if (byRisk != 0) return byRisk;
// Reforço da coordenação vem à frente dos demais do mesmo risco.
Expand Down
Loading
Loading