Skip to content
Merged
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`, `resolve`, `statusFor`), `patients` (`listMicroArea`, `myData`, chronic conditions, `updateConsent`, `requestDataDeletion`, `requestDataCorrection`), `visits` (`sync`, `pull` e `resolveConflict` (#79) 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`, `openForMicroArea` — a reserva por HTTP do ACS quando o MQTT cai, #80), `patients` (`listMicroArea`, `myData`, chronic conditions, `updateConsent`, `requestDataDeletion`, `requestDataCorrection`), `visits` (`sync`, `pull` e `resolveConflict` (#79) 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
12 changes: 12 additions & 0 deletions PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1683,3 +1683,15 @@ Backlog priorizado na seção final do relatório; conferência por `python3 scr
- Quando "Limpar este aparelho" é bloqueado por um conflito, a mensagem usa a frase genérica "precisa de revisão pelo ACS dono", sem dizer "conflito".
- Screenshots da tela de conflito.
- O conflito só aparece na tela de registro de visita (sem selo no painel), como as recusadas.

## Reserva por HTTP dos alertas do ACS (#80) — 2026-10-17

- Entregue: `alerts.openForMicroArea` (só ACS com microárea, microárea do token) devolve os alertas `pending`/`escalated` e depois os `acknowledged` da microárea, do mais antigo ao mais novo, teto 200 (`RedAlertService.openAlertsLimit`), com `escalated` derivado de `escalatedAt` ou do status legado. App ACS: `AcsBackend.openAlerts`, `AlertHttpFallback` e `AlertQueue.mergeFromServer`; o `AcsHomeShell` consulta ao abrir o painel e ao voltar ao primeiro plano e, a cada 30 s, só enquanto o broker está fora e o app em primeiro plano; linha `alert_fallback_status` no painel. Sem migração.
- Decisões: D1 os confirmados voltam (o painel os mantém até a conclusão; reabrir o app depois de "Sair" os reconstrói), concluídos não; D2 sem linha de auditoria de leitura por consulta (repete a cada 30 s; mesmo conteúdo do MQTT, que não é auditado; ACK e conclusão seguem auditados — o e2e conferiu 0 `read` de `alert`); D3 consulta periódica só com o broker fora e em primeiro plano, intervalo fixo sem backoff; D4 o chip "Sem central" fica como está, quem explica a reserva é a linha de status; D5 nenhum alerta gravado no aparelho (a mitigação do PRD cita `sqflite`; a reserva reconstrói a fila a partir do servidor, sem dado de saúde a mais no SQLCipher).
- Prova: `./scripts/qa/acs_alert_fallback_e2e.sh` no `emulator-5554` contra o banco de teste, 2026-10-17: broker fora do alcance do aparelho, alerta inserido por SQL sem linha no `alert_outbox`, intervalo de produção de 30 s; o cartão apareceu sem toque em 29 633 ms, foi confirmado pela tela e seguiu confirmado e único depois de mais um tique; no banco, `acknowledged` com `acknowledgedAt`, 1 linha `acknowledged`, 0 leituras de alerta auditadas, 0 linhas no outbox. Log em `docs/superpowers/plans/2026-10-17-issue-80-e2e.log`. Testes: `test/unit/red_alert_service_test.dart` (grupo `openForAcs (#80)`), `test/integration/acs_open_alerts_test.dart` (8 casos), `apps/acs/test/alert_queue_merge_test.dart` (9), `alert_http_fallback_test.dart` (2), `alert_http_fallback_shell_test.dart` (9).
- Aberto:
- Sem consulta com o app em segundo plano (WorkManager fora de `spec/stack.md`): broker fora + app no bolso = o alerta aparece quando o ACS volta ao app.
- O app segue sem som, vibração ou notificação do SO para alerta novo, venha por MQTT ou pela reserva.
- O teto de 200 não pagina: numa microárea com mais de 200 abertos, os mais ANTIGOS de cada grupo ficam de fora (não confirmados primeiro; o corte nunca tira o pendente mais novo).
- Um alerta concluído em outro aparelho do mesmo ACS some da resposta (o servidor não devolve `resolved`) mas o cartão local não muda, porque a fusão nunca remove: segue contando até sair do app.
- Revisão final (2026-10-17), corrigido com teste que falhou antes: (1) com `autoReconnect`, a queda do broker DEPOIS da primeira conexão não chamava `onDisconnected` no `mqtt_client` 10.5.1 — o painel seguia "em linha" e a reserva nunca ligava; `MqttSecureClient` agora trata `onAutoReconnect` como queda e usa `disconnectOnNoResponsePeriod = 10` para a conexão meio aberta do 3G (`test/mqtt_drop_after_connect_test.dart`, broker falso em TCP real); (2) o teto de 200 cortava o pendente mais NOVO — agora cada grupo é lido do mais novo para trás (`acs_open_alerts_test.dart`); (3) ao armar, a reserva consulta na hora e zera o status anterior, em vez de esperar 30 s mostrando a hora de quando o MQTT funcionava (`alert_http_fallback_shell_test.dart`).
2 changes: 2 additions & 0 deletions apps/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ The ACS login is institutional now (RF07): `_enter` in `apps/acs/lib/app/app.dar

**ACS: conflito de visita (#79, 2026-10-16).** O conflito sai da retentativa (D3): `VisitSyncResult.serverVisit` traz a cópia do servidor, `OfflineVisitQueue` guarda a visita em `_conflicts` com `conflictServerVersion`/`conflictServer` (schema v8, só `ADD COLUMN`), `resolveKeepLocal` (também usado por "Editar e enviar", com `outcome`/`notes`) chama `visits.resolveConflict` e `resolveKeepServer` só apaga a cópia local. Tela `resolve_conflicts` (chaves `conflict_*`). Detalhe e provas: `PROGRESS.md`, seção "Resolução de conflito de visita (#79)".

**ACS: reserva por HTTP dos alertas (#80, 2026-10-17).** `AlertHttpFallback` (`core/services/alert_http_fallback.dart`, sem `Timer`) chama `AcsBackend.openAlerts` (`alerts.openForMicroArea`) e funde em `AlertQueue.mergeFromServer`, que nunca remove cartão, ignora outra microárea, nunca desfaz ACK, conclusão ou reforço (só os ganha: um ACK feito em outro aparelho promove o cartão) e só notifica quando algo mudou. Queda do broker depois de conectar: com `autoReconnect` o `mqtt_client` 10.5.1 não chama `onDisconnected`, só `onAutoReconnect`, por isso `MqttSecureClient` trata os dois como queda (`test/mqtt_drop_after_connect_test.dart`) e usa `disconnectOnNoResponsePeriod = 10` contra a conexão meio aberta. O `AcsHomeShell` consulta sempre ao abrir o painel, ao voltar ao primeiro plano e no instante em que a reserva arma, e a cada `AcsHomeShell.defaultAlertFallbackInterval` (30 s; `alertFallbackInterval` nos testes) **só** enquanto o broker está desconectado e o app em primeiro plano; uma consulta por vez, e `close()` descarta a resposta que chega depois do `dispose`. Com a reserva ligada o painel mostra `Key('alert_fallback_status')` (`liveRegion`): "Recebendo alertas pelo servidor a cada 30 s. Última consulta às HH:MM." ou "…também falhou…"; o chip do cabeçalho continua "Sem central". Sessão vencida na consulta segue o `onSessionExpired` de sempre. Prova: `scripts/qa/acs_alert_fallback_e2e.sh` (broker fora do alcance do aparelho, alerta inserido por SQL sem `alert_outbox`, intervalo de produção; estrutura guardada por `acs_alert_fallback_e2e_test.sh`). Sem consulta em segundo plano e sem alertas gravados no aparelho.

**ACS: fila de visitas por dono, envio diferido e "Limpar este aparelho" (2026-10-04).** Plano `docs/superpowers/plans/2026-10-03-fila-de-visitas-por-dono-e-corrida-do-login.md` (D1–D9). `offline_visits` tem a coluna `owner` (schema v7, migração aditiva): `VisitStorage.forOwner(userId)` só lê, conta, grava e apaga `WHERE owner = ?` (o `save()` de B nunca toca nas linhas de A; dono vazio é `ArgumentError`; `localId` já existente em outro dono é `VisitLocalIdConflict`, unicidade global). Linhas do schema v6 ficam em **quarentena** (`owner IS NULL`): nenhum dono as vê; só `VisitStorage.legacy` (`LegacyVisitStore`) as lê e remove por `localId`. O cursor do pull é por `userId|microAreaId`; fila e pull são resolvidos por sessão (`visit_queue_factory.dart`, `visit_pull_service_factory.dart`) e recriados por dono, e `BackendVisitSynchronizer` recusa sessão ou token de outro dono (checagem no push e `expectedUserId` conferido depois de resolvido o token). Os testes que injetam `visitQueue`/`visitPullService` seguem como *seam*. `BackendClient._exclusive` (seção crítica serial, **não reentrante**: dentro dela só E/S local do Keystore e atribuições, nunca rede, `renewSession()` ou outra `_exclusive`) cobre login, `developmentLogin`, a leitura do token e a confirmação da renovação e o logout, e fecha a corrida em que uma renovação do usuário anterior sobrescrevia o login novo. `DeferredFlushService` (`core/services/deferred_flush_service.dart`) sobe o que não é do dono da sessão atual: o legado pela sessão atual como transporte (`syncLegacy`, "autoria desconhecida"), cada dono com token por `syncDeferred`; lotes de 100 (`deferredBatchSize`); a visita só sai do aparelho em `synced`; o token de envio é revogado no servidor e apagado do Keystore só com a lista do dono vazia; `flushAll()` é single-flight e não lança; o dono da sessão atual é pulado (a fila é do painel, por isso o "Sair" sincroniza a fila do painel **antes** de `flushAll`). `UploadTokenStore` (`core/security/secure_upload_token_store.dart`) guarda um token por dono no Keystore (`acs_upload_token|<userId>`) e um índice de donos; o envio também tenta o dono de toda linha no disco, então o índice é só otimização. Gatilhos em `app.dart`: painel aberto, retomada do app e conexão que volta (`connectivityChanges`, `connectivity_plus`). Rejeição "versão diferente" (`legacyVersionMismatchMessage`) não é erro de território: a visita fica em quarentena e pede revisão. "Limpar este aparelho" congela a área, espera o refresh em voo e chama `VisitDatabase.wipeAllData`, que numa transação recusa (`WipeBlocked`) se houver **qualquer** linha em `offline_visits` — recusada, legado ou de outro dono — ou fila só em RAM; sem linhas, apaga `offline_visits`, `sync_cursor` e o cache da microárea (arquivo e chave do Keystore ficam). Não há wipe forçado por supervisor (não existe login de supervisor) nem envio em segundo plano do SO (WorkManager fora de `spec/stack.md`); um token de envio vencido (7 dias) ou recusado deixa a fila do dono presa e bloqueia o wipe. Provas e limites no emulador: `PROGRESS.md`, seção "Fila de visitas por dono e corrida do login" (o offline do `acs_full_e2e.sh` é **simulado** por um decorator de `AcsBackend`, e o gatilho de conectividade vem de um `StreamController`).

The patient login is passwordless now (RF01), and no longer uses `developmentLogin` either: the app sends the CPF and the birth date to `auth.requestOtp`, then the 6-digit code to `auth.verifyOtp`, and only that second call issues the session. **There is no silent renewal on this side, on purpose** — an OTP code cannot be replayed the way the ACS's password can, so a 15-minute token would mean a new SMS every 15 minutes. The session therefore lives **1 hour** (`AuthEndpoint.patientSessionLifetime`, applying LGPD-RT06), the single deliberate asymmetry with the ACS: when it expires the app does not renew, it shows "Sua sessão expirou. Entre novamente com o código de acesso." with a button back to the login (`reenter_button`), because that is the only way back in. The asymmetry is written down in `spec/lgpd_design.md` so the next reader does not read it as an oversight. Both patient session paths — the OTP login and `onboarding.completeEnrollment` — issue the same 1-hour token (`AuthEndpoint.patientSessionLifetime`). After the OTP login the app asks `patients.hasAcceptedCurrentTerms` (a `bool` — not the whole "Meus dados" panel, which would write a read-audit row on every login) with a 3 s timeout and, when the answer is `false`, shows `TermsAcceptanceScreen` (`legal_screens.dart`); it is a prompt, not a gate — "Agora não" and a failed or slow `hasAcceptedCurrentTerms` both go straight to the home, so the emergency alert never waits for an acceptance. `patients.acceptTermsOfUse` is the only writer of `termsOfUse` outside onboarding; `updateConsent` still refuses it. After login and onboarding, and when "Avisos da equipe" is granted, the app silently calls `devices.registerPushToken` with the token from `PushTokenSource` (`core/push/push_token_source.dart`); the token comes from `pushTokenSourceProvider` (the only `flutter_riverpod` use in the patient app; `SinalAcsApp(pushTokens:)` overrides it in tests, and without a `ProviderScope` the app falls back to `NoPushTokenSource`), whose real implementation `NativePushTokenSource` asks the `sinalacs/push_token` method channel — the Android side exists (`MainActivity.kt` with `firebase-messaging`; the Google Services plugin is applied **only** when the git-ignored `android/app/google-services.json` exists, so CI and other devs still build and the app degrades to no push), while **the iOS/Swift side does not exist**. The manifest turns FCM auto-init **off** (`firebase_messaging_auto_init_enabled=false`, guarded by `test/android_push_manifest_test.dart`): otherwise the device would contact Google on every app open, before login and without consent; the explicit `getToken()` still works. `registerPushDevice` asks the provider for the token only after `patients.hasGrantedConsent(segmentedPush)` answers `true` (a `bool`: no dossier, no read-audit row; 3 s ceiling, fail closed, late answer discarded; skipped when the source is `NoPushTokenSource`). Onboarding (only when the box is ticked) and the "Meus dados" switch pass `consentKnownGranted: true` because they just wrote the decision. `scripts/qa/push_e2e.sh` proves the real delivery on `emulator-5554`; it needs the FCM service-account key and is not run in CI, and the two push integration tests only run with `--dart-define=PUSH_E2E=1`, so `e2e.sh --full` stays green without credentials. A refusal or failure never blocks the home or the emergency alert. The home also fetches `patients.termsChangeNotice` once (3 s ceiling, failure or a shell without `BackendScope` = no card) and shows a dismissible `TermsChangeNoticeCard` — never a gate, and never on the urgency tab. When the app embeds the text of exactly the version the server announced (`upcomingLegalDocuments`, injected via `UpcomingDocumentsScope`), the card also offers "Ler o texto novo" (`LegalDocumentsScreen(upcoming:)`); otherwise only "Ler os termos atuais". `PushTokenScope.maybeOf` falls back to no push instead of throwing. In the ACS app, "Avisos à comunidade" (`NoticesScreen`) calls `AcsBackend.sendNotice` → `notices.sendSegmented`.
Expand Down
Loading
Loading