diff --git a/CLAUDE.md b/CLAUDE.md index cbb760ca..7770b0bf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project overview -SinalACS is a platform that prioritizes Primary Health Care visits in Brazil by turning structured clinical signals into a risk-ranked work queue for the Community Health Agent (ACS). Project documentation, code comments, and UI copy are in Portuguese — match that language for anything user-facing or spec-related. Current state: functional prototype, validated locally via Docker Compose; not production-ready. Already delivered: ACS institutional login (RF07, matrícula + senha/Argon2id, credentials kept locally — no integration with a real identity provider), patient passwordless login (RF01, CPF + birth date + OTP), RBAC per micro-area (RNF06), TLS 1.3 on the RPC via Traefik (RNF04), on the Traefik → backend hop (one-way TLS 1.3 with the chain verified; no mTLS, Serverpod cannot request a client certificate — #78) and on the broker (TLS 1.3 only, password + ACL + client certificate on the local stack, `scripts/qa/mtls_invariants.sh` and `scripts/qa/tls_invariants.sh`), TOTP MFA for the ACS login, RF08 micro-area persisted in the encrypted local database (72 h, per owner), the ACS refresh token (rotating, idle window 2 h, absolute cap 8 h from the password+TOTP login, family revoked on reuse/other device/inactive account; the app keeps no password) bound to the Keystore: an AES-256-GCM + RSA-OAEP envelope whose RSA key requires user authentication on every use, so the token is decrypted only through `BiometricPrompt` + `CryptoObject` on cold start (API 30+ also accepts the device PIN; on API 30+ a newly enrolled fingerprint does **not** invalidate the key; proved on the API 36 emulator only, `scripts/qa/acs_keystore_bound_e2e.sh`), plus the 30 s background lock, which stays an interface gate over the token held in RAM and "Sair e encerrar o turno", `FLAG_SECURE` on the ACS window in every build (only a debug build made with `./scripts/dev/run_acs.sh --build --captura` opens it, by a compile-time constant that release refuses), release builds that refuse the debug key unless explicitly allowed (a release keystore is still to be provisioned), the UBS call button, the per-owner offline visit queue (schema v7, legacy rows quarantined and uploaded with unknown authorship, deferred upload token, wipe only when everything is sent) and the closing of the `login()` race (`BackendClient._exclusive`), plus the backoffice account management (#43: listing, ACS create/link/activate/deactivate and password/MFA reset, staff MFA reset with the activation code emitted by the screen, and the coordinator's audit scoped to his UBS — every operation audited, refusals included). Still missing: invalidation of the token key by a newly enrolled fingerprint on API 30+ and a proof of the Keystore binding on API 24–29 (the SQLCipher key and the deferred upload token stay unbound by design), a refresh token for the patient (OTP stays 1 h), iOS, a physical-device proof on Android ≤ 8.1 (API 24–27) (the emulator on API 24 and 27 showed no theme breakage; see `PROGRESS.md`, section Tema em API 24–27), background (OS-level) upload of visits left on the device (WorkManager is outside `spec/stack.md`), a supervisor-forced device wipe (no supervisor login exists), unblocking of a queue whose 7-day upload token expired or was refused (it needs the owner to log in again and blocks the wipe) and LGPD purge of an owner who never returns (see `PROGRESS.md`, section Fila de visitas por dono e corrida do login), per-device client-certificate provisioning in production (mTLS is delivered only on the local stack), creating coordinator accounts and assigning their `ubsId` through the UI (a coordinator still exists only in the seeds and by SQL; #43 covers the ACS lifecycle and the MFA resets), the ACS's own password change (a lost password is reset by the coordination), the audit trail's append-only enforcement in the database (the chain detects tampering, it does not prevent it; no trigger/`REVOKE`), a real file download of the CSV export (it goes through the clipboard), the owner's acceptance of the green "Cadeia íntegra" exception to `spec/ui_design.md` and the data-protection officer's review of the export (`PROGRESS.md`, section #45), and a production deploy (`backend/DEPLOY.md` is only a free-tier demo path). The data-subject request handling (#42) is delivered; the validation of its anonymization rules with the data-protection officer is still open (`PROGRESS.md`). +SinalACS is a platform that prioritizes Primary Health Care visits in Brazil by turning structured clinical signals into a risk-ranked work queue for the Community Health Agent (ACS). Project documentation, code comments, and UI copy are in Portuguese — match that language for anything user-facing or spec-related. Current state: functional prototype, validated locally via Docker Compose; not production-ready. Already delivered: ACS institutional login (RF07, matrícula + senha/Argon2id, credentials kept locally — no integration with a real identity provider), patient passwordless login (RF01, CPF + birth date + OTP), RBAC per micro-area (RNF06), TLS 1.3 on the RPC via Traefik (RNF04), on the Traefik → backend hop (one-way TLS 1.3 with the chain verified; no mTLS, Serverpod cannot request a client certificate — #78) and on the broker (TLS 1.3 only, password + ACL + client certificate on the local stack, `scripts/qa/mtls_invariants.sh` and `scripts/qa/tls_invariants.sh`), TOTP MFA for the ACS login, RF08 micro-area persisted in the encrypted local database (72 h, per owner), the ACS refresh token (rotating, idle window 2 h, absolute cap 8 h from the password+TOTP login, family revoked on reuse/other device/inactive account; the app keeps no password) bound to the Keystore: an AES-256-GCM + RSA-OAEP envelope whose RSA key requires user authentication on every use, so the token is decrypted only through `BiometricPrompt` + `CryptoObject` on cold start (API 30+ also accepts the device PIN; on API 30+ a newly enrolled fingerprint does **not** invalidate the key; proved on the API 36 emulator only, `scripts/qa/acs_keystore_bound_e2e.sh`), plus the 30 s background lock, which stays an interface gate over the token held in RAM and "Sair e encerrar o turno", `FLAG_SECURE` on the ACS window in every build (only a debug build made with `./scripts/dev/run_acs.sh --build --captura` opens it, by a compile-time constant that release refuses), release builds that refuse the debug key unless explicitly allowed (a release keystore is still to be provisioned), the UBS call button, the per-owner offline visit queue (schema v7, legacy rows quarantined and uploaded with unknown authorship, deferred upload token, wipe only when everything is sent) the closing of the `login()` race (`BackendClient._exclusive`), the ACS's resolution of a visit sync conflict (#79: persisted conflict with the server copy, side-by-side screen, keep mine / edit and send / keep the server's, `visits.resolveConflict`), plus the backoffice account management (#43: listing, ACS create/link/activate/deactivate and password/MFA reset, staff MFA reset with the activation code emitted by the screen, and the coordinator's audit scoped to his UBS — every operation audited, refusals included). Still missing: invalidation of the token key by a newly enrolled fingerprint on API 30+ and a proof of the Keystore binding on API 24–29 (the SQLCipher key and the deferred upload token stay unbound by design), a refresh token for the patient (OTP stays 1 h), iOS, a physical-device proof on Android ≤ 8.1 (API 24–27) (the emulator on API 24 and 27 showed no theme breakage; see `PROGRESS.md`, section Tema em API 24–27), background (OS-level) upload of visits left on the device (WorkManager is outside `spec/stack.md`), a supervisor-forced device wipe (no supervisor login exists), unblocking of a queue whose 7-day upload token expired or was refused (it needs the owner to log in again and blocks the wipe) and LGPD purge of an owner who never returns (see `PROGRESS.md`, section Fila de visitas por dono e corrida do login), per-device client-certificate provisioning in production (mTLS is delivered only on the local stack), creating coordinator accounts and assigning their `ubsId` through the UI (a coordinator still exists only in the seeds and by SQL; #43 covers the ACS lifecycle and the MFA resets), the ACS's own password change (a lost password is reset by the coordination), the audit trail's append-only enforcement in the database (the chain detects tampering, it does not prevent it; no trigger/`REVOKE`), a real file download of the CSV export (it goes through the clipboard), the owner's acceptance of the green "Cadeia íntegra" exception to `spec/ui_design.md` and the data-protection officer's review of the export (`PROGRESS.md`, section #45), and a production deploy (`backend/DEPLOY.md` is only a free-tier demo path). The data-subject request handling (#42) is delivered; the validation of its anonymization rules with the data-protection officer is still open (`PROGRESS.md`). Two core flows: - **Paciente (patient)**: passwordless auth (OTP), urgency alert, structured triage, request status tracking, "Meus Dados" (LGPD). @@ -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` 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`, `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. diff --git a/PROGRESS.md b/PROGRESS.md index 3e1e86cc..8d716c93 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -125,7 +125,7 @@ dados. | M2.1 | App Paciente - MVP | Implementado | [apps/patient/lib/app/app.dart](apps/patient/lib/app/app.dart) com login, triagem e status do paciente | | M2.2 | App ACS - MVP | Implementado | [apps/acs/lib/app/app.dart](apps/acs/lib/app/app.dart) com login, dashboard, territorialização e registro de visita | | M2.3 | MQTT com TLS | Parcialmente implementado | [apps/acs/lib/core/services/mqtt_secure_client.dart](apps/acs/lib/core/services/mqtt_secure_client.dart) adiciona configuração segura e payload de alerta com TLS/WSS e teste em [apps/acs/test/mqtt_secure_client_test.dart](apps/acs/test/mqtt_secure_client_test.dart) | -| M2.4 | Sincronização Offline-First | Implementado | [apps/acs/lib/core/services/offline_visit_queue.dart](apps/acs/lib/core/services/offline_visit_queue.dart) com lote, retry e conflito, validado em [apps/acs/test/login_flow_test.dart](apps/acs/test/login_flow_test.dart) | +| M2.4 | Sincronização Offline-First | Implementado | [apps/acs/lib/core/services/offline_visit_queue.dart](apps/acs/lib/core/services/offline_visit_queue.dart) com lote, retry e conflito (resolvido pelo ACS desde a #79), validado em [apps/acs/test/login_flow_test.dart](apps/acs/test/login_flow_test.dart) | | M2.5 | Testes de Caos (Toxiproxy) | Implementado | [apps/acs/lib/core/services/network_chaos_simulator.dart](apps/acs/lib/core/services/network_chaos_simulator.dart) e [apps/acs/test/network_chaos_test.dart](apps/acs/test/network_chaos_test.dart) simulam latência, jitter e retry em cenários de falha | | M2.6 | Testes de Usabilidade e Acessibilidade | Implementado | [spec/ux_accessibility_assessment.md](spec/ux_accessibility_assessment.md) — matriz de contraste WCAG 1.4.3 determinística (`contrast_tokens_test.dart`), `meetsGuideline` de contraste/alvo de toque e `liveRegion` (SC 4.1.3) em [apps/acs/test/login_flow_test.dart](apps/acs/test/login_flow_test.dart) e [apps/patient/test/patient_app_mvp_test.dart](apps/patient/test/patient_app_mvp_test.dart), validado ponta a ponta no emulador contra o backend e o broker reais | @@ -175,10 +175,10 @@ A fila local de visitas foi evoluída em [apps/acs/lib/core/services/offline_vis - registro de visita em estado pendente - lote de sincronização com resposta explícita - retry de reprocessamento em fila -- detecção de conflito em payloads divergentes +- detecção de conflito em payloads divergentes (desde a #79 o conflito sai da retentativa e é decidido pelo ACS) - contagem de pendentes, sincronizados e conflitos -A validação foi incluída em [apps/acs/test/login_flow_test.dart](apps/acs/test/login_flow_test.dart), cobrindo o fluxo de sucesso e o caso de conflito com reprocessamento. +A validação foi incluída em [apps/acs/test/login_flow_test.dart](apps/acs/test/login_flow_test.dart), cobrindo o fluxo de sucesso e o caso de conflito (na semântica original o conflito voltava à fila e uma 2ª sincronização o "resolvia"; desde a #79 ele fica em `_conflicts` até o ACS decidir, e esse teste foi reescrito para isso). #### A fila passou a sair do aparelho @@ -1669,3 +1669,17 @@ Backlog priorizado na seção final do relatório; conferência por `python3 scr - 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. + +## Resolução de conflito de visita (#79) — 2026-10-16 + +- Entregue: o ACS compara a versão do aparelho com a do servidor e decide. Backend: `VisitSyncResult.serverVisit` (cópia do servidor só no `conflict` de `sync`/`syncDeferred`, trilha `read`/`visit_conflict`) e `visits.resolveConflict` (compare-and-set sobre `expectedServerVersion`, trilha `write`/`visit`/`conflict_resolved_local`). App ACS: schema local v8 (`conflict_server_version`, `conflict_server_snapshot`, só `ADD COLUMN`), `OfflineVisitQueue` com `resolveKeepLocal` (com `outcome`/`notes` para "Editar e enviar")/`resolveKeepServer`, tela `resolve_conflicts` (chaves `conflict_*`). Sem migração de banco do servidor. +- Decisões: D1 cópia do servidor no conflito, só para o autor; D2 `resolveConflict` explícito, com as mesmas recusas do `sync`; D3 o conflito sai da retentativa e vive persistido até a decisão (continua bloqueando "Limpar este aparelho" e contando em `countsByOwner`; a antiga semântica "volta para a fila" caiu); D4 "Manter a do servidor" é só local, com confirmação destrutiva; D5 "Editar e enviar" sem merge campo a campo; D6 o envio diferido não envia nem apaga linhas em conflito; D7 conflito sem `serverVersion` vira `error` retentável. +- Prova: `./scripts/qa/acs_full_e2e.sh` no emulador contra o banco de teste, 6/6 testWidgets, 2026-10-16 (marcador `E2E_CONFLITO`, versões 4 e 3 no banco, 1 linha `conflict_resolved_local`, `audit_chain_check` verde); log em `docs/superpowers/plans/2026-10-16-issue-79-e2e.log`. Testes: `visit_conflict_resolution_test.dart`, `visit_sync_service_test.dart`, `offline_visit_queue_conflict_test.dart`, `visit_conflict_screen_test.dart`, `encrypted_database_test.dart`, `device_wipe_test.dart`. Screenshots da tela ainda não capturadas (`docs/telas-acs.md`). +- Aberto: + - "Manter a do servidor" não deixa trilha no servidor (nada é lido nem escrito lá). + - Conflito de visita legada (`syncLegacy`) segue sem tela: fica na quarentena como antes. + - O envio diferido não resolve conflito; a linha espera o dono voltar. + - `resolveConflict` faz leitura e depois update, sem trava de linha nem `WHERE version = esperada`: duas resoluções simultâneas da mesma visita pelo mesmo ACS (dois aparelhos) podem gravar `version + 1` as duas, a segunda vencendo em silêncio e deixando duas linhas `conflict_resolved_local`; o `visits.sync` tem a mesma lacuna. + - 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. diff --git a/apps/CLAUDE.md b/apps/CLAUDE.md index 14803fda..dc5739cf 100644 --- a/apps/CLAUDE.md +++ b/apps/CLAUDE.md @@ -22,7 +22,7 @@ Both follow the same skeleton: `lib/main.dart` → `lib/app/app.dart` (+ `*_them - `database/encrypted_database.dart` (ACS only) — local persistence via `sqflite_sqlcipher`. **INV-04 of the PRD says health data is never persisted in plaintext**, so opening outside Android/iOS throws unless the caller passes `allowUnencryptedForTesting: true`. That flag is deliberately embarrassing to type: `grep` for it and you see every place that gave up the guarantee — today, only VM tests. It used to be the *default* behaviour off-mobile, which is why the old test named "deve abrir banco criptografado" proved nothing on Linux CI. - `security/database_key_store.dart` (ACS only) — the 256-bit key lives in the Android Keystore / iOS Keychain (`flutter_secure_storage`), never in code. **Not** derived from a PIN, though `spec/PRD_system.md` 4.2.3 prescribes PBKDF2-from-PIN: no PIN flow exists in the apps, and the decision plus its reversibility is recorded in `spec/lgpd_design.md` §5.1. `SqlCipherVisitStore` recovers from a lost key (reinstall, backup restore) by setting the unreadable file aside and starting fresh instead of leaving the app permanently stuck — **only** for SQLCipher's wrong-key error (`isWrongDatabaseKeyError`: `SQLITE_NOTADB`, or the `open_failed` that `sqflite_sqlcipher` 3.2.0 turns it into on Android); any other open error (migration, I/O, platform channel) is rethrown, nothing is deleted, and the queue keeps working in RAM (`persistenceFailed`). The wrong-key error must repeat on a second open attempt (after `retryDelay`) before recovery runs, and recovery never deletes the file: it moves it (with its `-wal`/`-shm`/`-journal` sidecars) to a single `.recuperado`, still SQLCipher-encrypted with a key that is then deleted — so it is **not readable by the app or by support**; it is kept only as an unreadable forensic artifact (the bytes survive in case the lost key is ever restored from a device/Keystore backup), the pending visits in it are not recoverable, and on Android a real, persistent open failure ends the same way because `open_failed` is indistinguishable from a wrong key — if the move fails the error is rethrown with the file and key untouched; "Limpar este aparelho" (`wipeAllData`) also deletes `.recuperado`. - **Encryption is only actually proven on a device**: `apps/acs/integration_test/encrypted_storage_test.dart` reads the raw database file and asserts it neither starts with `SQLite format 3` nor contains the visit content. CI only runs the same probe inside `integration_test/smoke_test.dart`; the wrong-key, v1-migration and lost-key cases of `encrypted_storage_test.dart` run only with `./scripts/qa/e2e.sh --emulator --full`. `flutter test` cannot prove this — on Linux everything goes through the unencrypted FFI path, which is the trap that produced the original gap. -- ACS-only, in `apps/acs/lib/core/services/`: `mqtt_secure_client.dart` (TCP/TLS MQTT client on 8883 — the broker's only published listener; trusts the dev CA from an asset via `setTrustedCertificatesBytes`, keeps hostname verification on, uses a persistent session with a stable client id so the broker re-delivers alerts that arrived while the device was offline), `alert_feed.dart` (builds the MQTT config from the session and feeds `alert_queue.dart`; classifies failures into `AlertFeedFailure.transient` — a missing password/CA asset or a refused credential is not, an unreachable broker is), `reconnect_schedule.dart` (pure 2s→60s exponential backoff, same scale as the server's `MqttAlertDispatcher`, with no `Timer` of its own so it is testable without a fake clock — used by `AcsHomeShell` in `app.dart` to retry the *first* connection to the broker on its own when the failure is transient, with a "Tentar agora" button on the banner and an immediate retry on returning from the background; a permanent failure, such as a missing password, is not retried. `mqtt_client`'s own `autoReconnect` only takes over *after* a first successful connection — before that there is nothing to reconnect, which is why this existed as a real gap: a device that opened the app without signal used to need a restart to ever receive the alerts the broker was already holding for it with QoS 1), `offline_visit_queue.dart` (offline-first visit queue backed by a `VisitStore` — in production `SqlCipherVisitStore`, so visits survive closing the app; a store failure sets `persistenceFailed` and the queue keeps working in RAM rather than blocking field work, surfaced as its **own** banner (`Key('storage_error')`) alongside — never instead of — the broker one, plus an inline warning on the visit screen; the two used to share one slot through a `??`, so a field outage hid the fact that visits were not being saved — pushed to `visits.sync` through `backend_visit_synchronizer.dart`, wired in production by `visit_queue_factory.dart`; a conflict returns to the queue instead of being discarded, a per-visit retryable `error` also stays pending and is reported with the server's message, a network failure keeps the batch pending, and a per-visit terminal `rejected` leaves the retry queue but stays visible on the device (`rejectedCount`/`rejectedVisits`, `Key('rejected_visits_count')`) with the server's reason until the ACS explicitly discards it (`discardRejected()`, `Key('discard_rejected')`, behind a confirmation dialog) — nothing removes a rejected visit from disk on its own). An `OfflineVisitRecord` carries the patient's **UUID**, never a display label: when the visit comes from an alert the screen builds `Paciente <8 hex>` at render time, `visits.sync` rejects anything that is not a UUID, and the local schema (`offline_visits`, v4) has `patient_id` plus a nullable `rejection_reason`. The backend only ever publishes `riskLevel: 'red'` (emergency, routed to escalation), so a routine visit — the PRD's actual usage pattern, ≥8/ACS/day — cannot start from an alert at all; `patients.listMicroArea` (backend, territorialized by the caller's token, never a client-supplied micro-area) and the picker it feeds in `VisitRegistrationScreen` (`Key('patient_picker')`) exist for that path, showing name and chronic conditions per `spec/lgpd_design.md`'s routine-visit minimization rule — the picked name lives only in memory for the label, never written to `offline_visits` or logged. The red-alert path keeps its own way in too: the escalation screen's "Iniciar rota de visita" (`Key('escalation_visit')`) — the ACS visits after triggering SAMU, not instead of it. `VisitSyncService` also checks that the patient's micro-area matches the ACS's, not just that the ACS is territorialized; a mismatch is the terminal `SyncStatus.rejected` per-visit (never discards the rest of the batch) and writes an `audit_logs` row (`result: denied_territory`) per `spec/lgpd_design.md`'s access-monitoring requirement — the first writer that table ever had. `EncryptedLocalDatabase._upgrade` drops and recreates the table on v1 → v2 — acceptable only because the app had not shipped a release yet; from v3 → v4 onward the migration is additive (`ALTER TABLE ... ADD COLUMN`) and preserves existing rows, which is what any migration after a real release must do. Sync is triggered by hand from the visit screen ("Sincronizar agora" plus pending/conflict counters), not automatically, `network_chaos_simulator.dart` (injects latency/jitter/packet loss/partition for testing offline resilience — see `apps/acs/test/network_chaos_test.dart`). +- ACS-only, in `apps/acs/lib/core/services/`: `mqtt_secure_client.dart` (TCP/TLS MQTT client on 8883 — the broker's only published listener; trusts the dev CA from an asset via `setTrustedCertificatesBytes`, keeps hostname verification on, uses a persistent session with a stable client id so the broker re-delivers alerts that arrived while the device was offline), `alert_feed.dart` (builds the MQTT config from the session and feeds `alert_queue.dart`; classifies failures into `AlertFeedFailure.transient` — a missing password/CA asset or a refused credential is not, an unreachable broker is), `reconnect_schedule.dart` (pure 2s→60s exponential backoff, same scale as the server's `MqttAlertDispatcher`, with no `Timer` of its own so it is testable without a fake clock — used by `AcsHomeShell` in `app.dart` to retry the *first* connection to the broker on its own when the failure is transient, with a "Tentar agora" button on the banner and an immediate retry on returning from the background; a permanent failure, such as a missing password, is not retried. `mqtt_client`'s own `autoReconnect` only takes over *after* a first successful connection — before that there is nothing to reconnect, which is why this existed as a real gap: a device that opened the app without signal used to need a restart to ever receive the alerts the broker was already holding for it with QoS 1), `offline_visit_queue.dart` (offline-first visit queue backed by a `VisitStore` — in production `SqlCipherVisitStore`, so visits survive closing the app; a store failure sets `persistenceFailed` and the queue keeps working in RAM rather than blocking field work, surfaced as its **own** banner (`Key('storage_error')`) alongside — never instead of — the broker one, plus an inline warning on the visit screen; the two used to share one slot through a `??`, so a field outage hid the fact that visits were not being saved — pushed to `visits.sync` through `backend_visit_synchronizer.dart`, wired in production by `visit_queue_factory.dart`; a conflict (#79) leaves the retry loop instead of returning to the queue: it is kept in `_conflicts`, persisted with the server copy (schema v8: `conflict_server_version`, `conflict_server_snapshot`, additive `ALTER TABLE`), restored after a restart, still blocks "Limpar este aparelho" and counts in `countsByOwner`, and only the ACS decides it on the `resolve_conflicts` screen (side-by-side `conflict_local`/`conflict_server`, "Diferente" marker, `conflict_keep_local` via `visits.resolveConflict` with compare-and-set on the version seen, `conflict_edit` → `conflict_send_edited`, `conflict_keep_server` behind a destructive confirmation that is local-only and calls nothing); `DeferredFlushService` never sends conflict rows; a server conflict without `serverVersion` is a retryable `error`, a per-visit retryable `error` also stays pending and is reported with the server's message, a network failure keeps the batch pending, and a per-visit terminal `rejected` leaves the retry queue but stays visible on the device (`rejectedCount`/`rejectedVisits`, `Key('rejected_visits_count')`) with the server's reason until the ACS explicitly discards it (`discardRejected()`, `Key('discard_rejected')`, behind a confirmation dialog) — nothing removes a rejected visit from disk on its own). An `OfflineVisitRecord` carries the patient's **UUID**, never a display label: when the visit comes from an alert the screen builds `Paciente <8 hex>` at render time, `visits.sync` rejects anything that is not a UUID, and the local schema (`offline_visits`, v4) has `patient_id` plus a nullable `rejection_reason`. The backend only ever publishes `riskLevel: 'red'` (emergency, routed to escalation), so a routine visit — the PRD's actual usage pattern, ≥8/ACS/day — cannot start from an alert at all; `patients.listMicroArea` (backend, territorialized by the caller's token, never a client-supplied micro-area) and the picker it feeds in `VisitRegistrationScreen` (`Key('patient_picker')`) exist for that path, showing name and chronic conditions per `spec/lgpd_design.md`'s routine-visit minimization rule — the picked name lives only in memory for the label, never written to `offline_visits` or logged. The red-alert path keeps its own way in too: the escalation screen's "Iniciar rota de visita" (`Key('escalation_visit')`) — the ACS visits after triggering SAMU, not instead of it. `VisitSyncService` also checks that the patient's micro-area matches the ACS's, not just that the ACS is territorialized; a mismatch is the terminal `SyncStatus.rejected` per-visit (never discards the rest of the batch) and writes an `audit_logs` row (`result: denied_territory`) per `spec/lgpd_design.md`'s access-monitoring requirement — the first writer that table ever had. `EncryptedLocalDatabase._upgrade` drops and recreates the table on v1 → v2 — acceptable only because the app had not shipped a release yet; from v3 → v4 onward the migration is additive (`ALTER TABLE ... ADD COLUMN`) and preserves existing rows, which is what any migration after a real release must do. Sync is triggered by hand from the visit screen ("Sincronizar agora" plus pending/conflict counters), not automatically, `network_chaos_simulator.dart` (injects latency/jitter/packet loss/partition for testing offline resilience — see `apps/acs/test/network_chaos_test.dart`). Topic namespace must agree in three places: the server's `AlertDelivery.topicPrefix`, the broker ACL (`infra/docker/mosquitto/aclfile`), and `alertTopicFor()` in `mqtt_secure_client.dart` — all `sinalacs/v1/microareas//alerts`, where `` is the seed UUID, not `area-12`. @@ -36,6 +36,8 @@ The ACS login is institutional now (RF07): `_enter` in `apps/acs/lib/app/app.dar **Biometric unlock (`local_auth` 3.0.2).** `AppLockGate` (`lib/app/app_lock_gate.dart`) is mounted in `MaterialApp.builder`, *above* the Navigator, with the app's `navigatorKey` (required: it is how the gate swallows the back button while covered). After 30 s in the background it covers everything with an opaque "Aplicativo bloqueado" screen until `BiometricGate` (`lib/core/security/biometric_gate.dart`) succeeds; the panel, MQTT feed, queues and half-filled forms stay mounted underneath (an incoming red alert is still delivered and queued while locked), a failed or unavailable prompt keeps it locked, and a clock moved backwards counts as elapsed. Known limitation (documented in the gate): a programmatic `pop` while locked is not intercepted. On a **cold start** `LoginScreen` asks for the biometric/screen lock *before* resuming the stored session (`resumeSession()`); a device without any screen lock, or a refused prompt, falls back to the full login form (offline with a stored token shows a retry button instead of discarding it). "Entrar com senha" pushes an opaque, zero-duration reauthentication route and then recreates the gate, so the panel is never visible without the password; another user logging in discards the previous panel and feed (RNF06). The Preferências tab has "Sair e encerrar o turno", which revokes the refresh-token family and returns to the login — its confirmation counts the unacknowledged alerts (`AlertQueue.unacknowledgedCount`; they leave the device but stay pending on the server) and then labels the button "Sair mesmo assim" — and **does not clear the offline visit queue**: the queue belongs to its owner (see the visit-queue paragraph below), and "Sair" first syncs the panel queue, then uploads what it can of the others (20 s ceiling per step, `sessionEndSendTimeout`) and tells the ACS what stays on the device and why. **The cold-start unlock is bound to the Keystore for the refresh token only** (see above): the `AppLockGate` after 30 s in the background is still an interface gate over the token held in RAM, and the SQLCipher key and the deferred upload token stay in `flutter_secure_storage` without any authentication binding, by design (the offline queue and another owner's deferred upload must work without the owner present) — a rooted device can read those regardless. On a cold start the store's `unlock` (`AcsBackend.unlockStoredSession`) replaces the `BiometricGate` prompt; stores that answer `notRequired` (in-memory, legacy secure storage, the `integration_test` ones) fall back to the `BiometricGate`. Platform: `MainActivity` extends `FlutterFragmentActivity` (required by `local_auth`) and keeps `FLAG_SECURE`; the manifest declares `USE_BIOMETRIC`. The app theme still inherits `@android:style/Theme.*.NoTitleBar` while `local_auth_android` asks for an AppCompat parent on Android ≤ 8.1 (API 24–27) (`minSdk` is 24); on the API 24 and API 27 emulators (2026-10-05) the breakage did not reproduce — the vault never prompts there (`canAuthenticate(BIOMETRIC_STRONG)` = `NO_HARDWARE`) and `local_auth` opens the system credential screen — so the theme was left as is; a physical device is still unproved (`PROGRESS.md`, "Tema em API 24–27"). +**ACS: 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: fila de visitas por dono, envio diferido e "Limpar este aparelho" (2026-10-04).** Plano `docs/superpowers/plans/2026-10-03-fila-de-visitas-por-dono-e-corrida-do-login.md` (D1–D9). `offline_visits` tem a coluna `owner` (schema v7, migração aditiva): `VisitStorage.forOwner(userId)` só lê, conta, grava e apaga `WHERE owner = ?` (o `save()` de B nunca toca nas linhas de A; dono vazio é `ArgumentError`; `localId` já existente em outro dono é `VisitLocalIdConflict`, unicidade global). Linhas do schema v6 ficam em **quarentena** (`owner IS NULL`): nenhum dono as vê; só `VisitStorage.legacy` (`LegacyVisitStore`) as lê e remove por `localId`. O cursor do pull é por `userId|microAreaId`; fila e pull são resolvidos por sessão (`visit_queue_factory.dart`, `visit_pull_service_factory.dart`) e recriados por dono, e `BackendVisitSynchronizer` recusa sessão ou token de outro dono (checagem no push e `expectedUserId` conferido depois de resolvido o token). Os testes que injetam `visitQueue`/`visitPullService` seguem como *seam*. `BackendClient._exclusive` (seção crítica serial, **não reentrante**: dentro dela só E/S local do Keystore e atribuições, nunca rede, `renewSession()` ou outra `_exclusive`) cobre login, `developmentLogin`, a leitura do token e a confirmação da renovação e o logout, e fecha a corrida em que uma renovação do usuário anterior sobrescrevia o login novo. `DeferredFlushService` (`core/services/deferred_flush_service.dart`) sobe o que não é do dono da sessão atual: o legado pela sessão atual como transporte (`syncLegacy`, "autoria desconhecida"), cada dono com token por `syncDeferred`; lotes de 100 (`deferredBatchSize`); a visita só sai do aparelho em `synced`; o token de envio é revogado no servidor e apagado do Keystore só com a lista do dono vazia; `flushAll()` é single-flight e não lança; o dono da sessão atual é pulado (a fila é do painel, por isso o "Sair" sincroniza a fila do painel **antes** de `flushAll`). `UploadTokenStore` (`core/security/secure_upload_token_store.dart`) guarda um token por dono no Keystore (`acs_upload_token|`) e um índice de donos; o envio também tenta o dono de toda linha no disco, então o índice é só otimização. Gatilhos em `app.dart`: painel aberto, retomada do app e conexão que volta (`connectivityChanges`, `connectivity_plus`). Rejeição "versão diferente" (`legacyVersionMismatchMessage`) não é erro de território: a visita fica em quarentena e pede revisão. "Limpar este aparelho" congela a área, espera o refresh em voo e chama `VisitDatabase.wipeAllData`, que numa transação recusa (`WipeBlocked`) se houver **qualquer** linha em `offline_visits` — recusada, legado ou de outro dono — ou fila só em RAM; sem linhas, apaga `offline_visits`, `sync_cursor` e o cache da microárea (arquivo e chave do Keystore ficam). Não há wipe forçado por supervisor (não existe login de supervisor) nem envio em segundo plano do SO (WorkManager fora de `spec/stack.md`); um token de envio vencido (7 dias) ou recusado deixa a fila do dono presa e bloqueia o wipe. Provas e limites no emulador: `PROGRESS.md`, seção "Fila de visitas por dono e corrida do login" (o offline do `acs_full_e2e.sh` é **simulado** por um decorator de `AcsBackend`, e o gatilho de conectividade vem de um `StreamController`). The patient login is passwordless now (RF01), and no longer uses `developmentLogin` either: the app sends the CPF and the birth date to `auth.requestOtp`, then the 6-digit code to `auth.verifyOtp`, and only that second call issues the session. **There is no silent renewal on this side, on purpose** — an OTP code cannot be replayed the way the ACS's password can, so a 15-minute token would mean a new SMS every 15 minutes. The session therefore lives **1 hour** (`AuthEndpoint.patientSessionLifetime`, applying LGPD-RT06), the single deliberate asymmetry with the ACS: when it expires the app does not renew, it shows "Sua sessão expirou. Entre novamente com o código de acesso." with a button back to the login (`reenter_button`), because that is the only way back in. The asymmetry is written down in `spec/lgpd_design.md` so the next reader does not read it as an oversight. Both patient session paths — the OTP login and `onboarding.completeEnrollment` — issue the same 1-hour token (`AuthEndpoint.patientSessionLifetime`). After the OTP login the app asks `patients.hasAcceptedCurrentTerms` (a `bool` — not the whole "Meus dados" panel, which would write a read-audit row on every login) with a 3 s timeout and, when the answer is `false`, shows `TermsAcceptanceScreen` (`legal_screens.dart`); it is a prompt, not a gate — "Agora não" and a failed or slow `hasAcceptedCurrentTerms` both go straight to the home, so the emergency alert never waits for an acceptance. `patients.acceptTermsOfUse` is the only writer of `termsOfUse` outside onboarding; `updateConsent` still refuses it. After login and onboarding, and when "Avisos da equipe" is granted, the app silently calls `devices.registerPushToken` with the token from `PushTokenSource` (`core/push/push_token_source.dart`); the token comes from `pushTokenSourceProvider` (the only `flutter_riverpod` use in the patient app; `SinalAcsApp(pushTokens:)` overrides it in tests, and without a `ProviderScope` the app falls back to `NoPushTokenSource`), whose real implementation `NativePushTokenSource` asks the `sinalacs/push_token` method channel — the Android side exists (`MainActivity.kt` with `firebase-messaging`; the Google Services plugin is applied **only** when the git-ignored `android/app/google-services.json` exists, so CI and other devs still build and the app degrades to no push), while **the iOS/Swift side does not exist**. The manifest turns FCM auto-init **off** (`firebase_messaging_auto_init_enabled=false`, guarded by `test/android_push_manifest_test.dart`): otherwise the device would contact Google on every app open, before login and without consent; the explicit `getToken()` still works. `registerPushDevice` asks the provider for the token only after `patients.hasGrantedConsent(segmentedPush)` answers `true` (a `bool`: no dossier, no read-audit row; 3 s ceiling, fail closed, late answer discarded; skipped when the source is `NoPushTokenSource`). Onboarding (only when the box is ticked) and the "Meus dados" switch pass `consentKnownGranted: true` because they just wrote the decision. `scripts/qa/push_e2e.sh` proves the real delivery on `emulator-5554`; it needs the FCM service-account key and is not run in CI, and the two push integration tests only run with `--dart-define=PUSH_E2E=1`, so `e2e.sh --full` stays green without credentials. A refusal or failure never blocks the home or the emergency alert. The home also fetches `patients.termsChangeNotice` once (3 s ceiling, failure or a shell without `BackendScope` = no card) and shows a dismissible `TermsChangeNoticeCard` — never a gate, and never on the urgency tab. When the app embeds the text of exactly the version the server announced (`upcomingLegalDocuments`, injected via `UpcomingDocumentsScope`), the card also offers "Ler o texto novo" (`LegalDocumentsScreen(upcoming:)`); otherwise only "Ler os termos atuais". `PushTokenScope.maybeOf` falls back to no push instead of throwing. In the ACS app, "Avisos à comunidade" (`NoticesScreen`) calls `AcsBackend.sendNotice` → `notices.sendSegmented`. diff --git a/apps/acs/integration_test/full_journey_e2e.dart b/apps/acs/integration_test/full_journey_e2e.dart index 535bd5e0..9f830774 100644 --- a/apps/acs/integration_test/full_journey_e2e.dart +++ b/apps/acs/integration_test/full_journey_e2e.dart @@ -30,6 +30,13 @@ /// então passa e as tabelas ficam vazias. A autoria no servidor (`acsId`, /// `authorship`, `originDeviceId`) e o `audit_logs` são conferidos pelo script. /// +/// O teste do conflito de visita (#79), antes do da MFA, registra duas visitas +/// com o envio fora do ar, leva as duas à v3 no servidor por um "outro +/// aparelho" do mesmo ACS (o `BackendClient` real, fora do decorator) e, pela +/// tela, mantém a do aparelho numa e a do servidor noutra. A v4 gravada, a v3 +/// intacta, a trilha (`conflict_resolved_local`, `visit_conflict`) e a cadeia +/// de auditoria são conferidas pelo script (`E2E_CONFLITO`). +/// /// PRIVACIDADE: só fixtures sintéticas geradas na execução; nada de CPF em log. @Timeout(Duration(minutes: 4)) library; @@ -57,6 +64,7 @@ import 'package:sinalacs_acs/core/services/backend_visit_synchronizer.dart'; import 'package:sinalacs_acs/core/services/micro_area_directory.dart'; import 'package:sinalacs_acs/core/services/micro_area_directory_factory.dart'; import 'package:sinalacs_acs/core/services/offline_visit_queue.dart'; +import 'package:sinalacs_acs/core/services/visit_queue_factory.dart'; import 'package:sinalacs_client/sinalacs_client.dart' as api; import 'support/e2e_acs.dart'; @@ -89,6 +97,15 @@ Future _tocarEntrar(WidgetTester tester) async { await tester.tap(botao); } +/// Rola até [alvo] ficar no MEIO da área visível e toca. O `ensureVisible` +/// do tester encosta o alvo na borda de baixo, onde a barra de navegação ou +/// um SnackBar podem cobri-lo (o toque cairia neles: "would not hit test"). +Future _tocarNoMeio(WidgetTester tester, Finder alvo) async { + await Scrollable.ensureVisible(tester.element(alvo), alignment: 0.5); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(alvo); +} + /// Cliente com o refresh token e o id do aparelho no Keystore, como o `main.dart`. BackendClient _clienteComKeystore(List ca) => BackendClient( trustedCaBytes: ca, @@ -159,6 +176,17 @@ class _EnvioDeVisitasForaDoAr implements AcsBackend { Future> syncVisits(List visits, {String? expectedUserId}) => _rede(() => _inner.syncVisits(visits, expectedUserId: expectedUserId)); @override + Future resolveVisitConflict( + api.VisitSyncEntry visit, { + required int expectedServerVersion, + String? expectedUserId, + }) => + _rede(() => _inner.resolveVisitConflict( + visit, + expectedServerVersion: expectedServerVersion, + expectedUserId: expectedUserId, + )); + @override Future> syncLegacyVisits(List visits, {required String deviceId}) => _rede(() => _inner.syncLegacyVisits(visits, deviceId: deviceId)); @override @@ -356,6 +384,134 @@ void main() { await store.clear(); }); + // Antes do teste da MFA: depois dele o login da fixture passa a exigir TOTP. + testWidgets('conflito de visita: compara lado a lado, mantém a minha numa e a do servidor noutra (#79)', + (tester) async { + final ca = (await rootBundle.load(BackendConfig.rpcCaAsset)).buffer.asUint8List(); + final real = _clienteComKeystore(ca); + addTearDown(real.close); + final backend = _EnvioDeVisitasForaDoAr(real); + final cred = await acsCredentialFromRelay(); + final main = e2ePatient('main'); + + // Base própria deste teste (SQLCipher real no aparelho), com a fila de + // produção (`buildVisitQueue`) exposta ao teste por um resolvedor de escopo. + const arquivo = 'conflito_e2e.db'; + await EncryptedLocalDatabase.deleteDatabaseFile(arquivo); + final storage = SqlCipherVisitStorage(keyStore: SecureStorageDatabaseKeyStore(), databaseName: arquivo); + OfflineVisitQueue? fila; + final escopos = {}; + OwnerVisitScope resolver(AuthSession s) => escopos.putIfAbsent(visitCursorOwner(s), () { + fila ??= buildVisitQueue(backend: backend, store: storage.forOwner(s.userId), ownerId: s.userId); + return buildOwnerVisitScope(backend: backend, storage: storage, session: s, queue: fila); + }); + + await tester.pumpWidget(SinalAcsApp( + backend: backend, + biometricGate: _DesbloqueioDeTeste(), + syncInterval: const Duration(hours: 2), + visitScopeResolver: resolver, + )); + + // 1) Login real; o envio de visitas fica fora do ar (decorator) e duas + // visitas são registradas pela tela. + await tester.enterText(find.byKey(const Key('matricula_field')), cred.matricula); + await tester.enterText(find.byKey(const Key('senha_field')), cred.senha); + await _tocarEntrar(tester); + await _pumpUntil(tester, () => find.byKey(const Key('login_button')).evaluate().isEmpty); + final acsId = real.session!.userId; + + await tester.tap(find.text('Visita')); + await _pumpUntil(tester, () => find.byKey(Key('patient_${main.id}')).evaluate().isNotEmpty); + await tester.tap(find.byKey(Key('patient_${main.id}'))); + await tester.pump(); + await tester.tap(find.byKey(const Key('arrival_confirmation'))); + await tester.pump(); + await tester.enterText(find.widgetWithText(TextField, 'Observações de campo'), 'e2e conflito'); + await tester.pump(); + for (var n = 1; n <= 2; n++) { + final salvar = find.byKey(const Key('save_visit')); + await _tocarNoMeio(tester, salvar); + await _pumpUntil(tester, () => find.text('Pendentes de sincronização: $n').evaluate().isNotEmpty); + } + final pendentes = fila!.pendingVisits; + expect(pendentes, hasLength(2)); + expect(pendentes.every((v) => v.outcome == 'Realizada com sucesso'), isTrue); + final manterMinha = pendentes[0]; + final manterServidor = pendentes[1]; + + // 2) O "outro aparelho" do mesmo ACS, pela rede real (fora do decorator): + // cria a v1 com outro status e a leva até a v3 no servidor. + for (final visita in [manterMinha, manterServidor]) { + final base = visitSyncEntryFor(visita).copyWith(status: 'Paciente ausente'); + for (final (versao, esperada) in [(1, 1), (0, 2), (1, 3)]) { + final r = (await real.syncVisits([base.copyWith(version: versao)], expectedUserId: acsId)).single; + expect(r.syncStatus, api.SyncStatus.synced, reason: 'outro aparelho, envio com versão $versao: ${r.message}'); + expect(r.serverVersion, esperada, reason: 'versão no servidor depois do envio com versão $versao'); + } + } + + // 3) A rede volta e o ACS sincroniza: as duas viram conflito, nenhuma pendente. + backend.offline = false; + final sincronizar = find.byKey(const Key('sync_visits')); + await _tocarNoMeio(tester, sincronizar); + await _pumpUntil(tester, () => find.text('Em conflito: 2').evaluate().isNotEmpty); + expect(find.text('Pendentes de sincronização: 0'), findsOneWidget); + expect(fila!.conflictCount, 2); + expect(fila!.pendingCount, 0); + + // 4) Comparação da 1ª: aparelho x servidor (versão 3), diferença marcada; mantém a minha. + final resolver0 = find.byKey(const Key('resolve_conflicts')); + await _tocarNoMeio(tester, resolver0); + await _pumpUntil(tester, () => find.byKey(Key('conflict_item_${manterMinha.localId}')).evaluate().isNotEmpty); + await tester.tap(find.byKey(Key('conflict_item_${manterMinha.localId}'))); + await _pumpUntil(tester, () => find.byKey(const Key('conflict_local')).evaluate().isNotEmpty); + final local = find.byKey(const Key('conflict_local')); + final servidor = find.byKey(const Key('conflict_server')); + expect(find.descendant(of: local, matching: find.text('Realizada com sucesso')), findsOneWidget); + expect(find.descendant(of: servidor, matching: find.text('Paciente ausente')), findsOneWidget); + expect(find.descendant(of: servidor, matching: find.textContaining('versão 3')), findsOneWidget); + expect(find.descendant(of: servidor, matching: find.text('Diferente')), findsWidgets); + final manter = find.byKey(const Key('conflict_keep_local')); + await _tocarNoMeio(tester, manter); + await _pumpUntil(tester, + () => find.byKey(Key('conflict_item_${manterServidor.localId}')).evaluate().isNotEmpty && + find.byKey(Key('conflict_item_${manterMinha.localId}')).evaluate().isEmpty && + // A rota da comparação só sai da árvore no fim da animação do pop: + // antes disso há duas telas de comparação e dois botões com a mesma chave. + find.byKey(const Key('conflict_keep_local')).evaluate().isEmpty); + expect(fila!.conflictCount, 1); + expect(find.text('Versão do aparelho enviada ao servidor.'), findsOneWidget); + // O SnackBar fica no rodapé por 4 s, por cima dos botões do fim da + // comparação da 2ª visita: some antes de seguir. + ScaffoldMessenger.of(tester.element(find.byKey(Key('conflict_item_${manterServidor.localId}')))) + .hideCurrentSnackBar(); + await _pumpUntil(tester, () => find.byType(SnackBar).evaluate().isEmpty); + + // 5) A 2ª: mantém a do servidor, atrás da confirmação destrutiva. + await tester.tap(find.byKey(Key('conflict_item_${manterServidor.localId}'))); + await _pumpUntil(tester, () => find.byKey(const Key('conflict_keep_server')).evaluate().isNotEmpty); + final manterDoServidor = find.byKey(const Key('conflict_keep_server')); + await _tocarNoMeio(tester, manterDoServidor); + await _pumpUntil(tester, () => find.byKey(const Key('confirm_keep_server')).evaluate().isNotEmpty); + await tester.tap(find.byKey(const Key('confirm_keep_server'))); + await _pumpUntil(tester, () => + find.byKey(const Key('conflict_list_empty')).evaluate().isNotEmpty && + find.byKey(const Key('conflict_keep_server')).evaluate().isEmpty); + + // 6) Nada em conflito nem pendente, e nada no disco desta conta. + expect(fila!.conflictCount, 0); + expect(fila!.pendingCount, 0); + expect(await storage.forOwner(acsId).load(), isEmpty); + // ignore: avoid_print + print('E2E_CONFLITO manter_minha=${manterMinha.localId} manter_servidor=${manterServidor.localId} acs=$acsId'); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(seconds: 1)); + await real.logout(); + await EncryptedLocalDatabase.deleteDatabaseFile(arquivo); + }); + testWidgets('MFA: com a verificação ativa, o login pela tela pede e aceita o código; ' 'o refresh token renova sem reautenticação e retoma a sessão na partida', (tester) async { final ca = (await rootBundle.load(BackendConfig.rpcCaAsset)).buffer.asUint8List(); @@ -626,8 +782,7 @@ void main() { // 5) "Limpar este aparelho" com A e o legado pendentes: recusado, nada apagado. await irPara('Preferências'); - await tester.ensureVisible(find.byKey(const Key('wipe_button'))); - await tester.tap(find.byKey(const Key('wipe_button'))); + await _tocarNoMeio(tester, find.byKey(const Key('wipe_button'))); await _pumpUntil(tester, () => find.byKey(const Key('wipe_blocked')).evaluate().isNotEmpty); expect(find.text('Desta conta: 0 visitas. De outras contas ou sem autor: 2 visitas.'), findsOneWidget); expect(find.text('Nada foi apagado.'), findsOneWidget); @@ -648,8 +803,7 @@ void main() { print('E2E_FILA_POR_DONO visita_a=$visitaA legado=$legadoId acs_a=$idA acs_b=$idB'); // 7) Tudo enviado: o wipe pede confirmação e apaga as quatro tabelas. - await tester.ensureVisible(find.byKey(const Key('wipe_button'))); - await tester.tap(find.byKey(const Key('wipe_button'))); + await _tocarNoMeio(tester, find.byKey(const Key('wipe_button'))); await _pumpUntil(tester, () => find.byKey(const Key('wipe_confirm')).evaluate().isNotEmpty); await tester.tap(find.byKey(const Key('wipe_confirm'))); await _pumpUntil(tester, () => find.byKey(const Key('login_button')).evaluate().isNotEmpty); diff --git a/apps/acs/lib/app/app.dart b/apps/acs/lib/app/app.dart index 502eb638..da2ef0fe 100644 --- a/apps/acs/lib/app/app.dart +++ b/apps/acs/lib/app/app.dart @@ -10,6 +10,7 @@ import 'package:sinalacs_acs/app/app_lock_gate.dart'; import 'package:sinalacs_acs/app/pending_alerts_banner.dart'; import 'package:sinalacs_acs/app/invite_screen.dart'; import 'package:sinalacs_acs/app/mfa_enrollment_screen.dart'; +import 'package:sinalacs_acs/app/visit_conflict_screen.dart'; import 'package:sinalacs_acs/core/database/sqlcipher_visit_store.dart'; import 'package:sinalacs_acs/core/geo/location_cell.dart'; import 'package:sinalacs_acs/core/network/auth_session.dart'; @@ -191,14 +192,38 @@ class _DeviceCareScope extends InheritedWidget { /// O que impede o "Limpar este aparelho", só em contagens. [own] + [elsewhere] /// é o total (desta conta / de outras contas e sem autor); [unsent] + /// [review] + [noToken] é o mesmo total, por motivo: ainda não enviadas -/// (dependem de rede), recusadas ou em revisão (dependem do ACS dono), e de +/// (dependem de rede), recusadas, em conflito (#79) ou em revisão (dependem +/// do ACS dono), e de /// outro dono sem chave de envio neste aparelho (dependem de ele entrar). typedef UnsentOnDevice = ({int own, int elsewhere, int unsent, int review, int noToken}); +/// Visitas que a fila ainda guarda no aparelho: pendentes, em conflito (#79, +/// fora da retentativa até o ACS decidir) e recusadas. +/// Frase do "Sair" sobre as [ficam] visitas não enviadas. As [emConflito] +/// contam no total, mas não no "sobem sozinhas": o envio diferido não as +/// envia (#79, D6) — a linha `logout_conflict_visits` diz o que esperam. +String _pendingVisitsOnLogout({required int ficam, required int emConflito}) { + if (emConflito == 0) { + return ficam == 1 + ? '1 visita ainda não enviada continua neste aparelho. Ela segue protegida e sobe sozinha quando o aplicativo estiver aberto com conexão (em até 7 dias).' + : '$ficam visitas ainda não enviadas continuam neste aparelho. Elas seguem protegidas e sobem sozinhas quando o aplicativo estiver aberto com conexão (em até 7 dias).'; + } + final total = ficam == 1 + ? '1 visita ainda não enviada continua neste aparelho.' + : '$ficam visitas ainda não enviadas continuam neste aparelho.'; + final sozinhas = ficam - emConflito; + if (sozinhas <= 0) return total; + return sozinhas == 1 + ? '$total 1 delas segue protegida e sobe sozinha quando o aplicativo estiver aberto com conexão (em até 7 dias).' + : '$total $sozinhas delas seguem protegidas e sobem sozinhas quando o aplicativo estiver aberto com conexão (em até 7 dias).'; +} + +int _visitsOnDevice(OfflineVisitQueue queue) => + queue.pendingCount + queue.conflictCount + queue.rejectedCount; + /// Fila que não conseguiu gravar e ainda tem visita: essas visitas existem só /// em RAM, e descartá-la as perderia. -bool _onlyInMemory(OfflineVisitQueue queue) => - queue.persistenceFailed && (queue.pendingCount > 0 || queue.rejectedCount > 0); +bool _onlyInMemory(OfflineVisitQueue queue) => queue.persistenceFailed && _visitsOnDevice(queue) > 0; /// Fila que não pode sair da memória: só em RAM, ou com um envio em voo /// (`isSyncing`). A segunda, descartada, faria o próximo login do dono montar @@ -307,9 +332,10 @@ class _SinalAcsAppState extends State with WidgetsBindingObserver i return total; } - /// Visitas de [queue] que não estão no disco de [owner]. + /// Visitas de [queue] que não estão no disco de [owner]: pendentes, em + /// conflito (#79) e recusadas. Future _onlyInRam(String owner, OfflineVisitQueue queue) async { - final inQueue = [...queue.pendingVisits, ...queue.rejectedVisits]; + final inQueue = [...queue.pendingVisits, ...queue.conflictVisits, ...queue.rejectedVisits]; if (inQueue.isEmpty) return 0; final onDisk = {for (final row in await _storage.forOwner(owner).load()) row.localId}; return inQueue.where((visit) => !onDisk.contains(visit.localId)).length; @@ -330,7 +356,9 @@ class _SinalAcsAppState extends State with WidgetsBindingObserver i } else { elsewhere++; } - if (visit.rejectionReason != null) { + // Recusada ou em conflito (#79): as duas dependem de uma decisão do + // ACS dono, não de conexão. + if (visit.rejectionReason != null || visit.inConflict) { review++; } else if (owner != null && owner != currentOwner && ownersWithoutToken.contains(owner)) { noToken++; @@ -363,7 +391,7 @@ class _SinalAcsAppState extends State with WidgetsBindingObserver i // Memória: visitas de filas que não estão no disco do dono delas. final filas = {..._queues, currentOwner: current}; for (final MapEntry(key: owner, value: queue) in filas.entries) { - final naFila = [...queue.pendingVisits, ...queue.rejectedVisits]; + final naFila = [...queue.pendingVisits, ...queue.conflictVisits, ...queue.rejectedVisits]; if (naFila.isEmpty) continue; final noDisco = disco[owner] ?? {for (final row in await _storage.forOwner(owner).load()) row.localId}; for (final visit in naFila) { @@ -392,7 +420,7 @@ class _SinalAcsAppState extends State with WidgetsBindingObserver i Future wipe(String currentOwner, OfflineVisitQueue current) async { // A memória primeiro: o disco não enxerga a fila que não conseguiu gravar. final inMemory = {..._queues.values, current} - .fold(0, (total, queue) => total + queue.pendingCount + queue.rejectedCount); + .fold(0, (total, queue) => total + _visitsOnDevice(queue)); if (inMemory > 0) throw WipeBlocked(inMemory); // Reconta e apaga numa transação; recusa sem apagar nada se sobrou visita. @@ -1727,7 +1755,11 @@ class _AcsHomeShellState extends State with WidgetsBindingObserver setState(() => _enviandoAntesDeSair = false); final fila = widget.visitQueue; final recusadas = fila.rejectedCount; - final proprias = fila.pendingCount + recusadas; + // Em conflito também ficam no aparelho, mas o envio diferido as pula (D6): + // só sobem depois da decisão do dono, nesta conta. Não entram no "sobem + // sozinhas". + final emConflito = fila.conflictCount; + final proprias = _visitsOnDevice(fila); final outras = await _care?.pendingElsewhere(widget.acsId) ?? 0; if (!mounted) return; setState(() => _saindo = false); @@ -1754,9 +1786,7 @@ class _AcsHomeShellState extends State with WidgetsBindingObserver const Text('Tudo foi enviado. Nenhuma visita fica pendente neste aparelho.', key: Key('logout_all_sent')) else ...[ Text( - ficam == 1 - ? '1 visita ainda não enviada continua neste aparelho. Ela segue protegida e sobe sozinha quando o aplicativo estiver aberto com conexão (em até 7 dias).' - : '$ficam visitas ainda não enviadas continuam neste aparelho. Elas seguem protegidas e sobem sozinhas quando o aplicativo estiver aberto com conexão (em até 7 dias).', + _pendingVisitsOnLogout(ficam: ficam, emConflito: emConflito), key: const Key('logout_pending_visits'), ), if (outras > 0 && proprias > 0) ...[ @@ -1775,6 +1805,15 @@ class _AcsHomeShellState extends State with WidgetsBindingObserver key: const Key('logout_rejected_visits'), ), ], + if (emConflito > 0) ...[ + const SizedBox(height: 8), + Text( + emConflito == 1 + ? '1 delas está em conflito com o servidor e aguarda a sua decisão nesta conta.' + : '$emConflito delas estão em conflito com o servidor e aguardam a sua decisão nesta conta.', + key: const Key('logout_conflict_visits'), + ), + ], ], ], )), @@ -3015,7 +3054,8 @@ class _VisitRegistrationScreenState extends State { _message(context, switch (result.kind) { SyncOutcomeKind.synced => '${result.processed} visita(s) enviada(s) ao servidor.', - SyncOutcomeKind.conflict => '${result.processed} visita(s) em conflito. Continuam na fila para resolução.', + SyncOutcomeKind.conflict => '${result.processed} visita(s) em conflito com o servidor. ' + 'Toque em "Resolver conflito(s)" para comparar e escolher.', SyncOutcomeKind.empty => 'Não há visitas pendentes.', SyncOutcomeKind.error => result.message ?? (widget.queue.persistenceFailed @@ -3029,6 +3069,15 @@ class _VisitRegistrationScreenState extends State { }); } + /// Abre a lista de visitas em conflito (#79) e atualiza os contadores na volta. + Future _openConflicts() async { + await Navigator.of(context).push(MaterialPageRoute( + builder: (_) => VisitConflictListScreen(queue: widget.queue), + )); + if (!mounted) return; + setState(() {}); + } + /// Descarta as visitas recusadas, com confirmação — o registro sai do /// aparelho para sempre, e ele nunca chegou ao servidor. Future _discardRejected() async { @@ -3187,7 +3236,7 @@ class _VisitRegistrationScreenState extends State { contentPadding: EdgeInsets.zero, ), const SizedBox(height: 8), - DropdownButtonFormField(isExpanded: true, initialValue: outcome, decoration: const InputDecoration(labelText: 'Status do atendimento'), items: const ['Realizada com sucesso', 'Paciente ausente', 'Recusou atendimento'].map((v) => DropdownMenuItem(value: v, child: Text(v))).toList(), onChanged: !hasPatient || !_arrivalConfirmed ? null : (v) => setState(() => outcome = v!)), + DropdownButtonFormField(isExpanded: true, initialValue: outcome, decoration: const InputDecoration(labelText: 'Status do atendimento'), items: visitOutcomeOptions.map((v) => DropdownMenuItem(value: v, child: Text(v))).toList(), onChanged: !hasPatient || !_arrivalConfirmed ? null : (v) => setState(() => outcome = v!)), const SizedBox(height: 16), TextField( controller: notes, @@ -3219,15 +3268,25 @@ class _VisitRegistrationScreenState extends State { 'Pendentes de sincronização: ${queue.pendingCount}' '${queue.persistenceFailed ? ' (em memória)' : ''}', ), - if (queue.conflictCount > 0) Padding( - padding: const EdgeInsets.only(top: 4), - child: Text( - key: const Key('conflict_visits_count'), - 'Em conflito: ${queue.conflictCount}', - // Conflito de sincronização é operacional, não gravidade clínica. - style: TextStyle(color: context.acsRisk.accentOnSurface, fontWeight: FontWeight.bold), + if (queue.conflictCount > 0) ...[ + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + key: const Key('conflict_visits_count'), + 'Em conflito: ${queue.conflictCount}', + // Conflito de sincronização é operacional, não gravidade clínica. + style: TextStyle(color: context.acsRisk.accentOnSurface, fontWeight: FontWeight.bold), + ), ), - ), + const SizedBox(height: 8), + OutlinedButton.icon( + key: const Key('resolve_conflicts'), + onPressed: _openConflicts, + style: OutlinedButton.styleFrom(minimumSize: const Size(48, 52)), + icon: const Icon(Icons.compare_arrows), + label: const Text('Resolver conflito(s)'), + ), + ], if (queue.rejectedCount > 0) ...[ Padding( padding: const EdgeInsets.only(top: 4), diff --git a/apps/acs/lib/app/visit_conflict_screen.dart b/apps/acs/lib/app/visit_conflict_screen.dart new file mode 100644 index 00000000..10222af0 --- /dev/null +++ b/apps/acs/lib/app/visit_conflict_screen.dart @@ -0,0 +1,470 @@ +import 'package:flutter/material.dart'; +import 'package:sinalacs_acs/app/acs_theme.dart'; +import 'package:sinalacs_acs/core/services/offline_visit_queue.dart'; + +/// Opções de "Status do atendimento", as mesmas do registro da visita. +const visitOutcomeOptions = ['Realizada com sucesso', 'Paciente ausente', 'Recusou atendimento']; + +/// `dd/MM/yyyy HH:mm`, na hora local do aparelho. +String formatVisitDateTime(DateTime value) { + final local = value.toLocal(); + String dois(int n) => n.toString().padLeft(2, '0'); + return '${dois(local.day)}/${dois(local.month)}/${local.year} ${dois(local.hour)}:${dois(local.minute)}'; +} + +/// Rótulo do paciente montado na tela, nunca gravado: o disco guarda só o +/// UUID (LGPD-RF01), como no registro da visita. +String _patientLabel(String patientId) => 'Paciente ${patientId.length >= 8 ? patientId.substring(0, 8) : patientId}'; + +/// Mostra uma mensagem, substituindo a anterior em vez de enfileirar. +void _showMessage(BuildContext context, String message) { + final messenger = ScaffoldMessenger.maybeOf(context); + if (messenger == null) return; + messenger + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(message))); +} + +/// Visitas em conflito com o servidor (#79): uma linha por visita, que abre a +/// comparação. Volta ao registro pelo botão de voltar, inclusive vazia. +class VisitConflictListScreen extends StatefulWidget { + const VisitConflictListScreen({super.key, required this.queue}); + + final OfflineVisitQueue queue; + + @override + State createState() => _VisitConflictListScreenState(); +} + +class _VisitConflictListScreenState extends State { + Future _open(OfflineVisitRecord visit) async { + final message = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => VisitConflictCompareScreen(queue: widget.queue, localId: visit.localId), + ), + ); + if (!mounted) return; + setState(() {}); + if (message != null) _showMessage(context, message); + } + + @override + Widget build(BuildContext context) { + final conflicts = widget.queue.conflictVisits; + return Scaffold( + appBar: AppBar(title: const Text('Visitas em conflito')), + body: conflicts.isEmpty + ? const Center( + child: Padding( + padding: EdgeInsets.all(20), + child: Text( + key: Key('conflict_list_empty'), + 'Nenhuma visita em conflito.', + textAlign: TextAlign.center, + ), + ), + ) + : ListView( + padding: const EdgeInsets.all(20), + children: [ + const Text( + 'O servidor tem outra versão destas visitas. Abra cada uma para ' + 'comparar e escolher qual vale.', + ), + const SizedBox(height: 12), + for (final visit in conflicts) + Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + key: Key('conflict_item_${visit.localId}'), + minVerticalPadding: 12, + title: Text(_patientLabel(visit.patientId)), + subtitle: Text('Registrada em ${formatVisitDateTime(visit.createdAt)}'), + trailing: const Icon(Icons.chevron_right), + onTap: () => _open(visit), + ), + ), + ], + ), + ); + } +} + +/// Comparação lado a lado de uma visita em conflito e a decisão do ACS (#79): +/// manter a do aparelho, editar e enviar, ou manter a do servidor. +/// +/// Sai da tela (devolvendo a mensagem a quem abriu) quando a visita deixa de +/// estar em conflito; fica quando o servidor mudou de novo ou a rede falhou. +class VisitConflictCompareScreen extends StatefulWidget { + const VisitConflictCompareScreen({super.key, required this.queue, required this.localId}); + + final OfflineVisitQueue queue; + final String localId; + + @override + State createState() => _VisitConflictCompareScreenState(); +} + +class _VisitConflictCompareScreenState extends State { + /// Trava todas as ações enquanto uma chamada está em voo (sem duplo envio). + bool _busy = false; + String? _status; + bool _editing = false; + String? _editOutcome; + final _editNotes = TextEditingController(); + + @override + void dispose() { + _editNotes.dispose(); + super.dispose(); + } + + OfflineVisitRecord? get _visit { + for (final visit in widget.queue.conflictVisits) { + if (visit.localId == widget.localId) return visit; + } + return null; + } + + /// Volta para a lista com [message]; aberta sem lista por baixo, mostra aqui. + void _leave(String message) { + final navigator = Navigator.of(context); + if (navigator.canPop()) { + navigator.pop(message); + } else { + setState(() { + _busy = false; + _status = message; + }); + } + } + + Future _keepLocal({String? outcome, String? notes}) async { + if (_busy) return; + setState(() { + _busy = true; + _status = null; + }); + SyncOutcome result; + try { + result = await widget.queue.resolveKeepLocal(widget.localId, outcome: outcome, notes: notes); + } catch (error) { + result = SyncOutcome(kind: SyncOutcomeKind.error, processed: 0, message: '$error'); + } + if (!mounted) return; + switch (result.kind) { + case SyncOutcomeKind.synced: + _leave('Versão do aparelho enviada ao servidor.'); + case SyncOutcomeKind.rejected: + _leave('O servidor recusou esta visita: ${result.message ?? 'motivo não informado.'}'); + case SyncOutcomeKind.conflict: + setState(() { + _busy = false; + _status = 'A visita mudou de novo no servidor. Compare outra vez antes de decidir.'; + }); + case SyncOutcomeKind.error: + case SyncOutcomeKind.empty: + setState(() { + _busy = false; + _status = result.message ?? 'Não foi possível enviar. A visita continua neste aparelho.'; + }); + } + } + + void _startEditing(OfflineVisitRecord visit) { + setState(() { + if (!_editing) { + _editOutcome = visitOutcomeOptions.contains(visit.outcome) ? visit.outcome : null; + _editNotes.text = visit.notes; + } + _editing = true; + }); + } + + Future _sendEdited() => _keepLocal(outcome: _editOutcome, notes: _editNotes.text.trim()); + + /// "Manter a do servidor" (D4): só local, atrás de confirmação destrutiva. + Future _keepServer() async { + if (_busy) return; + final confirmed = await showDialog( + context: context, + builder: (dialog) => AlertDialog( + scrollable: true, + title: const Text('Descartar a versão deste aparelho?'), + content: const Text( + 'A visita registrada neste aparelho será apagada e a do servidor continua valendo. ' + 'Isso não pode ser desfeito.', + ), + actions: [ + TextButton( + key: const Key('cancel_keep_server'), + style: TextButton.styleFrom(minimumSize: const Size(48, 52)), + onPressed: () => Navigator.pop(dialog, false), + child: const Text('Cancelar'), + ), + TextButton( + key: const Key('confirm_keep_server'), + style: TextButton.styleFrom(minimumSize: const Size(48, 52)), + onPressed: () => Navigator.pop(dialog, true), + child: Text( + 'Descartar a minha', + style: TextStyle(color: dialog.acsRisk.redOnSurface, fontWeight: FontWeight.bold), + ), + ), + ], + ), + ); + if (confirmed != true || !mounted || _busy) return; + setState(() { + _busy = true; + _status = null; + }); + await widget.queue.resolveKeepServer(widget.localId); + if (!mounted) return; + _leave('A versão deste aparelho foi descartada. A do servidor continua valendo.'); + } + + @override + Widget build(BuildContext context) { + final visit = _visit; + final status = _status; + final statusWidget = status == null + ? null + : Padding( + padding: const EdgeInsets.only(top: 12), + child: Semantics( + key: const Key('conflict_status'), + liveRegion: true, + child: Text( + status, + style: TextStyle(color: context.acsRisk.accentOnSurface, fontWeight: FontWeight.bold), + ), + ), + ); + + return Scaffold( + appBar: AppBar(title: const Text('Comparar versões')), + // Rolagem sem construção preguiçosa: a tela é curta, e as ações têm de + // existir na árvore mesmo abaixo da dobra (leitor de tela, testes). + body: SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: visit == null + ? [ + const Text('Esta visita não está mais em conflito neste aparelho.'), + if (statusWidget != null) statusWidget, + ] + : [ + Text( + _patientLabel(visit.patientId), + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 4), + Text('Registrada em ${formatVisitDateTime(visit.createdAt)}'), + const SizedBox(height: 8), + const Text( + 'Esta visita foi alterada no servidor depois de registrada aqui. ' + 'Compare as duas versões e escolha qual vale.', + ), + const SizedBox(height: 16), + _panels(context, visit), + if (statusWidget != null) statusWidget, + const SizedBox(height: 20), + ..._actions(context, visit), + ], + ), + ), + ); + } + + Widget _panels(BuildContext context, OfflineVisitRecord visit) { + final server = visit.conflictServer; + final localStatus = visit.outcome.isEmpty ? visit.status : visit.outcome; + final localCompleted = formatVisitDateTime(visit.createdAt); + final serverCompleted = server?.completedAt == null ? 'Sem registro' : formatVisitDateTime(server!.completedAt!); + + final local = _Panel( + key: const Key('conflict_local'), + title: 'Neste aparelho', + rows: [ + _Row(label: 'Status do atendimento', value: localStatus), + _Row(label: 'Observações', value: _notesLabel(visit.notes)), + _Row(label: 'Concluída em', value: localCompleted), + ], + ); + final remote = _Panel( + key: const Key('conflict_server'), + title: 'No servidor (versão ${server?.version ?? visit.conflictServerVersion})', + unavailable: server == null, + rows: server == null + ? const [] + : [ + _Row( + label: 'Status do atendimento', + value: server.status, + diffKey: server.status.trim() == localStatus.trim() ? null : const Key('conflict_diff_status'), + ), + _Row( + label: 'Observações', + value: _notesLabel(server.notes), + diffKey: server.notes.trim() == visit.notes.trim() ? null : const Key('conflict_diff_notes'), + ), + _Row( + label: 'Concluída em', + value: serverCompleted, + diffKey: serverCompleted == localCompleted ? null : const Key('conflict_diff_completed'), + ), + ], + ); + + // Lado a lado só com largura de tablet; no celular, um embaixo do outro. + if (MediaQuery.sizeOf(context).width >= 600) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: local), + const SizedBox(width: 12), + Expanded(child: remote), + ], + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [local, const SizedBox(height: 12), remote], + ); + } + + static String _notesLabel(String notes) => notes.trim().isEmpty ? 'Sem observações' : notes; + + List _actions(BuildContext context, OfflineVisitRecord visit) { + const size = Size(48, 52); + return [ + FilledButton.icon( + key: const Key('conflict_keep_local'), + onPressed: _busy ? null : () => _keepLocal(), + style: FilledButton.styleFrom(minimumSize: size), + icon: _busy + ? const SizedBox(height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.phone_android), + label: const Text('Manter a minha versão'), + ), + const SizedBox(height: 12), + OutlinedButton.icon( + key: const Key('conflict_edit'), + onPressed: _busy ? null : () => _startEditing(visit), + style: OutlinedButton.styleFrom(minimumSize: size), + icon: const Icon(Icons.edit_outlined), + label: const Text('Editar e enviar'), + ), + if (_editing) ...[ + const SizedBox(height: 12), + DropdownButtonFormField( + key: const Key('conflict_edit_outcome'), + isExpanded: true, + initialValue: _editOutcome, + decoration: const InputDecoration(labelText: 'Status do atendimento'), + items: visitOutcomeOptions.map((v) => DropdownMenuItem(value: v, child: Text(v))).toList(), + onChanged: _busy ? null : (v) => setState(() => _editOutcome = v), + ), + const SizedBox(height: 12), + TextField( + key: const Key('conflict_edit_notes'), + controller: _editNotes, + enabled: !_busy, + maxLines: 4, + decoration: const InputDecoration(labelText: 'Observações de campo'), + ), + const SizedBox(height: 12), + FilledButton.icon( + key: const Key('conflict_send_edited'), + onPressed: _busy || _editOutcome == null ? null : _sendEdited, + style: FilledButton.styleFrom(minimumSize: size), + icon: const Icon(Icons.cloud_upload_outlined), + label: const Text('Enviar versão editada'), + ), + ], + const SizedBox(height: 12), + OutlinedButton.icon( + key: const Key('conflict_keep_server'), + onPressed: _busy ? null : _keepServer, + style: OutlinedButton.styleFrom(minimumSize: size), + icon: const Icon(Icons.cloud_done_outlined), + label: const Text('Manter a do servidor'), + ), + ]; + } +} + +class _Row { + const _Row({required this.label, required this.value, this.diffKey}); + + final String label; + final String value; + + /// Não-nulo: o valor difere do outro painel; a chave marca a linha. + final Key? diffKey; +} + +class _Panel extends StatelessWidget { + const _Panel({super.key, required this.title, required this.rows, this.unavailable = false}); + + final String title; + final List<_Row> rows; + final bool unavailable; + + @override + Widget build(BuildContext context) { + final accent = context.acsRisk.accentOnSurface; + final muted = Theme.of(context).colorScheme.onSurfaceVariant; + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + if (unavailable) const Text('Conteúdo do servidor indisponível neste aparelho.'), + for (final row in rows) + Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(row.label, style: TextStyle(color: muted)), + Text(row.value, style: const TextStyle(fontWeight: FontWeight.bold)), + if (row.diffKey != null) + // Ícone + texto, não só cor (WCAG 1.4.1); cor operacional, + // nunca de risco clínico. + Semantics( + key: row.diffKey, + label: '${row.label} diferente entre aparelho e servidor', + container: true, + excludeSemantics: true, + child: Padding( + padding: const EdgeInsets.only(top: 2), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.compare_arrows, size: 18, color: accent), + const SizedBox(width: 4), + Flexible( + child: Text( + 'Diferente', + style: TextStyle(color: accent, fontWeight: FontWeight.bold), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/acs/lib/core/database/encrypted_database.dart b/apps/acs/lib/core/database/encrypted_database.dart index 1a6502f9..026ee49d 100644 --- a/apps/acs/lib/core/database/encrypted_database.dart +++ b/apps/acs/lib/core/database/encrypted_database.dart @@ -26,8 +26,10 @@ class EncryptedLocalDatabase { /// `micro_area_cache` e `micro_area_cache_meta`, o cache da microárea (RF08, /// `spec/lgpd_design.md` §5.11). v7 acrescenta `offline_visits.owner`, o dono /// da visita (id do ACS); nulo = visita legada em QUARENTENA, que nenhum dono - /// enxerga e que só sai pelo envio legado (`visits.syncLegacy`). - static const schemaVersion = 7; + /// enxerga e que só sai pelo envio legado (`visits.syncLegacy`). v8 acrescenta + /// `conflict_server_version`/`conflict_server_snapshot`: a visita em conflito + /// e a cópia do servidor ficam no aparelho até o ACS decidir (#79). + static const schemaVersion = 8; /// Visitas registradas offline, aguardando sincronização. /// @@ -47,7 +49,9 @@ CREATE TABLE IF NOT EXISTS offline_visits ( created_at TEXT NOT NULL, version INTEGER NOT NULL, rejection_reason TEXT, - owner TEXT + owner TEXT, + conflict_server_version INTEGER, + conflict_server_snapshot TEXT )'''; /// Índice por dono: toda leitura/escrita de visitas filtra por `owner`. @@ -79,7 +83,7 @@ CREATE TABLE IF NOT EXISTS micro_area_cache_meta ( value TEXT NOT NULL )'''; - /// Migração v1 → v2, v2 → v3, v3 → v4, v4 → v5, v5 → v6 e v6 → v7. + /// Migração v1 → v2, v2 → v3, v3 → v4, v4 → v5, v5 → v6, v6 → v7 e v7 → v8. /// /// Nenhuma das duas formas de v1 guarda o UUID do paciente: `patient_name` /// era `'Paciente ' + 8 dos 32 dígitos hex`, irreversível. Sem UUID, @@ -150,6 +154,24 @@ CREATE TABLE IF NOT EXISTS micro_area_cache_meta ( } await db.execute(createOfflineVisitsOwnerIndex); } + + if (from < 8) { + // Aditiva: linhas existentes ficam com as duas colunas nulas = sem conflito. + final columns = await db.rawQuery( + "PRAGMA table_info('offline_visits')", + ); + final nomes = {for (final column in columns) column['name']}; + if (!nomes.contains('conflict_server_version')) { + await db.execute( + 'ALTER TABLE offline_visits ADD COLUMN conflict_server_version INTEGER;', + ); + } + if (!nomes.contains('conflict_server_snapshot')) { + await db.execute( + 'ALTER TABLE offline_visits ADD COLUMN conflict_server_snapshot TEXT;', + ); + } + } } /// Caminho do arquivo do banco neste dispositivo. diff --git a/apps/acs/lib/core/database/sqlcipher_visit_store.dart b/apps/acs/lib/core/database/sqlcipher_visit_store.dart index f2bd4abc..a1d4cd7b 100644 --- a/apps/acs/lib/core/database/sqlcipher_visit_store.dart +++ b/apps/acs/lib/core/database/sqlcipher_visit_store.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:sinalacs_acs/core/database/encrypted_database.dart'; import 'package:sinalacs_acs/core/security/database_key_store.dart'; @@ -181,7 +183,24 @@ OfflineVisitRecord _recordFrom(Map row) => OfflineVisitRecord( localId: row['local_id']! as String, createdAt: DateTime.parse(row['created_at']! as String), version: row['version']! as int, + conflictServerVersion: row['conflict_server_version'] as int?, + conflictServer: _snapshotFrom(row['conflict_server_snapshot'] as String?), + ); + +/// Cópia do servidor ilegível vira `null`, nunca exceção: um `load()` que +/// lançasse jogaria a fila inteira do dono só para a RAM por causa de uma +/// coluna auxiliar. O conflito continua valendo pela `conflict_server_version` +/// (a tela só não tem o que comparar). +VisitConflictSnapshot? _snapshotFrom(String? raw) { + if (raw == null) return null; + try { + return VisitConflictSnapshot.fromJson( + Map.from(jsonDecode(raw) as Map), ); + } catch (_) { + return null; + } +} /// Armazenamento por dono sobre UM [VisitDatabase]. class SqlCipherVisitStorage implements VisitStorage { @@ -338,6 +357,10 @@ class SqlCipherVisitStore implements VisitStore { 'version': visit.version, 'rejection_reason': visit.rejectionReason, 'owner': owner, + 'conflict_server_version': visit.conflictServerVersion, + 'conflict_server_snapshot': visit.conflictServer == null + ? null + : jsonEncode(visit.conflictServer!.toJson()), }); } }); diff --git a/apps/acs/lib/core/network/backend_client.dart b/apps/acs/lib/core/network/backend_client.dart index 1c426fd7..17dcc26e 100644 --- a/apps/acs/lib/core/network/backend_client.dart +++ b/apps/acs/lib/core/network/backend_client.dart @@ -138,6 +138,17 @@ abstract class AcsBackend { /// do token: o lote de A nunca pode sair com o token de B. Future> syncVisits(List visits, {String? expectedUserId}); + /// "Manter a minha versão" de uma visita em conflito (#79) por + /// `visits.resolveConflict`: compare-and-set sobre [expectedServerVersion], + /// a versão do servidor que o ACS viu. [expectedUserId] como em + /// [syncVisits]: sessão de outro dono lança [sessionOwnerMismatch] sem + /// enviar nada. + Future resolveVisitConflict( + VisitSyncEntry visit, { + required int expectedServerVersion, + String? expectedUserId, + }); + /// Envia visitas LEGADAS (sem dono, D4) por `visits.syncLegacy`. A sessão /// atual é só o transporte: o servidor grava autoria desconhecida e confere /// o território do paciente contra o de quem transporta. No máximo 200 por @@ -260,6 +271,14 @@ class MisconfiguredBackend implements AcsBackend { Future> syncVisits(List visits, {String? expectedUserId}) async => _recusar(); + @override + Future resolveVisitConflict( + VisitSyncEntry visit, { + required int expectedServerVersion, + String? expectedUserId, + }) async => + _recusar(); + @override Future> syncLegacyVisits(List visits, {required String deviceId}) async => _recusar(); @@ -760,6 +779,28 @@ class BackendClient implements AcsBackend { ); } + /// Mesma renovação, checagem de dono e tradução de falhas de [syncVisits]. + @override + Future resolveVisitConflict( + VisitSyncEntry visit, { + required int expectedServerVersion, + String? expectedUserId, + }) async { + final current = await _requireSession(); + // Depois da resolução do token e imediatamente antes da rede, como em + // `syncVisits`: a decisão de A nunca sai com o token de B. + if (expectedUserId != null && current.userId != expectedUserId) throw sessionOwnerMismatch; + final token = current.accessToken; + return _guard( + () => _client.visits.resolveConflict( + accessToken: token, + visit: visit, + expectedServerVersion: expectedServerVersion, + ), + permissionMessage: 'Sua sessão não autoriza sincronizar visitas. Entre novamente.', + ); + } + @override Future> syncLegacyVisits(List visits, {required String deviceId}) async { final token = await _requireToken(); diff --git a/apps/acs/lib/core/services/backend_visit_synchronizer.dart b/apps/acs/lib/core/services/backend_visit_synchronizer.dart index 28d5f74f..ac88c351 100644 --- a/apps/acs/lib/core/services/backend_visit_synchronizer.dart +++ b/apps/acs/lib/core/services/backend_visit_synchronizer.dart @@ -17,7 +17,10 @@ import 'package:sinalacs_client/sinalacs_client.dart'; /// recusa é recuperável — a fila converte a exceção em /// `SyncOutcomeKind.error` e o lote continua pendente, esperando o dono /// entrar de novo. -class BackendVisitSynchronizer implements VisitSynchronizer { +/// +/// Também é o [VisitConflictResolver] da fila do painel (#79): "Manter a +/// minha" vai por `visits.resolveConflict` com a mesma checagem de dono. +class BackendVisitSynchronizer implements VisitSynchronizer, VisitConflictResolver { BackendVisitSynchronizer({required this.backend, required String ownerId}) : ownerId = requireOwnerId(ownerId); @@ -37,18 +40,49 @@ class BackendVisitSynchronizer implements VisitSynchronizer { expectedUserId: ownerId, ); - return [ - for (final result in results) - VisitSyncOutcome( - localId: result.localId, - status: result.syncStatus.name, - serverVersion: result.serverVersion, - message: result.message, - ), - ]; + return [for (final result in results) visitSyncOutcomeFor(result)]; + } + + @override + Future resolve(OfflineVisitRecord visit, {required int expectedServerVersion}) async { + // Mesma dupla checagem de [push]: cedo, e de novo depois do token resolvido. + if (backend.session?.userId != ownerId) throw sessionOwnerMismatch; + final result = await backend.resolveVisitConflict( + visitSyncEntryFor(visit), + expectedServerVersion: expectedServerVersion, + expectedUserId: ownerId, + ); + return visitSyncOutcomeFor(result); } } +/// Resposta do servidor → resultado que a fila entende. A cópia do servidor +/// só é aproveitada no `conflict` (é só lá que o contrato a preenche). +VisitSyncOutcome visitSyncOutcomeFor(VisitSyncResult result) { + final serverVisit = result.serverVisit; + return VisitSyncOutcome( + localId: result.localId, + status: result.syncStatus.name, + serverVersion: result.serverVersion, + message: result.message, + serverSnapshot: result.syncStatus == SyncStatus.conflict && serverVisit != null + ? conflictSnapshotFor(serverVisit) + : null, + ); +} + +/// Visita do servidor → cópia guardada junto ao conflito, para o ACS comparar. +/// +/// As observações são o `'campo'` do mapa (o que este app grava, ver +/// [visitSyncEntryFor]); sem ele — visita gravada por outro cliente —, os +/// valores do mapa unidos por quebra de linha, para nada sumir da comparação. +VisitConflictSnapshot conflictSnapshotFor(VisitSyncEntry entry) => VisitConflictSnapshot( + version: entry.version, + status: entry.status, + notes: entry.notes['campo'] ?? entry.notes.values.join('\n'), + completedAt: entry.completedAt?.toUtc(), + ); + /// Visita da fila → contrato de `visits.sync`/`syncLegacy`/`syncDeferred`. Um /// só mapeamento para os três caminhos: o servidor deduplica pelo `localId` e /// confere a versão, então reenviar a mesma visita por outro caminho tem de diff --git a/apps/acs/lib/core/services/deferred_flush_service.dart b/apps/acs/lib/core/services/deferred_flush_service.dart index 81819a4e..534c141c 100644 --- a/apps/acs/lib/core/services/deferred_flush_service.dart +++ b/apps/acs/lib/core/services/deferred_flush_service.dart @@ -72,7 +72,7 @@ class DeferredVisitSynchronizer implements VisitSynchronizer { deviceId: deviceId, visits: [for (final visit in chunk) visitSyncEntryFor(visit)], ); - outcomes.addAll(results.map(_outcome)); + outcomes.addAll(results.map(visitSyncOutcomeFor)); } on UploadTokenRefused { tokenRefused = true; if (outcomes.isEmpty) rethrow; @@ -86,13 +86,6 @@ class DeferredVisitSynchronizer implements VisitSynchronizer { } } -VisitSyncOutcome _outcome(VisitSyncResult result) => VisitSyncOutcome( - localId: result.localId, - status: result.syncStatus.name, - serverVersion: result.serverVersion, - message: result.message, - ); - /// Sobe o que está preso no aparelho e não pertence à sessão atual (D4/D7). /// /// **A fila do dono da sessão atual NÃO é tocada aqui**: ela é do painel @@ -183,6 +176,10 @@ class DeferredFlushService { if (deviceId != null) { final synchronizer = DeferredVisitSynchronizer(backend: _backend, uploadToken: token, deviceId: deviceId); + // Sem `conflictResolver`, de propósito (#79, D6): a fila temporária + // separa as visitas em conflito no `restore` e não as envia — só o + // dono, presente, decide entre a versão dele e a do servidor. Elas + // voltam ao disco intactas e mantêm o token dele vivo (passo 3). final queue = OfflineVisitQueue(store: _MergingOwnerStore(_storage.forOwner(owner)), synchronizer: synchronizer); await queue.restore(); await queue.sync(); // nunca lança: falha vira SyncOutcome.error diff --git a/apps/acs/lib/core/services/offline_visit_queue.dart b/apps/acs/lib/core/services/offline_visit_queue.dart index 759cb944..aebeb4f7 100644 --- a/apps/acs/lib/core/services/offline_visit_queue.dart +++ b/apps/acs/lib/core/services/offline_visit_queue.dart @@ -29,6 +29,40 @@ class SyncOutcome { final String? message; } +/// Cópia da visita no servidor, guardada junto à visita local em conflito (#79). +/// +/// Sem identificação do paciente: só o que o ACS precisa para comparar. +class VisitConflictSnapshot { + const VisitConflictSnapshot({ + required this.version, + required this.status, + required this.notes, + this.completedAt, + }); + + final int version; + final String status; + final String notes; + final DateTime? completedAt; + + Map toJson() => { + 'version': version, + 'status': status, + 'notes': notes, + 'completedAt': completedAt?.toUtc().toIso8601String(), + }; + + factory VisitConflictSnapshot.fromJson(Map json) { + final completedAt = json['completedAt'] as String?; + return VisitConflictSnapshot( + version: json['version']! as int, + status: json['status']! as String, + notes: json['notes']! as String, + completedAt: completedAt == null ? null : DateTime.parse(completedAt), + ); + } +} + class OfflineVisitRecord { OfflineVisitRecord({ required this.patientId, @@ -40,6 +74,8 @@ class OfflineVisitRecord { String? localId, DateTime? createdAt, this.version = 1, + this.conflictServerVersion, + this.conflictServer, }) : localId = localId ?? _newLocalId(), createdAt = createdAt ?? DateTime.now(); @@ -82,6 +118,15 @@ class OfflineVisitRecord { /// `copyWith`. final String? rejectionReason; + /// Versão que o servidor tinha quando respondeu `conflict` (#79). Não-nulo = + /// a visita está em conflito e fica no aparelho até o ACS decidir. + final int? conflictServerVersion; + + /// Cópia da visita no servidor, vista no conflito, para o ACS comparar. + final VisitConflictSnapshot? conflictServer; + + bool get inConflict => conflictServerVersion != null; + OfflineVisitRecord copyWith({ String? patientId, String? risk, @@ -92,6 +137,8 @@ class OfflineVisitRecord { String? localId, DateTime? createdAt, int? version, + int? conflictServerVersion, + VisitConflictSnapshot? conflictServer, }) { return OfflineVisitRecord( patientId: patientId ?? this.patientId, @@ -103,6 +150,8 @@ class OfflineVisitRecord { localId: localId ?? this.localId, createdAt: createdAt ?? this.createdAt, version: version ?? this.version, + conflictServerVersion: conflictServerVersion ?? this.conflictServerVersion, + conflictServer: conflictServer ?? this.conflictServer, ); } @@ -124,6 +173,7 @@ class VisitSyncOutcome { required this.status, this.serverVersion, this.message, + this.serverSnapshot, }); final String localId; @@ -139,6 +189,11 @@ class VisitSyncOutcome { /// Ele já vinha no contrato (`VisitSyncResult.message`) e era descartado no /// caminho até aqui, o que deixava um `patientId` inválido parecendo silêncio. final String? message; + + /// Cópia da visita no servidor quando [status] é `conflict` (#79). Pode + /// faltar (caminho sem autor, servidor antigo): o conflito vale assim mesmo, + /// a tela só não tem o que comparar. + final VisitConflictSnapshot? serverSnapshot; } /// Quem leva as visitas pendentes ao servidor. @@ -149,6 +204,24 @@ abstract class VisitSynchronizer { Future> push(List visits); } +/// Quem leva ao servidor a decisão "manter a minha versão" de uma visita em +/// conflito (#79, `visits.resolveConflict`). +/// +/// Separado de [VisitSynchronizer] de propósito: o envio diferido não resolve +/// conflito (não há dono presente para decidir, D6), então não implementa isto. +abstract interface class VisitConflictResolver { + /// Envia [visit] (já com a edição do ACS) com compare-and-set sobre + /// [expectedServerVersion], a versão do servidor que o ACS viu. + Future resolve(OfflineVisitRecord visit, {required int expectedServerVersion}); +} + +/// D7: o servidor disse `conflict` sem dizer a versão. Sem versão de +/// referência não há como resolver depois, então é erro retentável. +const conflictWithoutVersionMessage = 'O servidor indicou conflito sem informar a versão.'; + +/// `resolveKeepLocal` numa fila sem [VisitConflictResolver]. +const conflictResolverMissingMessage = 'A resolução de conflitos não está configurada neste aplicativo.'; + /// Onde as visitas ficam enquanto não sincronizam. /// /// Desde que existe [OfflineVisitRecord.rejectionReason], o que [save] recebe @@ -335,12 +408,15 @@ class _InMemoryLegacyVisitStore implements LegacyVisitStore { /// /// Preserva a semântica da `SyncFsm` do backend /// (`idle → localWrite → queued → syncing → {synced|conflict|error|rejected}`): -/// uma visita em conflito **volta para a fila** em vez de ser descartada, uma -/// falha de rede não perde o lote, e uma recusa definitiva (`rejected`) SAI da -/// fila de retentativa mas fica visível no aparelho até o ACS descartá-la — -/// diferente de `error`, que continua pendente e retenta. +/// uma falha de rede não perde o lote; uma visita em conflito (#79, D3) SAI da +/// retentativa — reenviá-la só devolveria o mesmo conflito — e fica no +/// aparelho, gravada com a cópia do servidor, até o ACS decidir +/// ([resolveKeepLocal]/[resolveKeepServer]); uma recusa definitiva +/// (`rejected`) também sai da retentativa e fica visível até o ACS +/// descartá-la — diferente de `error`, que continua pendente e retenta. +/// Nenhum dos dois some do disco sozinho. class OfflineVisitQueue { - OfflineVisitQueue({VisitStore? store, this.synchronizer}) + OfflineVisitQueue({VisitStore? store, this.synchronizer, this.conflictResolver}) : _store = store ?? InMemoryVisitStore(); final VisitStore _store; @@ -348,6 +424,9 @@ class OfflineVisitQueue { /// `null` mantém o comportamento local: a fila enfileira, mas não envia. final VisitSynchronizer? synchronizer; + /// `null`: os conflitos ficam guardados, mas "Manter a minha" devolve erro. + final VisitConflictResolver? conflictResolver; + final List _pending = []; final List _synced = []; final List _conflicts = []; @@ -377,8 +456,9 @@ class OfflineVisitQueue { /// Recarrega o que ficou gravado de execuções anteriores. /// /// Sem isso, fechar o app perde a fila — o oposto de offline-first. O que - /// veio do disco é separado entre pendente e recusado pelo próprio - /// [OfflineVisitRecord.rejectionReason] — não há uma segunda tabela. + /// veio do disco é separado entre pendente, em conflito e recusado pelos + /// próprios campos do registro ([OfflineVisitRecord.inConflict] primeiro, + /// depois [OfflineVisitRecord.rejectionReason]) — não há uma segunda tabela. Future restore() async { if (_restored) return; _restored = true; @@ -386,9 +466,16 @@ class OfflineVisitQueue { try { final stored = await _store.load(); _pending.clear(); + _conflicts.clear(); _rejected.clear(); for (final visit in stored) { - (visit.rejectionReason == null ? _pending : _rejected).add(visit); + if (visit.inConflict) { + _conflicts.add(visit); + } else if (visit.rejectionReason != null) { + _rejected.add(visit); + } else { + _pending.add(visit); + } } _persistenceFailed = false; } catch (_) { @@ -415,14 +502,164 @@ class OfflineVisitQueue { await _persist(); } + /// "Manter a do servidor" (#79, D4): apaga a cópia deste aparelho da visita + /// em conflito. Só local — o servidor não é chamado, nada muda lá. A tela + /// pede confirmação antes. `localId` desconhecido não faz nada. + Future resolveKeepServer(String localId) async { + final before = _conflicts.length; + _conflicts.removeWhere((visit) => visit.localId == localId); + if (_conflicts.length == before) return; + await _persist(); + } + + Future? _resolving; + + /// "Manter a minha versão" / "Editar e enviar" (#79, D5): aplica [outcome] e + /// [notes] editados (nulos = mantém os do aparelho) e envia a visita por + /// `visits.resolveConflict`, com a versão do servidor que o ACS viu. + /// + /// `synced` → sai do aparelho; `conflict` → fica, com a cópia nova do + /// servidor e a edição preservada; `rejected` → vai para as recusadas; erro + /// de rede → fica em conflito como estava, mas com a edição gravada. + /// + /// Espera um [sync] em voo antes de ler o registro, e as resoluções correm + /// uma de cada vez. + Future resolveKeepLocal(String localId, {String? outcome, String? notes}) { + final previous = _resolving; + late final Future run; + run = () async { + if (previous != null) await previous.then((_) {}, onError: (_) {}); + final syncing = _syncing; + if (syncing != null) await syncing.then((_) {}, onError: (_) {}); + return _resolveKeepLocalNow(localId, outcome: outcome, notes: notes); + }() + .whenComplete(() { + if (identical(_resolving, run)) _resolving = null; + }); + return _resolving = run; + } + + Future _resolveKeepLocalNow(String localId, {String? outcome, String? notes}) async { + final index = _conflicts.indexWhere((visit) => visit.localId == localId); + if (index < 0) { + return const SyncOutcome( + kind: SyncOutcomeKind.error, + processed: 0, + message: 'Esta visita não está mais em conflito neste aparelho.', + ); + } + final edited = _conflicts[index].copyWith(outcome: outcome, notes: notes); + // A edição é gravada ANTES da rede: se a chamada falhar, o que o ACS + // digitou continua no aparelho. + _conflicts[index] = edited; + await _persist(); + + final resolver = conflictResolver; + if (resolver == null) { + return const SyncOutcome( + kind: SyncOutcomeKind.error, + processed: 1, + message: conflictResolverMissingMessage, + ); + } + + final VisitSyncOutcome result; + try { + result = await resolver.resolve(edited, expectedServerVersion: edited.conflictServerVersion!); + } catch (error) { + // Mesmo contrato do `sync`: em produção é uma `BackendFailure`, cujo + // `toString()` já é a frase em português. + return SyncOutcome(kind: SyncOutcomeKind.error, processed: 1, message: '$error'); + } + + // O registro pode ter saído durante a chamada (ex.: "Manter a do + // servidor" em outra tela): nada a recolocar. + final current = _conflicts.indexWhere((visit) => visit.localId == localId); + switch (result.status) { + case 'synced': + // Descartada durante a chamada: o servidor gravou, mas o ACS já tirou + // a cópia do aparelho — contá-la como enviada daqui a ressuscitaria, + // como no ramo `rejected`. + if (current < 0) { + return const SyncOutcome(kind: SyncOutcomeKind.synced, processed: 1); + } + _conflicts.removeAt(current); + _synced.add(edited.copyWith( + status: 'SINCRONIZADO', + version: result.serverVersion ?? edited.version, + )); + await _persist(); + return const SyncOutcome(kind: SyncOutcomeKind.synced, processed: 1); + case 'conflict': + final serverVersion = result.serverVersion; + if (serverVersion == null) { + return const SyncOutcome( + kind: SyncOutcomeKind.error, + processed: 1, + message: conflictWithoutVersionMessage, + ); + } + // copyWith não zera, mas sobrescreve: versão e cópia novas, conteúdo + // editado preservado. Sem cópia nova, a anterior não vale mais. + final replaced = OfflineVisitRecord( + patientId: edited.patientId, + risk: edited.risk, + status: edited.status, + outcome: edited.outcome, + notes: edited.notes, + rejectionReason: edited.rejectionReason, + localId: edited.localId, + createdAt: edited.createdAt, + version: edited.version, + conflictServerVersion: serverVersion, + conflictServer: result.serverSnapshot, + ); + if (current >= 0) { + _conflicts[current] = replaced; + await _persist(); + } + return const SyncOutcome(kind: SyncOutcomeKind.conflict, processed: 1); + case 'rejected': + final reason = result.message ?? 'O servidor recusou esta visita em definitivo.'; + // Descartada durante a chamada ("Manter a do servidor"): o ACS já + // decidiu apagar a cópia do aparelho; recolocá-la nas recusadas a + // ressuscitaria. + if (current < 0) { + return SyncOutcome(kind: SyncOutcomeKind.rejected, processed: 1, message: reason); + } + _conflicts.removeAt(current); + // Sai do conflito para as recusadas: o registro novo não carrega a + // versão de conflito, senão `restore` o poria de volta em conflito. + _rejected.add(OfflineVisitRecord( + patientId: edited.patientId, + risk: edited.risk, + status: edited.status, + outcome: edited.outcome, + notes: edited.notes, + rejectionReason: reason, + localId: edited.localId, + createdAt: edited.createdAt, + version: edited.version, + )); + await _persist(); + return SyncOutcome(kind: SyncOutcomeKind.rejected, processed: 1, message: reason); + default: + return SyncOutcome( + kind: SyncOutcomeKind.error, + processed: 1, + message: result.message ?? 'O servidor não confirmou a visita.', + ); + } + } + /// Grava o estado corrente, sem deixar a falha derrubar quem chamou. /// - /// Persiste pendentes E recusadas: uma recusada que saísse do disco junto - /// com a fila desapareceria do aparelho no instante em que fosse recusada, - /// silenciosamente — antes mesmo de o ACS decidir descartá-la. + /// Persiste pendentes, em conflito E recusadas: as duas últimas já saíram + /// da retentativa, e sair do disco junto as faria desaparecer do aparelho, + /// silenciosamente — antes mesmo de o ACS decidir o que fazer com elas. Future _persist() async { try { - await _store.save([..._pending, ..._rejected]); + await _store.save([..._pending, ..._conflicts, ..._rejected]); _persistenceFailed = false; } catch (_) { _persistenceFailed = true; @@ -431,42 +668,34 @@ class OfflineVisitQueue { Future? _syncing; - /// `true` enquanto um [sync] está em voo (o lote pode estar no servidor). - /// Quem descarta filas (o app, ao trocar de dono) não pode descartar esta: - /// uma segunda fila sobre o mesmo dono seria regravada às cegas por esta - /// quando o envio terminasse. - bool get isSyncing => _syncing != null; + /// `true` enquanto um [sync] ou um [resolveKeepLocal] está em voo (a + /// visita pode estar no servidor). Quem descarta filas (o app, ao trocar de + /// dono) não pode descartar esta: uma segunda fila sobre o mesmo dono seria + /// regravada às cegas por esta quando o envio terminasse. + bool get isSyncing => _syncing != null || _resolving != null; /// Envia o que está pendente. **Single-flight**: chamar de novo com um envio /// em voo devolve o resultado DESSE envio (o lote nunca sai duas vezes, nem /// é contado duas vezes); o que foi registrado depois da captura do lote - /// fica para o próximo `sync()`. - Future sync({bool forceConflict = false}) { + /// fica para o próximo `sync()`. Visitas em conflito não fazem parte do + /// lote (D3): reenviá-las só devolveria o mesmo conflito. + Future sync() { final inFlight = _syncing; if (inFlight != null) return inFlight; late final Future run; - run = _syncOnce(forceConflict: forceConflict).whenComplete(() { + run = _syncOnce().whenComplete(() { if (identical(_syncing, run)) _syncing = null; }); return _syncing = run; } - Future _syncOnce({required bool forceConflict}) async { + Future _syncOnce() async { if (_pending.isEmpty) { return const SyncOutcome(kind: SyncOutcomeKind.empty, processed: 0); } final batch = List.from(_pending); - // Gancho de teste preservado do comportamento anterior. - if (forceConflict) { - return _applyOutcomes(batch, [ - VisitSyncOutcome(localId: batch.first.localId, status: 'conflict'), - for (final visit in batch.skip(1)) - VisitSyncOutcome(localId: visit.localId, status: 'synced'), - ]); - } - final sender = synchronizer; if (sender == null) { // Sem sincronizador configurado a fila permanece local; nada é @@ -524,10 +753,24 @@ class OfflineVisitQueue { )); synced++; case 'conflict': - final conflicted = visit.copyWith(status: 'CONFLITO'); - _conflicts.add(conflicted); - // Volta para a fila: conflito precisa de resolução, não de descarte. - stillPending.add(conflicted); + final serverVersion = outcome?.serverVersion; + if (serverVersion == null) { + // D7: sem a versão do servidor não há referência para resolver + // depois. Fica pendente e retenta, como um `error`. + stillPending.add(visit); + errors++; + firstError ??= conflictWithoutVersionMessage; + break; + } + // D3: sai da retentativa e espera a decisão do ACS, gravada com a + // cópia do servidor. Substitui um conflito anterior da mesma visita + // em vez de duplicar. + _conflicts + ..removeWhere((c) => c.localId == visit.localId) + ..add(visit.copyWith( + conflictServerVersion: serverVersion, + conflictServer: outcome?.serverSnapshot, + )); conflicts++; case 'rejected': // Terminal: SAI da retentativa, mas fica visível até o ACS diff --git a/apps/acs/lib/core/services/visit_queue_factory.dart b/apps/acs/lib/core/services/visit_queue_factory.dart index c3b7ae58..3b52927c 100644 --- a/apps/acs/lib/core/services/visit_queue_factory.dart +++ b/apps/acs/lib/core/services/visit_queue_factory.dart @@ -36,11 +36,11 @@ OfflineVisitQueue buildVisitQueue({ required AcsBackend backend, required VisitStore store, required String ownerId, -}) => - OfflineVisitQueue( - store: store, - synchronizer: BackendVisitSynchronizer(backend: backend, ownerId: ownerId), - ); +}) { + // O mesmo objeto envia e resolve conflitos (#79): uma só checagem de dono. + final sender = BackendVisitSynchronizer(backend: backend, ownerId: ownerId); + return OfflineVisitQueue(store: store, synchronizer: sender, conflictResolver: sender); +} /// Fila e pull do dono de [session] sobre UM [storage]. /// diff --git a/apps/acs/test/backend_client_test.dart b/apps/acs/test/backend_client_test.dart index 26ae6563..e0c11858 100644 --- a/apps/acs/test/backend_client_test.dart +++ b/apps/acs/test/backend_client_test.dart @@ -2,6 +2,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:sinalacs_acs/core/network/backend_client.dart'; import 'package:sinalacs_acs/core/network/backend_config.dart'; import 'package:sinalacs_acs/core/security/session_token_store.dart'; +import 'package:sinalacs_acs/core/services/backend_visit_synchronizer.dart'; +import 'package:sinalacs_client/sinalacs_client.dart'; import 'support/fake_rpc_server.dart'; @@ -138,6 +140,61 @@ void main() { ); }); + group('resolução de conflito de visita (#79)', () { + VisitSyncEntry entrada({Map notes = const {'campo': 'obs editada'}}) => VisitSyncEntry( + localId: '00000000-0000-4000-8000-0000000000c1', + patientId: '00000000-0000-4000-8000-000000000001', + scheduledAt: DateTime.utc(2026, 10, 16, 9), + completedAt: DateTime.utc(2026, 10, 16, 9, 30), + status: 'Paciente ausente', + riskLevelBefore: RiskLevel.red, + notes: notes, + version: 1, + arrivalMethod: ArrivalMethod.manual, + ); + + test('resolveVisitConflict recusa sessão de outro dono', () async { + await backend.login(matricula: 'ACS-001', senha: 'senha-sintetica'); + + await expectLater( + backend.resolveVisitConflict(entrada(), expectedServerVersion: 3, expectedUserId: 'outro-acs'), + throwsA(same(sessionOwnerMismatch)), + ); + expect(server.requests.where((r) => r.method == 'resolveConflict'), isEmpty, + reason: 'a decisão de um ACS nunca sai com o token de outro'); + }); + + test('resolveVisitConflict envia a versão vista com a sessão do próprio dono', () async { + final sessao = await backend.login(matricula: 'ACS-001', senha: 'senha-sintetica'); + + final resultado = await backend.resolveVisitConflict( + entrada(), + expectedServerVersion: 3, + expectedUserId: sessao.userId, + ); + + expect(resultado.syncStatus, SyncStatus.synced); + expect(resultado.serverVersion, 4); + final pedido = server.requests.singleWhere((r) => r.method == 'resolveConflict'); + expect(pedido.endpoint, 'visits'); + expect(pedido.args['expectedServerVersion'], 3); + expect(pedido.args['accessToken'], isNotEmpty); + }); + + test('conflictSnapshotFor usa o campo das observações, ou une os valores sem ele', () { + final comCampo = conflictSnapshotFor(entrada(notes: {'campo': 'obs do servidor', 'outro': 'x'})); + expect(comCampo.notes, 'obs do servidor'); + expect(comCampo.version, 1); + expect(comCampo.status, 'Paciente ausente'); + expect(comCampo.completedAt, DateTime.utc(2026, 10, 16, 9, 30)); + + final semCampo = conflictSnapshotFor(entrada(notes: {'a': 'primeira', 'b': 'segunda'})); + expect(semCampo.notes, 'primeira\nsegunda'); + + expect(conflictSnapshotFor(entrada(notes: const {})).notes, ''); + }); + }); + group('host do RPC', () { test('recusa http — a porta em texto claro não existe mais', () { expect( diff --git a/apps/acs/test/contrast_tokens_light_test.dart b/apps/acs/test/contrast_tokens_light_test.dart index c41be5af..5a7f91f1 100644 --- a/apps/acs/test/contrast_tokens_light_test.dart +++ b/apps/acs/test/contrast_tokens_light_test.dart @@ -54,4 +54,12 @@ void main() { test('green (fill, desenhado para o escuro) usado como texto sobre card claro fica abaixo de 4.5:1', () { expect(contrastOn(AcsColors.green, AcsLightColors.surfaceRaised), lessThan(normalText)); }); + + // "Descartar a minha" (#79) pinta `redOnSurface` sobre o fundo do + // `AlertDialog`, que no Material 3 é `surfaceContainerHigh` do esquema + // (derivado da semente), não `surfaceRaised`. + test('redOnSurface sobre diálogo (confirmação destrutiva do conflito)', () { + final dialogo = buildAcsLightTheme().colorScheme.surfaceContainerHigh; + expect(contrastOn(risk.redOnSurface, dialogo), greaterThanOrEqualTo(normalText)); + }); } diff --git a/apps/acs/test/contrast_tokens_test.dart b/apps/acs/test/contrast_tokens_test.dart index 7933023f..1df4d703 100644 --- a/apps/acs/test/contrast_tokens_test.dart +++ b/apps/acs/test/contrast_tokens_test.dart @@ -69,4 +69,12 @@ void main() { final ratio = contrastOn(const Color(0xFFEF4444), AcsDarkColors.surfaceRaised); expect(ratio, lessThan(normalText)); }); + + // "Descartar a minha" (#79) pinta `redOnSurface` sobre o fundo do + // `AlertDialog`, que no Material 3 é `surfaceContainerHigh` do esquema + // (derivado da semente), não `surfaceRaised`. + test('redOnSurface sobre diálogo (confirmação destrutiva do conflito)', () { + final dialogo = buildAcsDarkTheme().colorScheme.surfaceContainerHigh; + expect(contrastOn(risk.redOnSurface, dialogo), greaterThanOrEqualTo(normalText)); + }); } diff --git a/apps/acs/test/deferred_flush_service_test.dart b/apps/acs/test/deferred_flush_service_test.dart index a824c871..82117f9f 100644 --- a/apps/acs/test/deferred_flush_service_test.dart +++ b/apps/acs/test/deferred_flush_service_test.dart @@ -219,6 +219,34 @@ void main() { expect(report.remaining, 1); }); + test('envio diferido não manda nem apaga visita em conflito', () async { + // #79, D6: só o dono, presente, decide o conflito. + final conflito = visita('a-conflito').copyWith( + conflictServerVersion: 3, + conflictServer: const VisitConflictSnapshot(version: 3, status: 'Paciente ausente', notes: ''), + ); + await storage.forOwner(acsA).save([conflito, visita('a-1', n: 2)]); + await tokens.write(acsA, 'upload-a'); + + final report = await servico().flushAll(); + + expect([for (final e in backend.deferredBatches.single.visits) e.localId], ['a-1'], + reason: 'a visita em conflito não entra no lote'); + final restantes = await storage.forOwner(acsA).load(); + expect(ids(restantes), ['a-conflito']); + expect(restantes.single.conflictServerVersion, 3); + expect(restantes.single.conflictServer?.status, 'Paciente ausente'); + expect(backend.revokedUploadTokens, isEmpty, reason: 'a linha em conflito mantém o token do dono vivo'); + expect(await tokens.read(acsA), 'upload-a'); + expect(report.sent, 1); + expect(report.remaining, 1); + + // Segunda execução: o conflito continua sem sair. + await servico().flushAll(); + expect(backend.deferredBatches, hasLength(1)); + expect(ids(await storage.forOwner(acsA).load()), ['a-conflito']); + }); + test('erro de rede no meio: nada é apagado; o próximo flushAll reenvia o mesmo localId (idempotente)', () async { await storage.forOwner(acsA).save([visita('a-1'), visita('a-2')]); await tokens.write(acsA, 'upload-a'); diff --git a/apps/acs/test/device_wipe_test.dart b/apps/acs/test/device_wipe_test.dart index 28fe92ee..be1eb393 100644 --- a/apps/acs/test/device_wipe_test.dart +++ b/apps/acs/test/device_wipe_test.dart @@ -428,6 +428,149 @@ void main() { expect(fila.pendingCount, 1); }); + testWidgets('conflito só na MEMÓRIA também bloqueia a limpeza (#79)', (tester) async { + await io(tester, () => gravarCursorECache(storage)); + // O conflito saiu da retentativa (D3): não conta mais como pendente, e + // ainda assim é trabalho de campo que não subiu. + final fila = OfflineVisitQueue( + store: FailingVisitStore(failOnLoad: false), + synchronizer: FakeVisitSynchronizer(statusFor: (_) => 'conflict'), + ); + await tester.pumpWidget(SinalAcsApp( + backend: backend, + visitStorage: storage, + visitQueue: fila, + uploadTokens: tokens, + deviceIds: MemoryDeviceIdStore('aparelho-sintetico'), + feedBuilder: (q) => FakeAlertFeed(q), + )); + await assentar(tester); + await entrarComo(tester, acsA); + final antes = await io(tester, () => contarTabelas(storage)); + await ateAConfirmacao(tester); + + await fila.add(pendente('conflito-na-ram')); + await tester.runAsync(fila.sync); + expect(fila.pendingCount, 0); + expect(fila.conflictCount, 1); + expect(fila.persistenceFailed, isTrue); + await tester.tap(find.byKey(const Key('wipe_confirm'))); + await assentar(tester); + + await esperarBloqueio(tester, visitas: 1, antes: antes); + expect(fila.conflictCount, 1); + }); + + testWidgets('conflito no DISCO do próprio ACS bloqueia pedindo revisão, não rede (#79)', (tester) async { + // O conflito depende da decisão do ACS dono, não de conexão: dizer + // "conecte e tente de novo" mandaria o ACS esperar algo que não vem. + await io(tester, () async { + await storage.forOwner(acsA).save([ + pendente('a-conflito').copyWith( + conflictServerVersion: 3, + conflictServer: const VisitConflictSnapshot(version: 3, status: 'Paciente ausente', notes: ''), + ), + ]); + await gravarCursorECache(storage); + }); + final antes = await io(tester, () => contarTabelas(storage)); + await abrirApp(tester); + await entrarComo(tester, acsA); + + await tocarEmLimpar(tester); + + await esperarBloqueio(tester, visitas: 1, antes: antes, motivo: 'revisao'); + expect((await io(tester, () => storage.forOwner(acsA).load())).single.inConflict, isTrue); + }); + + testWidgets('conflito só na MEMÓRIA entra na contagem antes da confirmação (#79)', (tester) async { + await io(tester, () => gravarCursorECache(storage)); + final fila = OfflineVisitQueue( + store: FailingVisitStore(failOnLoad: false), + synchronizer: FakeVisitSynchronizer(statusFor: (_) => 'conflict'), + ); + await tester.pumpWidget(SinalAcsApp( + backend: backend, + visitStorage: storage, + visitQueue: fila, + uploadTokens: tokens, + deviceIds: MemoryDeviceIdStore('aparelho-sintetico'), + feedBuilder: (q) => FakeAlertFeed(q), + )); + await assentar(tester); + await entrarComo(tester, acsA); + // Depois do login: o `restore` da fila do painel relê o disco (vazio). + await fila.add(pendente('conflito-na-ram')); + await tester.runAsync(fila.sync); + expect(fila.conflictCount, 1); + expect(fila.persistenceFailed, isTrue); + final antes = await io(tester, () => contarTabelas(storage)); + + await tocarEmLimpar(tester); + + // Direto no bloqueio: a confirmação não chega a abrir. + await esperarBloqueio(tester, visitas: 1, antes: antes, motivo: 'revisao'); + expect(fila.conflictCount, 1); + }); + + testWidgets('conflito só na MEMÓRIA de outro ACS aparece no "Sair" de quem entrou depois (#79)', (tester) async { + backend.syncResultFor = (entry) => VisitSyncResult( + localId: entry.localId, + syncStatus: SyncStatus.conflict, + serverVersion: 3, + ); + backend.deferredFailure = semRede; + late FakeAlertFeed feed; + await tester.pumpWidget(SinalAcsApp( + backend: backend, + visitStorage: _GravacaoDeAFalha(storage), + uploadTokens: tokens, + deviceIds: MemoryDeviceIdStore('aparelho-sintetico'), + feedBuilder: (q) => feed = FakeAlertFeed(q), + )); + await assentar(tester); + await entrarComo(tester, acsA); + + // A registra uma visita; a gravação falha e ela fica só na RAM. No + // "Sair", o envio devolve conflito: segue só na RAM, agora em conflito. + feed.deliver(testAlert(alertId: 'alerta-ram', riskLevel: 'yellow')); + await assentar(tester); + await tester.tap(find.text('Iniciar rota de visita')); + await assentar(tester); + await tester.tap(find.byKey(const Key('arrival_confirmation'))); + await assentar(tester); + final salvar = find.byKey(const Key('save_visit')); + await tester.ensureVisible(salvar); + await tester.pump(); + await tester.tap(salvar); + await assentar(tester); + await tester.pump(const Duration(seconds: 10)); + await assentar(tester); + + await irParaDoMais(tester, 'Preferências'); + final sair = find.byKey(const Key('logout_button')); + await tester.ensureVisible(sair); + await tester.pump(); + await tester.tap(sair); + await assentar(tester); + await tester.tap(find.byKey(const Key('logout_confirm'))); + await assentar(tester); + await entrarComo(tester, acsB); + + await irParaDoMais(tester, 'Preferências'); + await tester.ensureVisible(sair); + await tester.pump(); + await tester.tap(sair); + await assentar(tester); + + expect(find.byKey(const Key('logout_all_sent')), findsNothing, + reason: 'o conflito de A só na RAM não pode sumir da contagem'); + expect(find.byKey(const Key('logout_pending_visits')), findsOneWidget); + expect(find.textContaining('1 visita ainda não enviada'), findsOneWidget); + await tester.tap(find.text('Cancelar')); + await assentar(tester); + }); + testWidgets('visita que chega ao DISCO depois da confirmação aberta: a transação recusa e nada é apagado', (tester) async { await io(tester, () => gravarCursorECache(storage)); await abrirApp(tester); diff --git a/apps/acs/test/encrypted_database_test.dart b/apps/acs/test/encrypted_database_test.dart index 0b6c70e1..192f18bd 100644 --- a/apps/acs/test/encrypted_database_test.dart +++ b/apps/acs/test/encrypted_database_test.dart @@ -282,7 +282,7 @@ CREATE TABLE IF NOT EXISTS offline_visits ( ); expect(novas, hasLength(2)); expect(await atual.query('offline_visits'), hasLength(1)); - expect(EncryptedLocalDatabase.schemaVersion, 7); + expect(EncryptedLocalDatabase.schemaVersion, 8); await atual.close(); }); @@ -402,7 +402,7 @@ CREATE TABLE offline_visits ( passphrase: chave, allowUnencryptedForTesting: true, ); - expect(EncryptedLocalDatabase.schemaVersion, 7); + expect(EncryptedLocalDatabase.schemaVersion, 8); final colunas = [ for (final row in await atual.rawQuery('PRAGMA table_info(offline_visits)')) row['name']! as String, @@ -410,6 +410,68 @@ CREATE TABLE offline_visits ( expect(colunas, contains('owner')); await atual.close(); }); + + test('migração v7 → v8 preserva linhas e acrescenta as colunas de conflito', () async { + sqfliteFfiInit(); + final legado = await databaseFactoryFfi.openDatabase( + await EncryptedLocalDatabase.pathFor(nome), + options: OpenDatabaseOptions( + version: 7, + onCreate: (db, version) async { + await db.execute(''' +CREATE TABLE offline_visits ( + local_id TEXT PRIMARY KEY, patient_id TEXT NOT NULL, risk TEXT NOT NULL, + status TEXT NOT NULL, outcome TEXT NOT NULL, notes TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, version INTEGER NOT NULL, rejection_reason TEXT, + owner TEXT +)'''); + }, + ), + ); + for (final (id, dono) in [('local-v7-dono', 'acs-a'), ('local-v7-legada', null)]) { + await legado.insert('offline_visits', { + 'local_id': id, + 'patient_id': seedPatientId, + 'risk': 'red', + 'status': 'PENDENTE', + 'outcome': '', + 'notes': 'obs $id', + 'created_at': DateTime.utc(2026).toIso8601String(), + 'version': 2, + 'owner': dono, + }); + } + await legado.close(); + + final atual = await EncryptedLocalDatabase.open( + databaseName: nome, + passphrase: chave, + allowUnencryptedForTesting: true, + ); + final linhas = await atual.query('offline_visits', orderBy: 'local_id'); + expect(linhas.map((l) => l['local_id']), ['local-v7-dono', 'local-v7-legada']); + expect(linhas.map((l) => l['owner']), ['acs-a', null]); + expect(linhas.map((l) => l['notes']), ['obs local-v7-dono', 'obs local-v7-legada']); + for (final linha in linhas) { + expect(linha['conflict_server_version'], isNull); + expect(linha['conflict_server_snapshot'], isNull); + } + await atual.close(); + }); + + test('onCreate v8 tem as colunas de conflito', () async { + final atual = await EncryptedLocalDatabase.open( + databaseName: nome, + passphrase: chave, + allowUnencryptedForTesting: true, + ); + final colunas = [ + for (final row in await atual.rawQuery('PRAGMA table_info(offline_visits)')) + row['name']! as String, + ]; + expect(colunas, containsAll(['conflict_server_version', 'conflict_server_snapshot'])); + await atual.close(); + }); }); group('SqlCipherVisitStore', () { diff --git a/apps/acs/test/login_flow_test.dart b/apps/acs/test/login_flow_test.dart index dc2d5479..0ac8f050 100644 --- a/apps/acs/test/login_flow_test.dart +++ b/apps/acs/test/login_flow_test.dart @@ -485,25 +485,30 @@ void main() { expect(sender.batches.single, hasLength(1)); }); - test('deve reencolar registros em conflito e reprocessar no retry', () async { - var firstPass = true; - final sender = FakeVisitSynchronizer( - statusFor: (_) => firstPass ? 'conflict' : 'synced', - ); - final queue = OfflineVisitQueue(synchronizer: sender); + test('conflito sai da retentativa e é resolvido pela decisão do ACS', () async { + final sender = FakeVisitSynchronizer(statusFor: (_) => 'conflict'); + final resolver = _ResolvedorQueSincroniza(); + final queue = OfflineVisitQueue(synchronizer: sender, conflictResolver: resolver); - await queue.add(visit(syntheticPatientId(2))); + final registrada = visit(syntheticPatientId(2)); + await queue.add(registrada); final conflict = await queue.sync(); expect(conflict.kind, SyncOutcomeKind.conflict); - // Conflito volta para a fila: precisa de resolução, não de descarte. - expect(queue.pendingCount, 1); + // #79, D3: o conflito sai da retentativa e espera a decisão do ACS — + // não é descartado, e reenviar só devolveria o mesmo conflito. + expect(queue.pendingCount, 0); expect(queue.conflictCount, 1); + expect(queue.conflictVisits.single.conflictServerVersion, registrada.version + 1); + + expect((await queue.sync()).kind, SyncOutcomeKind.empty, reason: 'não reenvia o conflito'); + expect(sender.batches, hasLength(1)); - firstPass = false; - final retried = await queue.sync(); - expect(retried.kind, SyncOutcomeKind.synced); + final resolved = await queue.resolveKeepLocal(registrada.localId); + expect(resolved.kind, SyncOutcomeKind.synced); + expect(resolver.versoesVistas, [registrada.version + 1]); expect(queue.pendingCount, 0); + expect(queue.conflictCount, 0); expect(queue.syncedCount, 1); }); @@ -587,8 +592,11 @@ void main() { final result = await queue.sync(); expect(result.kind, SyncOutcomeKind.conflict); - // Nada saiu: as duas continuam no aparelho. - expect(queue.pendingCount, 2); + // Nada saiu do aparelho: a do erro continua pendente e retenta; a do + // conflito espera a decisão do ACS fora da retentativa (#79, D3). + expect(queue.pendingCount, 1); + expect(queue.conflictCount, 1); + expect(queue.pendingCount + queue.conflictCount, 2); }); test('visita recusada em definitivo sai da fila e não é reenviada', () async { @@ -630,8 +638,9 @@ void main() { expect(result.kind, SyncOutcomeKind.rejected); expect(queue.rejectedCount, 1); - // O conflito continua pendente para resolução — só a recusada saiu. - expect(queue.pendingCount, 1); + // As duas saem da retentativa (#79, D3): a recusada para as recusadas, + // o conflito para a decisão do ACS. Nenhuma some. + expect(queue.pendingCount, 0); expect(queue.conflictCount, 1); }); @@ -1728,3 +1737,14 @@ void main() { }); }); } + +/// Resolvedor de conflito que o servidor sempre aceita (#79). +class _ResolvedorQueSincroniza implements VisitConflictResolver { + final List versoesVistas = []; + + @override + Future resolve(OfflineVisitRecord visit, {required int expectedServerVersion}) async { + versoesVistas.add(expectedServerVersion); + return VisitSyncOutcome(localId: visit.localId, status: 'synced', serverVersion: expectedServerVersion + 1); + } +} diff --git a/apps/acs/test/offline_burst_test.dart b/apps/acs/test/offline_burst_test.dart index 02a855df..74248782 100644 --- a/apps/acs/test/offline_burst_test.dart +++ b/apps/acs/test/offline_burst_test.dart @@ -61,11 +61,11 @@ void main() { expect(fila.rejectedVisits.single.localId, 'burst-40'); expect(fila.rejectedVisits.single.rejectionReason, 'paciente de outra microárea'); expect(fila.conflictCount, 1, reason: 'o conflito fica registrado'); - // Conflito VOLTA para a fila (`_applyOutcomes`: "precisa de resolução, não de - // descarte"); a recusada, não — ela sai da retentativa e fica só visível. - expect(fila.pendingCount, 1); - expect(fila.pendingVisits.single.localId, 'burst-70'); - expect(fila.pendingVisits.single.status, 'CONFLITO'); + // Conflito sai da retentativa e espera a decisão do ACS (#79, D3); a + // recusada também sai e fica só visível. Nenhuma das duas se perde. + expect(fila.pendingCount, 0); + expect(fila.conflictVisits.single.localId, 'burst-70'); + expect(fila.conflictVisits.single.inConflict, isTrue); }); test('sem rede, as 100 continuam pendentes e a próxima tentativa as leva', () async { diff --git a/apps/acs/test/offline_visit_queue_conflict_test.dart b/apps/acs/test/offline_visit_queue_conflict_test.dart new file mode 100644 index 00000000..922bd4d6 --- /dev/null +++ b/apps/acs/test/offline_visit_queue_conflict_test.dart @@ -0,0 +1,323 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/core/network/backend_client.dart'; +import 'package:sinalacs_acs/core/services/offline_visit_queue.dart'; + +import 'support/fakes.dart'; + +/// Conflito de visita (#79, D3): sai da retentativa, fica no aparelho com a +/// cópia do servidor até o ACS decidir, e a decisão fala com o servidor só em +/// "Manter a minha". Ids e pacientes sintéticos. +void main() { + const copiaDoServidor = VisitConflictSnapshot( + version: 3, + status: 'Paciente ausente', + notes: 'Sem resposta', + ); + + OfflineVisitRecord visita(String localId, {int n = 1}) => OfflineVisitRecord( + localId: localId, + patientId: syntheticPatientId(n), + risk: 'red', + status: 'PENDENTE', + outcome: 'Realizada com sucesso', + notes: 'obs do aparelho', + ); + + OfflineVisitRecord emConflito(String localId, {int versao = 3}) => visita(localId).copyWith( + conflictServerVersion: versao, + conflictServer: VisitConflictSnapshot(version: versao, status: 'Paciente ausente', notes: 'Sem resposta'), + ); + + test('conflito sai da retentativa e guarda a cópia do servidor', () async { + final envio = _Sincronizador((v) => VisitSyncOutcome( + localId: v.localId, + status: 'conflict', + serverVersion: 3, + serverSnapshot: copiaDoServidor, + )); + final store = InMemoryVisitStore(); + final fila = OfflineVisitQueue(store: store, synchronizer: envio); + await fila.add(visita('local-1')); + + final resultado = await fila.sync(); + + expect(resultado.kind, SyncOutcomeKind.conflict); + expect(fila.pendingCount, 0); + expect(fila.conflictCount, 1); + final c = fila.conflictVisits.single; + expect(c.conflictServerVersion, 3); + expect(c.conflictServer, same(copiaDoServidor)); + expect(c.outcome, 'Realizada com sucesso', reason: 'o conteúdo do aparelho não muda'); + + final disco = await store.load(); + expect(disco.single.conflictServerVersion, 3, reason: 'o conflito é persistido'); + }); + + test('conflito repetido não duplica', () async { + final envio = _Sincronizador((v) => VisitSyncOutcome( + localId: v.localId, + status: 'conflict', + serverVersion: 3, + serverSnapshot: copiaDoServidor, + )); + final fila = OfflineVisitQueue(synchronizer: envio); + await fila.add(visita('local-1')); + await fila.add(visita('local-2', n: 2)); + + await fila.sync(); + await fila.sync(); + await fila.sync(); + + expect(fila.conflictCount, 2); + expect(envio.lotes, hasLength(1), reason: 'a visita em conflito não volta a ser enviada'); + expect(envio.lotes.single.map((v) => v.localId), ['local-1', 'local-2']); + }); + + test('restore separa pendente, conflito e recusada', () async { + final store = InMemoryVisitStore(); + await store.save([ + visita('pendente'), + emConflito('conflito'), + visita('recusada').copyWith(rejectionReason: 'paciente fora da sua microárea'), + ]); + + final fila = OfflineVisitQueue(store: store); + await fila.restore(); + + expect(fila.pendingCount, 1); + expect(fila.conflictCount, 1); + expect(fila.rejectedCount, 1); + expect(fila.conflictVisits.single.localId, 'conflito'); + }); + + test('conflito sem versão é erro retentável', () async { + final envio = _Sincronizador((v) => VisitSyncOutcome(localId: v.localId, status: 'conflict')); + final fila = OfflineVisitQueue(synchronizer: envio); + await fila.add(visita('local-1')); + + final resultado = await fila.sync(); + + expect(resultado.kind, SyncOutcomeKind.error); + expect(resultado.message, 'O servidor indicou conflito sem informar a versão.'); + expect(fila.pendingCount, 1); + expect(fila.conflictCount, 0); + }); + + test('resolveKeepServer remove só aquela visita e persiste', () async { + final store = InMemoryVisitStore(); + await store.save([emConflito('c-1'), emConflito('c-2'), visita('pendente')]); + final fila = OfflineVisitQueue(store: store); + await fila.restore(); + + await fila.resolveKeepServer('c-1'); + await fila.resolveKeepServer('desconhecida'); // no-op + + expect(fila.conflictVisits.map((v) => v.localId), ['c-2']); + expect(fila.pendingCount, 1); + expect((await store.load()).map((v) => v.localId).toSet(), {'c-2', 'pendente'}); + }); + + test('resolveKeepLocal sincronizado sai do aparelho', () async { + final store = InMemoryVisitStore(); + await store.save([emConflito('c-1')]); + final resolvedor = _Resolvedor((v, _) async => VisitSyncOutcome(localId: v.localId, status: 'synced', serverVersion: 4)); + final fila = OfflineVisitQueue(store: store, conflictResolver: resolvedor); + await fila.restore(); + + final resultado = await fila.resolveKeepLocal('c-1', outcome: 'Paciente ausente', notes: 'editada'); + + expect(resultado.kind, SyncOutcomeKind.synced); + expect(resultado.processed, 1); + expect(fila.conflictCount, 0); + expect(await store.load(), isEmpty); + final chamada = resolvedor.chamadas.single; + expect(chamada.versao, 3); + expect(chamada.visita.outcome, 'Paciente ausente'); + expect(chamada.visita.notes, 'editada'); + }); + + test('resolveKeepLocal com conflito novo atualiza a cópia', () async { + final store = InMemoryVisitStore(); + await store.save([emConflito('c-1')]); + const novaCopia = VisitConflictSnapshot(version: 4, status: 'Realizada com sucesso', notes: 'outra'); + final resolvedor = _Resolvedor((v, _) async => VisitSyncOutcome( + localId: v.localId, + status: 'conflict', + serverVersion: 4, + serverSnapshot: novaCopia, + )); + final fila = OfflineVisitQueue(store: store, conflictResolver: resolvedor); + await fila.restore(); + + final resultado = await fila.resolveKeepLocal('c-1', notes: 'editada'); + + expect(resultado.kind, SyncOutcomeKind.conflict); + final c = fila.conflictVisits.single; + expect(c.conflictServerVersion, 4); + expect(c.conflictServer, same(novaCopia)); + expect(c.notes, 'editada', reason: 'a edição do ACS não se perde'); + expect(c.outcome, 'Realizada com sucesso'); + final disco = (await store.load()).single; + expect(disco.conflictServerVersion, 4); + expect(disco.notes, 'editada'); + }); + + test('resolveKeepLocal recusado vai para recusadas', () async { + final store = InMemoryVisitStore(); + await store.save([emConflito('c-1')]); + final resolvedor = _Resolvedor((v, _) async => VisitSyncOutcome( + localId: v.localId, + status: 'rejected', + message: 'paciente fora da sua microárea', + )); + final fila = OfflineVisitQueue(store: store, conflictResolver: resolvedor); + await fila.restore(); + + final resultado = await fila.resolveKeepLocal('c-1'); + + expect(resultado.kind, SyncOutcomeKind.rejected); + expect(resultado.message, 'paciente fora da sua microárea'); + expect(fila.conflictCount, 0); + expect(fila.rejectedVisits.single.rejectionReason, 'paciente fora da sua microárea'); + expect((await store.load()).single.rejectionReason, 'paciente fora da sua microárea'); + }); + + test('resolveKeepLocal recusado não ressuscita visita descartada durante a chamada', () async { + final store = InMemoryVisitStore(); + await store.save([emConflito('c-1')]); + late final OfflineVisitQueue fila; + final resolvedor = _Resolvedor((v, _) async { + // "Manter a do servidor" correu enquanto a chamada estava em voo. + await fila.resolveKeepServer('c-1'); + return VisitSyncOutcome(localId: v.localId, status: 'rejected', message: 'paciente fora da sua microárea'); + }); + fila = OfflineVisitQueue(store: store, conflictResolver: resolvedor); + await fila.restore(); + + final resultado = await fila.resolveKeepLocal('c-1'); + + expect(resultado.kind, SyncOutcomeKind.rejected); + expect(fila.conflictCount, 0); + expect(fila.rejectedCount, 0, reason: 'o ACS já descartou a cópia do aparelho'); + expect(await store.load(), isEmpty); + }); + + test('resolveKeepLocal sincronizado não conta visita descartada durante a chamada', () async { + final store = InMemoryVisitStore(); + await store.save([emConflito('c-1')]); + late final OfflineVisitQueue fila; + final resolvedor = _Resolvedor((v, _) async { + // "Manter a do servidor" correu enquanto a chamada estava em voo. + await fila.resolveKeepServer('c-1'); + return VisitSyncOutcome(localId: v.localId, status: 'synced', serverVersion: 4); + }); + fila = OfflineVisitQueue(store: store, conflictResolver: resolvedor); + await fila.restore(); + + final resultado = await fila.resolveKeepLocal('c-1'); + + expect(resultado.kind, SyncOutcomeKind.synced); + expect(fila.conflictCount, 0); + expect(fila.syncedCount, 0, reason: 'o ACS já descartou a cópia do aparelho'); + expect(await store.load(), isEmpty); + }); + + test('resolveKeepLocal sem rede mantém o conflito', () async { + final store = InMemoryVisitStore(); + await store.save([emConflito('c-1')]); + final resolvedor = _Resolvedor((_, __) async => throw const BackendFailure('Sem conexão com o servidor.')); + final fila = OfflineVisitQueue(store: store, conflictResolver: resolvedor); + await fila.restore(); + + final resultado = await fila.resolveKeepLocal('c-1', notes: 'editada'); + + expect(resultado.kind, SyncOutcomeKind.error); + expect(resultado.message, 'Sem conexão com o servidor.'); + expect(fila.conflictCount, 1); + expect(fila.conflictVisits.single.conflictServerVersion, 3); + expect(fila.conflictVisits.single.notes, 'editada', reason: 'a edição fica gravada'); + final disco = (await store.load()).single; + expect(disco.inConflict, isTrue); + expect(disco.notes, 'editada'); + }); + + test('resolveKeepLocal sem resolvedor configurado é erro', () async { + final store = InMemoryVisitStore(); + await store.save([emConflito('c-1')]); + final fila = OfflineVisitQueue(store: store); + await fila.restore(); + + final resultado = await fila.resolveKeepLocal('c-1'); + + expect(resultado.kind, SyncOutcomeKind.error); + expect(resultado.message, 'A resolução de conflitos não está configurada neste aplicativo.'); + expect(fila.conflictCount, 1); + }); + + test('resolve aguarda sync em voo', () async { + final portao = Completer(); + final eventos = []; + final envio = _Sincronizador( + (v) => VisitSyncOutcome(localId: v.localId, status: 'synced', serverVersion: 2), + antes: () async { + eventos.add('sync começou'); + await portao.future; + eventos.add('sync terminou'); + }, + ); + final resolvedor = _Resolvedor((v, _) async { + eventos.add('resolve'); + return VisitSyncOutcome(localId: v.localId, status: 'synced', serverVersion: 4); + }); + final store = InMemoryVisitStore(); + await store.save([emConflito('c-1'), visita('pendente')]); + final fila = OfflineVisitQueue(store: store, synchronizer: envio, conflictResolver: resolvedor); + await fila.restore(); + + final sincronizando = fila.sync(); + await pumpEventQueue(); + final resolvendo = fila.resolveKeepLocal('c-1'); + await pumpEventQueue(); + expect(resolvedor.chamadas, isEmpty, reason: 'o resolvedor espera o sync em voo'); + + portao.complete(); + await sincronizando; + final resultado = await resolvendo; + + expect(resultado.kind, SyncOutcomeKind.synced); + expect(eventos, ['sync começou', 'sync terminou', 'resolve']); + expect(fila.conflictCount, 0); + expect(fila.pendingCount, 0); + expect(await store.load(), isEmpty); + }); +} + +class _Sincronizador implements VisitSynchronizer { + _Sincronizador(this.resposta, {this.antes}); + + final VisitSyncOutcome Function(OfflineVisitRecord visit) resposta; + final Future Function()? antes; + final List> lotes = []; + + @override + Future> push(List visits) async { + lotes.add(List.of(visits)); + await antes?.call(); + return [for (final visit in visits) resposta(visit)]; + } +} + +class _Resolvedor implements VisitConflictResolver { + _Resolvedor(this.resposta); + + final Future Function(OfflineVisitRecord visit, int versao) resposta; + final List<({OfflineVisitRecord visita, int versao})> chamadas = []; + + @override + Future resolve(OfflineVisitRecord visit, {required int expectedServerVersion}) { + chamadas.add((visita: visit, versao: expectedServerVersion)); + return resposta(visit, expectedServerVersion); + } +} diff --git a/apps/acs/test/session_resume_test.dart b/apps/acs/test/session_resume_test.dart index 4053cd1b..dda969e8 100644 --- a/apps/acs/test/session_resume_test.dart +++ b/apps/acs/test/session_resume_test.dart @@ -644,6 +644,67 @@ void main() { expect(formulario(), findsOneWidget); }); + OfflineVisitRecord emConflito(String localId) => OfflineVisitRecord( + localId: localId, + patientId: seedPatientId, + risk: 'red', + status: 'PENDENTE', + conflictServerVersion: 3, + conflictServer: const VisitConflictSnapshot(version: 3, status: 'Paciente ausente', notes: 'Sem resposta'), + ); + + testWidgets('"Sair" com visitas em conflito: linha própria, e "sobem sozinhas" não as conta', (tester) async { + backend = FakeAcsBackend()..syncFailure = const BackendFailure('Sem conexão com o servidor.'); + final store = InMemoryVisitStore(); + await store.save([ + OfflineVisitRecord(localId: 'local-1', patientId: seedPatientId, risk: 'red', status: 'PENDENTE'), + emConflito('local-c1'), + emConflito('local-c2'), + ]); + fila = OfflineVisitQueue(store: store, synchronizer: BackendVisitSynchronizer(backend: backend, ownerId: seedAcsId)); + await fila.restore(); + expect(fila.conflictCount, 2); + await abrirApp(tester, backend); + await entrarComSenha(tester); + await abrirSair(tester); + + expect( + find.text('3 visitas ainda não enviadas continuam neste aparelho. 1 delas segue protegida e sobe sozinha quando o aplicativo estiver aberto com conexão (em até 7 dias).'), + findsOneWidget, + ); + expect(find.byKey(const Key('logout_conflict_visits')), findsOneWidget); + expect(find.text('2 delas estão em conflito com o servidor e aguardam a sua decisão nesta conta.'), findsOneWidget); + expect(find.byKey(const Key('logout_rejected_visits')), findsNothing); + }); + + testWidgets('"Sair" só com 1 visita em conflito: nada "sobe sozinha"', (tester) async { + backend = FakeAcsBackend()..syncFailure = const BackendFailure('Sem conexão com o servidor.'); + final store = InMemoryVisitStore(); + await store.save([emConflito('local-c1')]); + fila = OfflineVisitQueue(store: store, synchronizer: BackendVisitSynchronizer(backend: backend, ownerId: seedAcsId)); + await fila.restore(); + await abrirApp(tester, backend); + await entrarComSenha(tester); + await abrirSair(tester); + + expect(find.text('1 visita ainda não enviada continua neste aparelho.'), findsOneWidget); + expect(find.textContaining('sozinha'), findsNothing); + expect(find.text('1 delas está em conflito com o servidor e aguarda a sua decisão nesta conta.'), findsOneWidget); + expect(find.byKey(const Key('logout_conflict_visits')), findsOneWidget); + }); + + testWidgets('"Sair" sem conflito não mostra a linha de conflito', (tester) async { + backend = FakeAcsBackend()..syncFailure = const BackendFailure('Sem conexão com o servidor.'); + fila = OfflineVisitQueue(synchronizer: BackendVisitSynchronizer(backend: backend, ownerId: seedAcsId)); + await fila.add(OfflineVisitRecord(localId: 'local-1', patientId: seedPatientId, risk: 'red', status: 'PENDENTE')); + await abrirApp(tester, backend); + await entrarComSenha(tester); + await abrirSair(tester); + + expect(find.byKey(const Key('logout_pending_visits')), findsOneWidget); + expect(find.byKey(const Key('logout_conflict_visits')), findsNothing); + }); + testWidgets('"Sair" com 1 alerta não confirmado usa o singular', (tester) async { await abrirPainel(tester); await abrirSair(tester); diff --git a/apps/acs/test/support/fake_rpc_server.dart b/apps/acs/test/support/fake_rpc_server.dart index c8adffa7..ce3de8cf 100644 --- a/apps/acs/test/support/fake_rpc_server.dart +++ b/apps/acs/test/support/fake_rpc_server.dart @@ -237,6 +237,12 @@ class FakeRpcServer { for (final visit in (decoded['visits'] as List).cast>()) {'localId': visit['localId'], 'syncStatus': 'synced', 'serverVersion': visit['version']}, ], + // `visits.resolveConflict` (#79): sempre aceita, versão vista + 1. + 'resolveConflict' => { + 'localId': (decoded['visit'] as Map)['localId'], + 'syncStatus': 'synced', + 'serverVersion': (decoded['expectedServerVersion'] as int) + 1, + }, 'refreshSession' => { 'accessToken': garbageAccessToken ? 'lixo' : _token(), 'tokenType': 'Bearer', diff --git a/apps/acs/test/support/fakes.dart b/apps/acs/test/support/fakes.dart index 6c0ae305..50e66b40 100644 --- a/apps/acs/test/support/fakes.dart +++ b/apps/acs/test/support/fakes.dart @@ -265,6 +265,35 @@ class FakeAcsBackend implements AcsBackend { ]; } + /// Chamadas de `resolveVisitConflict` (#79), na ordem. + final List<({VisitSyncEntry visit, int expectedServerVersion})> resolveConflictCalls = []; + + /// Falha da chamada de resolução, como uma queda de rede. + BackendFailure? resolveConflictFailure; + + /// Resultado da resolução; ausente significa `synced` com a versão vista + 1. + VisitSyncResult Function(VisitSyncEntry entry, int expectedServerVersion)? resolveConflictResultFor; + + @override + Future resolveVisitConflict( + VisitSyncEntry visit, { + required int expectedServerVersion, + String? expectedUserId, + }) async { + // Como o BackendClient: a conferência do dono vem antes de qualquer envio. + if (expectedUserId != null && _session?.userId != expectedUserId) throw sessionOwnerMismatch; + callLog.add('resolveConflict'); + resolveConflictCalls.add((visit: visit, expectedServerVersion: expectedServerVersion)); + final failure = resolveConflictFailure; + if (failure != null) throw failure; + return resolveConflictResultFor?.call(visit, expectedServerVersion) ?? + VisitSyncResult( + localId: visit.localId, + syncStatus: SyncStatus.synced, + serverVersion: expectedServerVersion + 1, + ); + } + /// Lotes de `syncLegacyVisits`, com o `userId` da sessão que transportou. final List<({String? transportUserId, String deviceId, List visits})> legacyBatches = []; @@ -578,6 +607,28 @@ class FakeVisitSynchronizer implements VisitSynchronizer { } } +/// Resolvedor de conflito (#79) programado pelo teste: registra cada chamada +/// e devolve [resposta]. Com [portao], espera por ele (chamada em voo). +class FakeVisitConflictResolver implements VisitConflictResolver { + FakeVisitConflictResolver({this.resposta}); + + /// Resposta por chamada; ausente = `synced` com a versão vista + 1. + Future Function(OfflineVisitRecord visit, int expectedServerVersion)? resposta; + + Completer? portao; + + final List<({OfflineVisitRecord visit, int expectedServerVersion})> chamadas = []; + + @override + Future resolve(OfflineVisitRecord visit, {required int expectedServerVersion}) async { + chamadas.add((visit: visit, expectedServerVersion: expectedServerVersion)); + await portao?.future; + final custom = resposta; + if (custom != null) return custom(visit, expectedServerVersion); + return VisitSyncOutcome(localId: visit.localId, status: 'synced', serverVersion: expectedServerVersion + 1); + } +} + PrioritizedAlert testAlert({ required String alertId, String riskLevel = 'red', diff --git a/apps/acs/test/text_scale_test.dart b/apps/acs/test/text_scale_test.dart index 3f13bd32..e3ae5d8f 100644 --- a/apps/acs/test/text_scale_test.dart +++ b/apps/acs/test/text_scale_test.dart @@ -1,8 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/app/acs_theme.dart'; import 'package:sinalacs_acs/app/app.dart'; import 'package:sinalacs_acs/app/mfa_enrollment_screen.dart'; +import 'package:sinalacs_acs/app/visit_conflict_screen.dart'; +import 'package:sinalacs_acs/core/services/offline_visit_queue.dart'; import 'support/fakes.dart'; import 'support/layout_harness.dart'; @@ -30,6 +33,62 @@ void main() { } } + // Comparação do conflito de visita (#79): dois painéis, o formulário de + // edição e as três ações precisam caber; "Manter a minha versão" tem de ser + // alcançável por rolagem a 200%. + for (final janela in janelas.entries) { + for (final escala in const [1.3, 2.0]) { + final rotulo = '${(escala * 100).toInt()}%'; + testWidgets('comparação do conflito não estoura com fonte a $rotulo em ${janela.key}', (tester) async { + redimensionar(tester, janela.value, escalaDeFonte: escala); + addTearDown(tester.view.reset); + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + final store = InMemoryVisitStore(); + await store.save([ + OfflineVisitRecord( + localId: 'conflito-escala', + patientId: syntheticPatientId(1), + risk: 'yellow', + status: 'PENDENTE', + outcome: 'Realizada com sucesso', + notes: 'Observação sintética de campo, longa o bastante para quebrar em mais de uma linha.', + conflictServerVersion: 3, + conflictServer: VisitConflictSnapshot( + version: 3, + status: 'Recusou atendimento', + notes: 'Outra observação sintética, registrada no servidor.', + completedAt: DateTime.utc(2026, 10, 10, 12), + ), + ), + ]); + final fila = OfflineVisitQueue(store: store, conflictResolver: FakeVisitConflictResolver()); + await fila.restore(); + await tester.pumpWidget(MaterialApp( + theme: buildAcsDarkTheme(), + home: VisitConflictCompareScreen(queue: fila, localId: 'conflito-escala'), + )); + await assentar(tester); + await percorrerTelaInteira(tester, 'comparação a $rotulo em ${janela.key}'); + + final manter = find.byKey(const Key('conflict_keep_local')); + await rolarAte(tester, manter); + expect(manter.hitTestable(), findsOneWidget); + expect(tester.getSize(manter).height, greaterThanOrEqualTo(48)); + + // O formulário de edição também cabe. + final editar = find.byKey(const Key('conflict_edit')); + await rolarAte(tester, editar); + await tester.tap(editar); + await assentar(tester); + await percorrerTelaInteira(tester, 'edição do conflito a $rotulo em ${janela.key}'); + final enviar = find.byKey(const Key('conflict_send_edited')); + await rolarAte(tester, enviar); + expect(enviar.hitTestable(), findsOneWidget); + esperarSemEstouro(tester, 'enviar versão editada a $rotulo em ${janela.key}'); + }); + } + } + // Alcançar não é só existir na árvore: o botão que grava a visita precisa estar // habilitado, receber toque (nada o cobre, nem a barra de navegação) e manter // o alvo mínimo de 48 dp. Desabilitado não prova nada, daí a chegada confirmada. diff --git a/apps/acs/test/visit_conflict_screen_test.dart b/apps/acs/test/visit_conflict_screen_test.dart new file mode 100644 index 00000000..b4baca9f --- /dev/null +++ b/apps/acs/test/visit_conflict_screen_test.dart @@ -0,0 +1,383 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sinalacs_acs/app/acs_theme.dart'; +import 'package:sinalacs_acs/app/app.dart'; +import 'package:sinalacs_acs/app/visit_conflict_screen.dart'; +import 'package:sinalacs_acs/core/network/backend_client.dart'; +import 'package:sinalacs_acs/core/services/offline_visit_queue.dart'; + +import 'support/fakes.dart'; + +/// Telas de comparação e decisão do conflito de visita (#79). Fila real sobre +/// armazenamento em memória e resolvedor falso. Ids e pacientes sintéticos. +void main() { + const localId = 'conflito-1'; + final registradaEm = DateTime(2026, 10, 10, 9, 30); + + OfflineVisitRecord emConflito({ + String id = localId, + int n = 1, + VisitConflictSnapshot? servidor, + bool semCopia = false, + }) => + OfflineVisitRecord( + localId: id, + patientId: syntheticPatientId(n), + risk: 'yellow', + status: 'PENDENTE', + outcome: 'Realizada com sucesso', + notes: 'obs igual', + createdAt: registradaEm, + conflictServerVersion: 3, + conflictServer: semCopia + ? null + : servidor ?? + VisitConflictSnapshot( + version: 3, + status: 'Paciente ausente', + notes: 'obs igual', + completedAt: registradaEm.toUtc(), + ), + ); + + Future<({OfflineVisitQueue fila, InMemoryVisitStore store, FakeVisitConflictResolver resolvedor})> montarFila( + List visitas, + ) async { + final store = InMemoryVisitStore(); + await store.save(visitas); + final resolvedor = FakeVisitConflictResolver(); + final fila = OfflineVisitQueue(store: store, conflictResolver: resolvedor); + await fila.restore(); + return (fila: fila, store: store, resolvedor: resolvedor); + } + + Future abrir(WidgetTester tester, Widget tela, {Size tamanho = const Size(360, 800)}) async { + tester.view.devicePixelRatio = 1.0; + tester.view.physicalSize = tamanho; + addTearDown(tester.view.reset); + await tester.pumpWidget(MaterialApp(theme: buildAcsDarkTheme(), home: tela)); + await tester.pumpAndSettle(); + } + + Future tocar(WidgetTester tester, Finder alvo) async { + await tester.ensureVisible(alvo); + await tester.pumpAndSettle(); + await tester.tap(alvo); + await tester.pumpAndSettle(); + } + + /// Lista com uma visita em conflito, já aberta na comparação. + Future abrirComparacaoPelaLista(WidgetTester tester, OfflineVisitQueue fila) async { + await abrir(tester, VisitConflictListScreen(queue: fila)); + await tocar(tester, find.byKey(const Key('conflict_item_$localId'))); + expect(find.byType(VisitConflictCompareScreen), findsOneWidget); + } + + testWidgets('botão Resolver conflito(s) só aparece com conflito e abre a lista', (tester) async { + final store = InMemoryVisitStore(); + final fila = OfflineVisitQueue( + store: store, + synchronizer: FakeVisitSynchronizer(statusFor: (_) => 'conflict'), + conflictResolver: FakeVisitConflictResolver(), + ); + await fila.add(OfflineVisitRecord( + localId: localId, + patientId: syntheticPatientId(1), + risk: 'yellow', + status: 'PENDENTE', + outcome: 'Realizada com sucesso', + )); + await abrir( + tester, + // No app a tela vive dentro do Scaffold do painel. + Scaffold(body: VisitRegistrationScreen(queue: fila, alert: testAlert(alertId: 'alerta-1', riskLevel: 'yellow'))), + ); + expect(find.byKey(const Key('resolve_conflicts')), findsNothing); + + await tocar(tester, find.byKey(const Key('sync_visits'))); + + expect( + find.text('1 visita(s) em conflito com o servidor. Toque em "Resolver conflito(s)" para comparar e escolher.'), + findsOneWidget, + ); + // O SnackBar cobre o pé da tela; espera ele sair antes de tocar. + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + final botao = find.byKey(const Key('resolve_conflicts')); + expect(botao, findsOneWidget); + expect(find.descendant(of: botao, matching: find.text('Resolver conflito(s)')), findsOneWidget); + await tocar(tester, botao); + + expect(find.byType(VisitConflictListScreen), findsOneWidget); + expect(find.text('Visitas em conflito'), findsOneWidget); + final item = find.byKey(const Key('conflict_item_$localId')); + expect(item, findsOneWidget); + expect(find.descendant(of: item, matching: find.text('Paciente ${syntheticPatientId(1).substring(0, 8)}')), + findsOneWidget); + }); + + testWidgets('lista vazia avisa e volta ao registro com o contador atualizado', (tester) async { + final (:fila, store: _, resolvedor: _) = await montarFila([emConflito()]); + await abrir( + tester, + // No app a tela vive dentro do Scaffold do painel. + Scaffold(body: VisitRegistrationScreen(queue: fila, alert: testAlert(alertId: 'alerta-1', riskLevel: 'yellow'))), + ); + await tocar(tester, find.byKey(const Key('resolve_conflicts'))); + await tocar(tester, find.byKey(const Key('conflict_item_$localId'))); + await tocar(tester, find.byKey(const Key('conflict_keep_server'))); + await tocar(tester, find.byKey(const Key('confirm_keep_server'))); + + expect(find.text('Nenhuma visita em conflito.'), findsOneWidget); + await tester.pageBack(); + await tester.pumpAndSettle(); + + expect(find.byType(VisitRegistrationScreen), findsOneWidget); + expect(find.byKey(const Key('conflict_visits_count')), findsNothing); + expect(find.byKey(const Key('resolve_conflicts')), findsNothing); + }); + + testWidgets('comparação mostra os dois lados e marca o que difere', (tester) async { + final semantica = tester.ensureSemantics(); + try { + final (:fila, store: _, resolvedor: _) = await montarFila([emConflito()]); + await abrir(tester, VisitConflictCompareScreen(queue: fila, localId: localId)); + + final local = find.byKey(const Key('conflict_local')); + final servidor = find.byKey(const Key('conflict_server')); + expect(find.descendant(of: local, matching: find.text('Neste aparelho')), findsOneWidget); + expect(find.descendant(of: servidor, matching: find.text('No servidor (versão 3)')), findsOneWidget); + expect(find.descendant(of: local, matching: find.text('Realizada com sucesso')), findsOneWidget); + expect(find.descendant(of: servidor, matching: find.text('Paciente ausente')), findsOneWidget); + expect(find.descendant(of: local, matching: find.text('10/10/2026 09:30')), findsOneWidget); + expect(find.descendant(of: servidor, matching: find.text('10/10/2026 09:30')), findsOneWidget, + reason: 'hora local, não UTC'); + + final status = find.byKey(const Key('conflict_diff_status')); + expect(status, findsOneWidget); + expect(find.descendant(of: status, matching: find.text('Diferente')), findsOneWidget); + expect(find.descendant(of: status, matching: find.byIcon(Icons.compare_arrows)), findsOneWidget); + expect(find.bySemanticsLabel('Status do atendimento diferente entre aparelho e servidor'), findsOneWidget); + final texto = tester.widget(find.descendant(of: status, matching: find.text('Diferente'))); + expect(texto.style?.color, AcsRiskColors.dark.accentOnSurface); + + expect(find.byKey(const Key('conflict_diff_notes')), findsNothing, reason: 'observações iguais'); + expect(find.byKey(const Key('conflict_diff_completed')), findsNothing, reason: 'mesma data'); + expect(find.text('Diferente'), findsOneWidget); + } finally { + semantica.dispose(); + } + }); + + testWidgets('observações vazias aparecem como "Sem observações"', (tester) async { + final (:fila, store: _, resolvedor: _) = await montarFila([ + emConflito(servidor: const VisitConflictSnapshot(version: 3, status: 'Realizada com sucesso', notes: '')), + ]); + await abrir(tester, VisitConflictCompareScreen(queue: fila, localId: localId)); + + expect( + find.descendant(of: find.byKey(const Key('conflict_server')), matching: find.text('Sem observações')), + findsOneWidget, + ); + expect(find.byKey(const Key('conflict_diff_notes')), findsOneWidget); + expect(find.byKey(const Key('conflict_diff_status')), findsNothing); + }); + + testWidgets('Manter a do servidor pede confirmação; cancelar não apaga', (tester) async { + final (:fila, :store, resolvedor: _) = await montarFila([emConflito()]); + await abrir(tester, VisitConflictCompareScreen(queue: fila, localId: localId)); + + await tocar(tester, find.byKey(const Key('conflict_keep_server'))); + expect(find.text('Descartar a versão deste aparelho?'), findsOneWidget); + expect( + find.text('A visita registrada neste aparelho será apagada e a do servidor continua valendo. ' + 'Isso não pode ser desfeito.'), + findsOneWidget, + ); + final confirmar = tester.widget( + find.descendant(of: find.byKey(const Key('confirm_keep_server')), matching: find.text('Descartar a minha')), + ); + expect(confirmar.style?.color, AcsRiskColors.dark.redOnSurface); + + await tocar(tester, find.byKey(const Key('cancel_keep_server'))); + + expect(fila.conflictCount, 1); + expect(await store.load(), hasLength(1)); + expect(find.byType(VisitConflictCompareScreen), findsOneWidget); + }); + + testWidgets('Manter a do servidor: confirmar remove a visita', (tester) async { + final (:fila, :store, :resolvedor) = await montarFila([emConflito()]); + await abrirComparacaoPelaLista(tester, fila); + + await tocar(tester, find.byKey(const Key('conflict_keep_server'))); + await tocar(tester, find.byKey(const Key('confirm_keep_server'))); + + expect(fila.conflictCount, 0); + expect(await store.load(), isEmpty); + expect(resolvedor.chamadas, isEmpty, reason: 'só local (D4): o servidor não é chamado'); + expect(find.byType(VisitConflictCompareScreen), findsNothing); + expect(find.text('Nenhuma visita em conflito.'), findsOneWidget); + }); + + testWidgets('Manter a minha envia com a versão vista e volta à lista', (tester) async { + final (:fila, :store, :resolvedor) = await montarFila([emConflito()]); + await abrirComparacaoPelaLista(tester, fila); + + await tocar(tester, find.byKey(const Key('conflict_keep_local'))); + + final chamada = resolvedor.chamadas.single; + expect(chamada.expectedServerVersion, 3, reason: 'a versão do servidor que o ACS viu'); + expect(chamada.visit.outcome, 'Realizada com sucesso'); + expect(chamada.visit.notes, 'obs igual'); + expect(find.byType(VisitConflictCompareScreen), findsNothing); + expect(find.byType(VisitConflictListScreen), findsOneWidget); + expect(find.text('Versão do aparelho enviada ao servidor.'), findsOneWidget); + expect(find.text('Nenhuma visita em conflito.'), findsOneWidget); + expect(await store.load(), isEmpty); + }); + + testWidgets('Editar e enviar manda o status e as observações editados', (tester) async { + final (:fila, store: _, :resolvedor) = await montarFila([emConflito()]); + await abrirComparacaoPelaLista(tester, fila); + + await tocar(tester, find.byKey(const Key('conflict_edit'))); + final notas = find.byKey(const Key('conflict_edit_notes')); + expect(tester.widget(notas).controller!.text, 'obs igual', reason: 'pré-preenchido com o do aparelho'); + expect(find.descendant(of: find.byKey(const Key('conflict_edit_outcome')), matching: find.text('Realizada com sucesso')), + findsOneWidget); + + await tocar(tester, find.byKey(const Key('conflict_edit_outcome'))); + await tester.tap(find.text('Recusou atendimento').last); + await tester.pumpAndSettle(); + await tester.enterText(notas, ' editada '); + await tocar(tester, find.byKey(const Key('conflict_send_edited'))); + + final chamada = resolvedor.chamadas.single; + expect(chamada.visit.outcome, 'Recusou atendimento'); + expect(chamada.visit.notes, 'editada'); + expect(chamada.expectedServerVersion, 3); + expect(find.text('Versão do aparelho enviada ao servidor.'), findsOneWidget); + }); + + testWidgets('conflito novo atualiza o painel do servidor e avisa', (tester) async { + final (:fila, store: _, :resolvedor) = await montarFila([emConflito()]); + resolvedor.resposta = (visit, _) async => VisitSyncOutcome( + localId: visit.localId, + status: 'conflict', + serverVersion: 5, + serverSnapshot: const VisitConflictSnapshot(version: 5, status: 'Recusou atendimento', notes: 'outra'), + ); + await abrirComparacaoPelaLista(tester, fila); + + await tocar(tester, find.byKey(const Key('conflict_keep_local'))); + + expect(find.byType(VisitConflictCompareScreen), findsOneWidget); + final servidor = find.byKey(const Key('conflict_server')); + expect(find.descendant(of: servidor, matching: find.text('No servidor (versão 5)')), findsOneWidget); + expect(find.descendant(of: servidor, matching: find.text('Recusou atendimento')), findsOneWidget); + expect(find.byKey(const Key('conflict_diff_notes')), findsOneWidget); + final aviso = find.byKey(const Key('conflict_status')); + expect(find.descendant(of: aviso, matching: find.text('A visita mudou de novo no servidor. Compare outra vez antes de decidir.')), + findsOneWidget); + expect(fila.conflictVisits.single.conflictServerVersion, 5); + }); + + testWidgets('falha de rede mantém a visita e mostra o motivo', (tester) async { + final (:fila, :store, :resolvedor) = await montarFila([emConflito()]); + resolvedor.resposta = (_, __) async => throw const BackendFailure('Sem conexão com o servidor.'); + await abrirComparacaoPelaLista(tester, fila); + + await tocar(tester, find.byKey(const Key('conflict_keep_local'))); + + expect(find.byType(VisitConflictCompareScreen), findsOneWidget); + expect(find.descendant(of: find.byKey(const Key('conflict_status')), matching: find.text('Sem conexão com o servidor.')), + findsOneWidget); + expect(fila.conflictCount, 1); + expect((await store.load()).single.inConflict, isTrue); + final status = tester.widget(find.ancestor( + of: find.text('Sem conexão com o servidor.'), + matching: find.byWidgetPredicate((w) => w is Semantics && w.properties.liveRegion == true), + ).first); + expect(status.properties.liveRegion, isTrue); + }); + + testWidgets('recusa do servidor volta à lista com o motivo', (tester) async { + final (:fila, store: _, :resolvedor) = await montarFila([emConflito()]); + resolvedor.resposta = (visit, _) async => VisitSyncOutcome( + localId: visit.localId, + status: 'rejected', + message: 'paciente fora da sua microárea', + ); + await abrirComparacaoPelaLista(tester, fila); + + await tocar(tester, find.byKey(const Key('conflict_keep_local'))); + + expect(find.byType(VisitConflictCompareScreen), findsNothing); + expect(find.text('O servidor recusou esta visita: paciente fora da sua microárea'), findsOneWidget); + expect(fila.rejectedCount, 1); + }); + + testWidgets('botões ficam desabilitados com a chamada em voo', (tester) async { + final (:fila, store: _, :resolvedor) = await montarFila([emConflito()]); + final portao = Completer(); + resolvedor.portao = portao; + await abrir(tester, VisitConflictCompareScreen(queue: fila, localId: localId)); + + await tester.ensureVisible(find.byKey(const Key('conflict_keep_local'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('conflict_keep_local'))); + await tester.pump(); + + for (final chave in ['conflict_keep_local', 'conflict_edit', 'conflict_keep_server']) { + final botao = tester.widget(find.byKey(Key(chave))); + expect(botao.onPressed, isNull, reason: '$chave com a chamada em voo'); + } + await tester.tap(find.byKey(const Key('conflict_keep_local')), warnIfMissed: false); + await tester.pump(); + expect(resolvedor.chamadas, hasLength(1), reason: 'sem duplo envio'); + + portao.complete(); + await tester.pumpAndSettle(); + }); + + testWidgets('sem cópia do servidor mostra o aviso e mantém as ações', (tester) async { + final (:fila, store: _, :resolvedor) = await montarFila([emConflito(semCopia: true)]); + await abrir(tester, VisitConflictCompareScreen(queue: fila, localId: localId)); + + final servidor = find.byKey(const Key('conflict_server')); + expect(find.descendant(of: servidor, matching: find.text('No servidor (versão 3)')), findsOneWidget); + expect(find.descendant(of: servidor, matching: find.text('Conteúdo do servidor indisponível neste aparelho.')), + findsOneWidget); + expect(find.text('Diferente'), findsNothing); + for (final chave in ['conflict_keep_local', 'conflict_edit', 'conflict_keep_server']) { + final botao = tester.widget(find.byKey(Key(chave))); + expect(botao.onPressed, isNotNull, reason: chave); + expect(tester.getSize(find.byKey(Key(chave))).height, greaterThanOrEqualTo(48)); + } + + await tocar(tester, find.byKey(const Key('conflict_keep_local'))); + expect(resolvedor.chamadas.single.expectedServerVersion, 3); + }); + + testWidgets('largura de celular empilha os painéis', (tester) async { + final (:fila, store: _, resolvedor: _) = await montarFila([emConflito()]); + await abrir(tester, VisitConflictCompareScreen(queue: fila, localId: localId), tamanho: const Size(360, 800)); + + final local = tester.getRect(find.byKey(const Key('conflict_local'))); + final servidor = tester.getRect(find.byKey(const Key('conflict_server'))); + expect(servidor.top, greaterThanOrEqualTo(local.bottom), reason: 'servidor abaixo do aparelho'); + }); + + testWidgets('largura de tablet põe os painéis lado a lado', (tester) async { + final (:fila, store: _, resolvedor: _) = await montarFila([emConflito()]); + await abrir(tester, VisitConflictCompareScreen(queue: fila, localId: localId), tamanho: const Size(800, 1000)); + + final local = tester.getRect(find.byKey(const Key('conflict_local'))); + final servidor = tester.getRect(find.byKey(const Key('conflict_server'))); + expect(servidor.top, local.top); + expect(servidor.left, greaterThanOrEqualTo(local.right)); + }); +} diff --git a/apps/acs/test/visit_storage_owner_test.dart b/apps/acs/test/visit_storage_owner_test.dart index 1e92e797..06e7de00 100644 --- a/apps/acs/test/visit_storage_owner_test.dart +++ b/apps/acs/test/visit_storage_owner_test.dart @@ -508,6 +508,116 @@ CREATE TABLE offline_visits ( expect((await b.load()).length, 2); }); }); + + test('ida e volta de visita em conflito', () async { + final loja = storage.forOwner('acs-a'); + final concluida = DateTime.utc(2026, 10, 16, 12, 30); + final emConflito = OfflineVisitRecord( + patientId: seedPatientId, + risk: 'red', + status: 'PENDENTE', + outcome: 'realizada', + localId: 'local-conflito', + conflictServerVersion: 3, + conflictServer: VisitConflictSnapshot( + version: 3, + status: 'Paciente ausente', + notes: 'Sem resposta', + completedAt: concluida, + ), + ); + await loja.save([emConflito, _visita('local-normal')]); + + final lidas = {for (final v in await loja.load()) v.localId: v}; + final c = lidas['local-conflito']!; + expect(c.inConflict, isTrue); + expect(c.conflictServerVersion, 3); + expect(c.conflictServer!.version, 3); + expect(c.conflictServer!.status, 'Paciente ausente'); + expect(c.conflictServer!.notes, 'Sem resposta'); + expect(c.conflictServer!.completedAt, concluida); + + final n = lidas['local-normal']!; + expect(n.inConflict, isFalse); + expect(n.conflictServerVersion, isNull); + expect(n.conflictServer, isNull); + + expect((await storage.countsByOwner())['acs-a'], 2); + }); + + test('cópia do servidor corrompida não derruba o load: o conflito e a versão ficam', () async { + final loja = storage.forOwner('acs-a'); + await loja.save([ + OfflineVisitRecord( + patientId: seedPatientId, + risk: 'red', + status: 'PENDENTE', + localId: 'local-conflito', + conflictServerVersion: 5, + conflictServer: const VisitConflictSnapshot(version: 5, status: 'Realizada', notes: ''), + ), + _visita('local-normal'), + ]); + final db = await storage.database.open(); + for (final lixo in ['{não é json', '[1, 2]', '{"version": "cinco"}']) { + await db.update( + 'offline_visits', + {'conflict_server_snapshot': lixo}, + where: 'local_id = ?', + whereArgs: ['local-conflito'], + ); + + final lidas = {for (final v in await loja.load()) v.localId: v}; + + expect(lidas.keys, containsAll(['local-conflito', 'local-normal']), reason: lixo); + final c = lidas['local-conflito']!; + expect(c.inConflict, isTrue, reason: lixo); + expect(c.conflictServerVersion, 5, reason: lixo); + expect(c.conflictServer, isNull, reason: lixo); + } + }); + + test('wipeAllData com só a linha em conflito lança WipeBlocked(1)', () async { + await storage.forOwner('acs-a').save([ + OfflineVisitRecord( + patientId: seedPatientId, + risk: 'red', + status: 'PENDENTE', + localId: 'local-conflito', + conflictServerVersion: 2, + conflictServer: const VisitConflictSnapshot( + version: 2, + status: 'Realizada', + notes: '', + ), + ), + ]); + await expectLater( + storage.wipeAllData(), + throwsA(isA().having((e) => e.unsent, 'unsent', 1)), + ); + }); + + test('VisitConflictSnapshot: toJson/fromJson ida e volta', () { + final snapshot = VisitConflictSnapshot( + version: 4, + status: 'Paciente ausente', + notes: 'a\nb', + completedAt: DateTime.utc(2026, 10, 16, 8), + ); + final json = snapshot.toJson(); + expect(json['completedAt'], '2026-10-16T08:00:00.000Z'); + final volta = VisitConflictSnapshot.fromJson(json); + expect(volta.version, 4); + expect(volta.status, 'Paciente ausente'); + expect(volta.notes, 'a\nb'); + expect(volta.completedAt, DateTime.utc(2026, 10, 16, 8)); + + final semData = VisitConflictSnapshot.fromJson( + const VisitConflictSnapshot(version: 1, status: 's', notes: '').toJson(), + ); + expect(semData.completedAt, isNull); + }); } /// Erro do sqflite como o plugin entrega (mensagem do SQLite/plugin). diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 470c6820..2d1577e0 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -101,7 +101,7 @@ Key pattern: application services depend on abstract interfaces (`AlertPublisher **MQTT do backend e outbox (#56).** `MqttAlertDispatcher` roda cada `client.connect` numa zona própria por cliente, derivada de `Zone.root`: o erro assíncrono do `Socket.done` do TLS (que antes ia sem dono para a zona do `connect` e matava o processo, saída 255) vira queda de conexão tratada, com reconexão e backoff. Política: `SocketException`, `TlsException`, `TimeoutException`, `ConnectionException`, `NoConnectionException` e `StateError('socket is null')` são **transitórios** (descarta o cliente; `publish` lança `MqttUnavailableException`); qualquer outro é **fatal** (registra só o tipo e chama `onFatal`, padrão `exit(70)`). Injetáveis nos testes: `initialBackoff`, `maxBackoff`, `publishAckTimeout`, `onFatal`, `log`. `AlertPublisher.publish` é `Future` e só completa no PUBACK do broker (timeout 10 s; PINGRESP também 10 s); o outbox só grava `publishedAt` depois disso. `bin/main.dart` tem um `runZonedGuarded` global (`runtime/uncaught_error_policy.dart`) como defesa em profundidade, e o `serverpod` do Compose tem `restart: unless-stopped`. Logs só com tipo e `errno` (VAZ-01). Testes: `dart test -t resiliencia` (broker falso em TCP real). QA manual e opcional, fora da CI, no Docker/TLS: `./scripts/qa/mqtt_resiliencia_e2e.sh` (base, B reinício do broker, C partição; o A, `docker pause` do backend, só com `--com-pausa-do-backend`). Evidências, decisões e riscos residuais: `docs/superpowers/plans/2026-10-12-issue-56-evidencias.md`. -**Serverpod is RPC, not REST**, so there are no URL routes to match: the generated client calls methods. Endpoints: `health.check` (returns `{status, mqttConnected, dbConnected}`; answers as soon as the server is up, independent of MQTT/DB state), `auth.developmentLogin` (throws `EndpointDisabledException` unless `ENABLE_DEV_LOGIN=true`, preserving the old 404-not-403 semantics), `auth.loginInstitutional` (o login real do ACS, RF07: matrícula + senha verificadas com Argon2id contra `user_credentials`, sem guardar a senha em nenhuma forma — só o hash com seu salt e os parâmetros de custo; bloqueia a conta após 5 tentativas falhas — 15 minutos na primeira rodada e o dobro a cada rodada seguinte sem um login válido no meio, até 24 h (`lockStreak`, `InstitutionalAuthService.lockDurationFor`) —, que é o rate limiting do achado F6 de `spec/security_assessment.md`, e audita cada desfecho em `audit_logs`; matrícula inexistente e senha errada devolvem a mesma mensagem), `auth.refreshSession`/`auth.logout` (o refresh token do ACS, LGPD-RT06; `loginInstitutional` só emite `refreshToken` quando o cliente envia um `deviceId` não-branco — sem ele o resultado traz `refreshToken` nulo e o JWT de 15 minutos segue valendo sozinho. O token guarda-se em `acs_refresh_tokens` **só como hash SHA-256**, com família, aparelho, `rotatedAt`/`revokedAt` — o `deviceId` é o identificador da instalação (UUID aleatório), não uma atestação de hardware: protege contra token vazado sem ele, não contra comprometimento do aparelho; constantes em `RefreshTokenService`: janela ociosa de 2 h (`idleWindow`), teto absoluto de 8 h contado do login por senha + TOTP (`absoluteWindow`; a rotação não o estende) e tolerância de reuso de 30 s (`reuseGrace`, para a resposta perdida em rede: reapresentar o pai dentro dela devolve um filho e audita `refresh_granted_grace`). Reuso fora da tolerância, aparelho diferente, conta inativa ou sem microárea **revogam a família inteira**, e toda recusa devolve a mesma `SessionExpiredException` com a mensagem única `Sessão expirada. Entre novamente.` (sem dizer o motivo). A microárea do JWT novo é relida do banco a cada refresh, nunca copiada do token antigo. A rotação é um INSERT condicional atômico e a revogação da família roda em duas passagens, para que dois refreshes concorrentes não gerem dois filhos vivos. Cada desfecho vira uma linha `session_refresh` em `audit_logs` (`refresh_granted`, `refresh_granted_grace`, `denied_*`). Os dois métodos são públicos por desenho — o token de acesso já venceu quando o primeiro é chamado — e estão na allowlist fina de `endpoint_auth_posture_test.dart`. `logout` revoga a família, audita `logout` e ignora em silêncio um token que não conhece. O login só poda os tokens expirados do próprio usuário: não há limpeza periódica da tabela), `auth.requestOtp`/`auth.verifyOtp` (o login passwordless do paciente, RF01: CPF validado por dígito verificador e hasheado no servidor em `users.cpfHash` com `CPF_HASH_PEPPER`, mais a data de nascimento; código de 6 dígitos, com TTL de 5 minutos, teto de 5 verificações e 60 s entre pedidos, guardado em `otp_challenges` **como HMAC** — o código em claro existe só entre a geração e o envio — e uma linha de auditoria por desfecho. `requestOtp` responde a mesma coisa exista ou não o CPF — mesmo payload, mesmo status, sem linha de desafio e sem SMS na recusa —, mas **não** equaliza o *tempo* de resposta: as lacunas do RF01 estão no `PROGRESS.md`. `verifyOtp` emite a sessão do paciente com `patientSessionLifetime` (**1 hora**, LGPD-RT06) e não com os 15 minutos padrão, que o ACS mantém porque a renovação dele é feita pelo refresh token (abaixo) — o paciente não tem refresh token e um código OTP não pode ser reapresentado; o caminho de onboarding emite o mesmo TTL — o defeito dos 15 minutos do RF02 foi corrigido, ver `PROGRESS.md`), `onboarding.generateEnrollmentToken`/`onboarding.completeEnrollment` (o convite de uso único que o ACS gera e a conclusão dele pelo paciente; `completeEnrollment` também emite sessão, com `patientSessionLifetime`), `patients.acceptTermsOfUse` (aceite do Termo de Uso e da Política de Privacidade vigentes por quem entrou por OTP sem onboarding, LGPD-RF18; única via de escrita de `termsOfUse` fora do cadastro; idempotente — com o aceite vigente já gravado devolve a linha existente) e `patients.hasAcceptedCurrentTerms` (o `bool` que o login consulta, sem ler o painel nem auditar leitura), `patients.hasGrantedConsent` (o mesmo desenho para uma finalidade: `true` se a decisão mais recente é `granted`; o app a consulta a cada login antes de pedir o token de push ao FCM); a criação do pedido de exclusão (`patients.requestDataDeletion`) é serializada por `pg_advisory_xact_lock` por titular (o aceite do termo também, e o registro de token de push por token: `lockPerSubject` em `infrastructure/database/subject_lock.dart`, forma de duas chaves `(namespace, hashtext)`, que não colide com a chave única da cadeia de auditoria), `notices.sendSegmented` (RF14: só ACS, a microárea vem do token; a consulta SQL em `OrmNoticeRecipientStore` usa o consentimento `segmentedPush` **mais recente** de cada titular, não a existência do token; entrega a lista ao Gorush por `GorushClient` — `dart:io`, tempo limite de 5 s no total e de 2 s só na **conexão** (estourar na conexão é "inacessível, tente de novo", porque nada saiu; estourar depois de o pedido sair é "resultado desconhecido", e corpo 2xx ilegível idem), um único cliente por processo encerrado em `overrideConfig`, `GORUSH_URL` vazio desliga o envio e a chamada falha com `NoticeDeliveryException`; tokens que o provedor declara inválidos são apagados; audita `community_notice` com a microárea e nunca o texto), `patients.termsChangeNotice` (aviso de mudança dos termos com 15 dias de antecedência, LGPD-RF18: só paciente e sem auditoria; desde a #46 lê as versões publicadas de `terms_versions` (`TermsVersionStore.currentPublished`), não faz mais "sem I/O"; a constante `upcomingTermsChange` em `terms_change_schedule.dart` é só um espelho `null` mantido por dois testes, não é mais a agenda, e `TermsChangeSchedule` se recusa (com `ArgumentError`, que vale em release, não `assert`) a existir com menos de 15 dias entre as datas de publicação e de vigência declaradas — não prova que o aviso chegou a alguém, um `publishedAt` retroativo passa — para anunciar uma mudança: (1) escreva o texto novo em `upcomingLegalDocuments` no app e publique essa versão do app, (2) só então troque `upcomingTermsChange` aqui — um teste do app (`upcoming_legal_documents_test.dart`) falha se as duas pontas divergirem —, (3) na vigência mude `consentPolicyVersion` e `legalDocumentsVersion`, mova o texto para `privacyPolicy`/`termsOfUse` e volte as duas constantes para `null`; um app antigo, sem o texto embarcado, vê só "Ler os termos atuais" durante os 15 dias), `devices.registerPushToken` (RF14: só paciente, só com o consentimento `segmentedPush` vigente, uma linha por token em `push_tokens`; revogar o consentimento apaga os tokens do titular na MESMA transação do `denied`, sob o lock por titular; no máximo 10 tokens por titular, o mais antigo sai; só a troca de dono do token é auditada, com uma linha para o novo dono e outra para o anterior; a poda apaga por id **e** titular e poupa o token recém-gravado; o consentimento mais recente desempata por `id`), `alerts.createRedAlert` (idempotency key is a method parameter, not a header; throws `AlertDispatchUnavailableException` if the MQTT dispatcher isn't connected), `alerts.acknowledge`, `alerts.resolve` (#76: só de `acknowledged` para `resolved`, sob trava, no território do token, auditado na mesma transação; `resolved: false` para inexistente/fora do território; o ACK não rebaixa `resolved`), `triage.evaluate` (exige `accessToken`; classifica pelo `TriageEngine` determinístico, grava a sessão em `triage_sessions` com o `patientId` vindo do token — nunca de parâmetro, INV-05 — e escreve uma linha `write`/`triage_session` em `audit_logs`; só o papel `patient` é aceito, um ACS recebe `AlertPermissionException`), `visits.sync` (batch upload of visits registered offline by the ACS; deduplicated by the device-generated `localId`, which has a unique index on `visits`, version-checked — a mismatched `version` returns `SyncStatus.conflict` and never overwrites — and territory-checked against the patient's own micro-area, not just the caller's; a malformed identifier, a territory mismatch, or a visit owned by another ACS all return the terminal `SyncStatus.rejected`, distinct from the retryable `SyncStatus.error` used for things like an unknown patient, so the device queue knows which failures are worth retrying; a visit with no author — `acsId` null, `authorship = legacyUnclaimed` — is also `rejected` there, never claimed), `visits.syncLegacy` (D4 of the 2026-10-03 plan: uploads the device's pre-owner legacy visits with **unknown authorship** — the ACS session is only the transport, territory-checked like `sync`, idempotent by `localId` from the same `originDeviceId`, refuses an inactive ACS, never writes the transporter as `acsId`, at most `VisitSyncService.maxLegacyBatch` (200) visits per call, one `visit_legacy_sync` audit row per batch plus one `visit_legacy_sync_item` row per visit actually created or changed (only the visit id, no clinical content); a re-send of an authored `localId` at the same version is a no-op `synced`, at another version `rejected`), `visits.syncDeferred`/`visits.revokeUploadToken` (D7 of the 2026-10-03 plan: the **deferred upload token** of `UploadTokenService` (`application/auth/upload_token_service.dart`, store `OrmUploadTokenStore`) lets the device upload the pending visits of an ACS who already logged out, **with that ACS's authorship** and no session. `auth.loginInstitutional` issues it as `uploadToken` next to the refresh token, under the same rule — only with a non-blank `deviceId`, otherwise `null`; patient logins and `developmentLogin` never return it. 256 random bits, stored in `acs_upload_tokens` **only as SHA-256** (helpers shared with the refresh token in `opaque_token.dart`), bound to user + device, fixed 7-day life (`UploadTokenService.lifetime`), no rotation, independent of the refresh family (logout does not touch it); a new login on the same (user, device) revokes the previous one, inside a transaction serialized by `lockPerSubject` (namespace 5). `resolve` returns the **token owner** as ACS with the micro-area **re-read from the database** on every call, and every refusal — unknown, expired, revoked, other device, inactive account, no micro-area — is the same `SessionExpiredException` with `UploadTokenService.deniedMessage` (`Envio não autorizado. Entre novamente.`); other device and inactive/no micro-area also revoke the token. `syncDeferred` checks the batch cap `VisitSyncService.maxLegacyBatch` (200) before anything else (`AlertValidationException`), resolves the token outside the batch transaction and runs the **same** `VisitSyncService.sync` as `visits.sync` — so author = owner, territory = owner's current one, another ACS's `localId` `rejected`. Audit rows in `audit_logs`, never with the token: `upload_token` / `upload_token_issued`, `upload_token_denied_{revoked,device,expired,inactive}` (unknown token → no row, no subject), `upload_token_revoked`, and one `visit_deferred` / `visit_deferred_sync` per committed batch. Both methods are public by design for `authenticate(...)` and sit in the allowlist of `endpoint_auth_posture_test.dart`. Still open, by design or deferred (`PROGRESS.md`, section "Fila de visitas por dono e corrida do login"): `visits.sync`/`pull` accept an inactive ACS until the JWT expires (`isActiveAcs` is only checked by `syncLegacy`; `syncDeferred` re-checks it through the token); no database CHECK ties a null `acsId` to `authorship = legacyUnclaimed`; the existing `visits.sync` has no batch cap; a `localId` existence oracle remains, identical to `visits.sync`'s; `uploadToken`/`refreshToken` are plain RPC arguments, so any future argument logging must scrub them, see VAZ-03 in `spec/lgpd_data_audit.md`), and `patients.listMicroArea` (the ACS's routine-visit patient picker; the micro-area comes from the caller's token, never a parameter, and every call is written to `audit_logs`, whose rows are hash-chained — `AuditChain`/`AuditChainVerifier` in `application/audit/`, keyed by `AUDIT_CHAIN_SECRET` — so tampering with a row is detectable even by someone with direct Postgres write access; `bin/audit_chain_check.dart` verifies the chain on demand). `bin/seed_e2e_fixtures.dart` seeds the e2e stack (`docker-compose.e2e.yml`, see `apps/CLAUDE.md`): synthetic UUIDs/CPFs generated per run by `lib/src/infrastructure/testing/e2e_fixtures.dart`, written **only** to the database `sinalacs_e2e` (it refuses any other name and any `APP_ENV` but `development`); `development.sql` and its `*-seed` services stay for the plain development stack. Errors are typed exceptions declared in `.spy.yaml` and serialized to the client, replacing HTTP status codes. MQTT connects in the background after boot (non-blocking) with exponential-backoff auto-reconnect, so the server stays responsive even if the broker is unreachable — this matters on free-tier hosts that sleep/hibernate. Toda decisão de papel e de presença de território no token passa por uma regra +**Serverpod is RPC, not REST**, so there are no URL routes to match: the generated client calls methods. Endpoints: `health.check` (returns `{status, mqttConnected, dbConnected}`; answers as soon as the server is up, independent of MQTT/DB state), `auth.developmentLogin` (throws `EndpointDisabledException` unless `ENABLE_DEV_LOGIN=true`, preserving the old 404-not-403 semantics), `auth.loginInstitutional` (o login real do ACS, RF07: matrícula + senha verificadas com Argon2id contra `user_credentials`, sem guardar a senha em nenhuma forma — só o hash com seu salt e os parâmetros de custo; bloqueia a conta após 5 tentativas falhas — 15 minutos na primeira rodada e o dobro a cada rodada seguinte sem um login válido no meio, até 24 h (`lockStreak`, `InstitutionalAuthService.lockDurationFor`) —, que é o rate limiting do achado F6 de `spec/security_assessment.md`, e audita cada desfecho em `audit_logs`; matrícula inexistente e senha errada devolvem a mesma mensagem), `auth.refreshSession`/`auth.logout` (o refresh token do ACS, LGPD-RT06; `loginInstitutional` só emite `refreshToken` quando o cliente envia um `deviceId` não-branco — sem ele o resultado traz `refreshToken` nulo e o JWT de 15 minutos segue valendo sozinho. O token guarda-se em `acs_refresh_tokens` **só como hash SHA-256**, com família, aparelho, `rotatedAt`/`revokedAt` — o `deviceId` é o identificador da instalação (UUID aleatório), não uma atestação de hardware: protege contra token vazado sem ele, não contra comprometimento do aparelho; constantes em `RefreshTokenService`: janela ociosa de 2 h (`idleWindow`), teto absoluto de 8 h contado do login por senha + TOTP (`absoluteWindow`; a rotação não o estende) e tolerância de reuso de 30 s (`reuseGrace`, para a resposta perdida em rede: reapresentar o pai dentro dela devolve um filho e audita `refresh_granted_grace`). Reuso fora da tolerância, aparelho diferente, conta inativa ou sem microárea **revogam a família inteira**, e toda recusa devolve a mesma `SessionExpiredException` com a mensagem única `Sessão expirada. Entre novamente.` (sem dizer o motivo). A microárea do JWT novo é relida do banco a cada refresh, nunca copiada do token antigo. A rotação é um INSERT condicional atômico e a revogação da família roda em duas passagens, para que dois refreshes concorrentes não gerem dois filhos vivos. Cada desfecho vira uma linha `session_refresh` em `audit_logs` (`refresh_granted`, `refresh_granted_grace`, `denied_*`). Os dois métodos são públicos por desenho — o token de acesso já venceu quando o primeiro é chamado — e estão na allowlist fina de `endpoint_auth_posture_test.dart`. `logout` revoga a família, audita `logout` e ignora em silêncio um token que não conhece. O login só poda os tokens expirados do próprio usuário: não há limpeza periódica da tabela), `auth.requestOtp`/`auth.verifyOtp` (o login passwordless do paciente, RF01: CPF validado por dígito verificador e hasheado no servidor em `users.cpfHash` com `CPF_HASH_PEPPER`, mais a data de nascimento; código de 6 dígitos, com TTL de 5 minutos, teto de 5 verificações e 60 s entre pedidos, guardado em `otp_challenges` **como HMAC** — o código em claro existe só entre a geração e o envio — e uma linha de auditoria por desfecho. `requestOtp` responde a mesma coisa exista ou não o CPF — mesmo payload, mesmo status, sem linha de desafio e sem SMS na recusa —, mas **não** equaliza o *tempo* de resposta: as lacunas do RF01 estão no `PROGRESS.md`. `verifyOtp` emite a sessão do paciente com `patientSessionLifetime` (**1 hora**, LGPD-RT06) e não com os 15 minutos padrão, que o ACS mantém porque a renovação dele é feita pelo refresh token (abaixo) — o paciente não tem refresh token e um código OTP não pode ser reapresentado; o caminho de onboarding emite o mesmo TTL — o defeito dos 15 minutos do RF02 foi corrigido, ver `PROGRESS.md`), `onboarding.generateEnrollmentToken`/`onboarding.completeEnrollment` (o convite de uso único que o ACS gera e a conclusão dele pelo paciente; `completeEnrollment` também emite sessão, com `patientSessionLifetime`), `patients.acceptTermsOfUse` (aceite do Termo de Uso e da Política de Privacidade vigentes por quem entrou por OTP sem onboarding, LGPD-RF18; única via de escrita de `termsOfUse` fora do cadastro; idempotente — com o aceite vigente já gravado devolve a linha existente) e `patients.hasAcceptedCurrentTerms` (o `bool` que o login consulta, sem ler o painel nem auditar leitura), `patients.hasGrantedConsent` (o mesmo desenho para uma finalidade: `true` se a decisão mais recente é `granted`; o app a consulta a cada login antes de pedir o token de push ao FCM); a criação do pedido de exclusão (`patients.requestDataDeletion`) é serializada por `pg_advisory_xact_lock` por titular (o aceite do termo também, e o registro de token de push por token: `lockPerSubject` em `infrastructure/database/subject_lock.dart`, forma de duas chaves `(namespace, hashtext)`, que não colide com a chave única da cadeia de auditoria), `notices.sendSegmented` (RF14: só ACS, a microárea vem do token; a consulta SQL em `OrmNoticeRecipientStore` usa o consentimento `segmentedPush` **mais recente** de cada titular, não a existência do token; entrega a lista ao Gorush por `GorushClient` — `dart:io`, tempo limite de 5 s no total e de 2 s só na **conexão** (estourar na conexão é "inacessível, tente de novo", porque nada saiu; estourar depois de o pedido sair é "resultado desconhecido", e corpo 2xx ilegível idem), um único cliente por processo encerrado em `overrideConfig`, `GORUSH_URL` vazio desliga o envio e a chamada falha com `NoticeDeliveryException`; tokens que o provedor declara inválidos são apagados; audita `community_notice` com a microárea e nunca o texto), `patients.termsChangeNotice` (aviso de mudança dos termos com 15 dias de antecedência, LGPD-RF18: só paciente e sem auditoria; desde a #46 lê as versões publicadas de `terms_versions` (`TermsVersionStore.currentPublished`), não faz mais "sem I/O"; a constante `upcomingTermsChange` em `terms_change_schedule.dart` é só um espelho `null` mantido por dois testes, não é mais a agenda, e `TermsChangeSchedule` se recusa (com `ArgumentError`, que vale em release, não `assert`) a existir com menos de 15 dias entre as datas de publicação e de vigência declaradas — não prova que o aviso chegou a alguém, um `publishedAt` retroativo passa — para anunciar uma mudança: (1) escreva o texto novo em `upcomingLegalDocuments` no app e publique essa versão do app, (2) só então troque `upcomingTermsChange` aqui — um teste do app (`upcoming_legal_documents_test.dart`) falha se as duas pontas divergirem —, (3) na vigência mude `consentPolicyVersion` e `legalDocumentsVersion`, mova o texto para `privacyPolicy`/`termsOfUse` e volte as duas constantes para `null`; um app antigo, sem o texto embarcado, vê só "Ler os termos atuais" durante os 15 dias), `devices.registerPushToken` (RF14: só paciente, só com o consentimento `segmentedPush` vigente, uma linha por token em `push_tokens`; revogar o consentimento apaga os tokens do titular na MESMA transação do `denied`, sob o lock por titular; no máximo 10 tokens por titular, o mais antigo sai; só a troca de dono do token é auditada, com uma linha para o novo dono e outra para o anterior; a poda apaga por id **e** titular e poupa o token recém-gravado; o consentimento mais recente desempata por `id`), `alerts.createRedAlert` (idempotency key is a method parameter, not a header; throws `AlertDispatchUnavailableException` if the MQTT dispatcher isn't connected), `alerts.acknowledge`, `alerts.resolve` (#76: só de `acknowledged` para `resolved`, sob trava, no território do token, auditado na mesma transação; `resolved: false` para inexistente/fora do território; o ACK não rebaixa `resolved`), `triage.evaluate` (exige `accessToken`; classifica pelo `TriageEngine` determinístico, grava a sessão em `triage_sessions` com o `patientId` vindo do token — nunca de parâmetro, INV-05 — e escreve uma linha `write`/`triage_session` em `audit_logs`; só o papel `patient` é aceito, um ACS recebe `AlertPermissionException`), `visits.sync` (batch upload of visits registered offline by the ACS; deduplicated by the device-generated `localId`, which has a unique index on `visits`, version-checked — a mismatched `version` returns `SyncStatus.conflict` and never overwrites — and territory-checked against the patient's own micro-area, not just the caller's; a malformed identifier, a territory mismatch, or a visit owned by another ACS all return the terminal `SyncStatus.rejected`, distinct from the retryable `SyncStatus.error` used for things like an unknown patient, so the device queue knows which failures are worth retrying; a visit with no author — `acsId` null, `authorship = legacyUnclaimed` — is also `rejected` there, never claimed), `visits.resolveConflict` (#79, D2: compare-and-set sobre `expectedServerVersion` — igual grava o conteúdo enviado com `version + 1`, diferente devolve `conflict` com a cópia nova e não grava; mesmas recusas do `sync`; trilha `write`/`visit`/`conflict_resolved_local` com `recordSafely`; `entry.version` é ignorado). `VisitSyncResult.serverVisit` (D1) vem preenchido só no `conflict` de `sync`/`syncDeferred` (nunca `syncLegacy`), com `read`/`visit_conflict` na trilha, sem migração. `visits.syncLegacy` (D4 of the 2026-10-03 plan: uploads the device's pre-owner legacy visits with **unknown authorship** — the ACS session is only the transport, territory-checked like `sync`, idempotent by `localId` from the same `originDeviceId`, refuses an inactive ACS, never writes the transporter as `acsId`, at most `VisitSyncService.maxLegacyBatch` (200) visits per call, one `visit_legacy_sync` audit row per batch plus one `visit_legacy_sync_item` row per visit actually created or changed (only the visit id, no clinical content); a re-send of an authored `localId` at the same version is a no-op `synced`, at another version `rejected`), `visits.syncDeferred`/`visits.revokeUploadToken` (D7 of the 2026-10-03 plan: the **deferred upload token** of `UploadTokenService` (`application/auth/upload_token_service.dart`, store `OrmUploadTokenStore`) lets the device upload the pending visits of an ACS who already logged out, **with that ACS's authorship** and no session. `auth.loginInstitutional` issues it as `uploadToken` next to the refresh token, under the same rule — only with a non-blank `deviceId`, otherwise `null`; patient logins and `developmentLogin` never return it. 256 random bits, stored in `acs_upload_tokens` **only as SHA-256** (helpers shared with the refresh token in `opaque_token.dart`), bound to user + device, fixed 7-day life (`UploadTokenService.lifetime`), no rotation, independent of the refresh family (logout does not touch it); a new login on the same (user, device) revokes the previous one, inside a transaction serialized by `lockPerSubject` (namespace 5). `resolve` returns the **token owner** as ACS with the micro-area **re-read from the database** on every call, and every refusal — unknown, expired, revoked, other device, inactive account, no micro-area — is the same `SessionExpiredException` with `UploadTokenService.deniedMessage` (`Envio não autorizado. Entre novamente.`); other device and inactive/no micro-area also revoke the token. `syncDeferred` checks the batch cap `VisitSyncService.maxLegacyBatch` (200) before anything else (`AlertValidationException`), resolves the token outside the batch transaction and runs the **same** `VisitSyncService.sync` as `visits.sync` — so author = owner, territory = owner's current one, another ACS's `localId` `rejected`. Audit rows in `audit_logs`, never with the token: `upload_token` / `upload_token_issued`, `upload_token_denied_{revoked,device,expired,inactive}` (unknown token → no row, no subject), `upload_token_revoked`, and one `visit_deferred` / `visit_deferred_sync` per committed batch. Both methods are public by design for `authenticate(...)` and sit in the allowlist of `endpoint_auth_posture_test.dart`. Still open, by design or deferred (`PROGRESS.md`, section "Fila de visitas por dono e corrida do login"): `visits.sync`/`pull` accept an inactive ACS until the JWT expires (`isActiveAcs` is only checked by `syncLegacy`; `syncDeferred` re-checks it through the token); no database CHECK ties a null `acsId` to `authorship = legacyUnclaimed`; the existing `visits.sync` has no batch cap; a `localId` existence oracle remains, identical to `visits.sync`'s; `uploadToken`/`refreshToken` are plain RPC arguments, so any future argument logging must scrub them, see VAZ-03 in `spec/lgpd_data_audit.md`), and `patients.listMicroArea` (the ACS's routine-visit patient picker; the micro-area comes from the caller's token, never a parameter, and every call is written to `audit_logs`, whose rows are hash-chained — `AuditChain`/`AuditChainVerifier` in `application/audit/`, keyed by `AUDIT_CHAIN_SECRET` — so tampering with a row is detectable even by someone with direct Postgres write access; `bin/audit_chain_check.dart` verifies the chain on demand). `bin/seed_e2e_fixtures.dart` seeds the e2e stack (`docker-compose.e2e.yml`, see `apps/CLAUDE.md`): synthetic UUIDs/CPFs generated per run by `lib/src/infrastructure/testing/e2e_fixtures.dart`, written **only** to the database `sinalacs_e2e` (it refuses any other name and any `APP_ENV` but `development`); `development.sql` and its `*-seed` services stay for the plain development stack. Errors are typed exceptions declared in `.spy.yaml` and serialized to the client, replacing HTTP status codes. MQTT connects in the background after boot (non-blocking) with exponential-backoff auto-reconnect, so the server stays responsive even if the broker is unreachable — this matters on free-tier hosts that sleep/hibernate. Toda decisão de papel e de presença de território no token passa por uma regra única, `Authorization.require` (`lib/src/application/auth/authorization.dart`): o chamador decide o que lançar (`StateError` nos serviços territoriais, `TriageAuthorizationException` na triagem), e é isso que preserva a tradução diff --git a/backend/sinalacs_client/lib/src/protocol/api/visit_sync_result.dart b/backend/sinalacs_client/lib/src/protocol/api/visit_sync_result.dart index 2e09a2db..57c8642c 100644 --- a/backend/sinalacs_client/lib/src/protocol/api/visit_sync_result.dart +++ b/backend/sinalacs_client/lib/src/protocol/api/visit_sync_result.dart @@ -13,6 +13,8 @@ import 'package:serverpod_client/serverpod_client.dart' as _i1; import '../enums/sync_status.dart' as _i2; +import '../api/visit_sync_entry.dart' as _i3; +import 'package:sinalacs_client/src/protocol/protocol.dart' as _i4; /// Resultado da sincronização de UMA visita. /// @@ -25,6 +27,7 @@ abstract class VisitSyncResult implements _i1.SerializableModel { required this.syncStatus, this.serverVersion, this.message, + this.serverVisit, }); factory VisitSyncResult({ @@ -32,6 +35,7 @@ abstract class VisitSyncResult implements _i1.SerializableModel { required _i2.SyncStatus syncStatus, int? serverVersion, String? message, + _i3.VisitSyncEntry? serverVisit, }) = _VisitSyncResultImpl; factory VisitSyncResult.fromJson(Map jsonSerialization) { @@ -42,6 +46,11 @@ abstract class VisitSyncResult implements _i1.SerializableModel { ), serverVersion: jsonSerialization['serverVersion'] as int?, message: jsonSerialization['message'] as String?, + serverVisit: jsonSerialization['serverVisit'] == null + ? null + : _i4.Protocol().deserialize<_i3.VisitSyncEntry>( + jsonSerialization['serverVisit'], + ), ); } @@ -56,6 +65,10 @@ abstract class VisitSyncResult implements _i1.SerializableModel { /// Preenchido apenas quando syncStatus é error. String? message; + /// Preenchido apenas em `conflict` do caminho com autor: a visita como está + /// no servidor, para o ACS comparar. + _i3.VisitSyncEntry? serverVisit; + /// Returns a shallow copy of this [VisitSyncResult] /// with some or all fields replaced by the given arguments. @_i1.useResult @@ -64,6 +77,7 @@ abstract class VisitSyncResult implements _i1.SerializableModel { _i2.SyncStatus? syncStatus, int? serverVersion, String? message, + _i3.VisitSyncEntry? serverVisit, }); @override Map toJson() { @@ -73,6 +87,7 @@ abstract class VisitSyncResult implements _i1.SerializableModel { 'syncStatus': syncStatus.toJson(), if (serverVersion != null) 'serverVersion': serverVersion, if (message != null) 'message': message, + if (serverVisit != null) 'serverVisit': serverVisit?.toJson(), }; } @@ -90,11 +105,13 @@ class _VisitSyncResultImpl extends VisitSyncResult { required _i2.SyncStatus syncStatus, int? serverVersion, String? message, + _i3.VisitSyncEntry? serverVisit, }) : super._( localId: localId, syncStatus: syncStatus, serverVersion: serverVersion, message: message, + serverVisit: serverVisit, ); /// Returns a shallow copy of this [VisitSyncResult] @@ -106,12 +123,16 @@ class _VisitSyncResultImpl extends VisitSyncResult { _i2.SyncStatus? syncStatus, Object? serverVersion = _Undefined, Object? message = _Undefined, + Object? serverVisit = _Undefined, }) { return VisitSyncResult( localId: localId ?? this.localId, syncStatus: syncStatus ?? this.syncStatus, serverVersion: serverVersion is int? ? serverVersion : this.serverVersion, message: message is String? ? message : this.message, + serverVisit: serverVisit is _i3.VisitSyncEntry? + ? serverVisit + : this.serverVisit?.copyWith(), ); } } diff --git a/backend/sinalacs_client/lib/src/protocol/client.dart b/backend/sinalacs_client/lib/src/protocol/client.dart index 9b041e74..772e26cd 100644 --- a/backend/sinalacs_client/lib/src/protocol/client.dart +++ b/backend/sinalacs_client/lib/src/protocol/client.dart @@ -1220,6 +1220,25 @@ class EndpointVisits extends EndpointAuthenticated { }, ); + /// Resolução explícita de um conflito pelo autor (#79): compare-and-set + /// sobre [expectedServerVersion], a versão do servidor que o aparelho viu no + /// conflito. `visit.version` é ignorado. Ver + /// `VisitSyncService.resolveConflict`. Mesma transação e mesma tradução de + /// erros de [sync]. + _i2.Future<_i42.VisitSyncResult> resolveConflict({ + required String accessToken, + required _i43.VisitSyncEntry visit, + required int expectedServerVersion, + }) => caller.callServerEndpoint<_i42.VisitSyncResult>( + 'visits', + 'resolveConflict', + { + 'accessToken': accessToken, + 'visit': visit, + 'expectedServerVersion': expectedServerVersion, + }, + ); + /// Envio das visitas LEGADAS do aparelho (autoria desconhecida, D4 do plano /// 2026-10-03): gravadas antes de existir dono por visita no banco local. /// diff --git a/backend/sinalacs_server/lib/src/application/visits/visit_sync_service.dart b/backend/sinalacs_server/lib/src/application/visits/visit_sync_service.dart index f922f62b..d5323918 100644 --- a/backend/sinalacs_server/lib/src/application/visits/visit_sync_service.dart +++ b/backend/sinalacs_server/lib/src/application/visits/visit_sync_service.dart @@ -227,27 +227,27 @@ class VisitSyncService { result: 'granted', )); - return [ - for (final visit in visits) - VisitSyncEntry( - localId: visit.localId.uuid, - patientId: visit.patientId.uuid, - scheduledAt: visit.scheduledAt, - completedAt: visit.completedAt, - status: visit.status, - riskLevelBefore: visit.riskLevelBefore, - riskLevelAfter: visit.riskLevelAfter, - notes: visit.notes, - version: visit.version, - arrivalMethod: visit.arrivalMethod, - // Relógio do SERVIDOR (gravado em `_syncOne` via `_clock()`), nunca - // o do dispositivo. É o que o cursor do app usa para avançar sem - // depender do relógio do aparelho — ver `VisitPullService` no ACS. - syncAt: visit.syncAt, - ), - ]; + return [for (final visit in visits) _entryFor(visit)]; } + /// A visita como o ACS a recebe (`pull` e cópia do servidor no conflito). + VisitSyncEntry _entryFor(VisitRecord visit) => VisitSyncEntry( + localId: visit.localId.uuid, + patientId: visit.patientId.uuid, + scheduledAt: visit.scheduledAt, + completedAt: visit.completedAt, + status: visit.status, + riskLevelBefore: visit.riskLevelBefore, + riskLevelAfter: visit.riskLevelAfter, + notes: visit.notes, + version: visit.version, + arrivalMethod: visit.arrivalMethod, + // Relógio do SERVIDOR (gravado em `_syncOne` via `_clock()`), nunca + // o do dispositivo. É o que o cursor do app usa para avançar sem + // depender do relógio do aparelho — ver `VisitPullService` no ACS. + syncAt: visit.syncAt, + ); + Future> sync({ required AuthenticatedUser user, required List entries, @@ -334,103 +334,90 @@ class VisitSyncService { return results; } - /// Uma visita do lote. Com [legacyDeviceId] nulo é o `sync` comum (o autor - /// é [user]); preenchido, é o envio legado (sem autor, [user] só transporta). - Future _syncOne({ + /// Resolução explícita de um conflito pelo AUTOR ("manter a minha" ou + /// "editar e enviar", #79): compare-and-set sobre a versão do servidor que o + /// aparelho viu. + /// + /// `entry.version` é IGNORADO — a única referência é + /// [expectedServerVersion]. Igual à versão corrente: grava o conteúdo da + /// entrada em `existing.version + 1` e devolve `synced`. Diferente (alguém + /// mudou a visita de novo depois do conflito): devolve um `conflict` novo + /// com a cópia atual, sem gravar nada. Visita inexistente no servidor é + /// criada como no `sync`. Recusas de identidade, território e autoria são + /// as mesmas do `sync` (`_checkTarget`). + /// + /// A decisão vai para a trilha como `write`/`visit`/`conflict_resolved_local` + /// com o id da visita e nada clínico — best-effort (`recordSafely`), como o + /// resto do domínio de visitas: o trabalho de campo não para por queda da + /// trilha. + Future resolveConflict({ required AuthenticatedUser user, required VisitSyncEntry entry, - String? legacyDeviceId, - List? legacyWritten, + required int expectedServerVersion, }) async { - final legacy = legacyDeviceId != null; - if (entry.localId.trim().isEmpty) { - // Terminal: um localId vazio não vira válido reenviando o mesmo lote. - return VisitSyncResult( - localId: entry.localId, - syncStatus: SyncStatus.rejected, - message: 'localId é obrigatório', - ); + Authorization.require( + user, + roles: {UserRole.acs}, + onDenied: () => + StateError('Somente ACS territorializados podem sincronizar visitas.'), + ); + if (expectedServerVersion < 1) { + throw ArgumentError.value(expectedServerVersion, 'expectedServerVersion', + 'versão esperada do servidor deve ser ao menos 1'); } - final UuidValue localId; - final UuidValue patientId; - try { - // `UuidValue.fromString` NÃO valida — só normaliza para minúsculas. Sem - // `withValidation`, um `localId` malformado entraria no banco e quebraria - // a deduplicação do reenvio, que é justamente o que o índice único - // protege. - localId = UuidValue.withValidation(entry.localId); - patientId = UuidValue.withValidation(entry.patientId); - } on FormatException { - // Terminal: um identificador malformado na origem não vira válido - // reenviando. - return VisitSyncResult( - localId: entry.localId, - syncStatus: SyncStatus.rejected, - message: 'identificadores devem ser UUID', - ); - } + final gate = await _checkTarget(user: user, entry: entry, legacy: false); + if (gate is _Refused) return gate.result; + final (:localId, :patientId, :existing) = (gate as _Cleared).target; - // Território ANTES de tocar em `existing`: um reenvio para o mesmo - // `localId` também precisa ser barrado, não só a primeira gravação. - final patientMicroAreaId = await _store.microAreaOfPatient(patientId); - if (patientMicroAreaId == null) { - return VisitSyncResult( - localId: entry.localId, - syncStatus: SyncStatus.error, - message: 'paciente não encontrado', - ); + if (existing != null && existing.version != expectedServerVersion) { + return _conflictFor(user, entry, existing); } - final acsMicroAreaId = UuidValue.fromString(user.microAreaId!); - if (patientMicroAreaId != acsMicroAreaId) { - // A mensagem não cita a microárea alheia nem o nome do paciente — só que - // o vínculo não existe. §404 de spec/lgpd_design.md: "o sistema monitora - // se um ACS consulta dados de um paciente fora de sua microárea sem - // justificativa, gerando alerta para auditoria". - await _audit.recordSafely(AuditEvent( - userId: user.id, - actionType: 'write', - resourceType: legacy ? 'visit_legacy' : 'visit', - resourceId: patientId.uuid, - result: 'denied_territory', - )); - // Terminal: o território não muda por retentar. - return _outsideTerritory(entry); - } + final written = existing == null + ? await _store.insert(_newVisit( + entry: entry, + localId: localId, + patientId: patientId, + acsId: UuidValue.fromString(user.id), + )) + : await _store.update(_updatedVisit(existing, entry)); - final acsId = UuidValue.fromString(user.id); - final VisitRecord? existing; - try { - existing = await _store.findByLocalId(entry.localId); - } on UnreadableVisitException { - // Terminal e só desta visita: o resto do lote segue, e o aparelho a - // mantém na fila para revisão em vez de achar que a sessão caiu. - return VisitSyncResult( - localId: entry.localId, - syncStatus: SyncStatus.rejected, - message: 'visita anterior à cifra do risco (#75)', - ); - } + await _audit.recordSafely(AuditEvent( + userId: user.id, + actionType: 'write', + resourceType: 'visit', + resourceId: written.id?.uuid, + result: 'conflict_resolved_local', + )); + return VisitSyncResult( + localId: entry.localId, + syncStatus: SyncStatus.synced, + serverVersion: written.version, + ); + } + + /// Uma visita do lote. Com [legacyDeviceId] nulo é o `sync` comum (o autor + /// é [user]); preenchido, é o envio legado (sem autor, [user] só transporta). + Future _syncOne({ + required AuthenticatedUser user, + required VisitSyncEntry entry, + String? legacyDeviceId, + List? legacyWritten, + }) async { + final legacy = legacyDeviceId != null; + final gate = await _checkTarget(user: user, entry: entry, legacy: legacy); + if (gate is _Refused) return gate.result; + final (:localId, :patientId, :existing) = (gate as _Cleared).target; if (existing == null) { - final inserted = await _store.insert(VisitRecord( + final inserted = await _store.insert(_newVisit( + entry: entry, + localId: localId, patientId: patientId, // Legado: autoria desconhecida. NUNCA gravar quem transportou. - acsId: legacy ? null : acsId, - authorship: legacy ? VisitAuthorship.legacyUnclaimed : VisitAuthorship.acs, - originDeviceId: legacyDeviceId, - scheduledAt: entry.scheduledAt, - completedAt: entry.completedAt, - status: entry.status, - riskLevelBefore: entry.riskLevelBefore, - riskLevelAfter: entry.riskLevelAfter, - notes: entry.notes, - syncStatus: SyncStatus.synced, - arrivalMethod: entry.arrivalMethod, - localId: localId, - syncAt: _clock().toUtc(), - version: 1, + acsId: legacy ? null : UuidValue.fromString(user.id), + legacyDeviceId: legacyDeviceId, )); legacyWritten?.add(inserted); return VisitSyncResult( @@ -489,25 +476,9 @@ class VisitSyncService { message: 'visita legada de outro aparelho', ); } - } else if (existing.acsId == null) { - // Visita sem autor (legado, D4): o `sync` comum NÃO a reivindica — se - // reivindicasse, qualquer ACS do território viraria autor de um trabalho - // de campo que ninguém sabe de quem é. Terminal: não muda por retentar. - return VisitSyncResult( - localId: entry.localId, - syncStatus: SyncStatus.rejected, - message: 'visita sem autor registrado', - ); - } else if (existing.acsId != acsId) { - // A visita pertence ao ACS que a registrou. Um ACS não sincroniza a - // visita de outro, mesmo conhecendo o localId. Terminal: o dono do - // registro não muda por retentar. - return VisitSyncResult( - localId: entry.localId, - syncStatus: SyncStatus.rejected, - message: 'visita registrada por outro agente', - ); } + // Fora do legado, a autoria (sem autor / outro agente) já foi conferida + // em `_checkTarget`. // Reenvio do MESMO estado: a rede caiu depois de o servidor gravar, e o // dispositivo não viu a resposta. Não é conflito — é a idempotência @@ -524,29 +495,20 @@ class VisitSyncService { // a visita no meio. Devolve conflito com a versão do servidor, sem // sobrescrever nada. if (entry.version != existing.version - 1) { - return VisitSyncResult( - localId: entry.localId, - syncStatus: SyncStatus.conflict, - serverVersion: existing.version, - ); + // Só o autor recebe a cópia (aqui `legacy` já exclui o transportador). + // Audita o EVENTO, sem conteúdo clínico, como o `visit_pull`. + if (legacy) { + return VisitSyncResult( + localId: entry.localId, + syncStatus: SyncStatus.conflict, + serverVersion: existing.version, + ); + } + return _conflictFor(user, entry, existing); } - // `acsId`, `authorship` e `originDeviceId` também ficam de fora: a autoria - // é decidida na criação e nenhuma atualização a muda (nem o envio legado - // preenche um autor, nem o `sync` comum chega aqui com visita sem autor). - // `arrivalMethod` fica de fora deliberadamente: é definido na criação da - // visita (como `scheduledAt`/`riskLevelBefore`), não numa atualização de - // conclusão — `copyWith` sem o argumento preserva o valor gravado no - // insert. - final updated = await _store.update(existing.copyWith( - completedAt: entry.completedAt, - status: entry.status, - riskLevelAfter: entry.riskLevelAfter, - notes: entry.notes, - syncStatus: SyncStatus.synced, - syncAt: _clock().toUtc(), - version: existing.version + 1, - )); + // Ver `_updatedVisit` sobre os campos que a atualização preserva. + final updated = await _store.update(_updatedVisit(existing, entry)); legacyWritten?.add(updated); return VisitSyncResult( @@ -556,6 +518,203 @@ class VisitSyncService { ); } + /// Checagens de identidade, território e autoria comuns a `sync`, + /// `syncLegacy` e `resolveConflict` — um só lugar, para as recusas serem as + /// MESMAS (mesmo `SyncStatus`, mesma `message`, mesma auditoria + /// `denied_territory`). + /// + /// Com [legacy], a autoria NÃO é conferida aqui: o envio legado tem regras + /// próprias (paciente divergente, visita com autor, outro aparelho), que + /// seguem em `_syncOne`. + Future<_Gate> _checkTarget({ + required AuthenticatedUser user, + required VisitSyncEntry entry, + required bool legacy, + }) async { + if (entry.localId.trim().isEmpty) { + // Terminal: um localId vazio não vira válido reenviando o mesmo lote. + return _Refused(VisitSyncResult( + localId: entry.localId, + syncStatus: SyncStatus.rejected, + message: 'localId é obrigatório', + )); + } + + final UuidValue localId; + final UuidValue patientId; + try { + // `UuidValue.fromString` NÃO valida — só normaliza para minúsculas. Sem + // `withValidation`, um `localId` malformado entraria no banco e quebraria + // a deduplicação do reenvio, que é justamente o que o índice único + // protege. + localId = UuidValue.withValidation(entry.localId); + patientId = UuidValue.withValidation(entry.patientId); + } on FormatException { + // Terminal: um identificador malformado na origem não vira válido + // reenviando. + return _Refused(VisitSyncResult( + localId: entry.localId, + syncStatus: SyncStatus.rejected, + message: 'identificadores devem ser UUID', + )); + } + + // Território ANTES de tocar em `existing`: um reenvio para o mesmo + // `localId` também precisa ser barrado, não só a primeira gravação. + final patientMicroAreaId = await _store.microAreaOfPatient(patientId); + if (patientMicroAreaId == null) { + return _Refused(VisitSyncResult( + localId: entry.localId, + syncStatus: SyncStatus.error, + message: 'paciente não encontrado', + )); + } + + final acsMicroAreaId = UuidValue.fromString(user.microAreaId!); + if (patientMicroAreaId != acsMicroAreaId) { + // A mensagem não cita a microárea alheia nem o nome do paciente — só que + // o vínculo não existe. §404 de spec/lgpd_design.md: "o sistema monitora + // se um ACS consulta dados de um paciente fora de sua microárea sem + // justificativa, gerando alerta para auditoria". + await _audit.recordSafely(AuditEvent( + userId: user.id, + actionType: 'write', + resourceType: legacy ? 'visit_legacy' : 'visit', + resourceId: patientId.uuid, + result: 'denied_territory', + )); + // Terminal: o território não muda por retentar. + return _Refused(_outsideTerritory(entry)); + } + + final VisitRecord? existing; + try { + existing = await _store.findByLocalId(entry.localId); + } on UnreadableVisitException { + // Terminal e só desta visita: o resto do lote segue, e o aparelho a + // mantém na fila para revisão em vez de achar que a sessão caiu. + return _Refused(VisitSyncResult( + localId: entry.localId, + syncStatus: SyncStatus.rejected, + message: 'visita anterior à cifra do risco (#75)', + )); + } + + if (!legacy && existing != null) { + // O território acima foi conferido contra o paciente DECLARADO. Sem + // isto, um ACS movido de microárea (`admin.setAcsMicroArea`) mandaria o + // `localId` de uma visita sua antiga com um paciente da área nova e + // receberia a cópia da área de origem no `conflict` (ou a sobrescreveria + // pelo `resolveConflict`). Antes da autoria, para nenhuma resposta + // revelar que o `localId` existe; a resposta é a mesma da recusa + // territorial, como no ramo legado. + if (existing.patientId != patientId) { + await _audit.recordSafely(AuditEvent( + userId: user.id, + actionType: 'write', + resourceType: 'visit', + resourceId: patientId.uuid, + result: 'denied_patient_mismatch', + )); + return _Refused(_outsideTerritory(entry)); + } + final acsId = UuidValue.fromString(user.id); + if (existing.acsId == null) { + // Visita sem autor (legado, D4): o `sync` comum NÃO a reivindica — se + // reivindicasse, qualquer ACS do território viraria autor de um + // trabalho de campo que ninguém sabe de quem é. Terminal: não muda por + // retentar. + return _Refused(VisitSyncResult( + localId: entry.localId, + syncStatus: SyncStatus.rejected, + message: 'visita sem autor registrado', + )); + } + if (existing.acsId != acsId) { + // A visita pertence ao ACS que a registrou. Um ACS não sincroniza a + // visita de outro, mesmo conhecendo o localId. Terminal: o dono do + // registro não muda por retentar. + return _Refused(VisitSyncResult( + localId: entry.localId, + syncStatus: SyncStatus.rejected, + message: 'visita registrada por outro agente', + )); + } + } + + return _Cleared((localId: localId, patientId: patientId, existing: existing)); + } + + /// Visita nova a partir da entrada do aparelho, já `synced` na versão 1. + /// Sem [acsId] é a visita legada, de autoria desconhecida (D4). + VisitRecord _newVisit({ + required VisitSyncEntry entry, + required UuidValue localId, + required UuidValue patientId, + required UuidValue? acsId, + String? legacyDeviceId, + }) => + VisitRecord( + patientId: patientId, + acsId: acsId, + authorship: acsId == null ? VisitAuthorship.legacyUnclaimed : VisitAuthorship.acs, + originDeviceId: legacyDeviceId, + scheduledAt: entry.scheduledAt, + completedAt: entry.completedAt, + status: entry.status, + riskLevelBefore: entry.riskLevelBefore, + riskLevelAfter: entry.riskLevelAfter, + notes: entry.notes, + syncStatus: SyncStatus.synced, + arrivalMethod: entry.arrivalMethod, + localId: localId, + syncAt: _clock().toUtc(), + version: 1, + ); + + /// [existing] com o conteúdo de conclusão da entrada, em + /// `existing.version + 1`. + /// + /// `acsId`, `authorship` e `originDeviceId` ficam de fora: a autoria é + /// decidida na criação e nenhuma atualização a muda (nem o envio legado + /// preenche um autor, nem o `sync` comum chega aqui com visita sem autor). + /// `arrivalMethod` fica de fora deliberadamente: é definido na criação da + /// visita (como `scheduledAt`/`riskLevelBefore`), não numa atualização de + /// conclusão — `copyWith` sem o argumento preserva o valor gravado no + /// insert. + VisitRecord _updatedVisit(VisitRecord existing, VisitSyncEntry entry) => existing.copyWith( + completedAt: entry.completedAt, + status: entry.status, + riskLevelAfter: entry.riskLevelAfter, + notes: entry.notes, + syncStatus: SyncStatus.synced, + syncAt: _clock().toUtc(), + version: existing.version + 1, + ); + + /// Conflito com a cópia do servidor para o AUTOR (nunca no legado, cujo + /// transportador não é autor). Audita o EVENTO, sem conteúdo clínico, como + /// o `visit_pull`. + Future _conflictFor( + AuthenticatedUser user, + VisitSyncEntry entry, + VisitRecord existing, + ) async { + await _audit.recordSafely(AuditEvent( + userId: user.id, + actionType: 'read', + resourceType: 'visit_conflict', + resourceId: existing.id?.uuid, + result: 'granted', + )); + return VisitSyncResult( + localId: entry.localId, + syncStatus: SyncStatus.conflict, + serverVersion: existing.version, + serverVisit: _entryFor(existing), + ); + } + /// A recusa territorial, única para fora do território e para `localId` de /// outro paciente: não cita a microárea alheia nem o paciente. Terminal. static VisitSyncResult _outsideTerritory(VisitSyncEntry entry) => VisitSyncResult( @@ -575,3 +734,23 @@ class VisitSyncService { result: 'visit_legacy_sync_item', )); } + +/// Desfecho de `VisitSyncService._checkTarget`. +sealed class _Gate { + const _Gate(); +} + +/// Recusa pronta para devolver ao aparelho. +final class _Refused extends _Gate { + const _Refused(this.result); + + final VisitSyncResult result; +} + +/// Identificadores válidos, território conferido e (fora do legado) autoria +/// conferida; [target] traz a linha existente, se houver. +final class _Cleared extends _Gate { + const _Cleared(this.target); + + final ({UuidValue localId, UuidValue patientId, VisitRecord? existing}) target; +} diff --git a/backend/sinalacs_server/lib/src/endpoints/visits_endpoint.dart b/backend/sinalacs_server/lib/src/endpoints/visits_endpoint.dart index c61b4f15..e707531a 100644 --- a/backend/sinalacs_server/lib/src/endpoints/visits_endpoint.dart +++ b/backend/sinalacs_server/lib/src/endpoints/visits_endpoint.dart @@ -37,6 +37,36 @@ class VisitsEndpoint extends AuthenticatedEndpoint { } } + /// Resolução explícita de um conflito pelo autor (#79): compare-and-set + /// sobre [expectedServerVersion], a versão do servidor que o aparelho viu no + /// conflito. `visit.version` é ignorado. Ver + /// `VisitSyncService.resolveConflict`. Mesma transação e mesma tradução de + /// erros de [sync]. + Future resolveConflict( + Session session, { + required String accessToken, + required VisitSyncEntry visit, + required int expectedServerVersion, + }) async { + final user = authenticate(accessToken); + + try { + return await session.db.transaction((transaction) async { + final service = + AlertRuntime.instance.visitSyncServiceFor(session, transaction: transaction); + return service.resolveConflict( + user: user, + entry: visit, + expectedServerVersion: expectedServerVersion, + ); + }); + } on ArgumentError catch (error) { + throw AlertValidationException(message: '${error.message}'); + } on StateError catch (error) { + throw AlertPermissionException(message: error.message); + } + } + /// Envio das visitas LEGADAS do aparelho (autoria desconhecida, D4 do plano /// 2026-10-03): gravadas antes de existir dono por visita no banco local. /// diff --git a/backend/sinalacs_server/lib/src/generated/api/visit_sync_result.dart b/backend/sinalacs_server/lib/src/generated/api/visit_sync_result.dart index e7f8f36e..f79cbdbd 100644 --- a/backend/sinalacs_server/lib/src/generated/api/visit_sync_result.dart +++ b/backend/sinalacs_server/lib/src/generated/api/visit_sync_result.dart @@ -13,6 +13,8 @@ import 'package:serverpod/serverpod.dart' as _i1; import '../enums/sync_status.dart' as _i2; +import '../api/visit_sync_entry.dart' as _i3; +import 'package:sinalacs_server/src/generated/protocol.dart' as _i4; /// Resultado da sincronização de UMA visita. /// @@ -26,6 +28,7 @@ abstract class VisitSyncResult required this.syncStatus, this.serverVersion, this.message, + this.serverVisit, }); factory VisitSyncResult({ @@ -33,6 +36,7 @@ abstract class VisitSyncResult required _i2.SyncStatus syncStatus, int? serverVersion, String? message, + _i3.VisitSyncEntry? serverVisit, }) = _VisitSyncResultImpl; factory VisitSyncResult.fromJson(Map jsonSerialization) { @@ -43,6 +47,11 @@ abstract class VisitSyncResult ), serverVersion: jsonSerialization['serverVersion'] as int?, message: jsonSerialization['message'] as String?, + serverVisit: jsonSerialization['serverVisit'] == null + ? null + : _i4.Protocol().deserialize<_i3.VisitSyncEntry>( + jsonSerialization['serverVisit'], + ), ); } @@ -57,6 +66,10 @@ abstract class VisitSyncResult /// Preenchido apenas quando syncStatus é error. String? message; + /// Preenchido apenas em `conflict` do caminho com autor: a visita como está + /// no servidor, para o ACS comparar. + _i3.VisitSyncEntry? serverVisit; + /// Returns a shallow copy of this [VisitSyncResult] /// with some or all fields replaced by the given arguments. @_i1.useResult @@ -65,6 +78,7 @@ abstract class VisitSyncResult _i2.SyncStatus? syncStatus, int? serverVersion, String? message, + _i3.VisitSyncEntry? serverVisit, }); @override Map toJson() { @@ -74,6 +88,7 @@ abstract class VisitSyncResult 'syncStatus': syncStatus.toJson(), if (serverVersion != null) 'serverVersion': serverVersion, if (message != null) 'message': message, + if (serverVisit != null) 'serverVisit': serverVisit?.toJson(), }; } @@ -85,6 +100,7 @@ abstract class VisitSyncResult 'syncStatus': syncStatus.toJson(), if (serverVersion != null) 'serverVersion': serverVersion, if (message != null) 'message': message, + if (serverVisit != null) 'serverVisit': serverVisit?.toJsonForProtocol(), }; } @@ -102,11 +118,13 @@ class _VisitSyncResultImpl extends VisitSyncResult { required _i2.SyncStatus syncStatus, int? serverVersion, String? message, + _i3.VisitSyncEntry? serverVisit, }) : super._( localId: localId, syncStatus: syncStatus, serverVersion: serverVersion, message: message, + serverVisit: serverVisit, ); /// Returns a shallow copy of this [VisitSyncResult] @@ -118,12 +136,16 @@ class _VisitSyncResultImpl extends VisitSyncResult { _i2.SyncStatus? syncStatus, Object? serverVersion = _Undefined, Object? message = _Undefined, + Object? serverVisit = _Undefined, }) { return VisitSyncResult( localId: localId ?? this.localId, syncStatus: syncStatus ?? this.syncStatus, serverVersion: serverVersion is int? ? serverVersion : this.serverVersion, message: message is String? ? message : this.message, + serverVisit: serverVisit is _i3.VisitSyncEntry? + ? serverVisit + : this.serverVisit?.copyWith(), ); } } diff --git a/backend/sinalacs_server/lib/src/generated/endpoints.dart b/backend/sinalacs_server/lib/src/generated/endpoints.dart index 7ecb2a93..f7d32f82 100644 --- a/backend/sinalacs_server/lib/src/generated/endpoints.dart +++ b/backend/sinalacs_server/lib/src/generated/endpoints.dart @@ -1859,6 +1859,37 @@ class Endpoints extends _i1.EndpointDispatch { visits: params['visits'], ), ), + 'resolveConflict': _i1.MethodConnector( + name: 'resolveConflict', + params: { + 'accessToken': _i1.ParameterDescription( + name: 'accessToken', + type: _i1.getType(), + nullable: false, + ), + 'visit': _i1.ParameterDescription( + name: 'visit', + type: _i1.getType<_i18.VisitSyncEntry>(), + nullable: false, + ), + 'expectedServerVersion': _i1.ParameterDescription( + name: 'expectedServerVersion', + type: _i1.getType(), + nullable: false, + ), + }, + call: + ( + _i1.Session session, + Map params, + ) async => + (endpoints['visits'] as _i12.VisitsEndpoint).resolveConflict( + session, + accessToken: params['accessToken'], + visit: params['visit'], + expectedServerVersion: params['expectedServerVersion'], + ), + ), 'syncLegacy': _i1.MethodConnector( name: 'syncLegacy', params: { diff --git a/backend/sinalacs_server/lib/src/generated/protocol.yaml b/backend/sinalacs_server/lib/src/generated/protocol.yaml index 2faae5f8..7d3c9fa7 100644 --- a/backend/sinalacs_server/lib/src/generated/protocol.yaml +++ b/backend/sinalacs_server/lib/src/generated/protocol.yaml @@ -72,6 +72,7 @@ ubs: - myContact: visits: - sync: + - resolveConflict: - syncLegacy: - syncDeferred: - revokeUploadToken: diff --git a/backend/sinalacs_server/lib/src/models/api/visit_sync_result.spy.yaml b/backend/sinalacs_server/lib/src/models/api/visit_sync_result.spy.yaml index 5a9ebac7..2cf60784 100644 --- a/backend/sinalacs_server/lib/src/models/api/visit_sync_result.spy.yaml +++ b/backend/sinalacs_server/lib/src/models/api/visit_sync_result.spy.yaml @@ -12,3 +12,6 @@ fields: serverVersion: int? ### Preenchido apenas quando syncStatus é error. message: String? + ### Preenchido apenas em `conflict` do caminho com autor: a visita como está + ### no servidor, para o ACS comparar. + serverVisit: VisitSyncEntry? 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 2b95c2ed..6cb08006 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 @@ -2609,6 +2609,43 @@ class _VisitsEndpoint { }); } + _i3.Future<_i43.VisitSyncResult> resolveConflict( + _i1.TestSessionBuilder sessionBuilder, { + required String accessToken, + required _i44.VisitSyncEntry visit, + required int expectedServerVersion, + }) async { + return _i1.callAwaitableFunctionAndHandleExceptions(() async { + var _localUniqueSession = + (sessionBuilder as _i1.InternalTestSessionBuilder).internalBuild( + endpoint: 'visits', + method: 'resolveConflict', + ); + try { + var _localCallContext = await _endpointDispatch.getMethodCallContext( + createSessionCallback: (_) => _localUniqueSession, + endpointPath: 'visits', + methodName: 'resolveConflict', + parameters: _i1.testObjectToJson({ + 'accessToken': accessToken, + 'visit': visit, + 'expectedServerVersion': expectedServerVersion, + }), + serializationManager: _serializationManager, + ); + var _localReturnValue = + await (_localCallContext.method.call( + _localUniqueSession, + _localCallContext.arguments, + ) + as _i3.Future<_i43.VisitSyncResult>); + return _localReturnValue; + } finally { + await _localUniqueSession.close(); + } + }); + } + _i3.Future> syncLegacy( _i1.TestSessionBuilder sessionBuilder, { required String accessToken, diff --git a/backend/sinalacs_server/test/integration/visit_conflict_resolution_test.dart b/backend/sinalacs_server/test/integration/visit_conflict_resolution_test.dart new file mode 100644 index 00000000..81f90b53 --- /dev/null +++ b/backend/sinalacs_server/test/integration/visit_conflict_resolution_test.dart @@ -0,0 +1,271 @@ +import 'package:serverpod/serverpod.dart'; +import 'package:sinalacs_server/src/application/auth/development_auth_service.dart'; +import 'package:sinalacs_server/src/config/app_config.dart'; +import 'package:sinalacs_server/src/generated/protocol.dart'; +import 'package:sinalacs_server/src/runtime/alert_runtime.dart'; +import 'package:test/test.dart'; + +import '../support/health_data_fixtures.dart'; +import 'test_tools/serverpod_test_tools.dart'; + +/// Prova, contra Postgres real e pelo endpoint, o que o grupo +/// `resolveConflict` de `visit_sync_service_test.dart` prova com fakes (#79): +/// o conflito devolve a cópia do servidor, `visits.resolveConflict` grava em +/// compare-and-set sobre a versão vista, as notas voltam decifradas, a versão +/// vencida não grava e a recusa territorial é a mesma do `sync`. +const _acsId = '00000000-0000-4000-8000-000000000002'; +const _acsOutraAreaId = '00000000-0000-4000-8000-000000000022'; +const _microAreaId = '00000000-0000-4000-8000-000000000003'; +const _otherMicroAreaId = '00000000-0000-4000-8000-000000000099'; +const _ubsId = '00000000-0000-4000-8000-000000000004'; +const _patientId = '00000000-0000-4000-8000-000000000005'; +const _localId = '00000000-0000-4000-8000-0000000000d1'; + +AppConfig _config() => AppConfig( + mqttBroker: 'localhost:1883', + jwtSecret: 'test-secret', + auditChainSecret: 'test-audit-chain-secret', + healthDataEncryptionKey: AppConfig.developmentHealthDataEncryptionKey, + cpfHashPepper: AppConfig.developmentCpfHashPepper, + smsGateway: 'log', + mqttUsername: null, + mqttPassword: null, + mqttUseTls: false, + mqttCaCertificatePath: null, + appEnv: 'development', + enableDevLogin: true, + ); + +Future _seed(Session session) async { + await Ubs.db.insertRow( + session, + Ubs( + id: UuidValue.fromString(_ubsId), + name: 'UBS Desenvolvimento', + address: 'Endereço local', + city: 'São Paulo', + state: 'SP', + ), + ); + await MicroArea.db.insert(session, [ + MicroArea( + id: UuidValue.fromString(_microAreaId), + name: 'Microárea 12', + ubsId: UuidValue.fromString(_ubsId), + geoJsonBoundary: '{}', + ), + MicroArea( + id: UuidValue.fromString(_otherMicroAreaId), + name: 'Microárea vizinha', + ubsId: UuidValue.fromString(_ubsId), + geoJsonBoundary: '{}', + ), + ]); + + final now = DateTime.now().toUtc(); + await User.db.insert(session, [ + User( + id: UuidValue.fromString(_acsId), + cpfHash: 'development-acs', + name: 'ACS de desenvolvimento', + birthDate: DateTime.utc(1980), + role: UserRole.acs, + microAreaId: UuidValue.fromString(_microAreaId), + createdAt: now, + updatedAt: now, + ), + User( + id: UuidValue.fromString(_acsOutraAreaId), + cpfHash: 'development-acs-22', + name: 'ACS da microárea vizinha', + birthDate: DateTime.utc(1982), + role: UserRole.acs, + microAreaId: UuidValue.fromString(_otherMicroAreaId), + createdAt: now, + updatedAt: now, + ), + User( + id: UuidValue.fromString(_patientId), + cpfHash: 'development-patient-05', + name: 'Paciente Sintético', + birthDate: DateTime.utc(1975, 3, 10), + role: UserRole.patient, + 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, + ), + ); +} + +VisitSyncEntry _entry({ + required int version, + String status = 'realizada', + Map notes = const {'campo': 'nota sintética inicial'}, +}) => + VisitSyncEntry( + localId: _localId, + patientId: _patientId, + scheduledAt: DateTime.utc(2026, 10, 16, 9), + completedAt: DateTime.utc(2026, 10, 16, 10), + status: status, + riskLevelBefore: RiskLevel.green, + riskLevelAfter: RiskLevel.green, + notes: notes, + version: version, + arrivalMethod: ArrivalMethod.manual, + ); + +void main() { + withServerpod('Dada uma visita em conflito, visits.resolveConflict', + (sessionBuilder, endpoints) { + setUp(() => AlertRuntime.instance.overrideConfig(_config())); + tearDown(() => AlertRuntime.instance.overrideConfig(null)); + + test( + 'resolve com a versão vista, recusa versão vencida e outro território, ' + 'e grava conflict_resolved_local na trilha', () async { + final session = sessionBuilder.build(); + await _seed(session); + final login = await endpoints.auth.developmentLogin(sessionBuilder, role: 'acs'); + final token = login.accessToken; + + Future sync(VisitSyncEntry e) async => (await endpoints.visits.sync( + sessionBuilder, + accessToken: token, + visits: [e], + )) + .single; + + // Cria (v1) e leva a v3 pela regra do `sync` (versão = servidor - 1). + expect((await sync(_entry(version: 0))).serverVersion, 1); + expect((await sync(_entry(version: 0))).serverVersion, 2); + expect((await sync(_entry(version: 1))).serverVersion, 3); + + // O aparelho partiu da v1: conflito com a cópia do servidor. + final conflito = await sync(_entry(version: 1, status: 'paciente ausente')); + expect(conflito.syncStatus, SyncStatus.conflict); + expect(conflito.serverVersion, 3); + expect(conflito.serverVisit!.version, 3); + expect(conflito.serverVisit!.status, 'realizada'); + + // "Manter a minha": compare-and-set sobre a versão vista. + const notasDoAparelho = {'campo': 'nota sintética do aparelho'}; + final resolvido = await endpoints.visits.resolveConflict( + sessionBuilder, + accessToken: token, + visit: _entry(version: 1, status: 'paciente ausente', notes: notasDoAparelho), + expectedServerVersion: 3, + ); + expect(resolvido.syncStatus, SyncStatus.synced); + expect(resolvido.serverVersion, 4); + + // As notas relidas do banco (cifradas na coluna) voltam iguais. + final relidas = await endpoints.visits.pull( + sessionBuilder, + accessToken: token, + since: DateTime.utc(2000), + ); + expect(relidas.single.version, 4); + expect(relidas.single.status, 'paciente ausente'); + expect(relidas.single.notes, notasDoAparelho); + + // Segunda decisão sobre a mesma versão vista: vencida, nada gravado. + final vencido = await endpoints.visits.resolveConflict( + sessionBuilder, + accessToken: token, + visit: _entry(version: 1, status: 'recusou atendimento'), + expectedServerVersion: 3, + ); + expect(vencido.syncStatus, SyncStatus.conflict); + expect(vencido.serverVersion, 4); + expect(vencido.serverVisit!.version, 4); + expect(vencido.serverVisit!.notes, notasDoAparelho); + + // ACS de outra microárea: a mesma recusa territorial do `sync`. + final tokenOutraArea = AlertRuntime.instance.auth.issueToken(const AuthenticatedUser( + id: _acsOutraAreaId, + role: UserRole.acs, + microAreaId: _otherMicroAreaId, + deviceId: 'acs-device-outra-area', + )); + final recusado = await endpoints.visits.resolveConflict( + sessionBuilder, + accessToken: tokenOutraArea, + visit: _entry(version: 4, status: 'recusou atendimento'), + expectedServerVersion: 4, + ); + expect(recusado.syncStatus, SyncStatus.rejected); + expect(recusado.message, 'paciente fora da sua microárea'); + expect(recusado.serverVisit, isNull); + + final negadas = await AuditLog.db.find( + session, + where: (t) => t.result.equals('denied_territory'), + ); + expect(negadas, hasLength(1)); + expect(negadas.single.resourceType, 'visit'); + expect(negadas.single.userId, UuidValue.fromString(_acsOutraAreaId)); + + final linha = (await Visit.db.find(session)).single; + expect(linha.version, 4); + expect(linha.status, 'paciente ausente'); + + final resolucoes = await AuditLog.db.find( + session, + where: (t) => t.result.equals('conflict_resolved_local'), + ); + expect(resolucoes, hasLength(1)); + expect(resolucoes.single.actionType, 'write'); + expect(resolucoes.single.resourceType, 'visit'); + expect(resolucoes.single.resourceId, linha.id); + expect(resolucoes.single.userId, UuidValue.fromString(_acsId)); + }); + + test('versão esperada menor que 1 vira AlertValidationException', () async { + await _seed(sessionBuilder.build()); + final login = await endpoints.auth.developmentLogin(sessionBuilder, role: 'acs'); + + await expectLater( + endpoints.visits.resolveConflict( + sessionBuilder, + accessToken: login.accessToken, + visit: _entry(version: 0), + expectedServerVersion: 0, + ), + throwsA(isA()), + ); + }); + + test('paciente recebe AlertPermissionException', () async { + await _seed(sessionBuilder.build()); + final login = await endpoints.auth.developmentLogin(sessionBuilder, role: 'patient'); + + await expectLater( + endpoints.visits.resolveConflict( + sessionBuilder, + accessToken: login.accessToken, + visit: _entry(version: 0), + expectedServerVersion: 1, + ), + throwsA(isA()), + ); + }); + }); +} diff --git a/backend/sinalacs_server/test/unit/visit_sync_service_test.dart b/backend/sinalacs_server/test/unit/visit_sync_service_test.dart index 0c318f60..d428833d 100644 --- a/backend/sinalacs_server/test/unit/visit_sync_service_test.dart +++ b/backend/sinalacs_server/test/unit/visit_sync_service_test.dart @@ -144,6 +144,7 @@ void main() { expect(results.single.syncStatus, SyncStatus.synced); expect(results.single.serverVersion, 1); + expect(results.single.serverVisit, isNull); expect(store.rows[_localId]?.status, 'realizada'); }); @@ -197,6 +198,41 @@ void main() { expect(store.rows[_localId]?.status, 'realizada'); }); + test('conflito devolve a visita do servidor ao autor', () async { + await service.sync(user: _acs, entries: [entry()]); + + final conflicted = await service.sync( + user: _acs, + entries: [entry(version: 7, status: 'recusou atendimento')], + ); + + expect(conflicted.single.syncStatus, SyncStatus.conflict); + expect(conflicted.single.serverVersion, 1); + final copia = conflicted.single.serverVisit!; + expect(copia.status, 'realizada'); + expect(copia.version, 1); + expect(copia.localId, _localId); + expect(copia.notes, store.rows[_localId]!.notes); + }); + + test('conflito grava read/visit_conflict sem conteúdo', () async { + await service.sync(user: _acs, entries: [entry()]); + audit.events.clear(); + + await service.sync( + user: _acs, + entries: [entry(version: 7, status: 'recusou atendimento')], + ); + + final event = audit.events.single; + expect(event.actionType, 'read'); + expect(event.resourceType, 'visit_conflict'); + expect(event.resourceId, store.rows[_localId]!.id!.uuid); + expect(event.userId, _acsId); + expect(event.result, isNot(contains('sem intercorrências'))); + expect(event.resourceId, isNot(contains('sem intercorrências'))); + }); + test('recusa sincronizar visita registrada por outro agente', () async { await service.sync(user: _acs, entries: [entry()]); @@ -210,6 +246,7 @@ void main() { // Terminal: o dono do registro não muda com uma próxima tentativa. expect(results.single.syncStatus, SyncStatus.rejected); + expect(results.single.serverVisit, isNull); expect(store.rows[_localId]?.acsId, UuidValue.fromString(_acsId)); }); @@ -309,6 +346,7 @@ void main() { ); expect(results.single.syncStatus, SyncStatus.error); + expect(results.single.serverVisit, isNull); // Não é necessariamente espionagem territorial — pode ser um localId // órfão de um seed antigo. Só a recusa POR TERRITÓRIO é auditada. expect(audit.events, isEmpty); @@ -551,6 +589,8 @@ void main() { expect(conflicted.single.syncStatus, SyncStatus.conflict); expect(conflicted.single.serverVersion, 1); expect(store.rows[_localId]!.status, 'realizada'); + // Legado não tem autor: a cópia do servidor não é devolvida. + expect(conflicted.single.serverVisit, isNull); }); test('visita legada de OUTRO aparelho com o mesmo localId: rejected', () async { @@ -901,4 +941,279 @@ void main() { expect(results.single.message, 'paciente fora da sua microárea'); }); }); + + group('resolveConflict', () { + const outroAcs = AuthenticatedUser( + id: _otherAcsId, + role: UserRole.acs, + microAreaId: _microAreaId, + deviceId: 'acs-device-002', + ); + + setUp(() { + store = FakeVisitStore(microAreaByPatient: { + _patientId: _microAreaId, + _outroTerritorioPatientId: _otherMicroAreaId, + }); + audit = FakeAuditTrail(); + service = VisitSyncService( + store: store, + audit: audit, + clock: () => DateTime.utc(2026, 10, 16, 12), + ); + }); + + /// Leva a visita a v3 no servidor pela regra atual do `sync` + /// (`entry.version == servidor - 1` atualiza). + Future servidorEmV3() async { + await service.sync(user: _acs, entries: [entry()]); + await service.sync(user: _acs, entries: [entry(version: 0)]); + await service.sync(user: _acs, entries: [entry(version: 1)]); + expect(store.rows[_localId]!.version, 3); + } + + test('grava o conteúdo do aparelho quando a versão esperada é a corrente', () async { + await servidorEmV3(); + audit.events.clear(); + + final result = await service.resolveConflict( + user: _acs, + entry: entry(status: 'recusou atendimento'), + expectedServerVersion: 3, + ); + + expect(result.syncStatus, SyncStatus.synced); + expect(result.serverVersion, 4); + expect(result.serverVisit, isNull); + final row = store.rows[_localId]!; + expect(row.status, 'recusou atendimento'); + expect(row.version, 4); + expect(row.acsId, UuidValue.fromString(_acsId)); + final resolvidos = + audit.events.where((e) => e.result == 'conflict_resolved_local').toList(); + expect(resolvidos, hasLength(1)); + expect(resolvidos.single.actionType, 'write'); + expect(resolvidos.single.resourceType, 'visit'); + expect(resolvidos.single.resourceId, row.id!.uuid); + expect(resolvidos.single.userId, _acsId); + }); + + test('ignora entry.version: a referência é só a versão esperada', () async { + await servidorEmV3(); + + final result = await service.resolveConflict( + user: _acs, + entry: entry(version: 99, status: 'recusou atendimento'), + expectedServerVersion: 3, + ); + + expect(result.syncStatus, SyncStatus.synced); + expect(result.serverVersion, 4); + expect(store.rows[_localId]!.version, 4); + }); + + test('versão vencida devolve conflito novo sem gravar', () async { + await servidorEmV3(); + final antes = store.rows[_localId]!; + audit.events.clear(); + + final result = await service.resolveConflict( + user: _acs, + entry: entry(status: 'recusou atendimento'), + expectedServerVersion: 2, + ); + + expect(result.syncStatus, SyncStatus.conflict); + expect(result.serverVersion, 3); + expect(result.serverVisit!.version, 3); + expect(result.serverVisit!.status, 'realizada'); + expect(identical(store.rows[_localId], antes), isTrue); + expect(store.rows[_localId]!.status, 'realizada'); + expect(audit.events.where((e) => e.result == 'conflict_resolved_local'), isEmpty); + // A cópia devolvida é uma leitura, auditada como no conflito do `sync`. + final leitura = audit.events.single; + expect(leitura.actionType, 'read'); + expect(leitura.resourceType, 'visit_conflict'); + expect(leitura.resourceId, antes.id!.uuid); + }); + + test('visita inexistente é criada', () async { + final result = await service.resolveConflict( + user: _acs, + entry: entry(status: 'recusou atendimento'), + expectedServerVersion: 3, + ); + + expect(result.syncStatus, SyncStatus.synced); + expect(result.serverVersion, 1); + final row = store.rows[_localId]!; + expect(row.status, 'recusou atendimento'); + expect(row.acsId, UuidValue.fromString(_acsId)); + expect(row.authorship, VisitAuthorship.acs); + final resolvidos = + audit.events.where((e) => e.result == 'conflict_resolved_local').toList(); + expect(resolvidos, hasLength(1)); + expect(resolvidos.single.resourceType, 'visit'); + expect(resolvidos.single.resourceId, row.id!.uuid); + }); + + group('localId de outro paciente (ACS movido de microárea)', () { + /// O mesmo ACS, depois de `admin.setAcsMicroArea` levá-lo para a área do + /// `_outroTerritorioPatientId`. + const acsMovido = AuthenticatedUser( + id: _acsId, + role: UserRole.acs, + microAreaId: _otherMicroAreaId, + deviceId: 'acs-device-001', + ); + + /// Visita própria antiga, na área de origem, levada a v3. + Future visitaAntigaNaAreaDeOrigem() async { + await servidorEmV3(); + audit.events.clear(); + return store.rows[_localId]!; + } + + void confereRecusa(VisitSyncResult result, VisitRecord antes) { + expect(result.syncStatus, SyncStatus.rejected); + expect(result.message, 'paciente fora da sua microárea'); + expect(result.serverVisit, isNull); + expect(result.serverVersion, isNull); + expect(identical(store.rows[_localId], antes), isTrue); + expect(audit.events, hasLength(1)); + final evento = audit.events.single; + expect(evento.result, 'denied_patient_mismatch'); + expect(evento.actionType, 'write'); + expect(evento.resourceType, 'visit'); + expect(evento.resourceId, UuidValue.fromString(_outroTerritorioPatientId).uuid); + expect(evento.userId, _acsId); + } + + test('sync com versão velha não devolve a cópia da área de origem', () async { + final antes = await visitaAntigaNaAreaDeOrigem(); + + final results = await service.sync( + user: acsMovido, + entries: [entry(version: 0, patientId: _outroTerritorioPatientId)], + ); + + confereRecusa(results.single, antes); + }); + + test('resolveConflict não sobrescreve a visita da área de origem', () async { + final antes = await visitaAntigaNaAreaDeOrigem(); + + final result = await service.resolveConflict( + user: acsMovido, + entry: entry(status: 'recusou atendimento', patientId: _outroTerritorioPatientId), + expectedServerVersion: 3, + ); + + confereRecusa(result, antes); + expect(store.rows[_localId]!.status, 'realizada'); + }); + }); + + group('recusas iguais às do sync', () { + /// Compara a recusa do `resolveConflict` com a do `sync` para a mesma + /// entrada e o mesmo usuário, e prova que nada foi gravado. + Future confere( + AuthenticatedUser user, + VisitSyncEntry e, { + required SyncStatus status, + required String message, + }) async { + final antes = Map.of(store.rows); + final resolvido = await service.resolveConflict( + user: user, + entry: e, + expectedServerVersion: 1, + ); + final sincronizado = (await service.sync(user: user, entries: [e])).single; + + expect(resolvido.syncStatus, status); + expect(resolvido.message, message); + expect(resolvido.syncStatus, sincronizado.syncStatus); + expect(resolvido.message, sincronizado.message); + expect(resolvido.serverVisit, isNull); + expect(resolvido.serverVersion, isNull); + expect(store.rows, antes); + expect(audit.events.where((ev) => ev.result == 'conflict_resolved_local'), isEmpty); + return resolvido; + } + + test('ACS de outro território', () async { + const acsDeOutraArea = AuthenticatedUser( + id: _otherAcsId, + role: UserRole.acs, + microAreaId: _otherMicroAreaId, + deviceId: 'acs-device-y', + ); + await service.sync(user: _acs, entries: [entry()]); + audit.events.clear(); + + await confere(acsDeOutraArea, entry(version: 1), + status: SyncStatus.rejected, message: 'paciente fora da sua microárea'); + + // Uma recusa auditada por chamada (resolveConflict + sync). + expect(audit.events.map((ev) => ev.result), ['denied_territory', 'denied_territory']); + expect(audit.events.first.resourceType, 'visit'); + expect(audit.events.first.resourceId, UuidValue.fromString(_patientId).uuid); + expect(audit.events.first.userId, _otherAcsId); + }); + + test('outro agente', () async { + await service.sync(user: _acs, entries: [entry()]); + + await confere(outroAcs, entry(version: 1), + status: SyncStatus.rejected, message: 'visita registrada por outro agente'); + expect(store.rows[_localId]!.acsId, UuidValue.fromString(_acsId)); + }); + + test('visita sem autor', () async { + await service.syncLegacy( + transporter: _acs, deviceId: 'aparelho-legado-01', entries: [entry()]); + + await confere(_acs, entry(version: 1), + status: SyncStatus.rejected, message: 'visita sem autor registrado'); + expect(store.rows[_localId]!.acsId, isNull); + }); + + test('localId malformado', () async { + await confere(_acs, entry().copyWith(localId: 'nao-e-uuid'), + status: SyncStatus.rejected, message: 'identificadores devem ser UUID'); + }); + + test('patientId malformado', () async { + await confere(_acs, entry(patientId: 'nao-e-uuid'), + status: SyncStatus.rejected, message: 'identificadores devem ser UUID'); + }); + + test('paciente inexistente', () async { + await confere(_acs, entry(patientId: '00000000-0000-4000-8000-00000000dead'), + status: SyncStatus.error, message: 'paciente não encontrado'); + expect(audit.events, isEmpty); + }); + }); + + test('expectedServerVersion menor que 1 é ArgumentError', () async { + await service.sync(user: _acs, entries: [entry()]); + + for (final versao in [0, -1]) { + await expectLater( + service.resolveConflict(user: _acs, entry: entry(), expectedServerVersion: versao), + throwsA(isA()), + ); + } + expect(store.rows[_localId]!.version, 1); + }); + + test('papel patient é StateError', () async { + await expectLater( + service.resolveConflict(user: _patient, entry: entry(), expectedServerVersion: 1), + throwsA(isA()), + ); + expect(store.rows, isEmpty); + }); + }); } diff --git a/docs/auditoria-prd/2026-10-15-lacunas-prd.md b/docs/auditoria-prd/2026-10-15-lacunas-prd.md index 6ec3edec..c3d526ff 100644 --- a/docs/auditoria-prd/2026-10-15-lacunas-prd.md +++ b/docs/auditoria-prd/2026-10-15-lacunas-prd.md @@ -149,7 +149,7 @@ próprio; o conferidor exige apenas os IDs do PRD. | ID | Requisito | Status | Evidência | O que falta | |----|-----------|--------|-----------|-------------| | DADOS-TIMESCALE | Logs e telemetria em TimescaleDB | **ausente** | Nenhuma ocorrência de TimescaleDB no repositório; logs e auditoria em tabelas comuns do Postgres. | Decidir se TimescaleDB entra (não está em `spec/stack.md`) ou corrigir o §3.2 do PRD. | -| DADOS-VERSION-OTIMISTA | Versionamento otimista com 409 | **entregue** | `visits.version`; versão divergente devolve `conflict` com a versão do servidor sem sobrescrever, mesma versão é idempotente (`backend/sinalacs_server/lib/src/application/visits/visit_sync_service.dart:490-500`); `visit_sync_service_test.dart` passa. | Resolução do conflito pelo ACS não existe (ver R1). | +| DADOS-VERSION-OTIMISTA | Versionamento otimista com 409 | **entregue** | `visits.version`; versão divergente devolve `conflict` com a versão do servidor sem sobrescrever, mesma versão é idempotente (`backend/sinalacs_server/lib/src/application/visits/visit_sync_service.dart:490-500`); `visit_sync_service_test.dart` passa. | Fechado pela #79 (2026-10-16): `visits.resolveConflict` compara e grava sobre a versão vista; prova em `visit_conflict_resolution_test.dart` e no e2e `acs_full_e2e.sh` (`docs/superpowers/plans/2026-10-16-issue-79-e2e.log`). Aberto: a gravação é leitura-depois-update sem trava de linha (ver `PROGRESS.md`, #79). | | DADOS-SYNC-TOKEN | sync_token sequencial | **ausente** | Nenhum `sync_token` sequencial; a idempotência é por `localId` + `version` por registro e a descida usa cursor (`visits.pull`). | Decidir se o `sync_token` ainda é necessário ou corrigir o §3.4 do PRD. | | DADOS-MIGRACAO-ROLLBACK | Migrações com rollback | **ausente** | Migrações do Serverpod só para frente (`backend/sinalacs_server/migrations/*/migration.sql`); nenhum script de rollback. | Rollback por versão ou decisão registrada de restauração por backup. | | DADOS-CACHE-PACIENTE | Cache local do paciente em `sqflite` com triagens e alertas pendentes (§3.2) | **ausente** | O app do paciente não guarda triagens nem alertas pendentes no aparelho; só lembretes e preferências de consentimento (ver M1.5). | Corrigir o §3.2 do PRD ou decidir guardar o alerta pendente quando não há rede. | @@ -210,7 +210,7 @@ próprio; o conferidor exige apenas os IDs do PRD. | ID | Requisito | Status | Evidência | O que falta | |----|-----------|--------|-----------|-------------| -| R1 | Colisão de sincronização | **parcial** | Conflito detectado sem sobrescrever (`backend/sinalacs_server/lib/src/application/visits/visit_sync_service.dart:490-500`), mantido em fila própria (`apps/acs/lib/core/services/offline_visit_queue.dart`) e contado na tela (`apps/acs/lib/app/app.dart:3143-3147`, `conflict_visits_count`). | Não existe tela para comparar e escolher versão (UI de resolução lado a lado do plano de contingência): a visita em conflito fica parada na fila. | +| R1 | Colisão de sincronização | **entregue** | Conflito detectado sem sobrescrever (`visit_sync_service.dart`), guardado fora da retentativa com a cópia do servidor (schema v8, `offline_visit_queue.dart`), tela de comparação lado a lado e decisão do ACS (`resolve_conflicts`, `conflict_*`) e `visits.resolveConflict` com compare-and-set. Emulador: `acs_full_e2e.sh`, 6/6 em 2026-10-16 (`docs/superpowers/plans/2026-10-16-issue-79-e2e.log`). | Conflito de visita legada segue sem tela (quarentena); "manter a do servidor" não deixa trilha no servidor; ver abertos em `PROGRESS.md`, #79. | | R2 | Falha do MQTT em rede 3G | **parcial** | Reconexão com backoff e sessão persistente no MQTT; outbox no servidor reentrega (`mqtt_resiliencia_e2e.sh`); visitas já vão por HTTP. | Sem reserva por HTTP para o ACS receber alertas quando o MQTT cai (o `backend_client.dart` do ACS não lista alertas pendentes). | | R3 | Vazamento de PII no dispositivo | **parcial** | Limpeza do aparelho pelo próprio ACS (`apps/acs/lib/app/app.dart:174`, `device_wipe_test.dart`); chave do SQLCipher no Keystore. | Remote wipe pela API (não existe login de supervisor para dispará-lo); rotação de chave. | | R4 | Rejeição do ACS | **externo** | Nada no código. | NPS semanal e treinamento (produto + UBS). | @@ -229,7 +229,7 @@ Critério: **P0** toca invariante ou o núcleo do MVP (§6.1) sem critério atin | 2 | P0 | **Resolvido (2026-10-16, #76).** Paciente nunca vê Atendido: nenhum caminho leva o alerta a `resolved` | RF05, MVP-STATUS, M2.1 | código | Critério do núcleo do MVP (§6.1) não atingido; precisa da ação do ACS de encerrar o atendimento. | | 3 | P0 | Envio de OTP por SMS real (provedor + telefone do paciente) | RF01, MVP-LOGIN-60S, M2.1, R6 | código + externo | Sem SMS o paciente só entra com o ACS presente gerando um QR (R6): não há login por conta própria fora do ambiente de dev; a escolha de provedor/contrato é de produto. | | 4 | P1 | ~~Broker MQTT aceita TLS 1.2; Traefik → backend em HTTP~~ — resolvido na #78 (2026-10-16): broker só 1.3, Traefik → backend em TLS 1.3 verificado; mTLS interno bloqueado pelo Serverpod | RNF04, M2.3, CRIPTO-TRAEFIK-BACKEND | infra | Risco Crítico; `tls_version tlsv1.3` no `mosquitto.conf` e o caso no `tls_invariants.sh` é pequeno; mTLS interno exige decisão. | -| 5 | P1 | Resolução de conflito de visita no ACS (comparar e escolher versão) | R1, DADOS-VERSION-OTIMISTA | código | Risco de impacto 5; hoje a visita em conflito fica parada na fila para sempre. | +| 5 | P1 | Resolução de conflito de visita no ACS (comparar e escolher versão) | R1, DADOS-VERSION-OTIMISTA | código | Risco de impacto 5; entregue em 2026-10-16 (#79); antes, a visita em conflito ficava parada na fila para sempre. | | 6 | P1 | Reserva por HTTP para o ACS receber alertas quando o MQTT cai | R2, INV-03 | código | Risco de impacto 5 em rede 3G; o ACS não tem como listar alertas pendentes por HTTP. | | 7 | P1 | Remote wipe e certificado de cliente por aparelho | R3, STRIDE-S, CRIPTO-APP-CACHE | código + infra | Risco de impacto 5; depende de login de supervisor e de PKI de produção. | | 8 | P1 | Auditoria append-only no banco (trigger/`REVOKE`) | RF17, STRIDE-R | código | A cadeia só detecta; o PRD pede logs imutáveis. | diff --git a/docs/superpowers/plans/2026-10-16-issue-79-e2e.log b/docs/superpowers/plans/2026-10-16-issue-79-e2e.log new file mode 100644 index 00000000..134f75fa --- /dev/null +++ b/docs/superpowers/plans/2026-10-16-issue-79-e2e.log @@ -0,0 +1,41 @@ +# ./scripts/qa/acs_full_e2e.sh — emulator-5554, banco sinalacs_e2e, 2026-10-17T01:27:08Z, branch feat/issue-79-conflito-visita +# Saída filtrada: sem o 'pub get' nem o Gradle; só fixtures sintéticas (UUIDs aleatórios por execução). +== stack de e2e (banco de teste) +00:00 +0: login real, seletor por microárea, visita e sincronização +00:04 +1: 100 visitas offline sobem em menos de 5 s (M2.4) +rajada: 100 visitas em 85 ms +00:05 +2: RF08: a microárea fica no aparelho, sobrevive à queda de rede e está cifrada +00:05 +3: conflito de visita: compara lado a lado, mantém a minha numa e a do servidor noutra (#79) +E2E_CONFLITO manter_minha=ff7f7858-a699-4e2d-befc-5c77a5103162 manter_servidor=4a5823fb-f5b4-4031-b41c-00b3b3e8a2de acs=65a0fa3b-3f62-4e0d-aff1-68e8f99cad62 +00:13 +4: MFA: com a verificação ativa, o login pela tela pede e aceita o código; o refresh token renova sem reautenticação e retoma a sessão na partida +jwt: renovado sem reautenticação (exp anterior 2026-10-17T01:41:01.000Z, novo 2026-10-17T01:41:01.000Z) +00:26 +5: fila por dono: A sai com visita pendente, B não a vê, o envio diferido sobe a de A com a autoria de A e a legada sem autor; o wipe recusa com pendência e apaga com tudo enviado +E2E_FILA_POR_DONO visita_a=9b71953c-84c3-4683-8f84-6375015236a1 legado=505a5cd6-2590-40f0-9e52-86817d4dfc47 acs_a=65a0fa3b-3f62-4e0d-aff1-68e8f99cad62 acs_b=a16fa79e-0ea1-4226-9eb7-3e5fed43fc8d +01:02 +6: (tearDownAll) +01:02 +6: All tests passed! +== refresh token no banco de teste + tokens emitidos com deviceId: 8 + tokens rotacionados: 2 + tokens revogados pelo Sair: 5 + audit_logs refresh_granted: 2 + audit_logs session_refresh negados: 0 +== fila por dono, envio diferido e legado no banco de teste + visita de A: acsId|authorship: 65a0fa3b-3f62-4e0d-aff1-68e8f99cad62|acs + visita legada: acsId nulo|authorship|originDeviceId preenchido: nulo|legacyUnclaimed|true + audit_logs visit_deferred_sync de A: 1 + audit_logs visit_legacy_sync (B transportou): 1 + audit_logs visit_legacy_sync_item da visita legada: 1 + token de envio de A revogado (fila zerou): 6 + token de envio de B revogado (wipe): 1 + tokens de envio de A ou B ainda ativos: 0 +== conflito de visita no banco de teste + manter a minha: version|status|acsId: 4|Realizada com sucesso|65a0fa3b-3f62-4e0d-aff1-68e8f99cad62 + manter a do servidor: version|status: 3|Paciente ausente + audit_logs conflict_resolved_local da 1ª visita: 1 + audit_logs conflict_resolved_local no total: 1 + audit_logs read/visit_conflict (cópia do servidor entregue): 2 +== cadeia de auditoria no banco de teste + OK — 53 linha(s) verificada(s), cadeia íntegra. +OK — jornada completa do ACS contra o banco de teste +A stack de desenvolvimento foi substituída pela de e2e: rode `docker compose up -d` para voltá-la. +exit=0 diff --git a/docs/superpowers/plans/2026-10-16-issue-79-resolucao-de-conflito.md b/docs/superpowers/plans/2026-10-16-issue-79-resolucao-de-conflito.md new file mode 100644 index 00000000..63ba1f80 --- /dev/null +++ b/docs/superpowers/plans/2026-10-16-issue-79-resolucao-de-conflito.md @@ -0,0 +1,243 @@ +# Issue #79 — Resolução de conflito de visita no ACS (R1) — 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:** O ACS vê cada visita em conflito, compara lado a lado a versão do aparelho com a do servidor e decide qual prevalece (ou edita e envia uma nova versão), sem que nenhuma das duas se perca em silêncio. + +**Architecture:** O servidor passa a devolver, junto com `SyncStatus.conflict`, a cópia da visita do servidor (só ao autor, depois das checagens de território e autoria que já existem) e ganha `visits.resolveConflict`, um compare-and-set explícito sobre a versão que o ACS viu. No app, o conflito sai da fila de retentativa para uma lista própria **persistida** no SQLCipher (schema v8, aditivo), com a cópia do servidor; uma tela nova compara e resolve. "Manter a do servidor" é local (descarta a cópia do aparelho, com confirmação); "Manter a minha"/"Editar e enviar" chama o servidor. + +**Tech Stack:** Serverpod 3.4.13 (Dart), Flutter (`sqflite_sqlcipher`), Postgres, emulador Android `emulator-5554` + `scripts/qa/acs_full_e2e.sh`. + +**Spec:** issue #79 (origem: item #5 de `docs/auditoria-prd/2026-10-15-lacunas-prd.md`); `spec/PRD_system.md` §2.3.2 (estado `Conflict` → "UI informa ACS sobre conflito; compara versões; ACS decide qual prevalece ou gera nova versão") e tabela de riscos R1 ("ACS resolve via UI (comparação lado a lado)"); `spec/ui_design.md`; `spec/ux_accessibility_assessment.md`; `spec/lgpd_design.md` §5 (fila por dono). + +## Estado verificado em `develop` (271367d) + +- `VisitSyncService._syncOne` (`backend/sinalacs_server/lib/src/application/visits/visit_sync_service.dart:513-532`): mesma versão = reenvio idempotente; `entry.version == existing.version - 1` = atualiza para `existing.version + 1`; qualquer outra = `conflict` com só `serverVersion`, sem conteúdo. O caminho de conflito só é alcançado depois de território, autoria (`existing.acsId == acsId`) e legado. +- `OfflineVisitQueue._applyOutcomes` (`apps/acs/lib/core/services/offline_visit_queue.dart:526-531`): o conflito **volta para `_pending`** e é acrescentado a `_conflicts` (só RAM). Consequências medidas na leitura do código: (a) cada "Sincronizar agora" reenvia a visita, recebe o mesmo conflito e **duplica** a entrada em `_conflicts` (o contador cresce sem limite); (b) `_conflicts` não é persistido — reiniciado o app, o contador zera e a visita volta a ser só "pendente"; (c) nada no app mostra a versão do servidor. É o "parada na fila para sempre" da issue. +- `forceConflict` em `OfflineVisitQueue.sync` não tem nenhum chamador em `apps/acs` (grep) — sai. +- Testes que codificam a semântica atual e mudam neste plano: `apps/acs/test/login_flow_test.dart:~495-505` e `~635` (conflito volta a pendente e uma 2ª sync o "resolve"), `apps/acs/test/offline_burst_test.dart:~63-66` (`pendingCount == 1` depois do conflito). + +## Decisões (o executor não reabre) + +- **D1 — cópia do servidor no conflito.** `VisitSyncResult` ganha `serverVisit: VisitSyncEntry?`, preenchido **só** no `conflict` do caminho com autor (`sync` e `syncDeferred`; nunca em `syncLegacy`, cujo transportador não é autor). É o próprio autor recebendo a própria visita, já decifrada como no `visits.pull`; grava `read`/`visit_conflict` (`recordSafely`, `resourceId` = id da visita, sem conteúdo clínico), como o `pull` grava `visit_pull`. Modelo de API, **sem migração** de banco. +- **D2 — `visits.resolveConflict` explícito**, em vez de reaproveitar a regra `version == servidor - 1` do `sync` (obscura e sem trilha da decisão). Compare-and-set sobre `expectedServerVersion`: igual → grava o conteúdo enviado com `version + 1`, `synced`; diferente → `conflict` com a cópia nova, nada gravado. Mesmas recusas do `sync` (território, outro agente, sem autor, ids malformados, `rejected` com os mesmos textos). Visita inexistente no servidor → cria como o `sync` (honra "manter a minha"). Trilha: `write`/`visit`/`conflict_resolved_local`, `resourceId` = id da visita, com `recordSafely` — convenção do domínio de visitas (o upload do trabalho de campo não para por queda da trilha), não a fail-closed do backoffice. +- **D3 — conflito sai da retentativa.** Reenviar uma visita em conflito devolve o mesmo conflito; ela passa a viver em `_conflicts`, persistida com a cópia do servidor, até o ACS decidir. Continua no aparelho (bloqueia "Limpar este aparelho", conta em `countsByOwner`), então "conflito não perde registro" (AGENTS.md) segue valendo; muda só "volta para a fila". Os três testes acima são reescritos para a semântica nova, não apagados. +- **D4 — "Manter a do servidor" é só local**, atrás de confirmação destrutiva: a cópia do aparelho sai; o servidor não muda e não é chamado. Não há linha de trilha no servidor para essa escolha (nada foi lido nem escrito lá). +- **D5 — "gera nova versão" = "Editar e enviar"**: formulário com status do atendimento e observações pré-preenchidos com os do aparelho, enviado por `resolveConflict`. Sem merge automático campo a campo. +- **D6 — o envio diferido (`DeferredFlushService`) não envia linhas em conflito** (não há dono presente para decidir) e não as apaga; a linha mantém o token de envio do dono vivo até ele voltar e resolver, como já acontece com qualquer linha não enviada. +- **D7 — conflito com `serverVersion` nulo** (servidor fora do contrato) é tratado como `error` retentável, com a mensagem `O servidor indicou conflito sem informar a versão.`; nunca vira conflito sem versão de referência. + +## Global Constraints + +- Copy, comentários e nomes de teste em português (pt-BR); código segue o idioma do arquivo vizinho. +- Nunca editar `lib/src/generated/` nem `migrations/` à mão: `serverpod generate` (de `backend/sinalacs_server`). Este plano **não** cria migração; `git status` depois do generate não pode mostrar pasta nova em `migrations/`. +- Schema local sobe de `7` para `8` só com `ALTER TABLE ... ADD COLUMN` (dado de campo real pode existir em aparelho; nada de drop/recreate). +- Nenhum dado real de paciente em teste, log, print ou screenshot; o marcador do e2e imprime só UUIDs sintéticos. +- Paciente na tela = `Paciente ` + 8 primeiros caracteres do UUID, como `app.dart:2974`; nome nunca é gravado em `offline_visits`. +- Cor de conflito é operacional: `context.acsRisk.accentOnSurface` (texto/ícone), nunca vermelho/amarelo/verde; diferença marcada por ícone + texto ("Diferente"), não só cor. +- Alvos de toque `minimumSize: Size(48, 52)`; mensagens de estado em `Semantics(liveRegion: true)`. +- Commits sem atribuição de IA (CLAUDE.md do repositório prevalece sobre o lembrete de atribuição). +- Depois de mudar código: `graphify update .`. + +## Review Focus + +1. **App reiniciado com conflito aberto** — o conflito e a cópia do servidor voltam do disco, o contador não zera e a visita não vira "pendente" (Task 3: `restore` com linha v8; Task 4: `restore` separa conflitos). +2. **Servidor mudou de novo entre ver o conflito e decidir** — `resolveConflict` com versão vencida não grava nada; o app troca a cópia exibida pela nova e avisa (Task 2: `versão vencida devolve conflito novo sem gravar`; Task 4: `resolveKeepLocal com conflito novo atualiza a cópia`; Task 5: mensagem na tela). +3. **Rede cai durante "Manter a minha"** — a visita continua em conflito no disco, nada se perde, a tela mostra o motivo (Task 4: `resolveKeepLocal sem rede mantém o conflito`; Task 5). +4. **Toques repetidos / sync concorrente** — "Sincronizar agora" várias vezes não duplica o conflito; resolver durante um sync em voo espera o sync (Task 4: `conflito repetido não duplica`, `resolve aguarda sync em voo`). +5. **Outro ACS ou outro território tentando resolver** — `rejected` com o mesmo texto do `sync`, sem cópia e sem gravação (Task 2: matriz de recusas). + +--- + +### Task 1: Backend — conflito devolve a cópia do servidor + +**Files:** +- Modify: `backend/sinalacs_server/lib/src/models/api/visit_sync_result.spy.yaml` +- Modify: `backend/sinalacs_server/lib/src/application/visits/visit_sync_service.dart` (`pull` e `_syncOne`) +- Test: `backend/sinalacs_server/test/unit/visit_sync_service_test.dart` +- Regenerado: `backend/sinalacs_server/lib/src/generated/**`, `backend/sinalacs_client/lib/src/protocol/**` + +**Interfaces:** +- Produces: `VisitSyncResult.serverVisit: VisitSyncEntry?` (doc no `.spy.yaml`: "Preenchido apenas em `conflict` do caminho com autor: a visita como está no servidor, para o ACS comparar."). Helper privado `VisitSyncEntry _entryFor(VisitRecord visit)` usado por `pull` e pelo conflito (extrair do `pull` atual, sem mudar o que o `pull` devolve). + +- [ ] **Step 1: Testes que falham** em `visit_sync_service_test.dart`, junto do teste `devolve conflito sem sobrescrever quando as versões divergem`: + - `conflito devolve a visita do servidor ao autor`: sync `entry()` → sync `entry(version: 7, status: 'recusou atendimento')`; espera `syncStatus == conflict`, `serverVersion == 1`, `serverVisit!.status == 'realizada'`, `serverVisit!.version == 1`, `serverVisit!.localId == _localId`, `serverVisit!.notes` igual às notas gravadas. + - `conflito grava read/visit_conflict sem conteúdo`: a trilha falsa recebe exatamente um evento `actionType: 'read'`, `resourceType: 'visit_conflict'`, `resourceId` = id da linha, e nenhum campo do evento contém o texto das notas. + - `syncLegacy em conflito não devolve a cópia`: no teste legado já existente de conflito (`entry(version: 7, ...)`, linha ~545) acrescentar `expect(conflicted.single.serverVisit, isNull)`. + - Nos testes de `synced`/`rejected`/`error` existentes que já pegam o resultado, acrescentar `serverVisit` nulo em um de cada. +- [ ] **Step 2:** `cd backend/sinalacs_server && dart test test/unit/visit_sync_service_test.dart` → FAIL (getter `serverVisit` não existe). +- [ ] **Step 3:** Adicionar o campo ao `.spy.yaml`, rodar `serverpod generate`; no `_syncOne`, no retorno de `conflict` (só alcançado com `legacy == false` ou com legado sem autor — preencher **só** quando `!legacy`), montar `serverVisit: _entryFor(existing)` e gravar o `read`/`visit_conflict` com `_audit.recordSafely`. +- [ ] **Step 4:** `dart test test/unit` e `dart analyze` → PASS / sem issues. `git status` sem pasta nova em `migrations/`. +- [ ] **Step 5: Commit** — `feat(backend): conflito de visita devolve a cópia do servidor ao autor (#79)` + +### Task 2: Backend — `visits.resolveConflict` + +**Files:** +- Modify: `backend/sinalacs_server/lib/src/application/visits/visit_sync_service.dart` +- Modify: `backend/sinalacs_server/lib/src/endpoints/visits_endpoint.dart` +- Test: `backend/sinalacs_server/test/unit/visit_sync_service_test.dart` (grupo novo `resolveConflict`) +- Create: `backend/sinalacs_server/test/integration/visit_conflict_resolution_test.dart` +- Regenerado: cliente (`serverpod generate`) + +**Interfaces:** +- Consumes: `VisitSyncResult.serverVisit` e `_entryFor` (Task 1). +- Produces: + - `Future VisitSyncService.resolveConflict({required AuthenticatedUser user, required VisitSyncEntry entry, required int expectedServerVersion})` + - `Future VisitsEndpoint.resolveConflict(Session session, {required String accessToken, required VisitSyncEntry visit, required int expectedServerVersion})` — `authenticate(accessToken)`, uma transação, mesma tradução `ArgumentError → AlertValidationException`, `StateError → AlertPermissionException` de `sync`. Cliente gerado: `client.visits.resolveConflict(accessToken: ..., visit: ..., expectedServerVersion: ...)`. + - As checagens de identidade/território/autoria hoje inline em `_syncOne` (linhas ~346-512, ramo não legado) passam para um helper privado compartilhado pelos dois caminhos, para as recusas serem as **mesmas** (mesmo `SyncStatus`, mesma `message`, mesma auditoria `denied_territory`). Comportamento do `sync` não muda: os testes existentes do `sync`/`syncLegacy` têm de passar sem edição. + +- [ ] **Step 1: Testes unitários que falham** (grupo `resolveConflict`): + - `grava o conteúdo do aparelho quando a versão esperada é a corrente`: servidor em v3 (`sync entry()`, depois `entry(version: 0)`, depois `entry(version: 1)` — a regra atual leva a v3); `resolveConflict(entry(status: 'recusou atendimento'), expectedServerVersion: 3)` → `synced`, `serverVersion == 4`, linha com `status == 'recusou atendimento'`, `version == 4`; trilha com um `write`/`visit`/`conflict_resolved_local` e `resourceId` = id da linha. + - `versão vencida devolve conflito novo sem gravar`: servidor em v3, `expectedServerVersion: 2` → `conflict`, `serverVersion == 3`, `serverVisit!.version == 3`, linha intacta, nenhuma linha `conflict_resolved_local`. + - `visita inexistente é criada`: sem linha prévia → `synced`, `serverVersion == 1`. + - Matriz de recusas, comparando `syncStatus` e `message` com o resultado de `service.sync` para a mesma entrada: ACS de outro território (`rejected`, + `denied_territory` auditado), outro agente (`visita registrada por outro agente`), visita sem autor (`visita sem autor registrado`), `localId`/`patientId` malformado (`identificadores devem ser UUID`), paciente inexistente (`error`, `paciente não encontrado`); em todas, `serverVisit` nulo e nenhuma escrita. + - `expectedServerVersion menor que 1 é ArgumentError`; papel `patient` → `StateError`. +- [ ] **Step 2:** `dart test test/unit/visit_sync_service_test.dart` → FAIL (método não existe). +- [ ] **Step 3:** Implementar o serviço e o endpoint; `serverpod generate`. +- [ ] **Step 4: Teste de integração** `visit_conflict_resolution_test.dart` (`withServerpod`, tokens reais como em `patient_directory_and_territory_test.dart`): pelo endpoint, criar visita, levar a v3 por `visits.sync`, chamar `visits.sync` com `version: 1` → `conflict` com `serverVisit.version == 3`; `visits.resolveConflict(expectedServerVersion: 3)` → `synced` v4 e notas relidas decifradas iguais às enviadas; segunda chamada com `expectedServerVersion: 3` → `conflict` v4; ACS de outra microárea → `rejected`. Confere em `audit_logs` uma linha `conflict_resolved_local`. +- [ ] **Step 5:** `docker compose --profile test up -d postgres-test`; `dart test -j 1 test/integration/visit_conflict_resolution_test.dart test/integration/patient_directory_and_territory_test.dart test/unit` → PASS; `dart test test/unit/endpoint_auth_posture_test.dart` → PASS (o método está num `AuthenticatedEndpoint`, sem allowlist nova); `dart analyze` limpo. +- [ ] **Step 6: Commit** — `feat(backend): visits.resolveConflict com compare-and-set sobre a versão vista (#79)` + +### Task 3: ACS — conflito persistido no SQLCipher (schema v8) + +**Files:** +- Modify: `apps/acs/lib/core/services/offline_visit_queue.dart` (modelos) +- Modify: `apps/acs/lib/core/database/encrypted_database.dart` (`schemaVersion = 8`, DDL e `_upgrade`) +- Modify: `apps/acs/lib/core/database/sqlcipher_visit_store.dart` (mapeamento de linha) +- Test: `apps/acs/test/encrypted_database_test.dart`, `apps/acs/test/visit_storage_owner_test.dart` + +**Interfaces:** +- Produces: + - `class VisitConflictSnapshot { const VisitConflictSnapshot({required this.version, required this.status, required this.notes, this.completedAt}); final int version; final String status; final String notes; final DateTime? completedAt; Map toJson(); factory VisitConflictSnapshot.fromJson(Map json); }` — `notes` é o valor de `'campo'` do mapa do servidor, ou os valores unidos por `\n` quando não houver `'campo'`; `completedAt` em UTC ISO-8601 no JSON. + - `factory VisitConflictSnapshot.fromEntry(VisitSyncEntry entry)` **não** fica aqui (a fila não importa o cliente gerado): a conversão vive em `backend_visit_synchronizer.dart` (Task 4). + - `OfflineVisitRecord` ganha `final int? conflictServerVersion;` e `final VisitConflictSnapshot? conflictServer;` + `bool get inConflict => conflictServerVersion != null;`, ambos no construtor e no `copyWith` (sem caminho para zerá-los: a resolução remove ou substitui o registro). + - Colunas novas em `offline_visits`: `conflict_server_version INTEGER`, `conflict_server_snapshot TEXT` (JSON). Nulas = não está em conflito. + +- [ ] **Step 1: Testes que falham:** + - `encrypted_database_test.dart`: `migração v7 → v8 preserva linhas e acrescenta as colunas de conflito` (abrir banco v7 com uma linha de dono e uma legada, reabrir em v8: as duas linhas intactas, colunas novas nulas); `onCreate v8 tem as colunas de conflito` (`PRAGMA table_info`). + - `visit_storage_owner_test.dart`: `ida e volta de visita em conflito` — `save` de um registro com `conflictServerVersion: 3` e snapshot (`status: 'Paciente ausente'`, `notes: 'Sem resposta'`, `completedAt` fixo UTC) e `load` devolve os mesmos valores; registro sem conflito volta com os dois nulos; `countsByOwner` conta a linha em conflito; `wipeAllData` com só a linha em conflito lança `WipeBlocked(1)`. + - Teste puro de `VisitConflictSnapshot.toJson/fromJson` (mesmo arquivo de teste da fila ou `test/offline_visit_queue_conflict_test.dart`, criado na Task 4 — aqui basta o round-trip). +- [ ] **Step 2:** `cd apps/acs && flutter test test/encrypted_database_test.dart test/visit_storage_owner_test.dart` → FAIL. +- [ ] **Step 3:** Implementar; atualizar o doc de `schemaVersion` com a linha "v8 acrescenta `conflict_server_version`/`conflict_server_snapshot`: a visita em conflito e a cópia do servidor ficam no aparelho até o ACS decidir (#79)". Migração no padrão da v4/v7: `PRAGMA table_info` + `ALTER TABLE offline_visits ADD COLUMN` só se a coluna faltar. +- [ ] **Step 4:** `flutter test` (suíte inteira do ACS) e `flutter analyze` → PASS. +- [ ] **Step 5: Commit** — `feat(acs): visita em conflito e cópia do servidor persistidas (schema v8) (#79)` + +### Task 4: ACS — fila resolve conflitos e fala com o servidor + +**Files:** +- Modify: `apps/acs/lib/core/services/offline_visit_queue.dart` +- Modify: `apps/acs/lib/core/services/backend_visit_synchronizer.dart` +- Modify: `apps/acs/lib/core/services/visit_queue_factory.dart` (liga o resolvedor) +- Modify: `apps/acs/lib/core/services/deferred_flush_service.dart` (D6) +- Modify: `apps/acs/lib/core/network/backend_client.dart` (`AcsBackend`, `MisconfiguredBackend`, `BackendClient`) +- Modify: `apps/acs/test/support/fakes.dart`, `apps/acs/integration_test/full_journey_e2e.dart` (decorator `_rede`) +- Modify (semântica D3): `apps/acs/test/login_flow_test.dart`, `apps/acs/test/offline_burst_test.dart` +- Create: `apps/acs/test/offline_visit_queue_conflict_test.dart` +- Test: `apps/acs/test/deferred_flush_service_test.dart`, `apps/acs/test/backend_client_test.dart` + +**Interfaces:** +- Consumes: `VisitConflictSnapshot`, campos de conflito do `OfflineVisitRecord` (Task 3); `client.visits.resolveConflict` e `VisitSyncResult.serverVisit` (Tasks 1–2). +- Produces: + - `VisitSyncOutcome` ganha `final VisitConflictSnapshot? serverSnapshot;`. + - `abstract interface class VisitConflictResolver { Future resolve(OfflineVisitRecord visit, {required int expectedServerVersion}); }` em `offline_visit_queue.dart`. `BackendVisitSynchronizer` implementa `VisitSynchronizer` **e** `VisitConflictResolver` (mesma checagem de dono de `push`); `DeferredVisitSynchronizer` e os falsos de teste existentes **não** mudam. + - `OfflineVisitQueue({VisitStore? store, this.synchronizer, this.conflictResolver})`; `final VisitConflictResolver? conflictResolver;`. + - `Future OfflineVisitQueue.resolveKeepServer(String localId)` — remove de `_conflicts` e persiste; `localId` desconhecido é no-op. + - `Future OfflineVisitQueue.resolveKeepLocal(String localId, {String? outcome, String? notes})` — aplica `outcome`/`notes` editados (D5) ao registro e chama `conflictResolver.resolve(record, expectedServerVersion: record.conflictServerVersion!)`. Resultado: `synced` → sai do aparelho (`SyncOutcomeKind.synced`, `processed: 1`); `conflict` → fica, com versão e cópia novas, e o conteúdo editado preservado (`SyncOutcomeKind.conflict`); `rejected` → vai para `_rejected` com o motivo (`SyncOutcomeKind.rejected`); `error`/exceção → fica como estava antes da chamada, mas com a edição gravada (`SyncOutcomeKind.error` com a mensagem); sem `conflictResolver` → `error` com `A resolução de conflitos não está configurada neste aplicativo.`. Se um `sync()` estiver em voo, espera-o antes de ler o registro. + - `_applyOutcomes`, caso `conflict`: o registro vai para `_conflicts` (substituindo outro de mesmo `localId`), **não** volta a `_pending`, com `conflictServerVersion: outcome.serverVersion` e `conflictServer: outcome.serverSnapshot`; `serverVersion` nulo → caminho `error` com a mensagem de D7. `restore()` separa por `inConflict` (antes de `rejectionReason`); `_persist()` grava `[..._pending, ..._conflicts, ..._rejected]`. Remover `forceConflict` e o ramo dele. + - `AcsBackend.resolveVisitConflict(VisitSyncEntry visit, {required int expectedServerVersion, String? expectedUserId}) → Future`; `BackendClient` com a mesma renovação/checagem de dono de `syncVisits`; `MisconfiguredBackend` lança como os vizinhos; `FakeAcsBackend` grava as chamadas e devolve um resultado configurável; o decorator do e2e embrulha em `_rede`. + - Em `backend_visit_synchronizer.dart`: `VisitConflictSnapshot conflictSnapshotFor(VisitSyncEntry entry)` (função de topo, pública para teste). + - `DeferredFlushService`: linhas com `inConflict` não entram no lote e nunca são removidas por ele. + +- [ ] **Step 1: Testes que falham** em `offline_visit_queue_conflict_test.dart` (falsos locais implementando `VisitSynchronizer`/`VisitConflictResolver`, store `InMemoryVisitStore`): + - `conflito sai da retentativa e guarda a cópia do servidor`: outcome `conflict`, `serverVersion: 3`, snapshot → `pendingCount == 0`, `conflictCount == 1`, `conflictVisits.single.conflictServerVersion == 3`, snapshot igual; resultado `SyncOutcomeKind.conflict`. + - `conflito repetido não duplica`: duas visitas, três `sync()` com a mesma resposta → `conflictCount == 2` e o falso recebeu as visitas em conflito **uma** vez só. + - `restore separa pendente, conflito e recusada`: store pré-carregado com os três tipos → contadores 1/1/1. + - `conflito sem versão é erro retentável`: `serverVersion: null` → fica pendente, `SyncOutcomeKind.error`, mensagem de D7. + - `resolveKeepServer remove só aquela visita e persiste`. + - `resolveKeepLocal sincronizado sai do aparelho` (e o resolvedor recebeu `expectedServerVersion: 3`, `outcome`/`notes` editados). + - `resolveKeepLocal com conflito novo atualiza a cópia` (`serverVersion: 4`, novo snapshot, edição preservada). + - `resolveKeepLocal recusado vai para recusadas` (motivo do servidor em `rejectionReason`). + - `resolveKeepLocal sem rede mantém o conflito` (resolvedor lança `BackendFailure`-like → `error`, `conflictCount == 1`, store ainda tem a linha). + - `resolve aguarda sync em voo` (sync preso num `Completer`; `resolveKeepLocal` só chama o resolvedor depois de o sync completar). + - `deferred_flush_service_test.dart`: `envio diferido não manda nem apaga visita em conflito`. + - `backend_client_test.dart`: `resolveVisitConflict recusa sessão de outro dono` (mesmo padrão do teste de `syncVisits` com `expectedUserId`), e teste de `conflictSnapshotFor` (`notes` com `'campo'` e sem `'campo'`). + - Reescrever com a semântica D3: `login_flow_test.dart` (~495–505 e ~635: depois do conflito `pendingCount == 0`, `conflictCount == 1`; a "2ª sync" vira `resolveKeepLocal` → `synced`) e `offline_burst_test.dart` (~63–66: `pendingCount == 0`, comentário trocado para "conflito sai da retentativa e espera a decisão do ACS"). +- [ ] **Step 2:** `flutter test test/offline_visit_queue_conflict_test.dart` → FAIL. +- [ ] **Step 3:** Implementar; `visit_queue_factory.dart` passa o mesmo `BackendVisitSynchronizer` como `synchronizer` e `conflictResolver`. +- [ ] **Step 4:** `flutter test` (suíte inteira) e `flutter analyze` → PASS; `flutter analyze integration_test` sem erro (o decorator compila). +- [ ] **Step 5: Commit** — `feat(acs): fila guarda o conflito fora da retentativa e resolve pelo servidor (#79)` + +### Task 5: ACS — telas de comparação e decisão + +**Files:** +- Create: `apps/acs/lib/app/visit_conflict_screen.dart` +- Modify: `apps/acs/lib/app/app.dart` (`VisitRegistrationScreen`: botão de entrada e texto do `SyncOutcomeKind.conflict`) +- Create: `apps/acs/test/visit_conflict_screen_test.dart` +- Modify: `apps/acs/test/text_scale_test.dart`, `apps/acs/test/contrast_tokens_test.dart` (só se surgir par token×superfície novo) + +**Interfaces:** +- Consumes: `OfflineVisitQueue.conflictVisits`, `resolveKeepServer`, `resolveKeepLocal` (Task 4). +- Produces: + - `class VisitConflictListScreen extends StatefulWidget { const VisitConflictListScreen({super.key, required this.queue}); final OfflineVisitQueue queue; }` — título "Visitas em conflito"; um item por conflito (`Key('conflict_item_')`, "Paciente <8>" + data local de registro); lista vazia mostra "Nenhuma visita em conflito." e fecha ao voltar. + - `class VisitConflictCompareScreen extends StatefulWidget { const VisitConflictCompareScreen({super.key, required this.queue, required this.localId}); ... }`. + - Em `VisitRegistrationScreen`, abaixo de `conflict_visits_count`: `OutlinedButton.icon(key: Key('resolve_conflicts'), label: Text('Resolver conflito(s)'))` que empurra a lista e faz `setState` ao voltar. Mensagem do `SyncOutcomeKind.conflict`: `'${n} visita(s) em conflito com o servidor. Toque em "Resolver conflito(s)" para comparar e escolher.'`. + +Comparação (copy exata): +- Dois painéis, `Key('conflict_local')` "Neste aparelho" e `Key('conflict_server')` "No servidor (versão N)"; lado a lado (`Row`) com largura ≥ 600 dp, empilhados abaixo disso. Linhas: "Status do atendimento", "Observações" (vazio → "Sem observações"), "Concluída em" (`dd/MM/yyyy HH:mm`, hora local). +- Linha cujo valor difere entre os painéis: ícone `Icons.compare_arrows` + texto "Diferente", em `accentOnSurface`, com `Semantics(label: ' diferente entre aparelho e servidor')`. +- Sem cópia do servidor (`conflictServer == null`): painel do servidor mostra "Conteúdo do servidor indisponível neste aparelho." e as três ações continuam disponíveis. +- Ações (`minimumSize: Size(48, 52)`): `Key('conflict_keep_local')` "Manter a minha versão"; `Key('conflict_edit')` "Editar e enviar" (abre, na própria tela, dropdown com as três opções de status de `app.dart` e campo de observações pré-preenchidos com os do aparelho, mais `Key('conflict_send_edited')` "Enviar versão editada"); `Key('conflict_keep_server')` "Manter a do servidor" → diálogo "Descartar a versão deste aparelho?" / "A visita registrada neste aparelho será apagada e a do servidor continua valendo. Isso não pode ser desfeito." com `Key('cancel_keep_server')` "Cancelar" e `Key('confirm_keep_server')` "Descartar a minha" (rótulo em `redOnSurface`, como as outras confirmações destrutivas do app). +- Resultado (`Semantics(liveRegion: true)`, `Key('conflict_status')`): `synced` → volta à lista com SnackBar "Versão do aparelho enviada ao servidor."; `conflict` → fica na tela, painel do servidor atualizado, "A visita mudou de novo no servidor. Compare outra vez antes de decidir."; `rejected` → volta à lista, "O servidor recusou esta visita: "; `error` → fica, mostra a mensagem da fila. Botões desabilitados enquanto a chamada está em voo (sem duplo envio). + +- [ ] **Step 1: Testes de widget que falham** (`visit_conflict_screen_test.dart`, fila real com falsos da Task 4): + - `botão Resolver conflito(s) só aparece com conflito e abre a lista`. + - `comparação mostra os dois lados e marca o que difere` (status diferente → "Diferente" na linha de status; observações iguais → sem marca na linha). + - `Manter a do servidor pede confirmação; cancelar não apaga` / `confirmar remove a visita`. + - `Manter a minha envia com a versão vista e volta à lista` (resolvedor recebeu `expectedServerVersion == conflictServerVersion`). + - `Editar e enviar manda o status e as observações editados`. + - `conflito novo atualiza o painel do servidor e avisa`. + - `falha de rede mantém a visita e mostra o motivo`. + - `sem cópia do servidor mostra o aviso e mantém as ações`. + - `largura de celular empilha os painéis` (360 dp: `conflict_server` abaixo de `conflict_local`). + - `text_scale_test.dart`: acrescentar a tela de comparação ao laço `janelas × [1.3, 2.0]` (sem overflow; `conflict_keep_local` alcançável por rolagem a 200%). +- [ ] **Step 2:** `flutter test test/visit_conflict_screen_test.dart test/text_scale_test.dart` → FAIL. +- [ ] **Step 3:** Implementar. +- [ ] **Step 4:** `flutter test` e `flutter analyze` → PASS; `flutter test test/contrast_tokens_test.dart test/no_fixed_white_text_test.dart` → PASS. +- [ ] **Step 5: Commit** — `feat(acs): tela de comparação lado a lado e decisão do conflito de visita (#79)` + +### Task 6: Prova no emulador contra o backend real + +**Files:** +- Modify: `apps/acs/integration_test/full_journey_e2e.dart` (testWidgets novo, inserido **antes** do teste `MFA: ...` — hoje o 4º —, porque depois dele o login da fixture passa a exigir TOTP) +- Modify: `scripts/qa/acs_full_e2e.sh` (bloco de asserções no banco) + +**Interfaces:** +- Consumes: tudo das Tasks 1–5; o decorator `_rede` (offline simulado) e a sessão da fixture que o arquivo já usa. +- Produces: linha marcadora `E2E_CONFLITO manter_minha= manter_servidor= acs=`. + +- [ ] **Step 1: Escrever o teste** `'conflito de visita: compara lado a lado, mantém a minha numa e a do servidor noutra (#79)'`: + 1. Login real; rede **desligada** no decorator; registrar duas visitas pela tela (status "Realizada com sucesso", observação sintética `e2e conflito`); ler os dois `localId` de `queue.pendingVisits`. + 2. Simular o "outro aparelho" do mesmo ACS **pelo `_inner`** (rede real, fora do decorator), para cada `localId`: `syncVisits` com `version: 1` e status `'Paciente ausente'` (cria v1), depois `version: 0` (v2), depois `version: 1` (v3). + 3. Religar a rede; tocar `sync_visits` → `conflict_visits_count` mostra "Em conflito: 2", `pending_visits_count` mostra 0. + 4. `resolve_conflicts` → item da 1ª visita → conferir "Realizada com sucesso" em `conflict_local`, "Paciente ausente" e "versão 3" em `conflict_server`, e "Diferente" → `conflict_keep_local` → volta à lista. + 5. Item da 2ª → `conflict_keep_server` → `confirm_keep_server`. + 6. Conferir `queue.conflictCount == 0` e `pendingCount == 0`; imprimir o marcador. +- [ ] **Step 2: Asserções do script** (após o bloco `E2E_FILA_POR_DONO`, com os helpers `sql`/`igual` existentes): para `manter_minha` → `version = 4`, `status = 'Realizada com sucesso'`, `"acsId" = acs`; para `manter_servidor` → `version = 3`, `status = 'Paciente ausente'`; `audit_logs` com exatamente 1 linha `resourceType = 'visit'` e `result = 'conflict_resolved_local'` com `"resourceId"` = id da 1ª visita, e ≥ 1 `visit_conflict`/`read`; cadeia de auditoria íntegra pelo mesmo passo que o script já roda (se roda; senão `dart run bin/audit_chain_check.dart` no banco de e2e). +- [ ] **Step 3: Rodar** `./scripts/qa/acs_full_e2e.sh` com `emulator-5554` ligado → todos os testes do arquivo PASS e o script termina com 0. Guardar a saída em `docs/superpowers/plans/2026-10-16-issue-79-e2e.log` (sem dado real: só fixtures sintéticas). Se falhar, usar superpowers:systematic-debugging antes de mexer. +- [ ] **Step 4: Commit** — `test(acs): e2e do conflito de visita no emulador contra o banco de teste (#79)` + +### Task 7: Documentação e fechamento + +**Files:** +- Modify: `PROGRESS.md` (seção nova "Resolução de conflito de visita (#79)": o que foi entregue, D1–D7, evidência do e2e com data, e abertos: escolha "manter a do servidor" sem trilha no servidor; conflito de visita legada segue sem tela — fica na quarentena como hoje; o envio diferido não resolve conflito) +- Modify: `apps/CLAUDE.md` (parágrafo da fila: "a conflict returns to the queue" → semântica D3, schema v8, tela, chaves) +- Modify: `backend/CLAUDE.md` (lista de endpoints: `visits.resolveConflict`, `serverVisit` no conflito, `visit_conflict`/`conflict_resolved_local` na trilha) +- Modify: `CLAUDE.md` raiz (lista de endpoints de `visits`; frase "Already delivered" com a resolução de conflito) +- Modify: `spec/lgpd_design.md` (tabela da fila por dono, linha nova: a cópia do servidor fica cifrada no SQLCipher só enquanto o conflito existe e sai com a decisão) +- Modify: `docs/telas-acs.md` (telas novas; screenshots só se capturadas de verdade com `./scripts/dev/run_acs.sh --build --captura`, senão nomeadas como pendentes) +- Modify: `docs/auditoria-prd/2026-10-15-lacunas-prd.md` (R1 e DADOS-VERSION-OTIMISTA: "entregue", com a evidência) + +- [ ] **Step 1:** Escrever as mudanças acima. +- [ ] **Step 2:** `bash scripts/qa/ci_invariants.sh` → PASS (nenhum job/CI mudou; confere que nada quebrou o guarda); `graphify update .`. +- [ ] **Step 3: Commit** — `docs: registra a resolução de conflito de visita (#79)` +- [ ] **Step 4:** Abrir PR para `develop` com `Closes #79`, corpo com D1–D7 e o resumo da evidência do e2e (superpowers:finishing-a-development-branch). diff --git a/docs/telas-acs.md b/docs/telas-acs.md index e087a220..df8fe112 100644 --- a/docs/telas-acs.md +++ b/docs/telas-acs.md @@ -36,6 +36,12 @@ Mostra pinos sintéticos conforme o risco e uma ação de rota. Não usa Google Permite escolher o desfecho, registrar observações e enfileirar a visita localmente. O registro usa a fila offline existente; sincronização central permanece pendente. +### Visita em conflito (#79) + +Quando o servidor já tem outra versão da mesma visita, o contador "Em conflito" aparece na tela de visita e o botão `resolve_conflicts` abre a lista de conflitos. Cada item mostra, lado a lado, a versão do aparelho (`conflict_local`) e a do servidor (`conflict_server`), com o marcador "Diferente" (ícone + texto, não só cor) nos campos que divergem. Três saídas: "Manter a minha" (`conflict_keep_local`, envia por `visits.resolveConflict`), "Editar e enviar" (`conflict_edit` → `conflict_send_edited`) e "Manter a do servidor" (`conflict_keep_server`, com confirmação destrutiva; só apaga a cópia do aparelho). Se o servidor mudou de novo, a cópia exibida é trocada e a tela avisa; sem rede, a visita continua em conflito. + +Screenshots: **pendentes**. Não foram capturadas; devem sair de `./scripts/dev/run_acs.sh --build --captura` (a prova atual é o e2e no emulador, `docs/superpowers/plans/2026-10-16-issue-79-e2e.log`). + ## Acionamento e escalonamento Resume paciente, risco e endereço para ações de SAMU ou UBS. Os botões comunicam explicitamente que discagem e encaminhamento reais ainda não foram integrados. diff --git a/scripts/qa/acs_full_e2e.sh b/scripts/qa/acs_full_e2e.sh index 1601de91..35e244b9 100755 --- a/scripts/qa/acs_full_e2e.sh +++ b/scripts/qa/acs_full_e2e.sh @@ -140,4 +140,34 @@ exigir 'token de envio de B revogado (wipe)' 1 \ "select count(*) from acs_upload_tokens where \"userId\"='$acs_b_id' and \"revokedAt\" is not null" igual 'tokens de envio de A ou B ainda ativos' 0 \ "select count(*) from acs_upload_tokens where \"userId\" in ('$acs_id','$acs_b_id') and \"revokedAt\" is null and \"expiresAt\" > now()" + +# Conflito de visita (#79): o "outro aparelho" levou as duas visitas à v3; o ACS +# manteve a dele na 1ª (resolveConflict grava a v4 com o conteúdo do aparelho) e +# a do servidor na 2ª (só local: o servidor segue na v3, sem chamada nem trilha). +echo "== conflito de visita no banco de teste" +conflito="$(grep -o 'E2E_CONFLITO .*' "$saida_teste" | tail -1)" +[[ -n "$conflito" ]] || { echo 'erro: o teste do conflito não informou os ids' >&2; exit 1; } +campo_conflito() { sed -n "s/.* $1=\([0-9a-fA-F-]*\).*/\1/p" <<<"$conflito"; } +manter_minha="$(campo_conflito manter_minha)"; manter_servidor="$(campo_conflito manter_servidor)" +[[ -n "$manter_minha" && -n "$manter_servidor" ]] || { echo 'erro: marcador E2E_CONFLITO incompleto' >&2; exit 1; } +[[ "$(campo_conflito acs)" == "$acs_id" ]] || { echo 'erro: o ACS do conflito não é o da fixture' >&2; exit 1; } +igual 'manter a minha: version|status|acsId' "4|Realizada com sucesso|$acs_id" \ + "select version || '|' || status || '|' || \"acsId\" from visits where \"localId\"='$manter_minha'" +igual 'manter a do servidor: version|status' '3|Paciente ausente' \ + "select version || '|' || status from visits where \"localId\"='$manter_servidor'" +id_minha="$(sql "select id from visits where \"localId\"='$manter_minha'")" +igual 'audit_logs conflict_resolved_local da 1ª visita' 1 \ + "select count(*) from audit_logs where \"userId\"='$acs_id' and \"resourceType\"='visit' and result='conflict_resolved_local' and \"resourceId\"='$id_minha'" +igual 'audit_logs conflict_resolved_local no total' 1 \ + "select count(*) from audit_logs where \"resourceType\"='visit' and result='conflict_resolved_local'" +exigir 'audit_logs read/visit_conflict (cópia do servidor entregue)' 1 \ + "select count(*) from audit_logs where \"userId\"='$acs_id' and \"actionType\"='read' and \"resourceType\"='visit_conflict' and result='granted'" + +# Cadeia de hash da trilha (LGPD-RT03) íntegra depois de tudo o que a jornada gravou. +echo "== cadeia de auditoria no banco de teste" +( cd backend/sinalacs_server && \ + SERVERPOD_DATABASE_HOST=localhost SERVERPOD_DATABASE_PORT=9090 SERVERPOD_DATABASE_NAME=sinalacs_e2e \ + SERVERPOD_DATABASE_USER=postgres SERVERPOD_DATABASE_PASSWORD="$TEST_DATABASE_PASSWORD" \ + dart run bin/audit_chain_check.dart 2>&1 | grep -E 'OK|FALHA|quebra|cadeia' | sed 's/^/ /'; exit "${PIPESTATUS[0]}" ) \ + || { echo 'erro: a cadeia de auditoria do banco de teste não está íntegra' >&2; exit 1; } echo 'OK — jornada completa do ACS contra o banco de teste' diff --git a/spec/lgpd_design.md b/spec/lgpd_design.md index 1e126081..1ddaa7bd 100644 --- a/spec/lgpd_design.md +++ b/spec/lgpd_design.md @@ -585,6 +585,7 @@ CPFs de documentação (como faz `seed_cpf_hashes.dart` para a stack de desenvol | **Por dono** | Cada linha de `offline_visits` tem `owner` = `userId` do ACS que a registrou (schema v7). Um ACS não carrega, conta, envia, descarta nem apaga visita de outro (RNF06); o `save()` de um dono nunca toca nas linhas de outro. O dono não vira dado novo: é o mesmo UUID pseudonimizado que o servidor grava como `acsId`. | | **Legado em quarentena** | Linhas gravadas antes do schema v7 não têm dono (`owner IS NULL`) e **nenhum ACS as adota**: adotá-las atribuiria dado clínico a quem não o registrou. Ficam invisíveis até subirem por `visits.syncLegacy`, que as grava com `authorship = legacyUnclaimed` e `acsId` nulo (autoria desconhecida, registrada como "migração v7"); o ACS que apenas transportou o lote fica só em `audit_logs`. | | **Envio diferido, com a autoria certa** | Quem sai do aparelho com visita pendente deixa no Keystore um **token de envio** (`acs_upload_tokens` no servidor: só hash, 7 dias, escopo único `visits.syncDeferred`/`visits.revokeUploadToken`, por usuário + aparelho). Ele sobe as visitas do dono sem sessão e sem poder de leitura, sob a autoria dele e no território dele relido do banco. O envio é disparado com o painel aberto, na retomada, quando a conexão volta e no "Sair"; a visita só sai do disco quando o servidor responde `synced`, e o token é revogado e apagado quando a fila do dono zera. | +| **Conflito de visita (#79)** | Quando o servidor responde `conflict`, a cópia da visita que ele tem (status e observações, já decifradas para o próprio autor, como no `pull`) é gravada no mesmo SQLCipher, em `offline_visits.conflict_server_snapshot` (schema v8), junto de `conflict_server_version`. Fica **só enquanto o conflito existe**: sai da linha quando o ACS decide (mantém a dele, edita e envia, ou mantém a do servidor). Nunca é gravada para visita legada (`syncLegacy`). A entrega da cópia ao autor é auditada (`read`/`visit_conflict`) e a decisão que escreve no servidor também (`conflict_resolved_local`), sem conteúdo clínico na trilha. | | **Limpar este aparelho** | O ACS pode apagar `offline_visits`, o cursor e o cache da microárea (§5.11), **só se não houver nenhuma linha** — de qualquer dono, do legado, recusada ou em revisão — nem fila só em memória; do contrário a ação é recusada sem apagar nada. O arquivo cifrado e a chave ficam. | | **O que NÃO fecha** | Não há envio em segundo plano do SO (só em primeiro plano). Token de envio vencido (7 dias) ou recusado deixa a fila do dono presa até ele entrar de novo e bloqueia o wipe; não há wipe forçado por supervisor (não existe login de supervisor). **Dono que nunca volta**: suas visitas permanecem cifradas no aparelho, sem prazo de expurgo, até haver envio ou desinstalação — a retenção local dele depende do envio e é um risco aberto (`PROGRESS.md`, seção Fila de visitas por dono e corrida do login). **Token que sobrevive ao "Sair" sem rede:** se o "Sair" não alcança o servidor, o token de envio do dono continua válido (no aparelho e no servidor) por até 7 dias, mesmo sem visita pendente, até um envio diferido rodar com rede e revogá-lo; desativar a conta o corta. | | **Estado** | Passou a valer de fato quando o `BackendVisitSynchronizer` foi ligado em produção. Antes, o app montava a fila sem sincronizador: `sync()` devolvia erro, nada era confirmado e nada era apagado — a retenção estava implementada e testada, mas nunca disparava. |